// runCodegen — top-level entry point. Called by the dev-server (on boot, // then on every watched file-change) and by `kumiko-build` (before // bundling). // // Lifecycle: // 1. Scan `/src/**` nach r.defineEvent. // 2. Render bis zu drei Files unter `/.kumiko/`: // - types.generated.d.ts (immer wenn Events oder bestehende Datei) // - schemas.generated.ts (nur wenn ≥1 inline-Schema) // - define.ts (immer) // 3. Schreibe nur bei tatsächlicher Änderung — sonst kein touch // (TS-Sprachserver bleibt cached, Watcher feuert nicht). // 4. Wenn 0 Events UND noch kein .kumiko/ existiert: bail. Apps die // `r.defineEvent` nicht nutzen brauchen keinen Wrapper-Pfad und // kein leeres Verzeichnis. import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { renderDefineFile, renderInlineSchemasFile, renderTypesAugmentation, renderWriteHandlerTypes, TYPED_DISPATCHER_MARKER, WRITE_HANDLER_QN_MARKER, } from "./render"; import { type ScanWarning, scanEvents } from "./scan-events"; export type CodegenOptions = { /** App-Root — `/.kumiko/` ist der Output-Ordner. */ readonly appRoot: string; /** Optional: alle registrierten Write-Handler-QNs. Wenn nicht gesetzt, * versucht der Codegen `feature-manifest.json` im appRoot zu lesen. * Die Dev-Server-Integration übergibt die QNs direkt aus der gebooteten * Registry — CLI ruft sie aus dem Manifest. */ readonly handlerQns?: readonly string[]; }; export type CodegenResult = { readonly outputDir: string; readonly eventCount: number; readonly warnings: readonly ScanWarning[]; readonly didWriteTypes: boolean; readonly didWriteSchemas: boolean; readonly didWriteDefine: boolean; readonly skipped: boolean; }; export function runCodegen(opts: CodegenOptions): CodegenResult { const outputDir = join(opts.appRoot, ".kumiko"); const outputExists = existsSync(outputDir); const scan = scanEvents({ appRoot: opts.appRoot }); const typesPath = join(outputDir, "types.generated.d.ts"); const definePath = join(outputDir, "define.ts"); const schemasPath = join(outputDir, "schemas.generated.ts"); const packageJsonPath = join(outputDir, "package.json"); // Skip-Pfad: keine Events gefunden + keine bestehende Output-Dir // bedeutet die App nutzt r.defineEvent nicht (oder noch nicht). Kein // leeres `.kumiko/` zurücklassen — das hilft niemandem und produziert // false-positives in CI ("eh, was ist denn das hier"). Wenn die Dir // schon existiert (alter Run), generieren wir trotzdem — ein Refactor // der den letzten r.defineEvent löscht soll das Output dann auch // bereinigen, statt eine stale Augmentation liegen zu lassen. if (scan.events.length === 0 && !outputExists) { return { outputDir, eventCount: 0, warnings: scan.warnings, didWriteTypes: false, didWriteSchemas: false, didWriteDefine: false, skipped: true, }; } mkdirSync(outputDir, { recursive: true }); const typesContent = renderTypesAugmentation(scan.events, outputDir); // `opts.handlerQns` undefined means the caller (kumiko-build) has no // live feature registry to draw from and relies on the manifest // fallback. That fallback can't tell "no handlers exist" apart from // "the manifest doesn't exist" — both come back as an empty array — // so an empty result here is a known-lossy read, not authoritative // data the way run-dev-app's live registry is. const usingManifestFallback = opts.handlerQns === undefined; const handlerQns = opts.handlerQns ?? readHandlerQnsFromManifest(opts.appRoot); const hasHandlerQns = handlerQns.length > 0; const defineContent = renderDefineFile(handlerQns); const schemasContent = renderInlineSchemasFile(scan.events, opts.appRoot); // package.json — turns `.kumiko/` into a real installable package // named `@app/define`. Apps that declare // "@app/define": "link:./.kumiko" // in their package.json get a node_modules symlink that the runtime // (Node, Vitest, Bun) all resolve via standard module-lookup. Yarn 4 // is required — yarn classic v1 ignored deps in versionless workspaces. const packageJsonContent = renderKumikoPackageJson(); // WriteHandlerQn-Union an den types-Content anhängen (optional). Der // Dev-Server übergibt handlerQns aus der lebenden Registry (genauer); // der CLI-Fallback liest das feature-manifest.json falls vorhanden. // // Preserve only applies to the manifest-fallback path: a caller that // explicitly passes `handlerQns: []` (the dev-server, when an app // truly registers zero write handlers) means it — that's real data, // not a missing-manifest gap, so it clears the block like before. // Only the manifest fallback coming back empty is ambiguous enough to // warrant carrying the previous block forward instead of dropping it. const shouldPreserveStaleBlock = !hasHandlerQns && usingManifestFallback; const finalTypesContent = shouldPreserveStaleBlock ? withPreservedBlock( typesContent, typesPath, WRITE_HANDLER_QN_MARKER, LEGACY_WRITE_HANDLER_QN_ANCHOR, ) : `${typesContent}${renderWriteHandlerTypes(handlerQns)}`; const finalDefineContent = shouldPreserveStaleBlock ? withPreservedBlock( defineContent, definePath, TYPED_DISPATCHER_MARKER, LEGACY_TYPED_DISPATCHER_ANCHOR, ) : defineContent; const didWriteTypes = writeIfChanged(typesPath, finalTypesContent); const didWriteDefine = writeIfChanged(definePath, finalDefineContent); writeIfChanged(packageJsonPath, packageJsonContent); const didWriteSchemas = schemasContent !== undefined ? writeIfChanged(schemasPath, schemasContent) : removeIfExists(schemasPath); const warnings: readonly ScanWarning[] = shouldPreserveStaleBlock ? [ ...scan.warnings, { message: "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.", }, ] : scan.warnings; return { outputDir, eventCount: scan.events.length, warnings, didWriteTypes, didWriteSchemas, didWriteDefine, skipped: false, }; } /** * Static package.json content — turns `.kumiko/` into an installable * package called `@app/define`. The shape never depends on event-scans, * so we don't bother passing the events in. * * Two things to keep stable: * - `name: "@app/define"` matches what handler imports use. * - `exports."."` points at the wrapper, `exports."./*"` lets apps * reach into types.generated etc. via `@app/types.generated`. */ function renderKumikoPackageJson(): string { const pkg = { name: "@app/define", private: true, // license: keep the generated package out of unknown-license territory // for the License-Check guard. The repo is BUSL-1.1-licensed, the generated // wrapper inherits that — the file just re-exports framework code, // there's no original IP in `.kumiko/` worth a different license. license: "BUSL-1.1", version: "0.0.0", type: "module", main: "./define.ts", types: "./define.ts", exports: { ".": "./define.ts", "./*": "./*", }, }; return `${JSON.stringify(pkg, null, 2)}\n`; } /** * Idempotent write — only touches the file when its content actually * changed. Critical for the dev-server watcher: a no-op codegen pass * must NOT trigger a full TS-language-server rebuild (which would * happen on every mtime change). */ function writeIfChanged(path: string, content: string): boolean { const existing = readExistingOrUndefined(path); if (existing === content) return false; writeFileSync(path, content, "utf-8"); return true; } function readExistingOrUndefined(path: string): string | undefined { try { return readFileSync(path, "utf-8"); } catch { return undefined; } } // Pre-marker renderer output has no WRITE_HANDLER_QN_MARKER/TYPED_DISPATCHER_MARKER // line to find — a file generated before this fix still needs to round-trip. // Each anchor is the first line of its block under the old renderer, so slicing // from the preceding newline recovers the same block the marker path recovers. const LEGACY_WRITE_HANDLER_QN_ANCHOR = `export type WriteHandlerQn =`; const LEGACY_TYPED_DISPATCHER_ANCHOR = `import type { Dispatcher, WriteOpts, WriteResult } from "@cosmicdrift/kumiko-headless";`; /** * Appends whichever handler-derived block a previous run left behind * in `existingPath`, found via its start-of-block `marker` (or, for a * file generated before markers existed, `legacyAnchor`). Used when * the current run has no `handlerQns` of its own — the block can't be * re-derived, so the only options are "delete it" (the #2757 bug) or * "leave it exactly as it was". The marker is always emitted as the * last thing its renderer produces, so slicing from it to EOF recovers * the whole block regardless of how many times this preserve-path runs * in a row. */ function withPreservedBlock( freshContent: string, existingPath: string, marker: string, legacyAnchor: string, ): string { const existing = readExistingOrUndefined(existingPath); const preserved = extractBlock(existing, marker) ?? extractBlock(existing, legacyAnchor); return preserved !== undefined ? `${freshContent}${preserved}` : freshContent; } function extractBlock(content: string | undefined, anchor: string): string | undefined { if (content === undefined) return undefined; const idx = content.indexOf(`\n${anchor}`); return idx === -1 ? undefined : content.slice(idx); } /** * Remove a previously-generated file when the latest scan no longer * produces it (e.g. the last inline-schema was refactored to a named * export). Returns true when an actual unlink happened. */ function removeIfExists(path: string): boolean { if (!existsSync(path)) return false; rmSync(path, { force: true }); return true; } /** * Fallback: liest `feature-manifest.json` aus dem appRoot und extrahiert * alle Write-Handler-QNs. Fehlende/stale Manifeste sind kein Fehler — * dann wird schlicht keine `WriteHandlerQn`-Union generiert (CLI ohne * Manifest oder CI-Setup ohne Manifest-Generator). */ function readHandlerQnsFromManifest(appRoot: string): readonly string[] { try { const manifestPath = join(appRoot, "feature-manifest.json"); const raw = readFileSync(manifestPath, "utf-8"); const manifest = JSON.parse(raw) as { readonly features: ReadonlyArray<{ readonly writeHandlers?: readonly string[] }>; }; const qns: string[] = []; for (const f of manifest.features) { if (f.writeHandlers) qns.push(...f.writeHandlers); } qns.sort(); return qns; } catch { return []; } }