Skip to content

Commit 08b68c1

Browse files
dmealingclaude
andcommitted
fix(cli): a sub-project generates from its OWN sources, not the ancestor's union (#340)
The gen-side remainder of #326/#327. Once source resolution learned to walk upward, `meta gen` run in a package whose `metaobjects.config.ts` sits below the collection root began loading the ancestor's ENTIRE source set. One adopter's web app went from 376 generated files to 831; the surplus was another module's server-side LLM prompt payload DTOs, absent from the app's own metadata directory entirely and types the app will never construct. It fails OPEN — `tsc --noEmit` passes and the app's tests pass — so the only symptom is a generated tree that quietly doubled. That is what makes it worth a gate: anything walking `src/generated` (a barrel, a bundler analysis, a reviewer) now sees a sibling module's internal vocabulary, and a prompt payload rename shows up as a diff in the web app's output. The rule, which is #326's own principle carried to its other half: an ancestor `.metaobjects/config.json` is the DEFAULT for a package that declares no sources, never an ADDITION to one that does. `resolveGenCollection` re-resolves the TS config's directory as a collection in its own right, and it wins only if it actually resolves metadata. So it can only ever narrow, and only where the shape could not have worked before: - the two configs sit together (every `meta init` project, and every run from a project root) — the original collection is returned without a second resolve; - the package declares no sources — the pinned resolve throws or comes back empty and the ancestor stands, so a package that genuinely lives off an ancestor tree is untouched. Gated as its own arm, since narrowing that case would re-break #326; - the package has its own metadata — it generates from exactly that, which is what it did before the upward walk existed. `verify --codegen` gets the same treatment, and that is not optional: it regenerates and diffs against the committed output, so narrowing `gen` alone would make every sub-project report the ancestor's whole contribution as drift — converting this fix into a broken gate. It re-resolves rather than reusing a hoisted value because verify's subverbs COMPOSE: `--db` and `--templates` ask about the whole declared collection, and narrowing the shared root would silently change what they check. Deliberately NOT applied to `.metaobjects/` STATE — migrations, snapshots and the operational block stay keyed on the discovered collection's directory, which #326 settled. This narrows what is LOADED and nothing about where state lives. Gated by the monorepo shape plus its counter-arm; verified in both directions — with the fix reverted, the sibling module's entity is emitted into the app's generated tree and the gate fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
1 parent 5db7066 commit 08b68c1

4 files changed

Lines changed: 210 additions & 6 deletions

File tree

server/typescript/packages/cli/src/commands/gen.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { relative } from "node:path";
22
import { parseGenArgs } from "../lib/args.js";
33
import { resolveGenConfig } from "../lib/config.js";
4-
import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
4+
import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenCollection, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
55
import { formatGenResult, formatGenResultToon, type GenFileEntry, type GenFileStatus } from "../lib/output.js";
66
import { formatGenResultJson } from "../lib/output-json.js";
77
import type { OutputFormat } from "../lib/format.js";
@@ -74,6 +74,12 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
7474
// project root's config exactly as before.
7575
const projectRoot = resolveGenConfigDir(cwd, collection.configDir);
7676

77+
// ...and a package that declares its own sources GENERATES from them (#340). An
78+
// ancestor collection is the default for a package that declares none, never an
79+
// addition to one that does — otherwise a sub-project's output silently absorbs
80+
// metadata from unrelated trees. Identical object when the two configs sit together.
81+
const genCollection = await resolveGenCollection(collection, projectRoot);
82+
7783
// Advisory: nudge to refresh the .claude/skills docs if they predate this CLI.
7884
// Rooted at `projectRoot`, not ambient cwd — the scaffolded agent context sits
7985
// with the project that declares the metadata, so a run from a subdirectory
@@ -97,8 +103,8 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
97103

98104
let metadata;
99105
try {
100-
metadata = await loadMemory(collection.configDir, {
101-
files: collection.files,
106+
metadata = await loadMemory(genCollection.configDir, {
107+
files: genCollection.files,
102108
...loadMemoryOptionsFrom(forgeConfig),
103109
});
104110
} catch (err) {
@@ -120,7 +126,7 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
120126
// GENERATED entities, never over what the collection loads. Always
121127
// passed: an unconfigured project's predicate admits everything, so this
122128
// is a no-op for the common case, not a behavior change.
123-
scope: collection.inScope,
129+
scope: genCollection.inScope,
124130
...(cliConfig.entities.length > 0 ? { entityFilter: cliConfig.entities } : {}),
125131
});
126132
} catch (err) {

server/typescript/packages/cli/src/commands/verify.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { warnIfManifestIgnored } from "../lib/manifest-ignored-check.js";
1515
import { scanSourceForAntiPatterns } from "../lib/anti-patterns.js";
1616
import { FileProvider } from "../lib/file-provider.js";
1717
import { derivePayloadFieldTree } from "../lib/payload-field-tree.js";
18-
import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
18+
import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenCollection, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
1919
import { computeCodegenDrift } from "../lib/codegen-drift.js";
2020
import { checkRequirements, summariseRequirements } from "../lib/requirement-check.js";
2121
import { resolveD1Config, resolveMigrateConfig } from "../lib/config.js";
@@ -954,9 +954,33 @@ export async function verifyCommand(
954954
// question 3) — a `gen` that committed under a narrowed scope and a
955955
// `verify --codegen` that regenerates unscoped would disagree about which
956956
// files should exist, reporting every out-of-scope entity as drift.
957+
//
958+
// The same argument governs the SOURCE SET (#340), and it is the reason this
959+
// resolves its own collection instead of reusing the outer one: `gen` in a
960+
// sub-project generates from that package's own sources, so a `--codegen` gate
961+
// that regenerated from the ancestor's wider set would report every file the
962+
// ancestor contributes as drift — turning the #340 fix into a broken gate. It is
963+
// re-resolved rather than hoisted because `verify`'s subverbs COMPOSE: `--db` and
964+
// `--templates` are answering a question about the whole declared collection, and
965+
// narrowing the outer `root` would silently change what they check.
966+
const genCollection = await resolveGenCollection(collection, genConfigDir);
967+
let codegenRoot = root;
968+
if (genCollection !== collection) {
969+
try {
970+
codegenRoot = await loadMemory(genCollection.configDir, {
971+
files: genCollection.files,
972+
...configLoadOptions,
973+
strict: !flags.lax,
974+
});
975+
} catch (err) {
976+
log.error(`verify --codegen: failed to load this package's metadata: ${(err as Error).message}`);
977+
return 2;
978+
}
979+
}
980+
957981
let result;
958982
try {
959-
result = await computeCodegenDrift(forgeConfig, root, genConfigDir, collection.inScope);
983+
result = await computeCodegenDrift(forgeConfig, codegenRoot, genConfigDir, genCollection.inScope);
960984
} catch (err) {
961985
log.error(`verify --codegen: regeneration failed: ${(err as Error).message}`);
962986
return 1;

server/typescript/packages/cli/src/lib/load-metaobjects-config.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
66
import { randomBytes } from "node:crypto";
77
import { createJiti } from "jiti";
88
import type { MetaDataTypeProvider, MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts";
9+
import { resolveCollection, type Collection } from "@metaobjectsdev/sdk";
910

1011
const CONFIG_FILE = "metaobjects.config.ts";
1112

@@ -212,6 +213,50 @@ export function resolveGenConfigDir(startDir: string, fallback: string): string
212213
return fallback;
213214
}
214215

216+
/**
217+
* The collection a TypeScript package GENERATES FROM (#340).
218+
*
219+
* #326/#327 established that the two config files answer different questions, and gave
220+
* `metaobjects.config.ts` its own walk. This is the remaining half of the same split:
221+
* a sub-project whose TS config sits below the collection root was still LOADING the
222+
* ancestor's whole source set, so its `src/generated` absorbed metadata belonging to
223+
* unrelated parts of the repository — one adopter's web app went from 376 files to 831,
224+
* the surplus being another module's server-side prompt payload DTOs. It fails OPEN
225+
* (`tsc` passes, tests pass), so the only symptom is a directory that quietly doubled.
226+
*
227+
* The rule: an ancestor `.metaobjects/config.json` is the DEFAULT for a package that
228+
* declares no sources of its own, never an ADDITION to one that does. So when the TS
229+
* config sits somewhere the collection did not, that directory is re-resolved as a
230+
* collection in its own right, and it wins if it actually resolves any metadata.
231+
*
232+
* It can only ever NARROW, and only in a shape that could not have worked before:
233+
* - the two directories coincide (every `meta init` project, and every run from a
234+
* project root) — returns the original, untouched, without a second resolve;
235+
* - the sub-project declares no sources — the pinned resolve throws
236+
* `ERR_SOURCE_UNRESOLVED` or comes back empty, and the ancestor stands, so a
237+
* package that genuinely lives off an ancestor tree keeps working;
238+
* - the sub-project has its own metadata — it generates from exactly that, which is
239+
* what it did before source resolution learned to walk upward.
240+
*
241+
* Deliberately NOT applied to `.metaobjects/` STATE. Migrations, snapshots and the
242+
* operational block stay keyed on the discovered collection's directory (#326 settled
243+
* that); this narrows what is LOADED, and nothing about where state lives.
244+
*/
245+
export async function resolveGenCollection(
246+
collection: Collection,
247+
genConfigDir: string,
248+
): Promise<Collection> {
249+
if (resolve(genConfigDir) === resolve(collection.configDir)) return collection;
250+
try {
251+
const pinned = await resolveCollection(genConfigDir, { explicitDir: genConfigDir });
252+
return pinned.files.length > 0 ? pinned : collection;
253+
} catch {
254+
// The sub-project declares nothing resolvable of its own — inherit, exactly as a
255+
// package with no config always has.
256+
return collection;
257+
}
258+
}
259+
215260
export async function loadMetaobjectsConfig(projectRoot: string): Promise<MetaobjectsGenConfig> {
216261
const fullPath = resolve(projectRoot, CONFIG_FILE);
217262
if (!existsSync(fullPath)) {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* #340 — a sub-project GENERATES from its own sources, not from the union of every
3+
* tree an ancestor `.metaobjects/config.json` declares.
4+
*
5+
* The gen-side remainder of #326/#327. Once source resolution learned to walk upward,
6+
* a package whose `metaobjects.config.ts` sits below the collection root began loading
7+
* the ancestor's entire source set: one adopter's web app went from 376 generated
8+
* files to 831, the surplus being another module's server-side prompt payload DTOs
9+
* that the app will never construct. It fails OPEN — `tsc` passes, tests pass — so the
10+
* only symptom is a directory that quietly doubled, which is why it needs a gate
11+
* rather than a reader.
12+
*
13+
* The rule under test: an ancestor collection is the DEFAULT for a package that
14+
* declares no sources, never an ADDITION to one that does. Both arms matter — the
15+
* second is what keeps a package that legitimately lives off an ancestor tree working.
16+
*/
17+
import { describe, test, expect } from "bun:test";
18+
import { cpSync, mkdtempSync, mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs";
19+
import { join, resolve } from "node:path";
20+
import { run } from "../../src/index.js";
21+
22+
const FIXTURES = resolve(import.meta.dirname, "../fixtures");
23+
// Temp dirs live inside the monorepo so jiti can resolve @metaobjectsdev/* when it
24+
// loads metaobjects.config.ts (same rationale as gen-nested-gen-config.test.ts).
25+
const WORKSPACE_TMP = resolve(import.meta.dirname, "../fixtures/__tmp__");
26+
27+
function genConfigBody(outDir: string): string {
28+
return `
29+
import { defineConfig } from "@metaobjectsdev/codegen-ts";
30+
import { entityFile } from "@metaobjectsdev/codegen-ts/generators";
31+
export default defineConfig({
32+
outDir: ${JSON.stringify(outDir)},
33+
dialect: "sqlite",
34+
dbImport: "~/db",
35+
extStyle: "none",
36+
generators: [entityFile()],
37+
});
38+
`;
39+
}
40+
41+
/** An entity that exists ONLY in the unrelated tree, so its output is unambiguous. */
42+
const OTHER_TREE_ENTITY = JSON.stringify({
43+
"metadata.root": {
44+
package: "other",
45+
children: [
46+
{
47+
"object.entity": {
48+
name: "AbilityGenerationPayload",
49+
children: [
50+
{ "source.rdb": { "@table": "ability_generation_payloads" } },
51+
{ "field.uuid": { name: "id" } },
52+
{ "identity.primary": { name: "id", "@fields": "id", "@generation": "uuid" } },
53+
],
54+
},
55+
},
56+
],
57+
},
58+
});
59+
60+
describe("meta gen — a sub-project's own sources govern its output (#340)", () => {
61+
test("does NOT absorb an unrelated tree the ancestor collection also declares", async () => {
62+
mkdirSync(WORKSPACE_TMP, { recursive: true });
63+
const root = mkdtempSync(join(WORKSPACE_TMP, "forge-gen-subscope-"));
64+
try {
65+
// The app: its own metadata AND its own TS config.
66+
const app = join(root, "app");
67+
mkdirSync(app, { recursive: true });
68+
cpSync(join(FIXTURES, "trainer-website-meta"), app, { recursive: true });
69+
const outDir = join(app, "generated", "db");
70+
writeFileSync(join(app, "metaobjects.config.ts"), genConfigBody(outDir));
71+
72+
// A sibling module's tree, which has no business in the app's output.
73+
mkdirSync(join(root, "other", "metaobjects"), { recursive: true });
74+
writeFileSync(join(root, "other", "metaobjects", "meta.other.json"), OTHER_TREE_ENTITY, "utf8");
75+
76+
// The repo root declares BOTH — the port-neutral "where does metadata live"
77+
// answer for a polyglot monorepo, which is exactly what it is for.
78+
mkdirSync(join(root, ".metaobjects"), { recursive: true });
79+
writeFileSync(
80+
join(root, ".metaobjects", "config.json"),
81+
JSON.stringify({
82+
schema_version: 1,
83+
sources: [{ path: "app/metaobjects" }, { path: "other/metaobjects" }],
84+
}),
85+
"utf8",
86+
);
87+
88+
const exit = await run(["gen", "--cwd", app]);
89+
expect(exit).toBe(0);
90+
// The app's own entity is generated, as always.
91+
expect(existsSync(join(outDir, "User.ts"))).toBe(true);
92+
// ...and the sibling module's is not. Before the fix this file was emitted
93+
// into the app's generated tree.
94+
expect(existsSync(join(outDir, "AbilityGenerationPayload.ts"))).toBe(false);
95+
} finally {
96+
rmSync(root, { recursive: true, force: true });
97+
}
98+
});
99+
100+
// The counter-arm. Narrowing must never strand a package that has no sources of its
101+
// own — for that one, the ancestor collection is still the answer, and #326's shape
102+
// (a TS config below a collection root) is precisely that package.
103+
test("a sub-project with NO sources of its own still inherits the ancestor's", async () => {
104+
mkdirSync(WORKSPACE_TMP, { recursive: true });
105+
const root = mkdtempSync(join(WORKSPACE_TMP, "forge-gen-subscope-inherit-"));
106+
try {
107+
// All metadata lives at the root, in a tree the app does not contain.
108+
cpSync(join(FIXTURES, "trainer-website-meta"), join(root, "shared"), { recursive: true });
109+
mkdirSync(join(root, ".metaobjects"), { recursive: true });
110+
writeFileSync(
111+
join(root, ".metaobjects", "config.json"),
112+
JSON.stringify({ schema_version: 1, sources: [{ path: "shared/metaobjects" }] }),
113+
"utf8",
114+
);
115+
116+
// The app carries a TS config and nothing else.
117+
const app = join(root, "app");
118+
mkdirSync(app, { recursive: true });
119+
const outDir = join(app, "generated", "db");
120+
writeFileSync(join(app, "metaobjects.config.ts"), genConfigBody(outDir));
121+
122+
const exit = await run(["gen", "--cwd", app]);
123+
expect(exit).toBe(0);
124+
expect(existsSync(join(outDir, "User.ts"))).toBe(true);
125+
} finally {
126+
rmSync(root, { recursive: true, force: true });
127+
}
128+
});
129+
});

0 commit comments

Comments
 (0)