410 lines
17 KiB
JavaScript
410 lines
17 KiB
JavaScript
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
import { join, relative, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
|
|
// `/pom resume [state-path]` is documented in the README command map and in the POM.yml
|
|
// `commands:` block, but it is deliberately absent from the HELP template literal in
|
|
// src/command.ts. HELP is canonical for what the built-in help advertises, so the verb is
|
|
// neither removed from the docs nor added to HELP. It is recorded here instead, so the
|
|
// divergence stays visible and the parity check still passes deterministically.
|
|
// Any command divergence that is not listed here is a failure.
|
|
const KNOWN_COMMAND_DIVERGENCES = new Set(["resume"]);
|
|
|
|
// Roots that src/*.ts loads through `new URL("../<root>", import.meta.url)`. Check 1 derives
|
|
// this set from source instead of hardcoding it, so a new bundled-resource root cannot be
|
|
// added in code and silently omitted from the published package.
|
|
const RESOURCE_SOURCES = ["src/prompts.ts", "src/themes.ts", "src/events.ts"];
|
|
const BUNDLED_ROOT = /new URL\(\s*["'`]\.\.\/([A-Za-z0-9._-]+)/g;
|
|
|
|
// Generated-layout names that drifted in earlier revisions and must never come back.
|
|
const FORBIDDEN_LAYOUT_NAMES = ["06_exports", "06_logs"];
|
|
const REQUIRED_LAYOUT_NAMES = ["06_ledgers", "07_exports"];
|
|
const LAYOUT_DOCS = ["docs/INVENTORY.md", "skills/pom/references/artifacts.md"];
|
|
|
|
const VERIFICATION_STATUSES = ["PASS", "FAIL", "NOT RUN", "BLOCKED"];
|
|
|
|
const MARKDOWN_LINK = /\[[^\]]*\]\(\s*([^)\s]+?)(?:\s+"[^"]*")?\s*\)/g;
|
|
const NAVIGATION_PAGES = ["index.md", "navigation.md"];
|
|
|
|
// Canonical project coordinates. These live on infrastructure the maintainer owns
|
|
// (loca.zone). There is no public forge mirror, so a plausible-looking third-party URL
|
|
// must never be substituted, and the earlier `UNRESOLVED:` placeholders must never return.
|
|
const METADATA_FIELDS = ["repository", "homepage", "bugs"];
|
|
const CANONICAL_HOST = "loca.zone";
|
|
const PLACEHOLDER_MARKER = "UNRESOLVED";
|
|
|
|
const TOTAL_CHECKS = 7;
|
|
|
|
const checks = [];
|
|
const failed = [];
|
|
|
|
function text(rel) {
|
|
return readFileSync(join(root, rel), "utf8");
|
|
}
|
|
|
|
function lineNumbers(body, pattern) {
|
|
return body
|
|
.split("\n")
|
|
.map((line, index) => (pattern.test(line) ? index + 1 : 0))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/** Blanks fenced blocks and inline code spans so illustrative links are not link-checked. */
|
|
function withoutCode(body) {
|
|
const blank = (matched) => matched.replace(/[^\n]/g, " ");
|
|
return body.replace(/^```[\s\S]*?^```/gm, blank).replace(/`[^`\n]*`/g, blank);
|
|
}
|
|
|
|
/** Reads the indented body of a top-level or nested block key, e.g. `commands:`. */
|
|
function blockLines(rel, key) {
|
|
const lines = text(rel).split("\n");
|
|
const start = lines.findIndex((line) => new RegExp(`^\\s*${key}:\\s*$`).test(line));
|
|
if (start < 0) return null;
|
|
const indent = lines[start].length - lines[start].trimStart().length;
|
|
const body = [];
|
|
for (const line of lines.slice(start + 1)) {
|
|
if (line.trim() === "") continue;
|
|
if (line.length - line.trimStart().length <= indent) break;
|
|
body.push(line.trim());
|
|
}
|
|
return body;
|
|
}
|
|
|
|
/** `/pom` -> `dashboard`; `/pom theme install` -> `theme`. */
|
|
function verbOf(command) {
|
|
const match = /^\/pom(?:\s+([a-z][a-z0-9-]*))?\b/.exec(command);
|
|
if (!match) return null;
|
|
return match[1] ?? "dashboard";
|
|
}
|
|
|
|
function check(index, name, run) {
|
|
const problems = [];
|
|
const notes = [];
|
|
try {
|
|
run((message) => problems.push(message), (message) => notes.push(message));
|
|
} catch (error) {
|
|
problems.push(`unexpected error: ${error.message}`);
|
|
}
|
|
console.log(`${problems.length === 0 ? "PASS" : "FAIL"} check ${index}/${TOTAL_CHECKS} ${name}`);
|
|
for (const problem of problems) console.log(` ${problem}`);
|
|
for (const note of notes) console.log(` note: ${note}`);
|
|
if (problems.length > 0) failed.push(name);
|
|
}
|
|
|
|
// 1. Resource/package parity: every bundled-resource root loaded by source must ship.
|
|
checks.push((index) =>
|
|
check(index, "resource-package-parity", (fail) => {
|
|
const roots = new Map();
|
|
for (const rel of RESOURCE_SOURCES) {
|
|
if (!existsSync(join(root, rel))) {
|
|
fail(`${rel}: resource-loading module is missing; check 1 cannot derive bundled roots from source`);
|
|
continue;
|
|
}
|
|
const body = text(rel);
|
|
for (const match of body.matchAll(BUNDLED_ROOT)) {
|
|
if (!roots.has(match[1])) roots.set(match[1], new Set());
|
|
roots.get(match[1]).add(rel);
|
|
}
|
|
}
|
|
if (roots.size === 0) {
|
|
fail(
|
|
`no bundled-resource roots discovered in ${RESOURCE_SOURCES.join(", ")}; the ` +
|
|
`\`new URL("../<root>", import.meta.url)\` pattern in scripts/check-docs.mjs no longer ` +
|
|
"matches source and must be repaired (this is a broken check, not an empty result)",
|
|
);
|
|
return;
|
|
}
|
|
const files = JSON.parse(text("package.json")).files;
|
|
if (!Array.isArray(files)) {
|
|
fail("package.json: `files` must be an array listing every published path");
|
|
return;
|
|
}
|
|
for (const [name, sources] of [...roots].sort(([left], [right]) => left.localeCompare(right))) {
|
|
if (!files.includes(name))
|
|
fail(`package.json: \`files\` must contain "${name}" because ${[...sources].join(" and ")} load it at runtime`);
|
|
if (!existsSync(join(root, name)))
|
|
fail(`${name}: directory is loaded by ${[...sources].join(" and ")} but does not exist in the repository`);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// 2. Command parity: HELP, the README command map, and POM.yml `commands:` must agree.
|
|
checks.push((index) =>
|
|
check(index, "command-parity", (fail, note) => {
|
|
const command = text("src/command.ts");
|
|
const help = /const HELP\s*=\s*`([\s\S]*?)`\s*;/.exec(command);
|
|
if (!help) {
|
|
fail("src/command.ts: could not locate the `const HELP = `...`;` template literal that defines the command surface");
|
|
return;
|
|
}
|
|
const helpVerbs = new Set();
|
|
for (const line of help[1].split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith("/pom")) continue;
|
|
const verb = verbOf(trimmed);
|
|
if (verb) helpVerbs.add(verb);
|
|
}
|
|
|
|
const readmeLines = text("README.md").split("\n");
|
|
const heading = readmeLines.findIndex((line) => /^##\s+Command map\s*$/.test(line));
|
|
if (heading < 0) {
|
|
fail("README.md: missing the `## Command map` heading that check 2 reads the command table from");
|
|
return;
|
|
}
|
|
const after = readmeLines.slice(heading + 1);
|
|
const end = after.findIndex((line) => /^##\s/.test(line));
|
|
const readmeVerbs = new Set();
|
|
for (const line of end < 0 ? after : after.slice(0, end)) {
|
|
if (!line.trimStart().startsWith("|")) continue;
|
|
for (const span of line.match(/`[^`]+`/g) ?? []) {
|
|
const verb = verbOf(span.slice(1, -1).trim());
|
|
if (verb) readmeVerbs.add(verb);
|
|
}
|
|
}
|
|
|
|
const commandBlock = blockLines("POM.yml", "commands");
|
|
if (!commandBlock) {
|
|
fail("POM.yml: missing the top-level `commands:` block that check 2 reads the command map from");
|
|
return;
|
|
}
|
|
const manifestVerbs = new Set();
|
|
for (const entry of commandBlock) {
|
|
const value = /:\s*(.+)$/.exec(entry);
|
|
if (!value) continue;
|
|
const verb = verbOf(value[1].trim().replace(/^["']|["']$/g, ""));
|
|
if (verb) manifestVerbs.add(verb);
|
|
}
|
|
|
|
if (helpVerbs.size === 0) fail("src/command.ts: HELP advertises no `/pom` verbs");
|
|
if (readmeVerbs.size === 0) fail("README.md: the `## Command map` table lists no `/pom` commands");
|
|
if (manifestVerbs.size === 0) fail("POM.yml: the `commands:` block lists no `/pom` commands");
|
|
|
|
const sources = [
|
|
["src/command.ts HELP", helpVerbs],
|
|
["README.md `## Command map`", readmeVerbs],
|
|
["POM.yml `commands:`", manifestVerbs],
|
|
];
|
|
const union = [...new Set(sources.flatMap(([, verbs]) => [...verbs]))].sort();
|
|
for (const verb of union) {
|
|
const missing = sources.filter(([, verbs]) => !verbs.has(verb)).map(([label]) => label);
|
|
if (missing.length === 0) continue;
|
|
if (KNOWN_COMMAND_DIVERGENCES.has(verb)) {
|
|
note(`/pom ${verb} is absent from ${missing.join(", ")}; allowlisted in KNOWN_COMMAND_DIVERGENCES`);
|
|
continue;
|
|
}
|
|
const present = sources.filter(([, verbs]) => verbs.has(verb)).map(([label]) => label);
|
|
fail(
|
|
`/pom ${verb} is documented in ${present.join(", ")} but absent from ${missing.join(", ")}; ` +
|
|
"add it there or record it in KNOWN_COMMAND_DIVERGENCES in scripts/check-docs.mjs",
|
|
);
|
|
}
|
|
for (const verb of KNOWN_COMMAND_DIVERGENCES) {
|
|
if (!union.includes(verb))
|
|
fail(
|
|
`KNOWN_COMMAND_DIVERGENCES lists "${verb}" but no source documents /pom ${verb}; ` +
|
|
"remove the stale allowlist entry from scripts/check-docs.mjs",
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// 3. Layout contract: the generated project tree names in docs must match src/persistence.ts.
|
|
checks.push((index) =>
|
|
check(index, "layout-contract", (fail) => {
|
|
for (const rel of LAYOUT_DOCS) {
|
|
if (!existsSync(join(root, rel))) {
|
|
fail(`${rel}: layout documentation is missing`);
|
|
continue;
|
|
}
|
|
const body = text(rel);
|
|
for (const name of FORBIDDEN_LAYOUT_NAMES) {
|
|
const lines = lineNumbers(body, new RegExp(`\\b${name}\\b`));
|
|
if (lines.length > 0)
|
|
fail(
|
|
`${rel}:${lines.join(",")} names "${name}", which src/persistence.ts bootstrapProject never ` +
|
|
`creates; the canonical names are ${REQUIRED_LAYOUT_NAMES.join(" and ")}`,
|
|
);
|
|
}
|
|
}
|
|
const inventory = text("docs/INVENTORY.md");
|
|
for (const name of REQUIRED_LAYOUT_NAMES) {
|
|
if (!new RegExp(`\\b${name}\\b`).test(inventory))
|
|
fail(`docs/INVENTORY.md: must document the canonical generated directory "${name}"`);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// 4. Prompt-order contract: POM.yml must mirror the composition order in src/prompts.ts.
|
|
checks.push((index) =>
|
|
check(index, "prompt-order-contract", (fail) => {
|
|
const items = blockLines("POM.yml", "prompt_precedence");
|
|
if (!items) {
|
|
fail("POM.yml: missing the `prompt_precedence:` list under `knowledge:`");
|
|
return;
|
|
}
|
|
const entries = items.filter((item) => item.startsWith("- ")).map((item) => item.slice(2).trim());
|
|
if (entries.length < 3) {
|
|
fail(`POM.yml: \`prompt_precedence\` must list the full composition order, found ${entries.length} entries`);
|
|
return;
|
|
}
|
|
const first = entries[0];
|
|
const last = entries[entries.length - 1];
|
|
if (!/immutable/i.test(first) || !/law/i.test(first))
|
|
fail(
|
|
`POM.yml: \`prompt_precedence\` entry 1 must name the immutable POM production law that ` +
|
|
`composePromptStack prepends, found "${first}"`,
|
|
);
|
|
if (!/executable stage contract/i.test(last))
|
|
fail(
|
|
`POM.yml: \`prompt_precedence\` must end with the executable stage contract appended by ` +
|
|
`renderStagePrompt, found "${last}" as the final entry`,
|
|
);
|
|
const misplaced = entries
|
|
.slice(0, -1)
|
|
.map((entry, position) => (/executable stage contract/i.test(entry) ? position + 1 : 0))
|
|
.filter(Boolean);
|
|
if (misplaced.length > 0)
|
|
fail(
|
|
`POM.yml: \`prompt_precedence\` mentions the executable stage contract at entry ` +
|
|
`${misplaced.join(",")}; renderStagePrompt appends it last, so it must be the final entry only`,
|
|
);
|
|
if (!entries.some((entry) => /replac/i.test(entry) && /\bID\b/i.test(entry)))
|
|
fail(
|
|
"POM.yml: `prompt_precedence` must state that project-local fragments replace bundled fragments " +
|
|
"by matching basename ID, as src/prompts.ts composePromptStack does",
|
|
);
|
|
}),
|
|
);
|
|
|
|
// 5. Link/orphan check: relative doc links must resolve, and every page must be reachable.
|
|
checks.push((index) =>
|
|
check(index, "link-and-orphan", (fail) => {
|
|
const pages = readdirSync(join(root, "docs"))
|
|
.filter((name) => name.endsWith(".md"))
|
|
.sort();
|
|
if (pages.length === 0) {
|
|
fail("docs: contains no Markdown pages");
|
|
return;
|
|
}
|
|
const reachable = new Set();
|
|
for (const page of pages) {
|
|
const rel = `docs/${page}`;
|
|
const body = withoutCode(text(rel));
|
|
for (const match of body.matchAll(MARKDOWN_LINK)) {
|
|
const target = match[1];
|
|
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#") || target.startsWith("/")) continue;
|
|
const path = target.split("#")[0].split("?")[0];
|
|
if (path === "") continue;
|
|
const resolved = resolve(join(root, "docs"), path);
|
|
if (NAVIGATION_PAGES.includes(page)) reachable.add(resolved);
|
|
if (!existsSync(resolved))
|
|
fail(
|
|
`${rel}: broken relative link \`${target}\` resolves to ${relative(root, resolved)}, ` +
|
|
"which does not exist on disk",
|
|
);
|
|
}
|
|
}
|
|
for (const page of pages) {
|
|
if (page === "index.md") continue;
|
|
if (reachable.has(resolve(join(root, "docs", page)))) continue;
|
|
fail(
|
|
`docs/${page}: unreachable page, no link from ${NAVIGATION_PAGES.map((name) => `docs/${name}`).join(" or ")}; ` +
|
|
"add it to the navigation tables so the wiki has no orphans",
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// 6. Release-evidence freshness: verification gates must be evidence objects, never literals.
|
|
checks.push((index) =>
|
|
check(index, "release-evidence", (fail) => {
|
|
const release = JSON.parse(text("RELEASE.json"));
|
|
const verification = release.verification;
|
|
if (verification === undefined || verification === null || typeof verification !== "object" || Array.isArray(verification)) {
|
|
fail("RELEASE.json: `verification` must be an object mapping each gate to an evidence object");
|
|
return;
|
|
}
|
|
const gates = Object.entries(verification);
|
|
if (gates.length === 0) {
|
|
fail("RELEASE.json: `verification` lists no gates");
|
|
return;
|
|
}
|
|
for (const [gate, value] of gates) {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
fail(
|
|
`RELEASE.json: verification.${gate} is the bare ${typeof value} ${JSON.stringify(value)}; every gate must ` +
|
|
`be an evidence object carrying a \`status\` of ${VERIFICATION_STATUSES.join(" | ")}, never a hard-coded literal`,
|
|
);
|
|
continue;
|
|
}
|
|
if (typeof value.status !== "string") {
|
|
fail(`RELEASE.json: verification.${gate} is missing a string \`status\` field`);
|
|
continue;
|
|
}
|
|
if (!VERIFICATION_STATUSES.includes(value.status))
|
|
fail(
|
|
`RELEASE.json: verification.${gate}.status is "${value.status}"; allowed values are ` +
|
|
`${VERIFICATION_STATUSES.join(" | ")}`,
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// 7. Package metadata: canonical coordinates must stay resolved and stay on owned infrastructure.
|
|
checks.push((index) =>
|
|
check(index, "package-metadata", (fail, note) => {
|
|
const pkg = JSON.parse(text("package.json"));
|
|
for (const field of METADATA_FIELDS) {
|
|
const value = pkg[field];
|
|
if (value === undefined) {
|
|
fail(`package.json: \`${field}\` is missing; canonical project coordinates are required`);
|
|
continue;
|
|
}
|
|
const urls = [];
|
|
if (typeof value === "string") urls.push(value);
|
|
else if (typeof value === "object" && value !== null) {
|
|
if (typeof value.url === "string") urls.push(value.url);
|
|
else fail(`package.json: \`${field}\` is an object without a string \`url\``);
|
|
} else {
|
|
fail(`package.json: \`${field}\` must be a string or an object with a \`url\``);
|
|
continue;
|
|
}
|
|
for (const url of urls) {
|
|
if (url.includes(PLACEHOLDER_MARKER)) {
|
|
fail(
|
|
`package.json: \`${field}\` still carries the ${PLACEHOLDER_MARKER} placeholder "${url}"; ` +
|
|
"set a real canonical URL before publishing",
|
|
);
|
|
continue;
|
|
}
|
|
let host;
|
|
try {
|
|
host = new URL(url.replace(/^git\+/, "")).host;
|
|
} catch {
|
|
fail(`package.json: \`${field}\` value "${url}" is not a parseable absolute URL`);
|
|
continue;
|
|
}
|
|
if (host !== CANONICAL_HOST && !host.endsWith(`.${CANONICAL_HOST}`))
|
|
fail(
|
|
`package.json: \`${field}\` points at "${host}", which is outside the canonical ${CANONICAL_HOST} ` +
|
|
"infrastructure; no public forge mirror has been confirmed for this package",
|
|
);
|
|
}
|
|
}
|
|
const repository = typeof pkg.repository?.url === "string" ? pkg.repository.url : "";
|
|
if (repository.includes(CANONICAL_HOST))
|
|
note(`${repository} is reserved on owned infrastructure and is not yet serving Git`);
|
|
}),
|
|
);
|
|
|
|
checks.forEach((run, position) => run(position + 1));
|
|
|
|
if (failed.length === 0) {
|
|
console.log("POM docs verification PASS");
|
|
} else {
|
|
console.log(`POM docs verification FAIL (${failed.length}/${checks.length}: ${failed.join(", ")})`);
|
|
process.exitCode = 1;
|
|
}
|