Skip to content

Commit 3adfa2d

Browse files
dmealingclaude
andcommitted
test(cli): two source-text assertions become a parse and a consumer call
Both were flagged in the FR-043 review as asserting over file CONTENT rather than behaviour. Neither was producing a wrong answer today; both were one generator change away from producing one quietly. **`library-manifest-resolved`** proved "every layer's ref is embedded in EVERY port" with `text.includes(`"${ref}"`)` over the five generated embeds. That check is not currently wrong — every payload escapes its own quotes, so a ref named in an embedded comment reads as `\"ai/db\"` and the bare `"ai/db"` occurs exactly once, as the key. It is fragile rather than broken: it cannot see an EXTRA key, it cannot tell the ref record from the manifest record the same file also carries, and it holds only while the generator keeps escaping payloads the way it does now. Keys are now extracted per port by their POSITION — line-initial, or following `.put(` — and compared as SETS against what the manifests declare, in both records and both directions. Set equality is also what proves the extractor is not matching nothing, which is how a containment check of this shape stops gating. Four synthetic fixtures pin the distinction, including the unescaped case a change to the generator's string handling would emit, where a substring check WOULD report a payload mention as a key. **`catalog-declarations-resolved`** asserted each reference template contains a `// use-when:` line. Present and reported are different claims: `headerFacet` takes continuation lines, stops at the next facet, and returns undefined for an empty body — so a marker with nothing after it satisfies the substring and still shows nothing in `meta gen --list`. It now calls `buildCatalogListing()` and asserts the facet arrives on the row a reader sees, across all 10 ejectable rows, with a count guard so an empty list cannot pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cQxyVuTkdPmAduLiedNEg
1 parent ba3ceca commit 3adfa2d

2 files changed

Lines changed: 150 additions & 28 deletions

File tree

‎server/typescript/packages/cli/test/catalog-declarations-resolved.test.ts‎

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import * as coreTpl from "@metaobjectsdev/codegen-ts";
2525
import * as reactTpl from "@metaobjectsdev/codegen-ts-react";
2626
import * as tanstackTpl from "@metaobjectsdev/codegen-ts-tanstack";
2727
import { composeCatalog } from "../src/lib/catalog.js";
28+
import { buildCatalogListing, type GeneratorCatalogRow } from "../src/lib/catalog-listing.js";
2829

2930
const FIXTURE = join(import.meta.dir, "fixtures", "catalog-probe");
3031
const OUT_DIR = "src/generated";
@@ -257,20 +258,23 @@ describe("catalog declarations are resolved against what the generators emit", (
257258
).toEqual([]);
258259
});
259260

260-
test("every ejectable entry's template header carries the facets `--list` reports", () => {
261-
const roots: ReadonlyArray<readonly [readonly string[], () => string]> = [
262-
[coreTpl.REFERENCE_GENERATOR_NAMES, coreTpl.resolveReferenceRoot],
263-
[reactTpl.REFERENCE_GENERATOR_NAMES, reactTpl.resolveReferenceRoot],
264-
[tanstackTpl.REFERENCE_GENERATOR_NAMES, tanstackTpl.resolveReferenceRoot],
265-
];
261+
test("every ejectable entry's facets actually REACH `--list`", async () => {
262+
// Asserted through the consumer rather than over the template source. A
263+
// `// use-when:` line being PRESENT and the facet arriving on the row a reader sees
264+
// are different claims: the header parser takes continuation lines, stops at the
265+
// next facet, and returns undefined for an empty body, so a marker with nothing
266+
// after it satisfies a substring check and still reports nothing.
267+
const rows = await buildCatalogListing();
268+
const generators = rows.filter(
269+
(r): r is GeneratorCatalogRow => r.kind === "generator" && r.source.ejectable,
270+
);
271+
// Guards the loop below from passing over an empty list.
272+
expect(generators.length, "no ejectable generator rows to check").toBeGreaterThan(0);
273+
266274
const missing: string[] = [];
267-
for (const [names, root] of roots) {
268-
for (const name of names) {
269-
const source = readFileSync(join(root(), `${name}.ts`), "utf8");
270-
for (const facet of ["use-when", "emits"]) {
271-
if (!source.includes(`// ${facet}:`)) missing.push(`${name} has no "${facet}:" header line`);
272-
}
273-
}
275+
for (const row of generators) {
276+
if ((row.useWhen ?? "").trim() === "") missing.push(`${row.name}: no use-when reaches --list`);
277+
if ((row.emits ?? "").trim() === "") missing.push(`${row.name}: no emits reaches --list`);
274278
}
275279
expect(missing, missing.join("\n ")).toEqual([]);
276280
});

‎server/typescript/packages/cli/test/library-manifest-resolved.test.ts‎

Lines changed: 133 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,68 @@ function findRepoRoot(start: string): string {
3232
}
3333
}
3434

35+
/**
36+
* Each port's generated embed, and how a KEY is written in it.
37+
*
38+
* Kotlin has no embed of its own — it runs on the JVM and reads Java's, which is why it
39+
* maps onto that file.
40+
*
41+
* The pattern matches a key POSITION, not the key text anywhere in the file, and that
42+
* distinction is the whole point. Every embedded value is the library's own YAML, and
43+
* that YAML talks about its own ref: `library/ai/db.yaml` opens with "Opted into as
44+
* `\"ai/db\"`". So a substring search for `"ai/db"` hits the payload and passes even if
45+
* the key is deleted — which is what this file used to do. A key is line-initial (or
46+
* follows `.put(`) and contains no backslash; a payload mention is mid-string and
47+
* escaped. Excluding `\\` from the captured class is what separates them.
48+
*/
49+
const EMBEDS: Record<string, { path: string; key: RegExp }> = {
50+
typescript: {
51+
path: "server/typescript/packages/metadata/src/library/embedded-library.generated.ts",
52+
key: /^[ \t]*"([^"\\]+)":[ \t]*"/gm,
53+
},
54+
java: {
55+
path: "server/java/metadata/src/main/java/com/metaobjects/library/EmbeddedLibrary.java",
56+
key: /\.put\("([^"\\]+)",[ \t]*"/g,
57+
},
58+
kotlin: {
59+
path: "server/java/metadata/src/main/java/com/metaobjects/library/EmbeddedLibrary.java",
60+
key: /\.put\("([^"\\]+)",[ \t]*"/g,
61+
},
62+
csharp: {
63+
path: "server/csharp/MetaObjects/Library/EmbeddedLibrary.cs",
64+
key: /^[ \t]*\["([^"\\]+)"\][ \t]*=[ \t]*"/gm,
65+
},
66+
python: {
67+
path: "server/python/src/metaobjects/library/embedded_library.py",
68+
key: /^[ \t]*['"]([^'"\\]+)['"]:[ \t]*['"]/gm,
69+
},
70+
};
71+
72+
/**
73+
* The keys one port's embed actually declares, split by the two records it carries:
74+
* refs (`<library>/<layer>`) and manifests (a bare library name).
75+
*/
76+
function extractKeys(text: string, key: RegExp): { refs: Set<string>; manifests: Set<string> } {
77+
const refs = new Set<string>();
78+
const manifests = new Set<string>();
79+
for (const m of text.matchAll(key)) {
80+
const k = m[1]!;
81+
(k.includes("/") ? refs : manifests).add(k);
82+
}
83+
return { refs, manifests };
84+
}
85+
86+
function embeddedKeys(port: string): { refs: Set<string>; manifests: Set<string> } {
87+
const entry = EMBEDS[port];
88+
if (entry === undefined) throw new Error(`no embed mapping for port "${port}"`);
89+
return extractKeys(readFileSync(join(findRepoRoot(import.meta.dir), entry.path), "utf8"), entry.key);
90+
}
91+
92+
/** Every ref every shipped library declares, across all its layers. */
93+
function declaredRefs(name: string): string[] {
94+
return Object.values(MANIFESTS[name]?.layers ?? {}).flatMap((l) => [...l.refs]);
95+
}
96+
3597
/** Load one library with every layer it declares. */
3698
async function loadWhole(name: string): Promise<MetaData> {
3799
const tokens = knownLibraryTokens().filter((t) => splitLayerToken(t)[0] === name);
@@ -109,29 +171,85 @@ describe("every library manifest fact is resolved against the thing it claims",
109171
// The row says `ports: [typescript, java, kotlin, csharp, python]`, and the basis
110172
// for that claim is that one generator script writes the embed for all of them.
111173
// Checked rather than asserted, because "the library is reachable from your port"
112-
// is the single fact a polyglot adopter acts on. Kotlin has no embed of its own —
113-
// it runs on the JVM and reads Java's, which is why it is mapped onto that file.
114-
const root = findRepoRoot(import.meta.dir);
115-
const EMBEDS: Record<string, string> = {
116-
typescript: "server/typescript/packages/metadata/src/library/embedded-library.generated.ts",
117-
java: "server/java/metadata/src/main/java/com/metaobjects/library/EmbeddedLibrary.java",
118-
kotlin: "server/java/metadata/src/main/java/com/metaobjects/library/EmbeddedLibrary.java",
119-
csharp: "server/csharp/MetaObjects/Library/EmbeddedLibrary.cs",
120-
python: "server/python/src/metaobjects/library/embedded_library.py",
121-
};
122-
expect(Object.keys(EMBEDS).sort(), "a port with no embed mapping").toEqual([...SERVER_LANGS].sort());
123-
124-
const refs = Object.values(manifest.layers ?? {}).flatMap((l) => [...l.refs]);
174+
// is the single fact a polyglot adopter acts on.
175+
const refs = declaredRefs(name);
125176
expect(refs.length, `${name} declares no refs at all`).toBeGreaterThan(0);
126177
for (const port of SERVER_LANGS) {
127-
const text = readFileSync(join(root, EMBEDS[port]!), "utf8");
178+
const embedded = embeddedKeys(port).refs;
128179
for (const ref of refs) {
129-
expect(text.includes(`"${ref}"`), `${port} does not embed ${ref}`).toBe(true);
180+
expect([...embedded].includes(ref), `${port} does not embed ${ref}`).toBe(true);
130181
}
131182
}
132183
});
133184
}
134185

186+
test("every port's embed carries EXACTLY the declared refs and manifests", () => {
187+
// The per-library test above proves nothing is MISSING. This one proves nothing is
188+
// extra and, just as importantly, that the extractor is not matching nothing: a key
189+
// pattern that silently stops matching would make every containment check above
190+
// vacuously true, which is the way a gate of this shape stops gating.
191+
expect(Object.keys(EMBEDS).sort(), "a port with no embed mapping").toEqual([...SERVER_LANGS].sort());
192+
const allRefs = NAMES.flatMap(declaredRefs).sort();
193+
expect(allRefs.length, "no library declares any ref").toBeGreaterThan(0);
194+
195+
for (const port of SERVER_LANGS) {
196+
const { refs, manifests } = embeddedKeys(port);
197+
expect([...refs].sort(), `${port} embeds a different ref set`).toEqual(allRefs);
198+
expect([...manifests].sort(), `${port} embeds a different manifest set`).toEqual(NAMES);
199+
}
200+
});
201+
202+
describe("the key extractor reads a POSITION, not a spelling", () => {
203+
// What this replaced was `text.includes(`"${ref}"`)` over the same files. That check
204+
// is not currently wrong — every embedded payload escapes its own quotes, so a ref
205+
// named in a comment reads as `\\"ai/db\\"` and the bare `"ai/db"` occurs exactly once,
206+
// as the key. It is fragile rather than broken: it cannot see an EXTRA key, it cannot
207+
// tell the ref record from the manifest record, and it holds only for as long as the
208+
// generator keeps escaping payloads the way it does today.
209+
//
210+
// The extractor does not depend on that. A key is a POSITION — line-initial, or after
211+
// `.put(` — so a mention anywhere inside a value is not a key however it is spelled.
212+
// These fixtures pin that, including the unescaped case a change to the generator
213+
// would produce, where a substring check WOULD produce a false positive.
214+
const ESCAPED = JSON.stringify('"ai/db"').slice(1, -1); // \"ai/db\"
215+
216+
/** One line of each record, in that port's syntax, for an arbitrary ref key. */
217+
const SYNTHETIC: Record<string, (ref: string) => string> = {
218+
typescript: (ref) => ` "${ref}": "# Opted into as ${ESCAPED}.",\n "ai": "{}",\n`,
219+
java: (ref) => ` m.put("${ref}", "# Opted into as ${ESCAPED}.");\n n.put("ai", "{}");\n`,
220+
csharp: (ref) => ` ["${ref}"] = "# Opted into as ${ESCAPED}.",\n ["ai"] = "{}",\n`,
221+
python: (ref) => ` '${ref}': '# Opted into as ${ESCAPED}.',\n 'ai': '{}',\n`,
222+
};
223+
224+
test("every port with an embed has a synthetic fixture", () => {
225+
// Kotlin shares Java's file and Java's syntax, so Java's row covers it.
226+
expect(Object.keys(SYNTHETIC).sort()).toEqual(
227+
[...SERVER_LANGS].filter((l) => l !== "kotlin").sort(),
228+
);
229+
});
230+
231+
for (const [port, line] of Object.entries(SYNTHETIC)) {
232+
test(`${port}: a key is extracted; a mention inside a value is not`, () => {
233+
const re = EMBEDS[port]!.key;
234+
235+
const present = extractKeys(line("ai/db"), re);
236+
expect([...present.refs], `${port}: the real key was not extracted`).toEqual(["ai/db"]);
237+
expect([...present.manifests], `${port}: the manifest key was not extracted`).toEqual(["ai"]);
238+
239+
// Same text, different KEY; the payload still names `ai/db`, escaped as today.
240+
expect([...extractKeys(line("ai/model"), re).refs], `${port}: an escaped mention read as a key`)
241+
.toEqual(["ai/model"]);
242+
243+
// And unescaped — what a change to the generator's string handling would emit.
244+
// Here the substring a raw check looks for IS present, and is still not a key.
245+
const unescaped = line("ai/model").replaceAll(ESCAPED, '"ai/db"');
246+
expect(unescaped.includes('"ai/db"'), `${port}: fixture did not produce a bare mention`).toBe(true);
247+
expect([...extractKeys(unescaped, re).refs], `${port}: a bare mention read as a key`)
248+
.toEqual(["ai/model"]);
249+
});
250+
}
251+
});
252+
135253
test("a library name and a generator stable name share ONE namespace", () => {
136254
// They are rows in one table, and `meta eject <name>` takes either. A collision
137255
// would make the catalog ambiguous in exactly the place an agent acts on it.

0 commit comments

Comments
 (0)