* chore(#4654): add local/no-unconfined-path-join and drain it to zero Phase 4 of epic #4636 — the ratchet, and the phase that makes the epic hold. THE MEASUREMENT THAT RESHAPED THE PHASE. An AST census (the repo's own parser, not grep) found what the epic never enumerated: ADR-4650 named seven containment implementations; `src/` alone held roughly 24 more hand-rolled gates across ~13 files, several guarding a write or an `fs.rmSync`. Two verified by reading rather than pattern-matching — `research-store.cts` comments its own as "ensure the resolved file path stays inside the store dir" immediately before a write, and `capability-lifecycle.cts` gates `fs.rmSync` with one. So the epic's Done-when "one containment predicate, used at every site" was FALSE when Phase 3 reported it satisfied. It is true now: the rule is clean across src/, scripts/, gsd-core/bin/ and hooks/ with an EMPTY allowlist. WHY NOT THE RULE THE ISSUE PROPOSED. #4654 proposed flagging `path.join` whose first argument is a managed root and whose later arguments derive from argv. That is a taint analysis over 2046 call sites, in ESLint, without type information; "derives from argv" is not locally decidable. Any approximation either floods or is trivially evaded, and a rule that fires on hundreds of correct sites earns an allowlist of hundreds — the opposite of a ratchet. What is actually duplicated is the COMPARISON, not the join, and that has one recognizable shape. Arm 1 X.startsWith(Y + sep) the hand-rolled containment idiom Arm 2 a containment predicate called as a bare statement, answer discarded Arm 2 is the issue's "asserts the result was narrowed, not merely that a helper was called". Its example `validatePath(x, root).resolved` is already structurally impossible — Phase 3 un-exported `validatePath` — so the remaining expressible failure is ignoring the answer, which is the defect that recurred five times in this epic. The census found exactly one live instance (`milestone.cts:1643`); it now returns the proven `ContainedPath` so consumers stop re-deriving the path the comment above it was extracted to stop them re-deriving. The rule deliberately does NOT try to catch validate-one-path-use-another where the answer is used but a different variable flows onward. That needs flow analysis; the branded `ContainedPath` from Phase 3 is the defense there, and the two are complementary. PER-SITE FAMILY CHOICE, NOT A DEFAULT. Phase 3's lesson binds: collapsing a lexical site onto the realpath family broke four tests and was caught only by the matrix. Every migrated site was triaged individually. The six installer-migrations tree-walks and the six capability-lifecycle gates take the LEXICAL family because their operands are already realpath-resolved and they deliberately treat the final component as a link; boundary sites take realpath. TWO SITES WITH AN INVERTED CONTRACT, which a mechanical swap would have broken. `installer-migrations.cts:127` and `runtime-artifact-install-plan.cts:144` REJECT `target === root` by contract, while the canonical comparison ACCEPTS it. Swapped naively, a migration could `rmdir` the user's config root and a third-party descriptor could write at configHome itself. Both keep `=== root` as an explicit additional arm alongside the predicate call — the predicate decides containment, the call site keeps its own extra condition (ADR-4650 decision 6). ONE DUPLICATE DELETED OUTRIGHT: `planning-inspect.cts`'s `isWithinRoot` was byte-identical to `isContainedIn` and said so in its own docstring. `isContainedIn` is now exported for callers that have already resolved both operands and need only the comparison, with a doc note that a caller which has NOT resolved them must use a full predicate instead. THE MARKER, AND WHY IT IS NOT THE ALLOWLIST. Nine sites are justified holdouts and carry `// allow-handrolled-containment: <reason>` with a mandatory, reviewable reason. Two justifications: (a) not a containment decision — an ancestor-walk loop condition, sub-repo grouping, worktree identity matching, declared-path coverage; (b) it IS containment but the canonical predicate is unreachable — `capability-validator.cjs` is a committed pre-build `.cjs` and the compiled `security.cjs` is untracked build output, so requiring it would break a fresh clone. `scripts/lib/drift-scan.cjs` runs under `lint:ci` with the same exposure. The marker was renamed from `allow-lexical-prefix-match` mid-phase because that name asserted only (a) and would have stated something false at the (b) sites. A marker suppresses BEFORE the violation counter increments, so a file whose every occurrence is marked still reports `staleAllowlistEntry` — otherwise a drained entry lingers and silently re-permits the site later. DEMONSTRATED RED, per #4654: a hand-rolled copy reintroduced into a real `src/` file made `npm run lint` fail with the rule's full guidance message; removing it returned the tree to clean. Both halves recorded — red alone proves nothing, since a rule red for an unrelated reason looks identical. DISCLOSED: `defaultRequireFromInstallRoot` (gsd-tools.cjs) previously carried two distinct rejection messages and two manual realpath calls; routing it through `tryWithinRoot` collapses them to one message, and a missing module now surfaces as MODULE_NOT_FOUND rather than ENOENT. No test asserts either message. The security property is preserved and slightly strengthened — the candidate is realpathed and containment re-checked, and the dangling-symlink oracle closure comes along with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#4654): record the containment ratchet in CONTEXT.md and the security model Both entries previously described the seam without the thing that keeps it a seam. They now state what the rule bans, and — more usefully for whoever reads this next — what it deliberately does NOT attempt: deciding per path.join call whether an argument came from user input. That question is not locally decidable, and an approximation across ~2000 join sites would earn an exemption list of hundreds, which is the opposite of a ratchet. Also records the marker's two legitimate justifications and that its reason is mandatory, so the escape stays reviewable rather than becoming a mute button. Glossary gate 270 refs exit 0; install-tree goldens and CONTEXT-INDEX.json regenerated and confirmed byte-identical rather than assumed — which also confirms eslint-rules/ is not a shipped path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4654): close review findings and the two matrix failures MATRIX FAILURE 1 — a collapsed message broke a negative-proof test, and my evidence for collapsing it was wrong. I searched tests/ for the literal string "resolves outside its install root", found nothing, and reported that no test asserted it. The test matches a REGEX SUBSTRING, /outside its install root/, so the literal search missed it. What broke was "NEGATIVE PROOF: a symlinked module pointing OUTSIDE the install root is not loaded" — the test guarding the exact property I claimed was preserved. defaultRequireFromInstallRoot now does both checks again with both messages byte-identical, each routed through the canonical predicate, which is better than the original since that hand-rolled both comparisons. MATRIX FAILURE 2 — shipped migrations are checksum-locked, and a marker cannot serve there. migrationChecksum hashes plan.toString(), which INCLUDES comments, so a suppression marker inside a plan body drifts the baseline exactly as an edit does. Measured: with markers in place, two of the four still differed from their committed checksums. The four shipped bodies are now byte-identical to next, and the rule's config excludes those four paths BY NAME rather than by a directory wildcard, so a NEW migration is still covered. Six containment comparisons stay un-ratcheted there; that gap is recorded in the rule's Known gaps, in CONTEXT.md and in the security model rather than left implicit. Justification (c) is removed from the marker's documented reasons, because a marker was proven unable to express it. ADVERSARIAL REVIEW — the sharpest finding was that the rule banned the CORRECT shape while permitting the incorrect one: startsWith(root) with no separator is the genuinely unsafe form, since it accepts a sibling such as root-evil, and my own test blessed it as valid. Flagging every bare startsWith would swamp the rule, so that stays a STATED gap rather than a silent one. Closed for real: the template-literal spelling, which the census never saw because it only inspected plus-concatenation — that surfaced TWELVE more sites, now triaged and migrated. A separator reached through a const alias is now resolved via scope analysis. And isContainedIn, exported in Phase 3, was missing from the discarded-result set, so a bare no-op call went unflagged on the one function the epic funnels through. SECURITY REVIEW — the marker could over-suppress two ways: a block comment worked identically to a line comment, and one marker silently covered every violation sharing its line. It now requires a Line comment positioned after the flagged node ends, so it anchors to the node it trails. Four sites had dropped an unreachable-but-deliberate equality rejection against the root; each is restored as the call site's own arm. eslint.config.mjs still documented the OLD marker token, which my rename missed — it would have sent the next author in circles. A FALSE GREEN, recorded because it nearly stuck: lint:ci reported exit 0 from a stale eslint cache while twelve real violations existed. Every lint check here now clears the cache first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4654): anchor a suppression marker to the violation it actually trails The matrix caught this; my own test caught it, on its first execution. The case "two violations on one line: trailing marker suppresses only the one it trails" expected 1 error and got 0 — both were suppressed. ROOT CAUSE: the anchoring accepted any Line comment on the node's line whose range started at or after the node's end. A trailing marker at the END of a line sits after EVERY node on that line, so that condition held for all of them. "After the node" does not identify WHICH node the marker trails. The fix reads as correct and is not. FIX: deferred reporting. Violations accumulate during traversal instead of being reported immediately; at Program:exit each marker claims exactly ONE pending violation — the one on its line whose end is nearest before the marker begins — and every unclaimed violation is then counted and reported. One marker, one suppression. An earlier violation sharing the line is still reported, which is the property the security review asked for and the previous attempt only appeared to deliver. The counter now increments at flush time rather than during traversal, so a suppressed occurrence still does not keep an allowlist entry alive. AND A TOOL THAT SHOULD HAVE EXISTED BEFORE THE FIRST MATRIX RUN. `node --test` is hard-blocked here, so this rule's test file could only ever be executed on the remote matrix — which is why a broken anchoring shipped into a run. ESLint's programmatic Linter API is not a test runner, and exercising the rule through it verifies every case locally in seconds. All 24 now pass locally, including the two-on-one-line case that failed remotely. That loop should have been built before the rule was first sent to the matrix rather than after it failed twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4654): backfill PR 4674 into the changeset and complete 70-docs.json The phase gate requires enablementSequence and the Diataxis quadrants; 70-docs now carries both, with the how-to quadrant skipped for a stated reason rather than an empty field. The audience for this deliverable is a contributor who trips the rule, and the task-oriented guidance reaches them in the ESLint message itself — which names the correct predicate, says how to choose between the realpath and lexical families, cites the Phase 3 regression caused by choosing wrong, and gives the marker syntax. A docs/how-to page would be a second, driftable copy read by nobody at the moment of failure. enablementSequence is recorded as what it actually is: a VERIFICATION sequence, not an enablement one. The rule is never off, so there is no off-to-on transition to describe. scripts/lint-docs-required.cjs now passes (ok_docs_updated) — it could not evaluate against the mandated pr:0 placeholder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
449 lines
21 KiB
TypeScript
449 lines
21 KiB
TypeScript
/**
|
|
* Reviewer Step Dispatch (#4209 Phase 1 Plan 2, ADR-2782 seam).
|
|
*
|
|
* ONE interpreter for "a step declares `supportsReviewerLanes: true`" — see
|
|
* `gsd-core/references/loop-hook-dispatch.md` for the canonical explanation of the trait and how
|
|
* `review-lane dispatch-step` re-derives it. This module trusts `trait` exactly as given: it is
|
|
* the CALLER's job to have derived it correctly. Every direct or lifecycle caller routes through
|
|
* `dispatchReviewerLanes` so selection/plan/invoke logic is owned once, not re-derived per
|
|
* feature. This module owns NONE of those primitives — it wires `resolveReviewerSelection`
|
|
* (selection) and `resolveLanePlan` (planning), the same building blocks
|
|
* `gsd-core/bin/gsd-tools.cjs`'s `review-lane plan` subcommand uses. Invocation (`runLane`) needs
|
|
* OS-aware spawn/probe plumbing this module does not own, so `deps.invoke` is the one required,
|
|
* caller-supplied seam (wired for real in `gsd-core/bin/gsd-tools.cjs`'s `review-lane
|
|
* dispatch-step` route).
|
|
*
|
|
* Fail-closed contract:
|
|
* - Trait not exactly `true`, or nothing selected → inert. Zero plan/invoke calls.
|
|
* - Missing/unsafe request-level input (paths escaping `repoRoot`, absent depth/base SHA) stops
|
|
* the WHOLE dispatch before any lane is planned or invoked.
|
|
* - An explicitly requested lane the selector could not resolve does not silently narrow the
|
|
* result to only what worked: lanes that DID resolve still run and their results are kept,
|
|
* but the aggregate `ok` is `false` so no caller mistakes a partial run for a clean one.
|
|
* - Once a lane is planned, a per-lane plan/budget/invoke failure never displaces or cancels a
|
|
* sibling lane already run.
|
|
* - A lane whose `promptChannel` is `'none'` (it reviews the working tree on its own terms, fed
|
|
* nothing — e.g. `coderabbit`) cannot receive the bounded prompt below and is rejected before
|
|
* plan/invoke, same as an unresolved slug.
|
|
*
|
|
* The bounded source-review prompt built here is METADATA ONLY — repository root, canonical
|
|
* file paths, review depth, base SHA, and four fixed prohibitions. It never embeds file
|
|
* contents. If the assembled prompt exceeds a lane's resolved budget, that lane's dispatch
|
|
* hard-fails before `invoke` runs for it — no silent truncation of the file list.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { estimateTokens } from './prompt-budget.cjs';
|
|
import { tryWithinRootLexical } from './security.cjs';
|
|
import type { LanePlan, ResolveResult } from './review-lane-invocation.cjs';
|
|
import { resolveLaneBudget, artifactPaths } from './review-lane-invocation.cjs';
|
|
import type { ReviewerLane } from './review-lane-descriptor.cjs';
|
|
import type {
|
|
ReviewerSelectionInput,
|
|
ReviewerSelectionResult,
|
|
} from './review-reviewer-selection.cjs';
|
|
import { resolveReviewerSelection } from './review-reviewer-selection.cjs';
|
|
|
|
/** Closed set of request-level (not per-lane) halt reasons. Mirrors `LANE_UNAVAILABLE`'s shape. */
|
|
export const DISPATCH_REASON = Object.freeze({
|
|
TRAIT_NOT_ENABLED: 'trait_not_enabled',
|
|
NO_LANES_SELECTED: 'no_lanes_selected',
|
|
SELECTION_FAILED: 'selection_failed',
|
|
INVALID_PATHS: 'invalid_paths',
|
|
PATH_ESCAPES_REPO_ROOT: 'path_escapes_repo_root',
|
|
MISSING_PROVENANCE: 'missing_provenance',
|
|
INVALID_PROVENANCE: 'invalid_provenance',
|
|
PROMPT_WRITE_FAILED: 'prompt_write_failed',
|
|
} as const);
|
|
export type DispatchReason = (typeof DISPATCH_REASON)[keyof typeof DISPATCH_REASON];
|
|
|
|
/** Fixed, non-negotiable prompt constraints (SAFE-03..SAFE-06). Order is the display order. */
|
|
export const SOURCE_REVIEW_PROHIBITIONS: readonly string[] = Object.freeze([
|
|
'Do not modify any source file.',
|
|
'Do not run tests.',
|
|
'Do not start background processes.',
|
|
'Do not poll or wait — return findings from a single read-only pass.',
|
|
]);
|
|
|
|
// #4209 RQ-04: a control character (newline, CR, NUL, ...) in ANY string this module embeds
|
|
// into the external prompt (`buildSourceReviewPrompt`) lets it inject a fabricated section —
|
|
// not just via `paths` (agy-F1's original finding), since `depth`, `baseSha`, and `repoRoot` land
|
|
// in that same markdown. Every embedded string is checked against this ONE shared boundary.
|
|
const CONTROL_CHAR = /[\x00-\x1f\x7f\u2028\u2029]/;
|
|
|
|
export interface ReviewerStepDispatchInput {
|
|
/**
|
|
* Value of the step's `supportsReviewerLanes` field, read verbatim from `activeHooks`.
|
|
* Anything other than the literal boolean `true` (absent, `false`, or a malformed non-boolean
|
|
* that slipped past `capability-validator.cjs`) makes this dispatch a hard no-op.
|
|
*/
|
|
trait: unknown;
|
|
/** Passed through verbatim to `resolveReviewerSelection` — this module invents no selection. */
|
|
selection: ReviewerSelectionInput;
|
|
/** Absolute repository root. */
|
|
repoRoot: string;
|
|
/** Canonical, already-resolved file paths under review. Never file contents. */
|
|
paths: readonly string[];
|
|
/** Review depth label, carried into the bounded prompt as provenance. */
|
|
depth: string;
|
|
/** Base SHA the review is anchored to, carried into the bounded prompt as provenance. */
|
|
baseSha: string;
|
|
/** Run-scoped directory; shared prompt file lands at `${runDir}/gsd-review-prompt.md`. */
|
|
runDir: string;
|
|
}
|
|
|
|
export interface PlanContext {
|
|
configGet: (key: string) => unknown;
|
|
runDir: string;
|
|
repoRoot: string;
|
|
}
|
|
|
|
export interface InvokeOutcome {
|
|
ok: boolean;
|
|
reason?: string;
|
|
detail?: string;
|
|
reviewPath?: string;
|
|
errPath?: string;
|
|
}
|
|
|
|
export interface ReviewerStepDispatchDeps {
|
|
/** Defaults to the real `resolveReviewerSelection`. Overridden by tests with a spy. */
|
|
resolveSelection?: (input: ReviewerSelectionInput) => ReviewerSelectionResult;
|
|
/**
|
|
* REQUIRED (#4209 R4). The one production caller always injects an overlay-merged lookup
|
|
* (`gsd-tools.cjs`'s `laneBySlug`); a first-party-only default would silently diverge from
|
|
* what actually ships, so there is no safe default to fall back to.
|
|
*/
|
|
getLane: (slug: string) => ReviewerLane | undefined;
|
|
/**
|
|
* REQUIRED (#4209 R3). A `configGet` that always returns `undefined` silently disables
|
|
* `resolveLaneBudget`'s overflow guard (a missing config key and an explicitly-unbounded
|
|
* config key are indistinguishable to it) — a safety-relevant gate must not fail open on a
|
|
* missing dependency, so this has no default.
|
|
*/
|
|
configGet: (key: string) => unknown;
|
|
/**
|
|
* REQUIRED (#4209 R4). The one production caller always injects a per-host effort-aware plan
|
|
* function; a simpler default that skips effort resolution would silently strip that behavior
|
|
* if `plan` were ever omitted, so there is no safe default to fall back to.
|
|
*/
|
|
plan: (lane: ReviewerLane, ctx: PlanContext) => ResolveResult;
|
|
/**
|
|
* REQUIRED. `runLane` needs OS-aware spawn/probe plumbing (`RunnerDeps`) this module does not
|
|
* own — the caller (`review-lane dispatch-step`) wires the real one; tests inject a spy.
|
|
*/
|
|
invoke: (lane: ReviewerLane, plan: LanePlan) => Promise<InvokeOutcome> | InvokeOutcome;
|
|
/** Defaults to `node:fs`'s `writeFileSync`. */
|
|
writePromptFile?: (filePath: string, content: string) => void;
|
|
}
|
|
|
|
export interface ReviewerLaneDispatchResult {
|
|
slug: string;
|
|
ok: boolean;
|
|
reason?: string;
|
|
detail?: string;
|
|
reviewPath?: string;
|
|
errPath?: string;
|
|
}
|
|
|
|
export interface ReviewerStepDispatchResult {
|
|
/** True iff at least one lane was actually planned. False means the dispatch was inert. */
|
|
dispatched: boolean;
|
|
/** Aggregate success: `dispatched` lanes all `ok`. */
|
|
ok: boolean;
|
|
reason?: DispatchReason;
|
|
selection?: ReviewerSelectionResult;
|
|
results: ReviewerLaneDispatchResult[];
|
|
}
|
|
|
|
function defaultWritePromptFile(filePath: string, content: string): void {
|
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
}
|
|
|
|
/**
|
|
* Validate that every path is a non-empty string resolving INSIDE `repoRoot` — blocks `..`
|
|
* traversal and absolute paths pointing elsewhere before any lane sees them.
|
|
*/
|
|
function validatePaths(
|
|
repoRoot: string,
|
|
paths: readonly string[],
|
|
): { ok: true } | { ok: false; reason: DispatchReason } {
|
|
if (!Array.isArray(paths) || paths.length === 0) {
|
|
return { ok: false, reason: DISPATCH_REASON.INVALID_PATHS };
|
|
}
|
|
const root = path.resolve(String(repoRoot ?? ''));
|
|
// repoRoot itself may be a symlink (e.g. a `/tmp`-based worktree on macOS, where `/tmp` is
|
|
// itself a symlink to `/private/tmp`) — realpath it once so the per-path comparison below
|
|
// compares like with like, not a resolved child path against an unresolved root.
|
|
let realRoot: string;
|
|
try {
|
|
realRoot = fs.realpathSync(root);
|
|
} catch {
|
|
realRoot = root;
|
|
}
|
|
// #4209 agy-F1: a control character (newline, CR, NUL, ...) in a path lets a maliciously
|
|
// named repo file inject a fabricated section into the markdown prompt built from `paths`
|
|
// below (buildSourceReviewPrompt) — reject it here, at the shared trust boundary, rather than
|
|
// relying on the incidental quoting `git diff --name-only` happens to apply upstream.
|
|
for (const p of paths) {
|
|
if (typeof p !== 'string' || p.length === 0 || CONTROL_CHAR.test(p)) {
|
|
return { ok: false, reason: DISPATCH_REASON.INVALID_PATHS };
|
|
}
|
|
// ADR-4650 decision 6: lexical family — this is the first of the two
|
|
// deliberate halves (#4209 WR-05); the ENOENT-tolerant realpath half
|
|
// below cannot be folded into a single `tryWithinRoot` call (its
|
|
// ancestor-walk would accept a deleted path via the nearest existing
|
|
// ancestor, not the explicit `continue` this code requires).
|
|
const resolved = path.resolve(root, p);
|
|
if (tryWithinRootLexical(p, root) === null) {
|
|
return { ok: false, reason: DISPATCH_REASON.PATH_ESCAPES_REPO_ROOT };
|
|
}
|
|
// #4209 WR-05: `path.resolve` is lexical only — a symlink whose OWN path sits inside
|
|
// repoRoot can still point outside it, passing the check above while listing an
|
|
// out-of-repo file for the external lane to read. `fs.realpathSync` follows the link;
|
|
// ENOENT is expected and benign here (a `git diff --name-only` path can legitimately name
|
|
// a file already deleted in a stale worktree) and is not itself an escape.
|
|
let real: string;
|
|
try {
|
|
real = fs.realpathSync(resolved);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (tryWithinRootLexical(real, realRoot) === null) {
|
|
return { ok: false, reason: DISPATCH_REASON.PATH_ESCAPES_REPO_ROOT };
|
|
}
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
// `resolveLaneBudget` (review-lane-invocation.cjs) resolves the number; `null` and a resolved
|
|
// `0` both mean unbounded (#2797) — the caller's overflow check must test both `!== null` and
|
|
// `!== 0`. See the call site below.
|
|
|
|
/**
|
|
* One-line depth definition for an external reviewer lane, condensed from `<depth_levels>` in
|
|
* `agents/gsd-code-reviewer.md` (#4209 review: a bare `quick`/`standard`/`deep` label means
|
|
* nothing to a third-party CLI that never sees that agent's system prompt — unlike the internal
|
|
* reviewer, whose own persona fully defines these three terms). Every category named here must
|
|
* stay a strict subset of what `<depth_levels>` actually does — `tests/reviewer-step-dispatch
|
|
* .test.cjs`'s "depthMeaning tracks depth_levels" tests assert each case against the real agent
|
|
* file, not just against this function, so the two cannot silently drift again. An unrecognised
|
|
* depth normalizes to `standard`'s text, matching `agents/gsd-code-reviewer.md`'s own "if depth
|
|
* is not one of quick/standard/deep, default to standard" rule — the raw label is not repeated
|
|
* here since `buildSourceReviewPrompt` already states it once, verbatim, earlier in the prompt.
|
|
*/
|
|
function depthMeaning(depth: string): string {
|
|
switch (depth) {
|
|
case 'quick':
|
|
return 'pattern-scan without reading full file contents: hardcoded secrets, dangerous functions, debug artifacts, empty catch blocks, commented-out code';
|
|
case 'standard':
|
|
return 'read each changed file in context for bugs, security, and quality problems; cross-reference imports and exports';
|
|
case 'deep':
|
|
return 'standard, plus cross-file analysis: trace call chains, check type consistency at API boundaries, verify error propagation, check state mutation consistency, detect circular dependencies';
|
|
default:
|
|
return depthMeaning('standard');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the bounded source-review prompt. Metadata only — repoRoot, paths, depth, base SHA, and
|
|
* the four fixed prohibitions. NEVER embeds file contents.
|
|
*/
|
|
export function buildSourceReviewPrompt(input: {
|
|
repoRoot: string;
|
|
paths: readonly string[];
|
|
depth: string;
|
|
baseSha: string;
|
|
}): string {
|
|
// Base SHA is identical for every file and already stated once above — repeating it per line
|
|
// (as an earlier version of this prompt did) wastes real tokens at O(files), for zero
|
|
// information gain, on every dispatched lane.
|
|
const fileLines = input.paths.map((p) => `- ${p}`).join('\n');
|
|
const ruleLines = SOURCE_REVIEW_PROHIBITIONS.map((r, i) => `${i + 1}. ${r}`).join('\n');
|
|
return [
|
|
'## Source Review Request',
|
|
'',
|
|
`Repository root: ${input.repoRoot}`,
|
|
`Review depth: ${input.depth}`,
|
|
`Base SHA: ${input.baseSha}`,
|
|
'',
|
|
'Review the changes introduced in each file below relative to the base SHA above, at the',
|
|
`requested depth (${depthMeaning(input.depth)}). Report every bug, security issue, and`,
|
|
'code-quality problem you find. For every claim you make, cite the exact file path and line',
|
|
'number(s) it applies to — a claim with no file:line citation cannot be independently',
|
|
're-verified and will be discarded by the consolidating reviewer. Performance issues',
|
|
'(O(n²), memory leaks) are out of scope unless also correctness issues (e.g. an infinite',
|
|
'loop) — do not flag them otherwise.',
|
|
'',
|
|
'### Files in scope',
|
|
fileLines,
|
|
'',
|
|
'### Rules',
|
|
ruleLines,
|
|
].join('\n');
|
|
}
|
|
|
|
/**
|
|
* Dispatch every selected reviewer lane for one opted-in step. See module docstring for scope.
|
|
*/
|
|
export async function dispatchReviewerLanes(
|
|
input: ReviewerStepDispatchInput,
|
|
deps: ReviewerStepDispatchDeps,
|
|
): Promise<ReviewerStepDispatchResult> {
|
|
if (input.trait !== true) {
|
|
return { dispatched: false, ok: true, reason: DISPATCH_REASON.TRAIT_NOT_ENABLED, results: [] };
|
|
}
|
|
|
|
const resolveSelection = deps.resolveSelection ?? resolveReviewerSelection;
|
|
const selection = resolveSelection(input.selection);
|
|
|
|
if (selection.selected.length === 0) {
|
|
// Distinguish "explicitly requested but every candidate was unavailable" (a real failure —
|
|
// `errors` is non-empty) from "nothing was ever requested" (a clean, inert no-op).
|
|
const reason = selection.errors.length > 0
|
|
? DISPATCH_REASON.SELECTION_FAILED
|
|
: DISPATCH_REASON.NO_LANES_SELECTED;
|
|
return { dispatched: false, ok: selection.errors.length === 0, reason, selection, results: [] };
|
|
}
|
|
|
|
const pathCheck = validatePaths(input.repoRoot, input.paths);
|
|
if (!pathCheck.ok) {
|
|
return { dispatched: false, ok: false, reason: pathCheck.reason, selection, results: [] };
|
|
}
|
|
// #4209 RQ-04: depth/baseSha/repoRoot/runDir land in the SAME markdown prompt `paths` does
|
|
// (buildSourceReviewPrompt, `dispatchReviewerLanes`'s `runDir`-derived promptPath write) — a
|
|
// control character in any of them is the identical injection vector agy-F1 found in `paths`,
|
|
// so this trust boundary must reject it here too, not just for the file list.
|
|
if (typeof input.depth !== 'string' || input.depth.length === 0
|
|
|| typeof input.baseSha !== 'string' || input.baseSha.length === 0
|
|
|| typeof input.repoRoot !== 'string' || input.repoRoot.length === 0
|
|
|| typeof input.runDir !== 'string' || input.runDir.length === 0) {
|
|
return { dispatched: false, ok: false, reason: DISPATCH_REASON.MISSING_PROVENANCE, selection, results: [] };
|
|
}
|
|
// #4209 WR-04: a present-but-malicious field (control character) is a different failure mode
|
|
// than an absent one — MISSING_PROVENANCE above means "the caller never supplied this"; this
|
|
// branch means "the caller supplied something and it's an injection attempt," which a caller
|
|
// handling the two reasons differently (e.g. surfacing one as a config problem, the other as
|
|
// a security event) must be able to tell apart.
|
|
if (CONTROL_CHAR.test(input.depth) || CONTROL_CHAR.test(input.baseSha)
|
|
|| CONTROL_CHAR.test(input.repoRoot) || CONTROL_CHAR.test(input.runDir)) {
|
|
return { dispatched: false, ok: false, reason: DISPATCH_REASON.INVALID_PROVENANCE, selection, results: [] };
|
|
}
|
|
// #4209 WR-03 (considered, declined): gating `depth` to code-review's quick/standard/deep
|
|
// enum here would reject the deliberately capability-neutral case this function supports —
|
|
// see "a second, unrelated synthetic step context dispatches through the same function
|
|
// identically" below, which passes a wholly different depth vocabulary on purpose to prove
|
|
// this dispatcher has no code-review-specific special-casing. `depthMeaning()`'s `standard`
|
|
// fallback for an off-enum value is accepted, not a bug, for that reason.
|
|
|
|
const { configGet, getLane, plan } = deps;
|
|
const writePromptFile = deps.writePromptFile ?? defaultWritePromptFile;
|
|
|
|
const prompt = buildSourceReviewPrompt(input);
|
|
const estimatedTokens = estimateTokens(prompt);
|
|
// Written once, before any lane's plan() runs: `promptPath` is derived from `runDir` alone
|
|
// (see `artifactPaths`), constant across every lane in this dispatch by construction — there
|
|
// is no per-lane variance to defend against, so writing it per-lane (as an earlier version of
|
|
// this function did) was pure redundancy, not a real safeguard.
|
|
// A hoisted, whole-dispatch write (see the doc comment above) that throws must not escape as
|
|
// an uncaught exception — no lane can succeed anyway if the shared prompt file was never
|
|
// written, so this is a dispatch-level halt like `validatePaths`/`MISSING_PROVENANCE` above,
|
|
// not a per-lane failure.
|
|
try {
|
|
writePromptFile(artifactPaths(input.runDir, '').promptPath, prompt);
|
|
} catch {
|
|
return { dispatched: false, ok: false, reason: DISPATCH_REASON.PROMPT_WRITE_FAILED, selection, results: [] };
|
|
}
|
|
|
|
const results: ReviewerLaneDispatchResult[] = [];
|
|
// Never narrow the requested set: an explicit reviewer the selector could not resolve is
|
|
// already surfaced in `selection.errors` — reflect that in the aggregate `ok` even though
|
|
// lanes that DID resolve still run below and keep their own results.
|
|
let anyFailed = selection.errors.length > 0;
|
|
// Tracks whether any lane actually reached plan() — `dispatched` must stay false when every
|
|
// selected slug turned out to be unresolvable, even though a `results` entry was still pushed.
|
|
let planned = false;
|
|
|
|
for (const slug of selection.selected) {
|
|
const lane = getLane(slug);
|
|
if (!lane) {
|
|
results.push({ slug, ok: false, reason: 'malformed_lane', detail: 'no such declared lane' });
|
|
anyFailed = true;
|
|
continue;
|
|
}
|
|
|
|
// #4209 review: a `promptChannel: 'none'` lane (coderabbit) is fed nothing and reviews
|
|
// whatever it independently sees fit (its own working-tree diff, review.md:367) rather than
|
|
// the bounded `paths`/`depth`/`baseSha` scope `buildSourceReviewPrompt` promises — silently
|
|
// dispatching it here would violate this interpreter's own scoped, metadata-only review
|
|
// contract. Reject before plan()/invoke() rather than let the mismatch surface as an
|
|
// unexplained out-of-scope review.
|
|
if (lane.transport === 'spawn' && lane.invoke.promptChannel === 'none') {
|
|
results.push({
|
|
slug,
|
|
ok: false,
|
|
reason: 'prompt_channel_unsupported',
|
|
detail: `lane '${slug}' declares promptChannel 'none' and cannot receive a scoped source-review prompt`,
|
|
});
|
|
anyFailed = true;
|
|
continue;
|
|
}
|
|
|
|
// A single throwing plan()/invoke() must not take down every sibling lane already collected
|
|
// in `results` — same rationale as gsd-tools.cjs's resolveLanePlan guard
|
|
// (#2494/#2605/#1698/#1936/#2073/#2176/#2589/#2794): belt and braces on purpose.
|
|
let planOutcome: ResolveResult;
|
|
try {
|
|
planOutcome = plan(lane, { configGet, runDir: input.runDir, repoRoot: input.repoRoot });
|
|
} catch (e) {
|
|
results.push({ slug, ok: false, reason: 'malformed_lane', detail: e instanceof Error ? e.message : String(e) });
|
|
anyFailed = true;
|
|
continue;
|
|
}
|
|
if (!planOutcome.ok) {
|
|
results.push({ slug, ok: false, reason: planOutcome.reason, detail: planOutcome.detail });
|
|
anyFailed = true;
|
|
continue;
|
|
}
|
|
const budget = resolveLaneBudget(lane, configGet);
|
|
if (budget !== null && budget !== 0 && estimatedTokens > budget) {
|
|
results.push({
|
|
slug,
|
|
ok: false,
|
|
reason: 'budget_exceeded',
|
|
detail: `estimated ${estimatedTokens} tokens exceeds resolved budget ${budget} for lane '${slug}'`,
|
|
});
|
|
anyFailed = true;
|
|
continue;
|
|
}
|
|
planned = true;
|
|
|
|
let invokeOutcome: InvokeOutcome;
|
|
try {
|
|
invokeOutcome = await deps.invoke(lane, planOutcome.plan);
|
|
} catch (e) {
|
|
results.push({ slug, ok: false, reason: 'invoke_failed', detail: e instanceof Error ? e.message : String(e) });
|
|
anyFailed = true;
|
|
continue;
|
|
}
|
|
if (!invokeOutcome.ok) anyFailed = true;
|
|
results.push({
|
|
slug,
|
|
ok: invokeOutcome.ok,
|
|
reason: invokeOutcome.reason,
|
|
detail: invokeOutcome.detail,
|
|
reviewPath: invokeOutcome.reviewPath,
|
|
errPath: invokeOutcome.errPath,
|
|
});
|
|
}
|
|
|
|
return {
|
|
dispatched: planned,
|
|
ok: !anyFailed,
|
|
selection,
|
|
results,
|
|
};
|
|
}
|