/** * lib/cli-args adoption — every colocated CLI under development/ parses argv * through the two wrappers: `parseArgsOrFail` (imported `as parseArgs`) or * `withEnvelopeExit(program, command)` for commander. That is what makes an * unknown flag / missing value ALWAYS answer a `success:false` envelope on * stdout instead of exit 1 with an empty stdout (audit 2026-09-24, C-WARN-11 / * B-WARN-09, lot 11). Nothing pinned the adoption (QC 2026-09-24): a new CLI * written with raw `node:util` parseArgs or a bare `new Command()` would * regress silently. This test walks every `cli//index.ts` and names each * parser that is not one of the two wrappers. * * A hand-rolled parser (no node:util, no commander) is tolerated only by an * explicit path in HAND_ROLLED — never by a loose pattern — and the allow-list * is checked back: an entry that no longer needs the allowance is red. */ import { describe, it, expect } from 'vitest' import { existsSync, readFileSync } from 'node:fs' import { join, relative } from 'node:path' import { SKILLS_ROOT, listFiles } from './helpers/cli-options.js' const DEV_ROOT = join(SKILLS_ROOT, 'development') /** A CLI entry is `…/cli//index.ts` — one folder under `cli/`, outside any `__tests__`. */ const CLI_ENTRY = /[\\/]cli[\\/][^\\/]+[\\/]index\.ts$/ const rel = (p: string): string => relative(SKILLS_ROOT, p).replace(/\\/g, '/') /** * CLIs whose argv parser is hand-rolled — neither node:util nor commander — * by exact path (under templates/skills), with the reason. Every other entry * must use one of the two lib/cli-args wrappers. */ const HAND_ROLLED: ReadonlyMap = new Map([ // Declares its own `function parseArgs(argv)` over `process.argv` (`--key value` / // `--flag` pairs) and imports nothing from node:util; its flags are the // CUSTOM_PARSERS entry of helpers/cli-options.ts. ['development/smoke-test/cli/run-smoke/index.ts', 'own parseArgs(argv) over process.argv — no node:util, no commander'], ]) const CALLS_PARSEARGS = /\bparseArgs\s*\(/ const CALLS_NEW_COMMAND = /\bnew\s+Command\s*\(/ const CALLS_WITH_ENVELOPE_EXIT = /\bwithEnvelopeExit\s*\(/ /** `import { …parseArgs… } from 'node:util'` (or `'util'`) — the raw parser, named. */ const IMPORTS_RAW_PARSEARGS = /import\s*\{[^}]*\bparseArgs\b[^}]*\}\s*from\s*['"](?:node:)?util['"]/ /** `import * as from 'node:util'` — the raw parser reachable as `.parseArgs(`. */ const IMPORTS_UTIL_NAMESPACE = /import\s*\*\s*as\s+(\w+)\s+from\s*['"](?:node:)?util['"]/ /** The lib module as a CLI spells it: `'../../../../lib/cli-args.js'` (any depth). */ const CLI_ARGS_MODULE = String.raw`['"](?:\.\.\/)+lib\/cli-args(?:\.js)?['"]` const IMPORTS_PARSEARGS_OR_FAIL = new RegExp(String.raw`import\s*\{[^}]*\bparseArgsOrFail\b[^}]*\}\s*from\s*` + CLI_ARGS_MODULE) const IMPORTS_WITH_ENVELOPE_EXIT = new RegExp(String.raw`import\s*\{[^}]*\bwithEnvelopeExit\b[^}]*\}\s*from\s*` + CLI_ARGS_MODULE) /** Why a CLI source does not go through lib/cli-args — empty when it does. */ function adoptionDefects(source: string): string[] { const defects: string[] = [] const parses = CALLS_PARSEARGS.test(source) const commands = CALLS_NEW_COMMAND.test(source) if (parses) { if (IMPORTS_RAW_PARSEARGS.test(source)) { defects.push("parseArgs imported from 'node:util' — import { parseArgsOrFail as parseArgs } from lib/cli-args.js instead") } const ns = IMPORTS_UTIL_NAMESPACE.exec(source)?.[1] if (ns && new RegExp(String.raw`\b${ns}\.parseArgs\s*\(`).test(source)) { defects.push(`${ns}.parseArgs( from 'node:util' — import { parseArgsOrFail as parseArgs } from lib/cli-args.js instead`) } if (!IMPORTS_PARSEARGS_OR_FAIL.test(source)) { defects.push('parseArgs( called without importing parseArgsOrFail from lib/cli-args.js') } } if (commands) { if (!CALLS_WITH_ENVELOPE_EXIT.test(source)) defects.push('new Command() never wrapped by withEnvelopeExit(program, command)') if (!IMPORTS_WITH_ENVELOPE_EXIT.test(source)) defects.push('withEnvelopeExit not imported from lib/cli-args.js') } if (!parses && !commands) { defects.push('no argv parser recognised (neither parseArgs( nor new Command() — a hand-rolled parser needs an explicit HAND_ROLLED entry)') } return defects } const entries = listFiles(DEV_ROOT, (p) => CLI_ENTRY.test(p) && !/[\\/]__tests__[\\/]/.test(p)) const paths = entries.map(rel) describe('development/**/cli/*/index.ts — argv parsing goes through lib/cli-args', () => { it('walks every CLI entry (an empty walk is red)', () => { expect(entries.length, paths.join('\n')).toBeGreaterThanOrEqual(45) }) it('every entry uses parseArgsOrFail or withEnvelopeExit — offenders named by path', () => { const offenders: string[] = [] let wrapped = 0 for (const file of entries) { const path = rel(file) if (HAND_ROLLED.has(path)) continue const defects = adoptionDefects(readFileSync(file, 'utf-8')) if (defects.length > 0) offenders.push(`${path}: ${defects.join('; ')}`) else wrapped++ } expect(offenders, offenders.join('\n')).toEqual([]) expect(wrapped).toBe(entries.length - HAND_ROLLED.size) }) it('the allow-list is exact: each entry exists, is a walked CLI entry, still needs the allowance, and never imports the raw parser', () => { for (const [path, why] of HAND_ROLLED) { const abs = join(SKILLS_ROOT, path) expect(existsSync(abs), `${path} (${why}) no longer exists — drop it from HAND_ROLLED`).toBe(true) expect(paths, `${path} is not a walked CLI entry — drop it from HAND_ROLLED`).toContain(path) const source = readFileSync(abs, 'utf-8') expect(adoptionDefects(source), `${path} now goes through lib/cli-args — drop it from HAND_ROLLED`).not.toEqual([]) // A hand-rolled parser is the tolerated shape; a raw node:util parseArgs never is. expect(IMPORTS_RAW_PARSEARGS.test(source), `${path} imports parseArgs from node:util — the allowance does not cover that`).toBe(false) expect(IMPORTS_UTIL_NAMESPACE.test(source), `${path} imports a node:util namespace — the allowance does not cover that`).toBe(false) } }) }) describe('the predicate itself', () => { it('FIRES — raw node:util parseArgs', () => { const raw = [ "import { parseArgs } from 'node:util'", "import { printEnvelope } from '../../lib/output.js'", "const { values } = parseArgs({ options: { spec: { type: 'string' } } })", ].join('\n') const defects = adoptionDefects(raw) expect(defects.join('\n')).toMatch(/parseArgs imported from 'node:util'/) expect(defects.join('\n')).toMatch(/without importing parseArgsOrFail/) }) it('FIRES — raw parseArgs through a node:util namespace', () => { const raw = "import * as util from 'node:util'\nconst { values } = util.parseArgs({ options: {} })\n" expect(adoptionDefects(raw).join('\n')).toMatch(/util\.parseArgs\( from 'node:util'/) }) it("FIRES — the bare 'util' specifier is the same raw parser", () => { const raw = "import { parseArgs } from 'util'\nparseArgs({ options: {} })\n" expect(adoptionDefects(raw).join('\n')).toMatch(/parseArgs imported from 'node:util'/) }) it('FIRES — a bare new Command() without withEnvelopeExit', () => { const raw = "import { Command } from 'commander'\nconst program = new Command()\nprogram.option('--spec ').parse()\n" const defects = adoptionDefects(raw) expect(defects.join('\n')).toMatch(/never wrapped by withEnvelopeExit/) expect(defects.join('\n')).toMatch(/withEnvelopeExit not imported/) }) it('FIRES — withEnvelopeExit called but not imported from lib/cli-args', () => { const raw = "import { Command } from 'commander'\nconst withEnvelopeExit = (p: any) => p\nconst program = withEnvelopeExit(new Command(), 'x')\n" expect(adoptionDefects(raw)).toEqual(['withEnvelopeExit not imported from lib/cli-args.js']) }) it('FIRES — no parser at all (process.argv read by hand)', () => { const raw = "const i = process.argv.indexOf('--spec')\nconst spec = i >= 0 ? process.argv[i + 1] : undefined\n" expect(adoptionDefects(raw)).toEqual([ 'no argv parser recognised (neither parseArgs( nor new Command() — a hand-rolled parser needs an explicit HAND_ROLLED entry)', ]) }) it('SILENT — parseArgsOrFail imported as parseArgs from lib/cli-args', () => { const ok = [ "import { parseArgsOrFail as parseArgs } from '../../../../lib/cli-args.js'", "import type { ParseArgsConfig } from 'node:util'", "const { values } = parseArgs({ options: { spec: { type: 'string' } } })", ].join('\n') expect(adoptionDefects(ok)).toEqual([]) }) it('SILENT — withEnvelopeExit(new Command(), command) from lib/cli-args', () => { const ok = [ "import { Command } from 'commander'", "import { withEnvelopeExit } from '../../../../lib/cli-args.js'", "const program = withEnvelopeExit(new Command(), 'audit-dev-x')", ].join('\n') expect(adoptionDefects(ok)).toEqual([]) }) it('SILENT — the two real adopters in the tree (one parseArgs CLI, one commander CLI)', () => { for (const path of [ 'development/backend/data-layer/cli/scaffold-entity/index.ts', 'development/audit-dev-api/cli/audit-dev-api/index.ts', ]) { expect(paths).toContain(path) expect(adoptionDefects(readFileSync(join(SKILLS_ROOT, path), 'utf-8')), path).toEqual([]) } }) })