feat(rules): add 6 Tier 2 discipline rules + extend schema with old_string#18
Conversation
…tring
Adds the Tier 2 discipline rule family — five `warn` rules and one
`block` rule that nudge authors toward documented patterns from feedback
memories without blocking the operation (the one block rule covers a
storage-layout error that is hard to undo once committed).
The 6 new rules:
| Rule | Action | Tool | Memory |
|---|---|---|---|
| warn-deletes-console-log | warn | Edit/MultiEdit | feedback_never_delete_debug_logs.md |
| warn-time-estimates-in-plans | warn | Edit/Write/MultiEdit | feedback_no_time_estimates.md |
| warn-pr-create-many-files | warn | Bash | feedback_split_large_prs.md |
| warn-posthog-in-alerts | warn | Slack | feedback_no_posthog_nag.md |
| block-sibling-suffix-storage | block | Bash | feedback_explicit_storage_prefixes.md |
| warn-localhost-in-pr-body | warn | Bash | feedback_test_on_staging.md |
## Schema extension: `old_string` field
`warn-deletes-console-log` is the first rule that needs to inspect the
`old_string` of an Edit (to detect deletion of debug-log lines).
Previously `parse-input.sh` only exposed `tool_input.content` and
`tool_input.new_string` (under the same `INPUT_CONTENT` alias). This PR
adds:
- `INPUT_OLD_STRING` extraction in `parse-input.sh` resolving from
`tool_input.old_string` first, falling back to a newline-joined
flatten of every `tool_input.edits[].old_string` (MultiEdit case),
so a single regex check covers both Edit and MultiEdit shapes.
- `old_string)` case in the field-resolver switch in `eval-rule.sh`.
- README schema list updated (`field: ... | old_string`).
Both edits are additive — no existing rule references `old_string`, so
backward compatibility is intact (verified: 136 / 136 baseline tests
still pass after the schema extension is applied alone).
## Coverage notes
- warn-deletes-console-log: pattern matches console.log(, console.debug(,
console.error(, debug(, logger.{debug,info,trace}(.
- warn-time-estimates-in-plans: bilingual EN/ES pattern covering
days/sprints/weeks/cycles + dias/semanas/ciclos with optional accent.
- warn-pr-create-many-files: heuristic on `gh pr create` (cannot count
files itself; the message instructs the caller to run
`git diff --stat <base>..HEAD | tail -1` before bypassing).
- warn-posthog-in-alerts: applies_to: [Slack] using the existing
post-slack.sh dispatcher (warn-only by construction).
- block-sibling-suffix-storage: Bash-only; CLI surface (supabase storage
upload, gcloud storage cp, gsutil cp) — application-layer paths are
covered in code review.
- warn-localhost-in-pr-body: two AND-chained command-field conditions
(gh pr create/edit + --body inline + localhost/127.0.0.1).
Tests: 136 -> 171 passing (+35 new).
Version bump: 1.9.0 -> 1.10.0 in package.json, plugin.json, and
marketplace.json (top-level + nested plugin entry).
|
@greptile review |
Greptile SummaryThis PR adds six Tier 2 discipline rules to the manifest-driven hook system and extends the rule schema with a new
Confidence Score: 5/5Safe to merge — all previously flagged issues are resolved and the additive schema extension introduces no regressions. The two concrete defects identified in prior reviews — missing No files require special attention.
|
| Filename | Overview |
|---|---|
| hooks/lib/parse-input.sh | Adds INPUT_OLD_STRING extraction with ` |
| hooks/lib/eval-rule.sh | Adds old_string) case to the field-resolver switch; no existing branches are touched. |
| hooks/rules/rules.yaml | Six new rules with comprehensive tests; previously flagged \bdebug\( word-boundary and MultiEdit shape tests are present; ` |
| hooks/rules/rules.json | Regenerated artifact matching rules.yaml; all six new rule objects and their test fixtures are present. |
| hooks/rules/README.md | Schema field list and Tier 2 rule family description updated; new old_string field documented. |
| package.json | Version bumped from 1.9.0 to 1.10.0; consistent with plugin.json and marketplace.json. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Tool Call arrives] --> B{jq available?}
B -- No --> C[Skip extraction]
B -- Yes --> D[Extract INPUT_COMMAND, INPUT_FILE_PATH, INPUT_CONTENT, INPUT_TEXT]
D --> E[Extract INPUT_OLD_STRING]
E --> F{tool_input.old_string exists?}
F -- Yes --> G[Use tool_input.old_string directly]
F -- No --> H[Flatten edits via strings filter and join on newline]
G --> I[Export all INPUT vars]
H --> I
I --> J[eval-rule.sh iterates conditions]
J --> K{field type?}
K -- command --> L[VALUE = INPUT_COMMAND]
K -- file_path --> M[VALUE = INPUT_FILE_PATH]
K -- content --> N[VALUE = INPUT_CONTENT]
K -- text --> O[VALUE = INPUT_TEXT]
K -- old_string --> P[VALUE = INPUT_OLD_STRING NEW]
K -- unknown --> Q[exit 0 rule skipped]
L & M & N & O & P --> R{pattern matches?}
R -- warn --> S[exit 0 and stderr message]
R -- block --> T[exit 2 and stderr message]
R -- No match --> U[exit 0 silent]
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
hooks/rules/rules.yaml:703-717
**Path to 5/5 Confidence**
All previously flagged issues (`| strings` null-guard, `\bdebug\(` word-boundary, MultiEdit-shape tests) are resolved in this iteration. The following concrete additions would make the test suite exhaustive and eliminate the last heuristic gaps:
1. **`hooks/rules/rules.yaml` — add a MultiEdit test with a missing `old_string` key** (`warns-on-multiedit-with-null-old-string`): supply `edits: [{new_string: "x"}, {old_string: "console.log(y)", new_string: ""}]` (first edit has no `old_string` key at all). This directly exercises the `| strings` null-guard in `parse-input.sh` and confirms the rule still fires when one edit object is missing the key entirely.
2. **`hooks/rules/rules.yaml` — add a negative `block-sibling-suffix-storage` test for correct-destination-bad-source-name**: `gsutil cp ./data.processed.zip gs://bucket/processed/data.zip`. Currently the regex matches any position in the command, so this correct upload (bad source name, good destination) would be blocked. If the intent is to match only the destination path, narrowing the regex would eliminate that false positive; if the intent is to also catch bad source naming, a test and a note in the message would document the deliberate choice.
3. **`hooks/rules/rules.yaml` — add a `warn-time-estimates-in-plans` negative test for numbers-without-units** (e.g., `content: "Phase 1 and Phase 2 cover the implementation"`) to lock in that bare ordinal numbers don't trigger the rule when no time-unit word follows.
Reviews (2): Last reviewed commit: "fix(rules): close 3 Greptile findings on..." | Re-trigger Greptile
| @@ -1467,3 +1467,395 @@ | |||
| - old_string: '# A' | |||
There was a problem hiding this comment.
Three concrete changes would close every open gap:
-
hooks/lib/parse-input.shline 48 — fix null filtering in the MultiEdit jq path
Change[.tool_input.edits[]?.old_string] | join("\n")→[.tool_input.edits[]?.old_string | strings] | join("\n"). The| stringsfilter drops null entries beforejoinis called, preventing silent jq failure when any edit object lacks anold_stringkey. -
hooks/rules/rules.yaml(and regeneratedrules.json) — add word boundary todebug\(
Changedebug\(→\bdebug\(so the rule does not fire on function names that merely end withdebug(e.g.setDebug(,embedDebug(). The\banchor is already used in the time-estimates pattern in this same PR. -
hooks/rules/rules.yaml— add a MultiEdit-shape test forwarn-deletes-console-log
Add two test cases that supplytool_input.editsinstead oftool_input.old_string:- name: warns-on-multiedit-deleting-console-log input: tool_input: edits: - old_string: "console.log('debug:', x)" new_string: '' expected_exit: 0 expected_stderr_contains: 'warn-deletes-console-log' - name: allows-multiedit-not-touching-logs input: tool_input: edits: - old_string: 'const x = 1' new_string: 'const x = 2' expected_exit: 0
Prompt To Fix With AI
This is a comment left during a code review.
Path: hooks/rules/rules.yaml
Line: 1467
Comment:
**Path to 5/5 Confidence**
Three concrete changes would close every open gap:
1. **`hooks/lib/parse-input.sh` line 48 — fix null filtering in the MultiEdit jq path**
Change `[.tool_input.edits[]?.old_string] | join("\n")` → `[.tool_input.edits[]?.old_string | strings] | join("\n")`. The `| strings` filter drops null entries before `join` is called, preventing silent jq failure when any edit object lacks an `old_string` key.
2. **`hooks/rules/rules.yaml` (and regenerated `rules.json`) — add word boundary to `debug\(`**
Change `debug\(` → `\bdebug\(` so the rule does not fire on function names that merely end with `debug` (e.g. `setDebug(`, `embedDebug(`). The `\b` anchor is already used in the time-estimates pattern in this same PR.
3. **`hooks/rules/rules.yaml` — add a MultiEdit-shape test for `warn-deletes-console-log`**
Add two test cases that supply `tool_input.edits` instead of `tool_input.old_string`:
```yaml
- name: warns-on-multiedit-deleting-console-log
input:
tool_input:
edits:
- old_string: "console.log('debug:', x)"
new_string: ''
expected_exit: 0
expected_stderr_contains: 'warn-deletes-console-log'
- name: allows-multiedit-not-touching-logs
input:
tool_input:
edits:
- old_string: 'const x = 1'
new_string: 'const x = 2'
expected_exit: 0
```
How can I resolve this? If you propose a fix, please make it concise.1. **jq null-handling for MultiEdit old_string** (parse-input.sh).
Greptile spotted that
[.tool_input.edits[]?.old_string] | join("\n")
throws when any edit object lacks `old_string`, leaving
INPUT_OLD_STRING empty and silently dropping the rule. Add `| strings`
to filter null entries before join:
[.tool_input.edits[]?.old_string | strings] | join("\n")
2. **`\bdebug\(` word boundary** (warn-deletes-console-log). The bare
`debug\(` matched as a substring inside identifiers ending in
`debug` (`setDebug(`, `embedDebug(`). Add `\b` so the rule only fires
on identifier-start boundaries — matches how the `\b` is already used
in warn-time-estimates-in-plans.
3. **MultiEdit-shape tests for warn-deletes-console-log**. The five
existing tests only exercised the Edit shape (`tool_input.old_string`).
Add three MultiEdit-shape tests:
- warns-on-multiedit-deleting-console-log (single-element edits[])
- warns-on-multiedit-second-edit-deletes-logger (multi-element
edits[], log on the second entry — locks in the join behavior)
- allows-multiedit-not-touching-logs (negative path)
Tests: 171 -> 174 passing (+3).
|
Round 2: addressed all 3 Greptile findings. See commit 8f21122.
Tests: 171 -> 174 passing. @greptile review |
Greptile already at 5/5; these are belt-and-suspenders test additions that close the last heuristic gaps. 1. **warns-on-multiedit-with-null-old-string** (warn-deletes-console-log) — locks in the `| strings` null-guard added to parse-input.sh by exercising a MultiEdit shape where the FIRST edit has no `old_string` key at all. Without the null filter, jq would throw on null entries passed to `join` and the rule would silently miss the actual log deletion in the second edit. 2. **allows-numbers-without-time-units** (warn-time-estimates-in-plans) — pins the negative path so a future regex tweak that drops the trailing alternation cannot quietly fire on bare ordinal numbers (`Phase 1`, `Phase 2`, `issue 42`). 3. **allows-clean-source-and-clean-destination-bucket-prefix** (block-sibling-suffix-storage) — pins that an upload with a clean source name and a clean destination path is allowed (sister test to `blocks-gcloud-storage-cp` which catches the suffix anywhere in the command). This documents the deliberate broad-match semantics: the policy disallows the sibling-suffix anywhere in the pipeline, so a misnamed source still trips the rule even with a correct destination — by design. Tests: 174 -> 177 passing.
Summary
Adds the Tier 2 discipline rule family — five
warnrules and oneblockrule that nudge authors toward documented patterns from feedbackmemories without blocking the operation. The one block rule
(
block-sibling-suffix-storage) is intentional because the storage-layouterror is hard to undo once committed.
This PR also extends the rule schema with a new
old_stringfield sothe new
warn-deletes-console-logrule can inspect the deleted side ofan Edit.
The 6 new rules
warn-deletes-console-logEdit/MultiEditfeedback_never_delete_debug_logs.mdwarn-time-estimates-in-plansEdit/Write/MultiEditfeedback_no_time_estimates.mdwarn-pr-create-many-filesBashfeedback_split_large_prs.mdwarn-posthog-in-alertsSlackfeedback_no_posthog_nag.mdblock-sibling-suffix-storageBashfeedback_explicit_storage_prefixes.mdwarn-localhost-in-pr-bodyBashfeedback_test_on_staging.mdSchema extension:
old_stringfieldwarn-deletes-console-logneeds to inspect theold_stringof an Editto detect deletion of debug-log lines. Previously
parse-input.shexposed only
tool_input.contentandtool_input.new_string(underthe same
INPUT_CONTENTalias). This PR adds:INPUT_OLD_STRINGextraction inparse-input.shresolving fromtool_input.old_stringfirst, falling back to a newline-joinedflatten of every
tool_input.edits[].old_string(MultiEdit case).old_string)case in the field-resolver switch ineval-rule.sh.field: ... | old_string).Both edits are additive. No existing rule references
old_string, andthe 136 / 136 baseline tests still pass after the schema extension is
applied alone (verified before adding the new rules).
Coverage notes
warn-deletes-console-log — covers
console.log(,console.debug(,console.error(,debug(,logger.debug/info/trace(. Bypass markerdocumented for the legitimate flows: env-gating conversion,
post-prod-verification cleanup, documented debug-temp.
warn-time-estimates-in-plans — bilingual EN/ES pattern covering
days/sprints/weeks/cycles+dias/semanas/cicloswithoptional accent. Scoped to
plans/,specs/,design/,openspec/,docs/superpowers/paths.warn-pr-create-many-files — heuristic on
gh pr create(cannotcount diff files itself); the message instructs the caller to run
git diff --stat <base>..HEAD | tail -1before bypassing.warn-posthog-in-alerts —
applies_to: [Slack]using the existingpost-slack.shdispatcher (warn-only by construction). Catchesposthog,dashboard,instrumentationkeywords in messages.block-sibling-suffix-storage — Bash CLI surface only (
supabase storage upload,gcloud storage cp,gsutil cp). Application-layerstorage-uploader paths are covered in code review.
warn-localhost-in-pr-body — two AND-chained command-field
conditions (
gh pr create/edit+ inline--body+localhost/
127.0.0.1). The--body-fileform is naturally excluded.Test count
```
Before: 136 / 136 passed (24 rules)
After: 171 / 171 passed (30 rules, +35 new tests)
```
Each rule covers positive matches across realistic invocations,
negative matches that should NOT fire, and bypass-marker behavior. The
schema extension was verified independently before adding rules: all
136 baseline tests pass after the
parse-input.sh+eval-rule.shedits alone.
Version bump
`1.9.0 -> 1.10.0`
Per semver, adding new enforcement rules is a feature add -> minor bump.
Bumped in:
Test plan
Notes for reviewers
blocks, this PR Tier 2 warns).
parse-input.sh/eval-rule.shchange since the manifest landed; the change isstrictly additive.
warn-time-estimates-in-planstest fixture intentionally usesinline content (no `\n` literals) — YAML single-quoted strings keep
`\n` as a literal backslash-n which prevents the `\b` regex
boundary from firing. Reviewers verifying patterns should keep this
in mind.
OpenSpec: `openspec/changes/2026-05-hooks-tier-2-discipline-warnings/`
(proposal, design, tasks).
Created by Claude Code on behalf of @lapc506
Claude Code