Skip to content

Commit 591dd51

Browse files
dmealingclaude
andcommitted
fix(conformance): address code-review findings — JSON robustness, navigate-grammar coverage, minors
- Fix 1: add parseExpectedErrors() validated parser in operation-script.ts; use in runner.ts + fixture-lint.ts (no blind cast) - Fix 2: wrap all fixture-file read+parse in runFixture with try/catch so malformed files produce a failed CheckResult instead of throwing - Fix 3: recognize bracket segments (type[subType]) in fixture-lint segment validation; add navigator.test.ts covering bracket-path navigation to nameless nodes - Fix 4: update namesIn doc comment to state it is an over-approximation collecting all name values - Fix 5: add comment on error equality in resultsEqual stating code-only contract - Fix 6: hoist const tree after outcome.tree !== undefined guards; eliminate outcome.tree! non-null assertions - Fix 7: add comment on local FIELD_SUBTYPES in generator.ts noting it is an intentional subset of the metamodel's full list - Fix 8: replace parseInt with Number()+Number.isInteger() in conformance CLI to reject "5abc"-style inputs - Fix 9: guard ERROR-CODES.json shape in lint subcommand before Object.keys() - Fix 10: add aggregator test asserting fixed-but-listed fixtures count in the Failing column Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 70b00c2 commit 591dd51

10 files changed

Lines changed: 348 additions & 71 deletions

File tree

typescript/packages/conformance/bin/conformance.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ switch (subcommand) {
2020
const fixtures = await discoverFixtures(corpusRoot);
2121
const errorCodesRaw: unknown = JSON.parse(
2222
readFileSync(join(corpusRoot, "ERROR-CODES.json"), "utf8"));
23+
// Fix 9: guard the shape — a missing or non-object `codes` key would cause
24+
// Object.keys(undefined) to throw an opaque TypeError at runtime.
25+
if (
26+
typeof errorCodesRaw !== "object" ||
27+
errorCodesRaw === null ||
28+
typeof (errorCodesRaw as Record<string, unknown>).codes !== "object" ||
29+
(errorCodesRaw as Record<string, unknown>).codes === null
30+
) {
31+
console.error("lint: ERROR-CODES.json must be an object with a 'codes' object property");
32+
process.exit(1);
33+
}
2334
const errorCodes = Object.keys(
2435
(errorCodesRaw as { codes: Record<string, string> }).codes);
2536

@@ -74,13 +85,15 @@ switch (subcommand) {
7485
console.error("Usage: conformance generate <corpusRoot> <count> [startSeed]");
7586
process.exit(1);
7687
}
77-
const count = parseInt(countStr, 10);
78-
if (isNaN(count) || count < 1) {
88+
// Fix 8: use Number() + Number.isInteger() so "5abc" is rejected (parseInt
89+
// would silently accept it as 5).
90+
const count = Number(countStr);
91+
if (!Number.isInteger(count) || count < 1) {
7992
console.error("generate: <count> must be a positive integer");
8093
process.exit(1);
8194
}
82-
const startSeed = startSeedStr !== undefined ? parseInt(startSeedStr, 10) : 1;
83-
if (isNaN(startSeed)) {
95+
const startSeed = startSeedStr !== undefined ? Number(startSeedStr) : 1;
96+
if (!Number.isInteger(startSeed)) {
8497
console.error("generate: [startSeed] must be an integer");
8598
process.exit(1);
8699
}

typescript/packages/conformance/src/fixture-lint.ts

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
import { readFileSync } from "node:fs";
44
import { join } from "node:path";
55
import type { Fixture } from "./fixture.js";
6-
import { parseOperationScript } from "./operation-script.js";
6+
import { parseOperationScript, parseExpectedErrors } from "./operation-script.js";
77

88
/**
9-
* Collect every node name that appears in a canonical expected.json — used to
10-
* cheaply check that a navigate path's terminal node exists in the resolved
11-
* tree. (A wrapper key is `type.subType`; the body's `name` is the node name.)
9+
* Collect every `"name"` value that appears anywhere in a canonical
10+
* expected.json — used as a cheap over-approximation when checking that a
11+
* navigate path's colon-segment terminal exists. Because this recurses into
12+
* attr/relationship bodies as well as node bodies, it can produce false-
13+
* negatives (a path may lint as clean even though the named node is absent),
14+
* but it never produces false-positives (a truly present node name is always
15+
* collected here).
1216
*/
1317
function namesIn(value: unknown, acc: Set<string>): void {
1418
if (Array.isArray(value)) { for (const v of value) namesIn(v, acc); return; }
@@ -24,8 +28,15 @@ export function lintFixture(fix: Fixture, errorCodes: readonly string[]): string
2428
const problems: string[] = [];
2529

2630
if (fix.hasExpectedErrors) {
27-
const codes = JSON.parse(
28-
readFileSync(join(fix.dir, "expected-errors.json"), "utf8")) as { code: string }[];
31+
// Fix 1: use parseExpectedErrors for validated parsing instead of a blind cast.
32+
let codes: { code: string }[];
33+
try {
34+
codes = parseExpectedErrors(
35+
JSON.parse(readFileSync(join(fix.dir, "expected-errors.json"), "utf8")));
36+
} catch (err) {
37+
problems.push(`${fix.name}: malformed expected-errors.json — ${(err as Error).message}`);
38+
return problems;
39+
}
2940
for (const { code } of codes) {
3041
if (!errorCodes.includes(code)) {
3142
problems.push(`${fix.name}: unregistered error code '${code}'`);
@@ -45,16 +56,31 @@ export function lintFixture(fix: Fixture, errorCodes: readonly string[]): string
4556
if (fix.hasExpected) {
4657
const known = new Set<string>();
4758
namesIn(JSON.parse(readFileSync(join(fix.dir, "expected.json"), "utf8")), known);
59+
// Fix 3a: recognize both segment grammars — `type:name` and `type[subType]`.
60+
const COLON_SEG = /^[a-z][a-z0-9-]*:[^[]+$/;
61+
const BRACKET_SEG = /^[a-z][a-z0-9-]*\[[a-zA-Z][a-zA-Z0-9-]*\]$/;
4862
for (const op of script.operations) {
4963
for (const segment of op.navigate) {
50-
const colon = segment.indexOf(":");
51-
if (colon !== -1) {
52-
const nodeName = segment.slice(colon + 1);
53-
if (!known.has(nodeName)) {
54-
problems.push(
55-
`${fix.name}: navigate segment '${segment}' names a node `
56-
+ `absent from expected.json`);
57-
}
64+
if (BRACKET_SEG.test(segment)) {
65+
// Bracket segments (`type[subType]`) address nameless nodes — we
66+
// cannot cheaply verify existence against expected.json, so we
67+
// accept any syntactically valid bracket segment without emitting a
68+
// false "absent node" problem.
69+
continue;
70+
}
71+
if (!COLON_SEG.test(segment)) {
72+
// Neither colon nor bracket — malformed segment grammar.
73+
problems.push(
74+
`${fix.name}: navigate segment '${segment}' has malformed syntax `
75+
+ `(expected 'type:name' or 'type[subType]')`);
76+
continue;
77+
}
78+
// Colon segment: check the named node exists in expected.json.
79+
const nodeName = segment.slice(segment.indexOf(":") + 1);
80+
if (!known.has(nodeName)) {
81+
problems.push(
82+
`${fix.name}: navigate segment '${segment}' names a node `
83+
+ `absent from expected.json`);
5884
}
5985
}
6086
}

typescript/packages/conformance/src/generator.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ function mulberry32(seed: number): () => number {
1010
};
1111
}
1212

13+
// Intentionally a small subset for fixture generation — @metaobjects/metadata
14+
// exports the full FIELD_SUBTYPES array (including currency, date, object, etc.)
15+
// which is too broad for deterministic round-trip fixtures. Keep this list in
16+
// sync with the generator's intent: only "safe" primitive types that the TS
17+
// oracle handles without extra provider registration.
1318
const FIELD_SUBTYPES = ["string", "int", "long", "boolean"] as const;
1419

1520
/** Generate a canonical-shaped metadata document deterministically from a seed. */

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,27 @@
22

33
import type { NormalizedResult } from "./result.js";
44

5+
/**
6+
* Parse + validate a parsed expected-errors.json value.
7+
* Throws a clear Error if `raw` is not an array of objects each with a string
8+
* `code` — mirrors the validation style of `parseOperationScript`.
9+
*/
10+
export function parseExpectedErrors(raw: unknown): { code: string }[] {
11+
if (!Array.isArray(raw)) {
12+
throw new Error("expected-errors.json must be a JSON array");
13+
}
14+
return raw.map((item, i) => {
15+
if (typeof item !== "object" || item === null) {
16+
throw new Error(`expected-errors.json entry ${i} is not an object`);
17+
}
18+
const { code } = item as Record<string, unknown>;
19+
if (typeof code !== "string") {
20+
throw new Error(`expected-errors.json entry ${i} missing string 'code' field`);
21+
}
22+
return { code };
23+
});
24+
}
25+
526
/** capability-id grammar: `<type>.<capability>`, both kebab-case. */
627
const CAPABILITY_ID = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/;
728

typescript/packages/conformance/src/result.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export function resultsEqual(a: NormalizedResult, b: NormalizedResult): boolean
2323
if ("effective-tree" in a && "effective-tree" in b) {
2424
return a["effective-tree"] === b["effective-tree"];
2525
}
26+
// Error equality is by `code` ONLY — message and path are intentionally not
27+
// part of the cross-language contract, so implementations may vary freely.
2628
if ("error" in a && "error" in b) return a.error.code === b.error.code;
2729
return false;
2830
}

typescript/packages/conformance/src/runner.ts

Lines changed: 108 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { join } from "node:path";
55
import type { Fixture } from "./fixture.js";
66
import type { ConformanceAdapter } from "./adapter.js";
77
import { UnknownCapabilityError } from "./adapter.js";
8-
import { parseOperationScript } from "./operation-script.js";
8+
import { parseOperationScript, parseExpectedErrors } from "./operation-script.js";
99
import { resultsEqual, type NormalizedResult } from "./result.js";
1010
import type { CheckResult, FixtureReport } from "./report.js";
1111

@@ -23,16 +23,29 @@ export async function runFixture(
2323
const outcome = await adapter.loadFixture(fix.inputDir, fix.providers);
2424

2525
if (fix.hasExpectedErrors) {
26-
const expected = (await readJson(join(fix.dir, "expected-errors.json"))) as
27-
{ code: string }[];
28-
const want = expected.map((e) => e.code).sort();
29-
const got = [...outcome.errorCodes].sort();
30-
const passed = want.length === got.length && want.every((c, i) => c === got[i]);
31-
checks.push({
32-
kind: "expected-errors",
33-
passed,
34-
...(passed ? {} : { detail: `expected [${want}], got [${got}]` }),
35-
});
26+
// Fix 1: use parseExpectedErrors — throws a clear Error on malformed input.
27+
// Fix 2: wrap the read+parse so a bad file produces a failed check, not a throw.
28+
let want: string[] | undefined;
29+
try {
30+
want = parseExpectedErrors(await readJson(join(fix.dir, "expected-errors.json")))
31+
.map((e) => e.code)
32+
.sort();
33+
} catch (err) {
34+
checks.push({
35+
kind: "expected-errors",
36+
passed: false,
37+
detail: `expected-errors.json parse error: ${(err as Error).message}`,
38+
});
39+
}
40+
if (want !== undefined) {
41+
const got = [...outcome.errorCodes].sort();
42+
const passed = want.length === got.length && want.every((c, i) => c === got[i]);
43+
checks.push({
44+
kind: "expected-errors",
45+
passed,
46+
...(passed ? {} : { detail: `expected [${want}], got [${got}]` }),
47+
});
48+
}
3649
}
3750

3851
// If the load produced no tree but tree-dependent checks are expected, push a
@@ -48,60 +61,102 @@ export async function runFixture(
4861
});
4962
}
5063

64+
// Fix 6: hoist tree after the undefined guard so tree-dependent blocks never
65+
// need outcome.tree! non-null assertions.
5166
if (fix.hasExpected && outcome.tree !== undefined) {
52-
const want = (await readFile(join(fix.dir, "expected.json"), "utf8")).trim();
53-
const got = adapter.canonicalSerialize(outcome.tree).trim();
54-
const passed = want === got;
55-
checks.push({
56-
kind: "expected",
57-
passed,
58-
...(passed ? {} : { detail: "canonical serialization mismatch" }),
59-
});
67+
const tree = outcome.tree;
68+
let want: string | undefined;
69+
try {
70+
// Fix 2: wrap file read so parse errors become failed checks, not throws.
71+
want = (await readFile(join(fix.dir, "expected.json"), "utf8")).trim();
72+
} catch (err) {
73+
checks.push({
74+
kind: "expected",
75+
passed: false,
76+
detail: `expected.json read error: ${(err as Error).message}`,
77+
});
78+
}
79+
if (want !== undefined) {
80+
const got = adapter.canonicalSerialize(tree).trim();
81+
const passed = want === got;
82+
checks.push({
83+
kind: "expected",
84+
passed,
85+
...(passed ? {} : { detail: "canonical serialization mismatch" }),
86+
});
87+
}
6088
}
6189

6290
if (fix.hasExpectedEffective && outcome.tree !== undefined) {
63-
const want = (await readFile(join(fix.dir, "expected-effective.json"), "utf8")).trim();
64-
const got = adapter.canonicalSerializeEffective(outcome.tree).trim();
65-
const passed = want === got;
66-
checks.push({
67-
kind: "expected-effective",
68-
passed,
69-
...(passed ? {} : { detail: "effective serialization mismatch" }),
70-
});
91+
const tree = outcome.tree;
92+
let want: string | undefined;
93+
try {
94+
// Fix 2: wrap file read so parse errors become failed checks, not throws.
95+
want = (await readFile(join(fix.dir, "expected-effective.json"), "utf8")).trim();
96+
} catch (err) {
97+
checks.push({
98+
kind: "expected-effective",
99+
passed: false,
100+
detail: `expected-effective.json read error: ${(err as Error).message}`,
101+
});
102+
}
103+
if (want !== undefined) {
104+
const got = adapter.canonicalSerializeEffective(tree).trim();
105+
const passed = want === got;
106+
checks.push({
107+
kind: "expected-effective",
108+
passed,
109+
...(passed ? {} : { detail: "effective serialization mismatch" }),
110+
});
111+
}
71112
}
72113

73114
if (fix.hasScript && outcome.tree !== undefined) {
74-
const script = parseOperationScript(await readJson(join(fix.dir, "script.json")));
75-
script.operations.forEach((op, i) => {
76-
if (!capabilities.includes(op.invoke)) capabilities.push(op.invoke);
77-
const node = adapter.navigate(outcome.tree!, op.navigate);
78-
if (node === undefined) {
115+
const tree = outcome.tree;
116+
let script: ReturnType<typeof parseOperationScript> | undefined;
117+
try {
118+
// Fix 2: wrap script parse so malformed script.json produces a failed check.
119+
script = parseOperationScript(await readJson(join(fix.dir, "script.json")));
120+
} catch (err) {
121+
checks.push({
122+
kind: "operation",
123+
operationIndex: 0,
124+
passed: false,
125+
detail: `script.json parse error: ${(err as Error).message}`,
126+
});
127+
}
128+
if (script !== undefined) {
129+
script.operations.forEach((op, i) => {
130+
if (!capabilities.includes(op.invoke)) capabilities.push(op.invoke);
131+
const node = adapter.navigate(tree, op.navigate);
132+
if (node === undefined) {
133+
checks.push({
134+
kind: "operation",
135+
operationIndex: i,
136+
passed: false,
137+
detail: `navigate [${op.navigate}] did not resolve`,
138+
});
139+
return;
140+
}
141+
let actual: NormalizedResult;
142+
try {
143+
actual = adapter.invoke(node, op.invoke, op.args ?? {});
144+
} catch (err) {
145+
const detail = err instanceof UnknownCapabilityError
146+
? `unbound capability '${op.invoke}'`
147+
: `invoke threw: ${(err as Error).message}`;
148+
checks.push({ kind: "operation", operationIndex: i, passed: false, detail });
149+
return;
150+
}
151+
const passed = resultsEqual(actual, op.expect);
79152
checks.push({
80153
kind: "operation",
81154
operationIndex: i,
82-
passed: false,
83-
detail: `navigate [${op.navigate}] did not resolve`,
155+
passed,
156+
...(passed ? {} : { detail: `expected ${JSON.stringify(op.expect)}, got ${JSON.stringify(actual)}` }),
84157
});
85-
return;
86-
}
87-
let actual: NormalizedResult;
88-
try {
89-
actual = adapter.invoke(node, op.invoke, op.args ?? {});
90-
} catch (err) {
91-
const detail = err instanceof UnknownCapabilityError
92-
? `unbound capability '${op.invoke}'`
93-
: `invoke threw: ${(err as Error).message}`;
94-
checks.push({ kind: "operation", operationIndex: i, passed: false, detail });
95-
return;
96-
}
97-
const passed = resultsEqual(actual, op.expect);
98-
checks.push({
99-
kind: "operation",
100-
operationIndex: i,
101-
passed,
102-
...(passed ? {} : { detail: `expected ${JSON.stringify(op.expect)}, got ${JSON.stringify(actual)}` }),
103158
});
104-
});
159+
}
105160
}
106161

107162
// A fixture with no expectation files at all is a configuration error — push an

typescript/packages/conformance/test/aggregator.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,21 @@ test("dashboard reports per-language pass and known-gap counts", () => {
1313
expect(md).toMatch(/java/);
1414
expect(md).toMatch(/known-gap|known gap/i);
1515
});
16+
17+
// Fix 10: renderDashboard buckets `fixed-but-listed` into the Failing column
18+
// alongside `fail`; verify this disjunct is actually exercised.
19+
test("dashboard counts fixed-but-listed fixtures in the Failing column", () => {
20+
const report = emptyReport("rust");
21+
report.fixtures.push({ name: "x", checks: [], status: "pass" });
22+
report.fixtures.push({ name: "y", checks: [], status: "fixed-but-listed" });
23+
report.fixtures.push({ name: "z", checks: [], status: "fixed-but-listed" });
24+
const md = renderDashboard([report]);
25+
// 1 pass out of 3 total → "1/3 (33%)" in the Passing column
26+
expect(md).toContain("1/3 (33%)");
27+
// 2 fixed-but-listed → Failing column shows 2
28+
const row = md.split("\n").find((line) => line.includes("rust"))!;
29+
expect(row).toBeDefined();
30+
// The failing count is the last pipe-delimited cell in the row
31+
const cells = row.split("|").map((s) => s.trim());
32+
expect(cells.at(-2)).toBe("2");
33+
});

0 commit comments

Comments
 (0)