claude-dev-env 1.80.0 → 1.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
- package/audit-rubrics/CLAUDE.md +3 -2
- package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
- package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
- package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
- package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
- package/audit-rubrics/prompts/CLAUDE.md +2 -1
- package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
- package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/code_rules_annotations_length.py +59 -11
- package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
- package/hooks/blocking/code_rules_command_dispatch.py +140 -0
- package/hooks/blocking/code_rules_docstrings.py +93 -0
- package/hooks/blocking/code_rules_enforcer.py +54 -4
- package/hooks/blocking/code_rules_js_conventions.py +246 -0
- package/hooks/blocking/code_rules_naming_collection.py +5 -0
- package/hooks/blocking/code_rules_test_assertions.py +3 -0
- package/hooks/blocking/code_rules_unused_imports.py +0 -3
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
- package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
- package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +16 -3
- package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +9 -0
- package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
- package/hooks/hooks_constants/js_conventions_constants.py +54 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
- package/skills/autoconverge/SKILL.md +2 -2
- package/skills/autoconverge/reference/convergence.md +3 -3
- package/skills/autoconverge/reference/stop-conditions.md +1 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +2 -2
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +245 -1
- package/skills/autoconverge/workflow/converge.mjs +129 -23
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
- A PowerShell cmdlet is invoked with a parameter that belongs to a different parameter set.
|
|
11
11
|
- A new gate-time validator omits the `all_changed_lines` parameter that peer span-based validators accept, so the dispatcher cannot plumb diff scope through and the check silently over- or under-blocks.
|
|
12
12
|
- A new span-based check applies its result cap before honoring `defer_scope_to_caller=True`, while peer checks return all violations in that mode and let the caller cap; this leaves the new sibling stale against the established pattern.
|
|
13
|
+
- A call passes `PollingIntervals.resume_check` (a poll interval — how often to re-check) into a `progress_check_timeout` parameter (a timeout — how long to wait). The value's role does not match the parameter's role, so the call type-checks while the behavior is wrong.
|
|
13
14
|
|
|
14
15
|
**Companion reference:** see `../source-material-section-types.md` for guidance on how to chunk the artifact under audit.
|
|
15
16
|
|
|
@@ -23,7 +24,7 @@ The decomposition that worked best for PR #394 (a Python+PowerShell scheduled-ta
|
|
|
23
24
|
|
|
24
25
|
| ID | Axis name | Concrete checks |
|
|
25
26
|
|---|---|---|
|
|
26
|
-
| A1 | Python function signatures vs internal call sites | Parameter count, names, defaults, kw-only barriers; every internal call binds correctly. Is the symbol `async def`? Confirm the exact access path a caller uses: free function vs instance method reached through an object attribute vs import path. A keyword-only parameter with no default is required; omitting it raises `TypeError`. |
|
|
27
|
+
| A1 | Python function signatures vs internal call sites | Parameter count, names, defaults, kw-only barriers; every internal call binds correctly. Is the symbol `async def`? Confirm the exact access path a caller uses: free function vs instance method reached through an object attribute vs import path. A keyword-only parameter with no default is required; omitting it raises `TypeError`. When a call passes a config value as an argument, confirm the value's documented role matches the parameter's role — a poll interval is not a timeout, and a timeout is not a budget. |
|
|
27
28
|
| A2 | Python return-type annotation vs every code path | Each function's return annotation is satisfied by every path: explicit `return X`, fall-through, exception-handler exit. The full failure contract is the return value AND every exception raised — trace the body and the docstring `Raises:` for each `raise`, including custom errors. A `-> bool` function that also raises is not fully described by "returns bool". |
|
|
28
29
|
| A3 | argparse parser → Namespace contract | Every `add_argument(...)` produces the exact dest name accessed downstream; `type=` matches downstream usage; switches produce bools. |
|
|
29
30
|
| A4 | Stdlib callback contracts | `os.walk(onerror=...)` callback shape; `os.path.getctime` / `os.rmdir` argument and exception contracts; `time.sleep` argument types. Catch-site precision: for any claim that code "catches X", confirm the exact catch site and scope — an `except` around only a rollback inside `finally` does not catch the same error raised in the `with` body. |
|
|
@@ -30,7 +30,19 @@
|
|
|
30
30
|
| J11 | sys.path.insert dedup | `sys.path.insert(0, X)` must be guarded by `if X not in sys.path:` (test files exempt). |
|
|
31
31
|
| J12 | Hardcoded user paths | String literals naming a specific user's home directory (`C:/Users/jon/...`, `/Users/alice/...`, `/home/bob/...`). Use `pathlib.Path.home()`. |
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
The write-time hook (`code_rules_enforcer.py`) exempts test files (`test_*.py`, `*_test.py`, `*.test.*`, `*.spec.*`, `conftest.py`, paths under `/tests/`) from most Category J sub-buckets, and skips the naming, logging, annotation, and unused-import rules on `.mjs` / `.js` files. J11 (`sys.path.insert`) always applies. Read the next section for the sub-buckets this audit applies more widely than the hook.
|
|
34
|
+
|
|
35
|
+
## Write-time exemptions do not scope this audit
|
|
36
|
+
|
|
37
|
+
The write-time hook skips several rules on test files and on `.mjs` / `.js` files. This audit does not. Apply these rules to every changed line — production files, test files, and JavaScript files alike:
|
|
38
|
+
|
|
39
|
+
- **Naming (J5, J6)** — banned identifiers (`ctx`, `cfg`, `msg`, `result`, `data`, `output`, `value`, `item`, `temp`, `elem`, `val`, …) and banned function prefixes (`handle_`, `process_`, `manage_`, `do_`); boolean names prefixed `is_` / `has_` / `should_` / `can_` / `was_` / `did_`.
|
|
40
|
+
- **Logging format (J9)** — the format string and its arguments pass as separate parameters (`log_*("...", arg)`), never an f-string.
|
|
41
|
+
- **Type annotations (J7)** — every changed function parameter and return is typed; no `Any`, no bare `# type: ignore`.
|
|
42
|
+
- **Unused imports** — a module-level import a changed file does not read is a finding.
|
|
43
|
+
- **Function length** — a changed function that runs past the length threshold splits into named helpers.
|
|
44
|
+
|
|
45
|
+
A `test_*.py` name or a `.mjs` extension takes the line out of the write-time gate, not out of this audit. When the diff touches a test or a JavaScript file, walk these five rules against the changed lines there too. J1 (magic values) and J3 (constants location) keep their test-file exemption.
|
|
34
46
|
|
|
35
47
|
---
|
|
36
48
|
|
|
@@ -15,6 +15,25 @@ The label-vs-body gap is its own failure mode independent of behavior-equivalenc
|
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
+
## Binding counterexample protocol
|
|
19
|
+
|
|
20
|
+
For every regex, brace or token scanner, or word-list the diff adds or edits, build at least three concrete inputs and trace each one through the pattern by hand before you call the pattern clean:
|
|
21
|
+
|
|
22
|
+
- **One intended match** — an input the pattern must catch.
|
|
23
|
+
- **One near-miss** — an input a quick reading would expect to catch, but the contract says the pattern must reject.
|
|
24
|
+
- **One structural edge** — an input whose shape stresses the scanner: a destructured parameter, a quoted key, a nested brace, an escaped delimiter.
|
|
25
|
+
|
|
26
|
+
Walk each input character by character (or token by token) and write down what the pattern returns. A pattern that returns the wrong answer on any of the three inputs is a Category P finding; cite the input that breaks it. The pattern is clean only after all three inputs return the answer the contract promises.
|
|
27
|
+
|
|
28
|
+
`hooks/blocking/` is the priority surface. Its patterns gate every write, so a pattern that matches too much or too little there fires on real edits across the whole codebase.
|
|
29
|
+
|
|
30
|
+
Two canonical failures show why the hand trace is binding:
|
|
31
|
+
|
|
32
|
+
- **Brace scan that reads a parameter as body.** A scanner counts braces from the signature's opening `(` and treats the first balanced close as the end of the function body. A destructured parameter closes a brace inside the signature — `renderRow({ id, label })` in JavaScript, or a `{a, b}` binding in another language — so the scanner marks the body one token too early and reads the parameter list as body code.
|
|
33
|
+
- **`\bschema\b` that matches a value reference.** A `\bschema\b` pattern targets a `schema` declaration, yet it also matches `schema` as the value in `{ label: schema }`, where the word is a reference, not the declaration the pattern means to find. A word boundary alone does not tell a declaration from a key or a value.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
18
37
|
## Sub-bucket decomposition (Category P)
|
|
19
38
|
|
|
20
39
|
Decomposition is by the **kind of identifier / reference data** whose label is being audited against its body.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Category Q — Cross-surface claim consistency (terminology, PR-description claims, message-vs-guard)
|
|
2
|
+
|
|
3
|
+
**What this category audits:** claims a change makes on one surface that a second surface contradicts. A code field carries one spelling while the prose that names it uses a near-miss variant. A PR description states a fact that the diff or the repo does not bear out. A log or error string asserts a condition that the branch gating it does not guarantee. Each defect lives in the gap between two surfaces that must agree: the code and the prose about it, the PR body and the diff, the message text and the guard that fires it.
|
|
4
|
+
|
|
5
|
+
**Why this category is its own bucket:** each surface reads as correct on its own. The code field is a valid identifier. The PR sentence is a well-formed claim. The log line is grammatical. The defect surfaces only when a reader holds two surfaces side by side and finds that one says a thing the other denies. Categories that read a single surface miss this; Q forces the reader to pair every claim with the surface that must back it.
|
|
6
|
+
|
|
7
|
+
## Canonical examples
|
|
8
|
+
|
|
9
|
+
- **PR #810 (Q1).** Eleven prose surfaces name the term one way while the code field reads `premium_interactions`. One divergent term spreads across README text, doc tables, and inline prose, so a reader who trusts any of the eleven surfaces reaches for a field name the code does not define.
|
|
10
|
+
- **PR #808 (Q2).** The PR description claims zero remaining gate subjects while `converge.mjs` still holds them. The body asserts a cleanup the diff does not finish, so a reviewer who trusts the description approves a state the code contradicts.
|
|
11
|
+
- **PR #823 (Q3).** A skip-branch log asserts a hardening PR opened, yet `spawnStandardsFollowUp` can return `hardeningPrOpened: false`. The message states an outcome the guard that reaches it does not guarantee, so the log reads as a success the run did not achieve.
|
|
12
|
+
|
|
13
|
+
## Other typical patterns
|
|
14
|
+
|
|
15
|
+
- A config key the diff adds reads `retry_budget`; a doc table row names it `retry-budget`, and a runbook names it `retry budget`.
|
|
16
|
+
- An API field the diff introduces is `itemCount` (singular tail); a client doc calls it `itemCounts`.
|
|
17
|
+
- A PR body says "this removes the last caller of `legacy_resolve`"; a reference search still finds a caller in a sibling module.
|
|
18
|
+
- A PR body claims a 30-second ceiling; the config value the diff sets reads 60 seconds.
|
|
19
|
+
- An error string says "config file is missing" while the branch that raises it fires on a parse failure, not an absent file.
|
|
20
|
+
- A log line says "retry limit reached this run" while the counter it reads resets each round, so the wording overstates the lifetime of the state it names.
|
|
21
|
+
|
|
22
|
+
**Companion reference:** see `../source-material-section-types.md`.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Sub-bucket decomposition (Category Q)
|
|
27
|
+
|
|
28
|
+
Decomposition is by the **pair of surfaces** that must agree.
|
|
29
|
+
|
|
30
|
+
| ID | Axis name | Concrete checks |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| Q1 | Term sweep | Pull out every identifier, API field, and config key the diff introduces or reads. Sweep all changed prose surfaces for near-miss variants — hyphen or space in place of an underscore, singular where the code is plural (or the reverse), a shared prefix with a divergent tail. Run `packages/claude-dev-env/_shared/pr-loop/scripts/terminology_sweep.py` for mechanical recall — it reads a unified diff on stdin or via `--diff-file`, prints one `file:line` finding per near-miss, exits 1 when findings exist and 0 when clean — then adjudicate each finding, since a near-miss can be an intentional distinct term. |
|
|
33
|
+
| Q2 | PR-description claim verification | Fetch the PR body. For each falsifiable claim, verify it two ways against the diff and the repo. A dead-code claim ("removes the last caller of X") gets a reference search across the repo. A performance or ceiling claim ("caps at 30 seconds", "cuts calls in half") gets checked against the actual config values and call sites the diff sets. |
|
|
34
|
+
| Q3 | Message-vs-guard consistency | For every log, error, and evidence string the diff adds or edits, read the condition that gates it. The message text matches what the guard guarantees — a string that asserts an outcome fires only on a branch that reaches that outcome. Temporal wording matches the lifetime of the state variable it names: "this run" versus "this round", "is open" versus "was opened", "reached" versus "attempted". |
|
|
35
|
+
|
|
36
|
+
Customize per-artifact: a change that adds no new identifier and no PR-body claim reduces Q to "verify every message the diff touches still matches its guard." A rename that spreads a term across many docs may need Q1 alone to list every prose surface.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Sample prompt
|
|
41
|
+
|
|
42
|
+
The reusable template for Category Q is in [`../prompts/category-q-cross-surface-claims.md`](../prompts/category-q-cross-surface-claims.md). The Category Q source-material block needs the diff, the PR body text, and the changed prose surfaces the agent must cross-reference against the code.
|
|
43
|
+
|
|
44
|
+
## Why Category Q matters as its own bucket
|
|
45
|
+
|
|
46
|
+
A reviewer walking a single surface at a time — the code alone, the docs alone, the PR body alone — judges each on its own merits and misses Q. The code field is a valid name; the PR sentence is a plausible claim; the log line is grammatical. Only pairing each claim with the surface that must back it surfaces the gap. Q forces that pairing: every identifier the diff touches is swept against the prose, every PR-body claim is checked against the diff and the repo, and every message is read against the guard that fires it.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# audit-rubrics/prompts
|
|
2
2
|
|
|
3
|
-
Agent-ready audit prompt templates, one per category (A–
|
|
3
|
+
Agent-ready audit prompt templates, one per category (A–Q). An agent inlines the artifact under review into the `[INLINE THE FULL ARTIFACT HERE]` placeholder and runs the prompt as-is.
|
|
4
4
|
|
|
5
5
|
## Files
|
|
6
6
|
|
|
@@ -22,6 +22,7 @@ Agent-ready audit prompt templates, one per category (A–P). An agent inlines t
|
|
|
22
22
|
| `category-n-test-name-scenario-verifier.md` | N — Test name / scenario verifier |
|
|
23
23
|
| `category-o-docstring-vs-impl-drift.md` | O — Docstring vs implementation drift |
|
|
24
24
|
| `category-p-name-vs-behavior-contract.md` | P — Name vs behavior contract |
|
|
25
|
+
| `category-q-cross-surface-claims.md` | Q — Cross-surface claim consistency |
|
|
25
26
|
|
|
26
27
|
## Prompt structure
|
|
27
28
|
|
|
@@ -16,6 +16,7 @@ ID prefix: `find`.
|
|
|
16
16
|
- Flag calls that omit a required parameter relying on a default that does not exist on the current branch.
|
|
17
17
|
- Verify decorators (`@staticmethod`, `@classmethod`, `@property`) do not silently shift the parameter binding (e.g., `self` / `cls` insertion).
|
|
18
18
|
- Confirm sync-vs-async (is the symbol `async def`?), the exact access path a caller uses (free function vs instance method via an object attribute vs import path), and that a keyword-only parameter with no default is required — omitting it raises `TypeError`.
|
|
19
|
+
- When a call passes a config value as an argument, confirm the value's documented role matches the parameter's role (poll interval vs timeout vs budget). A poll interval such as `PollingIntervals.resume_check` handed to a `progress_check_timeout` parameter type-checks but carries the wrong meaning.
|
|
19
20
|
|
|
20
21
|
**A2. Return-type annotation vs every code path**
|
|
21
22
|
- For each annotated function, walk every code path: explicit `return X`, fall-through to implicit `None`, exception-handler exit, generator `yield` paths, async coroutine return value.
|
|
@@ -11,10 +11,22 @@ ID prefix: `find`.
|
|
|
11
11
|
|
|
12
12
|
## Source material
|
|
13
13
|
|
|
14
|
-
Inline the artifact (full diff or full file contents) under a clearly delimited block below this section. Use the chunking guide in [`../source-material-section-types.md`](../source-material-section-types.md) to choose the right Source-material section type (full-diff, file-set, patch-series, or excerpt-with-context). Mark every line range that is in scope; mark explicitly which files are test files
|
|
14
|
+
Inline the artifact (full diff or full file contents) under a clearly delimited block below this section. Use the chunking guide in [`../source-material-section-types.md`](../source-material-section-types.md) to choose the right Source-material section type (full-diff, file-set, patch-series, or excerpt-with-context). Mark every line range that is in scope; mark explicitly which files are test files and which are production.
|
|
15
15
|
|
|
16
16
|
Replace this paragraph with the chunked source material before issuing the prompt.
|
|
17
17
|
|
|
18
|
+
## Write-time exemptions do not scope this audit
|
|
19
|
+
|
|
20
|
+
The write-time hook skips several rules on test files and on `.mjs` / `.js` files; this audit does not. Apply these rules to every changed line — production, test, and JavaScript files alike:
|
|
21
|
+
|
|
22
|
+
- Naming (J5, J6) — banned identifiers and banned function prefixes; boolean names prefixed `is_` / `has_` / `should_` / `can_` / `was_` / `did_`.
|
|
23
|
+
- Logging format (J9) — the format string and its arguments pass as separate parameters, never an f-string.
|
|
24
|
+
- Type annotations (J7) — every changed parameter and return is typed; no `Any`, no bare `# type: ignore`.
|
|
25
|
+
- Unused imports — a module-level import a changed file does not read.
|
|
26
|
+
- Function length — a changed function past the length threshold splits into named helpers.
|
|
27
|
+
|
|
28
|
+
A `test_*.py` name or a `.mjs` extension takes the line out of the write-time gate, not out of this audit. J1 (magic values) and J3 (constants location) keep their test-file exemption; the five rules above do not.
|
|
29
|
+
|
|
18
30
|
## Sub-buckets (each requires Shape A finding OR Shape B with ≥3 adversarial probes)
|
|
19
31
|
|
|
20
32
|
**J1. Magic values in production function bodies**
|
|
@@ -40,18 +52,18 @@ Replace this paragraph with the chunked source material before issuing the promp
|
|
|
40
52
|
- Adversarial probes: (a) is any imported constant referenced only once in the importing file (suggesting the import itself is gratuitous)? (b) is any helper function defined in a production file but never called from inside the same file (separate dead-code concern, surfaced here for completeness)? (c) does any constant in `config/` get imported from zero call sites across the repo?
|
|
41
53
|
|
|
42
54
|
**J5. Abbreviations**
|
|
43
|
-
- Walk every parameter, local, and attribute name
|
|
44
|
-
-
|
|
55
|
+
- Walk every parameter, local, and attribute name across production, test, and JavaScript changed lines. Flag: `ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `elem`, `val`, `tmp`, `str`, `num`, `arr`, `obj`, `fn`, `cb`, `req`, `res`. Loop counters `i`/`j`/`k` and `e` for exceptions are exempt.
|
|
56
|
+
- This audit walks changed test-file and `.mjs` / `.js` lines for this rule, even though the write-time hook skips them.
|
|
45
57
|
- Adversarial probes: (a) is there a borderline name (e.g., `removed`, `arguments`) that someone might mis-classify as an abbreviation but is actually a full English word? Confirm. (b) does any callback / parameter / attribute use a short variant of a domain term that is technically a full word but conventionally abbreviates a longer one? (c) does any variable in a comprehension or lambda use a single letter outside the `i`/`j`/`k`/`e` exemption?
|
|
46
58
|
|
|
47
59
|
**J6. Vague names**
|
|
48
60
|
- Flag any name from the vague list: `result`, `data`, `output`, `response`, `value`, `item`, `temp`, `info`, `stuff`, `thing`. Vague verb prefixes for function names: `handle`, `process`, `manage`, `do`.
|
|
49
|
-
-
|
|
61
|
+
- This audit walks changed test-file and `.mjs` / `.js` lines for this rule, even though the write-time hook skips them.
|
|
50
62
|
- Adversarial probes: (a) does any local variable use a domain-adjacent name that is actually on the vague list (e.g., `result` from a parser, `data` from a fetch)? (b) does any newly-introduced function name start with a vague prefix? (c) does any public attribute / dict key use a vague label that the call site has to disambiguate by surrounding context?
|
|
51
63
|
|
|
52
64
|
**J7. Type hints**
|
|
53
|
-
- Walk every function
|
|
54
|
-
-
|
|
65
|
+
- Walk every function across production, test, and JavaScript changed lines. Verify parameter and return types are present, no `Any`, no `# type: ignore`.
|
|
66
|
+
- This audit walks changed test-file and `.mjs` / `.js` lines for this rule, even though the write-time hook skips them.
|
|
55
67
|
- Adversarial probes: (a) does any production function rely on inferred return type from a single `return` path? (b) does any parameter use a string-quoted forward reference that masks `Any`? (c) is there a `# type: ignore` anywhere? Grep the diff explicitly.
|
|
56
68
|
|
|
57
69
|
**J8. New inline comments**
|
|
@@ -64,7 +76,7 @@ Replace this paragraph with the chunked source material before issuing the promp
|
|
|
64
76
|
**J9. Logging format**
|
|
65
77
|
- Walk every `log_*(...)` call. Must be `log_*("template with {}", arg)`, not `log_*(f"...")`.
|
|
66
78
|
- The rule applies to the project's structured `log_*` family, not stdlib `print`. `print` f-strings are J2-scope (string-template magic), not J9-scope.
|
|
67
|
-
-
|
|
79
|
+
- This audit walks changed test-file and `.mjs` / `.js` lines for this rule, even though the write-time hook skips them.
|
|
68
80
|
- Adversarial probes: (a) is there any imported `log_*` function in production code that uses an f-string? (b) is there a logger-equivalent call (e.g., `logger.info(f"...")` from `logging` stdlib) that should be subject to the same rule? (c) does any non-Python logger family (e.g., `console.log`, `Write-Host`, structured-log helpers) appear with a template-string pattern that mirrors the J9 anti-pattern?
|
|
69
81
|
|
|
70
82
|
**J10. Imports inside functions**
|
|
@@ -19,6 +19,20 @@ ID prefix: `find`.
|
|
|
19
19
|
|
|
20
20
|
[INLINE each fresh identifier in context — the constant declaration with the value, the function signature with the body, the flag with every assignment / reset site, the regex source with surrounding usage.]
|
|
21
21
|
|
|
22
|
+
## Binding counterexample protocol (run before every Shape B "clean" verdict)
|
|
23
|
+
|
|
24
|
+
For every regex, brace or token scanner, or word-list the diff adds or edits, build at least three concrete inputs and trace each one through the pattern by hand:
|
|
25
|
+
|
|
26
|
+
- One intended match the pattern must catch.
|
|
27
|
+
- One near-miss the pattern must reject (hyphen vs underscore, a value reference vs a declaration, a substring of a longer word).
|
|
28
|
+
- One structural edge that stresses the scanner: a destructured parameter, a quoted key, a nested brace, an escaped delimiter.
|
|
29
|
+
|
|
30
|
+
Record what the pattern returns for each input. A wrong answer on any input is a Shape A finding, cited with the breaking input; a clean verdict is earned only after all three inputs return the contract's answer. `hooks/blocking/` is the priority surface — its patterns gate every write.
|
|
31
|
+
|
|
32
|
+
Two canonical failures to probe for:
|
|
33
|
+
- A brace scan that counts from the signature `(` and reads a destructured parameter (`renderRow({ id, label })`) as the start of the function body.
|
|
34
|
+
- A `\bschema\b` pattern that matches the value in `{ label: schema }`, not just the `schema` declaration it targets.
|
|
35
|
+
|
|
22
36
|
## Sub-buckets (each requires Shape A finding OR Shape B with ≥3 adversarial probes)
|
|
23
37
|
|
|
24
38
|
**P1. Boolean / flag names assert state the body keeps**
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category Q only** (cross-surface claim consistency — terminology, PR-description claims, message-vs-guard). Skip A–P. Sub-bucket forced-exhaustion mode: Category Q is decomposed into 3 sub-buckets below. Each sub-bucket needs at least one Shape A finding OR exactly one Shape B proof-of-absence with **at least 3 adversarial probes** specific to that sub-bucket. A sub-bucket returning neither is a protocol gap.
|
|
2
|
+
|
|
3
|
+
[ARTIFACT METADATA]
|
|
4
|
+
|
|
5
|
+
- Title / one-line summary: [TITLE]
|
|
6
|
+
- Base ref / SHA (state before the change): [BASE_SHA]
|
|
7
|
+
- Head ref / SHA at audit time (state after the change): [HEAD_SHA]
|
|
8
|
+
- Changed surfaces (file + line range + symbol/region name): [CHANGED_SURFACES]
|
|
9
|
+
- New or read identifiers, API fields, and config keys the diff touches: [DIFF_TERMS]
|
|
10
|
+
- Changed prose surfaces (README, doc tables, inline prose, log and error strings): [CHANGED_PROSE_SURFACES]
|
|
11
|
+
- PR description body text: [PR_BODY]
|
|
12
|
+
- Stated intent of the change: [INTENT]
|
|
13
|
+
|
|
14
|
+
ID prefix: `find`.
|
|
15
|
+
|
|
16
|
+
[ONE-PARAGRAPH FRAME: describe what the diff changed in plain English, naming the identifiers and config keys it introduces, the PR-body claims it makes, and the messages it adds or edits. State the audit goal: find any surface whose wording contradicts another surface that must agree with it — a prose term that misspells a code field, a PR-body claim the diff or repo does not bear out, or a message that asserts more than the guard gating it guarantees.]
|
|
17
|
+
|
|
18
|
+
## Source material ([N] files/sections, all lines in scope)
|
|
19
|
+
|
|
20
|
+
[INLINE THE FULL DIFF — the changed lines and enough surrounding context to show each identifier's definition and each message's guard.]
|
|
21
|
+
|
|
22
|
+
[ALSO INLINE the PR description body text and every changed prose surface — README sections, doc tables, runbooks, log and error strings — so the agent can pair each claim with the code it names.]
|
|
23
|
+
|
|
24
|
+
## Sub-buckets (each needs a Shape A finding OR Shape B with ≥3 adversarial probes)
|
|
25
|
+
|
|
26
|
+
**Q1. Term sweep**
|
|
27
|
+
- Pull out every identifier, API field, and config key the diff introduces or reads. For each, sweep all changed prose surfaces for a near-miss variant — a hyphen or a space in place of an underscore, a singular where the code is plural (or the reverse), a shared prefix with a divergent tail.
|
|
28
|
+
- Run `packages/claude-dev-env/_shared/pr-loop/scripts/terminology_sweep.py` for mechanical recall. It reads a unified diff on stdin or via `--diff-file`, prints one `file:line` finding per near-miss, exits 1 when findings exist and 0 when clean. Feed it the PR diff, then adjudicate each finding: a near-miss can be an intentional distinct term, so cite the code definition and the prose line and judge whether they name the same thing.
|
|
29
|
+
- Adversarial probes when the sweep is clean: (a) check camelCase versus snake_case spellings of the same field across code and docs; (b) check singular/plural drift on collection field names; (c) check a shared-prefix family (`retry_budget`, `retry_count`, `retry_limit`) for a doc that names the wrong member.
|
|
30
|
+
|
|
31
|
+
**Q2. PR-description claim verification**
|
|
32
|
+
- Read the PR body. List each falsifiable claim — a dead-code removal, a performance or ceiling number, a "last caller" assertion, a "no remaining X" cleanup claim.
|
|
33
|
+
- Verify each claim two ways against the diff and the repo. A dead-code claim gets a reference search across the repo for the named symbol. A ceiling or performance claim gets checked against the actual config value the diff sets and the call sites it touches. Cite the PR-body line and the diff or repo line that confirms or contradicts it.
|
|
34
|
+
- Adversarial probes when the body looks correct: (a) search the repo for a surviving caller of any symbol the body calls dead; (b) compare each stated number against the config constant the diff sets; (c) check whether a "this finishes the cleanup" claim leaves a matching pattern in an unchanged sibling file.
|
|
35
|
+
|
|
36
|
+
**Q3. Message-vs-guard consistency**
|
|
37
|
+
- For every log, error, and evidence string the diff adds or edits, read the branch condition that gates it. The message text matches what that guard guarantees: a string asserting an outcome fires only on a branch that reaches the outcome.
|
|
38
|
+
- Check temporal wording against the lifetime of the state variable the message names: "this run" versus "this round", "is open" versus "was opened", "reached" versus "attempted". Cite the message line and the guard or state-variable line.
|
|
39
|
+
- Adversarial probes when messages look aligned: (a) trace each success message back to the return value or flag it claims and confirm the branch cannot reach it with the opposite value; (b) check every "missing" / "not found" error against the exception class the branch actually catches; (c) check every counter-based message against where the counter resets.
|
|
40
|
+
|
|
41
|
+
## Cross-bucket questions to answer at the end
|
|
42
|
+
|
|
43
|
+
Q-a: Which single claim in this change is most likely to mislead a reader at runtime — a prose term that names a field the code does not define, a PR-body fact the diff contradicts, or a message that overstates its guard? Cite both surfaces by `path:line`.
|
|
44
|
+
|
|
45
|
+
Q-b: Which surface is the strongest witness to the contradiction — the doc, PR body, or message a reader would trust while the code says otherwise?
|
|
46
|
+
|
|
47
|
+
Q-c: Is there a term the diff introduces that appears correct in the code yet drifts across two or more prose surfaces at once? Cite each prose line and the code definition.
|
|
48
|
+
|
|
49
|
+
## Output
|
|
50
|
+
|
|
51
|
+
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket Q1-Q3, produce Shape A or Shape B (with ≥3 probes). Each Shape A finding cites BOTH surfaces — the claim and the surface that contradicts it — by `path:line`. The `failure_mode` describes the contradiction between the two surfaces. Cross-bucket Q-a through Q-c answers after the per-sub-bucket walk. Adversarial second pass: "assume your first pass missed at least 3 cross-surface contradictions — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -23,7 +23,7 @@ Compact reference for agents. ⚡ marks rules enforced by `code_rules_enforcer.p
|
|
|
23
23
|
|
|
24
24
|
`code_rules_enforcer.py` blocks each of these at Write/Edit and explains the specific violation when it fires; exact patterns and exemption lists live in the hook:
|
|
25
25
|
|
|
26
|
-
no new comments · imports at top · logging format args (`log_*("...", arg)`) · no `%s`/`%d` printf tokens in a `str.format`-logger message (`log_*` imported from `automation_logging`; `str.format` drops the args — use `{}`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`, a parameter typed bare `object` whose body reads `param.attribute`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …) · known pytest fixture parameters a test function declares but never references (drop the unused parameter — pytest still pays its setup cost)
|
|
26
|
+
no new comments · imports at top · logging format args (`log_*("...", arg)`) · no `%s`/`%d` printf tokens in a `str.format`-logger message (`log_*` imported from `automation_logging`; `str.format` drops the args — use `{}`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`, a parameter typed bare `object` whose body reads `param.attribute`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …) · known pytest fixture parameters a test function declares but never references (drop the unused parameter — pytest still pays its setup cost) · JavaScript/TypeScript boolean declarations (`const`/`let`/`var` bound to a boolean literal or negation) and `@param {boolean}` JSDoc names prefixed `is`/`has`/`should`/`can`/`was`/`did` (camelCase forms) · banned identifiers as `.mjs`/`.js` declaration names (`result`, `data`, `ctx`, `msg`, …), scoped to changed lines · in test files, banned identifiers and unused module-level imports fire on changed lines, and pytest-collectable `test_*` functions need a return annotation · a `hooks/blocking/` command classifier anchors its multi-word command regex to the command start (`^`/`\A`) or tokenizes the first word (`shlex.split`), never matching a command as a bare substring
|
|
27
27
|
|
|
28
28
|
Test files are exempt from most checks. The one annotation the test-file exemption does NOT cover is a known pytest builtin fixture parameter: `tmp_path`, `monkeypatch`, `capsys`, `capfd`, `caplog`, `request`, and `tmp_path_factory` each have a single documented injected type, so the gate requires that annotation (`tmp_path: Path`) even inside a test file. The same set of fixtures is also subject to a use check: a pytest-collected test function that declares one of these parameters and never references it in its body fails the gate, because pytest materializes the fixture's setup (the temp directory, the monkeypatch context, the output capture) on every run whether or not the body reads the value — drop the unused parameter. A parameter counts as referenced when its name is read, augmented-assigned, or deleted anywhere in the body, including inside a nested function or comprehension. Only pytest-collectable functions are inspected — those at module top level or defined directly in a class body; a function nested inside another function's body is a local helper pytest never collects, so its fixture-named parameter is exempt. A `@pytest.fixture`-decorated function is exempt from the use check, since injecting one fixture into another purely to order its setup is intentional. Ordinary test parameters stay exempt from both checks. See also the file-global constants use-count rule: [`rules/file-global-constants.md`](../rules/file-global-constants.md).
|
|
29
29
|
|
|
@@ -94,4 +94,4 @@ If you already have the data, don't fetch it again.
|
|
|
94
94
|
|
|
95
95
|
## 11. ENFORCEMENT SURFACES
|
|
96
96
|
|
|
97
|
-
⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–
|
|
97
|
+
⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–Q) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md` and `skills/code/SKILL.md`; each names its own promotion path. The docstring-prose standard (free-form enumerations match the body) lives in `packages/claude-dev-env/rules/docstring-prose-matches-implementation.md`, enforced via Category O6 audit.
|
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -23,6 +23,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
23
23
|
| `code_rules_annotations_length.py` | Parameter/return annotations, function length, pytest fixture annotation requirements |
|
|
24
24
|
| `code_rules_banned_identifiers.py` | Banned short names (`ctx`, `cfg`, `msg`, etc.), banned prefixes (`handle_`, `process_`, etc.) |
|
|
25
25
|
| `code_rules_boolean_mustcheck.py` | Boolean naming (`is_`/`has_`/… prefixes) and must-check return values |
|
|
26
|
+
| `code_rules_command_dispatch.py` | A `hooks/blocking/` command classifier matching a multi-word command regex without a start anchor or first-word tokenization |
|
|
26
27
|
| `code_rules_comments.py` | No new inline comments; no deletion of existing ones |
|
|
27
28
|
| `code_rules_constants_config.py` | Constants must live in `config/`; file-global constant use-count |
|
|
28
29
|
| `code_rules_dead_argparse_argument.py` | Argparse arguments with no references in the same file |
|
|
@@ -33,6 +34,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
33
34
|
| `code_rules_docstrings.py` | Google-style docstrings; `Args:` section matches signature; fallback-branch coverage |
|
|
34
35
|
| `code_rules_duplicate_body.py` | A function body copied from a sibling module, or a helper body inlined as a block inside a larger function in the same file |
|
|
35
36
|
| `code_rules_imports_logging.py` | Imports at top of file; logging format-arg style; printf tokens in `str.format`-logger messages |
|
|
37
|
+
| `code_rules_js_conventions.py` | Boolean-prefix naming and banned identifiers for JavaScript/TypeScript declarations and `@param {boolean}` JSDoc, scoped to changed lines |
|
|
36
38
|
| `code_rules_magic_values.py` | No magic numbers or strings in production code bodies |
|
|
37
39
|
| `code_rules_mock_completeness.py` | Mock calls that skip required arguments |
|
|
38
40
|
| `code_rules_naming_collection.py` | Collection names must use `all_*` prefix |
|
|
@@ -384,24 +384,72 @@ def check_unused_known_pytest_fixture_parameters(
|
|
|
384
384
|
return issues
|
|
385
385
|
|
|
386
386
|
|
|
387
|
-
def check_return_annotations(
|
|
388
|
-
|
|
389
|
-
|
|
387
|
+
def check_return_annotations(
|
|
388
|
+
content: str,
|
|
389
|
+
file_path: str,
|
|
390
|
+
all_changed_lines: set[int] | None = None,
|
|
391
|
+
defer_scope_to_caller: bool = False,
|
|
392
|
+
) -> list[str]:
|
|
393
|
+
"""Flag functions missing a return type annotation, scoped to changed lines.
|
|
394
|
+
|
|
395
|
+
In a production file every function definition needs a return annotation. In
|
|
396
|
+
a test file the requirement narrows to pytest-collectable test functions —
|
|
397
|
+
``test_*`` at module top level or in a class body — which typically declare
|
|
398
|
+
``-> None``; an ordinary helper in a test file stays exempt. Findings scope
|
|
399
|
+
to *all_changed_lines* so a pre-existing unannotated function on an untouched
|
|
400
|
+
line never blocks while a newly written one does.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
content: The source text to inspect — the reconstructed full file on an
|
|
404
|
+
Edit so the parse succeeds.
|
|
405
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
406
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
407
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
408
|
+
blocks only when the function's definition line is among the changed
|
|
409
|
+
lines.
|
|
410
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
411
|
+
commit/push gate scopes by added line through its ``Line N:``
|
|
412
|
+
partitioning.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
One issue per function missing a return type annotation, scoped to the
|
|
416
|
+
changed lines unless *defer_scope_to_caller* is True or *all_changed_lines*
|
|
417
|
+
is None.
|
|
418
|
+
"""
|
|
390
419
|
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
391
420
|
return []
|
|
392
421
|
try:
|
|
393
422
|
tree = ast.parse(content)
|
|
394
423
|
except SyntaxError:
|
|
395
424
|
return []
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
425
|
+
if is_test_file(file_path):
|
|
426
|
+
candidate_functions: list[ast.FunctionDef | ast.AsyncFunctionDef] = [
|
|
427
|
+
each_node
|
|
428
|
+
for each_node in _collect_pytest_collectable_functions(tree)
|
|
429
|
+
if _is_pytest_test_function(each_node)
|
|
430
|
+
]
|
|
431
|
+
else:
|
|
432
|
+
candidate_functions = [
|
|
433
|
+
each_node
|
|
434
|
+
for each_node in ast.walk(tree)
|
|
435
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
436
|
+
]
|
|
437
|
+
all_violations_in_source_order: list[tuple[range, str]] = []
|
|
438
|
+
for each_node in candidate_functions:
|
|
439
|
+
if each_node.returns is not None:
|
|
399
440
|
continue
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
441
|
+
message = (
|
|
442
|
+
f"Line {each_node.lineno}: function {each_node.name!r} missing "
|
|
443
|
+
"return type annotation (CODE_RULES §6)"
|
|
444
|
+
)
|
|
445
|
+
all_violations_in_source_order.append(
|
|
446
|
+
(range(each_node.lineno, each_node.lineno + 1), message)
|
|
447
|
+
)
|
|
448
|
+
return _scope_violations_to_changed_lines(
|
|
449
|
+
all_violations_in_source_order,
|
|
450
|
+
all_changed_lines,
|
|
451
|
+
defer_scope_to_caller,
|
|
452
|
+
)
|
|
405
453
|
|
|
406
454
|
|
|
407
455
|
def check_function_length(
|
|
@@ -118,9 +118,39 @@ def _collect_banned_names_from_node(node: ast.AST) -> list[ast.Name]:
|
|
|
118
118
|
return []
|
|
119
119
|
|
|
120
120
|
|
|
121
|
-
def check_banned_identifiers(
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
def check_banned_identifiers(
|
|
122
|
+
content: str,
|
|
123
|
+
file_path: str,
|
|
124
|
+
all_changed_lines: set[int] | None = None,
|
|
125
|
+
defer_scope_to_caller: bool = False,
|
|
126
|
+
) -> list[str]:
|
|
127
|
+
"""Flag assignments to identifiers banned by the project Naming rules.
|
|
128
|
+
|
|
129
|
+
Test files are scoped rather than exempt: a banned identifier a test binds
|
|
130
|
+
is flagged only when its binding line is among the changed lines, so a
|
|
131
|
+
pre-existing ``result`` on an untouched line never blocks an edit while a
|
|
132
|
+
newly written one does. Production files scope the same way, and a new-file
|
|
133
|
+
or full-file write treats every line as changed. Hook infrastructure stays
|
|
134
|
+
exempt.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
content: The source text to inspect — the reconstructed full file on an
|
|
138
|
+
Edit so the parse succeeds and the diff scoping is meaningful.
|
|
139
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
140
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
141
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
142
|
+
blocks only when its binding line is among the changed lines.
|
|
143
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
144
|
+
commit/push gate scopes by added line through its ``Line N:``
|
|
145
|
+
partitioning.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
One issue per banned binding, scoped to the changed lines unless
|
|
149
|
+
*defer_scope_to_caller* is True or *all_changed_lines* is None. The
|
|
150
|
+
terminal result is capped at the module limit; the deferred result is
|
|
151
|
+
uncapped so the gate can scope by added line and apply its own ceiling.
|
|
152
|
+
"""
|
|
153
|
+
if is_hook_infrastructure(file_path):
|
|
124
154
|
return []
|
|
125
155
|
|
|
126
156
|
try:
|
|
@@ -135,15 +165,24 @@ def check_banned_identifiers(content: str, file_path: str) -> list[str]:
|
|
|
135
165
|
|
|
136
166
|
banned_name_nodes.sort(key=lambda each_name: (each_name.lineno, each_name.col_offset))
|
|
137
167
|
|
|
138
|
-
|
|
168
|
+
all_violations_in_source_order: list[tuple[range, str]] = []
|
|
139
169
|
for each_name in banned_name_nodes:
|
|
140
|
-
|
|
141
|
-
f"Line {each_name.lineno}: Banned identifier '{each_name.id}' -
|
|
170
|
+
message = (
|
|
171
|
+
f"Line {each_name.lineno}: Banned identifier '{each_name.id}' - "
|
|
172
|
+
f"{BANNED_IDENTIFIER_MESSAGE_SUFFIX}"
|
|
173
|
+
)
|
|
174
|
+
all_violations_in_source_order.append(
|
|
175
|
+
(range(each_name.lineno, each_name.lineno + 1), message)
|
|
142
176
|
)
|
|
143
|
-
if len(issues) >= MAX_BANNED_IDENTIFIER_ISSUES:
|
|
144
|
-
break
|
|
145
177
|
|
|
146
|
-
|
|
178
|
+
scoped_issues = _scope_violations_to_changed_lines(
|
|
179
|
+
all_violations_in_source_order,
|
|
180
|
+
all_changed_lines,
|
|
181
|
+
defer_scope_to_caller,
|
|
182
|
+
)
|
|
183
|
+
if defer_scope_to_caller:
|
|
184
|
+
return scoped_issues
|
|
185
|
+
return scoped_issues[:MAX_BANNED_IDENTIFIER_ISSUES]
|
|
147
186
|
|
|
148
187
|
|
|
149
188
|
def _identifier_word_parts(identifier: str) -> list[str]:
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Meta-gate flagging an unanchored multi-word command pattern in a blocker.
|
|
2
|
+
|
|
3
|
+
A hook that classifies a shell command reads the command text and matches it
|
|
4
|
+
against a regex. When that regex names a multi-word command such as
|
|
5
|
+
``gh\\s+pr\\s+(create|edit)`` and is matched with a bare ``re.search`` — no
|
|
6
|
+
start anchor and no first-word tokenization — the pattern matches the command
|
|
7
|
+
as a substring anywhere in the string. A benign command like
|
|
8
|
+
``echo gh pr create --title x`` then trips the gate. This check flags that shape
|
|
9
|
+
at write time on files under ``hooks/blocking/`` so a new blocker anchors its
|
|
10
|
+
command match to the start of the command or tokenizes the first word.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
18
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
19
|
+
if _blocking_directory not in sys.path:
|
|
20
|
+
sys.path.insert(0, _blocking_directory)
|
|
21
|
+
if _hooks_directory not in sys.path:
|
|
22
|
+
sys.path.insert(0, _hooks_directory)
|
|
23
|
+
|
|
24
|
+
from code_rules_shared import ( # noqa: E402
|
|
25
|
+
_scope_violations_to_changed_lines,
|
|
26
|
+
is_test_file,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from hooks_constants.command_dispatch_constants import ( # noqa: E402
|
|
30
|
+
ALL_REGEX_START_ANCHOR_TOKENS,
|
|
31
|
+
COMMAND_DISPATCH_LITERAL_PATTERN,
|
|
32
|
+
COMMAND_DISPATCH_MESSAGE_SUFFIX,
|
|
33
|
+
COMMAND_DISPATCH_PATH_MARKER,
|
|
34
|
+
COMMAND_KEY_ACCESS_PATTERN,
|
|
35
|
+
FIRST_TOKEN_TOKENIZATION_PATTERN,
|
|
36
|
+
MAX_COMMAND_DISPATCH_ISSUES,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_under_hooks_blocking(file_path: str) -> bool:
|
|
41
|
+
"""Return whether the path sits under the ``hooks/blocking`` directory."""
|
|
42
|
+
return COMMAND_DISPATCH_PATH_MARKER in file_path.replace("\\", "/")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _command_literal_is_anchored(literal_value: str, match_start: int) -> bool:
|
|
46
|
+
"""Return whether a start anchor precedes the command word in the pattern.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
literal_value: The regex string literal the command word appears in.
|
|
50
|
+
match_start: The index where the command word match begins.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
True when a ``^`` or ``\\A`` anchor appears before the command word, so
|
|
54
|
+
the pattern binds the command to the start of the string.
|
|
55
|
+
"""
|
|
56
|
+
prefix = literal_value[:match_start]
|
|
57
|
+
return any(each_anchor in prefix for each_anchor in ALL_REGEX_START_ANCHOR_TOKENS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _unanchored_command_literals(
|
|
61
|
+
parsed_tree: ast.AST,
|
|
62
|
+
) -> list[tuple[range, str]]:
|
|
63
|
+
"""Return one span-tagged violation per unanchored command-regex literal.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
parsed_tree: The parsed module to scan for string-constant nodes.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
``(line_range, message)`` pairs in source order.
|
|
70
|
+
"""
|
|
71
|
+
all_violations: list[tuple[range, str]] = []
|
|
72
|
+
for each_node in ast.walk(parsed_tree):
|
|
73
|
+
if not isinstance(each_node, ast.Constant) or not isinstance(
|
|
74
|
+
each_node.value, str
|
|
75
|
+
):
|
|
76
|
+
continue
|
|
77
|
+
literal_match = COMMAND_DISPATCH_LITERAL_PATTERN.search(each_node.value)
|
|
78
|
+
if literal_match is None:
|
|
79
|
+
continue
|
|
80
|
+
if _command_literal_is_anchored(each_node.value, literal_match.start()):
|
|
81
|
+
continue
|
|
82
|
+
end_line = each_node.end_lineno or each_node.lineno
|
|
83
|
+
message = (
|
|
84
|
+
f"Line {each_node.lineno}: command pattern {each_node.value!r} "
|
|
85
|
+
f"{COMMAND_DISPATCH_MESSAGE_SUFFIX}"
|
|
86
|
+
)
|
|
87
|
+
all_violations.append((range(each_node.lineno, end_line + 1), message))
|
|
88
|
+
return all_violations
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_unanchored_command_dispatch(
|
|
92
|
+
content: str,
|
|
93
|
+
file_path: str,
|
|
94
|
+
all_changed_lines: set[int] | None = None,
|
|
95
|
+
defer_scope_to_caller: bool = False,
|
|
96
|
+
) -> list[str]:
|
|
97
|
+
"""Flag an unanchored multi-word command regex in a hooks/blocking file.
|
|
98
|
+
|
|
99
|
+
The check fires only on a file under ``hooks/blocking/`` that reads a
|
|
100
|
+
``command`` key (a shell-command classifier) and does not tokenize the
|
|
101
|
+
command's first word (no ``shlex.split`` or ``.split(`` nearby). Under those
|
|
102
|
+
conditions, a regex string literal naming a known command word followed by
|
|
103
|
+
``\\s+`` without a leading ``^``/``\\A`` anchor is flagged. Findings scope to
|
|
104
|
+
*all_changed_lines* so a pre-existing pattern on an untouched line does not
|
|
105
|
+
block an edit while a newly written one does.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
content: The source text to inspect — the reconstructed full file on an
|
|
109
|
+
Edit so the parse succeeds.
|
|
110
|
+
file_path: The path the source will be written to, used for scoping to
|
|
111
|
+
``hooks/blocking`` and skipping test files.
|
|
112
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
113
|
+
None to treat the whole file as in scope.
|
|
114
|
+
defer_scope_to_caller: When True, return every violation so a downstream
|
|
115
|
+
scoper classifies by added line.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
One issue per unanchored command-regex literal, scoped to the changed
|
|
119
|
+
lines unless *defer_scope_to_caller* is True or *all_changed_lines* is
|
|
120
|
+
None, capped at the module limit.
|
|
121
|
+
"""
|
|
122
|
+
if is_test_file(file_path) or not _is_under_hooks_blocking(file_path):
|
|
123
|
+
return []
|
|
124
|
+
if COMMAND_KEY_ACCESS_PATTERN.search(content) is None:
|
|
125
|
+
return []
|
|
126
|
+
if FIRST_TOKEN_TOKENIZATION_PATTERN.search(content) is not None:
|
|
127
|
+
return []
|
|
128
|
+
try:
|
|
129
|
+
parsed_tree = ast.parse(content)
|
|
130
|
+
except SyntaxError:
|
|
131
|
+
return []
|
|
132
|
+
all_violations_in_source_order = _unanchored_command_literals(parsed_tree)
|
|
133
|
+
scoped_issues = _scope_violations_to_changed_lines(
|
|
134
|
+
all_violations_in_source_order,
|
|
135
|
+
all_changed_lines,
|
|
136
|
+
defer_scope_to_caller,
|
|
137
|
+
)
|
|
138
|
+
if defer_scope_to_caller:
|
|
139
|
+
return scoped_issues
|
|
140
|
+
return scoped_issues[:MAX_COMMAND_DISPATCH_ISSUES]
|