/** * Doctor remediation-arrow gate (celilo#1308). * * `celilo system doctor` renders `→ ` remediation lines under failing * checks. Those strings are built by hand in three files and were never checked * against the CLI's own command table, so doctor prescribed commands that * cannot run: * * - `celilo module update ` — `module update` takes a PATH, so a * module id names an argument form that does not exist (the lunacycle case * in #1308). * - `celilo system doctor --deep` — the doctor's own instruction, rejected by * `checkFlags` because `deep` was never declared on `system doctor`. * - `celilo capability chain ` — a command the ISS-0115 rework * removed from the registry. * * The gate scans the three files that produce the doctor's `→` surface * (system-doctor.ts, fleet-checks.ts, module-integrity.ts) for `celilo ...` * command spans and validates each against COMMANDS from @celilo/core: * * 1. every command word must resolve along the registry tree, * 2. every flag must be declared on the leaf command, * 3. positional count must fit the leaf command's declared args, * 4. a value known to be a module id must never fill a path slot (an arg whose * completion is `directories` or `files`), variadic or not. * * Commands are parsed, never executed. A span is "strict" when a quote or * backtick delimits it (the author wrote a command); a `celilo ...` occurrence * without a delimiter is a hint embedded in a sentence and is only validated * when everything after the command path is argument-shaped — trailing plain * words mean the line talks ABOUT celilo ("celilo knows where its control * plane runs"), which is prose, not a prescription. * * Reach floors: the scan asserts it resolved a real number of command spans * and contains the anchors it exists to police, because a scan that resolves * nothing exits 0 and proves nothing. */ import { describe, expect, it } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { type ArgDef, COMMANDS, type CommandDef } from '@celilo/core'; const SOURCES = [ join(import.meta.dir, 'system-doctor.ts'), join(import.meta.dir, '../../services/fleet-checks.ts'), join(import.meta.dir, '../../services/audit/module-integrity.ts'), ]; /** Completion kinds whose value is a filesystem path, not an id. */ const PATH_COMPLETIONS = new Set(['directories', 'files']); const INTERP = '\u0000'; /** `${...}` expressions found by masking, in order. */ const interps: string[] = []; function maskInterpolations(source: string): string { interps.length = 0; return source.replace(/\$\{([^{}]*)\}/g, (_m, expr: string) => { interps.push(expr); return `${INTERP}${interps.length - 1}${INTERP}`; }); } function interpText(masked: string): string { const m = masked.match(new RegExp(`${INTERP}(\\d+)${INTERP}`)); return m ? (interps[Number(m[1])] ?? masked) : masked; } interface ParsedToken { kind: 'word' | 'flag' | 'positional'; text: string; /** True when the token's runtime value is a module id. */ isModuleId: boolean; } /** * One `celilo ...` occurrence: the tokens after the word `celilo`, plus * whether a quote delimited it (strict). */ interface Span { tokens: ParsedToken[]; strict: boolean; raw: string; } const FLAG = /^--[a-z0-9][a-z0-9-]*/; const PLACEHOLDER = /^<[^<>\s]+>/; function tokenize(text: string): ParsedToken[] { const tokens: ParsedToken[] = []; let rest = text; while (rest.length > 0) { rest = rest.replace(/^[\s,;]+/, ''); if (rest.length === 0) break; let m = rest.match(FLAG); if (m) { tokens.push({ kind: 'flag', text: m[0].slice(2), isModuleId: false }); rest = rest.slice(m[0].length); continue; } m = rest.match(PLACEHOLDER); if (m) { tokens.push({ kind: 'positional', text: m[0], isModuleId: false }); rest = rest.slice(m[0].length); continue; } m = rest.match(/^\u0000(\d+)\u0000/); if (m) { const expr = interps[Number(m[1])] ?? ''; rest = rest.slice(m[0].length); // ANSI chrome (${ANSI.reset} and friends) renders as an escape code, // not as a value the operator types. if (/^ANSI\.\w+$/.test(expr.trim())) continue; tokens.push({ kind: 'positional', text: expr, isModuleId: /\bmoduleId\b/.test(expr) }); continue; } m = rest.match(/^[a-z][a-z0-9-]*(?:\.\u0000\d+\u0000[a-z0-9-]*)*/); if (m) { // A word that carries an interpolation (a key built at runtime, like // network..subnet) is a value, never a subcommand. const isInterp = m[0].includes(INTERP); const isModuleId = isInterp ? (m[0].match(/\u0000(\d+)\u0000/g) ?? []).some((mark) => /\bmoduleId\b/.test(interpText(mark)), ) : false; tokens.push({ kind: isInterp ? 'positional' : 'word', text: m[0], isModuleId }); rest = rest.slice(m[0].length); continue; } break; // punctuation, a closing quote, prose — the command part is over } return tokens; } /** Extract every `celilo ` occurrence with its span, per the rules above. */ function extractSpans(maskedLine: string): Span[] { const spans: Span[] = []; const re = /\bcelilo(?=\s)/g; let m: RegExpExecArray | null = re.exec(maskedLine); while (m !== null) { const before = m.index > 0 ? maskedLine[m.index - 1] : ''; const delimiter = before === '`' || before === "'" || before === '"' ? before : null; const start = m.index + 'celilo'.length; let content: string; let strict: boolean; if (delimiter) { const closer = maskedLine.indexOf(delimiter, start); strict = true; content = closer === -1 ? maskedLine.slice(start) : maskedLine.slice(start, closer); if (closer !== -1) re.lastIndex = closer + 1; } else { strict = false; content = maskedLine.slice(start); } const tokens = tokenize(content); if (tokens.length > 0) spans.push({ tokens, strict, raw: content.trim() }); if (re.lastIndex <= m.index) re.lastIndex = start; m = re.exec(maskedLine); } return spans; } function walkPath(tokens: ParsedToken[]): { path: CommandDef[]; rest: ParsedToken[] } { const path: CommandDef[] = []; let node: CommandDef | undefined = COMMANDS.find((c) => c.name === tokens[0]?.text); if (!node) return { path, rest: tokens }; path.push(node); let i = 1; while (i < tokens.length && tokens[i].kind === 'word') { const next: CommandDef | undefined = node.subcommands?.find((s) => s.name === tokens[i].text); if (!next) break; node = next; path.push(next); i++; } return { path, rest: tokens.slice(i) }; } /** Which declared arg the i-th positional fills (variadic absorbs the tail). */ function slotFor(args: ArgDef[], i: number): ArgDef | undefined { const variadicIndex = args.findIndex((a) => a.variadic); const fixed = variadicIndex === -1 ? args.length : variadicIndex; if (i < fixed) return args[i]; return variadicIndex === -1 ? undefined : args[variadicIndex]; } function validateSpan(span: Span): string[] { const { path, rest } = walkPath(span.tokens); if (path.length === 0) return []; // first word is not a command: prose about celilo // A hint embedded in prose is only a prescription when everything after the // command path is argument-shaped. Trailing plain words mean the sentence // continues ("celilo system migrate` to apply pending migrations..."). if (!span.strict && rest.some((t) => t.kind === 'word')) return []; const name = `celilo ${path.map((c) => c.name).join(' ')}`; const leaf = path[path.length - 1]; const violations: string[] = []; for (const t of rest.filter((t) => t.kind === 'word')) { violations.push(`${name}: unknown command word '${t.text}'`); } const declaredFlags = new Set([...(leaf.flags ?? []).map((f) => f.name), 'help', 'h']); for (const t of rest.filter((t) => t.kind === 'flag')) { if (!declaredFlags.has(t.text)) { violations.push(`${name}: flag --${t.text} is not declared in the command registry`); } } const args = leaf.args ?? []; const positionals = rest.filter((t) => t.kind === 'positional'); if (!args.some((a) => a.variadic) && positionals.length > args.length) { violations.push( `${name}: ${positionals.length} positional value(s) but only ${args.length} declared arg(s)`, ); } positionals.forEach((token, i) => { const slot = slotFor(args, i); if (!slot) return; if ( token.isModuleId && slot.completion !== undefined && PATH_COMPLETIONS.has(slot.completion) ) { violations.push( `${name}: module id '${token.text}' fills the '${slot.name}' slot, which takes a path (${slot.completion})`, ); } }); return violations; } describe('doctor remediation arrow gate (celilo#1308)', () => { it('flags a module id passed into a path slot, even a variadic one', () => { // Rule 7.6 anchor: the exact defect shape from #1308 (`module update // `), pinned synthetically so the rule cannot silently stop // matching the registry. maskInterpolations(''); // reset interps.push('result.moduleId'); const violations = validateSpan({ tokens: tokenize(`module update ${INTERP}0${INTERP}`), strict: true, raw: 'module update ${result.moduleId}', }); expect(violations.some((v) => v.includes('takes a path'))).toBe(true); }); it('every command the doctor prescribes exists, with declared flags and fitting args', () => { const violations: string[] = []; let resolvedCount = 0; const resolvedHeads = new Set(); for (const file of SOURCES) { const raw = readFileSync(file, 'utf8'); // Strip block comments and whole-line // comments so doc headers that // mention commands historically are not prescribed commands now. const stripped = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); const masked = maskInterpolations(stripped); const shortFile = file.split('/').pop() ?? file; for (const line of masked.split('\n')) { for (const span of extractSpans(line)) { const found = validateSpan(span); if (found.length === 0 && span.strict) resolvedCount++; for (const v of found) violations.push(`[${shortFile}] ${v} (span: "${span.raw}")`); const head = span.tokens .slice(0, 2) .map((t) => t.text) .join(' '); if (head) resolvedHeads.add(head); } } } // Reach floors. A scan that resolves nothing proves nothing. expect(resolvedCount).toBeGreaterThanOrEqual(12); for (const anchor of [ 'system doctor', 'module verify', 'monitor add', 'events repair', 'system migrate', ]) { const found = [...resolvedHeads].some((h) => h.startsWith(anchor)); expect(found).toBe(true); } expect(violations).toEqual([]); }); });