fix: close what an independent review found in the trial 2 fixes
- The pre-compact hook read only a checkpoint that started with `git commit`,
so every plan in the new `git add … && git commit` form rebuilt nothing, and
an empty match also skipped the session-state refresh. It now finds the
commit anywhere in the span and always refreshes; first test for the hook.
- A sub-bullet that carries its own command stays a criterion (folding it
left the command unrun); the shallowest bullet sets the base indent.
- Only a MINOR/SUGGESTION /code-review finding outside the range is released
from forbidding ALLOW: a MAJOR one may be a break cited at an unchanged
caller.
- `reuse` is an improvement category; the session-spec template stages too;
a negated bash 3.2 constraint, an env prefix and `bash -c` are handled.
- Stale comment, Declined-to-Judge wording and rule-key counts brought in line.
Tests fail on 7c11c2a first (9 of 24).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7c11c2aa9f
commit
b0d96a2d53
13 changed files with 194 additions and 54 deletions
|
|
@ -50,7 +50,7 @@ You will receive a prompt containing:
|
|||
review: `claude-code-review` (`/code-review`) and `claude-security-review`
|
||||
(`/security-review`, not in `quick`). Engine findings carry the catalogue
|
||||
keys `CODE_REVIEW_DEFECT` / `CODE_REVIEW_IMPROVEMENT` (MINOR: simplification,
|
||||
efficiency, style, readability) / `SECURITY_REVIEW_VULNERABILITY` (or a
|
||||
efficiency, reuse, style, readability) / `SECURITY_REVIEW_VULNERABILITY` (or a
|
||||
narrower key) and are treated exactly like any reviewer's findings.
|
||||
- **Expected reviewers** — the names that were due, engines included, and
|
||||
each engine recorded as not run with its reason. A name that did not report
|
||||
|
|
@ -58,9 +58,10 @@ You will receive a prompt containing:
|
|||
- **Engine findings outside the review scope** — the `out_of_scope` list from
|
||||
`lib/review/engine-findings.mjs`. List each one under `## Declined to Judge`
|
||||
(`engine:out-of-scope`): a real claim on a file this review did not cover.
|
||||
One from `/security-review` is `unverified` and forbids `ALLOW` (its base is
|
||||
wider than this review). One from `/code-review` does not: it was given
|
||||
exactly this range, so it does not hold `ALLOW` back.
|
||||
It is `unverified` and forbids `ALLOW` — except a MINOR or SUGGESTION from
|
||||
`/code-review` (given exactly this range): that one is declined but does not
|
||||
hold `ALLOW` back. A MAJOR one still does: a change that breaks an
|
||||
unchanged caller is cited at the caller.
|
||||
- **Triage map** — `{file → deep-review|summary-only|skip, reason}` from
|
||||
the /trekreview triage gate.
|
||||
- **Brief metadata** — `task`, `slug`, `project_dir`, `brief_path` from
|
||||
|
|
@ -278,8 +279,8 @@ the parser reads; a bare `findings:` is null and fails validation.
|
|||
`[unverified]`. Unverified lines come first: they are the ones that
|
||||
withheld `ALLOW`.
|
||||
9. `## Declined to Judge` — REQUIRED. One line per finding you set aside
|
||||
without ruling on it (every `[unverified]` one: `file:line rule_key —
|
||||
reason`), then one line `Trimmed: N` (MINOR/SUGGESTION text cut, findings
|
||||
without ruling on it (every `[unverified]` one, and every
|
||||
out-of-scope engine finding: `file:line rule_key — reason`), then one line `Trimmed: N` (MINOR/SUGGESTION text cut, findings
|
||||
kept). Write `None.` plus `Trimmed: 0` when empty. Borrowed: "Declined to
|
||||
judge … nothing you set aside is dropped silently" (Superpowers
|
||||
`skills/requesting-code-review/code-reviewer.md:43-48`, MIT).
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ later in this conversation. Do not start Phase 6 before both engines have
|
|||
delivered or have been recorded as not run. For each engine that delivered,
|
||||
transcribe every finding — file, line (0 when none), the one-sentence summary,
|
||||
the failure scenario, the category if given (it decides the tier:
|
||||
`simplification`, `efficiency`, `style` and `readability` enter as MINOR), the
|
||||
`simplification`, `efficiency`, `reuse`, `style` and `readability` enter as MINOR), the
|
||||
fix the engine proposes (`fix`) only when its text states one — never write one
|
||||
the engine did not give — and a narrower catalogue `rule_key` only when one
|
||||
plainly fits — into
|
||||
|
|
@ -329,11 +329,11 @@ Exit 0 prints a reviewer payload (`claude-code-review` /
|
|||
files outside the review's SHA range (`/security-review` diffs against
|
||||
origin's default branch, which can be wider). Pass `out_of_scope` to the
|
||||
coordinator as well: each one is listed under `## Declined to Judge`
|
||||
(`engine:out-of-scope`) and in Coverage — never silently dropped. One from
|
||||
`/security-review` is `unverified` and forbids `ALLOW`: its base is wider than
|
||||
this review and nobody here judged it. One from `/code-review` does not hold
|
||||
`ALLOW` back: that engine was given exactly this range, so a citation outside
|
||||
it is a remark about code this review did not change. Absolute paths under the repo root are made relative
|
||||
(`engine:out-of-scope`) and in Coverage — never silently dropped. It is
|
||||
`unverified` and forbids `ALLOW` — with one exception: a MINOR or SUGGESTION
|
||||
from `/code-review`, which was given exactly this range, is an improvement to
|
||||
code this review did not change and does not hold `ALLOW` back. A MAJOR one
|
||||
still does: a change that breaks an unchanged caller is cited at the caller. Absolute paths under the repo root are made relative
|
||||
before the scope match (`--repo-root`). Exit 1 = the transcription is
|
||||
malformed, or the scope list is empty: fix it from the engine's own text (or
|
||||
the scope from Phase 3) and re-run; never feed a payload that did not pass.
|
||||
|
|
|
|||
|
|
@ -423,7 +423,7 @@ Optional but standard sections: `## Findings (BLOCKER)`, `## Findings (MAJOR)`,
|
|||
Since 1.1 (ordre 5):
|
||||
- `## Remediation Steps` — required at `review_version` ≥ 1.1 when there is a BLOCKER: one `### Step R<n>` per BLOCKER carrying `Source finding: <id>` (strict error `REVIEW_MISSING_REMEDIATION_STEP`, soft warning). Steps are appended and numbered, never reworded across iterations (Spec Kit `/converge`).
|
||||
- `## Declined to Judge` — required at `review_version` ≥ 1.1 (strict error, soft warning; 1.0 reviews exempt): every finding set aside without a ruling, with its reason (including engine findings outside the review scope, `engine:out-of-scope`), plus `Trimmed: N`. **No finding is struck for length**: only MINOR/SUGGESTION text is trimmed, and counted (`trimmed` in the JSON block); a long BLOCKER/MAJOR keeps every character.
|
||||
- Findings from Claude Code's own `/code-review` and `/security-review` enter under the catalogue keys `CODE_REVIEW_DEFECT` (MAJOR), `CODE_REVIEW_IMPROVEMENT` (MINOR: a `/code-review` finding whose category is simplification, efficiency, style or readability) and `SECURITY_REVIEW_VULNERABILITY` (BLOCKER), or a narrower key, with `reviewer` `claude-code-review` / `claude-security-review`.
|
||||
- Findings from Claude Code's own `/code-review` and `/security-review` enter under the catalogue keys `CODE_REVIEW_DEFECT` (MAJOR), `CODE_REVIEW_IMPROVEMENT` (MINOR: a `/code-review` finding whose category is simplification, efficiency, reuse, style or readability) and `SECURITY_REVIEW_VULNERABILITY` (BLOCKER), or a narrower key, with `reviewer` `claude-code-review` / `claude-security-review`.
|
||||
|
||||
**Validation strategy:**
|
||||
|
||||
|
|
@ -440,7 +440,7 @@ Since 1.1 (ordre 5):
|
|||
|
||||
The validator (`lib/validators/review-validator.mjs`) exposes the same CLI as the others: `node lib/validators/review-validator.mjs --json <review.md>`. Strict mode is the default; `--soft` downgrades section-missing errors to warnings. `/trekreview` Phase 8 runs `--strict`. `/trekplan` Phase 1 (when consuming `--brief review.md`) runs `--soft` so a partially-valid review can still seed a plan.
|
||||
|
||||
**Versioning:** current is `1.1` (ordre 5: `verdict` required, remediation steps, Declined to Judge, two engine rule keys). N-1 window: a `1.0` review without `verdict` still validates in soft mode (warning) and still seeds a plan; it fails strict mode and the verdict gate (exit 2), because a missing verdict is never read as ALLOW. Future schema changes follow the breaking-change protocol above.
|
||||
**Versioning:** current is `1.1` (ordre 5: `verdict` required, remediation steps, Declined to Judge, two engine rule keys; a third, `CODE_REVIEW_IMPROVEMENT`, since lekerepo trial 2 — three engine rule keys). N-1 window: a `1.0` review without `verdict` still validates in soft mode (warning) and still seeds a plan; it fails strict mode and the verdict gate (exit 2), because a missing verdict is never read as ALLOW. Future schema changes follow the breaking-change protocol above.
|
||||
|
||||
**Failure modes:**
|
||||
- `REVIEW_NOT_FOUND` → consumer halts with usage message
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Imported from `CLAUDE.md` via pointer.
|
|||
|
||||
- `lib/util/{frontmatter,result,atomic-write,autonomy-gate}.mjs` — shared YAML-frontmatter parser + Result helpers + `atomicWriteJson(path, obj)` for tmp+rename writes + autonomy-gate state machine (v3.4.0)
|
||||
- `lib/parsers/{plan-schema,manifest-yaml,project-discovery,arg-parser,bash-normalize,jaccard,finding-id}.mjs` — pure parsers (no I/O), unit-tested. `manifest-yaml` extended in v3.4.0 with additive `skip_commit_check` + `memory_write` flags (forward-compat: unknown keys ignored)
|
||||
- `lib/review/{rule-catalogue,plan-review-dedup}.mjs` — version-pinned rule catalogue (14 keys) + Phase 9 inline dedup helpers (v3.4.0)
|
||||
- `lib/review/{rule-catalogue,plan-review-dedup}.mjs` — version-pinned rule catalogue (15 keys) + Phase 9 inline dedup helpers (v3.4.0)
|
||||
- `lib/review/{verdict-gate,engine-findings,recurrence}.mjs` — the review gate (K7: 0 ALLOW/WARN · 1 BLOCK · 2 never green), Claude Code `/code-review` + `/security-review` findings → reviewer payload, recurring finding classes → operator-approved project-memory lines (review_version 1.1); `lib/review/fill-finding-ids.mjs` computes the coordinator's `@@ID_<n>@@` placeholders over `(file, line, rule_key)`; `lib/validators/brief-review-stamp.mjs` lets `/trekplan` skip a brief review `/trekbrief` already ran on the same text
|
||||
- `lib/stats/event-emit.mjs` — single-source stats event emitter for autonomy-gate transitions and main-merge-gate (v3.4.0)
|
||||
- `lib/validators/{brief,research,plan,progress,session-state}-validator.mjs` — schema validators with CLI shims (`node lib/validators/X.mjs --json <path>`)
|
||||
|
|
|
|||
|
|
@ -63,7 +63,10 @@ function readPlanCheckpointPatterns(planPath) {
|
|||
const text = readFileSync(planPath, 'utf-8');
|
||||
const map = new Map();
|
||||
const stepRe = /^### Step (\d+):/gm;
|
||||
const checkpointRe = /\*\*Checkpoint:\*\*\s+`git commit -m "([^"]+)"`/;
|
||||
// The commit may follow a `git add … &&` inside the same span (plan template
|
||||
// since lekerepo trial 2, 2026-10-08); the old pattern matched only a span
|
||||
// that STARTED with `git commit` and so read every new plan as empty.
|
||||
const checkpointRe = /\*\*Checkpoint:\*\*\s+`[^`]*?\bgit commit -m "([^"]+)"[^`]*`/;
|
||||
const headings = [];
|
||||
let m;
|
||||
while ((m = stepRe.exec(text)) !== null) {
|
||||
|
|
@ -150,14 +153,12 @@ for (const { projDir, progPath, planPath } of progressFiles) {
|
|||
const repoRoot = repoRootOf(projDir);
|
||||
if (!repoRoot) continue;
|
||||
|
||||
// No recognisable checkpoint or no start sha means nothing to derive, but the
|
||||
// session-state refresh below still runs: it is independent of the step.
|
||||
const plan = readPlanCheckpointPatterns(planPath);
|
||||
if (plan.size === 0) continue;
|
||||
|
||||
const sessionStart = progress.session_start_sha;
|
||||
if (!sessionStart) continue;
|
||||
|
||||
const commits = gitLog(repoRoot, sessionStart);
|
||||
const derivedStep = deriveCurrentStep(progress, plan, commits);
|
||||
const commits = plan.size > 0 && sessionStart ? gitLog(repoRoot, sessionStart) : [];
|
||||
const derivedStep = commits.length > 0 ? deriveCurrentStep(progress, plan, commits) : null;
|
||||
|
||||
if (derivedStep !== null && derivedStep > (progress.current_step || 0)) {
|
||||
progress.current_step = derivedStep;
|
||||
|
|
|
|||
|
|
@ -388,14 +388,17 @@ export function runContract(reviewerPayloads, options = {}) {
|
|||
// Engine findings on files outside the review's SHA range (engine-findings.mjs
|
||||
// `out_of_scope`). /security-review diffs against origin's default branch,
|
||||
// wider than this review: a real claim nobody here judged, so it forbids
|
||||
// ALLOW. /code-review was given exactly this range, so its citation outside
|
||||
// it is about code the review did not change: declined, listed, never
|
||||
// dropped — but it does not lock the verdict on WARN (voyage-lekerepo
|
||||
// trial 2, 2026-10-08: one remark on a frozen file held ALLOW back for good).
|
||||
// ALLOW. /code-review was given exactly this range: its MINOR or SUGGESTION
|
||||
// finding outside it (an improvement to code the review did not change) is
|
||||
// declined and listed, never dropped, but does not hold ALLOW back
|
||||
// (voyage-lekerepo trial 2, 2026-10-08). A MAJOR+ one still forbids ALLOW:
|
||||
// a changed signature that breaks an unchanged caller is cited AT the
|
||||
// unchanged file (independent review, 2026-10-08).
|
||||
const outOfScope = (options.engineOutOfScope ?? [])
|
||||
.map((f) => ({ ...f, suppressed_reason: 'engine:out-of-scope' }));
|
||||
const outOfScopeBlocking = outOfScope.filter((f) => f.engine !== 'code-review');
|
||||
const outOfScopeNoted = outOfScope.filter((f) => f.engine === 'code-review');
|
||||
const minorOnly = (f) => f.engine === 'code-review' && (f.severity === 'MINOR' || f.severity === 'SUGGESTION');
|
||||
const outOfScopeBlocking = outOfScope.filter((f) => !minorOnly(f));
|
||||
const outOfScopeNoted = outOfScope.filter(minorOnly);
|
||||
const unverified = [...judged.unverified, ...reasoned.unverified, ...outOfScopeBlocking];
|
||||
// What the coordinator set aside without ruling on it, one line each, so a
|
||||
// reader sees every finding that did not reach a verdict tier. Borrowed:
|
||||
|
|
|
|||
|
|
@ -33,10 +33,11 @@
|
|||
// - File paths are made repo-relative (an absolute path under `repoRoot`
|
||||
// loses that prefix) before they are compared with the scope.
|
||||
// - A finding on a file outside the review scope is NOT dropped silently: it
|
||||
// lands in `out_of_scope`, which the coordinator counts as unverified
|
||||
// (`engine:out-of-scope`) — it forbids ALLOW and is listed under
|
||||
// Declined to Judge (`/security-review` diffs against origin's default
|
||||
// branch, which can be wider than the review's SHA range).
|
||||
// lands in `out_of_scope`, which the coordinator lists under Declined to
|
||||
// Judge (`engine:out-of-scope`). It forbids ALLOW unless it is a MINOR or
|
||||
// SUGGESTION from /code-review (coordinator-contract.mjs runContract);
|
||||
// `/security-review` diffs against origin's default branch, which can be
|
||||
// wider than the review's SHA range.
|
||||
// - An empty scope list is an error, never "everything is out of scope".
|
||||
//
|
||||
// CLI: node lib/review/engine-findings.mjs [--scope <file-list.txt>] [--repo-root <dir>] <engine-findings.json>
|
||||
|
|
@ -79,7 +80,7 @@ function parseLine(v) {
|
|||
const tier = (key) => SEVERITY_VALUES.indexOf(getRule(key).severity);
|
||||
|
||||
/** /code-review categories that name an improvement, not a defect. */
|
||||
export const IMPROVEMENT_CATEGORIES = Object.freeze(new Set(['simplification', 'efficiency', 'style', 'readability']));
|
||||
export const IMPROVEMENT_CATEGORIES = Object.freeze(new Set(['simplification', 'efficiency', 'reuse', 'style', 'readability']));
|
||||
|
||||
/**
|
||||
* Convert one engine's transcribed findings into a coordinator payload.
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
// Code's own `/code-review` and `/security-review` enter the coordinator under
|
||||
// them when no narrower key fits (wrap, not a new engine). A third engine key,
|
||||
// CODE_REVIEW_IMPROVEMENT (MINOR), takes a /code-review finding whose category
|
||||
// is an improvement rather than a defect (simplification, efficiency, style,
|
||||
// readability): the second voyage-lekerepo trial (2026-10-08) saw a pure
|
||||
// is an improvement rather than a defect (simplification, efficiency, reuse,
|
||||
// style, readability): the second voyage-lekerepo trial (2026-10-08) saw a pure
|
||||
// simplification proposal enter as MAJOR and drive the verdict.
|
||||
//
|
||||
// `memory_route` decides where a finding class that recurs across two
|
||||
|
|
@ -128,7 +128,7 @@ export const RULE_CATALOGUE = Object.freeze([
|
|||
rule_key: 'CODE_REVIEW_IMPROVEMENT',
|
||||
severity: 'MINOR',
|
||||
category: 'maintenance',
|
||||
description: "An improvement (simplification, efficiency, style, readability) reported by Claude Code's /code-review, not a defect.",
|
||||
description: "An improvement (simplification, efficiency, reuse, style, readability) reported by Claude Code's /code-review, not a defect.",
|
||||
memory_route: null,
|
||||
}),
|
||||
Object.freeze({
|
||||
|
|
|
|||
|
|
@ -274,7 +274,9 @@ export function validateBriefContent(text, opts = {}) {
|
|||
// on a Mac with a newer bash installed that is 5.x, so the constraint is
|
||||
// never measured (voyage-lekerepo trial 2, 2026-10-08).
|
||||
const constraints = (extractSection(body, 'Constraints') ?? '') + (extractSection(body, 'Non-Functional Requirements') ?? '');
|
||||
const needsBash32 = /\bbash\s*3\.2\b/i.test(constraints);
|
||||
// Line by line, and a line that negates it ("not required") does not count.
|
||||
const needsBash32 = constraints.split('\n').some((l) =>
|
||||
/\bbash\s*3\.2\b/i.test(l) && !/\b(?:not|no longer|never|no need)\b/i.test(l));
|
||||
for (const c of parseSuccessCriteria(body)) {
|
||||
if (c.subpoints && !c.command) {
|
||||
warnings.push(issue(
|
||||
|
|
@ -285,11 +287,15 @@ export function validateBriefContent(text, opts = {}) {
|
|||
));
|
||||
}
|
||||
if (!c.command) continue;
|
||||
if (needsBash32 && /^bash\s/.test(c.command)) {
|
||||
const bare = c.command.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '');
|
||||
if (needsBash32 && /^bash\s/.test(bare)) {
|
||||
const script = bare.split(/\s+/)[1] ?? '';
|
||||
warnings.push(issue(
|
||||
'BRIEF_SC_BASH_NOT_PINNED',
|
||||
`Success criterion ${c.label} runs \`${c.command}\`, but the brief requires bash 3.2: \`bash\` is whichever bash comes first on PATH, which may be newer`,
|
||||
`Write it as \`/bin/bash ${c.command.replace(/^bash\s+/, '')}\` (/bin/bash tests/<script>.sh) so the check runs under the system bash.`,
|
||||
script.startsWith('tests/')
|
||||
? `Write it as \`/bin/bash ${bare.replace(/^bash\s+/, '')}\` (/bin/bash tests/<script>.sh) so the check runs under the system bash.`
|
||||
: 'Put the check in a script under tests/ and run it as `/bin/bash tests/<script>.sh`, so it runs under the system bash.',
|
||||
));
|
||||
}
|
||||
const r = runnableCheck(c, { cwd: opts.cwd });
|
||||
|
|
|
|||
|
|
@ -164,11 +164,15 @@ function parseSection(markdown, heading, prefix) {
|
|||
const criteria = [];
|
||||
const push = (text, command, reason) =>
|
||||
criteria.push({ label: `${prefix}${criteria.length + 1}`, text, command, reason });
|
||||
// A bullet indented deeper than the section's first bullet is a sub-point of
|
||||
// the criterion above it, never a criterion of its own: one criterion with
|
||||
// ten sub-points became SC4..SC13, each `not-a-command` (voyage-lekerepo
|
||||
// trial 2, 2026-10-08). The parent counts them in `subpoints`.
|
||||
let baseIndent = null;
|
||||
// A bullet indented deeper than the section's shallowest bullet, carrying no
|
||||
// command of its own, is a sub-point of the criterion above it, never a
|
||||
// criterion of its own: one criterion with ten sub-points became SC4..SC13,
|
||||
// each `not-a-command` (voyage-lekerepo trial 2, 2026-10-08). The parent
|
||||
// counts them in `subpoints`. A sub-bullet that DOES carry a command stays a
|
||||
// criterion: folding it would leave its command unrun (independent review).
|
||||
const indentOf = (text) => text.match(/^\s*/)[0].replace(/\t/g, ' ').length;
|
||||
const bulletIndents = lines.filter((l) => !l.fenced && !l.marker && BULLET.test(l.text)).map((l) => indentOf(l.text));
|
||||
const baseIndent = bulletIndents.length ? Math.min(...bulletIndents) : 0;
|
||||
let lastBullet = null;
|
||||
for (const line of lines) {
|
||||
if (line.marker) continue;
|
||||
|
|
@ -181,17 +185,16 @@ function parseSection(markdown, heading, prefix) {
|
|||
}
|
||||
const bullet = line.text.match(BULLET);
|
||||
if (!bullet) continue;
|
||||
const indent = line.text.match(/^\s*/)[0].replace(/\t/g, ' ').length;
|
||||
if (baseIndent === null) baseIndent = indent;
|
||||
if (indent > baseIndent && lastBullet) {
|
||||
lastBullet.subpoints = (lastBullet.subpoints ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
const nested = indentOf(line.text) > baseIndent;
|
||||
const text = bullet[1].trim();
|
||||
if (text === '') continue;
|
||||
const { command, reason } = firstCommand(text);
|
||||
if (nested && lastBullet && !command) {
|
||||
lastBullet.subpoints = (lastBullet.subpoints ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
push(text, command, reason);
|
||||
lastBullet = criteria[criteria.length - 1];
|
||||
if (!nested) lastBullet = criteria[criteria.length - 1];
|
||||
}
|
||||
return criteria;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ bypass Step 0 (use only for offline/air-gapped testing).
|
|||
- **Test first:** {test file, what it verifies, pattern to follow}
|
||||
- **Verify:** `{exact command}` → expected: `{output}`
|
||||
- **On failure:** {revert | retry | skip | escalate} — {specific instructions}
|
||||
- **Checkpoint:** `git commit -m "{message}"`
|
||||
- **Checkpoint:** `git add {the step's Files and its test files} && git commit -m "{message}"`
|
||||
- **Manifest:**
|
||||
```yaml
|
||||
manifest:
|
||||
|
|
|
|||
72
tests/hooks/pre-compact-flush.test.mjs
Normal file
72
tests/hooks/pre-compact-flush.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// tests/hooks/pre-compact-flush.test.mjs
|
||||
// The PreCompact hook rebuilds progress.json from git and refreshes the
|
||||
// session state before compaction. An independent review (2026-10-08) found
|
||||
// that the checkpoint form `git add … && git commit -m "…"` (plan template,
|
||||
// lekerepo trial 2 point A7) matched nothing, and that an empty match skipped
|
||||
// the session-state refresh too. No test covered the hook until now.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const HOOK = join(HERE, '..', '..', 'hooks', 'scripts', 'pre-compact-flush.mjs');
|
||||
|
||||
function git(cwd, ...args) {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
function project(checkpoint, { stateStatus = 'in_progress' } = {}) {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'voyage-precompact-'));
|
||||
git(repo, 'init', '-q');
|
||||
git(repo, 'config', 'user.email', 't@example.invalid');
|
||||
git(repo, 'config', 'user.name', 'test');
|
||||
writeFileSync(join(repo, 'a.txt'), 'a\n');
|
||||
git(repo, 'add', 'a.txt');
|
||||
git(repo, 'commit', '-q', '-m', 'base');
|
||||
const base = git(repo, 'rev-parse', 'HEAD');
|
||||
writeFileSync(join(repo, 'a.txt'), 'b\n');
|
||||
git(repo, 'add', 'a.txt');
|
||||
git(repo, 'commit', '-q', '-m', 'feat(a): change a');
|
||||
const dir = join(repo, '.claude', 'projects', 'p');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'plan.md'), ['# Plan', '', '### Step 1: Change a', '', `- **Checkpoint:** ${checkpoint}`, ''].join('\n'));
|
||||
writeFileSync(join(dir, 'progress.json'), JSON.stringify({ status: 'in_progress', current_step: 0, steps: {}, session_start_sha: base }));
|
||||
writeFileSync(join(dir, '.session-state.local.json'), JSON.stringify({ status: stateStatus, updated_at: '2000-01-01T00:00:00.000Z' }));
|
||||
return { repo, dir };
|
||||
}
|
||||
|
||||
function run(repo) {
|
||||
return spawnSync(process.execPath, [HOOK], { input: '{}', encoding: 'utf8', env: { ...process.env, CLAUDE_PROJECT_DIR: repo } });
|
||||
}
|
||||
|
||||
const progress = (dir) => JSON.parse(readFileSync(join(dir, 'progress.json'), 'utf8'));
|
||||
const state = (dir) => JSON.parse(readFileSync(join(dir, '.session-state.local.json'), 'utf8'));
|
||||
|
||||
for (const [name, cp] of [
|
||||
['the old form', '`git commit -m "feat(a): change a"`'],
|
||||
['the template form with git add', '`git add a.txt && git commit -m "feat(a): change a"`'],
|
||||
]) {
|
||||
test(`pre-compact: ${name} -> progress.json is rebuilt from git`, () => {
|
||||
const { repo, dir } = project(cp);
|
||||
try {
|
||||
assert.equal(run(repo).status, 0);
|
||||
assert.equal(progress(dir).current_step, 1);
|
||||
} finally { rmSync(repo, { recursive: true, force: true }); }
|
||||
});
|
||||
}
|
||||
|
||||
test('pre-compact: a plan with no recognisable checkpoint still refreshes the session state', () => {
|
||||
const { repo, dir } = project('`make commit`');
|
||||
try {
|
||||
assert.equal(run(repo).status, 0);
|
||||
assert.equal(progress(dir).current_step, 0);
|
||||
assert.notEqual(state(dir).updated_at, '2000-01-01T00:00:00.000Z');
|
||||
} finally { rmSync(repo, { recursive: true, force: true }); }
|
||||
});
|
||||
|
|
@ -72,14 +72,21 @@ test('A2: /trekreview asks the transcription for the engine\'s fix text and forb
|
|||
|
||||
// --- A3. an out-of-range /code-review finding does not lock the verdict -----
|
||||
|
||||
test('A3: a /code-review finding outside the exact range it was given is declined, and does not hold ALLOW back', () => {
|
||||
const out = { ...engineFinding({ file: 'tests/cli.test.mjs', line: 1 }), engine: 'code-review', rule_key: 'CODE_REVIEW_DEFECT', severity: 'MAJOR', title: 'No CLI test for --sep' };
|
||||
test('A3: a small /code-review finding outside the exact range it was given is declined, and does not hold ALLOW back', () => {
|
||||
const out = { ...engineFinding({ file: 'src/util.mjs', line: 4 }), engine: 'code-review', rule_key: 'CODE_REVIEW_IMPROVEMENT', severity: 'MINOR', title: 'Simplify the helper' };
|
||||
const c = runContract([], { engineOutOfScope: [out] });
|
||||
assert.equal(c.verdict, 'ALLOW', JSON.stringify(c.allow_blocked_by));
|
||||
assert.equal(c.declined_to_judge.length, 1);
|
||||
assert.equal(c.declined_to_judge[0].reason, 'engine:out-of-scope');
|
||||
});
|
||||
|
||||
test('A3 (independent review): a MAJOR /code-review defect cited at an unchanged file still forbids ALLOW — a cross-file break is cited there', () => {
|
||||
const out = { ...engineFinding({ file: 'tests/cli.test.mjs', line: 1 }), engine: 'code-review', rule_key: 'CODE_REVIEW_DEFECT', severity: 'MAJOR', title: 'No CLI test for --sep' };
|
||||
const c = runContract([], { engineOutOfScope: [out] });
|
||||
assert.notEqual(c.verdict, 'ALLOW');
|
||||
assert.equal(c.declined_to_judge.length, 1);
|
||||
});
|
||||
|
||||
test('A3: a /security-review finding outside the range still forbids ALLOW (its base is wider and not ours)', () => {
|
||||
const out = { ...engineFinding({ file: 'lib/auth.mjs' }), engine: 'security-review', rule_key: 'SECURITY_REVIEW_VULNERABILITY', severity: 'BLOCKER', title: 'Token logged' };
|
||||
const c = runContract([], { engineOutOfScope: [out] });
|
||||
|
|
@ -170,3 +177,49 @@ test('A7: every checkpoint the plan template and /trekplan\'s example show stage
|
|||
assert.ok(lines.length >= 3, `found ${lines.length}`);
|
||||
for (const l of lines) assert.match(l, /`git add [^`]+ && git commit -m /, l);
|
||||
});
|
||||
|
||||
// --- the independent review of these fixes (2026-10-08) ----------------------
|
||||
|
||||
test('A1 (review): a `reuse` finding is an improvement too', () => {
|
||||
const r = toReviewerPayload({ engine: 'code-review', findings: [engineFinding({ category: 'Reuse ' })] });
|
||||
assert.equal(r.payload.findings[0].severity, 'MINOR');
|
||||
});
|
||||
|
||||
test('A5 (review): a sub-bullet that carries its own command stays a criterion; only command-less sub-points fold', () => {
|
||||
const sc = parseSuccessCriteria(brief(['## Success Criteria', '',
|
||||
'- Behaviour:', ' - `npm test` exits 0', ' - `bash tests/e2e.sh` exits 0', ' - reads well', '']));
|
||||
assert.deepEqual(sc.map((c) => c.command), [null, 'npm test', 'bash tests/e2e.sh']);
|
||||
assert.equal(sc[0].subpoints, 1);
|
||||
});
|
||||
|
||||
test('A5 (review): an indented first bullet does not turn later top-level bullets into sub-points', () => {
|
||||
const sc = parseSuccessCriteria(brief(['## Success Criteria', '',
|
||||
' - The first, indented by accident.', '- The second:', ' - a sub-point of the second', '- `npm test` exits 0.', '']));
|
||||
assert.equal(sc.length, 3, JSON.stringify(sc.map((c) => c.text)));
|
||||
assert.equal(sc[1].subpoints, 1);
|
||||
});
|
||||
|
||||
test('A6 (review): a negated bash 3.2 constraint, an env prefix and `bash -c` are handled', () => {
|
||||
const neg = validateBriefContent(brief(['## Constraints', '', '- bash 3.2 is not required here.', '',
|
||||
'## Success Criteria', '', '- `bash tests/contract.sh` exits 0.', '']));
|
||||
assert.ok(!codes(neg.warnings).includes('BRIEF_SC_BASH_NOT_PINNED'), 'negated constraint');
|
||||
const env = validateBriefContent(brief([...BASH32, '## Success Criteria', '', '- `LC_ALL=C bash tests/contract.sh` exits 0.', '']));
|
||||
assert.ok(codes(env.warnings).includes('BRIEF_SC_BASH_NOT_PINNED'), 'env-prefixed bash');
|
||||
const dashC = validateBriefContent(brief([...BASH32, '## Success Criteria', '', '- `bash -c true` exits 0.', '']));
|
||||
for (const w of dashC.warnings.filter((x) => x.code === 'BRIEF_SC_BASH_NOT_PINNED')) {
|
||||
assert.doesNotMatch(w.hint, /\/bin\/bash -c/, 'never suggests a form the runner refuses');
|
||||
}
|
||||
});
|
||||
|
||||
test('A7 (review): the session-spec template stages before it commits too', () => {
|
||||
const l = read('templates/session-spec-template.md').split('\n').filter((x) => /\*\*Checkpoint:\*\*/.test(x) && /`git [^`]*commit -m /.test(x));
|
||||
assert.ok(l.length >= 1);
|
||||
for (const x of l) assert.match(x, /`git add [^`]+ && git commit -m /, x);
|
||||
});
|
||||
|
||||
test('docs (review): the counts and the Declined-to-Judge rule match the code', () => {
|
||||
assert.match(read('docs/architecture.md'), /rule catalogue \(15 keys\)/);
|
||||
assert.match(read('docs/HANDOVER-CONTRACTS.md'), /three engine rule keys/);
|
||||
assert.doesNotMatch(read('lib/review/engine-findings.mjs'), /it forbids ALLOW and is listed under/);
|
||||
assert.match(read('agents/review-coordinator.md'), /every `\[unverified\]` one, and every\s+out-of-scope engine finding/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue