#!/usr/bin/env node /** * validate-narrative.mjs — check a consultant narrative `.md` before it is loaded. * * Mirrors `parseNarrativeDoc` in hoen-assessment * (`apps/hoen-explorer/src/snapshots/narrative-md.ts`) so a consultant can catch * a heading that resolves to nothing, a `scope:` no snapshot claims, or a curve * note that will silently never render — without opening the tool. * * Fully offline: need ids come from the sibling `assets/need-ids.csv`, curve * measure ids are the model's fixed list. No model checkout required, so this * runs on a client laptop. * * Usage: * validate-narrative.mjs narrative/*.md * validate-narrative.mjs --curves narrative/curve-notes.md * validate-narrative.mjs --strict --format text narrative/*.md * validate-narrative.mjs --snapshots snapshots narrative/*.md * * Exit: 0 clean, 1 findings, 2 usage error. * * Style: declarative/functional (see hoen-library CONVENTIONS.md → Script Conventions). */ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { dirname, join, basename } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const NEED_IDS_CSV = join(here, "..", "assets", "need-ids.csv"); /** The reserved provisional slot. */ const PROVISIONAL = "tbc"; /** * Curve measure ids, verbatim from `CURVES` in * `apps/hoen-explorer/src/snapshots/normalisation.ts`. Kept as a literal * because curve notes must validate with no model bundle present. */ const CURVE_IDS = [ "lead-time", "deploy-freq", "wait-time", "change-fail", "recovery-time-cqa", "low-incidents", "release-conf", "medhigh-incidents", "recovery-time", "release-conf-2", ]; /** Near-misses seen in the wild → the id that actually resolves. */ const CURVE_ALIASES = { "deploy-frequency": "deploy-freq", "deployment-frequency": "deploy-freq", "change-fail-rate": "change-fail", "change-failure-rate": "change-fail", cfr: "change-fail", "lead-time-for-changes": "lead-time", ltfc: "lead-time", mttr: "recovery-time", "recovery-time-qos": "recovery-time", "release-confidence": "release-conf", "incidents-low": "low-incidents", "incidents-medhigh": "medhigh-incidents", "sev12": "medhigh-incidents", }; /** Words that assert rather than evidence — flagged under --strict. */ const OVERCONFIDENT = [ "clearly", "obviously", "undoubtedly", "certainly", "self-evident", "it is well known", "best practice", "everyone knows", "without question", ]; const usage = () => `Usage: validate-narrative.mjs [options] Options: --curves resolve headings as curve measure ids, not need ids --snapshots check each scope against snapshot ids in this folder --strict also flag unevidenced or overconfident theses --format output shape (default: json) --help this message Exit: 0 clean, 1 findings, 2 usage error. Examples: validate-narrative.mjs narrative/*.md validate-narrative.mjs --curves narrative/curve-notes.md validate-narrative.mjs --strict --snapshots snapshots --format text narrative/*.md`; const fail = (message) => { process.stderr.write(`${message}\n\n${usage()}\n`); process.exit(2); }; /* ---------------------------------------------------------------- arguments */ const parseArgs = (argv, acc = { files: [], format: "json", curves: false, strict: false, snapshots: null }) => argv.length === 0 ? acc : argv[0] === "--help" || argv[0] === "-h" ? (process.stdout.write(`${usage()}\n`), process.exit(0)) : argv[0] === "--curves" ? parseArgs(argv.slice(1), { ...acc, curves: true }) : argv[0] === "--strict" ? parseArgs(argv.slice(1), { ...acc, strict: true }) : argv[0] === "--format" ? (["json", "text"].includes(argv[1]) ? parseArgs(argv.slice(2), { ...acc, format: argv[1] }) : fail(`--format must be json or text, got '${argv[1] ?? ""}'`)) : argv[0] === "--snapshots" ? (argv[1] ? parseArgs(argv.slice(2), { ...acc, snapshots: argv[1] }) : fail("--snapshots needs a directory")) : argv[0].startsWith("-") ? fail(`Unknown option '${argv[0]}'`) : parseArgs(argv.slice(1), { ...acc, files: [...acc.files, argv[0]] }); /* ------------------------------------------------------------------ lookups */ export const readNeedIds = (csvPath = NEED_IDS_CSV) => new Set( readFileSync(csvPath, "utf8") .split("\n") .slice(1) .map((line) => line.split(",")[0]?.trim()) .filter(Boolean), ); /** Snapshot ids (and codes, for a better message) from a folder of group JSON. */ export const readSnapshotIds = (dir) => !existsSync(dir) ? null : readdirSync(dir) .filter((name) => /\.json$/i.test(name)) .map((name) => { const parsed = ((text) => { try { return JSON.parse(text); } catch { return null; } })(readFileSync(join(dir, name), "utf8")); return parsed && parsed.kind !== "hoen-explorer-override-patch" ? { id: parsed.id, code: parsed.code, file: name } : null; }) .filter((entry) => entry && entry.id); /* ------------------------------------------------------------------- parser */ const stripBold = (line) => line.match(/^\*\*(.+)\*\*$/)?.[1]?.trim() ?? line.trim(); const splitFrontmatter = (text) => { const normalised = text.replace(/\r\n?/g, "\n"); const end = normalised.startsWith("---\n") ? normalised.indexOf("\n---", 3) : -1; const after = end === -1 ? -1 : normalised.indexOf("\n", end + 1); return end === -1 ? { meta: {}, body: normalised, bodyOffset: 0 } : { meta: Object.fromEntries( normalised .slice(4, end) .split("\n") .map((line) => line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*)$/)) .filter(Boolean) .map((match) => [match[1], match[2].replace(/\s+#.*$/, "").trim()]), ), body: after === -1 ? "" : normalised.slice(after + 1), bodyOffset: after === -1 ? 0 : normalised.slice(0, after + 1).split("\n").length - 1, }; }; const SYMPTOMS_RE = /^symptoms$/i; const SIGNALS_RE = /^signals\s*(?:&|and)\s*measures$/i; /** * Fold one entry's lines into its four slots. Mirrors `buildEntry` — the first * non-blank line is the thesis, later prose before a sub-heading is `extra`. */ const buildBody = (lines) => lines.reduce( (acc, line) => { const sub = line.match(/^###\s+(.*)$/); const label = sub?.[1]?.trim(); return sub ? SYMPTOMS_RE.test(label) ? { ...acc, current: "symptoms" } : SIGNALS_RE.test(label) ? { ...acc, current: "signals" } : { ...acc, current: "extra", extra: [...acc.extra, line] } : acc.current === "thesis" ? !line.trim() ? acc : acc.thesis.length === 0 ? { ...acc, thesis: [stripBold(line)] } : { ...acc, extra: [...acc.extra, line] } : { ...acc, [acc.current]: [...acc[acc.current], line] }; }, { current: "thesis", thesis: [], symptoms: [], signals: [], extra: [] }, ); const resolveHeading = (heading, needIds, curves) => curves ? CURVE_IDS.includes(heading.trim()) ? heading.trim() : null : ((bare) => (needIds.has(bare) ? bare : null))(heading.replace(/^need:\s*/i, "").trim()); export const parseNarrative = (text, needIds, curves = false) => { const { meta, body, bodyOffset } = splitFrontmatter(text); const lines = body.split("\n"); const blocks = lines.reduce( (acc, line, index) => { const match = line.match(/^##\s+(.*)$/); return match ? { entries: [...acc.entries, { heading: match[1].trim(), line: bodyOffset + index + 1, lines: [] }] } : acc.entries.length === 0 ? acc : { entries: [ ...acc.entries.slice(0, -1), { ...acc.entries.at(-1), lines: [...acc.entries.at(-1).lines, line] }, ], }; }, { entries: [] }, ).entries; return { scope: meta.scope || "overall", hasScope: Boolean(meta.scope), title: meta.title || null, entries: blocks.map((block) => { const parts = buildBody(block.lines); const provisional = block.heading.toLowerCase() === PROVISIONAL; const targetId = provisional ? null : resolveHeading(block.heading, needIds, curves); const join = (value) => value.join("\n").trim(); return { heading: block.heading, line: block.line, kind: provisional ? "provisional" : targetId == null ? "unresolved" : "target", thesis: join(parts.thesis), symptoms: join(parts.symptoms), signals: join(parts.signals), }; }), }; }; /* ------------------------------------------------------------------- checks */ const finding = (level, line, code, message) => ({ level, line, code, message }); const duplicateHeadings = (entries) => entries .filter((entry, index) => entries.findIndex((other) => other.heading === entry.heading) !== index) .map((entry) => finding("error", entry.line, "duplicate-heading", `Duplicate heading '## ${entry.heading}' — the tool keeps the first and the second is unreachable.`), ); const headingFindings = (entries, curves) => entries .filter((entry) => entry.kind === "unresolved") .map((entry) => { const guess = CURVE_ALIASES[entry.heading.trim().toLowerCase()]; return curves ? finding( "error", entry.line, "unknown-measure-id", guess ? `'## ${entry.heading}' is not a measure id — did you mean '${guess}'? This note will never render.` : `'## ${entry.heading}' is not a measure id. This note will never render. Valid ids: ${CURVE_IDS.join(", ")}.`, ) : finding( "warn", entry.line, "unresolved-heading", `'## ${entry.heading}' is not a need id — it will be held unranked in the unresolved banner. Deliberate is fine; map it in the tool when you have a need for it.`, ); }); const completenessFindings = (entries, curves) => curves ? entries .filter((entry) => !entry.thesis && !entry.symptoms && !entry.signals) .map((entry) => finding("warn", entry.line, "empty-note", `'## ${entry.heading}' has no body, so no note is exported for that curve.`)) : entries .filter((entry) => entry.kind !== "provisional") .flatMap((entry) => [ !entry.thesis && finding("error", entry.line, "no-thesis", `'## ${entry.heading}' has no thesis — the bold claim line is what the export leads with.`), entry.thesis && !entry.signals && finding("warn", entry.line, "no-evidence", `'## ${entry.heading}' has a thesis but no '### Signals & measures'. Cite the claim, or soften it.`), entry.thesis && !entry.symptoms && entry.signals && finding("warn", entry.line, "no-symptoms", `'## ${entry.heading}' has evidence but no '### Symptoms' — the client has nothing to recognise.`), ].filter(Boolean), ); /** --strict: a firm claim whose evidence does not carry it. */ const confidenceFindings = (entries) => entries.flatMap((entry) => { const hit = OVERCONFIDENT.find((word) => entry.thesis.toLowerCase().includes(word)); const bullets = entry.signals.split("\n").filter((line) => /^\s*[-*]/.test(line)).length; return [ hit && finding("warn", entry.line, "overconfident", `'## ${entry.heading}' thesis uses '${hit}' — that word is doing work the evidence should do.`), entry.kind !== "provisional" && entry.thesis && bullets === 1 && entry.symptoms.length > 240 && finding("warn", entry.line, "thin-evidence", `'## ${entry.heading}' has extensive symptoms and one evidence bullet — it reads better-evidenced than it is. Hedge the thesis to match.`), ].filter(Boolean); }); const scopeFindings = (doc, snapshots) => [ !doc.hasScope && finding("warn", 1, "no-scope", "No 'scope:' in frontmatter — the document is filed against whichever tab was open when it was dropped."), doc.hasScope && snapshots && doc.scope !== "overall" && !snapshots.some((snap) => snap.id === doc.scope) && finding( "error", 1, "unknown-scope", `scope '${doc.scope}' matches no snapshot id in the folder (found: ${snapshots.map((s) => `${s.id}${s.code ? ` [${s.code}]` : ""}`).join(", ") || "none"}). The tab will exist but never fill. Note scope is the snapshot 'id', not its display 'code'.`, ), ].filter(Boolean); export const checkNarrative = (text, { needIds, curves = false, strict = false, snapshots = null }) => { const doc = parseNarrative(text, needIds, curves); const findings = [ ...scopeFindings(doc, curves ? null : snapshots), ...duplicateHeadings(doc.entries), ...headingFindings(doc.entries, curves), ...completenessFindings(doc.entries, curves), ...(strict ? confidenceFindings(doc.entries) : []), ].sort((a, b) => a.line - b.line); return { scope: doc.scope, title: doc.title, entries: doc.entries.length, ranked: doc.entries.filter((entry) => entry.kind === "target").length, unresolved: doc.entries.filter((entry) => entry.kind === "unresolved").length, provisional: doc.entries.filter((entry) => entry.kind === "provisional").length, findings, }; }; /* ------------------------------------------------------------------ reports */ const textReport = (results) => results .map((result) => [ `${result.file} — scope: ${result.scope}${result.title ? ` · ${result.title}` : ""}`, ` ${result.entries} entries · ${result.ranked} ranked · ${result.unresolved} unresolved · ${result.provisional} provisional`, ...result.findings.map((item) => ` ${item.level === "error" ? "✗" : "!"} line ${item.line}: ${item.message}`), result.findings.length === 0 ? " ✓ no findings" : "", ] .filter(Boolean) .join("\n"), ) .join("\n\n"); const main = () => { const options = parseArgs(process.argv.slice(2)); options.files.length === 0 && fail("No files given."); const needIds = readNeedIds(); const snapshots = options.snapshots ? readSnapshotIds(options.snapshots) : null; options.snapshots && snapshots === null && fail(`--snapshots directory not found: ${options.snapshots}`); const results = options.files.map((file) => ({ file: basename(file), ...checkNarrative(readFileSync(file, "utf8"), { needIds, curves: options.curves, strict: options.strict, snapshots }), })); process.stdout.write( options.format === "text" ? `${textReport(results)}\n` : `${JSON.stringify({ mode: options.curves ? "curves" : "needs", strict: options.strict, results }, null, 2)}\n`, ); process.exit(results.some((result) => result.findings.length > 0) ? 1 : 0); }; process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1] && main();