/* RFC-1334: structural command reachability — invocation-literal scanning over source text (pipeline step objects, const tables, dispatch calls, commandName/validator fields, switch labels) plus computeCommandReachability, shared by command.manifest.generate (reachableVia) and command.coverage.validate. Does not read the filesystem — callers pass loaded source text. Does not record documentation mentions — those are command.coverage.validate's local concern. Edges are structural only: pipeline, invoker, coverage — mentions never enter the committed manifest. Edges sort by kind then ref and carry no timestamps, so the manifest stays deterministic. RFC-1334: invocation scanning moved from command.coverage.validate; computeCommandReachability with structural, sorted edges. */ /** Source trees scanned for literal invocations (forge modules live under os/). */ export const INVOKER_SOURCE_GLOBS = ["packages/*/src/**/*.ts", "packages/forge/os/**/*.ts"]; /** Const names whose initializer arrays/sets/objects list command names. */ const CONST_TABLE_RE = /(?:export\s+)?const\s+[A-Z0-9_]*(?:_STEPS|_PIPELINE|_COMMANDS|_VALIDATORS|_MAPPINGS)[A-Z0-9_]*\b/g; const FIELD_INVOKE_RES = [ /\bcommandName\s*:\s*["']([^"']+)["']/g, /\bvalidator\s*:\s*["']([^"']+)["']/g, /\bfixCommand\s*:\s*["']([^"']+)["']/g, // switch-dispatch on command names (e.g. mission step tables route by // `case "mission.validate":` — the label is the invocation). /\bcase\s+["']([a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+)["']\s*:/g, ]; const CALL_DISPATCH_RE = /\b(?:executeKernelCommand|runCommandSequence|runPipeline)\s*\(/g; const STRING_LITERAL_RE = /["'`]([^"'`]+)["'`]/g; /** Segments that hold test/fixture/template text — references, never invocations. */ export const SKIP_DIR_RE = /(^|\/)(?:node_modules|dist|coverage|reports|test-fixtures|fixtures|__tests__|tests|templates)\//; /** Remove // line comments and /* *\/ blocks without touching string literals. */ function stripComments(source: string): string { let out = ""; let i = 0; let state: "code" | "line" | "block" | "string" = "code"; let stringChar = ""; while (i < source.length) { const ch = source[i]!; const next = source[i + 1]; if (state === "code") { if (ch === "/" && next === "/") { state = "line"; i += 2; continue; } if (ch === "/" && next === "*") { state = "block"; i += 2; continue; } if (ch === '"' || ch === "'" || ch === "`") { state = "string"; stringChar = ch; out += ch; i += 1; continue; } out += ch; i += 1; continue; } if (state === "line") { if (ch === "\n") { state = "code"; out += ch; } i += 1; continue; } if (state === "block") { if (ch === "*" && next === "/") { state = "code"; i += 2; continue; } if (ch === "\n") out += ch; i += 1; continue; } out += ch; if (ch === "\\") { if (i + 1 < source.length) out += source[i + 1]; i += 2; continue; } if (ch === stringChar) state = "code"; i += 1; } return out; } /** Extract every "..."-style string literal inside a source span. */ function stringLiteralsIn(span: string): string[] { const values: string[] = []; for (const match of span.matchAll(STRING_LITERAL_RE)) values.push(match[1]!); return values; } /** * Return the initializer span of each `const NAME_` declaration: * from the `=` to the terminating `;` at bracket depth 0. */ function constTableSpans(source: string): string[] { const spans: string[] = []; for (const match of source.matchAll(CONST_TABLE_RE)) { let i = match.index + match[0].length; // Skip an optional type annotation up to the `=`. while (i < source.length && source[i] !== "=" && source[i] !== "\n") i += 1; if (source[i] !== "=") continue; i += 1; const start = i; let depth = 0; let inString: string | null = null; for (; i < source.length; i += 1) { const ch = source[i]!; if (inString) { if (ch === "\\") i += 1; else if (ch === inString) inString = null; continue; } if (ch === '"' || ch === "'" || ch === "`") inString = ch; else if (ch === "(" || ch === "[" || ch === "{") depth += 1; else if (ch === ")" || ch === "]" || ch === "}") depth -= 1; else if (ch === ";" && depth === 0) break; } spans.push(source.slice(start, i)); } return spans; } /** String literals inside a dispatch call-expression span (bounded by paren depth). */ function callArgSpans(source: string): string[] { const spans: string[] = []; for (const match of source.matchAll(CALL_DISPATCH_RE)) { let i = match.index + match[0].length; const start = i; let depth = 1; let inString: string | null = null; for (; i < source.length; i += 1) { const ch = source[i]!; if (inString) { if (ch === "\\") i += 1; else if (ch === inString) inString = null; continue; } if (ch === '"' || ch === "'" || ch === "`") inString = ch; else if (ch === "(") depth += 1; else if (ch === ")") { depth -= 1; if (depth === 0) break; } } spans.push(source.slice(start, i)); } return spans; } /** * Collect the command names invoked by a source file's text. `inPipelineDir` * widens the scan to `{ command: "x" }` step objects — that field shape only * carries invocation semantics inside pipeline declarations (RFC-1243 risk * note: lint-rule `command:` tables elsewhere are references, not calls). */ export function invocationsInSource(source: string, inPipelineDir: boolean): Set { const invoked = new Set(); const clean = stripComments(source); for (const re of FIELD_INVOKE_RES) { for (const match of clean.matchAll(re)) invoked.add(match[1]!); } if (inPipelineDir) { for (const match of clean.matchAll(/\bcommand\s*:\s*["']([^"']+)["']/g)) { invoked.add(match[1]!); } } for (const span of constTableSpans(clean)) { for (const literal of stringLiteralsIn(span)) { invoked.add(literal); // argv-tailed dispatch strings (`commands.push("sites-check.run --site x")`): // the first whitespace token carries the command name. invoked.add(literal.split(/\s/, 1)[0]!); } } for (const span of callArgSpans(clean)) { for (const literal of stringLiteralsIn(span)) { invoked.add(literal); invoked.add(literal.split(/\s/, 1)[0]!); } } return invoked; } export type ReachabilityKind = "pipeline" | "invoker" | "coverage"; export interface ReachabilityEdge { kind: ReachabilityKind; /** Pipeline name, invoking file, or coverage class. */ ref: string; } /** True when a repo-relative source path is an invocation surface (not test/generated/fixture). */ export function isInvokerSource(rel: string): boolean { const normalized = rel.replace(/\\/g, "/"); return !( normalized.includes(".test.") || normalized.includes(".generated.") || SKIP_DIR_RE.test(normalized) ); } const KIND_ORDER: Record = { pipeline: 0, invoker: 1, coverage: 2 }; /** Structural reachability per registered command, sorted deterministically. */ export function computeCommandReachability(input: { commands: Array<{ name: string; pipelines?: string[]; coverage?: string | null }>; sources: ReadonlyMap; }): Map { const registered = new Set(input.commands.map((c) => c.name)); const edges = new Map(); for (const c of input.commands) edges.set(c.name, []); for (const c of input.commands) { for (const p of c.pipelines ?? []) edges.get(c.name)!.push({ kind: "pipeline", ref: p }); if (c.coverage) edges.get(c.name)!.push({ kind: "coverage", ref: c.coverage }); } for (const [rel, text] of input.sources) { for (const name of invocationsInSource(text, rel.includes("/pipelines/"))) { if (registered.has(name)) edges.get(name)!.push({ kind: "invoker", ref: rel }); } } for (const [name, list] of edges) { const seen = new Set(); edges.set( name, list .filter((e) => { const key = `${e.kind}\u0000${e.ref}`; if (seen.has(key)) return false; seen.add(key); return true; }) .sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.ref.localeCompare(b.ref)), ); } return edges; }