Skip to content

review: add an opt-in documentation reviewer - #299

Merged
jwbron merged 6 commits into
mainfrom
jwies/documentation-lens
Jul 30, 2026
Merged

review: add an opt-in documentation reviewer#299
jwbron merged 6 commits into
mainfrom
jwies/documentation-lens

Conversation

@jwbron

@jwbron jwbron commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Adds an opt-in documentation reviewer: advisory-only, whole-change, checking the comments and prose docs a diff adds or changes against a documentation policy. Off everywhere until a repo adds enable documentation to its ROUTING file. First of three PRs from the #ai-guild thread on LLM-generated comments; the premise is that comment quality is an enforcement problem, not a prompt problem.

Flags: a comment that restates the code; one that narrates the change ("updated to…", "previously we…"); one this diff falsified; commented-out code; and the other direction, a specific unexplained constant, workaround, or ordering constraint the change introduces.

Does not flag: density preferences, anything CI owns, generated files, a TODO with a ticket, or comments the diff did not touch.

Design notes

The policy lives inline in the agent definition, like the specialist lenses' Review rules. It has to be written down somewhere, because every finder must quote the rule and claim-validator drops what it cannot verify against real rule text; an unwritten policy just yields {"findings": []}. A fifth required .github/aw/review/*.md consumer import was the alternative, rejected because the baseline is universal and a missing runtime import fails the whole review at run time, so every repo would have to write the file before its next PR could be reviewed. Repo-specific calibration rides the per-directory REVIEW.md contracts that already exist, which also answers Jeff's "backend and frontend may want different outcomes": they can, per directory.

It never reasons about who wrote the text. It cannot tell human from model, must not guess, and the policy is the same either way. Attribution is unreliable anyway (we strip Claude trailers by convention, so absence proves nothing), and a rule binding only bot-authored PRs is bypassed by deleting a trailer. If we want the author axis, it belongs in the counters as a measurement, not in the rubric.

The label is load-bearing. Findings render as suggestion (non-blocking, documentation), code-assigned from the lens like the , best-practice variants. The stacked autofix PR depends on it: worklist.ts selects threads by parsing the label off each posted comment, so nothing else about a finding survives to reach it, and without the variant a documentation-scoped autofix would widen to every nit on the PR. There is deliberately no blocking variant; minting one would enlarge BLOCKING_LABELS, the set autofix: blocking acts on. Blast radius is small because the taxonomy is centralized (thumbs-sweep-github.ts spreads both sets, verdict.ts counts blocking only, the eval runner goes through labelForFinding). The README notes the consumer ordering: autofix reads labels minted by whichever reviewer version a repo has installed.

Anchoring. The best finding here is "the change falsified this comment", and that comment usually sits on an unchanged line, because what changed is the code below it; the provenance gate would drop it. So the prompt says to anchor on the changed code line and name the comment in the prose. The golden case pins it: the stale-comment finding anchors on EXPIRY_DAYS = 90, not on the comment above.

Supporting changes

  • claim-validator gains a documentation-claim rule. A documentation claim is neither a code claim nor a skill claim, so it was being validated as a runtime-behaviour claim. The new bullet says what to verify and names the characteristic false positive: mistaking a real constraint for a restatement.
  • The live eval producer now dispatches a case's enabled reviewers. produceLive's roster was DEFAULT_FINDERS + routing.lensesToSpawn, so no opt-in whole-change reviewer has ever had a live arm and none could earn its enable line the way the README requires. routerConfig.enabledReviewers now adds them in canonical ENABLEABLE_REVIEWERS order, an unrecognised name throws (a typo would otherwise produce a green, expensive run measuring nothing), and labelLens gains all six. Existing cases set nothing, so the corpus is unchanged.
  • Reconciliation with scripted dispatch (third commit, from the rebase onto review: remove task mode; scripted dispatch is the only mode #289). Main moved the shed ranking and label taxonomy out of review.md prose into code, so those hunks moved with them. One gap the textual merge could not see: the dispatcher code-assigns each label-shape reviewer's lens, hardcoded correctness for everything but skill-auditor, so a documentation finding reached the render stage with the wrong lens. Replaced with a named LABEL_SHAPE_LENS map, with a test.

Corpus

A matched pair, both live-enabled:

  • golden-documentation-stale-and-narrated: a sound behaviour change (expiry 30 → 90 days, clock injected) leaving a falsified comment and a change-narrating comment. Both must post, verdict stays APPROVE. Plus a must-not-flag trap, an EXPIRY_SLACK_MS comment that reads like a restatement but records a constraint the code cannot show; it is inside the diff, so a false flag on it is reachable rather than gate-dropped for unrelated reasons.
  • clean-documentation-earned-comments: the precision guard, and the more important of the two. Every added comment earns its line, the run must post nothing, and both traps sit on added lines next to the code they explain.

Both diffs verified against computeChangedLines (zero orphan hunk lines); annotated line numbers match the fixture trees.

Left for later

  • PR titles and descriptions (Mike's original complaint). The read side is nearly free; the write side needs gh-aw's update-issue safe output and a shrink-only guard. Separate PR, not stacked.
  • Enabling it anywhere. No ROUTING change ships here; it earns its enable line through the live A/B, which the producer change above finally makes possible.

Testing

1421 tests pass, typecheck clean. The corpus pair runs through the deterministic replay in suite.test.ts, where checkExpectation enforces the mustCatch ids and the pinned postedCommentCount.

Worth a second opinion

  • documentation is first-to-shed, ahead of conventions. Advisory comment findings should go first under budget pressure, but the interior order is editorial.
  • One label, not four, where conventions picks among four: exact autofix selection and nil verdict impact, at the cost of the nitpick/question/note nuance.
  • claude-opus-4-8 / medium is copied from conventions, not measured.

@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 53d7ef2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
review Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@khan-actions-bot
khan-actions-bot requested review from a team, jeresig and kevinb-khan and removed request for a team July 29, 2026 22:09
@github-actions

This comment has been minimized.

if (raw === undefined) {
return [];
}
if (!Array.isArray(raw) || !raw.every((n) => typeof n === "string")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): The non-array enabledReviewers shape guard here is untested — live-producer.test.ts pins the happy path and the unknown-name throw, but not this branch. A case with enabledReviewers: "documentation" (or [1]) asserting rejects.toThrow(/must be an array of strings/) would close it. Low priority: it is a defensive guard whose sibling throw is already tested.

the comment must exist at the cited location and say what the claim quotes, and the
code the claim contrasts it with must say what the claim says it says. The
documentation reviewer's characteristic false positive is mistaking a real
constraint for a restatement, so **refute** whenever the comment carries information

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): This documentation-claim rule frames verification around an existing comment ("the comment must exist at the cited location"; "refute whenever the comment carries information the code does not show"). Does that leave the policy's "missing the non-obvious why" category — which flags an absent explanation, with no comment to quote — without a validation path, so it is refuted by default? A sentence covering the absence case (verify the unexplained constant exists on an added line and that nothing in the change explains it) would close it.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review live A/B

Baseline: origin/main (review.md 69f4bc1b6783); candidate: working tree (review.md 42ee80ea0b27).

Ruler: matcher deterministic+arbiter; corpus cc11988c0918 (9 cases).

Metric Baseline Candidate Delta
Must-catch recall 100% 100% +0%
Verdict agreement 100% 100% +0%
Noise (unmatched posted) 59% 61% +2%
Clean false flags 0 0
Judge mean quality 0.91 0.89 -0.01
Cost $9.13 $9.15
Wall clock 1161s 1099s
Cases run / skipped 9 / 0 9 / 0
Misses found-but-dropped 0 0
Findings anchor-snapped 0 0

Adversarial hard gate: PASSED on the candidate arm.

Single-run-stable rows: recall, verdict agreement, regressions, adversarial gate. Judge quality and noise are not: they jitter run-to-run at this corpus size, and a regressed reviewer can score HIGHER on judge quality (fewer, surer comments each read better). Recall against the labeled specs is the load-bearing metric.

Measured noise floor (identical arms, run 29069228968, 2026-07-10, 6 arm-samples, full corpus x3, pre-arbiter; budget skips left the samples on unequal case sets, so these v1 bands also carry case-mix variance): must-catch recall 54%-86% (sd 10%), verdict agreement 75%-100% (sd 9%), noise (unmatched posted) 50%-60% (sd 3%), judge mean quality 82%-86% (sd 2%). A single-run delta whose arms both sit inside a band is indistinguishable from run-to-run wobble; use --repeats to resolve smaller effects.

---
name: documentation
description: Advisory, opt-in check that code comments and prose docs in the diff document intent rather than restate code; returns findings as JSON.
model: claude-opus-4-8

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4-8? not 5?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having some trouble getting opus 5 working. Going to get that sorted out in #294

jwbron added 3 commits July 30, 2026 12:30
…ewer

An advisory-only whole-change reviewer that checks the comments and prose
docs a diff adds or changes against a documentation policy written inline in
its definition. Off everywhere until a repo adds `enable documentation` to
its ROUTING file.

Its findings render with a new non-blocking label,
`suggestion (non-blocking, documentation)`. The variant is not cosmetic: the
autofix workflow selects threads by parsing the label off each posted
comment, so it is the only channel by which a documentation-scoped autofix
can tell a documentation thread from any other nit.

Supporting changes: a documentation-claim rule for the claim-validator, and
the live eval producer now dispatches the opt-in reviewers a case enables
(before this, no opt-in reviewer had a live arm at all). New corpus pair:
one change with two real documentation defects and a must-not-flag trap, one
whose comments all earn their line.
…er with scripted dispatch

Rebase integration after main removed task mode. Two of this branch's
review.md hunks no longer had prose to edit, because main moved what they
edited into code:

- The shed ranking is now `SHED_RANKING` in `dispatch.ts`; `documentation`
  takes the first-to-shed slot there instead of in review.md's prose.
- The label taxonomy is now `BLOCKING_LABELS`/`NON_BLOCKING_LABELS` in
  `render-comment.ts`, which already carries the new label.

And one semantic gap the textual merge could not see: the scripted
dispatcher code-assigns each label-shape reviewer's `lens`, hardcoded to
`conventions` for `skill-auditor` and `correctness` for everything else, so
a documentation finding reached the render stage with the wrong lens.
Replaced with a named map (`LABEL_SHAPE_LENS`), so `lens: documentation`
survives to the downstream consumers that key on it and `labelForFinding`
agrees with the label the reviewer emitted.
@jwbron
jwbron force-pushed the jwies/documentation-lens branch from 76864d2 to 18fc3ec Compare July 30, 2026 19:33
…ymmetric arm, not a failed run

The live A/B baseline arm extracts its agents from the base tip, so on the
PR that adds a reviewer it cannot define that reviewer. `produceLive` threw
on any roster name missing from the arm's review.md and `runArm` does not
wrap its produce call, so selecting either new documentation case killed the
whole A/B before it wrote a report. That made the corpus pair this PR adds
unmeasurable, which is the one thing the pair exists for.

An enabled opt-in reviewer the arm does not define is now recorded as an
absent dimension (`PerAgentReport.absent`) and reported under a new "Arm
asymmetry" section, so a missing dimension never reads as a reviewer that
ran and stayed quiet. Tolerating absence cannot mask a typo, the failure
mode the enabledReviewers validation exists to prevent: the name is already
checked against ENABLEABLE_REVIEWERS, so absence can only mean the arm
predates the reviewer. Every other roster member still hard-errors, the
routed lenses included, since a lens name is validated nowhere.
@github-actions

This comment has been minimized.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 of 3 prior review threads are still unaddressed as of c2e3594:

3 non-blocking threads still open

const roster = rosterNames.flatMap((name) => {
const agent = agents.get(name);
if (agent === undefined) {
if ((enabled as readonly string[]).includes(name)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): This absence-tolerance path applies to both arms, but on the candidate arm of a new-reviewer A/B agents.get() returning undefined can only mean the candidate's review.md is misconfigured (the enable directive is validated against ENABLEABLE_REVIEWERS, not against the arm actually defining the sub-agent) — yet the run still completes green and the report normalizes it as expected. runArm already knows which arm it is on; a hard error, or at least a distinct flag, for candidate-arm absence would catch a misconfigured candidate that otherwise measures nothing about the reviewer the A/B exists to graduate.

Lower-confidence observations (1)
  • workflows/review/lib/dispatch-contracts.ts:250 — the label-shape reviewer→lens mapping now lives in two independent maps (production LABEL_SHAPE_LENS defaults to correctness; the eval labelLens names each reviewer), which agree for documentation but already diverge for the other opt-in reviewers, with no cross-check test tying them together.

…o the pooled report

The first live A/B of the documentation pair proved the absent-dimension
handling works (the baseline arm recorded `documentation` absent on both
cases in all three repeats and still produced a report), and exposed a gap
in the reporting half: a multi-repeat run renders through
renderMultiMarkdownReport, which had no asymmetry section, so the caveat
existed only in the JSON while the markdown a reader sees credited the
delta with no note that the baseline arm never had the dimension.

Extracted the section into a shared helper, deduplicated across repeats
(one absence per repeat would otherwise print N times), and used it in both
renderers.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — no blocking issues found.

4 of 4 prior review threads are still unaddressed as of 53d7ef2:

4 non-blocking threads still open

…criminate the documentation reviewer

The first A/B of the original pair read zero recall delta: the baseline arm
caught both seeded defects in 6 of 6 samples with no documentation reviewer
in it. Diagnosis is a corpus-shape error, not a reviewer failure. Both
seeded defects have a truth-value problem (a comment this diff falsified, a
comment that narrates the change), and a false claim sitting next to changed
code is exactly what a correctness pass reaches for; the ten-line diff gave
it nothing else to look at.

These three seed defects with no truth-value problem, so a correctness pass
has no lane for them, each inside a diff that carries real and correct logic
work so the default roster's attention has somewhere to go:

- restated-docstring: a docstring whose every line is recoverable from the
  signature, on a genuine paging fix (over-fetch by one, replacing a
  length === PAGE_SIZE test that could not tell a full last page from a
  truncated one).
- missing-why: three added constants, two carrying the rationale the code
  cannot show, and BASE_DELAY_MS = 1_700 carrying none. The reviewer has to
  notice the one gap its neighbours make conspicuous.
- commented-out-code: the same construct twice in one diff, kept with a
  dated reason and a ticket in one function and unexplained in the other. A
  reviewer that greps for commented-out lines fails it in both directions.

Every case carries its must-not-flag traps inside the diff, so a false flag
is reachable rather than dropped by the provenance gate for unrelated
reasons, and every diff is verified against computeChangedLines with each
added line matching the fixture tree byte for byte.
@github-actions

Copy link
Copy Markdown
Contributor

Review Guidance

github-actions (6 files)
File Reason
dispatch-contracts.ts New LABEL_SHAPE_LENS map assigns each label-shape reviewer's lens; a wrong entry mislabels every finding that reviewer emits.
render-comment.ts Owns the label-to-lens taxonomy and adds the documentation label with non-blocking-only handling; a taxonomy slip changes how findings render and gate.
review.md Orchestrator and agent-prompt source; adds the documentation reviewer and its claim-validator rule that the whole review flow reads.
live-producer.ts Now dispatches a case's enabled opt-in reviewers and maps their label-shape output — the mechanism every future reviewer's live arm depends on.
live-ab.ts Drives the live A/B comparison harness that decides when an opt-in reviewer graduates.
live-ab-report.ts Renders the live A/B report and now surfaces arm asymmetry when a reviewer is absent from one arm.

Common patterns

5 files: Single-line addition of "documentation" to an existing registered array constant (finding-schema.ts, routing-config.ts, router.ts, dispatch.ts, disciplines.test.ts).

5 files: New corpus case.json fixtures share the same skeleton — tags documentation+live, routerConfig.enabledReviewers: ["documentation"], empty policyConflicts, and a nested live block.

Excluded from review (5 files)

Not individually reviewed — generated, formatting-only, or
fully explained by a common pattern above:

  • workflows/review/lib/disciplines.test.ts — pattern-only
  • workflows/review/lib/dispatch.ts — pattern-only
  • workflows/review/lib/finding-schema.ts — pattern-only
  • workflows/review/lib/router.ts — pattern-only
  • workflows/review/lib/routing-config.ts — pattern-only

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 of 4 prior review threads resolved; 3 still unaddressed as of 102ee15:

3 non-blocking threads still open

@jwbron
jwbron merged commit 6532c6b into main Jul 30, 2026
11 checks passed
@jwbron
jwbron deleted the jwies/documentation-lens branch July 30, 2026 22:04
jwbron added a commit that referenced this pull request Jul 30, 2026
… dispatch.ts, which is over its line cap (#304)

main is red on lint, and neither PR that caused it could have seen it.
`@khanacademy/eslint-config` sets max-lines to 1000. #302 took
workflows/review/lib/dispatch.ts to exactly 1000 lines; the single line #299
added to SHED_RANKING took it to 1001. Each PR was green against its own
base, so the violation existed only in the merge, which is invisible to a
per-PR lint run. Every open PR in the repo inherits the failure, including
#300.

Fixed by extracting a concern rather than by reclaiming a line, so the next
addition does not land in the same place, and rather than raising the cap,
which lives in the shared Khan config and would be a house-rule deviation.
DEFAULT_FINDERS, SHED_RANKING, Roster, RosterShed and computeRoster move to
dispatch-roster.ts and are re-exported from dispatch.ts, which already
advertises one import surface for the dispatch machinery. The moved code is
byte-identical; dispatch.ts goes 1001 -> 920 lines and the new module is 108.

Verified: 1573 tests pass unmodified, typecheck clean, and eslint over
everything CI lints (actions, utils, workflows, minus the ignorePatterns
paths) reports zero errors. Worth recording for next time: a plain local
`pnpm run lint` cannot reproduce CI inside a git worktree under .claude,
because eslint skips dot-directories by default and silently ignores the
whole tree; `--resolve-plugins-relative-to . --no-ignore` scoped to the CI
paths is what actually reproduces it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants