@metaobjectsdev/metadata 0.7.0-rc.1 → 0.7.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/parser-yaml.d.ts.map +1 -1
- package/dist/core/parser-yaml.js +24 -8
- package/dist/core/parser-yaml.js.map +1 -1
- package/dist/core/yaml-desugar.d.ts.map +1 -1
- package/dist/core/yaml-desugar.js +54 -5
- package/dist/core/yaml-desugar.js.map +1 -1
- package/dist/core/yaml-positions-walker.d.ts +21 -0
- package/dist/core/yaml-positions-walker.d.ts.map +1 -0
- package/dist/core/yaml-positions-walker.js +75 -0
- package/dist/core/yaml-positions-walker.js.map +1 -0
- package/dist/core/yaml-positions.d.ts +19 -0
- package/dist/core/yaml-positions.d.ts.map +1 -0
- package/dist/core/yaml-positions.js +60 -0
- package/dist/core/yaml-positions.js.map +1 -0
- package/dist/core-types.d.ts.map +1 -1
- package/dist/core-types.js +7 -4
- package/dist/core-types.js.map +1 -1
- package/dist/errors.d.ts +4 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -1
- package/dist/loader/meta-data-loader.d.ts.map +1 -1
- package/dist/loader/meta-data-loader.js +26 -6
- package/dist/loader/meta-data-loader.js.map +1 -1
- package/dist/loader/validation-passes.d.ts.map +1 -1
- package/dist/loader/validation-passes.js +81 -17
- package/dist/loader/validation-passes.js.map +1 -1
- package/dist/parser-core.d.ts +16 -1
- package/dist/parser-core.d.ts.map +1 -1
- package/dist/parser-core.js +266 -8
- package/dist/parser-core.js.map +1 -1
- package/dist/source.d.ts +29 -1
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +25 -0
- package/dist/source.js.map +1 -1
- package/dist/template/template-constants.d.ts +3 -1
- package/dist/template/template-constants.d.ts.map +1 -1
- package/dist/template/template-constants.js +22 -6
- package/dist/template/template-constants.js.map +1 -1
- package/dist/template/template-schema.d.ts.map +1 -1
- package/dist/template/template-schema.js +41 -1
- package/dist/template/template-schema.js.map +1 -1
- package/package.json +1 -1
- package/src/core/parser-yaml.ts +24 -8
- package/src/core/yaml-desugar.ts +58 -4
- package/src/core/yaml-positions-walker.ts +101 -0
- package/src/core/yaml-positions.ts +80 -0
- package/src/core-types.ts +7 -4
- package/src/errors.ts +15 -0
- package/src/loader/meta-data-loader.ts +26 -6
- package/src/loader/validation-passes.ts +83 -20
- package/src/parser-core.ts +306 -10
- package/src/source.ts +40 -2
- package/src/template/template-constants.ts +23 -6
- package/src/template/template-schema.ts +43 -0
package/src/parser-core.ts
CHANGED
|
@@ -27,11 +27,13 @@ import { TypeId, TypeRegistry } from "./registry.js";
|
|
|
27
27
|
import type { MetaData } from "./shared/meta-data.js";
|
|
28
28
|
import { MetaRoot } from "./shared/meta-root.js";
|
|
29
29
|
import { MetaAttr } from "./core/attr/meta-attr.js";
|
|
30
|
-
import { inferAttrSubType } from "./serializer-json.js";
|
|
30
|
+
import { canonicalSerialize, inferAttrSubType } from "./serializer-json.js";
|
|
31
31
|
import { ParseError, type ErrorCode } from "./errors.js";
|
|
32
|
-
import type
|
|
32
|
+
import { resolvedSource, type ErrorSource, type LoaderWarning, type Contributor } from "./source.js";
|
|
33
|
+
import { semanticDiff } from "./semantic-diff.js";
|
|
33
34
|
import { resolveSuperRef } from "./super-resolve.js";
|
|
34
35
|
import { JsonPathBuilder } from "./json-path.js";
|
|
36
|
+
import { getYamlPosition, type YamlPosition } from "./core/yaml-positions.js";
|
|
35
37
|
import {
|
|
36
38
|
TYPE_ATTR,
|
|
37
39
|
TYPE_FIELD,
|
|
@@ -82,12 +84,27 @@ export interface ParseOptions {
|
|
|
82
84
|
* another file parsed later.
|
|
83
85
|
*/
|
|
84
86
|
deferSuperResolution?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* FR5b — discriminant for the source-on-node envelope's `format` field.
|
|
89
|
+
* Defaults to `"json"` (parseJson supplies nothing). parseYaml passes
|
|
90
|
+
* `"yaml"` so populateNodeSource emits `format: "yaml"` and, when the
|
|
91
|
+
* desugar attached one, the optional `yamlPosition`.
|
|
92
|
+
*/
|
|
93
|
+
sourceFormat?: "json" | "yaml";
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
export interface ParseResult {
|
|
88
97
|
root: MetaRoot;
|
|
89
98
|
warnings: string[];
|
|
90
99
|
errors: ParseError[];
|
|
100
|
+
/**
|
|
101
|
+
* FR5c — envelope-shaped warnings (e.g. WARN_DUPLICATE_DECLARATION) produced
|
|
102
|
+
* during the parse/merge pipeline. Distinct from the legacy `warnings:
|
|
103
|
+
* string[]` channel: those messages get wrapped in a `WARN_LEGACY` envelope
|
|
104
|
+
* at the loader boundary, while envelope warnings already carry their own
|
|
105
|
+
* `code` + `source` and are surfaced unchanged. Defaults to `[]`.
|
|
106
|
+
*/
|
|
107
|
+
envelopeWarnings: LoaderWarning[];
|
|
91
108
|
}
|
|
92
109
|
|
|
93
110
|
// ---------------------------------------------------------------------------
|
|
@@ -102,6 +119,20 @@ export interface ParseResult {
|
|
|
102
119
|
|
|
103
120
|
export function errSource(): ErrorSource {
|
|
104
121
|
if (_currentPath !== undefined && _currentSourceId !== undefined) {
|
|
122
|
+
// FR5b (finalized 2026-05-27, all four ports) — buildTree-emitted errors
|
|
123
|
+
// from a YAML input now emit `format: "yaml"` (was `"json"` interim while
|
|
124
|
+
// each port shipped yamlPosition tracking). The optional `yamlPosition`
|
|
125
|
+
// rides along when the desugar's position map covers the current node.
|
|
126
|
+
if (_currentFormat === "yaml") {
|
|
127
|
+
return {
|
|
128
|
+
format: "yaml",
|
|
129
|
+
files: [_currentSourceId],
|
|
130
|
+
jsonPath: _currentPath.toString(),
|
|
131
|
+
...(_currentYamlPosition !== undefined
|
|
132
|
+
? { yamlPosition: _currentYamlPosition }
|
|
133
|
+
: {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
105
136
|
return {
|
|
106
137
|
format: "json",
|
|
107
138
|
files: [_currentSourceId],
|
|
@@ -200,20 +231,49 @@ let _deferSuperResolution = false;
|
|
|
200
231
|
// _deferSuperResolution.
|
|
201
232
|
let _currentErrors: ParseError[] | undefined;
|
|
202
233
|
|
|
234
|
+
// FR5c — envelope-warnings sink for the merge phase. Set at buildTree entry
|
|
235
|
+
// so deeply-nested merge sites can emit WARN_DUPLICATE_DECLARATION without
|
|
236
|
+
// threading another parameter through every helper signature. Safe under
|
|
237
|
+
// the same synchronous-buildTree reentrancy argument as the others.
|
|
238
|
+
let _currentEnvelopeWarnings: LoaderWarning[] | undefined;
|
|
239
|
+
|
|
203
240
|
// FR5a / ADR-0009 — Module-level JSONPath builder + source id, set at
|
|
204
241
|
// buildTree entry and updated by recursive descent (push on the way down,
|
|
205
242
|
// pop on the way back up). Used by populateNodeSource() to stamp every
|
|
206
|
-
// constructed MetaData with `{ format: "json", files: [sourceId],
|
|
243
|
+
// constructed MetaData with `{ format: "json"|"yaml", files: [sourceId],
|
|
244
|
+
// jsonPath, [yamlPosition?] }`.
|
|
207
245
|
// Safe because buildTree is synchronous — same reentrancy argument as
|
|
208
246
|
// _currentErrors above.
|
|
209
247
|
let _currentPath: JsonPathBuilder | undefined;
|
|
210
248
|
let _currentSourceId: string | undefined;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
249
|
+
// FR5b — source format discriminant + current node's YAML position, set
|
|
250
|
+
// by the per-child iteration in processChildren (and at root) right before
|
|
251
|
+
// the parseNodeFresh call. The position is read from the wrapper object's
|
|
252
|
+
// position-by-key map (attached by the YAML walker in core/yaml-positions.ts
|
|
253
|
+
// and preserved through core/yaml-desugar.ts).
|
|
254
|
+
let _currentFormat: "json" | "yaml" = "json";
|
|
255
|
+
let _currentYamlPosition: YamlPosition | undefined;
|
|
256
|
+
|
|
257
|
+
/** FR5a/FR5b — stamp the source-provenance envelope on a freshly-created
|
|
258
|
+
* node. No-op when invoked outside buildTree's setup (defensive — the
|
|
259
|
+
* module-level state will always be populated during a normal parse).
|
|
260
|
+
*
|
|
261
|
+
* FR5b (finalized 2026-05-27) — YAML-input nodes get `format: "yaml"`
|
|
262
|
+
* envelopes (was `"json"` interim). The optional `yamlPosition` rides
|
|
263
|
+
* along when the desugar's position map covers the current node. */
|
|
215
264
|
function populateNodeSource(node: MetaData): void {
|
|
216
265
|
if (_currentPath === undefined || _currentSourceId === undefined) return;
|
|
266
|
+
if (_currentFormat === "yaml") {
|
|
267
|
+
node.setSource({
|
|
268
|
+
format: "yaml",
|
|
269
|
+
files: [_currentSourceId],
|
|
270
|
+
jsonPath: _currentPath.toString(),
|
|
271
|
+
...(_currentYamlPosition !== undefined
|
|
272
|
+
? { yamlPosition: _currentYamlPosition }
|
|
273
|
+
: {}),
|
|
274
|
+
});
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
217
277
|
node.setSource({
|
|
218
278
|
format: "json",
|
|
219
279
|
files: [_currentSourceId],
|
|
@@ -235,16 +295,25 @@ function populateNodeSource(node: MetaData): void {
|
|
|
235
295
|
export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
|
|
236
296
|
const warnings: string[] = [];
|
|
237
297
|
const errors: ParseError[] = [];
|
|
298
|
+
const envelopeWarnings: LoaderWarning[] = [];
|
|
238
299
|
const strict = opts.strict ?? false;
|
|
239
300
|
const source = opts.sourceName;
|
|
240
301
|
_deferSuperResolution = opts.deferSuperResolution === true;
|
|
241
302
|
_currentErrors = errors;
|
|
303
|
+
// FR5c — module-level handle so the deeply-nested merge code paths can
|
|
304
|
+
// emit envelope warnings without threading another parameter through the
|
|
305
|
+
// entire walk. Safe because buildTree is fully synchronous.
|
|
306
|
+
_currentEnvelopeWarnings = envelopeWarnings;
|
|
242
307
|
// FR5a — start a fresh JSONPath stack rooted at "$"; sourceId is the
|
|
243
308
|
// source's id (from FileSource / InMemoryStringSource via opts.sourceName).
|
|
244
309
|
// Falls back to "<unknown>" when no name was supplied (e.g. ad-hoc parseJson
|
|
245
310
|
// calls from tests).
|
|
246
311
|
_currentPath = new JsonPathBuilder();
|
|
247
312
|
_currentSourceId = source ?? "<unknown>";
|
|
313
|
+
// FR5b — propagate the per-parse source-format discriminant. parseJson
|
|
314
|
+
// omits the option (defaults to "json"); parseYaml supplies "yaml".
|
|
315
|
+
_currentFormat = opts.sourceFormat ?? "json";
|
|
316
|
+
_currentYamlPosition = undefined;
|
|
248
317
|
|
|
249
318
|
try {
|
|
250
319
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
@@ -304,6 +373,11 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
|
|
|
304
373
|
// populated with the current source's id (correct: the existing root was
|
|
305
374
|
// already stamped from the file that created it).
|
|
306
375
|
_currentPath!.pushKey(rootKey);
|
|
376
|
+
// FR5b — look up the root wrapper's YAML position (when in yaml mode)
|
|
377
|
+
// so parseNodeFresh stamps `source.yamlPosition` on the root node.
|
|
378
|
+
if (_currentFormat === "yaml") {
|
|
379
|
+
_currentYamlPosition = getYamlPosition(topLevel, rootKey);
|
|
380
|
+
}
|
|
307
381
|
|
|
308
382
|
if (opts.intoRoot !== undefined) {
|
|
309
383
|
// --- Merge mode: parse root's attrs/children into the existing root ---
|
|
@@ -325,7 +399,7 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
|
|
|
325
399
|
rootKey,
|
|
326
400
|
);
|
|
327
401
|
_currentPath!.pop();
|
|
328
|
-
return { root: opts.intoRoot, warnings, errors };
|
|
402
|
+
return { root: opts.intoRoot, warnings, errors, envelopeWarnings };
|
|
329
403
|
}
|
|
330
404
|
|
|
331
405
|
// --- Fresh root mode: create a new root from the JSON ---
|
|
@@ -350,12 +424,15 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
|
|
|
350
424
|
rootKey,
|
|
351
425
|
) as MetaRoot;
|
|
352
426
|
_currentPath!.pop();
|
|
353
|
-
return { root, warnings, errors };
|
|
427
|
+
return { root, warnings, errors, envelopeWarnings };
|
|
354
428
|
} finally {
|
|
355
429
|
_deferSuperResolution = false;
|
|
356
430
|
_currentErrors = undefined;
|
|
431
|
+
_currentEnvelopeWarnings = undefined;
|
|
357
432
|
_currentPath = undefined;
|
|
358
433
|
_currentSourceId = undefined;
|
|
434
|
+
_currentFormat = "json";
|
|
435
|
+
_currentYamlPosition = undefined;
|
|
359
436
|
}
|
|
360
437
|
}
|
|
361
438
|
|
|
@@ -446,9 +523,15 @@ function parseNodeFresh(
|
|
|
446
523
|
if (superModel !== undefined) {
|
|
447
524
|
model.setSuperResolved(superModel);
|
|
448
525
|
} else {
|
|
526
|
+
// FR5d — emit format=resolved with referrer + target. referrer is the
|
|
527
|
+
// declaring node's FQN (we just built it above); target is the
|
|
528
|
+
// unresolved supertype ref string.
|
|
449
529
|
throw new ParseError(
|
|
450
530
|
`the SuperClass '${model.superRef}' does not exist in file '${source ?? "<unknown>"}'`,
|
|
451
|
-
{
|
|
531
|
+
{
|
|
532
|
+
code: "ERR_UNRESOLVED_SUPER",
|
|
533
|
+
source: resolvedSource(errSource(), model.fqn(), model.superRef),
|
|
534
|
+
},
|
|
452
535
|
);
|
|
453
536
|
}
|
|
454
537
|
} else if (model.superRef !== undefined && accumRoot === undefined) {
|
|
@@ -493,6 +576,46 @@ function parseNodeInto(
|
|
|
493
576
|
source: string | undefined,
|
|
494
577
|
path: string,
|
|
495
578
|
): void {
|
|
579
|
+
// FR5c — capture pre-merge state so we can decide whether this contribution
|
|
580
|
+
// produced any semantic change. The "new contributor" is the file currently
|
|
581
|
+
// being parsed (_currentSourceId). The "base contributor" is whichever
|
|
582
|
+
// file(s) the existing target already records on its source envelope.
|
|
583
|
+
const newContributorFile = _currentSourceId ?? "<unknown>";
|
|
584
|
+
const targetIsRoot = target instanceof MetaRoot;
|
|
585
|
+
// The root is a synthetic accumulator: it is not a metadata-author node —
|
|
586
|
+
// every file declares { "metadata.root": ... } and the loader merges into
|
|
587
|
+
// a single root. We don't run FR5c diagnostics on the root itself; the
|
|
588
|
+
// merge attribution applies to author-meaningful nodes (object/field/etc).
|
|
589
|
+
const fr5cActive = !targetIsRoot && target.name !== "";
|
|
590
|
+
|
|
591
|
+
let preMergeShape: string | undefined;
|
|
592
|
+
let preMergeAttrSnapshot: Map<string, AttrValue> | undefined;
|
|
593
|
+
if (fr5cActive) {
|
|
594
|
+
preMergeShape = canonicalSerialize(target);
|
|
595
|
+
// Snapshot of own attrs (name → value) — used to detect ERR_MERGE_CONFLICT
|
|
596
|
+
// when a new contribution sets an attr the target already declared to a
|
|
597
|
+
// different non-empty value.
|
|
598
|
+
preMergeAttrSnapshot = new Map(target.ownAttrs());
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// FR5c — detect attribute-level merge conflicts: for each @-prefixed inline
|
|
602
|
+
// attr in nodeData, if the target already has an own attr of that name
|
|
603
|
+
// with a non-empty value, AND the new value differs from the existing
|
|
604
|
+
// value (both sides non-empty), emit ERR_MERGE_CONFLICT with a `format:
|
|
605
|
+
// "merged"` envelope. Last-writer-wins is preserved for non-conflicting
|
|
606
|
+
// cases (one side unset, same value, etc.) — those carry through to the
|
|
607
|
+
// existing applyInlineAttrsAndUnknownKeys logic below.
|
|
608
|
+
if (fr5cActive && preMergeAttrSnapshot !== undefined) {
|
|
609
|
+
detectAttrMergeConflicts(
|
|
610
|
+
target,
|
|
611
|
+
nodeData,
|
|
612
|
+
preMergeAttrSnapshot,
|
|
613
|
+
newContributorFile,
|
|
614
|
+
errors,
|
|
615
|
+
path,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
496
619
|
// Apply inline attrs (not reserved keys — those stay on the existing model)
|
|
497
620
|
applyInlineAttrsAndUnknownKeys(target, nodeData, strict, source, path, warnings, registry);
|
|
498
621
|
|
|
@@ -502,6 +625,168 @@ function parseNodeInto(
|
|
|
502
625
|
const effectivePkg = inheritedContextPkg;
|
|
503
626
|
|
|
504
627
|
processChildren(target, nodeData, accumRoot, effectivePkg, registry, warnings, errors, strict, source, path);
|
|
628
|
+
|
|
629
|
+
// FR5c — after merge: compare pre/post shape. If no semantic change → emit
|
|
630
|
+
// WARN_DUPLICATE_DECLARATION (the new contribution declared the same thing
|
|
631
|
+
// the target already had). If there was a change → upgrade the target's
|
|
632
|
+
// source envelope to `format: "merged"` with both contributors listed
|
|
633
|
+
// (ADR-0009 §Source-on-node: overlay merge with semantic change updates
|
|
634
|
+
// source; duplicate-with-no-change leaves source unchanged + emits warning).
|
|
635
|
+
if (fr5cActive && preMergeShape !== undefined) {
|
|
636
|
+
const postMergeShape = canonicalSerialize(target);
|
|
637
|
+
const preParsed = JSON.parse(preMergeShape) as Record<string, unknown>;
|
|
638
|
+
const postParsed = JSON.parse(postMergeShape) as Record<string, unknown>;
|
|
639
|
+
const changed = semanticDiff(preParsed, postParsed);
|
|
640
|
+
|
|
641
|
+
// Pull the existing contributor file(s) off the target's current source
|
|
642
|
+
// envelope (set at parse-fresh time or by a previous merge).
|
|
643
|
+
const existing = target.source;
|
|
644
|
+
const existingFiles = "files" in existing ? [...existing.files] : [];
|
|
645
|
+
|
|
646
|
+
if (changed) {
|
|
647
|
+
// Real overlay — upgrade source to merged with contributors.
|
|
648
|
+
const allFiles = [...new Set([...existingFiles, newContributorFile])];
|
|
649
|
+
// Alphabetical sort — matches DirectorySource ordering and gives a
|
|
650
|
+
// deterministic cross-port file list (ADR-0009 §"Cross-port adoption").
|
|
651
|
+
allFiles.sort();
|
|
652
|
+
const contributors: Contributor[] = allFiles.map((f, i) => ({
|
|
653
|
+
file: f,
|
|
654
|
+
role: i === 0 ? "overlay-base" : "overlay-extension",
|
|
655
|
+
}));
|
|
656
|
+
const mergedSource: ErrorSource = {
|
|
657
|
+
format: "merged",
|
|
658
|
+
files: allFiles,
|
|
659
|
+
jsonPath:
|
|
660
|
+
"jsonPath" in existing && existing.jsonPath !== undefined
|
|
661
|
+
? existing.jsonPath
|
|
662
|
+
: (_currentPath?.toString() ?? "$"),
|
|
663
|
+
contributors,
|
|
664
|
+
};
|
|
665
|
+
target.setSource(mergedSource);
|
|
666
|
+
} else if (
|
|
667
|
+
existingFiles.length > 0 &&
|
|
668
|
+
!existingFiles.includes(newContributorFile)
|
|
669
|
+
) {
|
|
670
|
+
// Identical re-declaration from a different file → warn.
|
|
671
|
+
const allFiles = [...new Set([...existingFiles, newContributorFile])];
|
|
672
|
+
allFiles.sort();
|
|
673
|
+
const contributors: Contributor[] = allFiles.map((f, i) => ({
|
|
674
|
+
file: f,
|
|
675
|
+
role: i === 0 ? "overlay-base" : "overlay-extension",
|
|
676
|
+
}));
|
|
677
|
+
const warnSource: ErrorSource = {
|
|
678
|
+
format: "merged",
|
|
679
|
+
files: allFiles,
|
|
680
|
+
jsonPath:
|
|
681
|
+
"jsonPath" in existing && existing.jsonPath !== undefined
|
|
682
|
+
? existing.jsonPath
|
|
683
|
+
: (_currentPath?.toString() ?? "$"),
|
|
684
|
+
contributors,
|
|
685
|
+
};
|
|
686
|
+
_currentEnvelopeWarnings?.push({
|
|
687
|
+
code: "WARN_DUPLICATE_DECLARATION",
|
|
688
|
+
message: `duplicate declaration of ${target.fqn()} with no semantic change`,
|
|
689
|
+
source: warnSource,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** FR5c — for every @-prefixed inline attr in `nodeData`, check whether the
|
|
696
|
+
* target already declares the same attr (in `preAttrs`) with a different
|
|
697
|
+
* non-empty value. If so, emit ERR_MERGE_CONFLICT with a `format: "merged"`
|
|
698
|
+
* envelope naming both contributors. The merge itself proceeds (existing
|
|
699
|
+
* last-writer-wins) so the loader sees one canonical tree; the error
|
|
700
|
+
* surfaces the conflict so a consumer can fix the metadata. */
|
|
701
|
+
function detectAttrMergeConflicts(
|
|
702
|
+
target: MetaData,
|
|
703
|
+
nodeData: Record<string, unknown>,
|
|
704
|
+
preAttrs: Map<string, AttrValue>,
|
|
705
|
+
newContributorFile: string,
|
|
706
|
+
errors: ParseError[],
|
|
707
|
+
path: string,
|
|
708
|
+
): void {
|
|
709
|
+
const existingFiles =
|
|
710
|
+
"files" in target.source ? [...target.source.files] : [];
|
|
711
|
+
|
|
712
|
+
function isEmptyValue(v: unknown): boolean {
|
|
713
|
+
if (v === undefined || v === null) return true;
|
|
714
|
+
if (typeof v === "string" && v === "") return true;
|
|
715
|
+
if (Array.isArray(v) && v.length === 0) return true;
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function attrValuesEqual(a: AttrValue, b: AttrValue): boolean {
|
|
720
|
+
// String/number/boolean — direct compare.
|
|
721
|
+
if (a === b) return true;
|
|
722
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
723
|
+
if (a.length !== b.length) return false;
|
|
724
|
+
for (let i = 0; i < a.length; i++) {
|
|
725
|
+
if (a[i] !== b[i]) return false;
|
|
726
|
+
}
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
if (
|
|
730
|
+
typeof a === "object" && a !== null && !Array.isArray(a) &&
|
|
731
|
+
typeof b === "object" && b !== null && !Array.isArray(b)
|
|
732
|
+
) {
|
|
733
|
+
// Object attrs — structural compare via JSON (key order independent
|
|
734
|
+
// through Object.keys().sort()).
|
|
735
|
+
try {
|
|
736
|
+
const ak = Object.keys(a).sort();
|
|
737
|
+
const bk = Object.keys(b).sort();
|
|
738
|
+
if (ak.length !== bk.length) return false;
|
|
739
|
+
for (let i = 0; i < ak.length; i++) {
|
|
740
|
+
if (ak[i] !== bk[i]) return false;
|
|
741
|
+
if (JSON.stringify((a as Record<string, unknown>)[ak[i]!]) !==
|
|
742
|
+
JSON.stringify((b as Record<string, unknown>)[bk[i]!])) {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return true;
|
|
747
|
+
} catch {
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
for (const key of Object.keys(nodeData)) {
|
|
755
|
+
if (!key.startsWith(ATTR_PREFIX)) continue;
|
|
756
|
+
if (RESERVED_KEYS.has(key)) continue;
|
|
757
|
+
const attrName = key.slice(ATTR_PREFIX.length);
|
|
758
|
+
if (RESERVED_KEYS.has(attrName)) continue;
|
|
759
|
+
|
|
760
|
+
const newValRaw = nodeData[key];
|
|
761
|
+
const existingVal = preAttrs.get(attrName);
|
|
762
|
+
|
|
763
|
+
if (existingVal === undefined) continue;
|
|
764
|
+
if (isEmptyValue(newValRaw) || isEmptyValue(existingVal)) continue;
|
|
765
|
+
// Both sides set a non-empty value — compare. If equal, last-writer-wins
|
|
766
|
+
// is a no-op; if different, that's a conflict.
|
|
767
|
+
if (attrValuesEqual(existingVal, newValRaw as AttrValue)) continue;
|
|
768
|
+
|
|
769
|
+
const allFiles = [...new Set([...existingFiles, newContributorFile])];
|
|
770
|
+
allFiles.sort();
|
|
771
|
+
const contributors: Contributor[] = allFiles.map((f, i) => ({
|
|
772
|
+
file: f,
|
|
773
|
+
role: i === 0 ? "overlay-base" : "overlay-extension",
|
|
774
|
+
}));
|
|
775
|
+
const conflictSource: ErrorSource = {
|
|
776
|
+
format: "merged",
|
|
777
|
+
files: allFiles,
|
|
778
|
+
jsonPath: `${_currentPath?.toString() ?? path}.${ATTR_PREFIX}${attrName}`,
|
|
779
|
+
contributors,
|
|
780
|
+
};
|
|
781
|
+
errors.push(
|
|
782
|
+
new ParseError(
|
|
783
|
+
`attr '${ATTR_PREFIX}${attrName}' conflicts: existing value ` +
|
|
784
|
+
`${JSON.stringify(existingVal)} differs from new value ` +
|
|
785
|
+
`${JSON.stringify(newValRaw)} on ${target.fqn()}`,
|
|
786
|
+
{ code: "ERR_MERGE_CONFLICT", source: conflictSource },
|
|
787
|
+
),
|
|
788
|
+
);
|
|
789
|
+
}
|
|
505
790
|
}
|
|
506
791
|
|
|
507
792
|
// ---------------------------------------------------------------------------
|
|
@@ -794,6 +1079,14 @@ function processChildren(
|
|
|
794
1079
|
const childData = childRecord[childKey];
|
|
795
1080
|
const childNodePath = `${childPath}.${childKey}`;
|
|
796
1081
|
_currentPath?.pushKey(childKey);
|
|
1082
|
+
// FR5b — set the current node's YAML position (if any) before the
|
|
1083
|
+
// create/merge call. Save the parent's position to restore after the
|
|
1084
|
+
// recursion returns (children may push deeper positions during their
|
|
1085
|
+
// own processChildren walk).
|
|
1086
|
+
const savedYamlPosition = _currentYamlPosition;
|
|
1087
|
+
if (_currentFormat === "yaml") {
|
|
1088
|
+
_currentYamlPosition = getYamlPosition(childRecord, childKey);
|
|
1089
|
+
}
|
|
797
1090
|
|
|
798
1091
|
if (typeof childData !== "object" || childData === null || Array.isArray(childData)) {
|
|
799
1092
|
reportProblem(
|
|
@@ -802,6 +1095,7 @@ function processChildren(
|
|
|
802
1095
|
);
|
|
803
1096
|
_currentPath?.pop(); // pop child wrapper key
|
|
804
1097
|
_currentPath?.pop(); // pop array index
|
|
1098
|
+
_currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
|
|
805
1099
|
continue;
|
|
806
1100
|
}
|
|
807
1101
|
|
|
@@ -828,6 +1122,7 @@ function processChildren(
|
|
|
828
1122
|
);
|
|
829
1123
|
_currentPath?.pop(); // pop child wrapper key
|
|
830
1124
|
_currentPath?.pop(); // pop array index
|
|
1125
|
+
_currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
|
|
831
1126
|
continue; // skip this child
|
|
832
1127
|
}
|
|
833
1128
|
}
|
|
@@ -858,6 +1153,7 @@ function processChildren(
|
|
|
858
1153
|
}
|
|
859
1154
|
_currentPath?.pop(); // pop child wrapper key
|
|
860
1155
|
_currentPath?.pop(); // pop array index
|
|
1156
|
+
_currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
|
|
861
1157
|
}
|
|
862
1158
|
_currentPath?.pop(); // pop the "children" key
|
|
863
1159
|
}
|
package/src/source.ts
CHANGED
|
@@ -7,9 +7,17 @@
|
|
|
7
7
|
// them byte-identically.
|
|
8
8
|
|
|
9
9
|
/** Discriminated union over the provenance variants a metadata node or error
|
|
10
|
-
* can carry. See ADR-0009 §Decision for the canonical shape.
|
|
10
|
+
* can carry. See ADR-0009 §Decision for the canonical shape.
|
|
11
|
+
*
|
|
12
|
+
* FR5b note (finalized 2026-05-27, all four ports): buildTree-emitted errors
|
|
13
|
+
* from a YAML input emit `format: "yaml"` and carry the optional
|
|
14
|
+
* `yamlPosition` (line/col from the desugar's position map). The
|
|
15
|
+
* `yamlPosition` field is also declared optionally on the `format: "json"`
|
|
16
|
+
* variant for backward shape-compat with any FR5b-interim envelopes still
|
|
17
|
+
* serialized to disk; new YAML errors no longer use that variant. */
|
|
11
18
|
export type ErrorSource =
|
|
12
|
-
| { format: "json"; files: [string]; jsonPath: string
|
|
19
|
+
| { format: "json"; files: [string]; jsonPath: string;
|
|
20
|
+
yamlPosition?: { line: number; col: number } }
|
|
13
21
|
| { format: "yaml"; files: [string]; jsonPath: string;
|
|
14
22
|
yamlPosition?: { line: number; col: number } }
|
|
15
23
|
| { format: "merged"; files: string[]; jsonPath: string;
|
|
@@ -59,3 +67,33 @@ export interface LoaderWarning {
|
|
|
59
67
|
export function codeSource(caller?: string): ErrorSource {
|
|
60
68
|
return caller ? { format: "code", caller } : { format: "code" };
|
|
61
69
|
}
|
|
70
|
+
|
|
71
|
+
/** FR5d — build a `format: "resolved"` envelope from a referrer node's source
|
|
72
|
+
* envelope (typically a `format: "json"` or `format: "yaml"` parse-time
|
|
73
|
+
* source) plus the referrer FQN and unresolved target string.
|
|
74
|
+
*
|
|
75
|
+
* The resolved envelope carries:
|
|
76
|
+
* - `files`: the referrer's source files (so editors can jump to it),
|
|
77
|
+
* - `jsonPath`: the referrer's jsonPath when known (the location of the
|
|
78
|
+
* broken reference on disk),
|
|
79
|
+
* - `referrer`: the FQN of the metadata node that declared the broken
|
|
80
|
+
* reference (e.g. "myapp::content::Video"),
|
|
81
|
+
* - `target`: the unresolved reference string itself (e.g. "BaseEntity",
|
|
82
|
+
* "Program.weeks.invalid", "DoesNotExist").
|
|
83
|
+
*
|
|
84
|
+
* `referrerSource` may be any FR5a variant — we read its files/jsonPath
|
|
85
|
+
* best-effort and fall back to an empty `files: []` when the referrer's
|
|
86
|
+
* source is itself a `code`/`database` envelope. */
|
|
87
|
+
export function resolvedSource(
|
|
88
|
+
referrerSource: ErrorSource,
|
|
89
|
+
referrer: string,
|
|
90
|
+
target: string,
|
|
91
|
+
): ErrorSource {
|
|
92
|
+
const files = "files" in referrerSource ? [...referrerSource.files] : [];
|
|
93
|
+
const jsonPath = "jsonPath" in referrerSource ? referrerSource.jsonPath : undefined;
|
|
94
|
+
const out: ErrorSource = { format: "resolved", files, referrer, target };
|
|
95
|
+
if (jsonPath !== undefined) {
|
|
96
|
+
(out as { jsonPath?: string }).jsonPath = jsonPath;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
// template.* subtype vocabulary + reserved attribute names (FR-004, R1).
|
|
1
|
+
// template.* subtype vocabulary + reserved attribute names (FR-004, R1; ADR-0011).
|
|
2
2
|
//
|
|
3
|
-
// `template` is the fourth-pillar base type
|
|
4
|
-
// a
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// `template` is the fourth-pillar base type — a typed payload bound to either
|
|
4
|
+
// a rendered text artifact (prompt/output) or to a tool-call envelope.
|
|
5
|
+
// Three subtypes:
|
|
6
|
+
// - prompt: LLM-targeted renderable text. Carries the prompt-overlay attrs.
|
|
7
|
+
// Renderable body required via @textRef.
|
|
7
8
|
// - output: every other rendered artifact (email, export, docs, config).
|
|
9
|
+
// Renderable body required via @textRef.
|
|
10
|
+
// - toolcall: LLM tool-call envelope (no renderable body — the body IS the
|
|
11
|
+
// structured output schema resolved via @payloadRef). Per ADR-0011.
|
|
8
12
|
//
|
|
9
13
|
// Format is the @format ATTRIBUTE (closed set below), never a subtype — the
|
|
10
14
|
// render engine keys its escaper off @format, so a new format costs one escaper
|
|
@@ -14,15 +18,18 @@ import { SUBTYPE_BASE } from "../shared/base-types.js";
|
|
|
14
18
|
|
|
15
19
|
export const TEMPLATE_SUBTYPE_PROMPT = "prompt";
|
|
16
20
|
export const TEMPLATE_SUBTYPE_OUTPUT = "output";
|
|
21
|
+
export const TEMPLATE_SUBTYPE_TOOLCALL = "toolcall";
|
|
17
22
|
|
|
18
23
|
export const TEMPLATE_SUBTYPES = [
|
|
19
24
|
SUBTYPE_BASE,
|
|
20
25
|
TEMPLATE_SUBTYPE_PROMPT,
|
|
21
26
|
TEMPLATE_SUBTYPE_OUTPUT,
|
|
27
|
+
TEMPLATE_SUBTYPE_TOOLCALL,
|
|
22
28
|
] as const;
|
|
23
29
|
export type TemplateSubType = (typeof TEMPLATE_SUBTYPES)[number];
|
|
24
30
|
|
|
25
|
-
// Generic reserved attrs (
|
|
31
|
+
// Generic reserved attrs (prompt + output). The "@" is applied at wire time.
|
|
32
|
+
// NOT inherited by toolcall — toolcall has no renderable body.
|
|
26
33
|
export const TEMPLATE_ATTR_PAYLOAD_REF = "payloadRef";
|
|
27
34
|
export const TEMPLATE_ATTR_TEXT_REF = "textRef";
|
|
28
35
|
export const TEMPLATE_ATTR_FORMAT = "format";
|
|
@@ -39,6 +46,16 @@ export const TEMPLATE_ATTR_MAX_TOKENS = "maxTokens";
|
|
|
39
46
|
export const TEMPLATE_ATTR_REQUIRED_SLOTS = "requiredSlots";
|
|
40
47
|
export const TEMPLATE_ATTR_MODEL = "model";
|
|
41
48
|
|
|
49
|
+
// Toolcall-specific attrs (template.toolcall only). Vendor-agnostic; vendor
|
|
50
|
+
// wire details (retry semantics, fallback shapes, etc.) are added by consumer
|
|
51
|
+
// providers via registry.extend per ADR-0011.
|
|
52
|
+
//
|
|
53
|
+
// @description is intentionally NOT a toolcall-specific constant — every type
|
|
54
|
+
// gets @description via the documentation common-attrs provider. Tool
|
|
55
|
+
// descriptions surfaced to the LLM use the same @description common attr
|
|
56
|
+
// doc-gen uses.
|
|
57
|
+
export const TEMPLATE_ATTR_TOOL_NAME = "toolName";
|
|
58
|
+
|
|
42
59
|
// Closed format set — escaping/whitespace behavior is keyed off this in the
|
|
43
60
|
// render engine's escaper registry (FR-004 R7).
|
|
44
61
|
export const TEMPLATE_FORMATS = [
|
|
@@ -16,6 +16,7 @@ import { SUBTYPE_BASE } from "../shared/base-types.js";
|
|
|
16
16
|
import {
|
|
17
17
|
TEMPLATE_SUBTYPE_PROMPT,
|
|
18
18
|
TEMPLATE_SUBTYPE_OUTPUT,
|
|
19
|
+
TEMPLATE_SUBTYPE_TOOLCALL,
|
|
19
20
|
TEMPLATE_ATTR_PAYLOAD_REF,
|
|
20
21
|
TEMPLATE_ATTR_TEXT_REF,
|
|
21
22
|
TEMPLATE_ATTR_FORMAT,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
TEMPLATE_ATTR_MAX_TOKENS,
|
|
27
28
|
TEMPLATE_ATTR_REQUIRED_SLOTS,
|
|
28
29
|
TEMPLATE_ATTR_MODEL,
|
|
30
|
+
TEMPLATE_ATTR_TOOL_NAME,
|
|
29
31
|
TEMPLATE_FORMATS,
|
|
30
32
|
} from "./template-constants.js";
|
|
31
33
|
|
|
@@ -99,8 +101,49 @@ const promptOverlayAttrs: AttrSchema[] = [
|
|
|
99
101
|
},
|
|
100
102
|
];
|
|
101
103
|
|
|
104
|
+
// Toolcall attrs (template.toolcall only — does NOT inherit genericAttrs).
|
|
105
|
+
// Per ADR-0011: vendor-agnostic in core; vendor wire details (retry semantics,
|
|
106
|
+
// fallback shapes, parallel invocation, cache hints) added by consumer
|
|
107
|
+
// providers via registry.extend(TYPE_TEMPLATE, "toolcall", { attributes: [...] }).
|
|
108
|
+
//
|
|
109
|
+
// Critical: @textRef is intentionally NOT required here. A tool-call has no
|
|
110
|
+
// renderable text body — the body IS the structured output schema resolved
|
|
111
|
+
// via @payloadRef. This is the design rationale for toolcall being its own
|
|
112
|
+
// subtype rather than template.output + @toolName.
|
|
113
|
+
//
|
|
114
|
+
// @description is intentionally NOT declared here — it's already a documentation
|
|
115
|
+
// common attr added to every type by docProvider. Tool descriptions surfaced to
|
|
116
|
+
// the LLM read the same @description common attr that doc-gen uses.
|
|
117
|
+
const toolcallAttrs: AttrSchema[] = [
|
|
118
|
+
{
|
|
119
|
+
name: TEMPLATE_ATTR_TOOL_NAME,
|
|
120
|
+
valueType: ATTR_SUBTYPE_STRING,
|
|
121
|
+
required: true,
|
|
122
|
+
description: "Wire tool name surfaced to the LLM (vendor-specific format).",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: TEMPLATE_ATTR_PAYLOAD_REF,
|
|
126
|
+
valueType: ATTR_SUBTYPE_STRING,
|
|
127
|
+
required: true,
|
|
128
|
+
description: "Output value-object the tool produces (resolved against the metamodel).",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: TEMPLATE_ATTR_OWNER,
|
|
132
|
+
valueType: ATTR_SUBTYPE_STRING,
|
|
133
|
+
required: false,
|
|
134
|
+
description: "Governance: the owner of this toolcall.",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: TEMPLATE_ATTR_SINCE,
|
|
138
|
+
valueType: ATTR_SUBTYPE_STRING,
|
|
139
|
+
required: false,
|
|
140
|
+
description: "Governance: the version this toolcall was introduced in.",
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
|
|
102
144
|
export const TEMPLATE_ATTRS_MAP = new Map<string, AttrSchema[]>([
|
|
103
145
|
[SUBTYPE_BASE, []],
|
|
104
146
|
[TEMPLATE_SUBTYPE_PROMPT, [...genericAttrs, ...promptOverlayAttrs]],
|
|
105
147
|
[TEMPLATE_SUBTYPE_OUTPUT, [...genericAttrs]],
|
|
148
|
+
[TEMPLATE_SUBTYPE_TOOLCALL, [...toolcallAttrs]],
|
|
106
149
|
]);
|