#!/usr/bin/env node
/**
* Validation cache + structural checks for `/doc-wiki:atlas` Phase 5.
*
* Three layers:
*
* 1. **Structural** — wraps `lint_checks.ts` so the orchestrator can spot-check
* a single atlas page and get a categorized list of findings.
* 2. **Semantic cache** — stores `{ page-hash, source-hash } → result` records
* under `.wiki-cache/atlas-validate/`. The actual semantic LLM call lives
* in the SKILL.md orchestrator; this module owns persistence + lookup so
* unchanged page/source pairs cost zero on re-runs.
* 3. **Combined-hash key** — `sha256(pageHash + ":" + sourceHash)` keeps the
* filename short and avoids ordering ambiguity between the two halves.
*
* Usage as a library:
* import { checkValidationCache, storeValidationCache } from "./atlas_validate.js";
* const hit = checkValidationCache(wikiRoot, pageHash, sourceHash);
*
* Usage as a script:
* node atlas_validate.js cache-check --wiki-root
--page-hash --source-hash
* node atlas_validate.js cache-store --wiki-root --page-hash --source-hash --result ''
* node atlas_validate.js cache-clear --wiki-root
* node atlas_validate.js structural --wiki-root
--page
*/
import { execFileSync } from "node:child_process";
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { parseFlags } from "./_cli_args.js";
import { parseFrontmatter } from "./_frontmatter.js";
// ── Constants ──────────────────────────────────────────────────────
export const VALIDATE_CACHE_SUBDIR = path.join(".wiki-cache", "atlas-validate");
/** Bumped when on-disk format changes; forces auto-invalidation. */
export const VALIDATE_CACHE_VERSION = "1";
// ── Types ──────────────────────────────────────────────────────────
export interface CachedValidation {
pageHash: string;
sourceHash: string;
/** Free-form LLM result from the orchestrator (kept opaque on purpose). */
result: unknown;
/** ISO-8601 UTC timestamp of when the entry was stored. */
timestamp: string;
/** Cache-format version; entries with mismatching versions miss. */
cache_version: string;
}
export interface StructuralFinding {
category: string;
message: string;
}
export type SourceExistenceStatus = "live" | "candidate" | "orphan";
export interface SourceExistenceResult {
status: SourceExistenceStatus;
/** Count of local-path sources (non-remote). */
total: number;
/** Local paths that don't exist on disk. */
missing: string[];
/** missing/total; 0 when total === 0. */
ratio: number;
}
// ── Hash key ───────────────────────────────────────────────────────
/**
* Combined cache key: `sha256(pageHash + ":" + sourceHash)`. The colon
* delimiter prevents `("ab", "cd")` and `("a", "bcd")` from colliding.
*/
export function computeValidationKey(pageHash: string, sourceHash: string): string {
return crypto
.createHash("sha256")
.update(`${pageHash}:${sourceHash}`, "utf8")
.digest("hex");
}
// ── Path helpers ───────────────────────────────────────────────────
function _cacheDir(wikiRoot: string): string {
return path.join(wikiRoot, VALIDATE_CACHE_SUBDIR);
}
function _entryPath(wikiRoot: string, key: string): string {
return path.join(_cacheDir(wikiRoot), `${key}.json`);
}
// ── Cache CRUD ─────────────────────────────────────────────────────
/**
* Look up a cached semantic-validation result. Returns `null` on miss,
* malformed entry, or version mismatch.
*/
export function checkValidationCache(
wikiRoot: string,
pageHash: string,
sourceHash: string,
): CachedValidation | null {
const key = computeValidationKey(pageHash, sourceHash);
const file = _entryPath(wikiRoot, key);
if (!fs.existsSync(file)) return null;
let raw: string;
try {
raw = fs.readFileSync(file, "utf-8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (
!parsed ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
return null;
}
const rec = parsed as Record;
if (rec["cache_version"] !== VALIDATE_CACHE_VERSION) return null;
if (rec["pageHash"] !== pageHash || rec["sourceHash"] !== sourceHash) {
// Hash collision shouldn't be possible with SHA-256, but defend anyway.
return null;
}
return {
pageHash,
sourceHash,
result: rec["result"],
timestamp: typeof rec["timestamp"] === "string" ? rec["timestamp"] : "",
cache_version: VALIDATE_CACHE_VERSION,
};
}
/**
* Persist a semantic-validation result. Creates the cache directory if
* absent. Overwrites any existing entry with the same key.
*/
export function storeValidationCache(
wikiRoot: string,
pageHash: string,
sourceHash: string,
result: unknown,
): void {
const key = computeValidationKey(pageHash, sourceHash);
const dir = _cacheDir(wikiRoot);
fs.mkdirSync(dir, { recursive: true });
const entry: CachedValidation = {
pageHash,
sourceHash,
result,
timestamp: new Date().toISOString(),
cache_version: VALIDATE_CACHE_VERSION,
};
fs.writeFileSync(_entryPath(wikiRoot, key), JSON.stringify(entry));
}
/**
* Remove every `.json` entry under the validate subdir. Returns the count
* removed. Does NOT touch the parent `.wiki-cache/` (that's the
* `cache_manager.ts` cache).
*/
export function clearValidationCache(wikiRoot: string): number {
const dir = _cacheDir(wikiRoot);
if (!fs.existsSync(dir)) return 0;
let count = 0;
for (const name of fs.readdirSync(dir)) {
if (name.endsWith(".json")) {
try {
fs.unlinkSync(path.join(dir, name));
count++;
} catch {
// best-effort
}
}
}
return count;
}
// ── Structural check ───────────────────────────────────────────────
/**
* Spot-check a single page by invoking `lint_checks.js` with `--page-only`
* filtering, parsing the JSON output, and returning a categorized findings
* list. The orchestrator uses this for Phase 5 sub-check 1 to avoid
* re-implementing the structural rules.
*
* Returns an empty array when the page is clean. Returns a non-empty array
* with at least one finding when issues are present. Throws if `lint_checks`
* is missing or returns malformed output.
*/
export function validateStructural(
wikiRoot: string,
pageRelPath: string,
): StructuralFinding[] {
const scriptPath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"lint_checks.js",
);
if (!fs.existsSync(scriptPath)) {
throw new Error(`lint_checks.js not found at ${scriptPath}`);
}
let raw: string;
try {
raw = execFileSync(
"node",
[scriptPath, "--wiki-root", wikiRoot, "--page", pageRelPath, "--json"],
{ encoding: "utf-8", maxBuffer: 16 * 1024 * 1024 },
);
} catch (e) {
// lint_checks may exit non-zero when findings exist. Recover stdout from
// the spawn error if present (Node attaches it as `e.stdout`).
const errAny = e as { stdout?: Buffer | string };
if (typeof errAny.stdout === "string") raw = errAny.stdout;
else if (errAny.stdout instanceof Buffer) raw = errAny.stdout.toString("utf-8");
else throw e;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return [];
}
if (!parsed || typeof parsed !== "object") return [];
const rec = parsed as Record;
const findings = rec["findings"];
if (!Array.isArray(findings)) return [];
const out: StructuralFinding[] = [];
for (const f of findings) {
if (f && typeof f === "object" && !Array.isArray(f)) {
const r = f as Record;
out.push({
category: typeof r["category"] === "string" ? r["category"] : "unknown",
message: typeof r["message"] === "string" ? r["message"] : "",
});
}
}
return out;
}
// ── Cross-doc ownership ────────────────────────────────────────────
/**
* Title of a Mermaid block on an atlas page. Extracted from the
* `` marker that `mermaid_inject.ts`
* wraps every spliced diagram in. The trailing ` start` token is stripped.
*/
const _MERMAID_TITLE_RE = //g;
/** Pair of atlas pages flagged as covering overlapping ground. */
export interface DuplicateDiagramFinding {
/** Wiki-relative paths of the two pages, sorted lexicographically. */
pages: [string, string];
/** Source paths both pages declare in their `sources:` frontmatter. */
sharedSources: string[];
/** Mermaid block titles present on both pages (case-insensitive match). */
sharedDiagramTitles: string[];
}
interface _ArchPageScan {
page: string;
sources: Set;
diagramTitles: Set;
}
function _scanArchPages(wikiRoot: string): _ArchPageScan[] {
const wikiContent = path.join(wikiRoot, "wiki");
if (!fs.existsSync(wikiContent)) return [];
const out: _ArchPageScan[] = [];
const walk = (dir: string): void => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
walk(full);
continue;
}
if (!e.isFile() || !full.endsWith(".md")) continue;
let body: string;
try {
body = fs.readFileSync(full, "utf-8");
} catch {
continue;
}
const { frontmatter, body: pageBody } = parseFrontmatter(body);
if (!frontmatter) continue;
if (frontmatter["atlas_facet"] !== "architecture") continue;
const sources = new Set();
const sourcesRaw = frontmatter["sources"];
if (Array.isArray(sourcesRaw)) {
for (const s of sourcesRaw) {
if (typeof s === "string" && s.length > 0) sources.add(s);
}
}
const diagramTitles = new Set();
_MERMAID_TITLE_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = _MERMAID_TITLE_RE.exec(pageBody)) !== null) {
const title = (m[1] ?? "").trim().toLowerCase();
if (title.length > 0) diagramTitles.add(title);
}
out.push({
page: path.relative(wikiRoot, full).split(path.sep).join("/"),
sources,
diagramTitles,
});
}
};
walk(wikiContent);
out.sort((a, b) => a.page.localeCompare(b.page));
return out;
}
/**
* Find pairs of architecture-facet atlas pages that likely duplicate each
* other's coverage. The heuristic: two pages are flagged when they share
* at least one source path AND at least one Mermaid diagram title. Either
* signal alone is too noisy (shared-source happens for thin slices that
* legitimately overlap; same-title happens for boilerplate "Service
* Topology" labels). Both together is a strong signal one page should
* own the topic and the other should `[link](../path/to/owner.md)`.
*
* Mirrors the "Cross-doc concerns" registry pattern from `docs/README.md`.
*/
export function findDuplicateDiagrams(wikiRoot: string): DuplicateDiagramFinding[] {
const arch = _scanArchPages(wikiRoot);
const findings: DuplicateDiagramFinding[] = [];
for (let i = 0; i < arch.length; i++) {
const a = arch[i];
if (!a) continue;
for (let j = i + 1; j < arch.length; j++) {
const b = arch[j];
if (!b) continue;
const sharedSources: string[] = [];
for (const s of a.sources) if (b.sources.has(s)) sharedSources.push(s);
const sharedTitles: string[] = [];
for (const t of a.diagramTitles) if (b.diagramTitles.has(t)) sharedTitles.push(t);
if (sharedSources.length === 0 || sharedTitles.length === 0) continue;
sharedSources.sort();
sharedTitles.sort();
findings.push({
pages: [a.page, b.page],
sharedSources,
sharedDiagramTitles: sharedTitles,
});
}
}
return findings;
}
// ── Source existence ───────────────────────────────────────────────
const REMOTE_SCHEMES = [
"http:",
"https:",
"jira:",
"github:",
"confluence:",
"notion:",
"aws:",
"gcp:",
];
function isRemoteScheme(s: string): boolean {
return REMOTE_SCHEMES.some((p) => s.startsWith(p));
}
/**
* Classify a wiki page's source files by checking whether each local-path
* source still exists on disk relative to `repoRoot`.
*
* - `live` — all local paths present (or no local paths).
* - `candidate` — some paths missing, ratio >= threshold (default: 1.0).
* - `orphan` — all local paths missing (ratio === 1.0 exactly).
*
* Remote sources (http, https, jira, github, confluence, notion, aws, gcp)
* are ignored and do not count toward `total`.
*
* Throws if the page file cannot be read (e.g. ENOENT, EACCES). The caller
* is responsible for handling missing pages — typically log + skip.
*/
export function sourceExistence(opts: {
wikiRoot: string;
repoRoot: string;
page: string;
threshold?: number;
}): SourceExistenceResult {
const body = fs.readFileSync(opts.page, "utf-8");
const { frontmatter } = parseFrontmatter(body);
const sourcesRaw = frontmatter?.["sources"];
const sources: string[] = Array.isArray(sourcesRaw)
? (sourcesRaw.filter((s) => typeof s === "string") as string[])
: [];
const localPaths = sources.filter((s) => !isRemoteScheme(s));
if (localPaths.length === 0) {
return { status: "live", total: 0, missing: [], ratio: 0 };
}
const missing: string[] = [];
for (const p of localPaths) {
const abs = path.resolve(opts.repoRoot, p);
if (!fs.existsSync(abs)) {
missing.push(p);
}
}
const ratio = missing.length / localPaths.length;
const threshold = opts.threshold ?? 1.0;
let status: SourceExistenceStatus;
if (ratio === 1.0) {
status = "orphan";
} else if (ratio >= threshold) {
status = "candidate";
} else {
status = "live";
}
return { status, total: localPaths.length, missing, ratio };
}
// ── CLI ────────────────────────────────────────────────────────────
const FLAG_SPEC = {
"--wiki-root": "wikiRoot",
"--repo-root": "repoRoot",
"--page-hash": "pageHash",
"--source-hash": "sourceHash",
"--result": "result",
"--page": "page",
"--threshold": "threshold",
} as const;
const HELP_TEXT = `usage: atlas_validate.js {cache-check,cache-store,cache-clear,structural,cross-doc,source-existence} [...]
Validation cache and structural checks for /doc-wiki:atlas Phase 5.
Subcommands:
cache-check --wiki-root --page-hash --source-hash
Lookup a cached validation result. Stdout: {hit, entry?}.
cache-store --wiki-root --page-hash --source-hash --result ''
Persist an LLM-derived validation result.
cache-clear --wiki-root
Remove every cached validation entry. Stdout: {removed: N}.
structural --wiki-root
--page
Run lint_checks on one page. Stdout: {findings: [...]}.
cross-doc --wiki-root
Find architecture pages with overlapping diagrams + sources.
Stdout: {findings: [{pages, sharedSources, sharedDiagramTitles}]}.
source-existence --wiki-root
--repo-root
--page [--threshold ]
Check whether a page's local source files still exist.
Stdout: {status, total, missing, ratio}.
status: live | candidate | orphan
`;
export function main(argv: readonly string[] = process.argv.slice(2)): number {
if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
process.stdout.write(HELP_TEXT);
return 0;
}
const sub = argv[0];
let parsed;
try {
parsed = parseFlags(argv.slice(1), FLAG_SPEC);
} catch (e) {
process.stderr.write(`${(e as Error).message}\n`);
return 2;
}
if (parsed.help) {
process.stdout.write(HELP_TEXT);
return 0;
}
const wikiRoot = parsed.values["wikiRoot"];
if (typeof wikiRoot !== "string" || wikiRoot.length === 0) {
process.stderr.write("--wiki-root is required\n");
return 2;
}
if (sub === "cache-check") {
const pageHash = parsed.values["pageHash"];
const sourceHash = parsed.values["sourceHash"];
if (typeof pageHash !== "string" || typeof sourceHash !== "string") {
process.stderr.write("--page-hash and --source-hash are required\n");
return 2;
}
const hit = checkValidationCache(wikiRoot, pageHash, sourceHash);
process.stdout.write(
JSON.stringify(hit === null ? { hit: false } : { hit: true, entry: hit }) + "\n",
);
return 0;
}
if (sub === "cache-store") {
const pageHash = parsed.values["pageHash"];
const sourceHash = parsed.values["sourceHash"];
const resultRaw = parsed.values["result"];
if (
typeof pageHash !== "string" ||
typeof sourceHash !== "string" ||
typeof resultRaw !== "string"
) {
process.stderr.write("--page-hash, --source-hash, and --result are required\n");
return 2;
}
let result: unknown;
try {
result = JSON.parse(resultRaw);
} catch {
// Allow non-JSON --result strings; store the raw text.
result = resultRaw;
}
storeValidationCache(wikiRoot, pageHash, sourceHash, result);
process.stdout.write(JSON.stringify({ stored: true }) + "\n");
return 0;
}
if (sub === "cache-clear") {
const removed = clearValidationCache(wikiRoot);
process.stdout.write(JSON.stringify({ removed }) + "\n");
return 0;
}
if (sub === "structural") {
const page = parsed.values["page"];
if (typeof page !== "string" || page.length === 0) {
process.stderr.write("--page is required\n");
return 2;
}
let findings: StructuralFinding[];
try {
findings = validateStructural(wikiRoot, page);
} catch (e) {
process.stderr.write(`structural check failed: ${(e as Error).message}\n`);
return 1;
}
process.stdout.write(JSON.stringify({ findings }) + "\n");
return 0;
}
if (sub === "cross-doc") {
const findings = findDuplicateDiagrams(wikiRoot);
process.stdout.write(JSON.stringify({ findings }) + "\n");
return 0;
}
if (sub === "source-existence") {
const repoRoot = parsed.values["repoRoot"];
const page = parsed.values["page"];
if (typeof repoRoot !== "string" || repoRoot.length === 0) {
process.stderr.write("--repo-root is required\n");
return 2;
}
if (typeof page !== "string" || page.length === 0) {
process.stderr.write("--page is required\n");
return 2;
}
const thresholdRaw = parsed.values["threshold"];
const threshold =
typeof thresholdRaw === "string" && thresholdRaw.length > 0
? Number(thresholdRaw)
: undefined;
const result = sourceExistence({ wikiRoot, repoRoot, page, threshold });
process.stdout.write(JSON.stringify(result) + "\n");
return 0;
}
process.stderr.write(`unknown subcommand: ${sub}\n`);
return 2;
}
const thisFile = fileURLToPath(import.meta.url);
if (process.argv[1] && path.resolve(process.argv[1]) === thisFile) {
process.exit(main());
}