Skip to content

Commit 3987527

Browse files
committed
Merge worktree-warn-envelope-ts-2026-05-27 — WARN envelope-shape TS reference (FR5c-finalize follow-up)
2 parents e262564 + bf97ade commit 3987527

7 files changed

Lines changed: 276 additions & 10 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"errors": [],
3+
"warnings": [
4+
{
5+
"code": "WARN_DUPLICATE_DECLARATION",
6+
"source": {
7+
"format": "merged",
8+
"files": [
9+
"meta.a.json",
10+
"meta.b.json"
11+
]
12+
}
13+
},
14+
{
15+
"code": "WARN_DUPLICATE_DECLARATION",
16+
"source": {
17+
"format": "merged",
18+
"files": [
19+
"meta.a.json",
20+
"meta.b.json"
21+
]
22+
}
23+
},
24+
{
25+
"code": "WARN_DUPLICATE_DECLARATION",
26+
"source": {
27+
"format": "merged",
28+
"files": [
29+
"meta.a.json",
30+
"meta.b.json"
31+
]
32+
}
33+
}
34+
]
35+
}

server/typescript/packages/conformance/src/adapter.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ export interface LoadOutcome {
4848
* shape. When absent, the runner falls back to code-set comparison only.
4949
*/
5050
readonly errors?: readonly ErrorEnvelopeRecord[];
51+
/**
52+
* Full warning envelopes (FR5c-finalize). Same shape as `errors` —
53+
* `{ code, source: { format, files, jsonPath?, contributors? } }`. When
54+
* present AND the fixture's expected-errors.json declares envelope-shape
55+
* warnings, the runner asserts per-warning envelope alignment (mirrors the
56+
* error-side assertion). When absent, the runner falls back to comparing
57+
* `warnings` as a flat string list against expected-warnings.json.
58+
*/
59+
readonly warningEnvelopes?: readonly ErrorEnvelopeRecord[];
5160
}
5261

5362
export interface ConformanceAdapter {

server/typescript/packages/conformance/src/operation-script.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@ export interface ExpectedError {
2828

2929
export interface ExpectedErrorsEnvelope {
3030
readonly errors: readonly ExpectedError[];
31-
readonly warnings: readonly unknown[];
31+
/**
32+
* Warning entries. FR5c-finalize: when each entry is `{code, source}`,
33+
* the runner asserts envelope shape on warnings (mirroring errors). Older
34+
* fixtures may still carry an empty array; the cross-port runner falls
35+
* back to flat string comparison against expected-warnings.json.
36+
*/
37+
readonly warnings: readonly ExpectedError[];
3238
/** True when the file used the legacy `[{code}]` array shape (no source). */
3339
readonly legacy: boolean;
3440
}
@@ -124,7 +130,18 @@ export function parseExpectedErrors(raw: unknown): ExpectedErrorsEnvelope {
124130
const source = parseSource(r.source, i);
125131
return source !== undefined ? { code: r.code, source } : { code: r.code };
126132
});
127-
const warnings = Array.isArray(env.warnings) ? env.warnings : [];
133+
const rawWarnings = Array.isArray(env.warnings) ? env.warnings : [];
134+
const warnings = rawWarnings.map((item, i): ExpectedError => {
135+
if (typeof item !== "object" || item === null) {
136+
throw new Error(`expected-errors.json warnings entry ${i} is not an object`);
137+
}
138+
const r = item as Record<string, unknown>;
139+
if (typeof r.code !== "string") {
140+
throw new Error(`expected-errors.json warnings entry ${i} missing string 'code' field`);
141+
}
142+
const source = parseSource(r.source, i);
143+
return source !== undefined ? { code: r.code, source } : { code: r.code };
144+
});
128145
return { errors, warnings, legacy: false };
129146
}
130147

server/typescript/packages/conformance/src/runner.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,62 @@ export async function runFixture(
116116
}
117117
}
118118
}
119-
// warnings — assert length matches (the new envelope always carries
120-
// a `warnings` array; FR5a fixtures all have []). Detailed warning
121-
// envelope assertion lives in FR5c when WARN_* fixtures land.
122-
if (envelope.warnings.length !== outcome.warnings.length) {
119+
}
120+
// FR5c-finalize — warnings: when the fixture's envelope declares
121+
// warning entries with `source`, assert per-warning envelope shape
122+
// (same algorithm as errors above). The block lives outside the
123+
// `outcome.errors !== undefined` gate so a port that ships
124+
// `warningEnvelopes` but not `errors` (rare) still gets the
125+
// assertion. When the fixture declares only counts (warnings:
126+
// [{code}] without source) or empty `[]`, the length check still
127+
// runs; the per-element source assertion is skipped element-by-
128+
// element when expected.source is undefined.
129+
if (envelope !== undefined && !envelope.legacy) {
130+
const expectedW = envelope.warnings;
131+
const gotW = outcome.warningEnvelopes;
132+
if (expectedW.length !== outcome.warnings.length) {
123133
checks.push({
124-
kind: "expected-errors",
134+
kind: "expected-warnings",
125135
passed: false,
126-
detail: `warnings count: expected ${envelope.warnings.length}, got ${outcome.warnings.length}`,
136+
detail: `warnings count: expected ${expectedW.length}, got ${outcome.warnings.length}`,
127137
});
138+
} else if (gotW !== undefined && expectedW.length === gotW.length) {
139+
for (let i = 0; i < expectedW.length; i++) {
140+
const w = expectedW[i]!;
141+
const g = gotW[i]!;
142+
if (w.code !== g.code) {
143+
checks.push({
144+
kind: "expected-warnings",
145+
passed: false,
146+
detail: `warning[${i}].code: expected '${w.code}', got '${g.code}'`,
147+
});
148+
continue;
149+
}
150+
if (w.source === undefined) continue;
151+
if (w.source.format !== g.source.format) {
152+
checks.push({
153+
kind: "expected-warnings",
154+
passed: false,
155+
detail: `warning[${i}].source.format: expected '${w.source.format}', got '${g.source.format}'`,
156+
});
157+
}
158+
const wf = [...w.source.files];
159+
const gf = [...g.source.files];
160+
if (wf.length !== gf.length || !wf.every((f, j) => f === gf[j])) {
161+
checks.push({
162+
kind: "expected-warnings",
163+
passed: false,
164+
detail: `warning[${i}].source.files: expected [${wf.join(",")}], got [${gf.join(",")}]`,
165+
});
166+
}
167+
if (w.source.jsonPath !== undefined && w.source.jsonPath !== g.source.jsonPath) {
168+
checks.push({
169+
kind: "expected-warnings",
170+
passed: false,
171+
detail: `warning[${i}].source.jsonPath: expected '${w.source.jsonPath}', got '${g.source.jsonPath}'`,
172+
});
173+
}
174+
}
128175
}
129176
}
130177
}
@@ -218,8 +265,10 @@ export async function runFixture(
218265
...(passed ? {} : { detail: `expected [${want}], got [${got}]` }),
219266
});
220267
}
221-
} else if (fix.hasExpected) {
268+
} else if (fix.hasExpected && !fix.hasExpectedErrors) {
222269
// Happy-path fixture with no expected-warnings.json — assert no warnings.
270+
// Skip when the fixture uses expected-errors.json (envelope shape); that
271+
// block above already asserted warning count + per-element envelope.
223272
const passed = outcome.warnings.length === 0;
224273
if (!passed) {
225274
checks.push({

server/typescript/packages/conformance/test/runner.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,135 @@ test("malformed script.json (bad JSON) → failed check, no throw", async () =>
217217
expect(errCheck!.passed).toBe(false);
218218
expect(errCheck!.detail).toMatch(/parse error/);
219219
});
220+
221+
// ── FR5c-finalize — warning envelope-shape assertion ────────────────────────
222+
223+
test("expected-errors warnings: matching envelope shape → pass", async () => {
224+
const warnAdapter: ConformanceAdapter = {
225+
...fake,
226+
async loadFixture() {
227+
return {
228+
tree: { ok: true },
229+
errorCodes: [],
230+
warnings: ["dup decl"],
231+
warningEnvelopes: [{
232+
code: "WARN_DUPLICATE_DECLARATION",
233+
source: { format: "merged", files: ["meta.a.json", "meta.b.json"] },
234+
}],
235+
};
236+
},
237+
};
238+
const root = await fixture({
239+
"input/m.json": "{}",
240+
"expected.json": '{"ok":true}',
241+
"expected-errors.json": JSON.stringify({
242+
errors: [],
243+
warnings: [{
244+
code: "WARN_DUPLICATE_DECLARATION",
245+
source: { format: "merged", files: ["meta.a.json", "meta.b.json"] },
246+
}],
247+
}),
248+
});
249+
const [fix] = await discoverFixtures(root);
250+
const report = await runFixture(fix!, warnAdapter);
251+
expect(report.status).toBe("pass");
252+
});
253+
254+
test("expected-errors warnings: mismatched code → fail with warning[i].code detail", async () => {
255+
const warnAdapter: ConformanceAdapter = {
256+
...fake,
257+
async loadFixture() {
258+
return {
259+
tree: { ok: true },
260+
errorCodes: [],
261+
warnings: ["something else"],
262+
warningEnvelopes: [{
263+
code: "WARN_SOMETHING_ELSE",
264+
source: { format: "merged", files: ["meta.a.json"] },
265+
}],
266+
};
267+
},
268+
};
269+
const root = await fixture({
270+
"input/m.json": "{}",
271+
"expected.json": '{"ok":true}',
272+
"expected-errors.json": JSON.stringify({
273+
errors: [],
274+
warnings: [{
275+
code: "WARN_DUPLICATE_DECLARATION",
276+
source: { format: "merged", files: ["meta.a.json"] },
277+
}],
278+
}),
279+
});
280+
const [fix] = await discoverFixtures(root);
281+
const report = await runFixture(fix!, warnAdapter);
282+
expect(report.status).toBe("fail");
283+
const detail = report.checks.filter((c) => !c.passed).map((c) => c.detail).join("; ");
284+
expect(detail).toMatch(/warning\[0\]\.code/);
285+
});
286+
287+
test("expected-errors warnings: mismatched files → fail with warning[i].source.files detail", async () => {
288+
const warnAdapter: ConformanceAdapter = {
289+
...fake,
290+
async loadFixture() {
291+
return {
292+
tree: { ok: true },
293+
errorCodes: [],
294+
warnings: ["dup decl"],
295+
warningEnvelopes: [{
296+
code: "WARN_DUPLICATE_DECLARATION",
297+
source: { format: "merged", files: ["meta.a.json", "meta.c.json"] },
298+
}],
299+
};
300+
},
301+
};
302+
const root = await fixture({
303+
"input/m.json": "{}",
304+
"expected.json": '{"ok":true}',
305+
"expected-errors.json": JSON.stringify({
306+
errors: [],
307+
warnings: [{
308+
code: "WARN_DUPLICATE_DECLARATION",
309+
source: { format: "merged", files: ["meta.a.json", "meta.b.json"] },
310+
}],
311+
}),
312+
});
313+
const [fix] = await discoverFixtures(root);
314+
const report = await runFixture(fix!, warnAdapter);
315+
expect(report.status).toBe("fail");
316+
const detail = report.checks.filter((c) => !c.passed).map((c) => c.detail).join("; ");
317+
expect(detail).toMatch(/warning\[0\]\.source\.files/);
318+
});
319+
320+
test("expected-errors warnings: count mismatch → fail with warnings count detail", async () => {
321+
const warnAdapter: ConformanceAdapter = {
322+
...fake,
323+
async loadFixture() {
324+
return {
325+
tree: { ok: true },
326+
errorCodes: [],
327+
warnings: ["only one"],
328+
warningEnvelopes: [{
329+
code: "WARN_DUPLICATE_DECLARATION",
330+
source: { format: "merged", files: ["meta.a.json"] },
331+
}],
332+
};
333+
},
334+
};
335+
const root = await fixture({
336+
"input/m.json": "{}",
337+
"expected.json": '{"ok":true}',
338+
"expected-errors.json": JSON.stringify({
339+
errors: [],
340+
warnings: [
341+
{ code: "WARN_DUPLICATE_DECLARATION", source: { format: "merged", files: ["meta.a.json"] } },
342+
{ code: "WARN_DUPLICATE_DECLARATION", source: { format: "merged", files: ["meta.a.json"] } },
343+
],
344+
}),
345+
});
346+
const [fix] = await discoverFixtures(root);
347+
const report = await runFixture(fix!, warnAdapter);
348+
expect(report.status).toBe("fail");
349+
const detail = report.checks.filter((c) => !c.passed).map((c) => c.detail).join("; ");
350+
expect(detail).toMatch(/warnings count: expected 2, got 1/);
351+
});

server/typescript/packages/metadata/test/conformance/adapter.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,29 @@ export const tsAdapter: ConformanceAdapter = {
9595
source: { format: "json", files: [], jsonPath: "$" },
9696
};
9797
});
98+
// FR5c-finalize — surface warning envelopes (same envelope shape as
99+
// errors). Loader warnings already carry full `LoaderWarning` envelopes;
100+
// mirror the error-envelope normalization (relativize files; preserve
101+
// jsonPath / referrer / target when present on the source variant).
102+
const warningEnvelopes: ErrorEnvelopeRecord[] = result.warnings.map((w) => {
103+
const src = w.source;
104+
if (src.format === "json" || src.format === "yaml"
105+
|| src.format === "merged" || src.format === "resolved") {
106+
const files = src.files.map(relativize);
107+
const jp = (src as { jsonPath?: string }).jsonPath;
108+
const referrer = (src as { referrer?: string }).referrer;
109+
const target = (src as { target?: string }).target;
110+
const source: ErrorEnvelopeRecord["source"] = {
111+
format: src.format,
112+
files,
113+
...(jp !== undefined ? { jsonPath: jp } : {}),
114+
...(referrer !== undefined ? { referrer } : {}),
115+
...(target !== undefined ? { target } : {}),
116+
};
117+
return { code: w.code, source };
118+
}
119+
return { code: w.code, source: { format: src.format, files: [] } };
120+
});
98121
return {
99122
tree: result.root,
100123
errorCodes: result.errors.map(errorCode),
@@ -103,6 +126,7 @@ export const tsAdapter: ConformanceAdapter = {
103126
// message for cross-port string-equality comparison.
104127
warnings: result.warnings.map((w) => w.message),
105128
errors: envelopes,
129+
warningEnvelopes,
106130
};
107131
},
108132

spec/conformance-tests.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Finalized 2026-05-27 across all four ports (TS / C# / Java / Python). When a fix
123123

124124
Last-writer-wins still governs non-conflicting attr overlays (one side unset, or both sides set to the same value). When both contributors set the same `@attr` to *different* non-empty values, the loader raises `ERR_MERGE_CONFLICT` with a `format: "merged"` envelope listing both contributors. When two files declare a node and the second contributes no semantic change (per the FR5a `semantic_diff`), the loader emits `WARN_DUPLICATE_DECLARATION` and the merged node's `source` is **not** upgraded to `format: "merged"`.
125125

126-
**WARN envelope-shape assertion**: as of FR5c-finalize the cross-port runners assert envelope shape on `errors[]` but compare `warnings[]` as a flat string list against `expected-warnings.json`. The error-side envelope assertion (code + source.format + source.files + source.jsonPath) is the cross-port contract; lifting the same assertion onto warnings is queued as follow-up work and is gated on a fixture migration from `expected-warnings.json` (string array) to `expected-errors.json#warnings[]` (envelope shape). All four ports currently exercise the FR5c `warning-duplicate-declaration` fixture under the string-list contract.
126+
**WARN envelope-shape assertion** (TS reference; cross-port follow-up): the conformance runner asserts envelope shape on `warnings[]` (code + source.format + source.files + source.jsonPath) when the fixture's `expected-errors.json` declares warning entries with `source`. Algorithm mirrors errors: per-warning code first, then per-element source-field comparison in declaration order. The `warning-duplicate-declaration` fixture is the reference (migrated from `expected-warnings.json` string list to `expected-errors.json#warnings[]` envelope). Ports that have not yet surfaced `warningEnvelopes` on their `LoadOutcome` get the warnings count check only; per-element source assertions activate as each port's adapter populates the field.
127127

128128
## TS conformance runner
129129

0 commit comments

Comments
 (0)