import * as fs from 'node:fs'; import * as path from 'node:path'; import { expect } from '../../test-utils'; /** * Every `rp ` we print at a user must be a command line the * parser accepts. * * Two tips told users to run `rp pull -f -ns`. `-ns` has never been registered: * commander reads it as `-n -s` and exits with `error: unknown option '-ns'`, so * the advice printed on a recoverable error sent the user to a dead end instead * of out of it. */ // Compiled to dist/src/__tests__, so walk up to the checkout that holds src/. const findRepoRoot = (): string => { let dir = __dirname; while (!fs.existsSync(path.join(dir, 'src', 'index.ts'))) { const parent = path.dirname(dir); if (parent === dir) throw new Error('could not locate the repo root from ' + __dirname); dir = parent; } return dir; }; const repoRoot = findRepoRoot(); const srcDir = path.join(repoRoot, 'src'); const walk = (dir: string): string[] => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const full = path.join(dir, entry.name); if (entry.isDirectory()) return entry.name === '__tests__' ? [] : walk(full); return entry.isFile() && entry.name.endsWith('.ts') ? [full] : []; }); /** * A commented-out `.option(…)` is not a registration. `src/index.ts` keeps one * (`-i, --integration`), and counting it would let `rp test -i` pass a check * whose whole job is to reject a flag the parser rejects. */ const stripComments = (source: string): string => source .split('\n') .filter((line) => !line.trimStart().startsWith('//')) .join('\n'); /** `.option('-f, --force', …)` / `.addOption(new Option('--no-sort', …))` → every token it accepts. */ const registeredFlagsByCommand = (): Map> => { const source = stripComments(fs.readFileSync(path.join(srcDir, 'index.ts'), 'utf8')); // Commander adds `help` itself; seed it so a `rp help` tip is not read as a // command that does not exist. const byCommand = new Map>([['help', new Set(['-h', '--help'])]]); let current: Set | null = null; // One ordered pass so each option lands on the command it is chained to. // Matching the whole file rather than line by line, because a long spec wraps // onto its own line; and both registration forms count — `.option(…)`, and // `.addOption(new Option(…))` for the ones that need `.choices()`. The command // name is NOT closed by a quote: `.command('clone ')` // declares positional args in the same string, and requiring the quote drops // those commands entirely — their options then leak onto the previous one. const pattern = /\.command\(\s*'([a-z-]+)|(?:\.option\(|new Option\()\s*'([^']+)'/g; for (const match of source.matchAll(pattern)) { const [, command, spec] = match; if (command) { // Commander gives every command `-h, --help` for free. current = new Set(['-h', '--help']); byCommand.set(command, current); } else if (spec && current) { for (const token of spec.split(/[,\s]+/)) { if (token.startsWith('-')) current.add(token); } } } return byCommand; }; /** `rp pull -f --no-sort` inside a string literal → { command, flags }. */ const tipsInSource = (): { file: string; command: string; flags: string[] }[] => walk(srcDir).flatMap((file) => { const text = fs.readFileSync(file, 'utf8'); // Capture the command and the rest of its line, stopping at a quote or // backtick, then pull every flag-shaped token out of it. Scanning the run // rather than a chain of ` -x` groups keeps `rp push -t --no-sort` // whole — a flag that follows an option's VALUE was being dropped. return [...text.matchAll(/\brp ([a-z][a-z-]*):?([^\n'"`]*)/g)].map((match) => ({ file: path.relative(repoRoot, file), command: match[1], flags: [...match[2].matchAll(/(?:^|\s)(-{1,2}[A-Za-z][\w-]*)/g)].map((flag) => flag[1]), })); }); describe('user-facing tips', function () { const registered = registeredFlagsByCommand(); const tips = tipsInSource(); it('finds the commands and the tips it is meant to be checking', function () { // Without this, a regex that stops matching turns the suite below into a // pass over an empty list — the exact shape that let `-ns` survive. expect(srcDir, 'src/ must be readable from the test').to.satisfy(fs.existsSync); // The FULL command set, not a couple of samples: a parser that silently // drops a command drops its tips too, and the check passes over nothing. // `create` and `clone` declare positional args inside the same string, so // they are exactly the ones a stricter pattern loses. expect([...registered.keys()]).to.have.members([ 'generate', 'create', 'clone', 'pull', 'push', 'publish', 'render', 'test', 'ai-test', 'invoke', 'logs', 'diff', 'ts-build', 'help', // commander's own, seeded above ]); expect([...registered.get('pull')!]).to.include.members(['-f', '--force', '--no-sort']); // `.addOption(new Option(…))` is the other registration form — missing it // would under-register and flag a real flag as invented. expect([...registered.get('logs')!]).to.include.members(['-s', '--status', '-l', '--level']); // A commented-out `.option` must not register: `rp test -i` has to fail. expect([...registered.get('test')!]).to.not.include('-i'); expect(tips.length, 'at least one `rp ` tip in src/').to.be.greaterThan(0); }); it('only ever suggests flags the command actually registers', function () { // An unknown command is a finding too, not a row to skip — dropping it is // how a typo'd `rp pul -f` would sail through the check. const bad = tips.flatMap((tip) => { const flags = registered.get(tip.command); if (!flags) return [`${tip.file}: "rp ${tip.command}" — no such command`]; return tip.flags .filter((flag) => !flags.has(flag)) .map((flag) => `${tip.file}: "rp ${tip.command} ${flag}" — ${tip.command} has no ${flag}`); }); expect(bad, bad.join('\n')).to.deep.equal([]); }); it('rejects a suggested flag that does not exist (proves the check can fail)', function () { const pull = registered.get('pull')!; expect(pull.has('-ns'), 'the flag two tips used to suggest').to.equal(false); expect(pull.has('-n'), 'the flag the support bot then invented').to.equal(false); }); });