Skip to content

feat(groom): path input scopes an audit to one directory, without resetting the cadence clock (BE-4757) - #83

Open
mattmillerai wants to merge 4 commits into
mainfrom
matt/be-4757-groom-path-scoping
Open

feat(groom): path input scopes an audit to one directory, without resetting the cadence clock (BE-4757)#83
mattmillerai wants to merge 4 commits into
mainfrom
matt/be-4757-groom-path-scoping

Conversation

@mattmillerai

Copy link
Copy Markdown
Contributor

ELI-5

Groom currently audits your whole repo or nothing. This adds a path input so you can say "just look at services/api this time" — and, importantly, makes that actually true rather than a polite request in a prompt. It also makes sure a small scoped run doesn't trick groom into thinking it already did the big whole-repo sweep this week.

What changed

New input path (type: string, required: false, default: ''). Empty is today's whole-repo behavior; all 11 live callers pass nothing and are unaffected. Exposed as a normal input, not gated on github.event_name, so a monorepo caller can pin a permanently scoped run the way the studio fleet already grooms Comfy-Org/cloud's two subtrees as independent units.

It constrains, it does not instruct. scope_label/scope_desc are prompt substitutions — setting scope_desc: "only services/api" today asks and enforces nothing. Layered enforcement in new .github/groom/scope.py:

  • Syntactic validation in the cheap gate job, before any billed agent: absolute paths, .. components, empty components, backslashes, control chars and anything outside a conservative [A-Za-z0-9._-] per-component charset are rejected. A dotted directory name (services/my..svc) is legitimate and is accepted — only a .. component is dangerous.
  • Filesystem containment in the finder job (the first job with the target checked out): both sides normalized through os.path.realpath (the Python cd && pwd -P) before the prefix compare, so a symlinked component can't escape and a trailing separator can't report a false escape. Also proves the directory exists, so a typo'd dispatch fails loudly instead of auditing an empty file list and reporting "clean".
  • A concrete in-scope file list appended to the finder brief (git ls-files), truncated at 1500 with the truncation announced.
  • Post-filter of findings whose evidence sites fall outside the scope, with the dropped count and each dropped title/sites logged as ::warning::. Runs on the finder output because that is where sites live — which also avoids paying the verifier to re-check findings that would be dropped anyway.

The checkout stays full on purpose: a refactor in services/api legitimately references common/, and a sparse checkout would blind the finder to the context that makes a finding correct.

The cadence clock — the headline requirement. workflow_dispatch bypasses the interval gate by design, so a scoped run does reach the finder. Without a fix, a manual path: services/api run would become the repo's "last real groom" and suppress the next scheduled whole-repo tick for a full GROOM_INTERVAL_DAYS — a partial audit stamping "done" over the full one.

I picked option (a), distinguishing the job name, over (b) run-name. Reason: run-name is a property of the caller workflow file, not the called reusable — a run created by ci-groom.yml takes ci-groom.yml's run-name, and the reusable's is ignored. Option (b) would therefore require editing all 11 callers to teach each one about path, which directly contradicts "existing callers unaffected". Option (a) is implementable entirely inside the reusable. The coupling the ticket flags is real, so I made it explicit and pinned on both sides: groom.yml renames the finder job Audit — finder (scoped) when path is set, interval.py grew _SCOPED_JOB_MARKER with a comment on each side saying "keep in sync", and ScopedRunDoesNotResetCadence in test_interval.py asserts it end-to-end through the real evaluate(), with a control case proving an unscoped run in the same fixture does suppress the tick (so the assertion can't pass because the gate is broken).

Dedup stays content-derived. verifier.md's signature template moved from {{REPO_BASENAME}}:{{SCOPE_LABEL}}:<slug> to {{REPO_BASENAME}}:{{SIG_SCOPE}}:<slug>, and SIG_SCOPE is wired to the caller's raw scope_label, never the path-derived one. So a scoped run and a whole-repo run produce the same signature for the same defect and suppress each other — and, just as important, already-filed issues (whose markers carry whole-repo) keep deduping, which a straight "drop the scope component" rewrite would have broken by re-filing every open finding once.

Filed issues name their scope. When path is set and scope_label/scope_desc are still at their documented defaults, they're derived (services/api, "the services/api directory"). An explicit caller value always wins.

Tests

python3 -m unittest discover -s .github/groom/tests -p 'test_*.py'125 tests, green (97 before). New test_scope.py mirrors scan-path-scoping-test.sh's structure and names the same four invariants in the order they'd hurt. actionlint clean on both touched workflows. .github/workflows/groom.yml added to test-groom-scripts.yml's path filter, since test_scope.py now asserts against the workflow's own SIG_SCOPE wiring.

Judgment calls

  • Keep-if-ANY-site-in-scope, not keep-only-if-ALL. A duplication finding spanning services/api/a.go and common/x.go is kept. Requiring every site in scope would suppress exactly the cross-cutting findings the full checkout was deliberately kept to enable. What gets dropped is a finding whose evidence lies entirely outside the scope (including one with no locatable sites, which can't be attributed). Called out because the acceptance criterion is compatible with either reading.
  • "Explicit override" is detected as "not equal to the documented default." An Actions reusable cannot distinguish "input omitted" from "input supplied with the default value"; this is the only available proxy and it is stated in the input descriptions and in scope.py.
  • scope_label/scope_desc now have whitespace runs collapsed before reaching $GITHUB_OUTPUT, where an embedded newline would truncate the value and inject a bogus output key. YAML >- inputs already fold to one line, so this is byte-identical for every real caller.
  • Builder scoping is advisory, not enforced. A scoped finding's fix legitimately needs a call-site update outside the directory to compile, so hard-rejecting an out-of-scope hunk would ship a broken patch. pr_size_limit and human review remain the backstops on patch breadth.
  • Collision direction on (scoped) is deliberate. A caller whose own job name contained (scoped) would make a real whole-repo run stop counting — i.e. groom runs more often. Same fail-open bias as every other branch in interval.py; never the silent under-run.

Unmet criterion

AC 7's docs/callers/groom.md row is NOT in this PR — that guide does not exist on main; it lands in #80 (docs/caller-setup-guides-and-public-hygiene, still open). The ticket sanctions either "rebase on it or add the row when merging", and I chose not to stack: stacking would block this behind a docs PR for one table row. Documentation here instead goes to the three places that do exist on main — the path input's own description (which covers the cadence interaction that AC 7 wants in the footguns section), the caller-pattern block in groom.yml's header, the groom.yml row in README.md, and AGENTS.md's layout section. Whichever of #80 and this PR merges second should add the path row + the cadence footgun to docs/callers/groom.md.

Not applicable: negative-claim falsification

This change adds a capability rather than denying one. The only deny paths it introduces (UnsafePathError on /etc, ../../etc, a non-existent directory) reject malformed input, not a product capability a user could reach another way — and each is paired with a positive test proving the legitimate neighbouring form (services/agent/internal/api, services/my..svc) is accepted, which is the anti-over-rejection half.

…esetting the cadence clock (BE-4757)

Adds `path` (string, default '') to groom.yml so a run — typically a manual
dispatch, but any caller may pin one — audits ONE directory instead of the whole
repo. Empty reproduces today's behavior, so all existing callers are unaffected.

Enforced, not merely instructed (scope_label/scope_desc were prompt prose only):
syntactic validation in the cheap gate job, filesystem containment against the
checkout in the finder job, a concrete in-scope file list handed to the finder,
and a post-filter that drops findings whose evidence lies entirely outside the
scope with the dropped count logged. The checkout stays FULL on purpose.

Critically, a scoped run must not stamp "done" over the whole-repo audit it never
performed: workflow_dispatch bypasses the interval gate, so a scoped run reaches
the finder. The finder job is renamed `Audit — finder (scoped)` and interval.py
excludes that marker when deriving the last real groom, so the next scheduled
whole-repo tick stays DUE. The dedup signature is likewise decoupled from `path`
(verifier.md's new `{{SIG_SCOPE}}` is wired to the caller's RAW scope_label), so
a scoped run and a whole-repo run suppress each other's duplicates.
@mattmillerai mattmillerai added cursor-review Multi-model cursor review agent-coded Authored by the agent-work loop labels Jul 27, 2026
@mattmillerai
mattmillerai marked this pull request as ready for review July 27, 2026 21:23
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 529afc68-3348-498d-b2f6-febb4c5827cb

📥 Commits

Reviewing files that changed from the base of the PR and between f4cbb50 and 99f189e.

📒 Files selected for processing (10)
  • .github/groom/README.md
  • .github/groom/interval.py
  • .github/groom/scope.py
  • .github/groom/tests/test_interval.py
  • .github/groom/tests/test_scope.py
  • .github/groom/verifier.md
  • .github/workflows/groom.yml
  • .github/workflows/test-groom-scripts.yml
  • AGENTS.md
  • README.md
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch matt/be-4757-groom-path-scoping
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch matt/be-4757-groom-path-scoping

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 10 finding(s).

Severity Count
🟠 High 3
🟡 Medium 6
🟢 Low 1

Panel: 8/8 reviewers contributed findings.

Comment thread .github/groom/interval.py Outdated
Comment thread .github/groom/scope.py
Comment thread .github/workflows/groom.yml
Comment thread .github/workflows/groom.yml Outdated
Comment thread .github/groom/scope.py Outdated
Comment thread .github/groom/scope.py Outdated
Comment thread .github/workflows/groom.yml Outdated
Comment thread .github/groom/scope.py Outdated
Comment thread .github/groom/scope.py
Comment thread .github/groom/scope.py
…accuracy (BE-4757)

Addresses the cursor-review panel on #83.

- interval.py: the cadence clock is now PER SCOPE. The finder job marker
  carries the path (`(scoped: services/api)`), and a tick counts only a prior
  run of its own scope. A scope-blind `(scoped)` exclusion left a permanently
  path-scoped caller — a configuration the `path` input explicitly advertises —
  with no countable run in history, failing open on every tick and re-billing
  the audit daily regardless of GROOM_INTERVAL_DAYS.
- interval.py: a job matched only by the bare `audit_find` id form carries no
  rendered display name, and therefore no scope marker, so it can no longer be
  attributed to a whole-repo run. Unknown scope now falls through to fail-open
  rather than letting a scoped run suppress the next full sweep.
- scope.py: `normalize_site` collapses traversal and rejects a site that climbs
  out of the repo root, so `services/api/../../common/x` can no longer pass the
  lexical prefix test — the `..` hole `validate_path` already closes on the
  input side, closed on the side the agent controls.
- scope.py: `scope_note` now states the ANY-in-scope rule the filter actually
  enforces. Demanding EVERY site be in scope told the agents to suppress the
  cross-boundary findings the full checkout exists to enable.
- scope.py: the filter FAILS when the finder emitted no `findings` array. The
  workflow's only check is `jq '.findings | length'`, which scores a missing
  field as 0 — indistinguishable from a clean directory, so a structurally
  broken finder went green on nothing.
- scope.py: `derive_scope` exposes `sig_scope`, the normalized (never
  path-derived) dedup-signature scope, wired to verifier.md's {{SIG_SCOPE}}. A
  blank `scope_label` previously reached the brief empty and yielded `repo::slug`.
- scope.py: tracked filenames with control characters are dropped rather than
  inlined into the finder prompt, where a newline could forge file-list entries.
- groom.yml: a scoped run with zero tracked files now fails loudly instead of
  buying a billed audit that can only report "clean" (an all-gitignored
  directory passes containment).
- scope.py/groom.yml: drop the private consumer repo/directory tuple from this
  public repo, per AGENTS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mattmillerai

Copy link
Copy Markdown
Contributor Author

Review resolution pass — all 10 cursor-review panel findings triaged in f297fcc. 9 fixed, 1 deferred, each answered on its own thread.

Two were behavior bugs, not hardening:

  • The cadence clock is now per-scope. The (scoped) marker was scope-blind, so a caller pinning path permanently — a configuration the input description explicitly advertises — had every finder job excluded from history, found no countable run, failed open on every tick, and re-billed the audit daily with GROOM_INTERVAL_DAYS silently dead. The marker now carries the path ((scoped: services/api)) and a tick counts only a prior run of its own scope. Both directions are pinned by tests: a scoped run still leaves the whole-repo tick due, and a whole-repo sweep does not satisfy a scoped caller's cadence.
  • _cmd_filter no longer shrugs at a missing findings array. Its comment deferred to "downstream schema assertions" that do not exist — the only check is jq '.findings | length', and jq scores a missing field as 0, identical to a clean directory. A structurally broken finder went green on nothing. "findings": [] still passes; {} now fails.

Correctness/robustness: site .. traversal is collapsed before the prefix test (services/api/../../common/x no longer fakes membership); a scoped run with zero tracked files fails loudly instead of buying an audit that could only report "clean"; sig_scope normalizes the dedup-signature scope so a blank scope_label cannot yield repo::slug; control-character filenames are dropped rather than inlined into the finder prompt.

Brief accuracy: scope_note told the agents EVERY site must be in scope while the filter keeps ANY-in-scope — i.e. it instructed them to suppress exactly the cross-boundary findings the full checkout exists to enable. It now states the real rule and says spanning findings are wanted.

Also: dropped the private consumer repo/directory tuple from this public repo, per AGENTS.md. Thanks for catching that one.

Deferred (1): the verifier output is never re-filtered against the scope, so a DOWNGRADE that narrows a kept cross-boundary finding to purely out-of-scope evidence can still be filed. Real, but not a filter call in the right place — the verifier schema has no sites field, so it needs a verifier.md schema change plus a fail-open second pass. Recorded for a follow-up with that plan; detail on the thread.

140 groom tests pass; actionlint clean. Not merging — this stays review-gated for a human.

@mattmillerai mattmillerai added cursor-review Multi-model cursor review and removed cursor-review Multi-model cursor review labels Jul 27, 2026
@mattmillerai

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mattmillerai

Copy link
Copy Markdown
Contributor Author

🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:

  • BE-4793 — Re-check groom verifier output against the path scope before filing

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 9 finding(s).

Severity Count
🟠 High 1
🟡 Medium 5
🟢 Low 3

Panel: 8/8 reviewers contributed findings.

Comment thread .github/workflows/groom.yml
Comment thread .github/groom/scope.py
Comment thread .github/workflows/groom.yml Outdated
Comment thread .github/groom/scope.py Outdated
Comment thread .github/groom/interval.py
Comment thread .github/groom/interval.py Outdated
Comment thread .github/workflows/groom.yml Outdated
Comment thread .github/groom/scope.py Outdated
Comment thread .github/groom/verifier.md Outdated
…g holes (BE-4757)

Cursor-review panel follow-ups on the `path` input, in severity order.

- The scope filter ran only on the FINDER's output, but a `DOWNGRADE` verdict
  explicitly reshapes a finding — so a cross-boundary candidate that legitimately
  passed that filter could be narrowed onto its OUT-of-scope half and filed under
  a directory it no longer belongs to. `verifier.md` now emits `sites`, and
  `scope.py verify` re-applies the same ANY-in-scope rule to what the verifier
  confirmed. A finding with no LOCATABLE site is KEPT there (the opposite of the
  finder-side rule): `sites` is advisory on that schema, so dropping on it would
  discard every survivor and read as an honest "nothing survived verification".
- `canonicalize_signature` forces the dedup key's scope component back to the
  literal the brief handed out, so signature scope-independence no longer rests
  on an untrusted model obeying `{{SIG_SCOPE}}`. Left alone when `scope_label`
  itself contains a colon — component boundaries are ambiguous there, and
  corrupting a working dedup key is worse than the double-filing it guards.
- `normalize_site` no longer relativizes an absolute path that is NOT under the
  clone; it returns "" (unlocatable). Stripping the leading slash silently
  reinterpreted `/services/api/x.go` as repo-relative, so an out-of-tree site
  could satisfy a matching scope.
- `resolve_within` rejects a SYMLINKED scope even when it points inside the
  repo. Verified against git: `git ls-files -- <link>` returns the link entry
  alone, so groom.yml's non-empty guard passed while the finder audited nothing
  and reported clean. The error names the fix (pass the real directory).
- `list_files` reads bytes and decodes with surrogateescape: one non-UTF-8
  tracked filename previously raised UnicodeDecodeError and killed the scoped
  audit before the finder ran. `printable_path` drops surrogates too, and the
  omitted count is now WARNED rather than silently shortening the list.
- `run_audited` compares the `(scoped: <path>)` marker case-SENSITIVELY, so
  `services/api` and `services/API` keep separate cadence clocks instead of one
  silently suppressing the other's due tick.
- Every caller-controlled value reaches argparse as `--flag=value`: a directory
  named `-foo` is valid per the charset and the bare form reads it as an option.
@mattmillerai mattmillerai added cursor-review Multi-model cursor review and removed cursor-review Multi-model cursor review labels Jul 27, 2026
@mattmillerai

Copy link
Copy Markdown
Contributor Author

🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:

  • BE-4809 — groom interval gate: only count a failure finder job as a spent audit if the agent step actually ran

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 7 finding(s).

Severity Count
🟡 Medium 3
🟢 Low 4

Panel: 8/8 reviewers contributed findings.

Comment thread .github/groom/scope.py Outdated
Comment thread .github/groom/interval.py
Comment thread .github/groom/scope.py Outdated
Comment thread .github/groom/scope.py
Comment thread .github/workflows/groom.yml
Comment thread .github/groom/scope.py Outdated
Comment thread .github/workflows/groom.yml
…e hardening (BE-4757)

Round-3 review findings on the `path` scoping PR. Five fixes, one deferral.

* SUBMODULE SCOPE READ AS CLEAN. `git ls-files -- <path>` on a submodule
  returns the mode-160000 gitlink as a single entry, so `list_files` was
  non-empty, groom.yml's empty-list guard passed, and the finder audited a
  directory a default checkout never populates — reporting it clean. The
  listing is now taken with `-s` and gitlinks are skipped (and warned about),
  so a submodule-only scope falls through to the empty-list FAILURE instead.
  Verified the premise: groom.yml never passes `submodules:` to
  actions/checkout, so a submodule's files are genuinely unauditable here; the
  error names the working path (groom it from its own repository).

* UNICODE LINE SEPARATORS FORGED PROMPT LINES. `printable_path` rejected ASCII
  controls but not U+0085/U+2028/U+2029, which Git permits in filenames and
  many consumers render as a line break — so a planted name could still forge
  `- {f}` bullets in the finder's authoritative file list.

* FILE LIST CAPPED BY COUNT, NOT SIZE. 1500 deeply-nested near-PATH_MAX names
  cleared `_MAX_LISTED_FILES` and still serialized to megabytes, overflowing
  the finder's context. Now capped by bytes as well, always listing at least
  one name so a single pathological path cannot empty the block.

* A VERDICT TYPO WAS SILENTLY DISCARDED. groom.yml's malformed-finding check
  required `verdict` to be a string but not one of CONFIRM/DOWNGRADE/REJECT,
  so `CONFIRMED` passed validation and was then dropped by the KEEP filter —
  a real confirmed finding lost with the run green, the exact silent discard
  that fail-loud step exists to prevent.

* AN OVER-LONG PATH KILLED THE CADENCE CLOCK. The path is embedded in the
  finder job name as the `(scoped: <path>)` marker `interval.py` matches;
  GitHub truncates a long job name, so no prior run is ever recognised and a
  permanently scoped caller re-bills its audit every tick. `validate_path` now
  caps the normalized path at 160 characters.

Not fixed, deliberately: `site_in_scope` stays purely lexical (replied on the
thread), and counting a PRE-AGENT finder failure as a spent audit is deferred
to a follow-up — it lives in `_AUDITED_CONCLUSIONS`, predates path scoping,
and changing it alters cadence for every groom run, whole-repo included.

Tests: submodule gitlink, the three line separators, the byte cap and its
always-list-one floor, and the path-length cap (including that it is measured
after normalization). 165 -> 170 green.
@mattmillerai

Copy link
Copy Markdown
Contributor Author

🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:

  • BE-4814 — groom cadence: don't count a PRE-AGENT finder failure as a spent audit

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-coded Authored by the agent-work loop cursor-review Multi-model cursor review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants