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" ;
1733import {
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
2451const 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+
2660export 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. */
3468export 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 {
4377function 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(
103138function 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.
122166function 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}
0 commit comments