Skip to content

autofix: add a docs scope that fixes only documentation threads - #300

Merged
jwbron merged 4 commits into
mainfrom
jwies/autofix-docs-scope
Jul 30, 2026
Merged

autofix: add a docs scope that fixes only documentation threads#300
jwbron merged 4 commits into
mainfrom
jwies/autofix-docs-scope

Conversation

@jwbron

@jwbron jwbron commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Adds one value to the scope axis: autofix: docs / /autofix docs fixes only the documentation reviewer's threads.

Both dependencies have landed: #298 (autofix itself) and #299 (which mints the label this scope selects on). Rebased onto main 07-30, so the four commits below are this PR's own and the diff is just the scope change: scope.ts, its tests, the label allowlist, and docs. The lock was regenerated with gh aw compile autofix rather than hand-merged, and it comes out byte-identical to the replayed patch; the two autofix.md copies are byte-identical to each other.

Why this is small

Because #299 did the load-bearing half. worklist.ts selects threads by parsing the Conventional-Comment label off each posted comment — autofix reads the PR's threads, not the reviewer's run artifact, so nothing else about a finding survives to reach it. Once the reviewer mints suggestion (non-blocking, documentation), a documentation-scoped autofix is exactly "the threads carrying that label", and this PR is a table entry plus a switch arm.

docs is a subset of nits, not a peer of it

The one thing worth arguing about. Documentation findings are non-blocking, so nits already covers them: arming both is the same as arming nits. The containment runs one way only, which is the whole point — arming nits to clear three stale comments also invites the fixer into every other cosmetic thread on the PR.

The flat token namespace cannot show that (blocking, nits, and docs all read as peers), so it is stated in scope.ts, in the README's axis table, and pinned by a test that asserts docs ⊂ nits and that no blocking label can ever land in docs scope. No new axis: union semantics, UNIMPLEMENTED_TOKENS, and the two arming surfaces are untouched.

findingLabelsForScope becomes an exhaustive switch rather than a blocking/non-blocking ternary, so the next scope value cannot silently inherit NON_BLOCKING_LABELS by falling through.

Not loop-eligible, and that is the interesting call

isLoopEligible exists to encode the rule most likely to be violated by whoever adds the cadence axis, so a new scope value has to answer to it.

docs looks like the exception to "nits never loop" and is only half one. Its deletion half genuinely converges: a comment that restates the code is either gone or it is not. Its other half does not — the documentation reviewer also flags a missing explanation, the fixer answers with prose, and prose is the thing a reviewer can always want written better. So docs stays ineligible, and the comment says why in those terms: it is a claim about evidence we do not have, not about the domain. If a cadence axis is ever built, docs is the first candidate to re-examine and the thing to measure is which half dominates.

The prompt rule that comes with it

A documentation item changes text, never code. Deleting a comment the finding calls redundant is the expected fix, not an overreach — and such findings often carry no suggestion block precisely because a deletion cannot be expressed as one (the reviewer's suggested_patch must be non-empty). But if the honest fix would touch an executable line (renaming the symbol a comment misdescribes, changing the constant it contradicts), that is a code change wearing a documentation label: leave it unfixed and report it.

Prompt-level, not code-enforced, and flagged as such — the same standing as "never weaken a test". A deterministic "comments only" check would have to be language-aware, and getting it wrong in either direction is worse than the prompt rule.

That property is also the argument for this scope existing: its edits cannot change program behaviour, which no other scope can say. It is the natural first thing to trial in a repo that has not run autofix before.

Version coupling worth knowing before trialling it

Autofix selects on labels minted by whichever review release a repo has installed. autofix: docs therefore finds threads only once that repo is on a reviewer carrying the documentation label. Against an older reviewer it is not broken; it is always empty. Documented in both READMEs.

Compiled output

remove-labels.allowed gained autofix: docs, so .github/workflows/autofix.lock.yml is recompiled (gh-aw v0.83.4, matching #298). The lock diff is exactly the two metadata hashes, the description, and the allowlist in its three baked locations. .github/workflows/autofix.md is kept byte-identical to workflows/autofix/autofix.md, as #298 has it.

Testing

1371 tests pass across the repo (133 in workflows/autofix), typecheck and prettier clean. New tests cover the token on both arming surfaces, the label mapping, the subset relation, the no-blocking-label-in-docs-scope guard, and loop ineligibility.

Not in scope

PR descriptions and titles. That is the third piece, it needs the update-issue safe output and a shrink-only guard, and it does not belong on this stack.

@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e652bf9

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

This PR includes changesets to release 1 package
Name Type
autofix 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, jaredly and somewhatabstract and removed request for a team July 29, 2026 22:18
@github-actions

This comment has been minimized.

@khan-actions-bot
khan-actions-bot requested a review from a team July 29, 2026 22:40
Comment thread workflows/review/lib/router.ts

### Why `docs` is the safest scope

Its edits cannot change program behaviour, which no other scope can say. That

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.

thought (non-blocking): The "edits cannot change program behaviour" property here is enforced only by the Step 4 prompt rule ("changes text, never code") — nothing in plan.ts/stage.ts inspects the edits the agent actually produces. Since this line sells docs as the safest scope to trial first, consider either backing the claim mechanically (fail closed if a docs-only diff touches a non-comment line) or softening the wording to "is instructed never to".

export const findingLabelsForScope = (
scope: AutofixScope,
): readonly string[] => {
switch (scope) {

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.

note (non-blocking): This switch is described as exhaustive, but there is no default arm with a const _: never = scope guard, and nothing else enforces exhaustiveness: workflows/** is outside the typecheck project and the tests hardcode the three scopes rather than iterating AUTOFIX_SCOPES. A future token added to AUTOFIX_SCOPES without a matching arm would return undefined at runtime — arming that scope with no labels — and nothing would flag it. A never default (or a test iterating AUTOFIX_SCOPES) would make it fail loud.

* cannot show this containment, so it is stated here and in the README.
*/
export const AUTOFIX_SCOPES = ["blocking", "nits"] as const;
export const AUTOFIX_SCOPES = ["blocking", "nits", "docs"] as const;

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): docs is the first scope token that selects by lens label rather than by severity class, and the containment argument in the docstring applies verbatim to suggestion (non-blocking, best-practice) and to every future lens variant. What bounds this axis from growing one token per lens label? It may be worth writing the boundary down now (e.g. "docs is the only lens-selection token" with the reason, or committing to the generalization) before a second lens token forces the choice ad hoc.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review live A/B

Baseline: origin/jwies/autofix-v1 (review.md ccece9056ede); candidate: working tree (review.md 6541ca76e869).

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) 53% 57% +5%
Clean false flags 0 0
Judge mean quality 0.90 0.90 -0.00
Cost $8.52 $8.68
Wall clock 1019s 1019s
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.

@jwbron
jwbron force-pushed the jwies/autofix-docs-scope branch from e30fd9c to e3628dc Compare July 30, 2026 19:28
@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.

Approved — no blocking issues found.

4 of 4 prior review threads are still unaddressed as of e3628dc:

4 non-blocking threads still open
  • thought (non-blocking) workflows/autofix/README.md:128: The "edits cannot change program behaviour" property here is enforced only by the Step 4 prompt rule ("changes text, nev...
  • note (non-blocking) workflows/autofix/lib/scope.ts:149: This switch is described as exhaustive, but there is no default arm with a const _: never = scope guard, and nothing...
  • question (non-blocking) workflows/autofix/lib/scope.ts:65: docs is the first scope token that selects by lens label rather than by severity class, and the containment argument i...
  • note (non-blocking) workflows/review/lib/router.ts:106: Adding documentation to ALWAYS_ON_LENSES does not make it always-on, and the addition is in fact required — `SPECIAL...

@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.

4 of 4 prior review threads are still unaddressed as of 4573ef3:

4 non-blocking threads still open
  • thought (non-blocking) workflows/autofix/README.md:207: The "edits cannot change program behaviour" property here is enforced only by the Step 4 prompt rule ("changes text, nev...
  • note (non-blocking) workflows/autofix/lib/scope.ts:189: This switch is described as exhaustive, but there is no default arm with a const _: never = scope guard, and nothing...
  • question (non-blocking) workflows/autofix/lib/scope.ts:89: docs is the first scope token that selects by lens label rather than by severity class, and the containment argument i...
  • note (non-blocking) workflows/review/lib/router.ts:106: Adding documentation to ALWAYS_ON_LENSES does not make it always-on, and the addition is in fact required — `SPECIAL...

case "blocking":
return BLOCKING_LABELS;
case "docs":
return [DOCUMENTATION_LABEL];

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.

nitpick (non-blocking): Arming autofix: nits and autofix: docs together lists suggestion (non-blocking, documentation) twice in findingLabelsdocs's label set is a subset of nits', and resolveTokens builds the field with flatMap and no dedupe. Harmless today since buildWorkList collapses it to a Set, but the field rides on AutofixRequest, so a future consumer that renders or counts it inherits the duplicate.

Suggested change
return [DOCUMENTATION_LABEL];
findingLabels: [...new Set(scopes.flatMap((scope) => findingLabelsForScope(scope)))],

Base automatically changed from jwies/autofix-v1 to main July 30, 2026 22:03
jwbron added 4 commits July 30, 2026 15:05
…y documentation threads

`autofix: docs` / `/autofix docs` acts on the threads carrying the
`documentation` reviewer's label and nothing else.

It is a subset of `nits`, not a peer: documentation findings are
non-blocking, so `nits` already covers them. The containment runs one way,
and `docs` exists because arming `nits` to clear three stale comments also
invites the fixer into every other cosmetic thread on the PR.

Not loop-eligible, despite looking like the exception to "nits never loop":
its deletion half converges, its missing-explanation half answers with prose,
and prose can always be wanted better.

One prompt rule comes with it: a documentation item changes text, never code.
If the honest fix would touch an executable line, the item is left unfixed
and reported. Edits that cannot alter behaviour is the property that makes
this the safest scope to trial first.
…nale and its enforcement point

Documentation only; no behaviour change. Two claims in the loop-eligibility
notes were wrong, and both are the kind whoever adds the cadence axis would
inherit.

The enforcement point. "isLoopEligible is enforced in code rather than left to
convention" overstates a function whose only callers are its own tests. What
actually stops a loop in v1 is the token table: `loop` is in
UNIMPLEMENTED_TOKENS, so `autofix: loop` is rejected and no cadence can be armed
at all. isLoopEligible is a documented intent with a test, which is the right
thing to have built now, but it is not a gate. Both the README and the module
header now say so, and say that calling it is the cadence author's job.

The mechanism. Both notes attributed re-flagging to the fixer's own prose ("the
reviewer will always find something cosmetic in the autofixer's own output").
Khan/webapp#41194 shows otherwise: one blocking-scoped cycle fixed its finding
and had the thread resolved and the PR approved, then drew two fresh
non-blocking findings against code the fixer never wrote (counts.go:7 is
func MergeCounts, twelve lines above its first added line; counts.go:16 is a
context line in its own hunk). That run planned no-prior-fingerprint, so the
reviewer's newly-changed-code scope filter was a no-op and the whole diff was
re-derived with no memory of the previous review. Open non-blocking threads went
3 to 5 in one cycle with no nits-scoped work done. The generator is a memoryless
re-derivation over the whole diff, which does not need the fixer to have written
anything. The conclusion is unchanged and better supported: nits still cannot
converge.

What that costs blocking is worth writing down next to the predicate that
returns true for it. The merge gate is a claim about a human eventually merging,
not a termination proof, and nothing in the reviewer backs it: applyScopeFilter
keeps plain `issue (blocking)` / `todo (blocking)` findings whether or not they
land on newly-changed code, so blocking is the one class that can be re-raised
on previously-reviewed, untouched lines every cycle. The scope filter bounds
nits; it does not bound blocking. A cadence axis needs its own stop condition
and cannot inherit one from isLoopEligible.

No guard is added here. A cycle cap and a no-progress guard both have their
substrate already (summariseLedger's cycles/nextCycle and attemptedThreadIds),
but adding uncalled guards beside an uncalled predicate is not an improvement
until there is a cadence axis to call them.
…ata point

Documentation only. Both loop-eligibility notes said `docs` ineligibility was
"a claim about evidence we do not have, not a claim about the domain". After
Khan/webapp#41194 that is no longer true, and it is the same class of stale claim
as the two the previous commit fixed.

The trial's second fresh finding is a missing explanation: counts.go:16 flags a
doc comment that covers tie resolution but not the empty/nil-map case, raised
unprompted against the fixer's own PR. That is exactly the half these notes
name as non-convergent, so the evidence exists now and it points the same way
the ineligibility call already did.

Two limits stated rather than glossed, because they are what keep this an
observation instead of a measurement. It did not come from the documentation
reviewer: webapp runs a `review` release too old to mint the `documentation`
label, so the finding was a plain `note (non-blocking)` and `autofix: docs`
would not have selected it. And one finding says nothing about which half
dominates, which remains the thing to measure first if a cadence axis is ever
built. The conclusion is unchanged: `docs` stays ineligible.

The changeset needs no edit; it already says "ineligible until something
measures which half dominates", which one data point does not do.
…ion condition, not a property

The nits-never-loop note says the generator is a memoryless re-derivation over
the whole diff. True of the run it cites, but stated as a property of the
generator a reader takes memorylessness as the normal state, and it is not: it
was a consumer version artifact with a named cause and a named remedy.

Khan/webapp pins review-v1.7.0 (2026-07-21). That release carries no
stage-pr.ts and no stampSource at all, so it has no cache-memory fingerprint
carrier and its only carrier is the review-body stamp that gh-aw's ingest
sanitizer strips. Hence no-prior-fingerprint on every re-review, hence the scope
filter no-op, hence the whole diff re-derived each cycle. review-v1.8.0
(2026-07-30) has both carriers, so a consumer on it can anchor a re-review and
scope one.

That does not make the convergence question go away, and the clause says what
survives it: a cadence axis must not assume any given consumer is on a release
that can scope. It is a condition to check, not a property to design around.

The clause also says what it is NOT, because the two degradations look alike and
are not. Autofix's own currency check is degraded for an unrelated and
non-version reason: cache memory is scoped per workflow, so the reviewer's
carrier is unreachable from autofix at any version. staleness.ts already
documents that; conflating them would make the reviewer's fixable problem look
permanent and autofix's permanent one look like a version bump away.
@jwbron
jwbron force-pushed the jwies/autofix-docs-scope branch from 4573ef3 to e652bf9 Compare July 30, 2026 22:06
@jwbron
jwbron merged commit 7d62a7a into main Jul 30, 2026
4 of 6 checks passed
@jwbron
jwbron deleted the jwies/autofix-docs-scope branch July 30, 2026 22:18
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.
@github-actions

Copy link
Copy Markdown
Contributor

Review Guidance

github-actions (2 files)
File Reason
scope.ts Resolves which review threads the autofixer may modify in consuming repos' CI; the new docs scope maps to one non-blocking label, a narrowing change.
autofix.md Frontmatter of a shipped agentic workflow with push-to-branch security surfaces; the only widening is one autofix:-namespaced entry in the remove-labels allowlist.

Common patterns

2 files: .github/workflows/autofix.md is a byte-identical mirror of workflows/autofix/autofix.md.

4 files: autofix: docs mechanically inserted into label allowlists and arming-syntax strings.

Excluded from review (2 files)

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

  • .github/workflows/autofix.lock.yml — generated
  • .github/workflows/autofix.md — 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.

Approved — no blocking issues found.

1 of 5 prior review threads resolved; 4 still unaddressed as of e652bf9:

4 non-blocking threads still open
  • thought (non-blocking) workflows/autofix/README.md:207: The "edits cannot change program behaviour" property here is enforced only by the Step 4 prompt rule ("changes text, nev...
  • note (non-blocking) workflows/autofix/lib/scope.ts:189: This switch is described as exhaustive, but there is no default arm with a const _: never = scope guard, and nothing...
  • nitpick (non-blocking) workflows/autofix/lib/scope.ts:193: Arming autofix: nits and autofix: docs together lists suggestion (non-blocking, documentation) twice in `findingLa...
  • question (non-blocking) workflows/autofix/lib/scope.ts:89: docs is the first scope token that selects by lens label rather than by severity class, and the containment argument i...

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