@cosmicdrift/kumiko-dev-server 0.247.0 → 0.249.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/kumiko-build.ts +2 -2
- package/package.json +4 -4
- package/src/codegen/__tests__/run-codegen.test.ts +144 -4
- package/src/codegen/index.ts +1 -0
- package/src/codegen/render.ts +22 -5
- package/src/codegen/run-codegen.ts +93 -13
- package/src/codegen/scan-events.ts +11 -2
- package/src/codegen/watch.ts +2 -1
package/bin/kumiko-build.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
formatServerBuildResult,
|
|
27
27
|
readExtraRuntimeExternals,
|
|
28
28
|
} from "../src/build";
|
|
29
|
-
import { runCodegen } from "../src/codegen";
|
|
29
|
+
import { formatScanWarning, runCodegen } from "../src/codegen";
|
|
30
30
|
|
|
31
31
|
const explicit = process.argv[2];
|
|
32
32
|
const cwd = explicit ? resolve(process.cwd(), explicit) : process.cwd();
|
|
@@ -61,7 +61,7 @@ try {
|
|
|
61
61
|
if (cgResult.warnings.length > 0) {
|
|
62
62
|
for (const w of cgResult.warnings) {
|
|
63
63
|
// biome-ignore lint/suspicious/noConsole: CLI-Output
|
|
64
|
-
console.warn(`${yellow}!${reset} [codegen] ${w
|
|
64
|
+
console.warn(`${yellow}!${reset} [codegen] ${formatScanWarning(w)}`);
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.249.0",
|
|
4
4
|
"description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. Its compose-stacks/env-schema subpaths are consumed at prod boot too — see @cosmicdrift/kumiko-server-runtime for the prod runner itself.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"kumiko-upgrade": "./bin/kumiko-upgrade.ts"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
63
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
64
|
-
"@cosmicdrift/kumiko-server-runtime": "0.
|
|
62
|
+
"@cosmicdrift/kumiko-bundled-features": "0.249.0",
|
|
63
|
+
"@cosmicdrift/kumiko-framework": "0.249.0",
|
|
64
|
+
"@cosmicdrift/kumiko-server-runtime": "0.249.0",
|
|
65
65
|
"ts-morph": "^28.0.0"
|
|
66
66
|
},
|
|
67
67
|
"publishConfig": {
|
|
@@ -57,7 +57,8 @@ export const myFeature = defineFeature("myFeat", (r) => {
|
|
|
57
57
|
`,
|
|
58
58
|
);
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// handlerQns: [] opts out of the #2757 manifest-fallback warning — out of scope here.
|
|
61
|
+
const result = runCodegen({ appRoot, handlerQns: [] });
|
|
61
62
|
|
|
62
63
|
expect(result.eventCount).toBe(1);
|
|
63
64
|
expect(result.warnings).toEqual([]);
|
|
@@ -122,7 +123,7 @@ export const myFeature = defineFeature("inlineFeat", (r) => {
|
|
|
122
123
|
`,
|
|
123
124
|
);
|
|
124
125
|
|
|
125
|
-
const result = runCodegen({ appRoot });
|
|
126
|
+
const result = runCodegen({ appRoot, handlerQns: [] });
|
|
126
127
|
expect(result.eventCount).toBe(1);
|
|
127
128
|
expect(result.warnings).toEqual([]);
|
|
128
129
|
expect(result.didWriteSchemas).toBe(true);
|
|
@@ -169,7 +170,7 @@ export const myFeature = defineFeature("billing", (r) => {
|
|
|
169
170
|
`,
|
|
170
171
|
);
|
|
171
172
|
|
|
172
|
-
const result = runCodegen({ appRoot });
|
|
173
|
+
const result = runCodegen({ appRoot, handlerQns: [] });
|
|
173
174
|
expect(result.eventCount).toBe(2);
|
|
174
175
|
expect(result.warnings).toEqual([]);
|
|
175
176
|
|
|
@@ -201,7 +202,7 @@ export const myFeature = defineFeature("billing", (r) => {
|
|
|
201
202
|
`,
|
|
202
203
|
);
|
|
203
204
|
|
|
204
|
-
const result = runCodegen({ appRoot });
|
|
205
|
+
const result = runCodegen({ appRoot, handlerQns: [] });
|
|
205
206
|
expect(result.eventCount).toBe(1);
|
|
206
207
|
expect(result.warnings).toEqual([]);
|
|
207
208
|
expect(result.didWriteSchemas).toBe(true);
|
|
@@ -522,4 +523,143 @@ export default defineFeature("pkgjson", (r) => {
|
|
|
522
523
|
},
|
|
523
524
|
});
|
|
524
525
|
});
|
|
526
|
+
|
|
527
|
+
// #2757 — `kumiko-build` runs `runCodegen({ appRoot })` with no
|
|
528
|
+
// `handlerQns`. Without a `feature-manifest.json` that falls back to
|
|
529
|
+
// an empty list, which used to mean "render types.generated.d.ts and
|
|
530
|
+
// define.ts as if there were no write handlers at all" — silently
|
|
531
|
+
// deleting an already-checked-in WriteHandlerQn union / TypedDispatcher
|
|
532
|
+
// that a previous dev-server run had generated from the real registry.
|
|
533
|
+
describe("preserving the handler-derived block when handlerQns is omitted (#2757)", () => {
|
|
534
|
+
function writeSingleEventFeature(appRoot: string): void {
|
|
535
|
+
write(
|
|
536
|
+
appRoot,
|
|
537
|
+
"src/feature/events.ts",
|
|
538
|
+
`import { z } from "zod";\nexport const sSchema = z.object({ id: z.string() });\n`,
|
|
539
|
+
);
|
|
540
|
+
write(
|
|
541
|
+
appRoot,
|
|
542
|
+
"src/feature/feature.ts",
|
|
543
|
+
`import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
544
|
+
import { sSchema } from "./events";
|
|
545
|
+
export default defineFeature("app", (r) => {
|
|
546
|
+
r.defineEvent("only", sSchema);
|
|
547
|
+
});
|
|
548
|
+
`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
test("a block generated with handlerQns survives a later run without handlerQns", () => {
|
|
553
|
+
const appRoot = makeAppDir();
|
|
554
|
+
writeSingleEventFeature(appRoot);
|
|
555
|
+
|
|
556
|
+
runCodegen({ appRoot, handlerQns: ["app:write:create"] });
|
|
557
|
+
const typesPath = join(appRoot, ".kumiko", "types.generated.d.ts");
|
|
558
|
+
const definePath = join(appRoot, ".kumiko", "define.ts");
|
|
559
|
+
expect(readFileSync(typesPath, "utf-8")).toContain('| "app:write:create"');
|
|
560
|
+
expect(readFileSync(definePath, "utf-8")).toContain("createTypedDispatcher");
|
|
561
|
+
|
|
562
|
+
// No feature-manifest.json in appRoot — this is the kumiko-build path.
|
|
563
|
+
const result = runCodegen({ appRoot });
|
|
564
|
+
|
|
565
|
+
expect(readFileSync(typesPath, "utf-8")).toContain('| "app:write:create"');
|
|
566
|
+
expect(readFileSync(definePath, "utf-8")).toContain("createTypedDispatcher");
|
|
567
|
+
expect(result.warnings.some((w) => w.message.includes("feature-manifest.json"))).toBe(true);
|
|
568
|
+
// Round-trip is byte-identical, not just "still contains the QN" — writeIfChanged
|
|
569
|
+
// must see no diff once the preserved block is reattached.
|
|
570
|
+
expect(result.didWriteTypes).toBe(false);
|
|
571
|
+
expect(result.didWriteDefine).toBe(false);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("a block from a pre-#2757 file (no marker) survives a run without handlerQns", () => {
|
|
575
|
+
const appRoot = makeAppDir();
|
|
576
|
+
writeSingleEventFeature(appRoot);
|
|
577
|
+
// Legacy shape: what the released renderer wrote before markers existed.
|
|
578
|
+
write(
|
|
579
|
+
appRoot,
|
|
580
|
+
".kumiko/types.generated.d.ts",
|
|
581
|
+
`export {};\n\nexport type WriteHandlerQn =\n | "app:write:legacy"\n;\n`,
|
|
582
|
+
);
|
|
583
|
+
write(
|
|
584
|
+
appRoot,
|
|
585
|
+
".kumiko/define.ts",
|
|
586
|
+
`export function defineQueryHandler() {}\n\nimport type { Dispatcher, WriteOpts, WriteResult } from "@cosmicdrift/kumiko-headless";\nexport function createTypedDispatcher() {}\n`,
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
const result = runCodegen({ appRoot });
|
|
590
|
+
|
|
591
|
+
expect(readFileSync(join(appRoot, ".kumiko", "types.generated.d.ts"), "utf-8")).toContain(
|
|
592
|
+
'| "app:write:legacy"',
|
|
593
|
+
);
|
|
594
|
+
expect(readFileSync(join(appRoot, ".kumiko", "define.ts"), "utf-8")).toContain(
|
|
595
|
+
"createTypedDispatcher",
|
|
596
|
+
);
|
|
597
|
+
expect(result.warnings.some((w) => w.message.includes("feature-manifest.json"))).toBe(true);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("no handlerQns and no prior generated file → no block, no crash", () => {
|
|
601
|
+
const appRoot = makeAppDir();
|
|
602
|
+
writeSingleEventFeature(appRoot);
|
|
603
|
+
|
|
604
|
+
const result = runCodegen({ appRoot });
|
|
605
|
+
|
|
606
|
+
const typesPath = join(appRoot, ".kumiko", "types.generated.d.ts");
|
|
607
|
+
const definePath = join(appRoot, ".kumiko", "define.ts");
|
|
608
|
+
expect(readFileSync(typesPath, "utf-8")).not.toContain("WriteHandlerQn");
|
|
609
|
+
expect(readFileSync(definePath, "utf-8")).not.toContain("TypedDispatcher");
|
|
610
|
+
expect(result.skipped).toBe(false);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("a preserved block is not duplicated across repeated runs without handlerQns", () => {
|
|
614
|
+
const appRoot = makeAppDir();
|
|
615
|
+
writeSingleEventFeature(appRoot);
|
|
616
|
+
|
|
617
|
+
runCodegen({ appRoot, handlerQns: ["app:write:create"] });
|
|
618
|
+
runCodegen({ appRoot });
|
|
619
|
+
runCodegen({ appRoot });
|
|
620
|
+
|
|
621
|
+
const typesPath = join(appRoot, ".kumiko", "types.generated.d.ts");
|
|
622
|
+
const definePath = join(appRoot, ".kumiko", "define.ts");
|
|
623
|
+
const typesOccurrences =
|
|
624
|
+
readFileSync(typesPath, "utf-8").split("app:write:create").length - 1;
|
|
625
|
+
const defineOccurrences =
|
|
626
|
+
readFileSync(definePath, "utf-8").split("createTypedDispatcher").length - 1;
|
|
627
|
+
expect(typesOccurrences).toBe(1);
|
|
628
|
+
expect(defineOccurrences).toBe(1);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("a later run with handlerQns overwrites a preserved stale block", () => {
|
|
632
|
+
const appRoot = makeAppDir();
|
|
633
|
+
writeSingleEventFeature(appRoot);
|
|
634
|
+
|
|
635
|
+
runCodegen({ appRoot, handlerQns: ["app:write:create"] });
|
|
636
|
+
runCodegen({ appRoot }); // preserves app:write:create
|
|
637
|
+
runCodegen({ appRoot, handlerQns: ["app:write:update"] });
|
|
638
|
+
|
|
639
|
+
const typesPath = join(appRoot, ".kumiko", "types.generated.d.ts");
|
|
640
|
+
const typesContent = readFileSync(typesPath, "utf-8");
|
|
641
|
+
expect(typesContent).toContain('| "app:write:update"');
|
|
642
|
+
expect(typesContent).not.toContain("app:write:create");
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("the missing-manifest warning appears only when handlerQns is omitted", () => {
|
|
646
|
+
const appRoot = makeAppDir();
|
|
647
|
+
writeSingleEventFeature(appRoot);
|
|
648
|
+
|
|
649
|
+
const withoutOption = runCodegen({ appRoot });
|
|
650
|
+
expect(withoutOption.warnings.some((w) => w.message.includes("feature-manifest.json"))).toBe(
|
|
651
|
+
true,
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
const explicitEmpty = runCodegen({ appRoot, handlerQns: [] });
|
|
655
|
+
expect(explicitEmpty.warnings.some((w) => w.message.includes("feature-manifest.json"))).toBe(
|
|
656
|
+
false,
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
const explicitList = runCodegen({ appRoot, handlerQns: ["app:write:create"] });
|
|
660
|
+
expect(explicitList.warnings.some((w) => w.message.includes("feature-manifest.json"))).toBe(
|
|
661
|
+
false,
|
|
662
|
+
);
|
|
663
|
+
});
|
|
664
|
+
});
|
|
525
665
|
});
|
package/src/codegen/index.ts
CHANGED
package/src/codegen/render.ts
CHANGED
|
@@ -26,11 +26,24 @@ import { basename, relative } from "node:path";
|
|
|
26
26
|
import type { ScannedEvent } from "./scan-events";
|
|
27
27
|
import { rewriteImportPath } from "./scan-events";
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Start-of-block markers for the two handler-derived sections that
|
|
31
|
+
* `run-codegen.ts` may need to carry forward verbatim from a previous
|
|
32
|
+
* run (when the current run has no `handlerQns` to render a fresh
|
|
33
|
+
* block from). Each marker is always the last thing its renderer
|
|
34
|
+
* emits, so a caller can locate it in a previously-written file and
|
|
35
|
+
* slice from the marker to EOF to recover the whole block.
|
|
36
|
+
*/
|
|
37
|
+
export const WRITE_HANDLER_QN_MARKER =
|
|
38
|
+
"// --- WriteHandlerQn (generated from live registry / feature-manifest.json) ---";
|
|
39
|
+
export const TYPED_DISPATCHER_MARKER =
|
|
40
|
+
"// --- TypedDispatcher (generated from live registry / feature-manifest.json) ---";
|
|
41
|
+
|
|
29
42
|
const HEADER = [
|
|
30
43
|
"// =====================================================================",
|
|
31
44
|
"// AUTO-GENERATED — DO NOT EDIT BY HAND",
|
|
32
|
-
"//
|
|
33
|
-
"// file-watcher, which
|
|
45
|
+
"// Regenerated by `bun run build` (kumiko-build) or by the dev-server's",
|
|
46
|
+
"// file-watcher, which runs codegen on every r.defineEvent change.",
|
|
34
47
|
"// =====================================================================",
|
|
35
48
|
].join("\n");
|
|
36
49
|
|
|
@@ -129,8 +142,9 @@ export function renderInlineSchemasFile(
|
|
|
129
142
|
"// Schema extracts purely for type inference: this file is referenced",
|
|
130
143
|
"// from types.generated.d.ts via `import type`. ts-strip elides it at",
|
|
131
144
|
"// build time, so there is NO runtime duplication of the inline schemas",
|
|
132
|
-
"// in feature files. When an event schema changes:
|
|
133
|
-
"//
|
|
145
|
+
"// in feature files. When an event schema changes: rebuild (`bun run",
|
|
146
|
+
"// build`) or let the dev-server regenerate it — otherwise the z.infer",
|
|
147
|
+
"// type drifts from the runtime schema.",
|
|
134
148
|
"",
|
|
135
149
|
`import { z } from "zod";`,
|
|
136
150
|
"",
|
|
@@ -174,6 +188,7 @@ export function renderDefineFile(handlerQns: readonly string[] = []): string {
|
|
|
174
188
|
handlerQns.length > 0
|
|
175
189
|
? [
|
|
176
190
|
"",
|
|
191
|
+
TYPED_DISPATCHER_MARKER,
|
|
177
192
|
`import type { Dispatcher, WriteOpts, WriteResult } from "@cosmicdrift/kumiko-headless";`,
|
|
178
193
|
`import type { WriteHandlerQn } from "./types.generated";`,
|
|
179
194
|
"",
|
|
@@ -282,5 +297,7 @@ export function renderWriteHandlerTypes(handlerQns: readonly string[]): string {
|
|
|
282
297
|
if (handlerQns.length === 0) return "";
|
|
283
298
|
|
|
284
299
|
const lines = handlerQns.map((qn) => ` | "${qn}"`);
|
|
285
|
-
return ["", `export type WriteHandlerQn =`, ...lines, ";", ""].join(
|
|
300
|
+
return ["", WRITE_HANDLER_QN_MARKER, `export type WriteHandlerQn =`, ...lines, ";", ""].join(
|
|
301
|
+
"\n",
|
|
302
|
+
);
|
|
286
303
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// runCodegen —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// runCodegen — top-level entry point. Called by the dev-server (on boot,
|
|
2
|
+
// then on every watched file-change) and by `kumiko-build` (before
|
|
3
|
+
// bundling).
|
|
4
4
|
//
|
|
5
5
|
// Lifecycle:
|
|
6
6
|
// 1. Scan `<appRoot>/src/**` nach r.defineEvent.
|
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
renderInlineSchemasFile,
|
|
22
22
|
renderTypesAugmentation,
|
|
23
23
|
renderWriteHandlerTypes,
|
|
24
|
+
TYPED_DISPATCHER_MARKER,
|
|
25
|
+
WRITE_HANDLER_QN_MARKER,
|
|
24
26
|
} from "./render";
|
|
25
27
|
import { type ScanWarning, scanEvents } from "./scan-events";
|
|
26
28
|
|
|
@@ -77,7 +79,15 @@ export function runCodegen(opts: CodegenOptions): CodegenResult {
|
|
|
77
79
|
mkdirSync(outputDir, { recursive: true });
|
|
78
80
|
|
|
79
81
|
const typesContent = renderTypesAugmentation(scan.events, outputDir);
|
|
82
|
+
// `opts.handlerQns` undefined means the caller (kumiko-build) has no
|
|
83
|
+
// live feature registry to draw from and relies on the manifest
|
|
84
|
+
// fallback. That fallback can't tell "no handlers exist" apart from
|
|
85
|
+
// "the manifest doesn't exist" — both come back as an empty array —
|
|
86
|
+
// so an empty result here is a known-lossy read, not authoritative
|
|
87
|
+
// data the way run-dev-app's live registry is.
|
|
88
|
+
const usingManifestFallback = opts.handlerQns === undefined;
|
|
80
89
|
const handlerQns = opts.handlerQns ?? readHandlerQnsFromManifest(opts.appRoot);
|
|
90
|
+
const hasHandlerQns = handlerQns.length > 0;
|
|
81
91
|
const defineContent = renderDefineFile(handlerQns);
|
|
82
92
|
const schemasContent = renderInlineSchemasFile(scan.events, opts.appRoot);
|
|
83
93
|
// package.json — turns `.kumiko/` into a real installable package
|
|
@@ -91,21 +101,53 @@ export function runCodegen(opts: CodegenOptions): CodegenResult {
|
|
|
91
101
|
// WriteHandlerQn-Union an den types-Content anhängen (optional). Der
|
|
92
102
|
// Dev-Server übergibt handlerQns aus der lebenden Registry (genauer);
|
|
93
103
|
// der CLI-Fallback liest das feature-manifest.json falls vorhanden.
|
|
94
|
-
|
|
95
|
-
|
|
104
|
+
//
|
|
105
|
+
// Preserve only applies to the manifest-fallback path: a caller that
|
|
106
|
+
// explicitly passes `handlerQns: []` (the dev-server, when an app
|
|
107
|
+
// truly registers zero write handlers) means it — that's real data,
|
|
108
|
+
// not a missing-manifest gap, so it clears the block like before.
|
|
109
|
+
// Only the manifest fallback coming back empty is ambiguous enough to
|
|
110
|
+
// warrant carrying the previous block forward instead of dropping it.
|
|
111
|
+
const shouldPreserveStaleBlock = !hasHandlerQns && usingManifestFallback;
|
|
112
|
+
const finalTypesContent = shouldPreserveStaleBlock
|
|
113
|
+
? withPreservedBlock(
|
|
114
|
+
typesContent,
|
|
115
|
+
typesPath,
|
|
116
|
+
WRITE_HANDLER_QN_MARKER,
|
|
117
|
+
LEGACY_WRITE_HANDLER_QN_ANCHOR,
|
|
118
|
+
)
|
|
119
|
+
: `${typesContent}${renderWriteHandlerTypes(handlerQns)}`;
|
|
120
|
+
const finalDefineContent = shouldPreserveStaleBlock
|
|
121
|
+
? withPreservedBlock(
|
|
122
|
+
defineContent,
|
|
123
|
+
definePath,
|
|
124
|
+
TYPED_DISPATCHER_MARKER,
|
|
125
|
+
LEGACY_TYPED_DISPATCHER_ANCHOR,
|
|
126
|
+
)
|
|
127
|
+
: defineContent;
|
|
96
128
|
|
|
97
129
|
const didWriteTypes = writeIfChanged(typesPath, finalTypesContent);
|
|
98
|
-
const didWriteDefine = writeIfChanged(definePath,
|
|
130
|
+
const didWriteDefine = writeIfChanged(definePath, finalDefineContent);
|
|
99
131
|
writeIfChanged(packageJsonPath, packageJsonContent);
|
|
100
132
|
const didWriteSchemas =
|
|
101
133
|
schemasContent !== undefined
|
|
102
134
|
? writeIfChanged(schemasPath, schemasContent)
|
|
103
135
|
: removeIfExists(schemasPath);
|
|
104
136
|
|
|
137
|
+
const warnings: readonly ScanWarning[] = shouldPreserveStaleBlock
|
|
138
|
+
? [
|
|
139
|
+
...scan.warnings,
|
|
140
|
+
{
|
|
141
|
+
message:
|
|
142
|
+
"No feature-manifest.json found — the WriteHandlerQn/TypedDispatcher block could not be determined from here; run the dev-server to get the exact handler list.",
|
|
143
|
+
},
|
|
144
|
+
]
|
|
145
|
+
: scan.warnings;
|
|
146
|
+
|
|
105
147
|
return {
|
|
106
148
|
outputDir,
|
|
107
149
|
eventCount: scan.events.length,
|
|
108
|
-
warnings
|
|
150
|
+
warnings,
|
|
109
151
|
didWriteTypes,
|
|
110
152
|
didWriteSchemas,
|
|
111
153
|
didWriteDefine,
|
|
@@ -151,17 +193,55 @@ function renderKumikoPackageJson(): string {
|
|
|
151
193
|
* happen on every mtime change).
|
|
152
194
|
*/
|
|
153
195
|
function writeIfChanged(path: string, content: string): boolean {
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
existing = readFileSync(path, "utf-8");
|
|
157
|
-
} catch {
|
|
158
|
-
existing = undefined;
|
|
159
|
-
}
|
|
196
|
+
const existing = readExistingOrUndefined(path);
|
|
160
197
|
if (existing === content) return false;
|
|
161
198
|
writeFileSync(path, content, "utf-8");
|
|
162
199
|
return true;
|
|
163
200
|
}
|
|
164
201
|
|
|
202
|
+
function readExistingOrUndefined(path: string): string | undefined {
|
|
203
|
+
try {
|
|
204
|
+
return readFileSync(path, "utf-8");
|
|
205
|
+
} catch {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Pre-marker renderer output has no WRITE_HANDLER_QN_MARKER/TYPED_DISPATCHER_MARKER
|
|
211
|
+
// line to find — a file generated before this fix still needs to round-trip.
|
|
212
|
+
// Each anchor is the first line of its block under the old renderer, so slicing
|
|
213
|
+
// from the preceding newline recovers the same block the marker path recovers.
|
|
214
|
+
const LEGACY_WRITE_HANDLER_QN_ANCHOR = `export type WriteHandlerQn =`;
|
|
215
|
+
const LEGACY_TYPED_DISPATCHER_ANCHOR = `import type { Dispatcher, WriteOpts, WriteResult } from "@cosmicdrift/kumiko-headless";`;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Appends whichever handler-derived block a previous run left behind
|
|
219
|
+
* in `existingPath`, found via its start-of-block `marker` (or, for a
|
|
220
|
+
* file generated before markers existed, `legacyAnchor`). Used when
|
|
221
|
+
* the current run has no `handlerQns` of its own — the block can't be
|
|
222
|
+
* re-derived, so the only options are "delete it" (the #2757 bug) or
|
|
223
|
+
* "leave it exactly as it was". The marker is always emitted as the
|
|
224
|
+
* last thing its renderer produces, so slicing from it to EOF recovers
|
|
225
|
+
* the whole block regardless of how many times this preserve-path runs
|
|
226
|
+
* in a row.
|
|
227
|
+
*/
|
|
228
|
+
function withPreservedBlock(
|
|
229
|
+
freshContent: string,
|
|
230
|
+
existingPath: string,
|
|
231
|
+
marker: string,
|
|
232
|
+
legacyAnchor: string,
|
|
233
|
+
): string {
|
|
234
|
+
const existing = readExistingOrUndefined(existingPath);
|
|
235
|
+
const preserved = extractBlock(existing, marker) ?? extractBlock(existing, legacyAnchor);
|
|
236
|
+
return preserved !== undefined ? `${freshContent}${preserved}` : freshContent;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function extractBlock(content: string | undefined, anchor: string): string | undefined {
|
|
240
|
+
if (content === undefined) return undefined;
|
|
241
|
+
const idx = content.indexOf(`\n${anchor}`);
|
|
242
|
+
return idx === -1 ? undefined : content.slice(idx);
|
|
243
|
+
}
|
|
244
|
+
|
|
165
245
|
/**
|
|
166
246
|
* Remove a previously-generated file when the latest scan no longer
|
|
167
247
|
* produces it (e.g. the last inline-schema was refactored to a named
|
|
@@ -91,8 +91,10 @@ export type SchemaSource =
|
|
|
91
91
|
};
|
|
92
92
|
|
|
93
93
|
export type ScanWarning = {
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
/** Absent for warnings that aren't tied to a scanned source line
|
|
95
|
+
* (e.g. a codegen-level warning about a missing manifest file). */
|
|
96
|
+
readonly file?: string;
|
|
97
|
+
readonly line?: number;
|
|
96
98
|
readonly message: string;
|
|
97
99
|
};
|
|
98
100
|
|
|
@@ -101,6 +103,13 @@ export type ScanResult = {
|
|
|
101
103
|
readonly warnings: readonly ScanWarning[];
|
|
102
104
|
};
|
|
103
105
|
|
|
106
|
+
/** Renders a `ScanWarning` for terminal output; omits the location prefix when the warning has no source line. */
|
|
107
|
+
export function formatScanWarning(warning: ScanWarning): string {
|
|
108
|
+
return warning.file !== undefined
|
|
109
|
+
? `${warning.file}:${warning.line} — ${warning.message}`
|
|
110
|
+
: warning.message;
|
|
111
|
+
}
|
|
112
|
+
|
|
104
113
|
export type ScanOptions = {
|
|
105
114
|
/** App-Wurzel — alles unter `<root>/src` wird gescannt. Tests +
|
|
106
115
|
* generated-files (`.kumiko`, `dist*`, `node_modules`) sind raus. */
|
package/src/codegen/watch.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { type FSWatcher, watch } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { runCodegen } from "./run-codegen";
|
|
20
|
+
import { formatScanWarning } from "./scan-events";
|
|
20
21
|
|
|
21
22
|
export type WatchOptions = {
|
|
22
23
|
/** App-Wurzel — gleiche Bedeutung wie für `runCodegen`. */
|
|
@@ -74,7 +75,7 @@ export function watchAndRegenerate(opts: WatchOptions): WatchHandle {
|
|
|
74
75
|
if (result.warnings.length > 0) {
|
|
75
76
|
for (const w of result.warnings) {
|
|
76
77
|
// biome-ignore lint/suspicious/noConsole: codegen-watcher logs to terminal
|
|
77
|
-
console.warn(`[codegen] ${w
|
|
78
|
+
console.warn(`[codegen] ${formatScanWarning(w)}`);
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
}
|