33 more tests of live behaviour from the deleted round files, each in the test file named after its source and seen failing under a mutation of that source (log in the project folder). Two stay dropped: one pinned the removed gate states, one the old Phase 1.5 file comparison. The two trekexecute.md sentences that still said /trekcontinue compares the two prompt files now say it uses the project-folder file (line count kept). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
18 KiB
JavaScript
339 lines
18 KiB
JavaScript
// tests/commands/trekendsession.test.mjs
|
|
// Regression tests for /trekendsession (commands/trekendsession.md).
|
|
//
|
|
// Bug (2026-07-03): two of the three !`...` eager-exec blocks contained
|
|
// unresolved placeholders (<project-dir> etc.). The harness executes
|
|
// eager-exec blocks at command LOAD time, so zsh parsed <project-dir> as
|
|
// input redirection and the command aborted before the model saw a single
|
|
// instruction. Eager-exec is only valid for self-contained commands.
|
|
//
|
|
// Pattern D (markdown structure) — assertions against command prose.
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { childEnv } from '../helpers/child-env.mjs';
|
|
import { findSection } from '../../lib/parsers/plan-schema.mjs';
|
|
import { readFrontmatterFields } from '../../lib/skills/skill-frontmatter.mjs';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(HERE, '..', '..');
|
|
const COMMANDS_DIR = join(ROOT, 'commands');
|
|
const COMMAND_FILE = join(COMMANDS_DIR, 'trekendsession.md');
|
|
|
|
function readCommand() {
|
|
return readFileSync(COMMAND_FILE, 'utf8');
|
|
}
|
|
|
|
function extractPhase(commandText, phaseHeader) {
|
|
const startIdx = commandText.indexOf(phaseHeader);
|
|
if (startIdx === -1) return '';
|
|
const rest = commandText.slice(startIdx);
|
|
const nextPhase = rest.search(/\n## (?:Phase |Hard )/);
|
|
if (nextPhase === -1) return rest;
|
|
return rest.slice(0, nextPhase);
|
|
}
|
|
|
|
// Extract all eager-exec blocks (!`...`) from a command/skill file,
|
|
// including multi-line blocks. Returns [{ content, line }].
|
|
function extractEagerBlocks(text) {
|
|
const blocks = [];
|
|
const re = /!`([^`]+)`/g;
|
|
let m;
|
|
while ((m = re.exec(text)) !== null) {
|
|
const line = text.slice(0, m.index).split('\n').length;
|
|
blocks.push({ content: m[1], line });
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Marketplace-wide regression guard: eager-exec blocks must be
|
|
// self-contained. An unresolved placeholder (<angle> or {curly}) in an
|
|
// eager block is executed verbatim by the shell at load time — <x> is
|
|
// parsed as input redirection and aborts the whole command load.
|
|
// ---------------------------------------------------------------
|
|
|
|
test('eager-exec guard — no !`-block in commands/ contains an unresolved placeholder', () => {
|
|
const offenders = [];
|
|
for (const file of readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md'))) {
|
|
const text = readFileSync(join(COMMANDS_DIR, file), 'utf8');
|
|
for (const { content, line } of extractEagerBlocks(text)) {
|
|
// Placeholder conventions: <angle-word> or {curly_word}. Curly must
|
|
// contain a separator (- or _) so JS destructuring like {join} in a
|
|
// legitimate self-contained script does not false-positive; angle
|
|
// placeholders are unambiguous (shell would parse them as redirects).
|
|
if (/<[a-z][a-z0-9_-]*>/.test(content) || /\{[a-z][a-z0-9]*([_-][a-z0-9]+)+\}/.test(content)) {
|
|
offenders.push(`${file}:${line}`);
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(
|
|
offenders,
|
|
[],
|
|
`eager-exec !\`-blocks run at command LOAD time and must be self-contained; ` +
|
|
`placeholder found in: ${offenders.join(', ')}`,
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------
|
|
// trekendsession-specific: exactly one eager block (Phase 1 project
|
|
// discovery — self-contained, legitimate); Phases 3 and 4 are runtime
|
|
// Bash-tool commands with model-substituted values, never eager.
|
|
// ---------------------------------------------------------------
|
|
|
|
test('trekendsession — exactly one eager-exec block remains (Phase 1 discovery)', () => {
|
|
const cmd = readCommand();
|
|
const blocks = extractEagerBlocks(cmd);
|
|
assert.equal(
|
|
blocks.length,
|
|
1,
|
|
`expected exactly 1 eager-exec block (Phase 1 discovery), got ${blocks.length} at line(s) ${blocks.map((b) => b.line).join(', ')}`,
|
|
);
|
|
assert.match(
|
|
blocks[0].content,
|
|
/readdirSync\(root\)/,
|
|
'the surviving eager block must be the self-contained Phase 1 discovery script',
|
|
);
|
|
});
|
|
|
|
test('trekendsession Phase 3 — atomic-write block is runtime Bash (no eager prefix) with plugin-root import', () => {
|
|
const phase3 = extractPhase(readCommand(), '## Phase 3 ');
|
|
assert.doesNotMatch(phase3, /!`/, 'Phase 3 must not use eager-exec — values exist only at runtime');
|
|
assert.match(
|
|
phase3,
|
|
/\$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/atomic-write\.mjs/,
|
|
'Phase 3 import must use the absolute ${CLAUDE_PLUGIN_ROOT} path — cwd is the user repo, not the plugin root',
|
|
);
|
|
assert.doesNotMatch(
|
|
phase3,
|
|
/['"]\.\/lib\/util\/atomic-write\.mjs['"]/,
|
|
'Phase 3 must not import atomic-write.mjs via a cwd-relative path',
|
|
);
|
|
});
|
|
|
|
test('trekendsession Phase 4 — validator call is runtime Bash (no eager prefix) with plugin-root path', () => {
|
|
const phase4 = extractPhase(readCommand(), '## Phase 4 ');
|
|
assert.doesNotMatch(phase4, /!`/, 'Phase 4 must not use eager-exec — the state-file path exists only at runtime');
|
|
assert.match(
|
|
phase4,
|
|
/\$\{CLAUDE_PLUGIN_ROOT\}\/lib\/validators\/session-state-validator\.mjs/,
|
|
'Phase 4 validator path must use the absolute ${CLAUDE_PLUGIN_ROOT} convention',
|
|
);
|
|
assert.doesNotMatch(
|
|
phase4,
|
|
/<[a-z][a-z0-9_-]*>/,
|
|
'Phase 4 must not use <angle> placeholders in commands — zsh parses <x> as input redirection',
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------
|
|
// DJ5 (fix round 4.5b): Phase 3 splices no label into its shell line.
|
|
// The block used to end in '{project_dir}' '{next_brief_path}'
|
|
// '{next_label}', filled in by the model: a label holding a ' closed the
|
|
// quote and the rest ran as shell. Phase 3 now re-reads $ARGUMENTS through
|
|
// the quoted heredoc on stdin, parses it with parseArgs and re-derives the
|
|
// project directory itself. The runtime tests run the block as Claude Code
|
|
// would after it substitutes ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS.
|
|
// ---------------------------------------------------------------
|
|
|
|
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
|
const PROJECT_REL = join('.claude', 'projects', '2026-01-01-demo');
|
|
const NEXT_BRIEF = '.claude/projects/2026-01-01-demo/next-brief.md';
|
|
|
|
/** The one runtime ```bash block of Phase 3 (the atomic-write block). */
|
|
function phase3Block() {
|
|
const phase3 = extractPhase(readCommand(), '## Phase 3 ');
|
|
const blocks = phase3.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0])
|
|
.filter((b) => b.includes('atomic-write.mjs'));
|
|
assert.equal(blocks.length, 1, `Phase 3 must hold one \`\`\`bash block that runs atomic-write.mjs; found ${blocks.length}`);
|
|
return blocks[0];
|
|
}
|
|
|
|
/** True when `bin` can be started from PATH. */
|
|
function onPath(bin) {
|
|
const r = spawnSync(bin, ['-c', 'exit 0'], { env: childEnv(), encoding: 'utf8' });
|
|
return r.error === undefined && r.status === 0;
|
|
}
|
|
|
|
/**
|
|
* Run the Phase 3 block with `shell` in a fresh fixture project, with
|
|
* `argText` standing where Claude Code puts $ARGUMENTS. Returns the result,
|
|
* what the run wrote, and whether a PWNED file appeared; the fixture is gone
|
|
* on return.
|
|
*/
|
|
function runPhase3(shell, argText) {
|
|
const block = phase3Block();
|
|
assert.ok(block.includes('$ARGUMENTS'),
|
|
'Phase 3 does not re-read $ARGUMENTS: its values still come from text the model splices into the shell line');
|
|
const script = block.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT).split('$ARGUMENTS').join(argText);
|
|
const fixture = mkdtempSync(join(tmpdir(), 'trekendsession-dj5-'));
|
|
try {
|
|
mkdirSync(join(fixture, PROJECT_REL), { recursive: true });
|
|
writeFileSync(join(fixture, PROJECT_REL, 'brief.md'), '# demo brief\n');
|
|
const r = spawnSync(shell, ['-c', script], { cwd: fixture, env: childEnv(), encoding: 'utf8' });
|
|
const stateFile = join(fixture, PROJECT_REL, '.session-state.local.json');
|
|
const promptFile = join(fixture, PROJECT_REL, 'NEXT-SESSION-PROMPT.local.md');
|
|
return {
|
|
r,
|
|
pwned: ['PWNED', 'PWNED2'].filter((f) => existsSync(join(fixture, f))),
|
|
state: existsSync(stateFile) ? JSON.parse(readFileSync(stateFile, 'utf8')) : null,
|
|
prompt: existsSync(promptFile) ? readFileSync(promptFile, 'utf8') : null,
|
|
};
|
|
} finally {
|
|
rmSync(fixture, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/** Assert one run wrote `label` whole and ran nothing from it. */
|
|
function assertLabelKept(shell, label) {
|
|
const { r, pwned, state, prompt } = runPhase3(shell, `${NEXT_BRIEF} ${label}`);
|
|
const how = `${shell}, label ${JSON.stringify(label)}: exit ${r.status}\n${r.stdout}${r.stderr}`;
|
|
assert.deepEqual(pwned, [], `the label ran as shell under ${how}`);
|
|
assert.equal(r.status, 0, `Phase 3 did not run cleanly under ${how}`);
|
|
assert.ok(state, `no .session-state.local.json was written under ${how}`);
|
|
assert.equal(state.next_session_label, label, `the written label is not the typed one under ${how}`);
|
|
assert.equal(state.next_session_brief_path, NEXT_BRIEF, `the written brief path is not the typed one under ${how}`);
|
|
assert.equal(state.project, PROJECT_REL, `the project directory was not re-derived under ${how}`);
|
|
assert.ok(prompt && prompt.includes(`\n# ${label}\n`), `NEXT-SESSION-PROMPT.local.md does not carry the label under ${how}`);
|
|
}
|
|
|
|
test('[DJ5] Phase 3 has no quoted placeholder for the label, brief or project', () => {
|
|
const block = phase3Block();
|
|
const placeholders = block.match(/\{[a-z][a-z0-9]*(?:[_ -][a-z0-9]+)*\}/g) || [];
|
|
assert.deepEqual(placeholders, [], 'Phase 3 still has the model fill {placeholders} into its shell line');
|
|
assert.doesNotMatch(block, /'\{/, 'Phase 3 still quotes a placeholder as a shell word');
|
|
const lines = block.split('\n');
|
|
const open = lines.findIndex((l) => l.trimEnd().endsWith(`<<'${ARGS_DELIM}'`));
|
|
assert.ok(open !== -1, `Phase 3 does not re-read the arguments through the quoted heredoc <<'${ARGS_DELIM}'`);
|
|
assert.equal(lines[open + 1], '$ARGUMENTS', 'the line inside the Phase 3 heredoc is not $ARGUMENTS alone');
|
|
const filled = lines.filter((l) => l.trim() !== '');
|
|
assert.ok(lines[open + 2] === ARGS_DELIM && filled[filled.length - 1] === ARGS_DELIM,
|
|
`the closing line ${ARGS_DELIM} does not follow $ARGUMENTS as the block's last non-empty line`);
|
|
assert.equal(lines.filter((l, i) => i !== open + 1 && l.includes('$ARGUMENTS')).length, 0,
|
|
'a line outside the Phase 3 heredoc carries $ARGUMENTS');
|
|
assert.match(block, /import \{ parseArgs \} from '\$\{CLAUDE_PLUGIN_ROOT\}\/lib\/parsers\/arg-parser\.mjs'/,
|
|
'Phase 3 does not import parseArgs at runtime from the plugin root');
|
|
});
|
|
|
|
test('[DJ5] Phase 3 run with an injection label creates no file and keeps the label whole', (t) => {
|
|
// The step's label, and the same with a command substitution: under bash,
|
|
// and under zsh (the shell the Bash tool runs) when zsh is on PATH.
|
|
const labels = ["a'; touch PWNED; echo 'b", "a'; touch PWNED; echo 'b $(touch PWNED2)"];
|
|
for (const label of labels) assertLabelKept('bash', label);
|
|
if (onPath('zsh')) {
|
|
for (const label of labels) assertLabelKept('zsh', label);
|
|
} else {
|
|
t.diagnostic('zsh is not on PATH: the zsh half of this test was not run');
|
|
}
|
|
});
|
|
|
|
test("[DJ5] a label with an apostrophe is kept", () => {
|
|
assertLabelKept('bash', "Fix the user's login");
|
|
});
|
|
|
|
// ---------------------------------------------------------------
|
|
// Phase 0 runs the arg parser and dispatches only on its exact result, so
|
|
// a slug such as "fix-header" never asks for help.
|
|
// ---------------------------------------------------------------
|
|
|
|
/** Whitespace (line breaks too) collapsed to one space. */
|
|
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
|
|
|
/**
|
|
* The text of a heading's section, up to the next heading of the same or a
|
|
* higher level outside a fence. The heading must exist.
|
|
*/
|
|
function sectionText(text, heading) {
|
|
const level = heading.match(/^#+/)[0].length;
|
|
const s = findSection(text, heading.replace(/^#+ /, ''), level);
|
|
assert.ok(s, `missing ${heading}`);
|
|
const lines = s.text.split('\n');
|
|
let fenced = false;
|
|
for (let i = 1; i < lines.length; i++) {
|
|
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
|
|
const m = !fenced && lines[i].match(/^(#+) /);
|
|
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
|
|
}
|
|
return s.text;
|
|
}
|
|
|
|
/** The single line in `text` that runs `lib/<mod>` (and holds `verb`, when given). */
|
|
function libLine(text, mod, verb = '') {
|
|
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
const re = new RegExp(`^\\s*node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/${esc}\\b`);
|
|
const hits = text.split('\n').filter((l) => re.test(l) && l.includes(verb));
|
|
assert.equal(hits.length, 1, `expected exactly one \`${mod}${verb ? ` ${verb}` : ''}\` line; found ${hits.length}`);
|
|
return hits[0].trim();
|
|
}
|
|
|
|
/** The runtime ```bash block of `text` that holds `cmd`, or undefined. */
|
|
function bashBlockHolding(text, cmd) {
|
|
return text.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0]).find((b) => b.includes(cmd));
|
|
}
|
|
|
|
/** A Phase 0 parser block run through bash with the plugin root filled in and `args` in place of $ARGUMENTS. */
|
|
function runParser(cmd, args) {
|
|
const filled = cmd.split('$ARGUMENTS').join(args).split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
|
|
return spawnSync('bash', ['-c', filled], { cwd: ROOT, encoding: 'utf8' });
|
|
}
|
|
|
|
const PHASE0_PARSER_LINE = "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'";
|
|
|
|
test('trekendsession Phase 0 has no "contains --help or -h" check, and its parser line run on fix-header gives no help', () => {
|
|
const phase0 = sectionText(readCommand(), '## Phase 0 — `--help` handling');
|
|
assert.doesNotMatch(flat(phase0), /contains\s+`?--help`?\s+or\s+`?-h`?/i,
|
|
'Phase 0 still prints help when $ARGUMENTS merely contains --help or -h');
|
|
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 cmd = libLine(phase0, 'parsers/arg-parser.mjs');
|
|
assert.equal(cmd, PHASE0_PARSER_LINE, 'the Phase 0 parser line is not the trekendsession parser call on $ARGUMENTS');
|
|
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');
|
|
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(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(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 from the parser\'s positional and the label from its tail', () => {
|
|
const phase2 = flat(sectionText(readCommand(), '## 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, /`<next-label>` is the parser's `tail`/, 'Phase 2 does not read <next-label> from the parser\'s tail');
|
|
});
|
|
|
|
// #67: "NEVER auto-invoke this command" was a sentence only; the frontmatter
|
|
// carries disable-model-invocation, so the model cannot start it.
|
|
test('[#67] /trekendsession sets disable-model-invocation, and CLAUDE.md names it as the one command that does', () => {
|
|
const text = readCommand();
|
|
const fm = readFrontmatterFields(text);
|
|
assert.ok(fm.ok, fm.error);
|
|
assert.equal(fm.fields['disable-model-invocation']?.value, 'true',
|
|
'commands/trekendsession.md does not set disable-model-invocation: true');
|
|
assert.match(flat(text),
|
|
/\*\*NEVER auto-invoke this command\.\*\* It is operator-typed only at session-end \(the frontmatter field keeps the model from starting it; the operator types it\)/,
|
|
'the hard rule does not say the frontmatter field keeps the model from starting it');
|
|
const withField = readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md'))
|
|
.filter((f) => readFrontmatterFields(readFileSync(join(COMMANDS_DIR, f), 'utf8')).fields['disable-model-invocation'] !== undefined);
|
|
assert.deepEqual(withField, ['trekendsession.md'], 'trekendsession must be the one command with the field');
|
|
const trunk = readFileSync(join(ROOT, 'CLAUDE.md'), 'utf8').split('\n').find((l) => l.startsWith('> **Commands are the trunk**'));
|
|
assert.ok(trunk, 'CLAUDE.md has no "Commands are the trunk" note');
|
|
assert.doesNotMatch(trunk, /none sets `disable-model-invocation`/, 'CLAUDE.md still says no command or skill sets the field');
|
|
assert.ok(trunk.includes('no skill sets `disable-model-invocation`; the one command that does is `/trekendsession`, operator-typed only (Claude Code still lets the operator type it; the model cannot start it)'),
|
|
'CLAUDE.md does not name /trekendsession as the one command that sets the field');
|
|
});
|