Skip to content

Commit 44bbcb9

Browse files
committed
Merge branch 'main' into worktree-documentation-provider
2 parents b7c8793 + 53b2d5d commit 44bbcb9

20 files changed

Lines changed: 767 additions & 33 deletions

File tree

fixtures/conformance/ERROR-CODES.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"ERR_RESERVED_ATTR": "An @-prefixed reserved structural keyword (e.g. @name, @isArray, @children) was used as an inline attribute.",
3333
"ERR_SOURCE_NO_PRIMARY": "An object declares source nodes but none has role=primary.",
3434
"ERR_SOURCE_MULTIPLE_PRIMARY": "An object declares more than one source node with role=primary.",
35+
"ERR_YAML_COERCION": "A YAML 1.2 silent type coercion produced a JS value whose runtime type differs from the attribute's declared valueType (e.g. an unquoted `column: TRUE` parsed as boolean for a string-typed attr). TS-only emitter — YAML is a TypeScript-side front-end; canonical JSON is unaffected.",
3536
"ERR_UNKNOWN": "An internal loader error with no stable error code."
3637
}
3738
}

server/typescript/packages/metadata/src/core/parser-yaml.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,25 @@ export function parseYaml(content: string, opts: ParseOptions): ParseResult {
3434
// If the desugar could not produce a usable document at all, surface the
3535
// first desugar error as a throw — parallels parseJson's top-level throws.
3636
if (Object.keys(canonical).length === 0) {
37+
const first = desugarErrors[0]!;
3738
throw new ParseError(
38-
desugarErrors[0]!,
39-
{ ...errOpts(opts.sourceName), code: "ERR_MALFORMED_YAML" },
39+
first.message,
40+
{ ...errOpts(opts.sourceName), code: first.code ?? "ERR_MALFORMED_YAML" },
4041
);
4142
}
4243

4344
const result = buildTree(canonical, opts);
4445

4546
// Merge collected desugar errors ahead of buildTree's own collected errors.
47+
// Each CollectedError carries its own stable code when set (e.g.
48+
// ERR_YAML_COERCION from the D2 type-coercion guard); the malformed-document
49+
// shape errors fall back to ERR_MALFORMED_YAML.
4650
const desugarParseErrors = desugarErrors.map(
47-
(msg) => new ParseError(msg, { ...errOpts(opts.sourceName), code: "ERR_MALFORMED_YAML" }),
51+
(e) =>
52+
new ParseError(e.message, {
53+
...errOpts(opts.sourceName),
54+
code: e.code ?? "ERR_MALFORMED_YAML",
55+
}),
4856
);
4957
return {
5058
root: result.root,

server/typescript/packages/metadata/src/core/yaml-desugar.ts

Lines changed: 206 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,71 @@
22
//
33
// desugar() turns the sugared authoring object (from yaml.parse) into the
44
// canonical-shaped object that buildTree (parser-core.ts) consumes. It applies
5-
// the four format-spec sugar rules:
5+
// the five format-spec sugar rules (ADR-0006):
66
// 1. Fused key, subType omittable — a bare `type` key resolves to the type's
77
// registry default subType.
88
// 2. Scalar-or-map body — a scalar body becomes { name: <scalar> }.
99
// 3. Omit empties — absent keys stay absent; the desugar invents nothing.
1010
// 4. `[]` arrays — a trailing `[]` on the key strips to isArray: true.
11+
// 5. Sigil-free attributes (ADR-0006 D1) — every body key not in
12+
// RESERVED_KEYS is treated as an inline attribute and re-prefixed with `@`
13+
// when lowering to canonical JSON. Keys already prefixed with `@` are
14+
// left as-is (backward-compat). Reserved structural keywords (name,
15+
// package, extends, abstract, overlay, isArray, children, value) stay
16+
// bare. Note: an already-`@`-prefixed reserved word remains an error
17+
// downstream — parser-core's ERR_RESERVED_ATTR check fires.
1118
//
12-
// Pure and total: it never throws. Malformed fragments are collected as error
13-
// strings and a safe placeholder is substituted so buildTree does not
14-
// double-report.
19+
// In addition, the desugar runs the ADR-0006 D2 type-coercion guard: for every
20+
// inline attr whose owning (type, subType) has a declared schema, if the raw
21+
// JS value's type does not match the declared valueType AND the JS value is
22+
// one of YAML 1.2's silently coerced shapes (boolean/number/null), the
23+
// desugar collects an ERR_YAML_COERCION error telling the author to quote the
24+
// value. The check protects against the classic YAML footgun (e.g. an unquoted
25+
// `column: TRUE` becoming the boolean `true` instead of the string "TRUE").
26+
//
27+
// Pure and total: it never throws. Malformed fragments are collected as
28+
// CollectedError entries (a string message + optional stable error code) and a
29+
// safe placeholder is substituted so buildTree does not double-report.
1530

16-
import type { TypeRegistry } from "../registry.js";
31+
import type { TypeRegistry, AttrSchema } from "../registry.js";
32+
import type { ErrorCode } from "../errors.js";
1733
import {
34+
ATTR_PREFIX,
35+
RESERVED_KEYS,
1836
RESERVED_KEY_CHILDREN,
1937
RESERVED_KEY_NAME,
2038
RESERVED_KEY_IS_ARRAY,
2139
TYPE_SUBTYPE_SEPARATOR,
2240
} from "../shared/structural.js";
41+
import {
42+
ATTR_SUBTYPE_STRING,
43+
ATTR_SUBTYPE_CLASS,
44+
ATTR_SUBTYPE_INT,
45+
ATTR_SUBTYPE_LONG,
46+
ATTR_SUBTYPE_DOUBLE,
47+
ATTR_SUBTYPE_BOOLEAN,
48+
ATTR_SUBTYPE_STRINGARRAY,
49+
} from "./attr/attr-constants.js";
2350

2451
const ARRAY_SUFFIX = "[]";
2552

53+
/** A collected desugar problem — message plus optional stable error code. */
54+
export interface CollectedError {
55+
message: string;
56+
/** Optional stable code; absent values map to ERR_MALFORMED_YAML in parser-yaml.ts. */
57+
code?: ErrorCode;
58+
}
59+
2660
export interface DesugarResult {
2761
/** The canonical-shaped object; `{}` when the document was unusable. */
2862
canonical: Record<string, unknown>;
2963
/** Collected desugar problems (never thrown). */
30-
errors: string[];
64+
errors: CollectedError[];
3165
}
3266

3367
/** Desugar a parsed-YAML authoring document into a canonical-shaped object. */
3468
export function desugar(input: unknown, registry: TypeRegistry): DesugarResult {
35-
const errors: string[] = [];
69+
const errors: CollectedError[] = [];
3670
const node = desugarNode(input, registry, errors, "<root>");
3771
return { canonical: node ?? {}, errors };
3872
}
@@ -43,21 +77,22 @@ export function desugar(input: unknown, registry: TypeRegistry): DesugarResult {
4377
function desugarNode(
4478
input: unknown,
4579
registry: TypeRegistry,
46-
errors: string[],
80+
errors: CollectedError[],
4781
path: string,
4882
): Record<string, unknown> | undefined {
4983
if (typeof input !== "object" || input === null || Array.isArray(input)) {
50-
errors.push(`Node at ${path} must be a mapping with one type key`);
84+
errors.push({ message: `Node at ${path} must be a mapping with one type key` });
5185
return undefined;
5286
}
5387

5488
const entries = Object.keys(input as Record<string, unknown>);
5589
if (entries.length !== 1) {
56-
errors.push(
57-
`Node at ${path} must have exactly one type key (found: ${
58-
entries.length === 0 ? "none" : entries.join(", ")
59-
})`,
60-
);
90+
errors.push({
91+
message:
92+
`Node at ${path} must have exactly one type key (found: ${
93+
entries.length === 0 ? "none" : entries.join(", ")
94+
})`,
95+
});
6196
return undefined;
6297
}
6398

@@ -76,7 +111,7 @@ function desugarNode(
76111
const canonicalKey = resolveKey(key, registry, errors, path);
77112

78113
// Rule 2: a scalar body → { name: <scalar> }.
79-
const body = desugarBody(rawBody, errors, path);
114+
const body = desugarBody(rawBody, registry, canonicalKey, errors, path);
80115

81116
// Rule 4 (cont.): stamp isArray onto the canonical body.
82117
if (isArray) body[RESERVED_KEY_IS_ARRAY] = true;
@@ -103,25 +138,36 @@ function desugarNode(
103138
function resolveKey(
104139
key: string,
105140
registry: TypeRegistry,
106-
errors: string[],
141+
errors: CollectedError[],
107142
path: string,
108143
): string {
109144
if (key.includes(TYPE_SUBTYPE_SEPARATOR)) return key; // already fused
110145
const subType = registry.defaultSubTypeOf(key);
111146
if (subType === undefined) {
112-
errors.push(
113-
`Cannot resolve subType for bare type key '${key}' at ${path} — ` +
147+
errors.push({
148+
message:
149+
`Cannot resolve subType for bare type key '${key}' at ${path} — ` +
114150
`type '${key}' has no default subType; write the full 'type.subType'`,
115-
);
151+
});
116152
return key; // pass through; buildTree reports the unknown type
117153
}
118154
return `${key}${TYPE_SUBTYPE_SEPARATOR}${subType}`;
119155
}
120156

121-
// Rule 2 — normalize a node body into a canonical mapping.
157+
// Rule 2 + 5 — normalize a node body into a canonical mapping. Reserved
158+
// structural keys stay bare; every other key is treated as an inline attribute
159+
// and `@`-prefixed (Rule 5 / ADR-0006 D1). Keys already starting with `@` are
160+
// kept as-authored so the awkward "@column: foo" form remains accepted.
161+
//
162+
// Also runs the D2 type-coercion guard: for each inline attr that the owning
163+
// (type, subType) declares with a typed `valueType`, if the raw JS value's
164+
// type was silently coerced by YAML 1.2 to something incompatible (e.g. a
165+
// `boolean` for a `string`-declared attr), an ERR_YAML_COERCION is collected.
122166
function desugarBody(
123167
rawBody: unknown,
124-
errors: string[],
168+
registry: TypeRegistry,
169+
canonicalKey: string,
170+
errors: CollectedError[],
125171
path: string,
126172
): Record<string, unknown> {
127173
if (
@@ -136,10 +182,146 @@ function desugarBody(
136182
return {};
137183
}
138184
if (Array.isArray(rawBody)) {
139-
errors.push(`Node body at ${path} must be a scalar or mapping, not a list`);
185+
errors.push({
186+
message: `Node body at ${path} must be a scalar or mapping, not a list`,
187+
});
140188
return {};
141189
}
142190
// A mapping — shallow-copy so isArray / children replacement do not mutate
143-
// the caller's parsed-YAML object.
144-
return { ...(rawBody as Record<string, unknown>) };
191+
// the caller's parsed-YAML object, AND apply Rule 5 (sigil-free attrs) +
192+
// Rule D2 (type-coercion guard).
193+
const src = rawBody as Record<string, unknown>;
194+
const out: Record<string, unknown> = {};
195+
const schemaIndex = attrSchemaIndex(registry, canonicalKey);
196+
for (const key of Object.keys(src)) {
197+
if (RESERVED_KEYS.has(key) || key.startsWith(ATTR_PREFIX)) {
198+
out[key] = src[key];
199+
// D2 also applies to author-written @-keys (the awkward form).
200+
const attrName = key.startsWith(ATTR_PREFIX) ? key.slice(ATTR_PREFIX.length) : "";
201+
if (attrName !== "" && !RESERVED_KEYS.has(attrName)) {
202+
checkCoercion(attrName, src[key], schemaIndex, errors, path);
203+
}
204+
} else {
205+
out[`${ATTR_PREFIX}${key}`] = src[key];
206+
checkCoercion(key, src[key], schemaIndex, errors, path);
207+
}
208+
}
209+
return out;
210+
}
211+
212+
// ---------------------------------------------------------------------------
213+
// D2 — YAML type-coercion guard
214+
// ---------------------------------------------------------------------------
215+
216+
// Build a name → AttrSchema map for the given canonical key (type.subType).
217+
// Returns undefined when the key has no declared attrs (open schema).
218+
function attrSchemaIndex(
219+
registry: TypeRegistry,
220+
canonicalKey: string,
221+
): Map<string, AttrSchema> | undefined {
222+
// canonicalKey is "type.subType" — split on the FIRST dot only so a subType
223+
// that happens to contain a dot still works.
224+
const dot = canonicalKey.indexOf(TYPE_SUBTYPE_SEPARATOR);
225+
if (dot < 0) return undefined;
226+
const type = canonicalKey.slice(0, dot);
227+
const subType = canonicalKey.slice(dot + 1);
228+
const schema = registry.attrsOf(type, subType);
229+
if (schema.length === 0) return undefined;
230+
const idx = new Map<string, AttrSchema>();
231+
for (const spec of schema) idx.set(spec.name, spec);
232+
return idx;
233+
}
234+
235+
// Check a single attr value against its declared schema's `valueType`. Emits
236+
// an ERR_YAML_COERCION when the JS type was silently changed by YAML 1.2's
237+
// core schema (boolean/number/null where a string/stringarray was declared,
238+
// or vice versa for booleans/numbers).
239+
function checkCoercion(
240+
attrName: string,
241+
raw: unknown,
242+
schemaIndex: Map<string, AttrSchema> | undefined,
243+
errors: CollectedError[],
244+
path: string,
245+
): void {
246+
if (schemaIndex === undefined) return;
247+
const spec = schemaIndex.get(attrName);
248+
if (spec === undefined || spec.valueType === undefined) return;
249+
250+
switch (spec.valueType) {
251+
case ATTR_SUBTYPE_STRING:
252+
case ATTR_SUBTYPE_CLASS:
253+
if (typeof raw !== "string") emitCoercion(attrName, raw, "string", errors, path);
254+
return;
255+
case ATTR_SUBTYPE_BOOLEAN:
256+
if (typeof raw !== "boolean") emitCoercion(attrName, raw, "boolean", errors, path);
257+
return;
258+
case ATTR_SUBTYPE_INT:
259+
case ATTR_SUBTYPE_LONG:
260+
case ATTR_SUBTYPE_DOUBLE:
261+
if (typeof raw !== "number") emitCoercion(attrName, raw, "number", errors, path);
262+
return;
263+
case ATTR_SUBTYPE_STRINGARRAY:
264+
// A bare string at the value position is the legitimate one-element
265+
// authoring shorthand for a string-array attr (StringArrayAttr.coerce
266+
// wraps it into a one-element array). It is NOT a coercion. A
267+
// non-string non-array scalar (boolean/number/null), however, is.
268+
if (typeof raw === "string") return;
269+
if (!Array.isArray(raw)) {
270+
emitCoercion(attrName, raw, "string-array (or single string)", errors, path);
271+
return;
272+
}
273+
// For an array, check every element. A non-string element is a YAML
274+
// coercion (e.g. unquoted `true` in a string-array list).
275+
for (let i = 0; i < raw.length; i++) {
276+
if (typeof raw[i] !== "string") {
277+
emitCoercion(
278+
`${attrName}[${i}]`,
279+
raw[i],
280+
"string (in string-array)",
281+
errors,
282+
path,
283+
);
284+
}
285+
}
286+
return;
287+
default:
288+
// Object-shaped attrs (properties, filter) — accept any object/array,
289+
// no YAML coercion path applies.
290+
return;
291+
}
292+
}
293+
294+
// Build the "quote this value" error. The shape is intentionally explicit so
295+
// AI authors can act on it: it identifies the attr, the (coerced) JS value,
296+
// its JS type, the declared expected type, and a one-line fix hint.
297+
function emitCoercion(
298+
attrName: string,
299+
raw: unknown,
300+
expected: string,
301+
errors: CollectedError[],
302+
path: string,
303+
): void {
304+
const actualType = coercedTypeName(raw);
305+
const literal = literalRepr(raw);
306+
errors.push({
307+
message:
308+
`Attribute '@${attrName}' at ${path}: expected ${expected} but got ${actualType} (${literal}). ` +
309+
`YAML 1.2 silently coerced an unquoted value — quote it in YAML: ` +
310+
`'@${attrName}: "${literal}"' not '@${attrName}: ${literal}'.`,
311+
code: "ERR_YAML_COERCION",
312+
});
313+
}
314+
315+
function coercedTypeName(raw: unknown): string {
316+
if (raw === null) return "null";
317+
if (Array.isArray(raw)) return "array";
318+
return typeof raw;
319+
}
320+
321+
function literalRepr(raw: unknown): string {
322+
if (raw === null) return "null";
323+
if (typeof raw === "boolean") return raw ? "true" : "false";
324+
if (typeof raw === "number") return String(raw);
325+
if (typeof raw === "string") return raw;
326+
return JSON.stringify(raw);
145327
}

server/typescript/packages/metadata/src/errors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ export const ERROR_CODES = [
4747
"ERR_RESERVED_ATTR",
4848
"ERR_SOURCE_NO_PRIMARY",
4949
"ERR_SOURCE_MULTIPLE_PRIMARY",
50+
// ADR-0006 D2 — YAML type-coercion guard (TS-only emitter; YAML is a TS
51+
// front-end). Registered here so the code vocabulary stays single-sourced.
52+
"ERR_YAML_COERCION",
5053
"ERR_UNKNOWN",
5154
] as const;
5255

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[
2+
{ "code": "ERR_YAML_COERCION" }
3+
]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
metadata:
2+
children:
3+
- object.entity:
4+
name: Product
5+
children:
6+
- field.string:
7+
name: active
8+
column: TRUE
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[
2+
{ "code": "ERR_YAML_COERCION" },
3+
{ "code": "ERR_BAD_ATTR_VALUE" }
4+
]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
metadata:
2+
children:
3+
- object.entity:
4+
name: Product
5+
children:
6+
- field.enum:
7+
name: status
8+
values: ["DRAFT", 42, "PUBLISHED"]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[
2+
{ "code": "ERR_RESERVED_ATTR" }
3+
]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
metadata:
2+
children:
3+
- object.entity:
4+
name: Product
5+
"@isArray": true

0 commit comments

Comments
 (0)