/** * Flag-surface gate (celilo#1327) and subcommand-surface gate (celilo#1337). * * A CLI flag lives in two places: the handler reads it off `flags.X`, and * packages/core/src/command-registry.ts declares it so the parser accepts it. * When only the first happens, the flag is fully implemented — often unit * tested, because the test drives the handler directly and never consults * validateFlags — and still rejected at the terminal with * "Command 'X' does not accept any flags". * * Instances of exactly that shape: #1308 (doctor remediation arrows), * #1316 (module deploy --keep), #1325 (system config set --force), and three * handler reads found the first time this gate ran (module check --no-build * and --strict, module changeset --bump — their subcommands were missing from * the registry entirely, so validation was skipped, not failed). * * A subcommand drifts the opposite way: the registry declares it, dispatch * runs it, and the hand-written help block never mentions it, so a user * reading `celilo --help` cannot learn the subcommand exists. * #1337 measured ten such subcommands under `celilo module` (upgrade, audit, * validate, changeset, status, where, operations, logs, journal, backup), and * the first run of this scan found four more elsewhere (events * list-unanswered, system migrate, ipam show, backup pull). * * All scans are deliberately coarse: a flag name must be declared SOMEWHERE * in the registry, and a subcommand name must merely APPEAR SOMEWHERE in its * command's help block — word-boundary match, not a parsed Subcommands: * section, because help blocks vary in shape. A per-subcommand check would be * tighter, but every known instance was declared nowhere / printed nowhere, * and a coarse gate that runs beats a precise one nobody finishes. * * Reach floors: each scan asserts it walked a real surface (minimum distinct * flag names / files / help blocks / commands). A scan that resolves no files * exits 0 and proves nothing, so the gate fails loudly instead. */ import { describe, expect, it } from 'bun:test'; import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { COMMANDS } from '@celilo/core'; const commandsDir = join(import.meta.dir, 'commands'); const indexSource = readFileSync(join(import.meta.dir, 'index.ts'), 'utf8'); /** Every flag name declared at any depth of the command registry. */ function collectDeclaredFlags(): Set { const declared = new Set(); function walk(defs: typeof COMMANDS): void { for (const def of defs) { for (const flag of def.flags ?? []) declared.add(flag.name); if (def.subcommands) walk(def.subcommands); } } walk(COMMANDS); return declared; } const declaredFlags = collectDeclaredFlags(); /** * Flags accepted without a registry entry, each with the reason: * - help/h: universal, added by validateFlags itself (parser.ts). * - remote: top-level transport flag, intercepted at the top of runCli by * resolveRemote (packages/core/src/remote-client.ts) before any dispatch. */ const flagsOutsideTheRegistry = new Set(['help', 'h', 'remote']); interface HandlerScan { byName: Map; fileCount: number; totalReads: number; } /** * Every flag literal a command handler reads via hasFlag(flags, 'x') or * getFlag(flags, 'x'). Reads that pass a variable instead of a literal are * invisible to this scan — accepted coarseness, see the header. */ function scanHandlerFlagReads(): HandlerScan { const byName = new Map(); const files = readdirSync(commandsDir).filter( (f) => f.endsWith('.ts') && !f.endsWith('.test.ts'), ); let totalReads = 0; for (const file of files) { const source = readFileSync(join(commandsDir, file), 'utf8'); const reads = source.matchAll(/(?:hasFlag|getFlag)\(\s*flags\s*,\s*'([^']+)'/g); for (const read of reads) { totalReads++; const flagName = read[1]; if (!flagName) continue; const holders = byName.get(flagName) ?? []; holders.push(file); byName.set(flagName, holders); } } return { byName, fileCount: files.length, totalReads }; } /** * Source of each displayXxxHelp function in index.ts (the help-text blocks). * Each block is bounded by the NEXT top-level declaration, not by the next * display function and not by end-of-source. The original end-of-source bound * let the last block swallow runCli's body, so --principal, --get-completions * and --version were read out of dispatch code and reported as advertised. */ function extractHelpBlocks(source: string): string[] { const fns = [...source.matchAll(/function display\w*Help\(\)/g)]; const topLevelDecl = /^(?:export )?(?:async )?function \w+|^export const \w+/gm; const blocks: string[] = []; for (const fn of fns) { if (fn.index === undefined) continue; const start = fn.index + fn[0].length; topLevelDecl.lastIndex = start; const next = topLevelDecl.exec(source); const end = next?.index ?? source.length; blocks.push(source.slice(start, end)); } return blocks; } /** Distinct --flag names advertised across all help blocks. */ function scanAdvertisedFlags(): { byName: Set; blockCount: number } { const byName = new Set(); let blockCount = 0; for (const block of extractHelpBlocks(indexSource)) { // External-tool example lines (ansible-vault, ansible-playbook) carry the // other tool's flags. Not celilo's surface. The flag often sits on the // continuation line, so drop --vault-password-file by name as well as the // ansible- command lines. // @psbanka - 2026-09 const celiloLines = block .split('\n') .filter((line) => !line.includes('ansible-')) .filter((line) => !line.includes('vault-password-file')); blockCount++; for (const line of celiloLines) { for (const match of line.matchAll(/--([a-zA-Z0-9][a-zA-Z0-9-]*)/g)) { const flagName = match[1]; if (flagName) byName.add(flagName); } } } return { byName, blockCount }; } /** `module` -> `Module`, `escalation-policy` -> `EscalationPolicy`. */ function pascalCase(name: string): string { return name.replace(/(^|-)([a-z])/g, (_match, prefix, char) => prefix + char.toUpperCase()); } /** * Help blocks keyed by the display function's name suffix: displayModuleHelp * -> "Module". A command with subcommands but no dedicated display function * (api, alerts, route, ...) is simply absent from the map and skipped by the * subcommand scan — it has no help block to drift from. */ function extractNamedHelpBlocks(): Map { const byName = new Map(); for (const match of indexSource.matchAll(/function display(\w*)Help\(\)/g)) { if (match.index === undefined) continue; const start = match.index + match[0].length; const topLevelDecl = /^(?:export )?(?:async )?function \w+|^export const \w+/gm; topLevelDecl.lastIndex = start; const next = topLevelDecl.exec(indexSource); byName.set(match[1], indexSource.slice(start, next?.index ?? indexSource.length)); } return byName; } /** * Registry subcommands that appear nowhere in their command's help block. * Commands without a dedicated help block are skipped (nothing to diff). */ function scanSubcommandDrift(): { missing: string[]; commandCount: number; subcommandCount: number; } { const helpBlocks = extractNamedHelpBlocks(); const missing: string[] = []; let commandCount = 0; let subcommandCount = 0; for (const command of COMMANDS) { if (!command.subcommands?.length) continue; const name = pascalCase(command.name); const block = helpBlocks.get(name); if (block === undefined) continue; commandCount++; subcommandCount += command.subcommands.length; for (const sub of command.subcommands) { if (!new RegExp(`\\b${sub.name}\\b`).test(block)) { missing.push(`celilo ${command.name} ${sub.name}`); } } } return { missing, commandCount, subcommandCount }; } describe('subcommand-surface gate: help text vs command registry', () => { it('scanned a real surface (reach floor)', () => { const scan = scanSubcommandDrift(); if (scan.commandCount < 14 || scan.subcommandCount < 60) { throw new Error( `Subcommand scan walked too little to prove anything: ${scan.commandCount} commands with help blocks, ${scan.subcommandCount} registry subcommands. Expected >= 14 commands, >= 60 subcommands. If the help-block surface genuinely shrank, update the floors here consciously.`, ); } expect(scan.commandCount).toBeGreaterThanOrEqual(14); }); it("every registry subcommand appears in its command's help block", () => { const scan = scanSubcommandDrift(); if (scan.missing.length > 0) { throw new Error( `${scan.missing.length} registry subcommand(s) appear nowhere in their command's help text (scanned ${scan.commandCount} commands, ${scan.subcommandCount} subcommands):\n${scan.missing.map((entry) => ` ${entry}`).join('\n')}\nFix: add the subcommand to its displayXxxHelp block in apps/celilo/src/cli/index.ts, or remove it from packages/core/src/command-registry.ts if it is no longer dispatched.`, ); } expect(scan.missing).toEqual([]); }); }); describe('flag-surface gate: handler reads vs command registry', () => { it('scanned a real surface (reach floor)', () => { const scan = scanHandlerFlagReads(); if (scan.fileCount < 10 || scan.byName.size < 30 || scan.totalReads < 60) { throw new Error( `Handler scan walked too little to prove anything: ${scan.fileCount} files, ${scan.byName.size} distinct flags, ${scan.totalReads} reads. Expected >= 10 files, >= 30 distinct, >= 60 reads. If the handler surface genuinely shrank, update the floors here consciously.`, ); } expect(scan.byName.size).toBeGreaterThanOrEqual(30); }); it('every flag a handler reads is declared in the command registry', () => { const scan = scanHandlerFlagReads(); const undeclared = [...scan.byName.entries()].filter(([name]) => !declaredFlags.has(name)); if (undeclared.length > 0) { const detail = undeclared .map(([name, files]) => ` --${name} read by: ${[...new Set(files)].join(', ')}`) .join('\n'); throw new Error( `${undeclared.length} handler-read flag(s) are declared nowhere in COMMANDS (scanned ${scan.fileCount} handler files, ${scan.totalReads} reads, ${scan.byName.size} distinct flags):\n${detail}\nFix: declare each flag on its command in packages/core/src/command-registry.ts.`, ); } expect(undeclared).toEqual([]); }); }); describe('flag-surface gate: help text vs command registry', () => { it('scanned a real surface (reach floor)', () => { const scan = scanAdvertisedFlags(); if (scan.blockCount < 15 || scan.byName.size < 60) { throw new Error( `Help-text scan walked too little to prove anything: ${scan.blockCount} help blocks, ${scan.byName.size} distinct advertised flags. Expected >= 15 blocks, >= 60 distinct. If the help surface genuinely shrank, update the floors here consciously.`, ); } expect(scan.byName.size).toBeGreaterThanOrEqual(60); }); it('every flag advertised in help text is declared in the command registry', () => { const scan = scanAdvertisedFlags(); const undeclared = [...scan.byName].filter( (name) => !declaredFlags.has(name) && !flagsOutsideTheRegistry.has(name), ); if (undeclared.length > 0) { throw new Error( `${undeclared.length} flag(s) advertised in index.ts help text are declared nowhere in COMMANDS (scanned ${scan.blockCount} help blocks, ${scan.byName.size} distinct advertised flags): ${undeclared.map((n) => `--${n}`).join(', ')}\nFix: declare each flag on its command in packages/core/src/command-registry.ts.`, ); } expect(undeclared).toEqual([]); }); });