fix(commands): Phase 0 gives the arguments to the parser on stdin, never as shell words
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f1704f0800
commit
2c0a98c1ba
8 changed files with 157 additions and 29 deletions
|
|
@ -1225,7 +1225,7 @@ test('[D67] the End to end section says to record run_seen, also when no app was
|
|||
// now carries disable-model-invocation, so the model cannot start it.
|
||||
|
||||
const ENDSESSION = 'commands/trekendsession.md';
|
||||
const ENDSESSION_PARSER_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession -- $ARGUMENTS';
|
||||
const ENDSESSION_PARSER_LINE = "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'";
|
||||
|
||||
test('[#15] /trekendsession Phase 0 has no "contains --help or -h" check, and its parser line run on fix-header gives no help', () => {
|
||||
const phase0 = section(read(ENDSESSION), '## Phase 0 — `--help` handling');
|
||||
|
|
@ -1238,24 +1238,26 @@ test('[#15] /trekendsession Phase 0 has no "contains --help or -h" check, and it
|
|||
const block = bashBlockHolding(phase0, cmd);
|
||||
assert.ok(block, 'the parser line is not in a runtime ```bash block');
|
||||
assert.equal(block.split('\n')[0], 'setopt nonomatch 2>/dev/null || true', 'the block does not open with setopt nonomatch');
|
||||
const plain = runParser(cmd, 'fix-header');
|
||||
assert.deepEqual(block.split('\n').slice(-2), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
|
||||
'the block does not end with $ARGUMENTS as the heredoc body and its closing line');
|
||||
const plain = runParser(block, 'fix-header');
|
||||
assert.equal(plain.status, 0, `the parser line refused fix-header: ${plain.stdout}${plain.stderr}`);
|
||||
const r = JSON.parse(plain.stdout);
|
||||
assert.equal(r.flags['--help'], undefined, 'fix-header set the --help flag');
|
||||
assert.deepEqual(r.positional, ['fix-header']);
|
||||
assert.notEqual(r.positional[0], '-h');
|
||||
const help = runParser(cmd, '--help');
|
||||
const help = runParser(block, '--help');
|
||||
assert.equal(help.status, 0, help.stdout + help.stderr);
|
||||
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'control: --help must set the help flag');
|
||||
const two = runParser(cmd, 'fix-header "Session 2"');
|
||||
const two = runParser(block, 'fix-header "Session 2"');
|
||||
assert.equal(two.status, 0, two.stdout + two.stderr);
|
||||
assert.deepEqual(JSON.parse(two.stdout).positional, ['fix-header', 'Session 2']);
|
||||
});
|
||||
|
||||
test('[#15] /trekendsession Phase 2 reads the brief path and the label from the parser\'s positional', () => {
|
||||
test('[#15] /trekendsession Phase 2 reads the brief path from the parser\'s positional and the label from its tail', () => {
|
||||
const phase2 = flat(section(read(ENDSESSION), '## Phase 2 — Required args check (headless-safe)'));
|
||||
assert.match(phase2, /`positional\[0\]`/, 'Phase 2 does not read <next-brief-path> from positional[0]');
|
||||
assert.match(phase2, /`positional\[1\]`/, 'Phase 2 does not read <next-label> from positional[1]');
|
||||
assert.match(phase2, /`<next-label>` is the parser's `tail`/, 'Phase 2 does not read <next-label> from the parser\'s tail');
|
||||
});
|
||||
|
||||
test('[#67] /trekendsession sets disable-model-invocation, and CLAUDE.md names it as the one command that does', () => {
|
||||
|
|
@ -1293,7 +1295,7 @@ test('keep: commands/trekreview.md carries no disable-model-invocation field (/t
|
|||
// passes the state file, so the state-anchored staleness check runs.
|
||||
|
||||
const CONTINUE = 'commands/trekcontinue.md';
|
||||
const CONTINUE_PARSER_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue -- $ARGUMENTS';
|
||||
const CONTINUE_PARSER_LINE = "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'";
|
||||
const CONSISTENCY_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/validators/next-session-prompt-validator.mjs --json --consistency <RESOLVED-PATH-A> <RESOLVED-PATH-B> --state-file <RESOLVED-PATH-STATE>';
|
||||
|
||||
test('[#66] /trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its parser line run with --help sets the help flag', () => {
|
||||
|
|
@ -1303,19 +1305,21 @@ test('[#66] /trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its pars
|
|||
assert.equal(cmd, CONTINUE_PARSER_LINE, 'the Phase 0 parser line is not the trekcontinue parser call on $ARGUMENTS');
|
||||
const block = bashBlockHolding(phase0, cmd);
|
||||
assert.ok(block, 'the parser line is not in a runtime ```bash block');
|
||||
assert.deepEqual(block.split('\n').slice(-2), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
|
||||
'the block does not end with $ARGUMENTS as the heredoc body and its closing line');
|
||||
assert.match(flat(phase0), /Run first, exactly like this:/);
|
||||
assert.match(flat(phase0), /Read `flags` and `positional` from the JSON it prints/);
|
||||
assert.match(flat(phase0), /`flags\['--help'\] === true`/, 'Phase 0 does not dispatch on the parsed --help flag');
|
||||
assert.match(flat(phase0), /`positional\[0\] === '-h'`/, 'Phase 0 does not dispatch on a lone -h as the first positional');
|
||||
const help = runParser(cmd, '--help');
|
||||
const help = runParser(block, '--help');
|
||||
assert.equal(help.status, 0, help.stdout + help.stderr);
|
||||
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'the parser line run with --help does not set the help flag');
|
||||
const dir = runParser(cmd, '.claude/projects/demo');
|
||||
const dir = runParser(block, '.claude/projects/demo');
|
||||
assert.equal(dir.status, 0, dir.stdout + dir.stderr);
|
||||
const r = JSON.parse(dir.stdout);
|
||||
assert.equal(r.flags['--help'], undefined, 'a project dir set the --help flag');
|
||||
assert.deepEqual(r.positional, ['.claude/projects/demo']);
|
||||
const h = runParser(cmd, '-h');
|
||||
const h = runParser(block, '-h');
|
||||
assert.equal(h.status, 0, h.stdout + h.stderr);
|
||||
assert.equal(JSON.parse(h.stdout).positional[0], '-h');
|
||||
const p05 = flat(section(read(CONTINUE), '## Phase 0.5 — Cleanup mode dispatch'));
|
||||
|
|
@ -1323,11 +1327,15 @@ test('[#66] /trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its pars
|
|||
assert.match(p05, /After the parser has printed/);
|
||||
});
|
||||
|
||||
test('[#66] sibling: agents/review-orchestrator.md passes the arguments to the parser after --', () => {
|
||||
test('[#66] sibling: agents/review-orchestrator.md passes the arguments to the parser on stdin through the quoted heredoc', () => {
|
||||
const p1 = section(read('agents/review-orchestrator.md'), '### Phase 1 — Parse mode and validate input');
|
||||
const cmd = line(p1, 'parsers/arg-parser.mjs');
|
||||
assert.equal(cmd, 'node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview -- "$@"');
|
||||
const r = bash(`set -- --project .claude/projects/demo; ${cmd}`, ROOT);
|
||||
assert.equal(cmd, "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'");
|
||||
const lines = p1.split('\n');
|
||||
const at = lines.findIndex((l) => l.trim() === cmd);
|
||||
assert.deepEqual(lines.slice(at + 1, at + 3), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
|
||||
'the parser line is not followed by $ARGUMENTS and the closing line');
|
||||
const r = runParser(lines.slice(at, at + 3).join('\n'), '--project .claude/projects/demo');
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.equal(JSON.parse(r.stdout).flags['--project'], '.claude/projects/demo', 'the parser did not see --project');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@
|
|||
// to the Pass 3 skip.
|
||||
// K3 The orchestrator's 4-pass item 3 names the brief-conflict lowering as
|
||||
// the exception to the catalogue reset.
|
||||
// ARG /trekendsession and /trekcontinue Phase 0 and /trekreview Phase 1
|
||||
// give $ARGUMENTS to the arg parser (`--stdin`) as the one body line of
|
||||
// a quoted heredoc, never as shell words.
|
||||
// M8 /trekcontinue and /trekendsession Phase 0 stop on the parser's exit 1
|
||||
// with its `errors`, and stop when the parser prints no JSON.
|
||||
// M10 /trekendsession Phase 2 reads the label from the parser's `tail`.
|
||||
// No static import of a new export: the file must load at be0b631.
|
||||
// Fix the SOURCE a pin guards, not the pin.
|
||||
|
||||
|
|
@ -202,3 +208,92 @@ test('[K3] orchestrator item 3 names the brief-conflict lowering as the exceptio
|
|||
assert.match(next, /MAJOR[^.]*\bMINOR\b/, `the exception does not say a MAJOR drops to MINOR: ${next}`);
|
||||
assert.match(next, /`brief_lowered_from: MAJOR`/, `the exception does not name brief_lowered_from: MAJOR: ${next}`);
|
||||
});
|
||||
|
||||
// --- ARG / M8 / M10 ----------------------------------------------------------------
|
||||
|
||||
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
||||
const ARGS_OPENER = `<<'${ARGS_DELIM}'`;
|
||||
|
||||
/** The text of a `## ` section of `rel` from the line starting with `heading` to the next `## ` line outside a fence. */
|
||||
function phase(rel, heading) {
|
||||
const lines = read(rel).split('\n');
|
||||
const s = lines.findIndex((l) => l.startsWith(heading));
|
||||
assert.ok(s !== -1, `${rel} must hold a heading starting "${heading}"`);
|
||||
let fenced = false;
|
||||
for (let i = s + 1; i < lines.length; i++) {
|
||||
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
|
||||
if (!fenced && lines[i].startsWith('## ')) return lines.slice(s, i).join('\n');
|
||||
}
|
||||
return lines.slice(s).join('\n');
|
||||
}
|
||||
|
||||
// [file, command, the section that runs the parser]
|
||||
const PARSER_PHASES = [
|
||||
['commands/trekendsession.md', 'trekendsession', '## Phase 0 — `--help` handling'],
|
||||
['commands/trekcontinue.md', 'trekcontinue', '## Phase 0 — `--help` handling'],
|
||||
['commands/trekreview.md', 'trekreview', '## Phase 1 — Parse mode and validate input'],
|
||||
];
|
||||
|
||||
/** The one runtime ```bash block of the section that runs the arg parser for `cmd`, as lines, with the parser line's index. */
|
||||
function parserBlock(rel, cmd, heading) {
|
||||
const text = phase(rel, heading);
|
||||
const blocks = text.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0])
|
||||
.filter((b) => b.includes(`lib/parsers/arg-parser.mjs --command ${cmd}`));
|
||||
assert.equal(blocks.length, 1, `${rel} ${heading} must hold one \`\`\`bash block that runs the ${cmd} arg parser; found ${blocks.length}`);
|
||||
const lines = blocks[0].split('\n');
|
||||
const at = lines.findIndex((l) => l.includes(`lib/parsers/arg-parser.mjs --command ${cmd}`));
|
||||
return { lines, at };
|
||||
}
|
||||
|
||||
test('[ARG] each of the three parser lines reads the arguments from a quoted heredoc', () => {
|
||||
for (const [rel, cmd, heading] of PARSER_PHASES) {
|
||||
const { lines, at } = parserBlock(rel, cmd, heading);
|
||||
const opener = lines[at].trim();
|
||||
assert.ok(opener.endsWith(` ${ARGS_OPENER}`),
|
||||
`${rel}: the parser line does not end with the quoted heredoc opener ${ARGS_OPENER}: ${opener}`);
|
||||
assert.equal(lines[at + 1], '$ARGUMENTS',
|
||||
`${rel}: the line inside the heredoc is not $ARGUMENTS alone: ${JSON.stringify(lines[at + 1])}`);
|
||||
assert.match(opener, new RegExp(`^node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/parsers/arg-parser\\.mjs --command ${cmd} --stdin <<`),
|
||||
`${rel}: the parser line does not read stdin (--command ${cmd} --stdin right after arg-parser.mjs): ${opener}`);
|
||||
const filled = lines.filter((l) => l.trim() !== '');
|
||||
assert.ok(lines[at + 2] === ARGS_DELIM && filled[filled.length - 1] === ARGS_DELIM,
|
||||
`${rel}: the closing line ${ARGS_DELIM} does not follow $ARGUMENTS as the block's last non-empty line: ${JSON.stringify(lines)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[ARG] no line outside the heredoc carries $ARGUMENTS in those blocks', () => {
|
||||
for (const [rel, cmd, heading] of PARSER_PHASES) {
|
||||
const { lines } = parserBlock(rel, cmd, heading);
|
||||
const open = lines.findIndex((l) => l.trimEnd().endsWith(ARGS_OPENER));
|
||||
assert.ok(open !== -1, `${rel}: the parser block opens no ${ARGS_OPENER} heredoc: ${JSON.stringify(lines)}`);
|
||||
const close = lines.findIndex((l, i) => i > open && l.trim() === ARGS_DELIM);
|
||||
assert.ok(close !== -1, `${rel}: the parser block's heredoc has no closing line ${ARGS_DELIM}`);
|
||||
const outside = lines.filter((l, i) => (i <= open || i >= close) && l.includes('$ARGUMENTS'));
|
||||
assert.deepEqual(outside, [], `${rel}: a line outside the heredoc carries $ARGUMENTS`);
|
||||
}
|
||||
});
|
||||
|
||||
const PHASE0 = '## Phase 0 — `--help` handling';
|
||||
const STOP_COMMANDS = ['commands/trekcontinue.md', 'commands/trekendsession.md'];
|
||||
|
||||
test('[M8] /trekcontinue and /trekendsession Phase 0 stop on exit 1 with the parser\'s errors', () => {
|
||||
for (const rel of STOP_COMMANDS) {
|
||||
const hits = sentencesWith(phase(rel, PHASE0), 'exit 1');
|
||||
assert.ok(hits.some((s) => /^On exit 1, print (its|the parser's) `errors` and stop\.$/.test(s)),
|
||||
`${rel} Phase 0 does not say: on exit 1, print the parser's \`errors\` and stop: ${hits.join(' | ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[M8] /trekcontinue and /trekendsession Phase 0 stop when the parser prints no JSON', () => {
|
||||
for (const rel of STOP_COMMANDS) {
|
||||
const hits = sentencesWith(phase(rel, PHASE0), 'no JSON');
|
||||
assert.ok(hits.some((s) => /\bprints no JSON\b/.test(s) && /\bthe parser did not run\b/.test(s) && /\band stop\.$/.test(s)),
|
||||
`${rel} Phase 0 does not say: when the parser prints no JSON, say that it did not run and stop: ${hits.join(' | ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[M10] /trekendsession Phase 2 reads the label from tail', () => {
|
||||
const p2 = flat(phase('commands/trekendsession.md', '## Phase 2 — Required args check'));
|
||||
assert.doesNotMatch(p2, /`positional\[1\]`/, 'Phase 2 still reads <next-label> from positional[1], which splits a label at spaces');
|
||||
assert.match(p2, /`<next-label>` is the parser's `tail`/, `Phase 2 does not read <next-label> from the parser's tail: ${p2}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -149,13 +149,17 @@ test('plan-halts-without-phase-signals: the validator\'s own code must be in the
|
|||
// ---- case 5: the missing-flag error comes from code ----
|
||||
|
||||
test('review-requires-project: the arg parser, run as trekreview.md spells it, prints the error (exit 1)', () => {
|
||||
const lines = read('commands/trekreview.md').split('\n').filter((l) =>
|
||||
/^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/parsers\/arg-parser\.mjs --command trekreview\b/.test(l));
|
||||
assert.equal(lines.length, 1, 'trekreview.md must carry exactly one arg-parser line');
|
||||
const line = lines[0].trim();
|
||||
assert.match(line, /\$ARGUMENTS/, 'the parser must receive $ARGUMENTS (the Bash tool has no "$@")');
|
||||
const all = read('commands/trekreview.md').split('\n');
|
||||
const at = all.map((l, i) =>
|
||||
(/^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/parsers\/arg-parser\.mjs --command trekreview\b/.test(l) ? i : -1)).filter((i) => i >= 0);
|
||||
assert.equal(at.length, 1, 'trekreview.md must carry exactly one arg-parser line');
|
||||
const line = all[at[0]].trim();
|
||||
assert.match(line, / --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'$/, 'the parser must read the arguments on stdin from the quoted heredoc');
|
||||
const block = all.slice(at[0], at[0] + 3).map((l) => l.trim());
|
||||
assert.deepEqual(block.slice(1), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
|
||||
'the parser must receive $ARGUMENTS as the heredoc body (the Bash tool has no "$@")');
|
||||
const run = (args) => spawnSync('bash', ['-c',
|
||||
line.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT).split('$ARGUMENTS').join(args)], { encoding: 'utf8' });
|
||||
block.join('\n').split('${CLAUDE_PLUGIN_ROOT}').join(ROOT).split('$ARGUMENTS').join(args)], { encoding: 'utf8' });
|
||||
const none = run('');
|
||||
assert.equal(none.status, 1, `missing --project must exit 1: ${none.stdout} ${none.stderr}`);
|
||||
assert.deepEqual(JSON.parse(none.stdout).errors.map((e) => e.code), ['ARG_REQUIRED_MISSING']);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue