From 8784cac5bd15b47ecc1d9511eee637d77d8baf20 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 22 Sep 2026 11:21:53 +0000 Subject: [PATCH] fix(view): reject anchors absent from knowledge/index.json view check and view render fail with view/orphan-anchor when a cited id is missing from the ledger. doctor JSON-parses view.json anchors[]. --- src/commands/doctor.mjs | 22 ++++++- src/commands/view.mjs | 60 ++++++++++++++++++- src/views/schema.mjs | 89 +++++++++++++++++++++++++++- tests/doctor-coverage.test.mjs | 35 +++++++++++ tests/gates/baseline/audit-tests.txt | 2 - tests/views.test.mjs | 72 ++++++++++++++++++++++ 6 files changed, 273 insertions(+), 7 deletions(-) diff --git a/src/commands/doctor.mjs b/src/commands/doctor.mjs index f7911f76..6b355abe 100644 --- a/src/commands/doctor.mjs +++ b/src/commands/doctor.mjs @@ -107,6 +107,20 @@ function deliveredAnchors(skillDir) { const collect = (text) => { for (const match of String(text).matchAll(/\[(k\d{4}(?::t\d+)?)\]/g)) found.add(match[1]); }; + const collectView = (value) => { + if (Array.isArray(value)) { + for (const item of value) collectView(item); + return; + } + if (!value || typeof value !== "object") return; + if (Array.isArray(value.anchors)) { + for (const item of value.anchors) if (typeof item === "string" && item) found.add(item); + } + for (const [key, child] of Object.entries(value)) { + if (key === "anchors") continue; + collectView(child); + } + }; for (const name of ["SKILL.md", "work.md", "persona.md", "work_skill.md", "persona_skill.md"]) { const path = join(skillDir, name); if (existsSync(path)) collect(readFileSync(path, "utf8")); @@ -114,7 +128,13 @@ function deliveredAnchors(skillDir) { const viewsDir = join(skillDir, "views"); if (existsSync(viewsDir)) { for (const name of readdirSync(viewsDir)) { - if (name.endsWith(".view.json")) collect(readFileSync(join(viewsDir, name), "utf8")); + if (!name.endsWith(".view.json")) continue; + const text = readFileSync(join(viewsDir, name), "utf8"); + try { + collectView(JSON.parse(text)); + } catch { + collect(text); + } } } return found; diff --git a/src/commands/view.mjs b/src/commands/view.mjs index d181d151..68c3f17c 100644 --- a/src/commands/view.mjs +++ b/src/commands/view.mjs @@ -9,11 +9,12 @@ * diagnostics) rather than thrown, so `--json` always yields one object. */ -import { relative, resolve } from "node:path"; +import { existsSync, readdirSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; import { register } from "./index.mjs"; import { CliError, createReceipt } from "../cli/receipt.mjs"; -import { checkView, expectedSlug, formatDiagnostic, normalizeView } from "../views/schema.mjs"; +import { checkView, expectedSlug, formatDiagnostic, normalizeView, readLedgerAnchorSet } from "../views/schema.mjs"; import { ViewError, findViewDocuments, loadViewDocument, renderView } from "../views/render.mjs"; const OPTIONS = { @@ -75,6 +76,35 @@ function resolveViewPath(options) { return candidates[0]; } +function findLedgerPath(options, viewPath) { + const candidates = []; + const consider = (candidate) => { + if (candidate && !candidates.includes(candidate)) candidates.push(candidate); + }; + consider(join(dirname(dirname(viewPath)), "knowledge", "index.json")); + const root = resolve(options.root); + consider(join(root, "knowledge", "index.json")); + if (options.slug) { + const skillsRoot = join(root, "skills"); + if (existsSync(skillsRoot)) { + for (const family of readdirSync(skillsRoot)) { + consider(join(skillsRoot, family, options.slug, "knowledge", "index.json")); + } + } + } + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +function ledgerCheckOptions(options, viewPath) { + const ledgerPath = findLedgerPath(options, viewPath); + if (ledgerPath) return { ledgerAnchors: readLedgerAnchorSet(ledgerPath) ?? new Set() }; + if (options.file) return { ledgerAbsent: true }; + return {}; +} + function helpFor(sub) { const zh = [ "用法:", @@ -106,7 +136,12 @@ const checkCommand = { const options = parseViewArgs(argv); const viewPath = resolveViewPath(options); const document = loadViewDocument(viewPath); - const report = checkView(document.view, { viewPath: document.path, shareable: options.shareable, allowMissing: options.allowMissing }); + const report = checkView(document.view, { + viewPath: document.path, + shareable: options.shareable, + allowMissing: options.allowMissing, + ...ledgerCheckOptions(options, document.path), + }); const normalized = normalizeView(document.view).view; const receipt = { ...createReceipt("view check", { @@ -140,6 +175,25 @@ const renderCommand = { run({ argv, json, reporter }) { const options = parseViewArgs(argv); const viewPath = resolveViewPath(options); + const document = loadViewDocument(viewPath); + const preview = checkView(document.view, { + viewPath: document.path, + shareable: options.shareable, + allowMissing: options.allowMissing, + ...ledgerCheckOptions(options, document.path), + }); + if (!preview.ok) { + const receipt = { + ...createReceipt("view render", { ok: false }), + diagnostics: preview.diagnostics, + supported_fixes: ["run: distilly view check ", "every diagnostic carries supportedFixes[]"], + }; + if (!json) { + reporter.error(`Error: view.json failed view check with ${preview.errors.length} error(s); fix them before rendering`); + for (const entry of preview.diagnostics) reporter.error(formatDiagnostic(entry)); + } + return { receipt, exitCode: 1 }; + } try { const result = renderView({ allowMissing: options.allowMissing, diff --git a/src/views/schema.mjs b/src/views/schema.mjs index 9654f2af..b4b05715 100644 --- a/src/views/schema.mjs +++ b/src/views/schema.mjs @@ -6,7 +6,8 @@ * * Zero runtime dependencies. */ -import { basename } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; /** The eight page segments, in page order. The eighth is derived from `evidence[]`. */ export const REQUIRED_SECTIONS = Object.freeze([ @@ -107,6 +108,65 @@ export function expectedSlug(viewPath) { return match ? match[1] : null; } +/** Every id `knowledge/index.json` declares, matching doctor's resolvedAnchors walk. */ +export function readLedgerAnchorSet(ledgerPath) { + if (!ledgerPath || !existsSync(ledgerPath)) return null; + let parsed; + try { + parsed = JSON.parse(readFileSync(ledgerPath, "utf8")); + } catch { + return null; + } + const entries = Array.isArray(parsed) ? parsed : (parsed?.entries ?? []); + const known = new Set(); + for (const entry of entries) { + if (!entry || typeof entry !== "object") continue; + if (typeof entry.id === "string") known.add(entry.id); + for (const anchor of entry.anchors ?? []) { + const id = typeof anchor === "string" ? anchor : anchor?.anchor ?? anchor?.id; + if (id) known.add(id); + } + for (const unit of entry.units ?? []) { + const id = unit?.anchor ?? unit?.id; + if (id) known.add(id); + } + for (const detail of entry.anchor_detail ?? []) { + const id = detail?.anchor ?? detail?.id; + if (id) known.add(id); + } + } + return known; +} + +function ledgerPathNearView(viewPath) { + if (!viewPath) return null; + return join(dirname(dirname(viewPath)), "knowledge", "index.json"); +} + +function ledgerSetFromOptions(options) { + if (options.ledgerAnchors instanceof Set) return options.ledgerAnchors; + if (Array.isArray(options.ledgerAnchors)) return new Set(options.ledgerAnchors); + if (options.ledgerAnchors === false) return null; + return readLedgerAnchorSet(ledgerPathNearView(options.viewPath)); +} + +/** Walk every `anchors[]` in a view document. Paths are RENDER §6 subject.path values. */ +function eachAnchorsArray(value, path, visit) { + if (Array.isArray(value)) { + value.forEach((item, index) => eachAnchorsArray(item, `${path}[${index}]`, visit)); + return; + } + if (!isPlainObject(value)) return; + if (Array.isArray(value.anchors)) { + const prefix = path ? `${path}.anchors` : "anchors"; + value.anchors.forEach((anchor, index) => visit(`${prefix}[${index}]`, anchor)); + } + for (const [key, child] of Object.entries(value)) { + if (key === "anchors") continue; + eachAnchorsArray(child, path ? `${path}.${key}` : key, visit); + } +} + class Report { constructor() { this.diagnostics = []; @@ -682,6 +742,33 @@ export function checkView(view, options = {}) { return finish(report, { shareable, sections: 0, items: 0, evidence: 0, cited: 0, anchors: 0 }); } + const known = ledgerSetFromOptions(options); + if (known) { + eachAnchorsArray(view, "", (path, anchor) => { + if (typeof anchor !== "string" || !ANCHOR_PATTERN.test(anchor)) return; + if (known.has(anchor)) return; + report.diagnostics.push({ + code: "view/orphan-anchor", + severity: "error", + message: `anchor "${anchor}" is not declared in knowledge/index.json`, + subject: { path, identity: anchor }, + evidence: { observed: anchor, ledger: "knowledge/index.json" }, + supportedFixes: [ + "cite an id that exists in knowledge/index.json", + "run distilly harvest so the ledger records the source of this claim", + ], + }); + }); + } else if (options.ledgerAbsent === true) { + report.warn( + "VIEW_LEDGER_ABSENT", + "VIEW_LEDGER_ABSENT: no knowledge/index.json beside this view; anchors were not checked against a ledger", + { path: options.viewPath ?? "view.json", identity: null }, + { observed: "absent" }, + ["pass --person or --base-dir so view check can read knowledge/index.json"], + ); + } + checkMeta(report, view.meta, slug); const evidenceIndex = checkEvidence(report, view.evidence); const sections = checkSections(report, view.sections, evidenceIndex, { allowMissing }); diff --git a/tests/doctor-coverage.test.mjs b/tests/doctor-coverage.test.mjs index 409f1e7f..6c7ea88e 100644 --- a/tests/doctor-coverage.test.mjs +++ b/tests/doctor-coverage.test.mjs @@ -212,6 +212,41 @@ test("the corpus shape check catches a multi-speaker corpus nobody can be cut ou } }); +test("doctor fails when views/.view.json cites an anchor the ledger does not declare", () => { + const { base, skillDir } = fixture(); + try { + mkdirSync(join(skillDir, "views"), { recursive: true }); + writeFileSync( + join(skillDir, "views", "demo.view.json"), + `${JSON.stringify( + { + headline: { anchors: ["k9999"] }, + sections: [ + { + claims: [{ anchors: ["k9999"] }], + items: [{ text: "悬空锚点", anchors: ["k9999"], confidence: "low" }], + points: [{ anchors: ["k9999"] }], + }, + ], + points: [{ anchors: ["k9999"] }], + }, + null, + 2, + )}\n`, + "utf8", + ); + const run = runCli(["doctor", "--base-dir", base, "--json"]); + assert.equal(run.status, 1, "a view.json citation the ledger does not declare must fail doctor"); + const receipt = parseReceipt(run.stdout); + assert.equal(receipt.ok, false); + assert.equal(receipt.verdict, "FAIL"); + assert.equal(receipt.anchors.dangling, 1, `doctor reported ${JSON.stringify(receipt.anchors)} for a view.json citing k9999`); + assert.match(receipt.warnings.join("\n"), /k9999/); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + test("too little material fails the shape check before quality is even discussed", () => { const base = shapeFixture({ anchors: 5, speakers: {} }); try { diff --git a/tests/gates/baseline/audit-tests.txt b/tests/gates/baseline/audit-tests.txt index 6aead1cb..00741fee 100644 --- a/tests/gates/baseline/audit-tests.txt +++ b/tests/gates/baseline/audit-tests.txt @@ -14,7 +14,5 @@ R21 identity.json: a handle claimed twice exits 2 with the exact message and wri R21 identity.json with a person that lists no handles is a warning, not a crash R28 a document's labels (根因:/ 时间线:) are not counted as participants in a multi-source person R24 Step 1.5: `doctor --require-shape` run after Collect and before Derive fails the C-SPAN floor corpus -R49 doctor fails when views/.view.json cites an anchor the ledger does not declare R48 doctor reports the channels this machine cannot collect from and the computer-use share R58 manifest.json must not point the host at Python installers that are not in the tree -R38 SKILL.md Step 5.1: `view check` rejects an anchor that does not exist in knowledge/index.json diff --git a/tests/views.test.mjs b/tests/views.test.mjs index 6e4bc4aa..ae0baf73 100644 --- a/tests/views.test.mjs +++ b/tests/views.test.mjs @@ -528,3 +528,75 @@ test("cli: view needs a slug or --file", () => { assert.equal(help.status, 0); assert.match(help.stdout, /## English/); }); + +const LEDGER_IDS = ["k0012", "k0014", "k0021", "k0033", "k0040:t2", "k0044", "k0051", "k0063"]; + +function writePersonWithLedger(root, { orphan = false } = {}) { + const skillDir = join(root, "skills", "colleague", "zhang-san"); + mkdirSync(join(skillDir, "knowledge"), { recursive: true }); + writeFileSync( + join(skillDir, "knowledge", "index.json"), + `${JSON.stringify([{ id: "k0001", kind: "subtitle", bytes: 10, sha256: "a".repeat(64), anchors: LEDGER_IDS }], null, 2)}\n`, + "utf8", + ); + const view = validView(); + if (orphan) { + view.sections[0].items[0].anchors = ["k9999"]; + view.evidence.push(evidenceEntry("k9999", "subtitle", "message", { quote: "不存在的锚点原文", note: "不存在的锚点" })); + } + writeFixture(root, view); + return { + skillDir, + htmlPath: join(skillDir, "views", "zhang-san.html"), + receiptPath: join(skillDir, "evidence", "renders", "receipt.json"), + }; +} + +test("a cited id missing from the ledger is view/orphan-anchor", () => { + const report = check( + (view) => { + view.sections[0].items[0].anchors = ["k9999"]; + view.evidence.push(evidenceEntry("k9999", "feishu", "message", { quote: "不存在的锚点原文" })); + }, + { ledgerAnchors: new Set(LEDGER_IDS) }, + ); + assert.equal(report.ok, false); + assert.equal(report.diagnostics[0].code, "view/orphan-anchor"); + assert.equal(report.diagnostics[0].severity, "error"); + assert.equal(report.diagnostics[0].subject.identity, "k9999"); + assert.deepEqual(Object.keys(report.diagnostics[0]).sort(), ["code", "evidence", "message", "severity", "subject", "supportedFixes"]); +}); + +test("cli: view check --json exits 1 with view/orphan-anchor when the ledger lacks the cited id", () => { + const root = tempRoot(); + writePersonWithLedger(root, { orphan: true }); + const run = runCli(["view", "check", "--person", "zhang-san", "--base-dir", root, "--json"]); + assert.equal(run.status, 1, run.stderr); + const receipt = JSON.parse(run.stdout); + assert.equal(receipt.ok, false); + assert.equal(receipt.diagnostics[0].code, "view/orphan-anchor"); + assert.equal(receipt.diagnostics[0].severity, "error"); + assert.equal(receipt.diagnostics[0].subject.identity, "k9999"); +}); + +test("cli: view render exits 1 and writes no html when a cited anchor is absent from the ledger", () => { + const root = tempRoot(); + const { htmlPath, receiptPath } = writePersonWithLedger(root, { orphan: true }); + const run = runCli(["view", "render", "--person", "zhang-san", "--base-dir", root, "--json"]); + assert.equal(run.status, 1, run.stderr); + assert.equal(existsSync(htmlPath), false); + assert.equal(existsSync(receiptPath), false); +}); + +test("cli: view check --file without a ledger warns VIEW_LEDGER_ABSENT and still exits 0", () => { + const root = tempRoot(); + const viewPath = writeFixture(root, validView()); + const run = runCli(["view", "check", "--file", viewPath, "--json"]); + assert.equal(run.status, 0, run.stderr); + const receipt = JSON.parse(run.stdout); + assert.equal(receipt.ok, true); + const warned = + receipt.warnings.some((row) => String(row).includes("VIEW_LEDGER_ABSENT")) || + receipt.diagnostics.some((row) => row.code === "VIEW_LEDGER_ABSENT"); + assert.equal(warned, true, JSON.stringify({ warnings: receipt.warnings, codes: receipt.diagnostics.map((row) => row.code) })); +});