/** * cli-options — read the flags a colocated CLI DECLARES (parseArgs `options` * block, commander `.option()` / `.requiredOption()`), and the flags a * documented invocation USES. The doc ↔ CLI contract test compares the two: * seven documented invocations could not run before 2026-09-24 (`run-smoke * --spec`, `audit-dev-wire --module-path`, `aggregate-component-registry * --spec`, `--theme-only`, ui-polish `--audit/--apply`, `scaffold-tests * --layer`…) and nothing could tell (audit D-CRIT-01/02/03/05, B-WARN-19). */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) /** templates/skills */ export const SKILLS_ROOT = resolve(here, '../../..') export type ArgStyle = 'parseArgs' | 'commander' | 'custom' | 'unknown' export interface DeclaredOptions { style: ArgStyle /** Flag names without the leading dashes (`spec`, `project-path`, `no-write-report`…). */ flags: Set } /** * CLIs whose argv parsing is hand-rolled (no `options:` block, no commander): * their flags are listed here, keyed by the path under templates/skills. */ const CUSTOM_PARSERS: Record = { 'development/smoke-test/cli/run-smoke/index.ts': [ 'project-path', 'prd-slice', 'backend-port', 'frontend-port', 'timeout-ms', 'dry-run', 'self-boot', 'require-static-pass', 'module-code', 'skip-browser', 'module-root', 'admin-token', 'exercise-actions', ], } /** Text between the `options: {` opening brace and its matching close. */ function optionsBlock(source: string): string | null { const m = /\boptions\s*:\s*\{/.exec(source) if (!m) return null let depth = 0 for (let i = m.index + m[0].length - 1; i < source.length; i++) { const ch = source[i] if (ch === '{') depth++ else if (ch === '}') { depth-- if (depth === 0) return source.slice(m.index + m[0].length, i) } } return null } export function declaredOptions(indexPath: string): DeclaredOptions { const rel = resolve(indexPath).slice(SKILLS_ROOT.length + 1).replace(/\\/g, '/') const custom = CUSTOM_PARSERS[rel] if (custom) return { style: 'custom', flags: new Set(custom) } const source = readFileSync(indexPath, 'utf-8') const flags = new Set() if (/from\s+['"]commander['"]/.test(source)) { for (const m of source.matchAll(/\.(?:requiredOption|option)\(\s*['"](--[^\s'"<]+)/g)) { const flag = m[1].slice(2) flags.add(flag) if (flag.startsWith('no-')) flags.add(flag.slice(3)) // commander: --no-x also accepts --x } if (flags.size > 0) return { style: 'commander', flags } } if (/\bparseArgs\s*\(/.test(source)) { const block = optionsBlock(source) if (block !== null) { // Top-level keys only (`spec: {`, `'spec-file': {`, `"dry-run": {`), wherever // they sit — one key per line or the whole options object on a single line. for (const k of block.matchAll(/(?:'([^']+)'|"([^"]+)"|([A-Za-z_][\w-]*))\s*:\s*\{/g)) { const before = block.slice(0, k.index) const depth = (before.match(/\{/g) ?? []).length - (before.match(/\}/g) ?? []).length if (depth === 0) flags.add(k[1] ?? k[2] ?? k[3]) } return { style: 'parseArgs', flags } } } return { style: 'unknown', flags } } export interface Invocation { /** Path as written in the document (e.g. `skills/development/.../index.ts`). */ written: string /** The argument text (continuation lines joined). */ args: string /** Flag names used (without dashes, `--x=1` → `x`), quoted values removed first. */ flags: string[] /** Values passed to `--rules` (comma-split), when present. */ rules: string[] /** The source document and 1-based line. */ file: string line: number } /** Every `npx [--prefer-offline] tsx …/index.ts ` invocation of a text, continuation lines joined. */ export function extractInvocations(text: string, file: string): Invocation[] { const out: Invocation[] = [] const re = /npx\s+(?:--prefer-offline\s+)?tsx\s+(\S*?index\.ts)((?:[^\n]*\\\s*\r?\n)*[^\n]*)/g for (const m of text.matchAll(re)) { const written = m[1].replace(/^["'`]|["'`]$/g, '') const rawArgs = m[2].replace(/\\\s*\r?\n/g, ' ') // Drop quoted values (a `--spec '{…}'` JSON never carries a flag of the CLI) // and inline-code closers, then read the flags. const stripped = rawArgs.replace(/'[^']*'|"[^"]*"/g, ' ').replace(/`.*$/, ' ') const flags: string[] = [] for (const f of stripped.matchAll(/(?:^|\s)--([A-Za-z][\w-]*)/g)) flags.push(f[1]) // A full ui-polish id is `R7-error-banner-anatomy` (lower-case tail) — capture it whole. const rulesMatch = /--rules[=\s]+["']?([A-Z][A-Za-z0-9-]*(?:\s*,\s*[A-Z][A-Za-z0-9-]*)*)/.exec(rawArgs) const rules = rulesMatch ? rulesMatch[1].split(',').map((s) => s.trim()).filter(Boolean) : [] const line = text.slice(0, m.index).split('\n').length out.push({ written, args: rawArgs.trim(), flags, rules, file, line }) } return out } /** * A file of a BA skill, in BOTH trees: the source (`business-analyse//…`) * and the deployed / mirror one, where the installer flattened it to * `ba-/…`. `rel` is spelled the source way, WITHOUT the * `business-analyse/` prefix: `audit-run/cli/audit-ba/rules/dm.ts`. A test that * reads a BA file by a bare `business-analyse/…` path is red in every deployed * tree for a reason that has nothing to do with what it checks. */ export function baSkillFile(rel: string): string { const source = join(SKILLS_ROOT, 'business-analyse', rel) return existsSync(source) ? source : join(SKILLS_ROOT, `ba-${rel}`) } /** * Resolve a documented path to its source under templates/skills. Documents * spell the DEPLOYED layout: `skills/` — and the installer flattens * `business-analyse/` to `ba-`, so both spellings resolve. * Returns null for a placeholder path (``) or a path that does not exist. */ export function resolveCliSource(written: string, fromFile?: string): string | null { if (/[<>{}…]/.test(written) || written.includes('...')) return null const idx = written.lastIndexOf('skills/') if (idx >= 0) { const suffix = written.slice(idx + 'skills/'.length) const direct = join(SKILLS_ROOT, suffix) if (existsSync(direct)) return direct const ba = /^ba-([^/]+)\/(.*)$/.exec(suffix) if (ba) { const alt = join(SKILLS_ROOT, 'business-analyse', ba[1], ba[2]) if (existsSync(alt)) return alt } return null } // A relative spelling (`cli/finish/index.ts` in a source comment): resolve against the // document's own folder and its ancestors up to templates/skills. if (fromFile) { let dir = dirname(resolve(fromFile)) while (dir.length >= SKILLS_ROOT.length) { const candidate = join(dir, written) if (existsSync(candidate)) return candidate const parent = dirname(dir) if (parent === dir) break dir = parent } } return null } /** Recursively list files under a root (no node_modules), filtered by predicate. */ export function listFiles(root: string, keep: (path: string) => boolean): string[] { const out: string[] = [] const visit = (dir: string): void => { for (const name of readdirSync(dir).sort()) { if (name === 'node_modules' || name === '.git') continue const p = join(dir, name) if (statSync(p).isDirectory()) visit(p) else if (keep(p)) out.push(p) } } visit(root) return out }