test(hygiene): nothing published may start Claude Code (red)
A node:test gate that scans every tracked file (the tree a release tag
publishes) for any way to start a Claude Code session or model run: the
CLI with a flag or prompt, `claude plugin eval`, spawn/exec of the binary,
the Agent SDK / claude-code package / GitHub Action, and the permission
bypass flags. Plugin management (install, validate, marketplace add)
starts no session and is not a hit.
The scanner is proven on a known-positive fixture that exercises every
pattern, and on look-alikes that must not hit. Historical mentions (class
c) stand only in EXEMPT, each with a reason and its exact hit count, so a
new hit in an exempt file fails as well. Two entries are the operator's
own eval run through Anthropic's tool; they are listed as their own class
so the decision stays visible.
Red on 57e620b: 54 hit lines in 14 files outside EXEMPT (a: 34 in 6 files,
b: 18 in 7 files, a descriptive comment: 2 in 1 file). EXEMPT holds 16
files and 46 hit lines, and matches exactly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
57e620b028
commit
bc652ec7d3
1 changed files with 142 additions and 0 deletions
142
tests/lib/no-claude-code-launch.test.mjs
Normal file
142
tests/lib/no-claude-code-launch.test.mjs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// tests/lib/no-claude-code-launch.test.mjs
|
||||
// Nothing Voyage publishes may start Claude Code. The published tree is every
|
||||
// tracked file (the catalog pins a release tag, so the tag's tree is what a
|
||||
// user installs). A hit is any line that carries a way to start a Claude Code
|
||||
// session or model run:
|
||||
// cli the `claude` CLI with a flag or a prompt (-p, --print, --bg,
|
||||
// --resume, --version, "prompt", $(...)); a user opening their
|
||||
// own session is the PM way and carries no command line
|
||||
// plugin-eval `claude plugin eval` (it runs one child session per case)
|
||||
// spawn spawn/exec/execFile/fork of the `claude` binary from code
|
||||
// sdk the Agent SDK, the claude-code npm package, the claude-code
|
||||
// GitHub Action
|
||||
// bypass permission-bypass flags, which only exist for launched sessions
|
||||
// Plugin management (`claude plugin install|validate`, `marketplace add`)
|
||||
// starts no session and is not a hit.
|
||||
//
|
||||
// Classes: (a) starts Claude Code, (b) tells a user or a session to start it,
|
||||
// (c) historical mention (changelog, measurement records, recorded fixtures).
|
||||
// (a) and (b) must be zero. A (c) file stands only in EXEMPT, with its reason
|
||||
// and its EXACT hit count: a new hit in an exempt file fails, and so does an
|
||||
// entry that no longer hits.
|
||||
//
|
||||
// When this test fails, remove the launch (work that needs another session
|
||||
// becomes an order plus a starter the user runs in a session they open); do
|
||||
// NOT widen EXEMPT to hide it.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const SELF = 'tests/lib/no-claude-code-launch.test.mjs';
|
||||
|
||||
const PATTERNS = [
|
||||
['cli', /(^|[^A-Za-z0-9_./@-])claude[ \t]+(-{1,2}[A-Za-z]|["'$])/],
|
||||
['plugin-eval', /(^|[^A-Za-z0-9_./@-])claude[ \t]+plugin[ \t]+eval\b/],
|
||||
['spawn', /\b(spawn|spawnSync|exec|execSync|execFile|execFileSync|fork)\(\s*['"`]claude\b/],
|
||||
['sdk', /claude-agent-sdk|claude_agent_sdk|@anthropic-ai\/claude-code|claude-code-sdk|claude_code_sdk|claude-code-action/],
|
||||
['bypass', /bypassPermissions|--dangerously-skip-permissions/],
|
||||
];
|
||||
|
||||
// path -> [class, exact hit count, reason]
|
||||
const EXEMPT = new Map([
|
||||
[SELF, ['self', 17, "the gate's own pattern table and known-positive fixture"]],
|
||||
['CHANGELOG.md', ['c', 15, 'release history; never rewritten']],
|
||||
['docs/T1-cc26-delegated-orchestration.md', ['c', 4, 'measurement record (T1/CC-26)']],
|
||||
['docs/T1-synthesis-poc-results.md', ['c', 1, 'measurement record (synthesis PoC)']],
|
||||
['docs/cc-upgrade-2.1.181-decision-matrix.md', ['c', 3, 'decision record (CC 2.1.181 upgrade)']],
|
||||
['docs/deep-research-engine-research.md', ['c', 6, 'research record (deep-research engine)']],
|
||||
['docs/operations.md', ['c', 1, 'Path A decision record: why cache-first was rejected']],
|
||||
['docs/spike-pretooluse-subagent-reach.md', ['c', 2, 'spike record (PreToolUse reach)']],
|
||||
['docs/voyage-vs-cc-balance-analysis.md', ['c', 1, 'decision record (balance analysis)']],
|
||||
['scripts/synthesis-measure.mjs', ['c', 2, 'measurement script; says why a child-session instrument was not used, launches nothing']],
|
||||
['tests/fixtures/research-deep-research-adapted.md', ['c', 1, 'recorded research fixture']],
|
||||
['tests/fixtures/synthesis/digest.json', ['c', 1, 'recorded synthesis fixture']],
|
||||
['tests/fixtures/synthesis/exploration/architecture-mapper.md', ['c', 3, 'recorded exploration fixture']],
|
||||
['tests/fixtures/synthesis/exploration/dependency-tracer.md', ['c', 2, 'recorded exploration fixture']],
|
||||
['docs/proevesett.md', ['operator-instrument', 3, "the operator's own eval run through Anthropic's `claude plugin eval`; Voyage starts nothing - open for the operator (see the order report)"]],
|
||||
['.gitignore', ['operator-instrument', 1, 'ignores the output of that same operator-run eval']],
|
||||
]);
|
||||
|
||||
function trackedFiles(root) {
|
||||
const out = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf-8' });
|
||||
return out.split('\0').filter(Boolean);
|
||||
}
|
||||
|
||||
// One "path:line:pattern" entry per hit line (first matching pattern wins).
|
||||
function scan(root, files) {
|
||||
const hits = [];
|
||||
for (const rel of files) {
|
||||
const buf = readFileSync(join(root, rel));
|
||||
if (buf.includes(0)) continue; // binary
|
||||
buf.toString('utf-8').split('\n').forEach((line, i) => {
|
||||
const p = PATTERNS.find(([, re]) => re.test(line));
|
||||
if (p) hits.push(`${rel}:${i + 1}:${p[0]}`);
|
||||
});
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
test('scan finds every launch form and passes the look-alikes (known-positive + known-negative)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cc-launch-'));
|
||||
try {
|
||||
const positives = [
|
||||
'claude -p "/trekexecute --session 1 plan.md"',
|
||||
' GIT_OPTIONAL_LOCKS=0 claude --print "x"',
|
||||
'run `claude --bg` in a pane',
|
||||
'claude "$(cat session-1.md)"',
|
||||
'claude --resume',
|
||||
'claude plugin eval . --runs 1',
|
||||
"const child = spawn('claude', argv);",
|
||||
'execFileSync("claude", ["--version"])',
|
||||
"import { query } from '@anthropic-ai/claude-agent-sdk';",
|
||||
'npx @anthropic-ai/claude-code',
|
||||
' --permission-mode bypassPermissions \\',
|
||||
'claude --dangerously-skip-permissions',
|
||||
'uses: anthropics/claude-code-action@v1',
|
||||
];
|
||||
const negatives = [
|
||||
'Claude Code runs the command inline.',
|
||||
'model: claude-opus-5-5',
|
||||
'paths under .claude/projects/ and ${CLAUDE_PLUGIN_ROOT}',
|
||||
'claude plugin install voyage@ktg-plugin-marketplace',
|
||||
'claude plugin validate',
|
||||
'Open a Claude Code session in the worktree and run `/trekexecute --session 1 plan.md`.',
|
||||
'const bin = "claude";',
|
||||
'the claude binary is not on PATH',
|
||||
];
|
||||
writeFileSync(join(dir, 'pos.md'), positives.join('\n') + '\n');
|
||||
writeFileSync(join(dir, 'neg.md'), negatives.join('\n') + '\n');
|
||||
const pos = scan(dir, ['pos.md']);
|
||||
assert.equal(pos.length, positives.length, `every positive must hit:\n${pos.join('\n')}`);
|
||||
assert.deepEqual(scan(dir, ['neg.md']), []);
|
||||
assert.deepEqual(new Set(pos.map(h => h.split(':')[2])), new Set(PATTERNS.map(([k]) => k)),
|
||||
'the known-positive fixture must exercise every pattern');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('no tracked file outside EXEMPT starts Claude Code or tells anyone to', () => {
|
||||
const files = trackedFiles(ROOT);
|
||||
assert.ok(files.length > 100, `git ls-files returned ${files.length} files; refusing to pass on an empty scan`);
|
||||
const hits = scan(ROOT, files.filter(f => !EXEMPT.has(f)));
|
||||
assert.deepEqual(hits, [], `${hits.length} launch hit(s) in (a)/(b):\n${hits.join('\n')}`);
|
||||
});
|
||||
|
||||
test('every EXEMPT entry is tracked, has a reason, and hits exactly its recorded count', () => {
|
||||
const files = new Set(trackedFiles(ROOT));
|
||||
for (const [rel, [cls, count, reason]] of EXEMPT) {
|
||||
assert.ok(files.has(rel), `EXEMPT names untracked ${rel}`);
|
||||
assert.ok(['c', 'self', 'operator-instrument'].includes(cls), `${rel}: unknown class ${cls}`);
|
||||
assert.ok(reason && reason.length > 10, `${rel}: an exemption needs a reason`);
|
||||
const n = scan(ROOT, [rel]).length;
|
||||
assert.equal(n, count, `${rel}: ${n} hit(s), EXEMPT records ${count} - a new hit is a launch, a lost one is a stale entry`);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue