Skip to content

Commit f505681

Browse files
committed
Merge worktree-fr5d-ts-2026-05-27 — FR5d TS reference (resolved envelopes for extends/@via/@payloadRef/@Of with referrer+target)
2 parents b647252 + 1af5b0f commit f505681

6 files changed

Lines changed: 633 additions & 23 deletions

File tree

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,11 @@
1-
{ "language": "typescript", "fixtures": [] }
1+
{
2+
"language": "typescript",
3+
"fixtures": [
4+
"error-extends-nonexistent",
5+
"error-origin-bad-via-path",
6+
"error-template-payload-ref-not-value",
7+
"error-template-payload-ref-unresolved",
8+
"error-template-required-slot-missing"
9+
],
10+
"notes": "FR5d (2026-05-27): TS reference port emits format=resolved for reference-resolution errors (extends:, @via, @of, @payloadRef, @requiredSlots). Fixtures still encode the FR5a-era format=json envelope per the cross-port-safety stance — these will migrate once C#/Java/Python ship FR5d, at which point the ledger entries can be removed."
11+
}

server/typescript/packages/metadata/src/loader/meta-data-loader.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { composeRegistry } from "../provider.js";
1515
import { TYPE_METADATA, SUBTYPE_ROOT } from "../shared/base-types.js";
1616
import { ParseError } from "../errors.js";
1717
import type { LoaderWarning } from "../source.js";
18-
import { codeSource } from "../source.js";
18+
import { codeSource, resolvedSource } from "../source.js";
1919
import { parseJson } from "../parser-json.js";
2020
import { validateDataGridSortFields, validateFilterableHasIndex, validateOriginPaths, validateDataGridFilterValues, validateFieldObjectStorage, validateTemplatePayloadRefs } from "./validation-passes.js";
2121
import { validateSourceRoles } from "../persistence/source/validate-source-roles.js";
@@ -379,10 +379,17 @@ export class MetaDataLoader {
379379
if (root !== undefined) {
380380
const failures = resolveDeferredSupers(root);
381381
for (const failure of failures) {
382+
// FR5d — emit format=resolved with referrer + target. The referrer's
383+
// parse-time source supplies files + jsonPath (the location of the
384+
// broken `extends:` on disk); referrer = the declaring node's FQN;
385+
// target = the unresolved supertype ref.
382386
errors.push(
383387
new ParseError(
384388
`the SuperClass '${failure.ref}' does not exist (referenced by ${failure.nodeFqn})`,
385-
{ code: "ERR_UNRESOLVED_SUPER", source: failure.source },
389+
{
390+
code: "ERR_UNRESOLVED_SUPER",
391+
source: resolvedSource(failure.source, failure.nodeFqn, failure.ref),
392+
},
386393
),
387394
);
388395
}

server/typescript/packages/metadata/src/loader/validation-passes.ts

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import type { MetaData } from "../shared/meta-data.js";
1313
import { ParseError } from "../errors.js";
14-
import type { ErrorSource } from "../source.js";
14+
import { resolvedSource, type ErrorSource } from "../source.js";
1515
import {
1616
TYPE_OBJECT,
1717
TYPE_FIELD,
@@ -101,10 +101,15 @@ export function validateTemplatePayloadRefs(root: MetaData): ParseError[] {
101101
if (typeof payloadRef !== "string") continue; // absence handled by the required-attr schema check
102102
const payload = root.ownChildren().find((c) => c.type === TYPE_OBJECT && c.name === payloadRef);
103103
if (!payload || payload.subType !== OBJECT_SUBTYPE_VALUE) {
104+
// FR5d — @payloadRef is a reference; emit format=resolved with
105+
// referrer=template FQN, target=the unresolved payloadRef string.
104106
errors.push(
105107
new ParseError(
106108
`template "${tmpl.name}" @payloadRef "${payloadRef}" does not resolve to an object.value at root`,
107-
{ code: "ERR_INVALID_TEMPLATE", source: tmpl.source },
109+
{
110+
code: "ERR_INVALID_TEMPLATE",
111+
source: resolvedSource(tmpl.source, tmpl.fqn(), payloadRef),
112+
},
108113
),
109114
);
110115
continue;
@@ -116,11 +121,17 @@ export function validateTemplatePayloadRefs(root: MetaData): ParseError[] {
116121
const slotList = Array.isArray(slots) ? slots : typeof slots === "string" ? [slots] : [];
117122
for (const slot of slotList) {
118123
if (typeof slot === "string" && !fieldNames.has(slot)) {
124+
// FR5d — @requiredSlots is a field-on-payload reference; emit
125+
// format=resolved with referrer=template FQN, target=`payloadRef.slot`
126+
// (the dotted ref that did not resolve to a payload field).
119127
errors.push(
120128
new ParseError(
121129
`template "${tmpl.name}" @requiredSlots "${slot}" is not a field on payload "${payloadRef}". ` +
122130
`Available fields: ${[...fieldNames].join(", ")}`,
123-
{ code: "ERR_INVALID_TEMPLATE", source: tmpl.source },
131+
{
132+
code: "ERR_INVALID_TEMPLATE",
133+
source: resolvedSource(tmpl.source, tmpl.fqn(), `${payloadRef}.${slot}`),
134+
},
124135
),
125136
);
126137
}
@@ -196,18 +207,28 @@ function _findRelationship(obj: MetaData, name: string): MetaData | undefined {
196207
function _validateFromPath(
197208
fromAttr: string,
198209
root: MetaData,
199-
projectionName: string,
210+
projection: MetaData,
200211
fieldName: string,
201212
originSource: ErrorSource,
202213
errors: ParseError[],
203214
label: string = "origin.passthrough.@from",
204215
): void {
216+
const projectionName = projection.name;
217+
// FR5d — referrer is `<projection-FQN>::<fieldName>` (the canonical
218+
// "where the broken reference lives" identifier).
219+
const referrer = `${projection.fqn()}::${fieldName}`;
205220
const dotIdx = fromAttr.indexOf(".");
206221
if (dotIdx < 1 || dotIdx === fromAttr.length - 1) {
222+
// Malformed shape (not "Entity.field") — not a reference resolution
223+
// failure per se, but emit format=resolved with target=the bad string
224+
// so consumers see the same envelope shape across all FR5d sites.
207225
errors.push(
208226
new ParseError(
209227
`${label} "${fromAttr}" on ${projectionName}.${fieldName}: must be of form "Entity.field".`,
210-
{ code: "ERR_INVALID_ORIGIN", source: originSource },
228+
{
229+
code: "ERR_INVALID_ORIGIN",
230+
source: resolvedSource(originSource, referrer, fromAttr),
231+
},
211232
),
212233
);
213234
return;
@@ -216,20 +237,28 @@ function _validateFromPath(
216237
const targetFieldName = fromAttr.slice(dotIdx + 1);
217238
const sourceObj = _findObject(root, entityName);
218239
if (!sourceObj) {
240+
// FR5d — entity half of the ref didn't resolve. target = full ref.
219241
errors.push(
220242
new ParseError(
221243
`${label} "${fromAttr}" on ${projectionName}.${fieldName}: no such entity "${entityName}".`,
222-
{ code: "ERR_INVALID_ORIGIN", source: originSource },
244+
{
245+
code: "ERR_INVALID_ORIGIN",
246+
source: resolvedSource(originSource, referrer, fromAttr),
247+
},
223248
),
224249
);
225250
return;
226251
}
227252
const sourceField = _findField(sourceObj, targetFieldName);
228253
if (!sourceField) {
254+
// FR5d — entity resolved, field on it did not. target = full ref.
229255
errors.push(
230256
new ParseError(
231257
`${label} "${fromAttr}" on ${projectionName}.${fieldName}: no such field "${targetFieldName}" on ${entityName}.`,
232-
{ code: "ERR_INVALID_ORIGIN", source: originSource },
258+
{
259+
code: "ERR_INVALID_ORIGIN",
260+
source: resolvedSource(originSource, referrer, fromAttr),
261+
},
233262
),
234263
);
235264
}
@@ -238,17 +267,23 @@ function _validateFromPath(
238267
function _validateViaPath(
239268
viaAttr: string,
240269
root: MetaData,
241-
projectionName: string,
270+
projection: MetaData,
242271
fieldName: string,
243272
originSource: ErrorSource,
244273
errors: ParseError[],
245274
): void {
275+
const projectionName = projection.name;
276+
// FR5d — referrer is `<projection-FQN>::<fieldName>`.
277+
const referrer = `${projection.fqn()}::${fieldName}`;
246278
const segments = viaAttr.split(".");
247279
if (segments.length < 2) {
248280
errors.push(
249281
new ParseError(
250282
`origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: must be of form "Entity.relationship[.relationship...]".`,
251-
{ code: "ERR_INVALID_ORIGIN", source: originSource },
283+
{
284+
code: "ERR_INVALID_ORIGIN",
285+
source: resolvedSource(originSource, referrer, viaAttr),
286+
},
252287
),
253288
);
254289
return;
@@ -259,18 +294,32 @@ function _validateViaPath(
259294
errors.push(
260295
new ParseError(
261296
`origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: no such entity "${entityName}".`,
262-
{ code: "ERR_INVALID_ORIGIN", source: originSource },
297+
{
298+
code: "ERR_INVALID_ORIGIN",
299+
source: resolvedSource(originSource, referrer, viaAttr),
300+
},
263301
),
264302
);
265303
return;
266304
}
305+
// FR5d — track the deepest-valid-prefix as we walk. The prefix grows
306+
// segment-by-segment; on a hop failure the error message names the prefix
307+
// that DID resolve, so authors can fix multi-hop typos quickly.
308+
// After the entity lookup above, the deepest valid prefix is just the
309+
// entity name; each successful relationship hop appends a segment.
310+
const validSegments: string[] = [entityName];
267311
for (const relName of relSegments) {
268312
const rel = _findRelationship(currentObj, relName);
269313
if (!rel) {
314+
const prefix = validSegments.join(".");
270315
errors.push(
271316
new ParseError(
272-
`origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: no such relationship "${relName}" on ${currentObj.name}.`,
273-
{ code: "ERR_INVALID_ORIGIN", source: originSource },
317+
`origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: no such relationship "${relName}" on ${currentObj.name}. ` +
318+
`Deepest valid prefix was "${prefix}".`,
319+
{
320+
code: "ERR_INVALID_ORIGIN",
321+
source: resolvedSource(originSource, referrer, viaAttr),
322+
},
274323
),
275324
);
276325
return;
@@ -280,21 +329,32 @@ function _validateViaPath(
280329
errors.push(
281330
new ParseError(
282331
`origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: relationship "${relName}" on ${currentObj.name} is missing @objectRef.`,
283-
{ code: "ERR_INVALID_ORIGIN", source: originSource },
332+
{
333+
code: "ERR_INVALID_ORIGIN",
334+
source: resolvedSource(originSource, referrer, viaAttr),
335+
},
284336
),
285337
);
286338
return;
287339
}
288340
const nextObj = _findObject(root, refTarget);
289341
if (!nextObj) {
342+
// FR5d — relationship's @objectRef points at a missing entity. This
343+
// is the @objectRef-resolution edge of the via-path walk (the "5th
344+
// site" in FR5d's scope list for @objectRef references encountered
345+
// transitively).
290346
errors.push(
291347
new ParseError(
292348
`origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: relationship "${relName}" points to non-existent entity "${refTarget}".`,
293-
{ code: "ERR_INVALID_ORIGIN", source: originSource },
349+
{
350+
code: "ERR_INVALID_ORIGIN",
351+
source: resolvedSource(originSource, referrer, refTarget),
352+
},
294353
),
295354
);
296355
return;
297356
}
357+
validSegments.push(relName);
298358
currentObj = nextObj;
299359
}
300360
}
@@ -307,6 +367,8 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
307367
if (origin.subType === ORIGIN_SUBTYPE_PASSTHROUGH) {
308368
const from = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_FROM);
309369
if (typeof from !== "string" || from === "") {
370+
// Missing-attr (not a reference resolution failure) — keep the
371+
// node's own source envelope (json/yaml/merged).
310372
errors.push(
311373
new ParseError(
312374
`origin.passthrough on ${obj.name}.${field.name}: missing @from.`,
@@ -315,10 +377,10 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
315377
);
316378
continue;
317379
}
318-
_validateFromPath(from, root, obj.name, field.name, origin.source, errors);
380+
_validateFromPath(from, root, obj, field.name, origin.source, errors);
319381
const via = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA);
320382
if (typeof via === "string" && via !== "") {
321-
_validateViaPath(via, root, obj.name, field.name, origin.source, errors);
383+
_validateViaPath(via, root, obj, field.name, origin.source, errors);
322384
}
323385
} else if (origin.subType === ORIGIN_SUBTYPE_AGGREGATE) {
324386
const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF);
@@ -331,7 +393,7 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
331393
);
332394
continue;
333395
}
334-
_validateFromPath(of_, root, obj.name, field.name, origin.source, errors, "origin.aggregate.@of");
396+
_validateFromPath(of_, root, obj, field.name, origin.source, errors, "origin.aggregate.@of");
335397
const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
336398
if (typeof via !== "string" || via === "") {
337399
errors.push(
@@ -342,7 +404,7 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
342404
);
343405
continue;
344406
}
345-
_validateViaPath(via, root, obj.name, field.name, origin.source, errors);
407+
_validateViaPath(via, root, obj, field.name, origin.source, errors);
346408
}
347409
}
348410
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { MetaRoot } from "./shared/meta-root.js";
2929
import { MetaAttr } from "./core/attr/meta-attr.js";
3030
import { inferAttrSubType } from "./serializer-json.js";
3131
import { ParseError, type ErrorCode } from "./errors.js";
32-
import type { ErrorSource } from "./source.js";
32+
import { resolvedSource, type ErrorSource } from "./source.js";
3333
import { resolveSuperRef } from "./super-resolve.js";
3434
import { JsonPathBuilder } from "./json-path.js";
3535
import { getYamlPosition, type YamlPosition } from "./core/yaml-positions.js";
@@ -502,9 +502,15 @@ function parseNodeFresh(
502502
if (superModel !== undefined) {
503503
model.setSuperResolved(superModel);
504504
} else {
505+
// FR5d — emit format=resolved with referrer + target. referrer is the
506+
// declaring node's FQN (we just built it above); target is the
507+
// unresolved supertype ref string.
505508
throw new ParseError(
506509
`the SuperClass '${model.superRef}' does not exist in file '${source ?? "<unknown>"}'`,
507-
{ code: "ERR_UNRESOLVED_SUPER", source: errSource() },
510+
{
511+
code: "ERR_UNRESOLVED_SUPER",
512+
source: resolvedSource(errSource(), model.fqn(), model.superRef),
513+
},
508514
);
509515
}
510516
} else if (model.superRef !== undefined && accumRoot === undefined) {

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,33 @@ export interface LoaderWarning {
6767
export function codeSource(caller?: string): ErrorSource {
6868
return caller ? { format: "code", caller } : { format: "code" };
6969
}
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+
}

0 commit comments

Comments
 (0)