From d24597cd8969212f77bb5a1c912076b2b153c7cd Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 25 Mar 2026 14:36:19 +0800 Subject: [PATCH] fix: align node22 runtime and publish verification --- .node-version | 1 + .nvmrc | 1 + .../cli/src/__tests__/publish-package.test.ts | 99 +++++++++++++++++++ packages/core/src/pipeline/runner.ts | 23 +++++ packages/core/src/state/state-bootstrap.ts | 64 ++++++++++++ scripts/verify-no-workspace-protocol.mjs | 55 ++++++++--- 6 files changed, 232 insertions(+), 11 deletions(-) create mode 100644 .node-version create mode 100644 .nvmrc diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/packages/cli/src/__tests__/publish-package.test.ts b/packages/cli/src/__tests__/publish-package.test.ts index 178f2da..76acdda 100644 --- a/packages/cli/src/__tests__/publish-package.test.ts +++ b/packages/cli/src/__tests__/publish-package.test.ts @@ -108,6 +108,105 @@ describe.sequential("publish packaging", () => { ); }); + it("allows source workspace protocol manifests when they normalize cleanly for publish", async () => { + const tempRoot = await mkdtemp(join(tmpdir(), "inkos-publish-verify-pass-")); + const tempPackagesDir = join(tempRoot, "packages"); + const tempCoreDir = join(tempPackagesDir, "core"); + const tempCliDir = join(tempPackagesDir, "cli"); + + try { + await mkdir(tempCoreDir, { recursive: true }); + await mkdir(tempCliDir, { recursive: true }); + + await writeFile( + join(tempRoot, "package.json"), + `${JSON.stringify({ name: "inkos", version: "0.5.1" }, null, 2)}\n`, + ); + await writeFile( + join(tempCoreDir, "package.json"), + `${JSON.stringify({ name: "@actalk/inkos-core", version: "0.5.1" }, null, 2)}\n`, + ); + await writeFile( + join(tempCliDir, "package.json"), + `${JSON.stringify( + { + name: "@actalk/inkos", + version: "0.5.1", + dependencies: { + "@actalk/inkos-core": "workspace:*", + commander: "^13.0.0", + }, + }, + null, + 2, + )}\n`, + ); + + expect(() => + execFileSync( + "node", + [resolve(workspaceRoot, "scripts/verify-no-workspace-protocol.mjs"), "packages/core", "packages/cli"], + { + cwd: tempRoot, + env: process.env, + encoding: "utf-8", + stdio: "pipe", + }, + )).not.toThrow(); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("rejects workspace protocol manifests that normalize to the wrong internal version", async () => { + const tempRoot = await mkdtemp(join(tmpdir(), "inkos-publish-verify-fail-")); + const tempPackagesDir = join(tempRoot, "packages"); + const tempCoreDir = join(tempPackagesDir, "core"); + const tempCliDir = join(tempPackagesDir, "cli"); + + try { + await mkdir(tempCoreDir, { recursive: true }); + await mkdir(tempCliDir, { recursive: true }); + + await writeFile( + join(tempRoot, "package.json"), + `${JSON.stringify({ name: "inkos", version: "0.5.1" }, null, 2)}\n`, + ); + await writeFile( + join(tempCoreDir, "package.json"), + `${JSON.stringify({ name: "@actalk/inkos-core", version: "0.5.1" }, null, 2)}\n`, + ); + await writeFile( + join(tempCliDir, "package.json"), + `${JSON.stringify( + { + name: "@actalk/inkos", + version: "0.5.1", + dependencies: { + "@actalk/inkos-core": "workspace:0.5.0", + }, + }, + null, + 2, + )}\n`, + ); + + expect(() => + execFileSync( + "node", + [resolve(workspaceRoot, "scripts/verify-no-workspace-protocol.mjs"), "packages/cli"], + { + cwd: tempRoot, + env: process.env, + encoding: "utf-8", + stdio: "pipe", + }, + )).toThrow(/normalizes to 0\.5\.0, expected 0\.5\.1/); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + it("replaces workspace dependencies before npm pack", { timeout: 30_000 }, async () => { const packDir = await mkdtemp(join(tmpdir(), "inkos-cli-pack-")); diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 850726a..09aa32f 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -31,6 +31,7 @@ import { ChapterIntentSchema, type ContextPackage, type RuleStack } from "../mod import { buildLengthSpec, countChapterLength, formatLengthCount, isOutsideHardRange, isOutsideSoftRange, resolveLengthCountingMode, type LengthLanguage } from "../utils/length-metrics.js"; import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js"; import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state/runtime-state-store.js"; +import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js"; import { readFile, readdir, writeFile, mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; @@ -451,6 +452,7 @@ export class PipelineRunner { this.logStage(stageLanguage, { zh: "落盘草稿与真相文件", en: "persisting draft and truth files" }); await writer.saveChapter(bookDir, draftOutput, gp.numericalSystem, resolvedLang); await writer.saveNewTruthFiles(bookDir, draftOutput, resolvedLang); + await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, draftOutput); await this.syncNarrativeMemoryIndex(bookId); // Update index @@ -793,6 +795,7 @@ export class PipelineRunner { if (reviseOutput.updatedHooks !== "(伏笔池未更新)") { await writeFile(join(storyDir, "pending_hooks.md"), reviseOutput.updatedHooks, "utf-8"); } + await this.syncLegacyStructuredStateFromMarkdown(bookDir, targetChapter); // Update index const updatedIndex = index.map((ch) => @@ -1152,6 +1155,7 @@ export class PipelineRunner { await writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang); await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang); + await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput); this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" }); await this.syncNarrativeMemoryIndex(bookId); @@ -1549,6 +1553,7 @@ ${matrix}`, postWriteErrors: [], postWriteWarnings: [], }, resolvedLanguage); + await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, output); await this.syncNarrativeMemoryIndex(input.bookId); // Update chapter index @@ -1835,6 +1840,24 @@ ${matrix}`, } } + private async syncLegacyStructuredStateFromMarkdown( + bookDir: string, + chapterNumber: number, + output?: { + readonly runtimeStateDelta?: WriteChapterOutput["runtimeStateDelta"]; + readonly runtimeStateSnapshot?: WriteChapterOutput["runtimeStateSnapshot"]; + }, + ): Promise { + if (output?.runtimeStateDelta || output?.runtimeStateSnapshot) { + return; + } + + await rewriteStructuredStateFromMarkdown({ + bookDir, + fallbackChapter: chapterNumber, + }); + } + private async syncNarrativeMemoryIndex(bookId: string): Promise { const bookDir = this.state.bookDir(bookId); try { diff --git a/packages/core/src/state/state-bootstrap.ts b/packages/core/src/state/state-bootstrap.ts index dfab869..5e810cf 100644 --- a/packages/core/src/state/state-bootstrap.ts +++ b/packages/core/src/state/state-bootstrap.ts @@ -89,6 +89,70 @@ export async function bootstrapStructuredStateFromMarkdown(params: { }; } +export async function rewriteStructuredStateFromMarkdown(params: { + readonly bookDir: string; + readonly fallbackChapter?: number; +}): Promise { + const storyDir = join(params.bookDir, "story"); + const stateDir = join(storyDir, "state"); + const manifestPath = join(stateDir, "manifest.json"); + const currentStatePath = join(stateDir, "current_state.json"); + const hooksPath = join(stateDir, "hooks.json"); + const summariesPath = join(stateDir, "chapter_summaries.json"); + + await mkdir(stateDir, { recursive: true }); + + const warnings: string[] = []; + const existingManifest = await loadJsonIfValid(manifestPath, StateManifestSchema, warnings, "manifest.json"); + const language = existingManifest?.language ?? await resolveRuntimeLanguage(params.bookDir); + + const summariesMarkdown = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""); + const summariesState = ChapterSummariesStateSchema.parse({ + rows: parseChapterSummariesMarkdown(summariesMarkdown), + }); + + const hooksMarkdown = await readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""); + const hooksState = parsePendingHooksStateMarkdown(hooksMarkdown, warnings); + + const inferredFallbackChapter = Math.max( + params.fallbackChapter ?? 0, + maxSummaryChapter(summariesState), + maxHookChapter(hooksState.hooks), + ); + const currentStateMarkdown = await readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""); + const currentState = parseCurrentStateStateMarkdown(currentStateMarkdown, inferredFallbackChapter, warnings); + + const manifest = StateManifestSchema.parse({ + schemaVersion: 2, + language, + lastAppliedChapter: Math.max( + existingManifest?.lastAppliedChapter ?? 0, + inferredFallbackChapter, + currentState.chapter, + maxSummaryChapter(summariesState), + maxHookChapter(hooksState.hooks), + ), + projectionVersion: existingManifest?.projectionVersion ?? 1, + migrationWarnings: uniqueStrings([ + ...(existingManifest?.migrationWarnings ?? []), + ...warnings, + ]), + }); + + await Promise.all([ + writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf-8"), + writeFile(currentStatePath, JSON.stringify(currentState, null, 2), "utf-8"), + writeFile(hooksPath, JSON.stringify(hooksState, null, 2), "utf-8"), + writeFile(summariesPath, JSON.stringify(summariesState, null, 2), "utf-8"), + ]); + + return { + createdFiles: [], + warnings: manifest.migrationWarnings, + manifest, + }; +} + export function parseChapterSummariesMarkdown(markdown: string): StoredSummary[] { const rows = parseMarkdownTableRows(markdown) .filter((row) => /^\d+$/.test(row[0] ?? "")); diff --git a/scripts/verify-no-workspace-protocol.mjs b/scripts/verify-no-workspace-protocol.mjs index a8ce616..25d8118 100644 --- a/scripts/verify-no-workspace-protocol.mjs +++ b/scripts/verify-no-workspace-protocol.mjs @@ -1,15 +1,15 @@ /** - * Standalone verification for publish-time package manifests. + * Verify that source manifests are publishable once prepack normalization runs. * * Usage: * node scripts/verify-no-workspace-protocol.mjs packages/cli packages/core * node ../../scripts/verify-no-workspace-protocol.mjs . * - * Run this only after publish versions have been rewritten. + * This script is safe to run on source manifests before prepack. * * Checks two invariants before publish: - * 1. publishable dependency fields must not contain workspace: specifiers - * 2. internal workspace dependencies must match the current workspace package version exactly + * 1. workspace:* / workspace:^ / workspace:~ references can be normalized to real versions + * 2. non-workspace internal dependencies already point at the current workspace version */ import { access, readdir, readFile } from "node:fs/promises"; @@ -64,6 +64,14 @@ async function loadWorkspaceVersions(workspaceRoot) { return versions; } +function normalizeWorkspaceSpecifier(specifier, version) { + const value = specifier.slice("workspace:".length); + if (value === "*" || value === "") return version; + if (value === "^") return `^${version}`; + if (value === "~") return `~${version}`; + return value; +} + let failed = false; const workspaceRoot = await findWorkspaceRoot(process.cwd()); const workspaceVersions = await loadWorkspaceVersions(workspaceRoot); @@ -79,17 +87,42 @@ for (const dirArg of dirs) { const deps = pkg[field]; if (!deps) continue; for (const [name, specifier] of Object.entries(deps)) { - if (typeof specifier === "string" && specifier.startsWith("workspace:")) { - process.stderr.write(`FAIL: ${dir} — ${field}.${name}: ${specifier}\n`); - dirFailed = true; - failed = true; + const workspaceVersion = workspaceVersions.get(name); + if (typeof specifier !== "string") { continue; } - const workspaceVersion = workspaceVersions.get(name); - if (workspaceVersion && specifier !== workspaceVersion) { + if (specifier.startsWith("workspace:")) { + if (!workspaceVersion) { + process.stderr.write(`FAIL: ${dir} — ${field}.${name}: ${specifier} (workspace package not found)\n`); + dirFailed = true; + failed = true; + continue; + } + + const normalized = normalizeWorkspaceSpecifier(specifier, workspaceVersion); + if ( + normalized !== workspaceVersion + && normalized !== `^${workspaceVersion}` + && normalized !== `~${workspaceVersion}` + ) { + process.stderr.write( + `FAIL: ${dir} — ${field}.${name}: ${specifier} normalizes to ${normalized}, expected ${workspaceVersion}, ^${workspaceVersion}, or ~${workspaceVersion}\n`, + ); + dirFailed = true; + failed = true; + } + continue; + } + + if ( + workspaceVersion + && specifier !== workspaceVersion + && specifier !== `^${workspaceVersion}` + && specifier !== `~${workspaceVersion}` + ) { process.stderr.write( - `FAIL: ${dir} — ${field}.${name}: expected ${workspaceVersion}, got ${specifier}\n`, + `FAIL: ${dir} — ${field}.${name}: expected ${workspaceVersion}, ^${workspaceVersion}, or ~${workspaceVersion}, got ${specifier}\n`, ); dirFailed = true; failed = true;