morphit/scripts/operator-doc-fenced-path-existence-smoke.ts

358 lines
12 KiB
TypeScript

#!/usr/bin/env tsx
/**
* scripts/operator-doc-fenced-path-existence-smoke.ts
*
* Structural Defense #31 — operator-doc fenced-path existence
* (cp82-O29 candidate, deferred from cp82, shipped cp84).
*
* Verifies that every script-path referenced in operator-facing
* documentation resolves to a real file on disk. Catches the
* drift class that cp82-A6 fixed manually: `scripts/encrypt-
* active-key.ts` was referenced in 6 places across OPERATIONS.md
* and RUN-A-MORPHIT-NODE.md, but the actual script lived at
* `apps/relay/scripts/encrypt-active-key.ts`. An operator
* following the documented command would hit file-not-found.
*
* Patterns matched (case-sensitive, must look like a path):
*
* - `bash X.sh` / `bash X` where X starts with `scripts/`,
* `apps/<ws>/scripts/`, `ops/scripts/`, or `ops/ansible/`
* - `tsx X.ts` / `node X.mjs` / `python3 X.py` with same prefix
* - Inline backtick-quoted `<prefix>/<name>.<ext>` paths
* - Code-fenced lines containing such paths (no leading
* command word required)
*
* Path roots we verify against:
*
* - `scripts/...` → repo-root scripts/
* - `apps/<workspace>/scripts/...` → workspace scripts/
* - `apps/<ws>/<subpath>` → workspace subpath (for cp82-A6 class)
* - `ops/scripts/...` → ops scripts
* - `ops/ansible/...` → ansible material
* - `packages/<pkg>/scripts/...` → package scripts
*
* False-positive avoidance:
*
* 1. Generic prose like "the relay's `scripts/` directory" —
* we require a file extension before flagging.
* 2. `node_modules/...` paths — excluded (third-party).
* 3. URLs (https://github.com/.../scripts/...) — excluded
* via a leading-scheme check.
* 4. Glob patterns containing `*` — excluded.
*
* Doc scope: every operator-facing markdown file under docs/
* plus README.md. ADRs are excluded — they encode historical
* decisions and may legitimately reference paths that have
* since been renamed (annotation-pattern-not-rewrite rule).
* The audit log and REVISIT-LIST.md are excluded as historical
* journals. TARBALL.md is excluded — its purpose is exactly
* to record past states, including superseded paths.
*
* Each (doc, line, path) tuple counts as one scenario.
*/
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, '..');
// Doc files in scope. Each operator-facing surface; excludes
// ADRs (historical), audit (frozen), REVISIT-LIST (journal),
// TARBALL (state log), PHASE-* (planning), DESIGN docs (RFCs).
const OPERATOR_DOCS = [
'README.md',
'docs/start-here/README.md',
'docs/RUN-A-MORPHIT-NODE.md',
'docs/OPERATIONS.md',
'docs/PRE-LAUNCH-CHECKLIST.md',
'docs/LAUNCH-DAY.md',
'docs/POST-LAUNCH-WEEK-ONE.md',
'docs/UPGRADING.md',
'docs/MIGRATE-TO-RELEASE-TRACK.md',
'docs/BETA-INCIDENT-RUNBOOK.md',
'docs/SECURITY.md',
'docs/ADDING-A-COIN.md',
'docs/API.md',
'docs/ARCHITECTURE.md',
'docs/FORGEJO-RUNNER-STANDUP.md',
'docs/CONTRIBUTING-TRANSLATIONS.md'
];
// Path prefixes that are allowed root anchors. A path-like
// token whose first segment matches one of these is checked
// against the filesystem.
const VALID_ROOTS = [
'scripts/',
'apps/',
'ops/',
'packages/',
'docs/'
];
// File extensions that suggest "this is a file the operator
// would invoke or read", not a directory or glob.
const VALID_EXTENSIONS = new Set([
'sh',
'ts',
'tsx',
'js',
'mjs',
'cjs',
'py',
'sql',
'yml',
'yaml',
'json',
'md',
'service',
'timer',
'conf',
'env',
'example'
]);
// Pattern: a path of the form
// <root>/<intermediate-dirs>/<filename>.<ext>
// Captured groups not used; we use the full match.
const PATH_RE =
/(?:^|[\s`"'(\[])((?:scripts|apps|ops|packages|docs)\/[A-Za-z0-9_./-]+\.[A-Za-z0-9]+)/g;
interface Hit {
doc: string;
line: number;
path: string;
cdContext: string | null;
}
const hits: Hit[] = [];
const failures: string[] = [];
function isExcludedPath(p: string): boolean {
// Glob / wildcard
if (p.includes('*')) return true;
// URLs (http://x/scripts/y) — caught only if some prefix
// got stripped; safer to reject any path with `://`.
if (p.includes('://')) return true;
// node_modules
if (p.includes('node_modules/')) return true;
// Trailing punctuation that snuck into the path token
if (p.endsWith('.') || p.endsWith(',') || p.endsWith(':') || p.endsWith(';')) {
return true;
}
// Bracketed asset templates like apps/web/.../[lang]/[asset]
if (p.includes('[') || p.includes(']')) return true;
// Must have a valid root
if (!VALID_ROOTS.some((r) => p.startsWith(r))) return true;
// Must end in a known file extension (filter out
// "the scripts/ dir" false positives)
const lastDot = p.lastIndexOf('.');
if (lastDot === -1) return true;
const ext = p.slice(lastDot + 1).toLowerCase();
if (!VALID_EXTENSIONS.has(ext)) return true;
return false;
}
// Operator-managed runtime files: never in the repo, created by
// the operator at install time or by the ops-cli wizard. The
// repo carries templates (`.example`) and documentation; the
// real files materialize on the operator's box. Excluded from
// the existence check.
//
// Detection strategy is deliberately data-driven, not hardcoded:
//
// - `X.env` paths are excluded iff `X.env.example` exists in
// the repo (signals "operator copies the template").
// - `*/keystore.json` / `*/keystore.wif` paths are excluded
// unconditionally (encrypted-key files are by definition
// operator-managed).
// - paths under `ops/backup/` matching `*.env` follow the
// same `.example` rule.
//
// This auto-handles future operator-managed-file additions:
// add a `.env.example` template to the repo and the smoke
// stops flagging the corresponding `.env` reference.
function isOperatorManagedRuntimeFile(p: string, repoRoot: string): boolean {
if (p.endsWith('/keystore.json') || p.endsWith('/keystore.wif')) {
return true;
}
// Build-generated outputs written by apps/web/scripts/build-manifest.mjs:
// the reproducible-build fingerprint (`build-manifest.sha256`) and the
// SRI on-chain release manifest (`build-manifest.release.json`). They
// materialize only after `npm run build` / `build:manifest`; never
// committed (see apps/web/.gitignore). Operator docs reference them as
// the release-op manifest source (PRE-LAUNCH-CHECKLIST §B/§E, OPERATIONS
// §40.6), so the existence check must skip them.
if (
p.endsWith('/build-manifest.sha256') ||
p.endsWith('/build-manifest.release.json')
) {
return true;
}
if (p.endsWith('.env')) {
const examplePath = join(repoRoot, p + '.example');
if (existsSync(examplePath)) return true;
}
return false;
}
// "Update history" / "Changelog" boundary: docs commonly carry
// a trailing change-log table that paraphrases past commands
// for historical traceability. Such tables MUST be allowed to
// hold stale path references — that's the annotation-pattern-
// not-rewrite rule applied to operator-doc history. When the
// smoke sees one of these headings, it stops scanning the rest
// of the file.
const HISTORY_BOUNDARY_RE =
/^##\s+(Update history|Changelog|Change log|Revision history|History)\s*$/i;
// CD-context resolution: inside a fenced code block, a bare
// `scripts/foo.ts` after a preceding `cd apps/relay` (in the
// SAME fence) refers to `apps/relay/scripts/foo.ts`. Track
// the most recent `cd` target per fence and resolve bare
// `scripts/...` paths against it when the literal path doesn't
// exist at repo root.
//
// Recognized `cd` forms:
//
// cd /opt/morphit/apps/indexer
// cd apps/web
// cd ./apps/relay
//
// We strip `/opt/morphit/` (the documented deploy path) and
// any leading `./` to map deployment-style cd into a repo-
// relative directory.
const CD_RE = /\bcd\s+(?:\.\/)?(?:\/opt\/morphit\/)?([A-Za-z0-9_./-]+)/;
interface ResolvedHit extends Hit {
resolved: string;
}
function resolveWithCdContext(
rawPath: string,
cdTarget: string | null,
repoRoot: string
): string {
if (existsSync(join(repoRoot, rawPath))) {
return rawPath; // already valid at repo root
}
if (cdTarget && rawPath.startsWith('scripts/')) {
const candidate = `${cdTarget}/${rawPath}`;
if (existsSync(join(repoRoot, candidate))) {
return candidate;
}
}
return rawPath; // unresolved — will fail existence check
}
console.log('\n── operator-doc fenced-path existence smoke ────────────\n');
for (const docRel of OPERATOR_DOCS) {
const docPath = join(REPO, docRel);
if (!existsSync(docPath)) {
// Doc list itself is wrong — that's a real bug.
failures.push(`doc-list references nonexistent ${docRel}`);
continue;
}
const text = readFileSync(docPath, 'utf8');
const lines = text.split('\n');
let inFence = false;
let cdInFence: string | null = null;
let pastHistoryBoundary = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
// Stop scanning once we cross an Update-history boundary.
if (HISTORY_BOUNDARY_RE.test(line)) {
pastHistoryBoundary = true;
continue;
}
if (pastHistoryBoundary) continue;
// Track fenced code blocks. Each ``` toggles fence state
// and resets cd-context (a new fence starts in repo-root).
// Match indented fences too — Markdown allows fenced blocks
// inside list items, and those carry leading whitespace.
if (/^\s*```/.test(line)) {
inFence = !inFence;
cdInFence = null;
continue;
}
// Inside a fence, harvest `cd <dir>` to set cd-context.
if (inFence) {
const cdMatch = line.match(CD_RE);
if (cdMatch) {
cdInFence = cdMatch[1]!;
}
}
// HTML-comment-stripped scan: paths inside <!-- ... -->
// may be intentionally archival. We strip those out.
const visible = line.replace(/<!--.*?-->/g, '');
PATH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = PATH_RE.exec(visible)) !== null) {
const candidate = m[1]!;
if (isExcludedPath(candidate)) continue;
// Dedupe within the same line (a path mentioned
// multiple times on one line still counts as one
// scenario, since one fix would close all of them).
const already = hits.some(
(h) => h.doc === docRel && h.line === i + 1 && h.path === candidate
);
if (!already) {
hits.push({
doc: docRel,
line: i + 1,
path: candidate,
cdContext: inFence ? cdInFence : null
});
}
}
}
}
// Verify each hit against the filesystem.
let operatorManagedSkipped = 0;
for (const h of hits) {
if (isOperatorManagedRuntimeFile(h.path, REPO)) {
operatorManagedSkipped++;
continue;
}
const resolved = resolveWithCdContext(h.path, h.cdContext, REPO);
const abs = join(REPO, resolved);
if (!existsSync(abs)) {
const suffix =
h.cdContext && h.cdContext !== resolved
? ` (also tried via cd-context \`${h.cdContext}\`)`
: '';
failures.push(
`${h.doc}:${h.line} references nonexistent path \`${h.path}\`${suffix}`
);
}
}
console.log(` scanned ${OPERATOR_DOCS.length} operator-facing docs`);
console.log(` path references found: ${hits.length}`);
console.log(` operator-managed runtime files skipped: ${operatorManagedSkipped}`);
const checked = hits.length - operatorManagedSkipped;
if (failures.length > 0) {
console.log(`\n ✗ ${failures.length} path(s) do not exist on disk:`);
for (const f of failures) console.log(` - ${f}`);
console.log('\n──────────────────────────────────────────────────────');
console.log(`${failures.length}/${checked} scenarios failed`);
process.exit(1);
}
// Sanity: if no path references were found at all, the regex
// is broken or the doc list is empty. Either way, fail loudly
// rather than silently passing zero.
if (checked === 0) {
console.log('\n ✗ no verifiable path references found — pattern is broken');
console.log('\n──────────────────────────────────────────────────────');
console.log(`✗ 1/1 scenarios failed`);
process.exit(1);
}
console.log(` ✓ all ${checked} verifiable path references resolve to real files`);
console.log('\n──────────────────────────────────────────────────────');
console.log(`✓ all ${checked} scenarios passed`);