Przeglądaj źródła

test(v13): cover Phase 5 hotfix edge cases [phase 5 hotfix 4]

Adds regression tests for all three hotfixes.

Studio (packages/studio/src/api/phase5-hotfix.test.ts, 8 tests):
- GET outline/story_frame.md serves the authoritative file (no legacy flag)
- GET roles/主要角色/<name>.md serves role sheets
- GET story_bible.md / book_rules.md returns legacy: true
- Path traversal (`..` sequences) returns 400
- Non-whitelisted flat / outline / roles paths return 400
- PUT legacy shim (book_rules.md) is refused with a clear error
- PUT outline/story_frame.md writes successfully
- Truth browser lists outline/* and roles/**\/*.md entries and flags shims

Core (packages/core/src/__tests__/phase5-hotfix.test.ts, 9 tests):
Hotfix 2 — body semantic decoupling:
- Story-frame source yields empty ParsedBookRules.body (prose not leaked)
- Legacy book_rules.md body preserved verbatim (byte-identical)
- planner-context readBookRules no longer duplicates story_frame prose

Hotfix 3 — broken frontmatter fallback:
- tryParseBookRulesFrontmatter returns null on missing frontmatter
- ... returns null on malformed YAML and invokes onError
- ... returns parsed rules on valid YAML
- readBookRules falls back to legacy book_rules.md on broken story_frame
- readBookRules returns null (no silent defaults) when broken story_frame
  has no legacy fallback, with a warning logged
- readBookRules prefers valid story_frame over legacy (priority intact)
Ma 6 miesięcy temu
rodzic
commit
b5225af284

+ 258 - 0
packages/core/src/__tests__/phase5-hotfix.test.ts

@@ -0,0 +1,258 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { readBookRules } from "../agents/rules-reader.js";
+import { readBookRules as readPlannerBookRules } from "../agents/planner-context.js";
+import { tryParseBookRulesFrontmatter } from "../models/book-rules.js";
+
+// ---------------------------------------------------------------------------
+// Phase 5 hotfix 2 — ParsedBookRules.body must no longer contain story_frame
+// prose; it should only hold narrow narrative rules from the legacy
+// book_rules.md file (if any).
+// ---------------------------------------------------------------------------
+
+describe("Phase 5 hotfix 2 — bookRules.body decoupling", () => {
+  let bookDir: string;
+
+  beforeEach(async () => {
+    bookDir = await mkdtemp(join(tmpdir(), "inkos-hotfix2-"));
+  });
+
+  afterEach(async () => {
+    await rm(bookDir, { recursive: true, force: true });
+    vi.restoreAllMocks();
+  });
+
+  it("readBookRules() yields empty body when the source is story_frame.md", async () => {
+    // New-layout book: YAML frontmatter + prose outline essay on story_frame.md
+    const storyDir = join(bookDir, "story");
+    await mkdir(join(storyDir, "outline"), { recursive: true });
+    await writeFile(
+      join(storyDir, "outline/story_frame.md"),
+      [
+        "---",
+        "version: \"1.0\"",
+        "protagonist:",
+        "  name: 主角甲",
+        "  personalityLock: [沉默]",
+        "  behavioralConstraints: []",
+        "prohibitions:",
+        "  - 禁止美化暴力",
+        "---",
+        "",
+        "## 主题与基调",
+        "这是 story_frame 的散文正文,不应该被当作 book rules body。",
+        "## 主角弧线",
+        "从 A 到 B 的旅程。",
+      ].join("\n"),
+      "utf-8",
+    );
+
+    const parsed = await readBookRules(bookDir);
+    expect(parsed).not.toBeNull();
+    expect(parsed?.rules.protagonist?.name).toBe("主角甲");
+    expect(parsed?.rules.prohibitions).toEqual(["禁止美化暴力"]);
+    // The prose MUST NOT leak into body.
+    expect(parsed?.body).toBe("");
+  });
+
+  it("readBookRules() preserves legacy book_rules.md body for pre-cleanup books (byte-identical)", async () => {
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+    const legacyBody = "## 叙事视角\n第三人称单一视角。\n\n## 语言风格\n冷静克制。";
+    await writeFile(
+      join(storyDir, "book_rules.md"),
+      [
+        "---",
+        "version: \"1.0\"",
+        "protagonist:",
+        "  name: LegacyHero",
+        "  personalityLock: []",
+        "  behavioralConstraints: []",
+        "---",
+        "",
+        legacyBody,
+      ].join("\n"),
+      "utf-8",
+    );
+
+    const parsed = await readBookRules(bookDir);
+    expect(parsed?.rules.protagonist?.name).toBe("LegacyHero");
+    // Legacy body is preserved — this is critical for the reviser/continuity
+    // style_guide fallback chain.
+    expect(parsed?.body).toBe(legacyBody);
+  });
+
+  it("planner-context readBookRules does NOT paste story_frame prose (no duplication)", async () => {
+    const storyDir = join(bookDir, "story");
+    await mkdir(join(storyDir, "outline"), { recursive: true });
+    await writeFile(
+      join(storyDir, "outline/story_frame.md"),
+      [
+        "---",
+        "version: \"1.0\"",
+        "protagonist:",
+        "  name: 林辞",
+        "  personalityLock: [沉默]",
+        "  behavioralConstraints: []",
+        "prohibitions:",
+        "  - 不得神化主角",
+        "---",
+        "",
+        "## 主题与基调",
+        "独家的五段散文正文内容,planner 不应重复注入。",
+      ].join("\n"),
+      "utf-8",
+    );
+
+    const rendered = await readPlannerBookRules(storyDir);
+    expect(rendered).toContain("林辞");
+    expect(rendered).toContain("不得神化主角");
+    // The story_frame prose must NOT be duplicated via book rules body.
+    expect(rendered).not.toContain("独家的五段散文正文内容");
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Phase 5 hotfix 3 — broken YAML frontmatter on story_frame.md must not
+// silently zero out rules; fall back to legacy book_rules.md if present.
+// ---------------------------------------------------------------------------
+
+describe("Phase 5 hotfix 3 — broken frontmatter fallback", () => {
+  let bookDir: string;
+  let warnSpy: ReturnType<typeof vi.spyOn>;
+
+  beforeEach(async () => {
+    bookDir = await mkdtemp(join(tmpdir(), "inkos-hotfix3-"));
+    warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { /* suppress */ });
+  });
+
+  afterEach(async () => {
+    await rm(bookDir, { recursive: true, force: true });
+    vi.restoreAllMocks();
+  });
+
+  it("tryParseBookRulesFrontmatter returns null on missing frontmatter", () => {
+    const result = tryParseBookRulesFrontmatter("# just markdown, no yaml\n\nhello");
+    expect(result).toBeNull();
+  });
+
+  it("tryParseBookRulesFrontmatter returns null on malformed YAML and invokes onError", () => {
+    const onError = vi.fn();
+    const result = tryParseBookRulesFrontmatter(
+      "---\nprotagonist: {name: 主角\nno_close_brace: true\n---\n",
+      onError,
+    );
+    expect(result).toBeNull();
+    expect(onError).toHaveBeenCalledOnce();
+  });
+
+  it("tryParseBookRulesFrontmatter returns parsed rules on valid YAML", () => {
+    const result = tryParseBookRulesFrontmatter(
+      "---\nversion: \"1.0\"\nprotagonist:\n  name: X\n  personalityLock: []\n  behavioralConstraints: []\nprohibitions: []\n---\n",
+    );
+    expect(result).not.toBeNull();
+    expect(result?.rules.protagonist?.name).toBe("X");
+    expect(result?.body).toBe("");
+  });
+
+  it("readBookRules falls back to legacy book_rules.md when story_frame frontmatter is broken", async () => {
+    const storyDir = join(bookDir, "story");
+    await mkdir(join(storyDir, "outline"), { recursive: true });
+    // Broken YAML (unterminated flow sequence)
+    await writeFile(
+      join(storyDir, "outline/story_frame.md"),
+      "---\nprotagonist:\n  name: [broken\n  personalityLock: oops\n---\n\n# prose\n",
+      "utf-8",
+    );
+    // Valid legacy rules
+    await writeFile(
+      join(storyDir, "book_rules.md"),
+      [
+        "---",
+        "version: \"1.0\"",
+        "protagonist:",
+        "  name: LegacyHero",
+        "  personalityLock: [stoic]",
+        "  behavioralConstraints: []",
+        "prohibitions:",
+        "  - No lazy tropes",
+        "---",
+        "",
+        "## 叙事视角",
+        "legacy body content",
+      ].join("\n"),
+      "utf-8",
+    );
+
+    const parsed = await readBookRules(bookDir);
+    expect(parsed?.rules.protagonist?.name).toBe("LegacyHero");
+    expect(parsed?.rules.prohibitions).toEqual(["No lazy tropes"]);
+    expect(parsed?.body).toContain("legacy body content");
+    // Warning was logged
+    expect(warnSpy).toHaveBeenCalled();
+    const warnMessage = String(warnSpy.mock.calls[0]?.[0] ?? "");
+    expect(warnMessage).toMatch(/story_frame\.md frontmatter is malformed/);
+  });
+
+  it("readBookRules with broken frontmatter AND no legacy returns null (with warning)", async () => {
+    const storyDir = join(bookDir, "story");
+    await mkdir(join(storyDir, "outline"), { recursive: true });
+    await writeFile(
+      join(storyDir, "outline/story_frame.md"),
+      "---\nprotagonist: [unterminated\n---\n",
+      "utf-8",
+    );
+
+    const parsed = await readBookRules(bookDir);
+    // No legacy source, no valid frontmatter → null (caller provides defaults
+    // at runtime via `?.rules ?? null`). Critically: rules were NOT silently
+    // zeroed and a warning WAS logged.
+    expect(parsed).toBeNull();
+    expect(warnSpy).toHaveBeenCalled();
+  });
+
+  it("readBookRules prefers story_frame.md frontmatter over legacy when BOTH are valid", async () => {
+    const storyDir = join(bookDir, "story");
+    await mkdir(join(storyDir, "outline"), { recursive: true });
+    // Valid story_frame frontmatter
+    await writeFile(
+      join(storyDir, "outline/story_frame.md"),
+      [
+        "---",
+        "version: \"1.0\"",
+        "protagonist:",
+        "  name: NewHero",
+        "  personalityLock: []",
+        "  behavioralConstraints: []",
+        "---",
+        "",
+        "## prose",
+      ].join("\n"),
+      "utf-8",
+    );
+    // Legacy file also present with different name — must be IGNORED
+    await writeFile(
+      join(storyDir, "book_rules.md"),
+      [
+        "---",
+        "version: \"1.0\"",
+        "protagonist:",
+        "  name: OldHero",
+        "  personalityLock: []",
+        "  behavioralConstraints: []",
+        "---",
+        "",
+        "legacy body",
+      ].join("\n"),
+      "utf-8",
+    );
+
+    const parsed = await readBookRules(bookDir);
+    expect(parsed?.rules.protagonist?.name).toBe("NewHero");
+    expect(parsed?.body).toBe(""); // story_frame source → empty body
+    // No warning — story_frame was valid.
+    expect(warnSpy).not.toHaveBeenCalled();
+  });
+});

+ 258 - 0
packages/studio/src/api/phase5-hotfix.test.ts

@@ -0,0 +1,258 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+// ---------------------------------------------------------------------------
+// Phase 5 hotfix 1 — Studio truth-file endpoints must reach the authoritative
+// outline/* and roles/** paths, refuse path traversal, and surface legacy
+// compat shims as read-only (legacy: true).
+// ---------------------------------------------------------------------------
+
+const createLLMClientMock = vi.fn(() => ({}));
+const chatCompletionMock = vi.fn();
+const loadProjectConfigMock = vi.fn();
+const logger = {
+  child: () => logger,
+  info: vi.fn(),
+  warn: vi.fn(),
+  error: vi.fn(),
+};
+
+vi.mock("@actalk/inkos-core", () => {
+  class MockStateManager {
+    constructor(private readonly root: string) {}
+    async listBooks(): Promise<string[]> {
+      return [];
+    }
+    async loadBookConfig(): Promise<never> {
+      throw new Error("not implemented");
+    }
+    async loadChapterIndex(): Promise<[]> {
+      return [];
+    }
+    async getNextChapterNumber(): Promise<number> {
+      return 1;
+    }
+    bookDir(id: string): string {
+      return join(this.root, "books", id);
+    }
+  }
+
+  class MockPipelineRunner {
+    constructor(_config: unknown) {}
+    initBook = vi.fn();
+    runRadar = vi.fn();
+  }
+
+  class MockScheduler {
+    constructor(_config: unknown) {}
+    async start(): Promise<void> { /* noop */ }
+    stop(): void { /* noop */ }
+    get isRunning(): boolean { return false; }
+  }
+
+  return {
+    StateManager: MockStateManager,
+    PipelineRunner: MockPipelineRunner,
+    Scheduler: MockScheduler,
+    createLLMClient: createLLMClientMock,
+    createLogger: vi.fn(() => logger),
+    computeAnalytics: vi.fn(() => ({})),
+    chatCompletion: chatCompletionMock,
+    loadProjectConfig: loadProjectConfigMock,
+    GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
+  };
+});
+
+const projectConfig = {
+  name: "studio-hotfix-test",
+  version: "0.1.0",
+  language: "zh" as const,
+  llm: {
+    provider: "openai",
+    baseUrl: "https://api.example.com/v1",
+    apiKey: "sk-test",
+    model: "gpt-5.4",
+    temperature: 0.7,
+    maxTokens: 4096,
+    stream: false,
+  },
+  daemon: {
+    schedule: { radarCron: "0 */6 * * *", writeCron: "*/15 * * * *" },
+    maxConcurrentBooks: 1,
+    chaptersPerCycle: 1,
+    retryDelayMs: 30000,
+    cooldownAfterChapterMs: 0,
+    maxChaptersPerDay: 50,
+  },
+  modelOverrides: {},
+  notify: [],
+};
+
+function cloneProjectConfig(): typeof projectConfig {
+  return structuredClone(projectConfig);
+}
+
+describe("Phase 5 hotfix 1 — Studio truth file endpoints", () => {
+  let root: string;
+  let bookDir: string;
+  let storyDir: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "studio-hotfix-"));
+    await writeFile(join(root, "inkos.json"), JSON.stringify(projectConfig, null, 2), "utf-8");
+    bookDir = join(root, "books", "hotfix-book");
+    storyDir = join(bookDir, "story");
+    await mkdir(join(storyDir, "outline"), { recursive: true });
+    await mkdir(join(storyDir, "roles/主要角色"), { recursive: true });
+    await mkdir(join(storyDir, "roles/次要角色"), { recursive: true });
+    loadProjectConfigMock.mockReset();
+    loadProjectConfigMock.mockResolvedValue(cloneProjectConfig());
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("serves outline/story_frame.md content (Phase 5 authoritative path)", async () => {
+    await writeFile(
+      join(storyDir, "outline/story_frame.md"),
+      "---\nversion: \"1.0\"\n---\n\n# Frame prose",
+      "utf-8",
+    );
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request(
+      "http://localhost/api/books/hotfix-book/truth/outline/story_frame.md",
+    );
+    expect(response.status).toBe(200);
+    const body = await response.json() as { file: string; content: string; legacy?: boolean };
+    expect(body.file).toBe("outline/story_frame.md");
+    expect(body.content).toContain("# Frame prose");
+    expect(body.legacy).toBeUndefined();
+  });
+
+  it("serves roles/主要角色/<name>.md content", async () => {
+    await writeFile(
+      join(storyDir, "roles/主要角色/主角甲.md"),
+      "# 主角甲\n核心标签:沉默",
+      "utf-8",
+    );
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request(
+      "http://localhost/api/books/hotfix-book/truth/roles/主要角色/主角甲.md",
+    );
+    expect(response.status).toBe(200);
+    const body = await response.json() as { file: string; content: string };
+    expect(body.content).toContain("核心标签");
+  });
+
+  it("tags legacy shim files (story_bible.md, book_rules.md) with legacy: true on GET", async () => {
+    await writeFile(join(storyDir, "story_bible.md"), "# Legacy bible shim", "utf-8");
+    await writeFile(join(storyDir, "book_rules.md"), "# Legacy rules shim", "utf-8");
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    for (const file of ["story_bible.md", "book_rules.md"]) {
+      const res = await app.request(`http://localhost/api/books/hotfix-book/truth/${file}`);
+      expect(res.status).toBe(200);
+      const body = await res.json() as { legacy?: boolean };
+      expect(body.legacy).toBe(true);
+    }
+  });
+
+  it("rejects path traversal attempts (..)", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    // Encoded form — the :file{.+} pattern accepts this; resolveTruthFilePath
+    // must reject the resolved path regardless.
+    const response = await app.request(
+      "http://localhost/api/books/hotfix-book/truth/outline/..%2F..%2Fetc%2Fpasswd",
+    );
+    expect(response.status).toBe(400);
+    await expect(response.json()).resolves.toEqual({ error: "Invalid truth file" });
+  });
+
+  it("rejects files outside the whitelist", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    // Unknown flat file
+    const response1 = await app.request(
+      "http://localhost/api/books/hotfix-book/truth/random.md",
+    );
+    expect(response1.status).toBe(400);
+
+    // Unknown subdir
+    const response2 = await app.request(
+      "http://localhost/api/books/hotfix-book/truth/outline/unknown.md",
+    );
+    expect(response2.status).toBe(400);
+
+    // roles/ with unknown tier
+    const response3 = await app.request(
+      "http://localhost/api/books/hotfix-book/truth/roles/其他/x.md",
+    );
+    expect(response3.status).toBe(400);
+  });
+
+  it("refuses to PUT legacy shim files (story_bible.md / book_rules.md)", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request(
+      "http://localhost/api/books/hotfix-book/truth/book_rules.md",
+      {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ content: "# new" }),
+      },
+    );
+    expect(response.status).toBe(400);
+    const body = await response.json() as { error: string };
+    expect(body.error).toMatch(/Legacy compat shim/);
+  });
+
+  it("writes outline/story_frame.md via PUT", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request(
+      "http://localhost/api/books/hotfix-book/truth/outline/story_frame.md",
+      {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ content: "---\nversion: \"1.0\"\n---\n\n# Updated" }),
+      },
+    );
+    expect(response.status).toBe(200);
+    const saved = await readFile(join(storyDir, "outline/story_frame.md"), "utf-8");
+    expect(saved).toContain("# Updated");
+  });
+
+  it("lists outline/* and roles/**/*.md files in the truth browser", async () => {
+    await writeFile(join(storyDir, "outline/story_frame.md"), "frame", "utf-8");
+    await writeFile(join(storyDir, "outline/volume_map.md"), "map", "utf-8");
+    await writeFile(join(storyDir, "roles/主要角色/主角甲.md"), "major", "utf-8");
+    await writeFile(join(storyDir, "roles/次要角色/朋友乙.md"), "minor", "utf-8");
+    await writeFile(join(storyDir, "book_rules.md"), "shim", "utf-8");
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/books/hotfix-book/truth");
+    expect(response.status).toBe(200);
+    const body = await response.json() as { files: ReadonlyArray<{ name: string; legacy?: true }> };
+    const names = body.files.map((f) => f.name).sort();
+    expect(names).toContain("outline/story_frame.md");
+    expect(names).toContain("outline/volume_map.md");
+    expect(names).toContain("roles/主要角色/主角甲.md");
+    expect(names).toContain("roles/次要角色/朋友乙.md");
+    const shimEntry = body.files.find((f) => f.name === "book_rules.md");
+    expect(shimEntry?.legacy).toBe(true);
+  });
+});