fix: the findings of the release review of 6.3.0
The review of v6.2.0..f669b87 (BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR), each fixed behind a test that failed first, or, for the two missing tests, a test shown to fail on a stub of the branch it covers. - Review steps appended by /trekexecute Phase 7.2 are held to the step rules on any plan version: plan-validator --steps-from <N> (on a plan below 1.8 a template review step validated and was executed). - The guard refuses where no one can be asked also when its rules will not load; the wave merge path names that refusal instead of MERGE_REFUSED. Tests for the classifier-error refusal and post-approval-log's own check. - release-check: the version files may change only in their version after the review (JSON compared without it), README diff lines are read inside hunks only, an unreadable diff is exit 2, and npm test runs without NODE_TEST_CONTEXT. - What counts as a step of only tests: a note in parentheses is not a path, and a data file under specs/ is not a test. End-to-end: rides on the last step that builds behaviour (validator hint, trekplan, template). - A removed line is cited as <rev>:<path>:<line> (findings-schema hint, trekreview re-ask). - skill-lint counts a trailing newline as no line; regression-check takes commandEnv from lib/util/command-env.mjs; CLAUDE.md names four mode-read exemptions. - /security-review, older than v6.2.0: the criteria runner refuses git --output, which writes a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f669b8775f
commit
bef9c5b3ac
18 changed files with 450 additions and 43 deletions
|
|
@ -265,7 +265,7 @@ test('P11: a review step in the template form is caught by the plan validator, s
|
|||
assert.ok(errors.some((c) => /VERIFY_NOT_RUNNABLE|TEST_FIRST/.test(c)), errors.join(','));
|
||||
|
||||
const p = section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)');
|
||||
const check = p.indexOf('plan-validator.mjs --strict --json "{plan_path}"');
|
||||
const check = p.indexOf('plan-validator.mjs --strict --json --steps-from {total + 1} "{plan_path}"');
|
||||
const ask = p.indexOf('Approve the plan as it is now?');
|
||||
assert.ok(check > 0 && ask > check, 'the validator runs before the approval question');
|
||||
assert.match(p, /restore the plan from the copy/, 'a stop leaves the plan matching its approval');
|
||||
|
|
@ -332,6 +332,56 @@ test('P12: the guard asks before a merge into main; in the mode with no prompts
|
|||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// Release review of 6.3.0 (2026-10-10): the refusal covers the guard's two
|
||||
// fallbacks too — rules that will not load, and a command it could not read.
|
||||
function guardIn(plugin, cwd, command, permission_mode) {
|
||||
return node([join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs')], {
|
||||
input: JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd, permission_mode, session_id: 'coupling', tool_use_id: `t${Math.random()}` }),
|
||||
});
|
||||
}
|
||||
|
||||
test('P12 / review 630: a guard whose rules will not load refuses where no one is asked', async () => {
|
||||
const { pluginCopy } = await import('../hooks/guard-helpers.mjs');
|
||||
const plugin = pluginCopy({ 'hooks/lib/git-approval.mjs': 'missing' });
|
||||
const dir = repo();
|
||||
try {
|
||||
const asked = guardIn(plugin, dir, 'git status', 'default');
|
||||
assert.equal(asked.status, 0, asked.stderr);
|
||||
assert.match(asked.stdout, /"permissionDecision":"ask"/, 'the control: where someone can answer, the broken guard asks');
|
||||
const r = guardIn(plugin, dir, 'git push origin main', NO_PROMPT_MODE);
|
||||
assert.equal(r.status, 2, `refused, not asked: ${r.stdout}${r.stderr}`);
|
||||
assert.match(r.stderr, /no one to ask/);
|
||||
assert.match(r.stderr, /could not load/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
rmSync(plugin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('P12 / review 630: a command the classifier could not read is asked, and refused where no one is asked', async () => {
|
||||
const { pluginCopy } = await import('../hooks/guard-helpers.mjs');
|
||||
const plugin = pluginCopy();
|
||||
const lib = join(plugin, 'hooks', 'lib', 'git-approval.mjs');
|
||||
const src = readFileSync(lib, 'utf8');
|
||||
assert.ok(src.includes('export function classifyGitApproval('), 'the copy carries the classifier');
|
||||
writeFileSync(lib, src.replace('export function classifyGitApproval(',
|
||||
'export function classifyGitApproval() { throw new Error(\'classifier broke\'); }\nfunction classifyGitApprovalUnused('));
|
||||
const dir = repo();
|
||||
try {
|
||||
const asked = guardIn(plugin, dir, 'git push origin feature', 'default');
|
||||
assert.equal(asked.status, 0, asked.stderr);
|
||||
assert.match(asked.stdout, /"permissionDecision":"ask"/, 'where someone can answer it is asked');
|
||||
assert.match(asked.stdout, /could not read/);
|
||||
const r = guardIn(plugin, dir, 'git push origin feature', NO_PROMPT_MODE);
|
||||
assert.equal(r.status, 2, `refused: ${r.stdout}${r.stderr}`);
|
||||
assert.match(r.stderr, /could not read/);
|
||||
assert.match(r.stderr, /no one to ask/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
rmSync(plugin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- the other new progress fields (Handover 5, additive-optional) -----------
|
||||
|
||||
test('H5: the wave-2 fields are shape-checked when present and optional when absent', () => {
|
||||
|
|
|
|||
2
tests/fixtures/jsonl-schemas.md
vendored
2
tests/fixtures/jsonl-schemas.md
vendored
|
|
@ -22,7 +22,7 @@
|
|||
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1130-1145 | profile, phase_models, profile_source | none |
|
||||
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:782-808 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
|
||||
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1115-1135 | profile, phase_models, parallel_agents, profile_source | none |
|
||||
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2858-2880 | profile, phase_models, profile_source | none |
|
||||
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2867-2889 | profile, phase_models, profile_source | none |
|
||||
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none |
|
||||
| trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) |
|
||||
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:628-632 | profile, phase_models, profile_source | none |
|
||||
|
|
|
|||
|
|
@ -109,11 +109,12 @@ test('review 8: a command nobody was asked about is not logged, even if it reads
|
|||
// Operator decision 2026-10-09: where no person can be asked, the guard refuses
|
||||
// a merge into main or a push (exit 2) instead of asking, so the call never runs
|
||||
// and nothing reaches the approval log. post-approval-log's own check of the
|
||||
// recorded mode stays as a second line.
|
||||
// recorded mode stays as a second line (its own test below).
|
||||
const NO_PROMPT_MODE = 'bypassPermissions';
|
||||
test('uncertain (no-prompt mode): the push to main is refused, and no approval is logged', async () => {
|
||||
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
||||
const repo = makeRepo('main');
|
||||
const base = { session_id: `s-b-${RUN}`, tool_use_id: 'toolu_b', cwd: repo, tool_name: 'Bash', tool_input: { command: 'git push origin main' }, permission_mode: 'bypassPermissions' };
|
||||
const base = { session_id: `s-b-${RUN}`, tool_use_id: 'toolu_b', cwd: repo, tool_name: 'Bash', tool_input: { command: 'git push origin main' }, permission_mode: NO_PROMPT_MODE };
|
||||
const pre = await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { ...base, hook_event_name: 'PreToolUse' });
|
||||
assert.equal(pre.code, 2, `refused, not asked: ${JSON.stringify(pre)}`);
|
||||
const log = join(tmp(), 'stub.jsonl');
|
||||
|
|
@ -121,6 +122,25 @@ test('uncertain (no-prompt mode): the push to main is refused, and no approval i
|
|||
assert.equal(readStubLog(log).length, 0);
|
||||
});
|
||||
|
||||
// The second line on its own (release review of 6.3.0, 2026-10-10): a record
|
||||
// that says no person was asked is never logged, whoever wrote it.
|
||||
test('uncertain (no-prompt mode): a recorded question no one saw is not logged', async () => {
|
||||
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
||||
const repo = makeRepo('main');
|
||||
const { writeApprovalRecord } = await import(join(plugin, 'hooks', 'lib', 'approval-record.mjs'));
|
||||
const ids = { session_id: `s-r-${RUN}`, tool_use_id: 'toolu_r' };
|
||||
assert.ok(writeApprovalRecord(ids.session_id, ids.tool_use_id, {
|
||||
command: 'git push origin main', permission_mode: NO_PROMPT_MODE,
|
||||
matches: [{ kind: 'merge', name: 'push to main', subject: repo }], asked_at: new Date().toISOString(),
|
||||
}), 'the record is written');
|
||||
const log = join(tmp(), 'stub.jsonl');
|
||||
const r = await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), {
|
||||
...ids, hook_event_name: 'PostToolUse', cwd: repo, tool_name: 'Bash', tool_input: { command: 'git push origin main' },
|
||||
}, { STUB_LOG: log });
|
||||
assert.match(parseJsonOut(r.stdout).hookSpecificOutput.additionalContext, /not logged/i);
|
||||
assert.equal(readStubLog(log).length, 0);
|
||||
});
|
||||
|
||||
// 9. Classification
|
||||
const kinds = (cmd, branch = 'feat', cwd) => classifyGitApproval(cmd, { branch, cwd }).map((m) => m.kind);
|
||||
for (const cmd of ['git tag -v v1', 'git tag --verify v1', 'git push -n', 'git push --dry-run origin main']) {
|
||||
|
|
|
|||
166
tests/lib/review-630.test.mjs
Normal file
166
tests/lib/review-630.test.mjs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// tests/lib/review-630.test.mjs
|
||||
// The findings of the release review of 6.3.0 (2026-10-10), each held by a
|
||||
// test that failed on f669b87. The guard's and the release check's own tests
|
||||
// live next to their files (tests/commands/trekexecute-coupling.test.mjs,
|
||||
// tests/hooks/guards-review-regressions.test.mjs, tests/scripts/release-check.test.mjs).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
|
||||
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
|
||||
import { stepTestsOnly } from '../../lib/parsers/plan-schema.mjs';
|
||||
import { validateFindings } from '../../lib/review/findings-schema.mjs';
|
||||
import { lintSkill } from '../../lib/skills/skill-lint.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
||||
const VALIDATOR = join(ROOT, 'lib', 'validators', 'plan-validator.mjs');
|
||||
|
||||
// The review template's own Step R1, appended the Phase 7.2 way.
|
||||
function withTemplateStep(plan, n) {
|
||||
const r1 = /### Step R1:[^\n]*\n[\s\S]*?(?=\n> )/.exec(read('templates/trekreview-template.md'));
|
||||
assert.ok(r1, 'templates/trekreview-template.md carries a ### Step R1');
|
||||
// Phase 7.2 step 2 also adds the manifest the legacy rule synthesizes (expected_paths ← Files).
|
||||
const appended = r1[0].replace(/^### Step R1:/, `### Step ${n}:`) +
|
||||
'- **Checkpoint:** `git commit -m "fix(review): x [01234567]"`\n' +
|
||||
'- **Manifest:**\n ```yaml\n manifest:\n expected_paths:\n - lib/foo.mjs\n min_file_count: 1\n' +
|
||||
' commit_message_pattern: "^fix\\\\(review\\\\):"\n bash_syntax_check: []\n forbidden_paths: []\n must_contain: []\n ```\n';
|
||||
return plan.replace('\n## Test Strategy', `\n${appended}\n## Test Strategy`);
|
||||
}
|
||||
|
||||
// ---- BLOCKER 1b25ba56: appended review steps hold the 1.8 step rules on any plan
|
||||
|
||||
test('review 630 / 1b25ba56: on a 1.7 plan, an appended review step that cannot run is refused (--steps-from)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'r630-'));
|
||||
try {
|
||||
const plan = withTemplateStep(buildPlan({ version: '1.7', design: false }), 3);
|
||||
const p = join(dir, 'plan.md');
|
||||
writeFileSync(p, plan);
|
||||
const plain = spawnSync(process.execPath, [VALIDATOR, '--strict', '--json', p], { encoding: 'utf8' });
|
||||
assert.equal(plain.status, 0, 'the control: a 1.7 plan is not held to the 1.8 step rules');
|
||||
const r = spawnSync(process.execPath, [VALIDATOR, '--strict', '--json', '--steps-from', '3', p], { encoding: 'utf8' });
|
||||
assert.equal(r.status, 1, `the appended step is held to them: ${r.stdout}`);
|
||||
const codes = JSON.parse(r.stdout).errors.map((e) => e.code);
|
||||
assert.ok(codes.includes('PLAN_STEP_VERIFY_NOT_RUNNABLE') || codes.includes('PLAN_STEP_NO_TEST_FIRST'), codes.join(','));
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('review 630 / 1b25ba56: --steps-from leaves the earlier steps of a 1.7 plan alone', () => {
|
||||
const plan = buildPlan({ version: '1.7', design: false, steps: [{ testFirst: false }, { endToEnd: 'bash tests/e2e.sh' }] });
|
||||
assert.deepEqual(validatePlanContent(plan, { strict: true, stepsFrom: 3 }).errors, [],
|
||||
'step 1 has no Test first, and it is not one of the appended steps');
|
||||
});
|
||||
|
||||
test('review 630 / 1b25ba56: Phase 7.2 validates the appended steps with --steps-from', () => {
|
||||
const cmd = read('commands/trekexecute.md');
|
||||
assert.match(cmd, /plan-validator\.mjs --strict --json --steps-from \{total \+ 1\} "\{plan_path\}"/);
|
||||
});
|
||||
|
||||
// ---- 57a5e67c: the wave path names the guard's refusal ------------------------
|
||||
|
||||
test('review 630 / 57a5e67c: the wave merge path says what to do when the guard refused because no one can be asked', () => {
|
||||
const cmd = read('commands/trekexecute.md');
|
||||
const at = cmd.indexOf('**Merge session branches back (SEQUENTIAL, one at a time):**');
|
||||
const before = cmd.slice(cmd.lastIndexOf('- **`all_completed: true`:**', at), at);
|
||||
assert.match(before, /no one to ask/, 'the guard-refused case is named on the wave path');
|
||||
assert.match(before, /by hand/, 'and the way on: the operator runs the merge by hand');
|
||||
assert.match(before, /wave\.json/, 'wave.json is kept');
|
||||
});
|
||||
|
||||
// ---- 2cf29d6f / 727924c0: what counts as a step of only tests ------------------
|
||||
|
||||
test('review 630: a comma inside a Files: note is not a path', () => {
|
||||
const body = '- **Files:** `tests/a.test.mjs` (new: covers x, y)\n- **Changes:** add the test\n';
|
||||
assert.deepEqual(stepTestsOnly(body), { testsOnly: true, files: ['tests/a.test.mjs'] });
|
||||
});
|
||||
|
||||
test('review 630: a data file under specs/ is not a test', () => {
|
||||
const body = '- **Files:** `api/specs/openapi.yaml`\n- **Changes:** add the endpoint\n';
|
||||
assert.equal(stepTestsOnly(body).testsOnly, false);
|
||||
const fixture = '- **Files:** `tests/fixtures/users.json`\n- **Changes:** add the fixture\n';
|
||||
assert.equal(stepTestsOnly(fixture).testsOnly, true, 'the control: a fixture under tests/ is a test file');
|
||||
});
|
||||
|
||||
test('review 630: a tests-only End-to-end step is told where End-to-end belongs', () => {
|
||||
const plan = buildPlan({ steps: [{}, { file: 'tests/e2e.sh', test: 'tests/e2e.sh', endToEnd: 'bash tests/e2e.sh' }] });
|
||||
const w = validatePlanContent(plan, { strict: true }).warnings.find((x) => x.code === 'PLAN_STEP_TESTS_ONLY');
|
||||
assert.ok(w, 'the warning fires: red proof would reject this step');
|
||||
assert.match(`${w.message} ${w.hint ?? ''}`, /`End-to-end:` (rides|belongs) on the last step that builds/);
|
||||
assert.match(read('templates/plan-template.md'), /`End-to-end:` (rides|belongs) on the last step that builds/);
|
||||
});
|
||||
|
||||
// ---- c4623362: a removed line can still be cited --------------------------------
|
||||
|
||||
test('review 630 / c4623362: the out-of-range hint says how to cite a line that no longer exists', () => {
|
||||
const r = validateFindings({ reviewer: 'r', findings: [{
|
||||
id: 'a'.repeat(40), severity: 'MAJOR', rule_key: 'MISSING_TEST', file: 'package.json', line: 1,
|
||||
brief_ref: 'x', title: 't', recommended_action: 'a',
|
||||
detail: 'the check formerly at package.json:9999 was removed',
|
||||
}] }, { repoRoot: ROOT });
|
||||
const e = r.errors.find((x) => x.code === 'FINDING_PROSE_LINE_OUT_OF_RANGE');
|
||||
assert.ok(e, 'the control: the plain citation is still checked');
|
||||
assert.match(e.hint, /<rev>:<path>:<line>/);
|
||||
const ok = validateFindings({ reviewer: 'r', findings: [{
|
||||
id: 'a'.repeat(40), severity: 'MAJOR', rule_key: 'MISSING_TEST', file: 'package.json', line: 1,
|
||||
brief_ref: 'x', title: 't', recommended_action: 'a',
|
||||
detail: 'the check formerly at v6.2.0:package.json:9999 was removed',
|
||||
}] }, { repoRoot: ROOT });
|
||||
assert.deepEqual(ok.errors, [], 'a revision-qualified citation is not judged against today\'s file');
|
||||
assert.match(read('commands/trekreview.md'), /<rev>:<path>:<line>/, 'the re-ask names the form');
|
||||
});
|
||||
|
||||
// ---- 2261016d: a 499-line SKILL.md is under the 500-line limit -------------------
|
||||
|
||||
test('review 630 / 2261016d: a SKILL.md of 499 lines ending in a newline passes the size rule', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'r630-skill-'));
|
||||
try {
|
||||
const skill = join(dir, 'test-first');
|
||||
cpSync(join(ROOT, 'skills', 'test-first'), skill, { recursive: true });
|
||||
const text = readFileSync(join(skill, 'SKILL.md'), 'utf8').replace(/\n+$/, '\n');
|
||||
const lines = text.split('\n').length - 1;
|
||||
assert.ok(lines < 499, `the fixture pads the real skill (${lines} lines)`);
|
||||
const padded = text.replace('\n## Credits', `${'\n'.repeat(499 - lines)}\n## Credits`);
|
||||
assert.equal(padded.split('\n').length - 1, 499);
|
||||
writeFileSync(join(skill, 'SKILL.md'), padded);
|
||||
const codes = lintSkill(skill).errors.map((e) => e.code);
|
||||
assert.ok(!codes.includes('SKILL_TOO_LONG'), codes.join(','));
|
||||
writeFileSync(join(skill, 'SKILL.md'), padded + '\n');
|
||||
assert.ok(lintSkill(skill).errors.map((e) => e.code).includes('SKILL_TOO_LONG'), 'the control: 500 lines fail');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// ---- cb34c32e: regression-check takes commandEnv from the shared util ------------
|
||||
|
||||
test('review 630 / cb34c32e: regression-check imports commandEnv from lib/util/command-env.mjs', () => {
|
||||
const src = read('lib/verification/regression-check.mjs');
|
||||
assert.match(src, /import \{ commandEnv \} from '\.\.\/util\/command-env\.mjs';/);
|
||||
assert.doesNotMatch(src, /from '\.\/red-proof\.mjs'/);
|
||||
assert.ok(src.indexOf('function exec(') > src.lastIndexOf('\nimport '), 'exec is defined after the imports');
|
||||
});
|
||||
|
||||
// ---- CLAUDE.md names the mode-read exemptions as they are -----------------------
|
||||
|
||||
test('review 630: CLAUDE.md counts the mode-read exemptions the launch test holds', () => {
|
||||
const src = read('tests/lib/no-claude-code-launch.test.mjs');
|
||||
const n = (src.match(/\['mode-read', \d+,/g) || []).length;
|
||||
assert.equal(n, 4, 'the launch test holds four mode-read entries');
|
||||
assert.match(read('CLAUDE.md'), /four files that only read\s+(?:> )?the session's permission mode/);
|
||||
});
|
||||
|
||||
// ---- /security-review, older than v6.2.0: a read-only git that writes a file ----
|
||||
|
||||
test('review 630 / security: `git diff --output=<path>` is not a read-only check', async () => {
|
||||
const { allowedCommand } = await import('../../lib/verification/criteria-runner.mjs');
|
||||
assert.equal(allowedCommand('git diff --stat').allowed, true, 'the control: a read-only git check runs');
|
||||
assert.equal(allowedCommand('git log --output-indicator-new=+ -1').allowed, true, 'an option that only starts with --output is not it');
|
||||
for (const cmd of ['git diff --output=../../.zshrc', 'git diff --output ../x', 'git log -p --output=x', 'git show HEAD --output=x']) {
|
||||
const r = allowedCommand(cmd);
|
||||
assert.equal(r.allowed, false, `${cmd} writes a file`);
|
||||
assert.match(r.reason, /--output/);
|
||||
}
|
||||
});
|
||||
|
|
@ -85,6 +85,7 @@ const DOORS = `### Doors
|
|||
function fixture({
|
||||
suiteExit = 0, doors = DOORS, verdict = 'ALLOW', writeReview = true,
|
||||
reviewSha = 'work', afterRelease = null, dirty = false, version = '1.0.1',
|
||||
testScript = null, files = {}, attributes = null,
|
||||
} = {}) {
|
||||
const base = mkdtempSync(join(tmpdir(), 'release-check-'));
|
||||
const repo = join(base, 'repo');
|
||||
|
|
@ -94,12 +95,14 @@ function fixture({
|
|||
git(repo, 'config', 'user.name', 't');
|
||||
git(repo, 'config', 'commit.gpgsign', 'false');
|
||||
|
||||
const pkg = (v) => JSON.stringify({ name: 'fx', version: v, scripts: { test: `node -e "process.exit(${suiteExit})"` } }, null, 2) + '\n';
|
||||
const pkg = (v) => JSON.stringify({ name: 'fx', version: v, scripts: { test: testScript ?? `node -e "process.exit(${suiteExit})"` } }, null, 2) + '\n';
|
||||
writeFileSync(join(repo, 'package.json'), pkg('1.0.0'));
|
||||
writeFileSync(join(repo, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'fx', version: '1.0.0' }) + '\n');
|
||||
writeFileSync(join(repo, 'README.md'), '# fx\n\n\n\nText.\n');
|
||||
writeFileSync(join(repo, 'CHANGELOG.md'), '# Changelog\n\n## v1.0.0 — 2026-10-01 — first\n\n- first\n');
|
||||
writeFileSync(join(repo, 'a.mjs'), 'export const a = 1;\n');
|
||||
for (const [rel, text] of Object.entries(files)) writeFileSync(join(repo, rel), text);
|
||||
if (attributes) writeFileSync(join(repo, '.git', 'info', 'attributes'), attributes);
|
||||
git(repo, 'add', '-A');
|
||||
git(repo, 'commit', '-q', '-m', 'feat: first');
|
||||
writeFileSync(join(repo, 'a.mjs'), 'export const a = 2;\n');
|
||||
|
|
@ -301,3 +304,59 @@ test('release-check — releaseSection matches the CHANGELOG heading dialects ex
|
|||
assert.match(mod.releaseSection(text, '1.10.0'), /ten/);
|
||||
assert.equal(mod.releaseSection(text, '2.0.0'), null);
|
||||
});
|
||||
|
||||
// ---- release review of 6.3.0 (2026-10-10): what counts as bookkeeping ----------
|
||||
|
||||
const AFTER_PKG = JSON.stringify({ name: 'fx', version: '1.0.1', scripts: { test: 'node -e "process.exit(0)" && echo changed' } }, null, 2) + '\n';
|
||||
|
||||
test('release-check — a change to package.json after the review other than its version → 1', () => {
|
||||
const fx = fixture({ afterRelease: { 'package.json': AFTER_PKG } });
|
||||
try {
|
||||
const r = run(fx);
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
assert.match(r.stdout, /package\.json \(lines other than the version\)/);
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
||||
test('release-check — a change to plugin.json after the review other than its version → 1', () => {
|
||||
const fx = fixture({ afterRelease: { '.claude-plugin/plugin.json': JSON.stringify({ name: 'fx', version: '1.0.1', hooks: './x.json' }) + '\n' } });
|
||||
try {
|
||||
const r = run(fx);
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
assert.match(r.stdout, /plugin\.json \(lines other than the version\)/);
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
||||
test('release-check — a README line that starts with "++ " is a change, not a diff header → 1', () => {
|
||||
const fx = fixture({ afterRelease: { 'README.md': '# fx\n\n\n\nText.\n++ new claim\n' } });
|
||||
try {
|
||||
const r = run(fx);
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
assert.match(r.stdout, /README\.md \(lines other than the version badge\)/);
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
||||
test('release-check — a README diff that cannot be read → 2 (never green)', () => {
|
||||
const fx = fixture({ attributes: 'README.md diff=broken\n' });
|
||||
try {
|
||||
spawnSync('git', ['config', 'diff.broken.textconv', 'false'], { cwd: fx.repo });
|
||||
const r = run(fx);
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.match(r.stdout, /README\.md/);
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
||||
test('release-check — a red suite is red when the check itself runs under node --test', () => {
|
||||
const fx = fixture({
|
||||
testScript: 'node --test red.test.mjs',
|
||||
files: { 'red.test.mjs': "import { test } from 'node:test';\nimport assert from 'node:assert';\ntest('red', () => assert.equal(1, 2));\n" },
|
||||
});
|
||||
try {
|
||||
const control = run(fx);
|
||||
assert.equal(control.status, 1, `the control: the suite is red: ${control.stdout}`);
|
||||
const r = spawnSync(process.execPath, [CHECK, '--repo', fx.repo, '--review', fx.review],
|
||||
{ encoding: 'utf8', env: { ...process.env, NODE_TEST_CONTEXT: 'child-v8' } });
|
||||
assert.equal(r.status, 1, `under a parent test runner: ${r.stdout}`);
|
||||
assert.match(r.stdout, /npm test exited/);
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue