Skip to content

Commit 837e84c

Browse files
dmealingclaude
andcommitted
test: close two FR-023 coverage gaps (reference entity scope, zero-dep ledger)
1. src/reference/entity.ts's ctx.select call site — the exact file meta init copies into every project's codegen/generators/entity.ts, the default entity-module path — had no end-to-end test. The underlying renderSharedEnumsFile({select}) primitive was already unit-tested (shared-enums-imported.test.ts), but nothing proved runGen actually threads opts.scope through to ctx.select, or that this file's own ternary reads it. Add an end-to-end runGen test through the literal reference/entity.ts generator: a scope excluding an enum's only consumer suppresses enums.ts; no scope still materializes it. 2. No test covered "scope declared, zero dependencies" — the one behaviour change this branch deliberately records in the CHANGELOG (Collection.inScope composes matchesScope(fqn, scope) regardless of imports, so scope.include narrows the requirements ledger's denominator even with no dependencies at all). verify-requirements-imported.test.ts only covered the WITH-a- dependency half of the predicate. Add a zero-dependency fixture (two of the project's own entities, no dependency anywhere) that asserts scope.include narrows the ledger denominator from 1/2 to 1/1 and drops the excluded entity out of the count entirely. Both mutation-verified: dropping ctx.select in the reference generator, and dropping `coverable: collection.inScope` in verify.ts, each turn their respective new test red; reverting turns them green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
1 parent 0d96748 commit 837e84c

2 files changed

Lines changed: 263 additions & 0 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// FR-023 §11.1 item 2 (2026-09-11 qualification) — `Collection.inScope` is
2+
// `matchesScope(fqn, scope) && (...)`, so declaring `scope.include` narrows
3+
// the requirements ledger's denominator even for a project with ZERO
4+
// dependencies — a real behaviour change the CHANGELOG records ("A project
5+
// that declares scope.include now sees its requirements-ledger denominator
6+
// narrow to that scope — even with zero dependencies"), and nothing exercised
7+
// it: `verify-requirements-imported.test.ts` only covers the WITH-a-
8+
// dependency case (the import-exclusion half of the predicate); this covers
9+
// the other half — `matchesScope` alone, no dependency involved at all.
10+
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test";
11+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
12+
import { tmpdir } from "node:os";
13+
import { join } from "node:path";
14+
import { run } from "../src/index.js";
15+
16+
/** Two of the project's OWN entities, in different packages — no dependency
17+
* anywhere in this fixture. */
18+
const APP = JSON.stringify({
19+
"metadata.root": {
20+
package: "app",
21+
children: [
22+
{
23+
"object.entity": {
24+
name: "Order",
25+
children: [
26+
{ "source.rdb": { "@table": "orders" } },
27+
{ "field.long": { name: "id" } },
28+
{ "identity.primary": { name: "pk", "@fields": ["id"] } },
29+
],
30+
},
31+
},
32+
],
33+
},
34+
});
35+
36+
const OTHER = JSON.stringify({
37+
"metadata.root": {
38+
package: "other",
39+
children: [
40+
{
41+
"object.entity": {
42+
name: "Thing",
43+
children: [
44+
{ "source.rdb": { "@table": "things" } },
45+
{ "field.long": { name: "id" } },
46+
{ "identity.primary": { name: "pk", "@fields": ["id"] } },
47+
],
48+
},
49+
},
50+
],
51+
},
52+
});
53+
54+
/** One requirement claiming only `app::Order`. */
55+
const REQUIREMENTS = JSON.stringify({
56+
"metadata.root": {
57+
children: [
58+
{
59+
"requirement.functional": {
60+
name: "orderRecord",
61+
"@level": 4,
62+
"@status": "live",
63+
"@statement": "An order is a durable record.",
64+
"@counterexample": "An order vanishes on restart.",
65+
"@implementedBy": ["app::Order"],
66+
},
67+
},
68+
],
69+
},
70+
});
71+
72+
const dirs: string[] = [];
73+
afterAll(() => { for (const d of dirs) rmSync(d, { recursive: true, force: true }); });
74+
75+
/** A project with NO dependencies, optionally declaring `scope.include`. */
76+
function project(opts: { scopeInclude?: string[] } = {}): string {
77+
const root = mkdtempSync(join(tmpdir(), "vreq-scope-nodeps-"));
78+
dirs.push(root);
79+
80+
mkdirSync(join(root, "metaobjects"), { recursive: true });
81+
writeFileSync(join(root, "metaobjects", "meta.app.json"), APP, "utf8");
82+
writeFileSync(join(root, "metaobjects", "meta.other.json"), OTHER, "utf8");
83+
writeFileSync(join(root, "metaobjects", "meta.req.json"), REQUIREMENTS, "utf8");
84+
85+
const config: Record<string, unknown> = { schema_version: 1, sources: [] };
86+
if (opts.scopeInclude) config.scope = { include: opts.scopeInclude };
87+
mkdirSync(join(root, ".metaobjects"), { recursive: true });
88+
writeFileSync(join(root, ".metaobjects", "config.json"), JSON.stringify(config), "utf8");
89+
90+
return root;
91+
}
92+
93+
let out: string[];
94+
let err: string[];
95+
let origLog: typeof console.log;
96+
let origErr: typeof console.error;
97+
98+
beforeEach(() => {
99+
out = [];
100+
err = [];
101+
origLog = console.log;
102+
origErr = console.error;
103+
console.log = (...a: unknown[]) => { out.push(a.map(String).join(" ")); };
104+
console.error = (...a: unknown[]) => { err.push(a.map(String).join(" ")); };
105+
});
106+
afterEach(() => {
107+
console.log = origLog;
108+
console.error = origErr;
109+
});
110+
111+
describe("meta verify — scope.include narrows the ledger denominator with ZERO dependencies (FR-023)", () => {
112+
test("no scope declared: both entities are coverable, only one is claimed", async () => {
113+
const root = project();
114+
const exit = await run(["verify", "--format", "text", "--cwd", root]);
115+
expect(exit).toBe(0);
116+
117+
const all = [...out, ...err].join("\n");
118+
expect(all).toContain("1/2 entities claimed");
119+
expect(all).toContain("other::Thing");
120+
});
121+
122+
test("scope.include naming only app:: narrows the denominator to 1/1 — other::Thing drops out entirely", async () => {
123+
const root = project({ scopeInclude: ["app::**"] });
124+
const exit = await run(["verify", "--format", "text", "--cwd", root]);
125+
expect(exit).toBe(0);
126+
127+
const all = [...out, ...err].join("\n");
128+
// Denominator narrows to the declared scope alone — other::Thing is
129+
// excluded from the COUNT, not merely reported as unclaimed.
130+
expect(all).toContain("1/1 entities claimed");
131+
expect(all).not.toContain("other::Thing");
132+
expect(all).not.toContain("WARN_REQUIREMENT_OBJECT_UNCLAIMED");
133+
});
134+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// FR-023 §11.1 item 2 — end-to-end coverage for `src/reference/entity.ts`'s
2+
// `ctx.select` call site.
3+
//
4+
// `src/reference/entity.ts` is not just another generator: it is the literal
5+
// file `meta init` (ADR-0034 scaffold-and-own) copies into every scaffolded
6+
// project's `codegen/generators/entity.ts` — the DEFAULT entity-module path
7+
// every new project runs. Its `generate()` closure reads
8+
// `ctx.select !== undefined ? { select: ctx.select } : undefined` when
9+
// rendering the shared-enums module (see the file's own FR-023 comment).
10+
// `shared-enums-imported.test.ts` already unit-tests the underlying
11+
// `renderSharedEnumsFile({ select })` primitive directly; nothing exercised
12+
// the WIRING — that `runGen` threads `opts.scope` through to `ctx.select`
13+
// and that THIS file's own ternary reads it — end to end. That gap is the
14+
// same shape as the drift this branch's final gate caught elsewhere: the
15+
// owned copy diverging from what the built-in path actually does, silently.
16+
//
17+
// Two packages, one shared enum, mirroring shared-enums-imported.test.ts's
18+
// fixture: `lib::Status` (abstract) is used ONLY by `lib::Thing`; `app::Order`
19+
// carries no enum at all. A `scope` predicate admitting only `app::**`
20+
// excludes `lib::Thing` — the enum's one consumer — so `enums.ts` must not be
21+
// emitted. Without a `scope` predicate (undefined), nothing is excluded and
22+
// `enums.ts` must carry `Status` — proving the ternary's OTHER branch too.
23+
import { describe, test, expect, afterEach } from "bun:test";
24+
import { mkdtempSync, rmSync, readdirSync } from "node:fs";
25+
import { tmpdir } from "node:os";
26+
import { join } from "node:path";
27+
import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
28+
import { runGen, defineConfig } from "../src/index.js";
29+
import { entityFile as refEntity } from "../src/reference/entity.js";
30+
31+
const LIB = {
32+
"metadata.root": {
33+
package: "lib",
34+
children: [
35+
{ "field.enum": { name: "Status", abstract: true, "@values": ["a", "b"] } },
36+
{
37+
"object.entity": {
38+
name: "Thing",
39+
children: [
40+
{ "source.rdb": { "@table": "things" } },
41+
{ "field.long": { name: "id" } },
42+
{ "field.enum": { name: "status", extends: "lib::Status" } },
43+
{ "identity.primary": { name: "pk", "@fields": ["id"] } },
44+
],
45+
},
46+
},
47+
],
48+
},
49+
};
50+
51+
const APP = {
52+
"metadata.root": {
53+
package: "app",
54+
children: [
55+
{
56+
"object.entity": {
57+
name: "Order",
58+
children: [
59+
{ "source.rdb": { "@table": "orders" } },
60+
{ "field.long": { name: "id" } },
61+
{ "field.string": { name: "note" } },
62+
{ "identity.primary": { name: "pk", "@fields": ["id"] } },
63+
],
64+
},
65+
},
66+
],
67+
},
68+
};
69+
70+
async function loadRoot() {
71+
const result = await new MetaDataLoader().load([
72+
new InMemoryStringSource(JSON.stringify(LIB)),
73+
new InMemoryStringSource(JSON.stringify(APP)),
74+
]);
75+
expect(result.errors).toEqual([]);
76+
return result.root;
77+
}
78+
79+
let dirs: string[] = [];
80+
afterEach(() => {
81+
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
82+
});
83+
84+
describe("src/reference/entity.ts — ctx.select call site, end to end via runGen", () => {
85+
test("a scope excluding the enum's only consumer suppresses enums.ts entirely", async () => {
86+
const root = await loadRoot();
87+
const dir = mkdtempSync(join(tmpdir(), "ref-entity-scope-"));
88+
dirs.push(dir);
89+
90+
await runGen({
91+
config: defineConfig({
92+
outDir: dir,
93+
extStyle: "none",
94+
dbImport: "~/server/db",
95+
dialect: "sqlite",
96+
generators: [refEntity()],
97+
}),
98+
metadata: root,
99+
// Admits app::Order only — lib::Thing (the enum's one consumer) is excluded.
100+
scope: (fqn: string) => fqn.startsWith("app::"),
101+
});
102+
103+
const files = readdirSync(dir);
104+
expect(files).toContain("Order.ts");
105+
expect(files).not.toContain("enums.ts");
106+
});
107+
108+
test("with no scope declared, the same shared enum still materializes", async () => {
109+
const root = await loadRoot();
110+
const dir = mkdtempSync(join(tmpdir(), "ref-entity-noscope-"));
111+
dirs.push(dir);
112+
113+
await runGen({
114+
config: defineConfig({
115+
outDir: dir,
116+
extStyle: "none",
117+
dbImport: "~/server/db",
118+
dialect: "sqlite",
119+
generators: [refEntity()],
120+
}),
121+
metadata: root,
122+
// No `scope` — byte-identical to a project with no scope declared.
123+
});
124+
125+
const files = readdirSync(dir);
126+
expect(files).toContain("Order.ts");
127+
expect(files).toContain("enums.ts");
128+
});
129+
});

0 commit comments

Comments
 (0)