feat(review): review_version 1.2 with answers, pre-existing, rounds and policy fields
This commit is contained in:
parent
ed67526987
commit
5709957e10
16 changed files with 1034 additions and 11 deletions
|
|
@ -71,7 +71,7 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
|
|||
> finding, fetched pages are data, nothing local in a query, and the one confidence scale. Only the
|
||||
> brief's structure is checked; the rest is prose only (a query guard is a separate later order).
|
||||
|
||||
> **Review is a gate (review_version 1.1).** `verdict:` is required in
|
||||
> **Review is a gate (review_version 1.2).** `verdict:` is required in
|
||||
> `review.md` (K7); exit 2 from the verdict gate is never green. No finding is
|
||||
> struck for length — only MINOR/SUGGESTION text is trimmed, and counted.
|
||||
> Project-memory lines are written only between Voyage's markers in the target
|
||||
|
|
|
|||
25
MIGRATION.md
25
MIGRATION.md
|
|
@ -10,3 +10,28 @@ path — see [`GOVERNANCE.md`](https://git.fromaitochitta.com/open/ktg-plugin-ma
|
|||
|
||||
Prior version migration notes (v1→v2, v2→v3) are preserved in
|
||||
`CHANGELOG.md` only.
|
||||
|
||||
## review_version 1.2 (from the next release)
|
||||
|
||||
`review.md` moves from `review_version: "1.1"` to `"1.2"`. Everything new is
|
||||
optional, and each field or section is checked for its shape only when it is
|
||||
present:
|
||||
|
||||
- frontmatter `round`, `round_base`, `review_policy`, `review_policy_note`,
|
||||
`review_policy_sha256` and `finalized`;
|
||||
- in the trailing JSON block, per finding `pre_existing`, `carried_from` and
|
||||
`instruction_ref`, and at the top level `pre_existing_counts`, `resolved`
|
||||
and `ran`;
|
||||
- the sections `## Answers` (the operator's answer to each counting MAJOR),
|
||||
`## Pre-existing` and `## Resolved since round <n>`;
|
||||
- three `## Declined to Judge` reasons: `policy:skip`, `policy:cap` and
|
||||
`instruction-out-of-set`.
|
||||
|
||||
N-1 window: a 1.1 review validates unchanged for one minor version, and only
|
||||
with a warning after that. No reader fails on a 1.1 review: the validator,
|
||||
`/trekplan` and the verdict gate all read it.
|
||||
|
||||
Gate change: a WARN opens the merge only when the verdict gate finds every
|
||||
counting MAJOR answered. That holds for a 1.1 review too, so a 1.1 WARN with a
|
||||
MAJOR keeps the merge lock closed until the MAJOR is answered; a 1.1 WARN
|
||||
without a MAJOR is read as before.
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ Produce the full review.md content as your output. The
|
|||
```yaml
|
||||
---
|
||||
type: trekreview
|
||||
review_version: "1.1"
|
||||
review_version: "1.2"
|
||||
created: {YYYY-MM-DD}
|
||||
task: "{from brief frontmatter}"
|
||||
slug: {from brief frontmatter}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ The review is **independent**: each reviewer runs without cross-feeding,
|
|||
and the coordinator applies BOUNDED operations only. Synthesis-level
|
||||
inference across files is forbidden in v1.0 (Judge Agent pattern).
|
||||
|
||||
**The review is a gate (review_version 1.1, ordre 5).** In this order:
|
||||
**The review is a gate (review_version 1.2, ordre 5).** In this order:
|
||||
Claude Code's own `/code-review` and `/security-review` are the engine
|
||||
(Phase 4.7); Voyage's control against the brief runs after them and is the
|
||||
part that stops (Phase 5–6); the verdict is written in `review.md` and one
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Each artifact carries an explicit version field. Schema bumps are coordinated:
|
|||
| `research/*.md` | (implicit; tracked via `type: trekresearch-brief`) | unversioned |
|
||||
| `plan.md` | `plan_version` (plan header line) | `1.9` (1.8 and 1.7 still valid) |
|
||||
| `progress.json` | `schema_version` (top-level) | `"1"` |
|
||||
| `review.md` | `review_version` (frontmatter) | `1.1` |
|
||||
| `review.md` | `review_version` (frontmatter) | `1.2` |
|
||||
| `.session-state.local.json` | `schema_version` (top-level) | `1` (number) |
|
||||
|
||||
## Breaking-change protocol
|
||||
|
|
@ -432,7 +432,7 @@ missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
|
|||
|
||||
**Consumer:** `/trekplan` Phase 1 when `--brief review.md` is supplied and the consumer detects `type: trekreview` in frontmatter. The plan command branches into a remediation-plan path: BLOCKER + MAJOR findings become plan goals, the produced `plan.md` carries a `source_findings: [<id>, ...]` frontmatter list as the audit trail back to the consumed findings. MINOR + SUGGESTION are skipped for plan-input. Since 1.1 every BLOCKER is also written as a numbered `### Step R<n>` under `## Remediation Steps` (Files · Source finding · Test first · Verify · On failure), so a consumer can append it as a plan step without rewording it.
|
||||
|
||||
**Second consumer — the verdict gate (K7, 1.1):** `lib/review/verdict-gate.mjs <review.md>` reads the frontmatter `verdict` and exits 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide. Exit 2 is never green: a missing file, a missing or unknown verdict, two verdict lines, a trailing JSON block that is not the last fence or lacks `verdict`/`counts`/`findings`, and a file whose JSON block, `## Findings (BLOCKER)` section or finding-id list contradicts a greener verdict all land there — and so does a WARN that exists because a reviewer or engine did not run (`missing-reviewer:` / `unattributable-payload` in the JSON block's `allow_blocked_by`, which a 1.1 review must carry): a review not performed in full stops a merge (operator decision 2026-10-06). Exactly one file per call. With `--repo <dir>` (`verdict-gate.mjs --json <review.md> --repo <dir>`, R6) an ALLOW/WARN review must also be of the commit being merged in `<dir>`: its `scope_sha_end`, read only as a full 40- or 64-hex commit id (a short id or a ref name is stale), is HEAD, or an ancestor of HEAD whose later commits change only the review lessons (`.claude/rules/voyage-review-lessons.md`, inside its managed block) and the `CLAUDE.md` pointer (`lib/review/reviewed-commit.mjs`); otherwise exit 2 with a reason that starts `review-stale:`, which `/trekexecute`'s merge lock (Phase 7.2 step 4 and Phase 8, both with `--repo .`) maps to the decline reason `review-stale`. When the gate cannot look (the `--repo` path is missing, it is no git repository, or a git call failed), the exit is 2 with a reason that starts `review-not-measured:`, never `review-stale:`: the merge lock reports it as not measured, never green. A `--repo` with no value is exit 2 as well; BLOCK stays exit 1. Whatever stops a merge or a release on a review reads this exit code, never a summary. Borrowed: the verdict goes to a file and a command gates on it (Kiro `feature-pipeline`); the executor reads the verdict but never writes it (Spec Kit).
|
||||
**Second consumer — the verdict gate (K7, 1.1):** `lib/review/verdict-gate.mjs <review.md>` reads the frontmatter `verdict` and exits 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide. WARN opens the merge only when the verdict gate finds every counting MAJOR answered. Exit 2 is never green: a missing file, a missing or unknown verdict, two verdict lines, a trailing JSON block that is not the last fence or lacks `verdict`/`counts`/`findings`, and a file whose JSON block, `## Findings (BLOCKER)` section or finding-id list contradicts a greener verdict all land there — and so does a WARN that exists because a reviewer or engine did not run (`missing-reviewer:` / `unattributable-payload` in the JSON block's `allow_blocked_by`, which a 1.1 review must carry): a review not performed in full stops a merge (operator decision 2026-10-06). Exactly one file per call. With `--repo <dir>` (`verdict-gate.mjs --json <review.md> --repo <dir>`, R6) an ALLOW/WARN review must also be of the commit being merged in `<dir>`: its `scope_sha_end`, read only as a full 40- or 64-hex commit id (a short id or a ref name is stale), is HEAD, or an ancestor of HEAD whose later commits change only the review lessons (`.claude/rules/voyage-review-lessons.md`, inside its managed block) and the `CLAUDE.md` pointer (`lib/review/reviewed-commit.mjs`); otherwise exit 2 with a reason that starts `review-stale:`, which `/trekexecute`'s merge lock (Phase 7.2 step 4 and Phase 8, both with `--repo .`) maps to the decline reason `review-stale`. When the gate cannot look (the `--repo` path is missing, it is no git repository, or a git call failed), the exit is 2 with a reason that starts `review-not-measured:`, never `review-stale:`: the merge lock reports it as not measured, never green. A `--repo` with no value is exit 2 as well; BLOCK stays exit 1. Whatever stops a merge or a release on a review reads this exit code, never a summary. Borrowed: the verdict goes to a file and a command gates on it (Kiro `feature-pipeline`); the executor reads the verdict but never writes it (Spec Kit).
|
||||
|
||||
**Path conventions:**
|
||||
- Project-dir mode (recommended): `{project_dir}/review.md` (one per review iteration; subsequent runs overwrite atomically).
|
||||
|
|
@ -443,7 +443,7 @@ missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
|
|||
| Field | Type | Required | Allowed values | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `type` | string | yes | `trekreview` | Hard-coded discriminator |
|
||||
| `review_version` | string | yes | `"1.1"` (current) | Bump on schema change |
|
||||
| `review_version` | string | yes | `"1.2"` (current); `"1.1"` still valid | Bump on schema change |
|
||||
| `task` | string | yes | one-line description | Mirrors brief task |
|
||||
| `slug` | string | yes | URL-safe slug | Used in project_dir |
|
||||
| `project_dir` | string | yes | `.claude/projects/{date}-{slug}/` | |
|
||||
|
|
@ -454,6 +454,12 @@ missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
|
|||
| `verdict` | string | yes (1.1; strict at write, warning at read) | `BLOCK \| WARN \| ALLOW` | Coordinator output; equals the trailing JSON block's `verdict` (K7) |
|
||||
| `created` | date | optional | YYYY-MM-DD | |
|
||||
| `scope_sha_start` | string | optional | git sha at review start | `null` if mtime fallback used |
|
||||
| `round` | integer | optional (1.2) | `1`-`3` | The review round; written by `review-finalize.mjs` |
|
||||
| `round_base` | string \| null | optional (1.2) | full 40- or 64-hex commit id, or `null` at round 1 | The commit the previous round reviewed; written by `review-finalize.mjs` |
|
||||
| `review_policy` | string | optional (1.2) | `absent \| applied \| not-applied` | Whether a `REVIEW.md` policy block was applied; written by `review-finalize.mjs` |
|
||||
| `review_policy_note` | string | optional (1.2) | one line | Why a policy was not applied, or what it did |
|
||||
| `review_policy_sha256` | string | optional (1.2) | 64-hex, or `none` | sha256 of the policy block read at `scope_sha_start` |
|
||||
| `finalized` | boolean | optional (1.2) | `true \| false` | `true` is set by `review-finalize.mjs` when it has written `ran` |
|
||||
|
||||
`findings:` is a flat array of finding-IDs (40-char hex from `lib/parsers/finding-id.mjs`). The full finding objects (severity, location, message, evidence, fix) live in the body as `### <id>` subsections under per-severity `## Findings (...)` headings — same pattern as brief-reviewer to avoid frontmatter-parser fragility on lists of dicts.
|
||||
|
||||
|
|
@ -469,6 +475,13 @@ Since 1.1 (ordre 5):
|
|||
- `## 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, reuse, style or readability) and `SECURITY_REVIEW_VULNERABILITY` (BLOCKER), or a narrower key, with `reviewer` `claude-code-review` / `claude-security-review`.
|
||||
|
||||
Since 1.2 (every field and section optional; a malformed one present is a strict error, a soft warning):
|
||||
- Trailing JSON block, additive: per finding `pre_existing` (boolean: the range did not add or change the cited line), `carried_from` (the previous round's finding id) and `instruction_ref` (the repo path of the instruction file a CLAUDE.md-key finding cites); top level `pre_existing_counts` (`{SEVERITY: n}`), `resolved` (the previous round's finding ids no longer raised) and `ran` (`{engines: [code-review|security-review], reviewers: [string]}`, written with `finalized: true`). Codes `REVIEW_PRE_EXISTING_MALFORMED`, `REVIEW_ROUND_MALFORMED`, `REVIEW_POLICY_MALFORMED`.
|
||||
- `## Pre-existing` — the titles and counts of the findings marked `pre_existing`; written by `review-finalize.mjs`.
|
||||
- `## Resolved since round <n>` — the previous round's findings this round no longer raises; written by `review-finalize.mjs`.
|
||||
- `## Answers` — the operator's answer to each counting MAJOR, written by `/trekreview` Phase 8.8 after `## Remediation Steps` and before the trailing JSON block: a pipe table with the header exactly `| Finding | Answer | Reason | Record | Carried from |`; per row a 40-hex finding id (optionally `#n`), `accepted` or `fixed`, a reason (non-empty when `accepted`), the 64-hex hash of the K2 `review` record, and `-` or `review-round-<run12>-<n>.md#<id>`; at most one row per finding. A MAJOR answered `fixed` needs its `### Step R<n>` with `Source finding: <id>` under `## Remediation Steps`. Code `REVIEW_ANSWERS_MALFORMED`.
|
||||
- `## Declined to Judge` gains three reasons: `policy:skip` and `policy:cap` (a MINOR or SUGGESTION finding the `REVIEW.md` policy skipped or capped) and `instruction-out-of-set` (a CLAUDE.md-key finding that cites a file outside the tracked instruction set).
|
||||
|
||||
**The brief-conflict mark (fields `engine`, `contradicts_brief`, `brief_lowered_from`).**
|
||||
Each finding may carry three more fields, in its body field list and in the trailing JSON block:
|
||||
`engine` (`code-review` | `security-review`, on a finding that came from an engine),
|
||||
|
|
@ -499,11 +512,12 @@ A plan made from the review skips a lowered finding: it is MINOR, and `/trekplan
|
|||
| Verdict present | strict: error · soft: warning | `verdict` ∈ `ALLOW \| WARN \| BLOCK`; an out-of-enum value is an error in both modes |
|
||||
| Remediation step per BLOCKER | strict: error · soft: warning | `### Step R<n>` with `Source finding: <id>` under `## Remediation Steps` |
|
||||
| Body sections | strict only | `Executive Summary`, `Coverage`, `Remediation Summary` |
|
||||
| 1.2 field shapes | strict: error · soft: warning | at `review_version` ≥ 1.2, each optional field and the `## Answers` table present has its shape; a MAJOR answered `fixed` has its `### Step R<n>` |
|
||||
| Version format | every read | `review_version` matches `N.M`; warning otherwise |
|
||||
|
||||
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; 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.
|
||||
**Versioning:** current is `1.2`. From 1.1 to 1.2 only optional fields and sections were added (the round, policy and `finalized` frontmatter fields, the `pre_existing`, `carried_from`, `instruction_ref`, `pre_existing_counts`, `resolved` and `ran` JSON fields, `## Answers`, `## Pre-existing` and `## Resolved since round <n>`), so a 1.1 review still validates, and it never opens the merge lock with an unanswered MAJOR, because the verdict gate checks the answers of a 1.1 WARN as it does a 1.2 one. 1.1 (ordre 5) made `verdict` required and added remediation steps, Declined to Judge and two engine rule keys; a third, `CODE_REVIEW_IMPROVEMENT`, came with lekerepo trial 2 — three engine rule keys. N-1 window: a `1.1` review validates unchanged for one minor version, and only with a warning after that; no reader fails on it. 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
|
||||
|
|
@ -518,6 +532,10 @@ The validator (`lib/validators/review-validator.mjs`) exposes the same CLI as th
|
|||
- `REVIEW_MISSING_VERDICT` → strict halt; soft-mode warning (1.0 reviews)
|
||||
- `REVIEW_BAD_VERDICT` → halt; verdict outside `ALLOW | WARN | BLOCK`
|
||||
- `REVIEW_MISSING_REMEDIATION_STEP` → strict halt; soft-mode warning
|
||||
- `REVIEW_ANSWERS_MALFORMED` → strict halt; soft-mode warning (1.2: the `## Answers` table, or a MAJOR answered `fixed` without its R-step)
|
||||
- `REVIEW_POLICY_MALFORMED` → strict halt; soft-mode warning (1.2: `review_policy`, `review_policy_sha256`, `instruction_ref`)
|
||||
- `REVIEW_ROUND_MALFORMED` → strict halt; soft-mode warning (1.2: `round`, `round_base`, `finalized`, `resolved`, `ran`, `carried_from`)
|
||||
- `REVIEW_PRE_EXISTING_MALFORMED` → strict halt; soft-mode warning (1.2: `pre_existing`, `pre_existing_counts`)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,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 (17 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/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.2); `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>`)
|
||||
- `lib/validators/architecture-discovery.mjs` — drift-WARN external-contract discovery for `architecture/overview.md`
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@
|
|||
// warns, so a 1.0 review can still seed a plan (N-1 window, Handover 6). The
|
||||
// gate that decides on the verdict is lib/review/verdict-gate.mjs, which never
|
||||
// reads a missing verdict as ALLOW.
|
||||
//
|
||||
// review_version 1.2: optional round, policy, pre-existing, ran and `## Answers`
|
||||
// fields; each one present is checked for shape (`review12Issues`). A 1.1
|
||||
// review is read as before (N-1 window).
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
|
|
@ -67,6 +71,175 @@ function remediationStepSources(body) {
|
|||
return ids;
|
||||
}
|
||||
|
||||
// ---- review_version 1.2 shapes ------------------------------------------------
|
||||
// Codes: REVIEW_ANSWERS_MALFORMED (the `## Answers` table, and a MAJOR answered
|
||||
// `fixed` without its R-step), REVIEW_POLICY_MALFORMED (`review_policy`,
|
||||
// `review_policy_sha256`, a finding's `instruction_ref`), REVIEW_ROUND_MALFORMED
|
||||
// (`round`, `round_base`, `finalized`, `resolved`, `ran`, a finding's
|
||||
// `carried_from`) and REVIEW_PRE_EXISTING_MALFORMED (a finding's `pre_existing`,
|
||||
// `pre_existing_counts`).
|
||||
|
||||
const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
||||
const SHA256_RE = /^[0-9a-f]{64}$/;
|
||||
const SEVERITIES = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'];
|
||||
const REVIEW_POLICY_VALUES = ['absent', 'applied', 'not-applied'];
|
||||
const RAN_ENGINES = ['code-review', 'security-review'];
|
||||
const ANSWERS_HEADER = '| Finding | Answer | Reason | Record | Carried from |';
|
||||
const ANSWER_FINDING_RE = /^([0-9a-f]{40})(?:#\d+)?$/;
|
||||
const CARRIED_FROM_RE = /^review-round-[0-9a-f]{12}-\d+\.md#[0-9a-f]{40}$/;
|
||||
|
||||
const isPlainObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
|
||||
/** The lines of the `## Answers` section (heading excluded), up to the next `## ` heading or a fence; null when absent. */
|
||||
function answersSectionLines(body) {
|
||||
const lines = body.split('\n');
|
||||
const at = lines.findIndex((l) => /^##\s+Answers\s*$/.test(l));
|
||||
if (at === -1) return null;
|
||||
const out = [];
|
||||
for (const l of lines.slice(at + 1)) {
|
||||
if (/^##\s/.test(l) || /^\s{0,3}(`{3,}|~{3,})/.test(l)) break;
|
||||
out.push(l);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `## Answers` table. Returns `{rows, problems}`; rows hold
|
||||
* `{id, answer, reason, record, carriedFrom}`. An absent section, or one with
|
||||
* no table, has no rows.
|
||||
*/
|
||||
function parseAnswersTable(body) {
|
||||
const section = answersSectionLines(body);
|
||||
const rows = [];
|
||||
const problems = [];
|
||||
if (section === null) return { rows, problems };
|
||||
const table = section.map((l) => l.trim()).filter((l) => l.startsWith('|'));
|
||||
if (table.length === 0) return { rows, problems };
|
||||
if (table[0] !== ANSWERS_HEADER) {
|
||||
problems.push(`## Answers header must be exactly "${ANSWERS_HEADER}", got ${JSON.stringify(table[0])}`);
|
||||
return { rows, problems };
|
||||
}
|
||||
if (table.length < 2 || !/^\|(?:\s*:?-{3,}:?\s*\|){5}$/.test(table[1])) {
|
||||
problems.push('## Answers table has no separator line of five columns under its header');
|
||||
return { rows, problems };
|
||||
}
|
||||
const seen = new Set();
|
||||
table.slice(2).forEach((line, i) => {
|
||||
const where = `## Answers row ${i + 1}`;
|
||||
if (!line.endsWith('|')) { problems.push(`${where} does not end with "|"`); return; }
|
||||
const cells = line.slice(1, -1).split('|').map((c) => c.trim());
|
||||
if (cells.length !== 5) { problems.push(`${where} has ${cells.length} cells, not 5`); return; }
|
||||
const [finding, answer, reason, record, carriedFrom] = cells;
|
||||
const m = finding.match(ANSWER_FINDING_RE);
|
||||
if (!m) problems.push(`${where}: Finding ${JSON.stringify(finding)} is not a 40-char hex id (optionally #n)`);
|
||||
if (answer !== 'accepted' && answer !== 'fixed') {
|
||||
problems.push(`${where}: Answer must be accepted|fixed, got ${JSON.stringify(answer)}`);
|
||||
}
|
||||
if (answer === 'accepted' && reason === '') problems.push(`${where}: an accepted answer needs a non-empty Reason`);
|
||||
if (!SHA256_RE.test(record)) problems.push(`${where}: Record ${JSON.stringify(record)} is not a 64-char hex record hash`);
|
||||
if (carriedFrom !== '-' && !CARRIED_FROM_RE.test(carriedFrom)) {
|
||||
problems.push(`${where}: Carried from must be "-" or review-round-<run12>-<n>.md#<id>, got ${JSON.stringify(carriedFrom)}`);
|
||||
}
|
||||
if (m) {
|
||||
if (seen.has(m[1])) problems.push(`${where}: a second row for finding ${m[1]} (at most one row per finding)`);
|
||||
seen.add(m[1]);
|
||||
rows.push({ id: m[1], answer, reason, record, carriedFrom });
|
||||
}
|
||||
});
|
||||
return { rows, problems };
|
||||
}
|
||||
|
||||
/** The parsed trailing JSON block, or null when absent or unparseable (other checks report those). */
|
||||
function findingsPayload(body) {
|
||||
const last = extractFindingsBlock(body);
|
||||
if (last === null) return null;
|
||||
try { return JSON.parse(last); } catch { return null; }
|
||||
}
|
||||
|
||||
/** Issues for the review_version 1.2 fields that are present. Absent fields are never an issue. */
|
||||
function review12Issues(fm, body) {
|
||||
const out = [];
|
||||
const round = (msg) => out.push(issue('REVIEW_ROUND_MALFORMED', msg));
|
||||
const policy = (msg) => out.push(issue('REVIEW_POLICY_MALFORMED', msg));
|
||||
const pre = (msg) => out.push(issue('REVIEW_PRE_EXISTING_MALFORMED', msg));
|
||||
const answers = (msg) => out.push(issue('REVIEW_ANSWERS_MALFORMED', msg));
|
||||
|
||||
// Frontmatter.
|
||||
if ('round' in fm && !(Number.isInteger(fm.round) && fm.round >= 1 && fm.round <= 3)) {
|
||||
round(`frontmatter.round must be an integer 1-3, got ${JSON.stringify(fm.round)}`);
|
||||
}
|
||||
if ('round_base' in fm && fm.round_base !== null
|
||||
&& !(typeof fm.round_base === 'string' && FULL_SHA_RE.test(fm.round_base))) {
|
||||
round(`frontmatter.round_base must be a full 40- or 64-hex commit id or null, got ${JSON.stringify(fm.round_base)}`);
|
||||
}
|
||||
if ('finalized' in fm && typeof fm.finalized !== 'boolean') {
|
||||
round(`frontmatter.finalized must be a boolean, got ${JSON.stringify(fm.finalized)}`);
|
||||
}
|
||||
if ('review_policy' in fm && !REVIEW_POLICY_VALUES.includes(fm.review_policy)) {
|
||||
policy(`frontmatter.review_policy must be one of ${REVIEW_POLICY_VALUES.join('|')}, got ${JSON.stringify(fm.review_policy)}`);
|
||||
}
|
||||
if ('review_policy_sha256' in fm
|
||||
&& !(fm.review_policy_sha256 === 'none' || (typeof fm.review_policy_sha256 === 'string' && SHA256_RE.test(fm.review_policy_sha256)))) {
|
||||
policy(`frontmatter.review_policy_sha256 must be 64-char hex or none, got ${JSON.stringify(fm.review_policy_sha256)}`);
|
||||
}
|
||||
|
||||
// Trailing JSON block.
|
||||
const payload = findingsPayload(body);
|
||||
const findings = Array.isArray(payload?.findings) ? payload.findings : [];
|
||||
findings.forEach((f, i) => {
|
||||
if (!isPlainObject(f)) return;
|
||||
if ('pre_existing' in f && typeof f.pre_existing !== 'boolean') {
|
||||
pre(`findings[${i}].pre_existing must be a boolean, got ${JSON.stringify(f.pre_existing)}`);
|
||||
}
|
||||
if ('carried_from' in f && !(typeof f.carried_from === 'string' && HEX_ID_RE.test(f.carried_from))) {
|
||||
round(`findings[${i}].carried_from must be a 40-char hex finding id, got ${JSON.stringify(f.carried_from)}`);
|
||||
}
|
||||
if ('instruction_ref' in f && typeof f.instruction_ref !== 'string') {
|
||||
policy(`findings[${i}].instruction_ref must be a string, got ${JSON.stringify(f.instruction_ref)}`);
|
||||
}
|
||||
});
|
||||
if (isPlainObject(payload)) {
|
||||
if ('pre_existing_counts' in payload) {
|
||||
const c = payload.pre_existing_counts;
|
||||
const okCounts = isPlainObject(c) && Object.entries(c)
|
||||
.every(([k, n]) => SEVERITIES.includes(k) && Number.isInteger(n) && n >= 0);
|
||||
if (!okCounts) pre(`pre_existing_counts must map ${SEVERITIES.join('|')} to counts ≥ 0, got ${JSON.stringify(c)}`);
|
||||
}
|
||||
if ('resolved' in payload) {
|
||||
const r = payload.resolved;
|
||||
if (!(Array.isArray(r) && r.every((id) => typeof id === 'string' && HEX_ID_RE.test(id)))) {
|
||||
round(`resolved must be an array of 40-char hex finding ids, got ${JSON.stringify(r)}`);
|
||||
}
|
||||
}
|
||||
if ('ran' in payload) {
|
||||
const r = payload.ran;
|
||||
const okRan = isPlainObject(r)
|
||||
&& Array.isArray(r.engines) && r.engines.every((e) => RAN_ENGINES.includes(e))
|
||||
&& Array.isArray(r.reviewers) && r.reviewers.every((s) => typeof s === 'string');
|
||||
if (!okRan) {
|
||||
round(`ran must be {engines: subset of ${RAN_ENGINES.join('|')}, reviewers: [string]}, got ${JSON.stringify(r)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ## Answers.
|
||||
const { rows, problems } = parseAnswersTable(body);
|
||||
for (const p of problems) answers(p);
|
||||
const majors = new Set(findings
|
||||
.filter((f) => isPlainObject(f) && typeof f.id === 'string' && String(f.severity).toUpperCase() === 'MAJOR')
|
||||
.map((f) => f.id));
|
||||
const fixedMajors = rows.filter((r) => r.answer === 'fixed' && majors.has(r.id));
|
||||
if (fixedMajors.length > 0) {
|
||||
const steps = remediationStepSources(body);
|
||||
for (const r of fixedMajors) {
|
||||
if (!steps.has(r.id)) {
|
||||
answers(`MAJOR ${r.id} is answered fixed but no "### Step R<n>" under ## Remediation Steps has "Source finding: ${r.id}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function validateReviewContent(text, opts = {}) {
|
||||
const strict = opts.strict !== false;
|
||||
const doc = parseDocument(text);
|
||||
|
|
@ -152,6 +325,16 @@ export function validateReviewContent(text, opts = {}) {
|
|||
else warnings.push(issueObj);
|
||||
}
|
||||
|
||||
// 1.2+: every new field is optional; a malformed one present is an error
|
||||
// (strict) or a warning (soft). A 1.1 file is read as before (N-1 window).
|
||||
const atLeast12 = v && (Number(v[1]) > 1 || (Number(v[1]) === 1 && Number(v[2]) >= 2));
|
||||
if (atLeast12) {
|
||||
for (const issueObj of review12Issues(fm, body)) {
|
||||
if (strict) errors.push(issueObj);
|
||||
else warnings.push(issueObj);
|
||||
}
|
||||
}
|
||||
|
||||
for (const section of REVIEW_BODY_SECTIONS) {
|
||||
const re = new RegExp(`^##\\s+${section}\\b`, 'm');
|
||||
if (!re.test(body)) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
type: trekreview
|
||||
review_version: "1.1"
|
||||
review_version: "1.2"
|
||||
created: {YYYY-MM-DD}
|
||||
task: "{Task description from brief.md}"
|
||||
slug: {project-slug}
|
||||
|
|
@ -10,6 +10,13 @@ scope_sha_start: {sha-from-progress.json/session_start_sha-OR-null-if-mtime-fall
|
|||
scope_sha_end: {sha-of-HEAD-at-review-time}
|
||||
reviewed_files_count: {N}
|
||||
verdict: BLOCK
|
||||
# Optional (review_version 1.2): written by review-finalize.mjs, never by the coordinator.
|
||||
round: 1
|
||||
round_base: null
|
||||
review_policy: absent
|
||||
review_policy_note: "no REVIEW.md policy block at scope_sha_start"
|
||||
review_policy_sha256: none
|
||||
finalized: true
|
||||
findings:
|
||||
- 0123456789abcdef0123456789abcdef01234567
|
||||
- fedcba9876543210fedcba9876543210fedcba98
|
||||
|
|
@ -95,6 +102,18 @@ should look at first.
|
|||
bug but worth noting for v1.1 cleanup.
|
||||
- recommended_action: Track in TODO.md if not already.
|
||||
|
||||
## Pre-existing
|
||||
|
||||
<!-- Optional (review_version 1.2). Written by review-finalize.mjs: the titles and counts of the findings on lines this range did not add or change (`pre_existing: true`). -->
|
||||
|
||||
None.
|
||||
|
||||
## Resolved since round {n}
|
||||
|
||||
<!-- Optional (review_version 1.2), from round 2. Written by review-finalize.mjs: the previous round's findings this round no longer raises. -->
|
||||
|
||||
None.
|
||||
|
||||
## Declined to Judge
|
||||
|
||||
None.
|
||||
|
|
@ -132,10 +151,20 @@ becomes the step intent; each BLOCKER's `## Remediation Steps` entry is
|
|||
ready to append as a plan step). MINOR + SUGGESTION are skipped for
|
||||
plan-input.
|
||||
|
||||
## Answers
|
||||
|
||||
<!-- Optional (review_version 1.2). Written by /trekreview Phase 8.8 (review-answers.mjs record), one row per counting MAJOR the operator answered: the 40-hex finding id, accepted or fixed, the reason, the 64-hex K2 record hash, and - or review-round-<run12>-<n>.md#<id>. -->
|
||||
|
||||
| Finding | Answer | Reason | Record | Carried from |
|
||||
|---|---|---|---|---|
|
||||
|
||||
```json
|
||||
{
|
||||
"verdict": "BLOCK",
|
||||
"counts": { "BLOCKER": 1, "MAJOR": 1, "MINOR": 1, "SUGGESTION": 1 },
|
||||
"pre_existing_counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 },
|
||||
"resolved": [],
|
||||
"ran": { "engines": ["code-review", "security-review"], "reviewers": ["brief-conformance-reviewer", "code-correctness-reviewer"] },
|
||||
"allow_blocked_by": [],
|
||||
"declined_to_judge": [],
|
||||
"trimmed": 0,
|
||||
|
|
|
|||
74
tests/fixtures/review-version/review-1-1.md
vendored
Normal file
74
tests/fixtures/review-version/review-1-1.md
vendored
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
---
|
||||
type: trekreview
|
||||
review_version: "1.1"
|
||||
created: 2026-10-30
|
||||
task: "Add a retry to the HTTP client"
|
||||
slug: http-retry
|
||||
project_dir: .claude/projects/2026-10-30-http-retry/
|
||||
brief_path: .claude/projects/2026-10-30-http-retry/brief.md
|
||||
scope_sha_start: "9c7415404313b37eb41902654837df5c93d2b5d7"
|
||||
scope_sha_end: "801dd95f7372095cc06b9a6a1984f86038c5886b"
|
||||
reviewed_files_count: 2
|
||||
verdict: WARN
|
||||
findings:
|
||||
- 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
|
||||
---
|
||||
|
||||
# Review: Add a retry to the HTTP client
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Verdict: WARN. One MAJOR finding: the retry loop has no upper bound on its total wait.
|
||||
|
||||
## Coverage
|
||||
|
||||
| File | Treatment | Reason |
|
||||
|------|-----------|--------|
|
||||
| lib/http.mjs | deep-review | risk |
|
||||
| lib/config.mjs | summary-only | default |
|
||||
|
||||
## Findings (MAJOR)
|
||||
|
||||
### 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
|
||||
|
||||
- **Rule:** `CODE_REVIEW_DEFECT`
|
||||
- **Location:** `lib/http.mjs:41`
|
||||
- **Title:** The retry loop has no total time limit
|
||||
- **Recommended action:** Stop retrying once the total wait passes the configured limit.
|
||||
|
||||
## Declined to Judge
|
||||
|
||||
None.
|
||||
|
||||
Trimmed: 0
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
One MAJOR finding; no BLOCKER, so no remediation step is required.
|
||||
|
||||
```json
|
||||
{
|
||||
"verdict": "WARN",
|
||||
"counts": {
|
||||
"BLOCKER": 0,
|
||||
"MAJOR": 1,
|
||||
"MINOR": 0,
|
||||
"SUGGESTION": 0
|
||||
},
|
||||
"allow_blocked_by": [],
|
||||
"findings": [
|
||||
{
|
||||
"id": "77bc675af2ef7b4524b6a21b6fb23190bb56a32f",
|
||||
"severity": "MAJOR",
|
||||
"rule_key": "CODE_REVIEW_DEFECT",
|
||||
"file": "lib/http.mjs",
|
||||
"line": 41,
|
||||
"title": "The retry loop has no total time limit",
|
||||
"detail": "A server that keeps answering 503 keeps the caller waiting without end.",
|
||||
"recommended_action": "Stop retrying once the total wait passes the configured limit.",
|
||||
"engine": "code-review",
|
||||
"reviewer": "claude-code-review"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
154
tests/fixtures/review-version/review-1-2-bad-answers.md
vendored
Normal file
154
tests/fixtures/review-version/review-1-2-bad-answers.md
vendored
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
---
|
||||
type: trekreview
|
||||
review_version: "1.2"
|
||||
created: 2026-11-02
|
||||
task: "Add a retry to the HTTP client"
|
||||
slug: http-retry
|
||||
project_dir: .claude/projects/2026-10-30-http-retry/
|
||||
brief_path: .claude/projects/2026-10-30-http-retry/brief.md
|
||||
scope_sha_start: "9c7415404313b37eb41902654837df5c93d2b5d7"
|
||||
scope_sha_end: "801dd95f7372095cc06b9a6a1984f86038c5886b"
|
||||
reviewed_files_count: 2
|
||||
verdict: WARN
|
||||
round: 1
|
||||
round_base: null
|
||||
review_policy: applied
|
||||
review_policy_note: "max-minor 10, two skip paths"
|
||||
review_policy_sha256: "823412d1eacb67956220e532959f0104603057c88704863ca38e7cd188fda812"
|
||||
finalized: true
|
||||
findings:
|
||||
- 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
|
||||
- e1fa7656c24e23e316f96169884da5f15059beb9
|
||||
- bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb
|
||||
---
|
||||
|
||||
# Review: Add a retry to the HTTP client
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Verdict: WARN. Two MAJOR findings on lines this range changed, one answered `accepted` and one `fixed`; one
|
||||
pre-existing MAJOR on a line the range did not change.
|
||||
|
||||
## Coverage
|
||||
|
||||
| File | Treatment | Reason |
|
||||
|------|-----------|--------|
|
||||
| lib/http.mjs | deep-review | risk |
|
||||
| lib/config.mjs | summary-only | default |
|
||||
|
||||
## Findings (MAJOR)
|
||||
|
||||
### 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
|
||||
|
||||
- **Rule:** `CODE_REVIEW_DEFECT`
|
||||
- **Location:** `lib/http.mjs:41`
|
||||
- **Title:** The retry loop has no total time limit
|
||||
- **Recommended action:** Stop retrying once the total wait passes the configured limit.
|
||||
|
||||
### e1fa7656c24e23e316f96169884da5f15059beb9
|
||||
|
||||
- **Rule:** `CODE_REVIEW_DEFECT`
|
||||
- **Location:** `lib/http.mjs:58`
|
||||
- **Title:** A 429 answer is retried without reading Retry-After
|
||||
- **Recommended action:** Wait at least the Retry-After value before the next attempt.
|
||||
|
||||
### bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb
|
||||
|
||||
- **Rule:** `CODE_REVIEW_DEFECT`
|
||||
- **Location:** `lib/config.mjs:12`
|
||||
- **Title:** The timeout default is read as seconds and used as milliseconds
|
||||
- **Pre-existing:** yes (the range did not add or change this line)
|
||||
|
||||
## Pre-existing
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| MAJOR | 1 |
|
||||
|
||||
- The timeout default is read as seconds and used as milliseconds (`lib/config.mjs:12`)
|
||||
|
||||
## Declined to Judge
|
||||
|
||||
None.
|
||||
|
||||
Trimmed: 0
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
Two counting MAJOR findings, both answered; no BLOCKER.
|
||||
|
||||
## Remediation Steps
|
||||
|
||||
### Step R1: Honour Retry-After on a 429 answer
|
||||
|
||||
- **Files:** `lib/http.mjs`
|
||||
- **Source finding:** `e1fa7656c24e23e316f96169884da5f15059beb9`
|
||||
- **Test first:** a 429 with `Retry-After: 2` waits at least two seconds before the next attempt
|
||||
- **Verify:** re-run /trekreview → finding `e1fa7656c24e23e316f96169884da5f15059beb9` absent
|
||||
- **On failure:** revert
|
||||
|
||||
## Answers
|
||||
|
||||
| Finding | Answer | Reason | Record | Carried from |
|
||||
|---|---|---|---|---|
|
||||
| 77bc675af2ef7b4524b6a21b6fb23190bb56a32f | maybe | The caller sets its own deadline; a total limit here is a follow-up. | c97c121a37e664a6ccbe874f9b0afb22e21661305772e0c2939e61ddfdb74924 | - |
|
||||
| e1fa7656c24e23e316f96169884da5f15059beb9 | fixed | Fixed by Step R1. | 191904440c3a567c054e56ee1831204c422553732b255ad562f7034e54543554 | - |
|
||||
|
||||
```json
|
||||
{
|
||||
"verdict": "WARN",
|
||||
"counts": {
|
||||
"BLOCKER": 0,
|
||||
"MAJOR": 3,
|
||||
"MINOR": 0,
|
||||
"SUGGESTION": 0
|
||||
},
|
||||
"pre_existing_counts": {"BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0},
|
||||
"resolved": [],
|
||||
"ran": {
|
||||
"engines": ["code-review", "security-review"],
|
||||
"reviewers": ["brief-conformance-reviewer", "code-correctness-reviewer"]
|
||||
},
|
||||
"allow_blocked_by": [],
|
||||
"findings": [
|
||||
{
|
||||
"id": "77bc675af2ef7b4524b6a21b6fb23190bb56a32f",
|
||||
"severity": "MAJOR",
|
||||
"rule_key": "CODE_REVIEW_DEFECT",
|
||||
"file": "lib/http.mjs",
|
||||
"line": 41,
|
||||
"title": "The retry loop has no total time limit",
|
||||
"detail": "A server that keeps answering 503 keeps the caller waiting without end.",
|
||||
"recommended_action": "Stop retrying once the total wait passes the configured limit.",
|
||||
"engine": "code-review",
|
||||
"reviewer": "claude-code-review",
|
||||
"pre_existing": false
|
||||
},
|
||||
{
|
||||
"id": "e1fa7656c24e23e316f96169884da5f15059beb9",
|
||||
"severity": "MAJOR",
|
||||
"rule_key": "CODE_REVIEW_DEFECT",
|
||||
"file": "lib/http.mjs",
|
||||
"line": 58,
|
||||
"title": "A 429 answer is retried without reading Retry-After",
|
||||
"detail": "The client retries a 429 at once, so a rate-limited server is hit again before it allows it.",
|
||||
"recommended_action": "Wait at least the Retry-After value before the next attempt.",
|
||||
"engine": "code-review",
|
||||
"reviewer": "claude-code-review"
|
||||
},
|
||||
{
|
||||
"id": "bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb",
|
||||
"severity": "MAJOR",
|
||||
"rule_key": "CODE_REVIEW_DEFECT",
|
||||
"file": "lib/config.mjs",
|
||||
"line": 12,
|
||||
"title": "The timeout default is read as seconds and used as milliseconds",
|
||||
"detail": "The default of 30 becomes a 30 ms timeout.",
|
||||
"recommended_action": "Multiply the default by 1000 where it is read.",
|
||||
"engine": "code-review",
|
||||
"reviewer": "claude-code-review",
|
||||
"pre_existing": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
154
tests/fixtures/review-version/review-1-2-bad-policy.md
vendored
Normal file
154
tests/fixtures/review-version/review-1-2-bad-policy.md
vendored
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
---
|
||||
type: trekreview
|
||||
review_version: "1.2"
|
||||
created: 2026-11-02
|
||||
task: "Add a retry to the HTTP client"
|
||||
slug: http-retry
|
||||
project_dir: .claude/projects/2026-10-30-http-retry/
|
||||
brief_path: .claude/projects/2026-10-30-http-retry/brief.md
|
||||
scope_sha_start: "9c7415404313b37eb41902654837df5c93d2b5d7"
|
||||
scope_sha_end: "801dd95f7372095cc06b9a6a1984f86038c5886b"
|
||||
reviewed_files_count: 2
|
||||
verdict: WARN
|
||||
round: 1
|
||||
round_base: null
|
||||
review_policy: applied
|
||||
review_policy_note: "max-minor 10, two skip paths"
|
||||
review_policy_sha256: xyz
|
||||
finalized: true
|
||||
findings:
|
||||
- 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
|
||||
- e1fa7656c24e23e316f96169884da5f15059beb9
|
||||
- bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb
|
||||
---
|
||||
|
||||
# Review: Add a retry to the HTTP client
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Verdict: WARN. Two MAJOR findings on lines this range changed, one answered `accepted` and one `fixed`; one
|
||||
pre-existing MAJOR on a line the range did not change.
|
||||
|
||||
## Coverage
|
||||
|
||||
| File | Treatment | Reason |
|
||||
|------|-----------|--------|
|
||||
| lib/http.mjs | deep-review | risk |
|
||||
| lib/config.mjs | summary-only | default |
|
||||
|
||||
## Findings (MAJOR)
|
||||
|
||||
### 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
|
||||
|
||||
- **Rule:** `CODE_REVIEW_DEFECT`
|
||||
- **Location:** `lib/http.mjs:41`
|
||||
- **Title:** The retry loop has no total time limit
|
||||
- **Recommended action:** Stop retrying once the total wait passes the configured limit.
|
||||
|
||||
### e1fa7656c24e23e316f96169884da5f15059beb9
|
||||
|
||||
- **Rule:** `CODE_REVIEW_DEFECT`
|
||||
- **Location:** `lib/http.mjs:58`
|
||||
- **Title:** A 429 answer is retried without reading Retry-After
|
||||
- **Recommended action:** Wait at least the Retry-After value before the next attempt.
|
||||
|
||||
### bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb
|
||||
|
||||
- **Rule:** `CODE_REVIEW_DEFECT`
|
||||
- **Location:** `lib/config.mjs:12`
|
||||
- **Title:** The timeout default is read as seconds and used as milliseconds
|
||||
- **Pre-existing:** yes (the range did not add or change this line)
|
||||
|
||||
## Pre-existing
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| MAJOR | 1 |
|
||||
|
||||
- The timeout default is read as seconds and used as milliseconds (`lib/config.mjs:12`)
|
||||
|
||||
## Declined to Judge
|
||||
|
||||
None.
|
||||
|
||||
Trimmed: 0
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
Two counting MAJOR findings, both answered; no BLOCKER.
|
||||
|
||||
## Remediation Steps
|
||||
|
||||
### Step R1: Honour Retry-After on a 429 answer
|
||||
|
||||
- **Files:** `lib/http.mjs`
|
||||
- **Source finding:** `e1fa7656c24e23e316f96169884da5f15059beb9`
|
||||
- **Test first:** a 429 with `Retry-After: 2` waits at least two seconds before the next attempt
|
||||
- **Verify:** re-run /trekreview → finding `e1fa7656c24e23e316f96169884da5f15059beb9` absent
|
||||
- **On failure:** revert
|
||||
|
||||
## Answers
|
||||
|
||||
| Finding | Answer | Reason | Record | Carried from |
|
||||
|---|---|---|---|---|
|
||||
| 77bc675af2ef7b4524b6a21b6fb23190bb56a32f | accepted | The caller sets its own deadline; a total limit here is a follow-up. | c97c121a37e664a6ccbe874f9b0afb22e21661305772e0c2939e61ddfdb74924 | - |
|
||||
| e1fa7656c24e23e316f96169884da5f15059beb9 | fixed | Fixed by Step R1. | 191904440c3a567c054e56ee1831204c422553732b255ad562f7034e54543554 | - |
|
||||
|
||||
```json
|
||||
{
|
||||
"verdict": "WARN",
|
||||
"counts": {
|
||||
"BLOCKER": 0,
|
||||
"MAJOR": 3,
|
||||
"MINOR": 0,
|
||||
"SUGGESTION": 0
|
||||
},
|
||||
"pre_existing_counts": {"BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0},
|
||||
"resolved": [],
|
||||
"ran": {
|
||||
"engines": ["code-review", "security-review"],
|
||||
"reviewers": ["brief-conformance-reviewer", "code-correctness-reviewer"]
|
||||
},
|
||||
"allow_blocked_by": [],
|
||||
"findings": [
|
||||
{
|
||||
"id": "77bc675af2ef7b4524b6a21b6fb23190bb56a32f",
|
||||
"severity": "MAJOR",
|
||||
"rule_key": "CODE_REVIEW_DEFECT",
|
||||
"file": "lib/http.mjs",
|
||||
"line": 41,
|
||||
"title": "The retry loop has no total time limit",
|
||||
"detail": "A server that keeps answering 503 keeps the caller waiting without end.",
|
||||
"recommended_action": "Stop retrying once the total wait passes the configured limit.",
|
||||
"engine": "code-review",
|
||||
"reviewer": "claude-code-review",
|
||||
"pre_existing": false
|
||||
},
|
||||
{
|
||||
"id": "e1fa7656c24e23e316f96169884da5f15059beb9",
|
||||
"severity": "MAJOR",
|
||||
"rule_key": "CODE_REVIEW_DEFECT",
|
||||
"file": "lib/http.mjs",
|
||||
"line": 58,
|
||||
"title": "A 429 answer is retried without reading Retry-After",
|
||||
"detail": "The client retries a 429 at once, so a rate-limited server is hit again before it allows it.",
|
||||
"recommended_action": "Wait at least the Retry-After value before the next attempt.",
|
||||
"engine": "code-review",
|
||||
"reviewer": "claude-code-review"
|
||||
},
|
||||
{
|
||||
"id": "bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb",
|
||||
"severity": "MAJOR",
|
||||
"rule_key": "CODE_REVIEW_DEFECT",
|
||||
"file": "lib/config.mjs",
|
||||
"line": 12,
|
||||
"title": "The timeout default is read as seconds and used as milliseconds",
|
||||
"detail": "The default of 30 becomes a 30 ms timeout.",
|
||||
"recommended_action": "Multiply the default by 1000 where it is read.",
|
||||
"engine": "code-review",
|
||||
"reviewer": "claude-code-review",
|
||||
"pre_existing": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
154
tests/fixtures/review-version/review-1-2.md
vendored
Normal file
154
tests/fixtures/review-version/review-1-2.md
vendored
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
---
|
||||
type: trekreview
|
||||
review_version: "1.2"
|
||||
created: 2026-11-02
|
||||
task: "Add a retry to the HTTP client"
|
||||
slug: http-retry
|
||||
project_dir: .claude/projects/2026-10-30-http-retry/
|
||||
brief_path: .claude/projects/2026-10-30-http-retry/brief.md
|
||||
scope_sha_start: "9c7415404313b37eb41902654837df5c93d2b5d7"
|
||||
scope_sha_end: "801dd95f7372095cc06b9a6a1984f86038c5886b"
|
||||
reviewed_files_count: 2
|
||||
verdict: WARN
|
||||
round: 1
|
||||
round_base: null
|
||||
review_policy: applied
|
||||
review_policy_note: "max-minor 10, two skip paths"
|
||||
review_policy_sha256: "823412d1eacb67956220e532959f0104603057c88704863ca38e7cd188fda812"
|
||||
finalized: true
|
||||
findings:
|
||||
- 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
|
||||
- e1fa7656c24e23e316f96169884da5f15059beb9
|
||||
- bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb
|
||||
---
|
||||
|
||||
# Review: Add a retry to the HTTP client
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Verdict: WARN. Two MAJOR findings on lines this range changed, one answered `accepted` and one `fixed`; one
|
||||
pre-existing MAJOR on a line the range did not change.
|
||||
|
||||
## Coverage
|
||||
|
||||
| File | Treatment | Reason |
|
||||
|------|-----------|--------|
|
||||
| lib/http.mjs | deep-review | risk |
|
||||
| lib/config.mjs | summary-only | default |
|
||||
|
||||
## Findings (MAJOR)
|
||||
|
||||
### 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
|
||||
|
||||
- **Rule:** `CODE_REVIEW_DEFECT`
|
||||
- **Location:** `lib/http.mjs:41`
|
||||
- **Title:** The retry loop has no total time limit
|
||||
- **Recommended action:** Stop retrying once the total wait passes the configured limit.
|
||||
|
||||
### e1fa7656c24e23e316f96169884da5f15059beb9
|
||||
|
||||
- **Rule:** `CODE_REVIEW_DEFECT`
|
||||
- **Location:** `lib/http.mjs:58`
|
||||
- **Title:** A 429 answer is retried without reading Retry-After
|
||||
- **Recommended action:** Wait at least the Retry-After value before the next attempt.
|
||||
|
||||
### bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb
|
||||
|
||||
- **Rule:** `CODE_REVIEW_DEFECT`
|
||||
- **Location:** `lib/config.mjs:12`
|
||||
- **Title:** The timeout default is read as seconds and used as milliseconds
|
||||
- **Pre-existing:** yes (the range did not add or change this line)
|
||||
|
||||
## Pre-existing
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| MAJOR | 1 |
|
||||
|
||||
- The timeout default is read as seconds and used as milliseconds (`lib/config.mjs:12`)
|
||||
|
||||
## Declined to Judge
|
||||
|
||||
None.
|
||||
|
||||
Trimmed: 0
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
Two counting MAJOR findings, both answered; no BLOCKER.
|
||||
|
||||
## Remediation Steps
|
||||
|
||||
### Step R1: Honour Retry-After on a 429 answer
|
||||
|
||||
- **Files:** `lib/http.mjs`
|
||||
- **Source finding:** `e1fa7656c24e23e316f96169884da5f15059beb9`
|
||||
- **Test first:** a 429 with `Retry-After: 2` waits at least two seconds before the next attempt
|
||||
- **Verify:** re-run /trekreview → finding `e1fa7656c24e23e316f96169884da5f15059beb9` absent
|
||||
- **On failure:** revert
|
||||
|
||||
## Answers
|
||||
|
||||
| Finding | Answer | Reason | Record | Carried from |
|
||||
|---|---|---|---|---|
|
||||
| 77bc675af2ef7b4524b6a21b6fb23190bb56a32f | accepted | The caller sets its own deadline; a total limit here is a follow-up. | c97c121a37e664a6ccbe874f9b0afb22e21661305772e0c2939e61ddfdb74924 | - |
|
||||
| e1fa7656c24e23e316f96169884da5f15059beb9 | fixed | Fixed by Step R1. | 191904440c3a567c054e56ee1831204c422553732b255ad562f7034e54543554 | - |
|
||||
|
||||
```json
|
||||
{
|
||||
"verdict": "WARN",
|
||||
"counts": {
|
||||
"BLOCKER": 0,
|
||||
"MAJOR": 3,
|
||||
"MINOR": 0,
|
||||
"SUGGESTION": 0
|
||||
},
|
||||
"pre_existing_counts": {"BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0},
|
||||
"resolved": [],
|
||||
"ran": {
|
||||
"engines": ["code-review", "security-review"],
|
||||
"reviewers": ["brief-conformance-reviewer", "code-correctness-reviewer"]
|
||||
},
|
||||
"allow_blocked_by": [],
|
||||
"findings": [
|
||||
{
|
||||
"id": "77bc675af2ef7b4524b6a21b6fb23190bb56a32f",
|
||||
"severity": "MAJOR",
|
||||
"rule_key": "CODE_REVIEW_DEFECT",
|
||||
"file": "lib/http.mjs",
|
||||
"line": 41,
|
||||
"title": "The retry loop has no total time limit",
|
||||
"detail": "A server that keeps answering 503 keeps the caller waiting without end.",
|
||||
"recommended_action": "Stop retrying once the total wait passes the configured limit.",
|
||||
"engine": "code-review",
|
||||
"reviewer": "claude-code-review",
|
||||
"pre_existing": false
|
||||
},
|
||||
{
|
||||
"id": "e1fa7656c24e23e316f96169884da5f15059beb9",
|
||||
"severity": "MAJOR",
|
||||
"rule_key": "CODE_REVIEW_DEFECT",
|
||||
"file": "lib/http.mjs",
|
||||
"line": 58,
|
||||
"title": "A 429 answer is retried without reading Retry-After",
|
||||
"detail": "The client retries a 429 at once, so a rate-limited server is hit again before it allows it.",
|
||||
"recommended_action": "Wait at least the Retry-After value before the next attempt.",
|
||||
"engine": "code-review",
|
||||
"reviewer": "claude-code-review"
|
||||
},
|
||||
{
|
||||
"id": "bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb",
|
||||
"severity": "MAJOR",
|
||||
"rule_key": "CODE_REVIEW_DEFECT",
|
||||
"file": "lib/config.mjs",
|
||||
"line": 12,
|
||||
"title": "The timeout default is read as seconds and used as milliseconds",
|
||||
"detail": "The default of 30 becomes a 30 ms timeout.",
|
||||
"recommended_action": "Multiply the default by 1000 where it is read.",
|
||||
"engine": "code-review",
|
||||
"reviewer": "claude-code-review",
|
||||
"pre_existing": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
|
@ -126,7 +126,7 @@ test('[M4] Handover 6 names the review-not-measured reason', () => {
|
|||
|
||||
test('[M4] Handover 6 lists review_version 1.1 as current', () => {
|
||||
const row = fieldRow('review_version');
|
||||
assert.match(row, /"1\.1"`? \(current\)/, `the review_version row does not list 1.1 as current: ${row}`);
|
||||
assert.match(row, /"1\.2"`? \(current\)/, `the review_version row does not list 1.2 as current: ${row}`);
|
||||
assert.doesNotMatch(row, /"1\.0"`? \(current\)/, `the review_version row still lists 1.0 as current: ${row}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
116
tests/lib/fix-round-4-5b2-b-prose.test.mjs
Normal file
116
tests/lib/fix-round-4-5b2-b-prose.test.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// tests/lib/fix-round-4-5b2-b-prose.test.mjs
|
||||
// Prose pins for fix round 4.5b2, part B (plan
|
||||
// .claude/projects/2026-10-31-review-gate-4-5b2): the parts of the round that
|
||||
// are text a session reads. The tag in brackets is the part-B id from the
|
||||
// round's plan; each tagged test fails on an assertion at the step's base.
|
||||
// V12 review_version 1.2 is current: Handover 6's table and field row name
|
||||
// it, no line of docs/HANDOVER-CONTRACTS.md still names 1.1 as current,
|
||||
// the Versioning paragraph says what 1.1 -> 1.2 changed, MIGRATION.md
|
||||
// holds the 1.2 section with its N-1 window, and every other place that
|
||||
// states the current review_version says 1.2.
|
||||
// The helpers below are copied, never imported from an earlier prose test.
|
||||
// Fix the SOURCE a pin guards, not the pin.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
||||
const readCache = new Map();
|
||||
|
||||
/** A repo file's text; the file must exist. Read from disk once per run. */
|
||||
// Copied from tests/lib/fix-round-4-5b2-a-prose.test.mjs (read).
|
||||
function read(rel) {
|
||||
if (!readCache.has(rel)) {
|
||||
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
|
||||
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
|
||||
}
|
||||
return readCache.get(rel);
|
||||
}
|
||||
|
||||
/** Whitespace (line breaks too) collapsed to one space. */
|
||||
// Copied from tests/lib/fix-round-4-5b2-a-prose.test.mjs (flat).
|
||||
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
||||
|
||||
/** The text from `heading` up to the next `## ` heading; the heading must exist. */
|
||||
// Copied from tests/lib/review-prose-pins.test.mjs (section).
|
||||
function section(text, heading) {
|
||||
const start = text.indexOf(heading);
|
||||
assert.ok(start >= 0, `heading missing: ${heading}`);
|
||||
const next = text.indexOf('\n## ', start + heading.length);
|
||||
return text.slice(start, next === -1 ? undefined : next);
|
||||
}
|
||||
|
||||
/** Every sentence of `text` (flattened). */
|
||||
const sentences = (text) => flat(text).split(/(?<=\.)\s+(?=[A-Z`"*(])/);
|
||||
|
||||
const CONTRACTS = 'docs/HANDOVER-CONTRACTS.md';
|
||||
|
||||
// --- V12: review_version 1.2 is current ------------------------------------------
|
||||
|
||||
test('[V12] docs/HANDOVER-CONTRACTS.md names 1.2 as current in the table and the row, and no line names 1.1 as current', () => {
|
||||
const text = read(CONTRACTS);
|
||||
const tableRows = text.split('\n').filter((l) => l.startsWith('| `review.md` | `review_version`'));
|
||||
assert.equal(tableRows.length, 1, `the versioning table has one review.md row; found ${tableRows.length}`);
|
||||
assert.match(tableRows[0], /\|\s*`1\.2`\s*\|\s*$/, `the versioning table's review.md row says 1.2 is current: ${tableRows[0]}`);
|
||||
|
||||
const h6 = section(text, '## Handover 6');
|
||||
const fieldRows = h6.split('\n').filter((l) => l.startsWith('| `review_version` |'));
|
||||
assert.equal(fieldRows.length, 1, `Handover 6 has one review_version row; found ${fieldRows.length}`);
|
||||
assert.match(fieldRows[0], /"1\.2"`? \(current\)/, `the review_version row lists 1.2 as current: ${fieldRows[0]}`);
|
||||
|
||||
const stale = text.split('\n').filter((l) =>
|
||||
/1\.1"?`?\s*\(current\)/.test(l)
|
||||
|| /current is `?1\.1\b/.test(l)
|
||||
|| (l.startsWith('| `review.md` | `review_version`') && /`1\.1`/.test(l)));
|
||||
assert.deepEqual(stale, [], `no line of ${CONTRACTS} may name review_version 1.1 as current`);
|
||||
});
|
||||
|
||||
test('[V12] the Handover 6 Versioning paragraph has the 1.1 -> 1.2 sentence', () => {
|
||||
const h6 = section(read(CONTRACTS), '## Handover 6');
|
||||
const paras = h6.split('\n').filter((l) => l.startsWith('**Versioning:**'));
|
||||
assert.equal(paras.length, 1, `Handover 6 has one Versioning paragraph; found ${paras.length}`);
|
||||
const para = flat(paras[0]);
|
||||
assert.match(para, /current is `1\.2`/, `the Versioning paragraph says current is 1.2: ${para}`);
|
||||
const hits = sentences(para).filter((s) => /1\.1/.test(s) && /1\.2/.test(s));
|
||||
assert.ok(hits.length > 0, `no sentence of the Versioning paragraph names both 1.1 and 1.2: ${para}`);
|
||||
assert.ok(hits.some((s) => /still validates/.test(s)),
|
||||
`the 1.1 -> 1.2 sentence says a 1.1 review still validates: ${hits.join(' | ')}`);
|
||||
assert.ok(hits.some((s) => /never opens/.test(s) && /unanswered MAJOR/.test(s) && /lock/.test(s)),
|
||||
`the 1.1 -> 1.2 sentence says a 1.1 review never opens the lock with an unanswered MAJOR: ${hits.join(' | ')}`);
|
||||
});
|
||||
|
||||
test('[V12] MIGRATION.md has the review_version 1.2 section with its N-1 window', () => {
|
||||
const text = read('MIGRATION.md');
|
||||
const heads = text.split('\n').filter((l) => /^## review_version 1\.2\b/.test(l));
|
||||
assert.deepEqual(heads, ['## review_version 1.2 (from the next release)'],
|
||||
'MIGRATION.md has exactly one "## review_version 1.2 (from the next release)" section');
|
||||
const body = flat(section(text, heads[0]));
|
||||
assert.match(body, /\bN-1\b/, `the 1.2 section names the N-1 window: ${body}`);
|
||||
assert.match(body, /\b1\.1\b/, `the 1.2 section says what happens to a 1.1 review: ${body}`);
|
||||
});
|
||||
|
||||
test('[V12] every current review_version statement names 1.2', () => {
|
||||
const tpl = read('templates/trekreview-template.md');
|
||||
assert.match(tpl.split('\n')[2], /^review_version: "1\.2"$/, 'the template\'s frontmatter says review_version "1.2"');
|
||||
assert.ok(!tpl.includes('review_version: "1.1"'), 'the template no longer writes review_version "1.1"');
|
||||
|
||||
const coord = read('agents/review-coordinator.md');
|
||||
assert.ok(coord.includes('review_version: "1.2"'), 'the coordinator writes review_version "1.2"');
|
||||
assert.ok(!coord.includes('review_version: "1.1"'), 'the coordinator no longer writes review_version "1.1"');
|
||||
|
||||
const cmd = read('commands/trekreview.md');
|
||||
assert.match(cmd, /\*\*The review is a gate \(review_version 1\.2,/, '/trekreview says the review is a gate at review_version 1.2');
|
||||
assert.doesNotMatch(cmd, /\(review_version 1\.1,/, '/trekreview no longer says review_version 1.1 is current');
|
||||
|
||||
const claude = read('CLAUDE.md');
|
||||
assert.ok(claude.includes('**Review is a gate (review_version 1.2).**'), 'CLAUDE.md says the review gate is review_version 1.2');
|
||||
assert.ok(!claude.includes('(review_version 1.1)'), 'CLAUDE.md no longer names review_version 1.1');
|
||||
|
||||
const arch = read('docs/architecture.md');
|
||||
assert.ok(arch.includes('review_version 1.2'), 'docs/architecture.md names review_version 1.2');
|
||||
assert.ok(!arch.includes('review_version 1.1'), 'docs/architecture.md no longer names review_version 1.1');
|
||||
});
|
||||
|
|
@ -76,7 +76,7 @@ test('coordinator — writes ## Declined to Judge with the trim count', () => {
|
|||
// ---- K7: the verdict in the file, and the gate -------------------------------
|
||||
|
||||
test('coordinator — review_version 1.1 frontmatter carries verdict', () => {
|
||||
assert.ok(COORD.includes('review_version: "1.1"'));
|
||||
assert.ok(COORD.includes('review_version: "1.2"'));
|
||||
assert.match(COORD, /\nverdict: \{BLOCK \| WARN \| ALLOW\}\n/);
|
||||
});
|
||||
|
||||
|
|
|
|||
116
tests/lib/review-validator-1-2.test.mjs
Normal file
116
tests/lib/review-validator-1-2.test.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// tests/lib/review-validator-1-2.test.mjs
|
||||
// Fix round 4.5b2 part B, Step 2 (tag V12): review_version 1.2 validates, and
|
||||
// a 1.1 review still does (N-1 window). Every new 1.2 field is optional; a
|
||||
// malformed one present is a strict error (a soft warning in --soft) with one
|
||||
// of four codes. Each [V12] case fails at its base on an assertion: the
|
||||
// fixtures are new, so each is read only after `assert.ok(existsSync(p))`,
|
||||
// and the base validator knows no 1.2 field, so a malformed one passes there.
|
||||
// The kept behaviour (a 1.1 review validates, unknown keys are tolerated)
|
||||
// sits inside the tagged tests as "Control:" assertions.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { validateReviewContent } from '../../lib/validators/review-validator.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const FIXTURES = 'tests/fixtures/review-version';
|
||||
|
||||
/** A fixture's text; the fixture must exist (they are new in this step). */
|
||||
function fixture(name) {
|
||||
const p = join(ROOT, FIXTURES, name);
|
||||
assert.ok(existsSync(p), `missing fixture ${FIXTURES}/${name}`);
|
||||
return readFileSync(p, 'utf-8');
|
||||
}
|
||||
|
||||
const codes = (list) => list.map((i) => i.code);
|
||||
|
||||
// The ids the 1.2 fixture uses (written down here, independent of the validator).
|
||||
const FIXED_ID = 'e1fa7656c24e23e316f96169884da5f15059beb9';
|
||||
|
||||
/** `text` with `from` replaced once; `from` must be in it, or the mutation proves nothing. */
|
||||
function mutate(text, from, to) {
|
||||
assert.ok(text.includes(from), `mutation source missing from the fixture: ${JSON.stringify(from)}`);
|
||||
return text.replace(from, to);
|
||||
}
|
||||
|
||||
test('[V12] the 1.2 fixture validates strictly and the 1.1 fixture validates', () => {
|
||||
const v12 = fixture('review-1-2.md');
|
||||
assert.match(v12, /^review_version: "1\.2"$/m, 'the 1.2 fixture declares review_version "1.2"');
|
||||
const r = validateReviewContent(v12, { strict: true });
|
||||
assert.equal(r.valid, true, `the 1.2 fixture is valid in strict mode: ${JSON.stringify(r.errors)}`);
|
||||
assert.deepEqual(r.errors, []);
|
||||
|
||||
// The 1.2 fields of the fixture are read, not merely tolerated: the same file
|
||||
// with an out-of-range round is refused.
|
||||
const badRound = mutate(v12, '\nround: 1\n', '\nround: 4\n');
|
||||
const rb = validateReviewContent(badRound, { strict: true });
|
||||
assert.equal(rb.valid, false, 'round: 4 is outside 1-3 and must be refused at 1.2');
|
||||
assert.ok(codes(rb.errors).includes('REVIEW_ROUND_MALFORMED'), JSON.stringify(rb.errors));
|
||||
|
||||
// Control: a 1.1 review validates as before (N-1 window), strict and soft.
|
||||
const v11 = fixture('review-1-1.md');
|
||||
assert.match(v11, /^review_version: "1\.1"$/m, 'the 1.1 fixture declares review_version "1.1"');
|
||||
assert.match(v11, /^verdict: WARN$/m, 'the 1.1 fixture is a WARN review');
|
||||
const r11 = validateReviewContent(v11, { strict: true });
|
||||
assert.equal(r11.valid, true, `the 1.1 fixture is valid in strict mode: ${JSON.stringify(r11.errors)}`);
|
||||
assert.equal(validateReviewContent(v11, { strict: false }).valid, true, 'the 1.1 fixture is valid in soft mode');
|
||||
|
||||
// Control: at 1.1 the new keys are unknown keys, and unknown keys stay tolerated.
|
||||
const v11Extra = mutate(v11, '\nverdict: WARN\n', '\nverdict: WARN\nround: 9\nreview_policy_sha256: xyz\n');
|
||||
assert.equal(validateReviewContent(v11Extra, { strict: true }).valid, true,
|
||||
'a 1.1 review is not held to the 1.2 field shapes');
|
||||
});
|
||||
|
||||
test('[V12] the bad-answers and bad-policy fixtures fail with their codes', () => {
|
||||
const badAnswers = fixture('review-1-2-bad-answers.md');
|
||||
assert.match(badAnswers, /^\| [0-9a-f]{40} \| maybe \|/m, 'the bad-answers fixture has a row answered "maybe"');
|
||||
const ra = validateReviewContent(badAnswers, { strict: true });
|
||||
assert.equal(ra.valid, false, 'an ## Answers row with answer "maybe" is refused in strict mode');
|
||||
assert.ok(codes(ra.errors).includes('REVIEW_ANSWERS_MALFORMED'), JSON.stringify(ra.errors));
|
||||
const sa = validateReviewContent(badAnswers, { strict: false });
|
||||
assert.equal(sa.valid, true, 'soft mode reads it with a warning, not an error');
|
||||
assert.ok(codes(sa.warnings).includes('REVIEW_ANSWERS_MALFORMED'), JSON.stringify(sa.warnings));
|
||||
|
||||
const badPolicy = fixture('review-1-2-bad-policy.md');
|
||||
assert.match(badPolicy, /^review_policy_sha256: xyz$/m, 'the bad-policy fixture has review_policy_sha256: xyz');
|
||||
const rp = validateReviewContent(badPolicy, { strict: true });
|
||||
assert.equal(rp.valid, false, 'review_policy_sha256: xyz is refused in strict mode');
|
||||
assert.ok(codes(rp.errors).includes('REVIEW_POLICY_MALFORMED'), JSON.stringify(rp.errors));
|
||||
const sp = validateReviewContent(badPolicy, { strict: false });
|
||||
assert.equal(sp.valid, true, 'soft mode reads it with a warning, not an error');
|
||||
assert.ok(codes(sp.warnings).includes('REVIEW_POLICY_MALFORMED'), JSON.stringify(sp.warnings));
|
||||
});
|
||||
|
||||
test('[V12] a `fixed` answer without its R-step fails', () => {
|
||||
const v12 = fixture('review-1-2.md');
|
||||
assert.match(v12, new RegExp(`^\\| ${FIXED_ID} \\| fixed \\|`, 'm'), 'the 1.2 fixture answers one MAJOR "fixed"');
|
||||
// Remove the one ### Step R<n> that names the fixed finding.
|
||||
const step = v12.match(/^### Step R1\b[\s\S]*?(?=^## )/m);
|
||||
assert.ok(step && step[0].includes(FIXED_ID), 'the 1.2 fixture has a ### Step R1 naming the fixed finding');
|
||||
const noStep = v12.replace(step[0], '');
|
||||
const r = validateReviewContent(noStep, { strict: true });
|
||||
assert.equal(r.valid, false, 'a MAJOR answered "fixed" without a ### Step R<n> naming it is refused');
|
||||
const hit = r.errors.find((e) => e.code === 'REVIEW_ANSWERS_MALFORMED' && e.message.includes(FIXED_ID));
|
||||
assert.ok(hit, `the error names the fixed finding: ${JSON.stringify(r.errors)}`);
|
||||
});
|
||||
|
||||
test('[V12] a malformed round, pre-existing or ran field at 1.2 fails with its code', () => {
|
||||
const v12 = fixture('review-1-2.md');
|
||||
const cases = [
|
||||
['round_base not full hex', '\nround_base: null\n', '\nround_base: abc123\n', 'REVIEW_ROUND_MALFORMED'],
|
||||
['finalized not boolean', '\nfinalized: true\n', '\nfinalized: yes\n', 'REVIEW_ROUND_MALFORMED'],
|
||||
['pre_existing not boolean', '"pre_existing": true', '"pre_existing": "yes"', 'REVIEW_PRE_EXISTING_MALFORMED'],
|
||||
['pre_existing_counts not counts', '"pre_existing_counts": {"BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0}',
|
||||
'"pre_existing_counts": {"MAJOR": "one"}', 'REVIEW_PRE_EXISTING_MALFORMED'],
|
||||
['ran engine unknown', '"engines": ["code-review", "security-review"]', '"engines": ["code-review", "lint"]', 'REVIEW_ROUND_MALFORMED'],
|
||||
['review_policy unknown', '\nreview_policy: applied\n', '\nreview_policy: maybe\n', 'REVIEW_POLICY_MALFORMED'],
|
||||
];
|
||||
for (const [name, from, to, code] of cases) {
|
||||
const r = validateReviewContent(mutate(v12, from, to), { strict: true });
|
||||
assert.equal(r.valid, false, `${name}: refused in strict mode`);
|
||||
assert.ok(codes(r.errors).includes(code), `${name}: ${code} expected, got ${JSON.stringify(r.errors)}`);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue