Skip to content

Commit 314c36f

Browse files
committed
fix(codegen-ts): byte-identical duplicate emissions collapse instead of failing the build (#266)
A root-level abstract field.enum makes EVERY entityFile() instance emit the shared enums.ts at its target root -- the module is rendered from the whole loaded root, not from the instance's filtered subset. A config running more than one entityFile() against one target (the normal way to split a model across generated areas) therefore died on `Output path collision: enums.ts emitted by both "entity-file" and "entity-file"` -- an emission colliding with a byte-identical copy of itself. That made shared enums unusable in any multi-entityFile config, and the workaround distorted the model: the closed set ends up "owned" by whichever field happened to be written first, with every other usage inheriting from that field rather than from a named shared concept. The runner now keeps the first of two byte-identical emissions at the same path and moves on. Differing content is STILL a hard error -- there the result would depend on generator order, which is exactly the ambiguity the guard exists to catch. Existing single-entityFile output is byte-identical. Tests: the reported repro end-to-end (two filtered entityFile() instances + a shared abstract enum in one target, watched failing against the unfixed runner first) plus a runner unit case; the pre-existing differing-content collision test is unchanged and still throws. codegen-ts 1035 pass, cli 416 pass, workspace build + typecheck green. Also documents the Java meta:verify per-unique-outputDir fix (eed4a6b) in CHANGELOG.
1 parent eed4a6b commit 314c36f

5 files changed

Lines changed: 116 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,36 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10-
**npm-only**`metadata` + `codegen-ts`; PyPI / NuGet / Maven Central unchanged.
10+
**npm**`metadata` + `codegen-ts`; **Maven Central**`maven-plugin`; PyPI / NuGet
11+
unchanged.
12+
13+
### Fixed — a shared `enums.ts` no longer collides across `entityFile()` instances (#266)
14+
15+
Declaring a root-level abstract `field.enum` made **every** `entityFile()` instance emit
16+
the shared `enums.ts` at its target root — the module is rendered from the whole loaded
17+
root, not from the instance's filtered subset — so a config running more than one
18+
`entityFile()` against one target (the normal way to split a model across generated
19+
areas) failed the build with `Output path collision: enums.ts emitted by both
20+
"entity-file" and "entity-file"`: an emission colliding with a byte-identical copy of
21+
itself. Shared enums were therefore unusable in any multi-`entityFile` config, and the
22+
only workaround distorted the model (declare the enum inline on an arbitrary "owner"
23+
field, every other field `extends` it).
24+
25+
The runner now collapses byte-identical duplicate emissions to one file. Content that
26+
genuinely **differs** at the same path is still a hard error — there the result would
27+
depend on generator order, which is the ambiguity the guard exists to catch. Existing
28+
single-`entityFile` output is byte-identical.
29+
30+
### Fixed — `mvn meta:verify` diffs per unique `outputDir`, not per generator
31+
32+
`MetaDataVerifyMojo`'s codegen mode minted a temp output dir per `<generator>` and
33+
compared each against the shared committed tree, so two file-emitting generators
34+
configured with the same `outputDir` each saw only their own half of it and reported the
35+
other's committed files as `[stale-in-repo]` — permanent, unfixable false drift. The temp
36+
dir is now minted per unique `outputDir` (normalized absolute path) and compared once per
37+
output dir over the union of every generator writing there, matching the TypeScript
38+
`computeCodegenDrift` and the Python `verify --codegen` (#267) semantics. Byte-identical
39+
for the idiomatic one-`outputDir`-per-generator pom.
1140

1241
### Fixed — a reference to a non-`object` top-level node now resolves (#194)
1342

CLAUDE.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,9 +306,12 @@ README, "Multiple output targets".
306306

307307
The runner `runGen()` (1) loads metadata, (2) resolves targets + derives the
308308
entity-module target, (3) precomputes shared render state once, (4) runs each
309-
generator with a per-target `RenderContext`, (5) errors on duplicate full output
310-
paths, unknown target, missing `importBase` for cross-target imports, or any
311-
generator throw, (6) writes each file under its target's `outDir` (overwriting only
309+
generator with a per-target `RenderContext`, (5) errors on *conflicting* duplicate
310+
full output paths — two emissions of the same path whose CONTENT differs, where the
311+
result would depend on generator order — while byte-identical duplicates collapse to
312+
one file (#266: a shared artifact rendered from the whole loaded root, like the shared
313+
`enums.ts`, is emitted by every `entityFile()` instance); also errors on an unknown
314+
target, missing `importBase` for cross-target imports, or any generator throw, (6) writes each file under its target's `outDir` (overwriting only
312315
files carrying the `@generated` header; refusing others).
313316

314317
### Filter syntax + sort (Project D)

server/typescript/packages/codegen-ts/src/runner.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,15 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
319319
const fullPath = join(writeOutDir, file.path);
320320
const collision = emitted.find((prev) => prev.fullPath === fullPath);
321321
if (collision) {
322+
// #266 — identical bytes at the same path are not a conflict: either
323+
// emission produces exactly the same file, so keep the first and move on.
324+
// This is what makes a SHARED artifact rendered from the whole loaded root
325+
// (the shared `enums.ts`, emitted by every entityFile() instance) work in a
326+
// config that runs more than one instance against one target — previously
327+
// the build failed on an emission colliding with a copy of itself. Content
328+
// that genuinely DIFFERS is still a hard error: the outcome would depend on
329+
// generator order, which is exactly the ambiguity this guard exists to catch.
330+
if (collision.content === file.content) continue;
322331
throw new Error(
323332
`Output path collision: "${fullPath}" emitted by both ` +
324333
`"${collision.generatedBy}" and "${generator.name}". ` +

server/typescript/packages/codegen-ts/test/runner.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,33 @@ describe("runGen — error paths", () => {
8484
})).rejects.toThrow(/Output path collision.*Post\.ts".*alpha.*beta/);
8585
});
8686

87+
// #266 — a shared artifact rendered from the whole loaded root (the shared
88+
// `enums.ts`) is emitted by EVERY entityFile() instance, so a config running more
89+
// than one instance against one target collided an emission with a byte-identical
90+
// copy of itself. Identical bytes at the same path are not a conflict: whichever
91+
// "wins" produces the same file, so emit once instead of failing the build.
92+
test("byte-identical duplicate emissions collapse to one file instead of throwing", async () => {
93+
const loader = new MetaDataLoader();
94+
const { root } = await loader.load([new FileSource(FIXTURE)]);
95+
96+
const shared = "// shared artifact\n";
97+
const a: Generator = { name: "alpha", generate: oncePerRun(() => ({ path: "enums.ts", content: shared })) };
98+
const b: Generator = { name: "beta", generate: oncePerRun(() => ({ path: "enums.ts", content: shared })) };
99+
100+
const result = await runGen({
101+
config: defineConfig({
102+
outDir: tmp, extStyle: "none", dbImport: "../db", dialect: "sqlite",
103+
generators: [a, b],
104+
}),
105+
metadata: root,
106+
});
107+
108+
const enumFiles = result.files.filter((f) => f.path.endsWith("enums.ts"));
109+
expect(enumFiles.length).toBe(1);
110+
expect(readFileSync(join(tmp, "enums.ts"), "utf-8")).toBe(shared);
111+
expect(result.warnings).toEqual([]);
112+
});
113+
87114
test("generator throws -> error prefixed with [generator.name]", async () => {
88115
const loader = new MetaDataLoader();
89116
const { root } = await loader.load([new FileSource(FIXTURE)]);

server/typescript/packages/codegen-ts/test/templates/enum-shared-provided.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,50 @@ describe("FR-019 enum-shared-materialized-once", () => {
118118
});
119119
});
120120

121+
describe("#266 shared enums across multiple entityFile() instances", () => {
122+
// The reported repro: splitting a model across generated areas is done by running
123+
// several entityFile() instances against one target with disjoint filters. Every
124+
// instance renders the shared enums module from the WHOLE loaded root, so each
125+
// emitted a byte-identical `enums.ts` and the runner failed the build on the
126+
// collision — making a root-level abstract field.enum unusable in any such config.
127+
test("two filtered entityFile() instances in one target emit the shared enums module once", async () => {
128+
const root = await loadRoot(sharedModel());
129+
const tmp = mkdtempSync(join(tmpdir(), "enum-266-"));
130+
try {
131+
const result = await runGen({
132+
config: defineConfig({
133+
outDir: tmp,
134+
extStyle: "none",
135+
dbImport: "~/db",
136+
dialect: "sqlite",
137+
generators: [
138+
entityFile({ filter: (e) => e.name === "Order" }),
139+
entityFile({ filter: (e) => e.name === "Article" }),
140+
],
141+
}),
142+
metadata: root,
143+
});
144+
145+
// Both areas generated, and the shared module was emitted exactly once.
146+
expect(existsSync(join(tmp, "Order.ts"))).toBe(true);
147+
expect(existsSync(join(tmp, "Article.ts"))).toBe(true);
148+
expect(result.files.filter((f) => f.path.endsWith("enums.ts")).length).toBe(1);
149+
150+
// Its content is the full shared declaration — rendered from the whole root,
151+
// NOT narrowed to one instance's filtered subset.
152+
const enums = readFileSync(join(tmp, "enums.ts"), "utf-8");
153+
expect(enums).toContain('export type Status = "DRAFT" | "PUBLISHED" | "ARCHIVED";');
154+
155+
// Both entity files reference the one shared module.
156+
for (const name of ["Order.ts", "Article.ts"]) {
157+
expect(readFileSync(join(tmp, name), "utf-8")).toContain('from "./enums"');
158+
}
159+
} finally {
160+
rmSync(tmp, { recursive: true, force: true });
161+
}
162+
});
163+
});
164+
121165
describe("FR-019 enum-provided", () => {
122166
test("@provided: true emits NO type anywhere; entities import from the configured module", async () => {
123167
const root = await loadRoot(sharedModel({ provided: true }));

0 commit comments

Comments
 (0)