Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/commands/doctor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,34 @@ 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"));
}
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;
Expand Down
60 changes: 57 additions & 3 deletions src/commands/view.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = [
"用法:",
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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 <slug>", "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,
Expand Down
89 changes: 88 additions & 1 deletion src/views/schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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 });
Expand Down
35 changes: 35 additions & 0 deletions tests/doctor-coverage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,41 @@ test("the corpus shape check catches a multi-speaker corpus nobody can be cut ou
}
});

test("doctor fails when views/<slug>.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 {
Expand Down
2 changes: 0 additions & 2 deletions tests/gates/baseline/audit-tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>.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
72 changes: 72 additions & 0 deletions tests/views.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) }));
});
Loading