claude-dev-env 1.32.0 → 1.34.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.
@@ -0,0 +1,496 @@
1
+ # Copilot gap analysis
2
+
3
+ This file is the reference record produced by the read-only investigation of why the `/bugteam` audit/fix loop and `bugteam_code_rules_gate.py` repeatedly miss the classes of code-quality violations that the GitHub Copilot reviewer raises on follow-up review rounds. It is written so future bugteam runs can skim the inventory, the rubric/validator coverage diffs, and the patch plan without re-deriving them.
4
+
5
+ Sources of truth cited below: `~/.claude/docs/CODE_RULES.md`, `~/.claude/CLAUDE.md`, `~/.claude/rules/file-global-constants.md`, `~/.claude/skills/bugteam/SKILL.md`, `~/.claude/skills/bugteam/PROMPTS.md`, `~/.claude/skills/bugteam/CONSTRAINTS.md`, `~/.claude/skills/bugteam/scripts/bugteam_code_rules_gate.py`, `~/.claude/skills/bugteam/scripts/bugteam_preflight.py`, `~/.claude/hooks/blocking/code_rules_enforcer.py`, plus `gh api repos/JonEcho/python-automation/pulls/{70,73}/comments` filtered to author `Copilot`.
6
+
7
+ ---
8
+
9
+ ## Investigation report
10
+
11
+ ### Copilot finding inventory
12
+
13
+ Copilot review comments fetched with:
14
+
15
+ ```
16
+ gh api repos/JonEcho/python-automation/pulls/70/comments --paginate --jq '.[] | select(.user.login == "Copilot")'
17
+ gh api repos/JonEcho/python-automation/pulls/73/comments --paginate --jq '.[] | select(.user.login == "Copilot")'
18
+ ```
19
+
20
+ PR #70 head SHA `29117309cf4ec1e83883160d8c819e0843f9c3ac` (merged). PR #73 head SHA `c9c935a96cc59d39d623dc7eddda3d341007607c` (merged); Copilot reviewed at original commit `e4abf52c3a6c724b4e64bfed0d979cd60a2c8bf0`.
21
+
22
+ | finding_id | pr_number | file:line | rule_cited | severity | mapped_bugteam_category_letter | layer_that_should_have_caught_it |
23
+ |---|---|---|---|---|---|---|
24
+ | 3153098661 | 70 | `shared_utils/theme_db/writer.py:158` | Magic values — column-name string literals (`"theme_name"`, `"content_id"`, …) hardcoded inside SQL builder bodies (CODE_RULES.md §⚡ Magic values; J) | P1 | J | bugteam pre-flight gate (`bugteam_code_rules_gate.py`) — string literals are masked by `_mask_string_literals_preserving_length`, so the number-only magic-value detector never sees them; bugteam audit rubric (J) names the rule but the regex passes |
25
+ | 3153098689 | 70 | `shared_utils/theme_db/writer.py:361` | File-length / function-length / SRP smell (`write_outcome` >30 lines, module 446 lines exceeds the 400-line advisory) | P2 | none | initial+final standards-review phases bracketing the loop — no rubric letter exists; the existing 400/1000-line advisory in `code_rules_enforcer.advise_file_line_count` is stderr-only and never blocks |
26
+ | 3153098727 | 70 | `shared_utils/theme_db/summary.py:267` | Library `print()` calls in non-CLI library code (CODE_RULES.md §Self-Documenting Code; "Make output stream explicit" practice) | P1 | none | harness PreToolUse hook (`code_rules_enforcer.py`) — no detector exists for `print(`/`sys.stdout.write` inside library modules |
27
+ | 3153098762 | 70 | `shared_utils/theme_db/summary.py:263` | PR-description spec drift — banner missing column-header rows promised in PR body | P2 | A (loosely) | initial+final standards-review phases — A is signature/async-shaped, not "promised behavior vs implementation" shaped |
28
+ | 3153098782 | 70 | `shared_utils/theme_db/writer.py:125` | Naming clarity — `_is_set_column_value` reads like it excludes `None` but does not | P2 | none | bugteam audit rubric addendum — no naming-clarity category in A–J |
29
+ | 3153475246 | 73 | `shared_utils/theme_db/config/constants.py:91` | Collection naming — `THEMES_INSERT_REQUIRED_COLUMN_NAMES` is a tuple and must use the `ALL_*` prefix (CODE_RULES.md §5 "Extended naming rules" → Collections: `all_orders`, `all_users`) | P1 | none | harness PreToolUse hook AND bugteam pre-flight gate — no detector for the collection-prefix rule. Reproduced at `e4abf52c`; renamed to `ALL_THEMES_INSERT_REQUIRED_COLUMN_NAMES` in the merged PR head |
30
+ | 3153475297 | 73 | `shared_utils/theme_db/writer.py:296` | Collection naming — parameter `column_value_pairs` is a list and must use the `all_*` prefix | P1 | none | harness PreToolUse hook AND bugteam pre-flight gate — same gap as 3153475246 for parameter names |
31
+ | 3153475331 | 73 | `shared_utils/theme_db/summary.py:206` (referenced; underlying defect is in `shared_utils/theme_db/tracker.py` `flush()`) | Wrapper plumb-through — public `tracker.flush(*, output_folder)` silently drops `loud_banner_stream` that `ThemeDatabaseWriteSummary.flush(*, output_folder, loud_banner_stream=None)` accepts | P1 | A (loosely) | bugteam audit rubric addendum — A focuses on signatures/return types, not on whether a wrapper preserves the optional kwargs of the function it delegates to |
32
+
33
+ ### Rubric coverage diff
34
+
35
+ Source: `~/.claude/skills/bugteam/PROMPTS.md` lines 25-38 ("bug_categories"). Each line below names what the category currently asks the bugfind teammate to do, then lists the Copilot finding ids that fell through it.
36
+
37
+ - **A. API contract verification (signatures, return types, async/await correctness)** — checks signatures and types on the function under audit; does not require the bugfind teammate to compare a public wrapper signature against the inner function it delegates to, and does not cover the human-shaped "the function name is misleading" or "the implementation does not match the PR description" cases. Fell through: 3153098689, 3153098727 (no library-print framing), 3153098762, 3153098782, 3153475331.
38
+ - **B. Selector / query / engine compatibility** — none.
39
+ - **C. Resource cleanup and lifecycle (file handles, connections, processes, locks)** — none.
40
+ - **D. Variable scoping, ordering, and unbound references** — none.
41
+ - **E. Dead code: dead parameters, dead locals, dead imports, dead branches, dead returns, and unused imports** — none.
42
+ - **F. Silent failures (catch-all excepts, unconditional success returns, missing error propagation)** — none.
43
+ - **G. Off-by-one, bounds, and integer overflow** — none.
44
+ - **H. Security boundaries (injection, path traversal, auth bypass, secret leakage)** — none.
45
+ - **I. Concurrency hazards (race conditions, missing awaits, shared mutable state)** — none.
46
+ - **J. Magic values and configuration drift** — names the rule but the bugfind teammate has been observed treating numeric literals as the only magic-value class; string literals that are domain identifiers (column names, status enums, table names) repeatedly slip past. Fell through: 3153098661.
47
+
48
+ Categories absent from A–J entirely: collection prefix (`ALL_*` / `all_*`), library `print()` / direct `sys.stdout.write` in non-CLI code, file-length/function-length/SRP smell, naming-clarity (misleading positive name), wrapper plumb-through of optional kwargs, and PR-description vs implementation drift.
49
+
50
+ ### Validator coverage diff
51
+
52
+ Source: every detector inside `~/.claude/hooks/blocking/code_rules_enforcer.py` reused by `~/.claude/skills/bugteam/scripts/bugteam_code_rules_gate.py` via `load_validate_content()` (gate.py:24-40).
53
+
54
+ - `check_comments_python` / `check_comments_javascript` (enforcer.py:103-184) — flag `#` and `//` comments outside exempt markers. Catches none of the eight Copilot findings.
55
+ - `check_comment_changes` (enforcer.py:256-289) — diff-aware comment add/remove. Catches none.
56
+ - `check_imports_at_top` (enforcer.py:292-354) — `import` inside function bodies. Catches none.
57
+ - `check_logging_fstrings` (enforcer.py:357-376) — `log_*(f"...")` and `logger.*(f"...")`. Catches none.
58
+ - `check_windows_api_none` (enforcer.py:402-414) — `win32gui.*(..., None)`. Catches none.
59
+ - `check_magic_values` (enforcer.py:444-491) — number literals inside function bodies, with `_mask_string_literals_preserving_length` (enforcer.py:422-441) blanking string content before the regex runs and `0`, `1`, `-1`, `0.0`, `1.0` allowed. Cannot see string-literal magic values such as `"theme_name"` or `"content_id"`. Misses 3153098661.
60
+ - `check_fstring_structural_literals` (enforcer.py:551-598) — only flags f-strings whose literal portion looks like a path / URL / Windows drive / regex anchor. Plain `("theme_name", theme_name)` tuple entries are not f-strings and are not "structural" by `_has_structural_shape` (enforcer.py:525-548). Misses 3153098661.
61
+ - `check_constants_outside_config` (enforcer.py:735-786) — module-level `UPPER_SNAKE = …` outside `config/`. Files in `config/` are exempt via `is_config_file`, so a constant placed correctly under `config/` is silent. Does not check the `ALL_*` shape on collection-typed constants. Misses 3153475246.
62
+ - `check_constants_outside_config_advisory` (enforcer.py:848-859) — function-local UPPER_SNAKE advisory only. Catches none.
63
+ - `check_file_global_constants_use_count` (enforcer.py:1338-1390) — file-global UPPER_SNAKE used by exactly one caller. Catches none.
64
+ - `check_type_escape_hatches` (enforcer.py:711-726) — `Any` and unjustified `# type: ignore`. Catches none.
65
+ - `check_banned_identifiers` (enforcer.py:908-933) — `result`, `data`, `output`, `response`, `value`, `item`, `temp`. Does not name `column_value_pairs` (it is not a banned word) and does not enforce the inverse "must have a prefix" rule for collections. Misses 3153475297.
66
+ - `check_boolean_naming` (enforcer.py:1032-1064) — boolean assignments require `is_/has_/should_/can_` prefix. Direct analogue of the rule we need but only for booleans. Misses 3153475246, 3153475297.
67
+ - `check_skip_decorators_in_tests` / `check_existence_check_tests` / `check_constant_equality_tests` (enforcer.py:1079-1277) — test-file checks. Catches none.
68
+ - `check_unused_optional_parameters` (enforcer.py:1854-1933) — same-file callers must vary an optional parameter. Does not catch the inverse case where a wrapper drops an underlying function's optional kwarg from its own signature. Misses 3153475331.
69
+ - `check_incomplete_mocks` / `check_duplicated_format_patterns` (enforcer.py:1746-1851) — advisory-only on test files / repeated f-strings. Catches none.
70
+ - `advise_file_line_count` (enforcer.py:379-399) — soft advisory at 400, hard advisory at 1000; never blocking. Triggers on PR #70 `writer.py` (446 lines) but is stderr-only, so the bugteam gate exit code stays 0 and the audit/fix loop never sees the signal as a finding. Misses 3153098689 in practice.
71
+
72
+ `bugteam_code_rules_gate.py` adds no detectors of its own — `run_gate` (gate.py:379-443) only filters violations to the changed-line set and routes blocking/advisory output. Extending the gate without extending `validate_content` (or adding sibling detectors invoked from `run_gate`) cannot close the gap.
73
+
74
+ Validators absent entirely:
75
+ - Collection-prefix `ALL_*` / `all_*` for tuple/list/set/dict assignments and function parameters.
76
+ - Library `print(` / `sys.stdout.write(` / `sys.stderr.write(` outside CLI entry points.
77
+ - String-literal magic values inside function bodies (column names, status enums, table names) when they are not f-string structural shapes.
78
+ - Wrapper plumb-through detector — public function calling a same-file inner function whose signature has optional kwargs absent from the wrapper.
79
+
80
+ ### Root-cause statement
81
+
82
+ The bugteam audit rubric (PROMPTS.md §bug_categories A–J) and the deterministic validators (`bugteam_code_rules_gate.py` reusing `code_rules_enforcer.validate_content`) together cover only a narrow slice of `CODE_RULES.md`: number-only magic values, UPPER_SNAKE constants location, boolean naming, banned identifiers, comments, type hints, and a small AST-level f-string check. Three rule classes that `CODE_RULES.md §5 "Extended naming rules"` and the readability rubric explicitly require — the collection prefix `ALL_*` / `all_*`, library `print()` / direct `sys.stdout.write` in non-CLI code, and string-literal magic values that are not structural f-string fragments — have neither a rubric category nor a deterministic detector, so every audit/fix loop converges "0 P0 / 0 P1 / 0 P2 → clean" while leaving them in place; the fourth class — wrapper plumb-through (a public function silently dropping the optional kwargs of an underlying call) — is API-contract-shaped but does not fit category A's signature/async framing. The right enforcement layer is layered: deterministic checks (collection prefix, library print, string-literal magic for known SQL/HTTP keys) belong in `code_rules_enforcer.py` so they block at write time AND in the gate via the existing `validate_content` reuse path; judgment-heavy checks (PR-description drift, naming clarity, SRP/length smells, wrapper plumb-through) belong in a Copilot-derived rubric addendum to `PROMPTS.md`, plus an INITIAL and FINAL standards-review phase bracketing the audit/fix loop so the addendum runs against the cumulative diff with no clean-room context loss.
83
+
84
+ ---
85
+
86
+ ## Patch plan
87
+
88
+ Each section names exactly one target file, the literal text or regex to add, and a verification step that re-runs the new detection against the PR #70 / PR #73 diffs.
89
+
90
+ ### a. `~/.claude/skills/bugteam/PROMPTS.md`
91
+
92
+ **Insertion site:** the `<bug_categories>` block inside the AUDIT spawn-prompt XML (PROMPTS.md lines 25-38). Append four new categories K–N and a "Copilot-derived addendum" preamble immediately before the closing `</bug_categories>` tag, leaving A–J unchanged.
93
+
94
+ **New section header:** `Copilot-derived addendum (K–N)`
95
+
96
+ **Literal text to add:**
97
+
98
+ ```
99
+ Copilot-derived addendum (K–N) — verify each one explicitly. Return at
100
+ least one finding per category OR a verified-clean entry that names the
101
+ exact files and lines you walked.
102
+ K. Collection naming. Every tuple, list, set, dict, mapping, or sequence
103
+ parameter must follow the CODE_RULES.md §5 "Extended naming rules"
104
+ prefix discipline:
105
+ - module-level constant whose value is a tuple/list/set/dict/frozenset
106
+ literal MUST start with `ALL_` (e.g. `ALL_THEMES_INSERT_REQUIRED_COLUMN_NAMES`)
107
+ - function/method parameter whose annotation is `list[...]`, `tuple[...]`,
108
+ `set[...]`, `dict[...]`, `Iterable[...]`, `Sequence[...]`, `Mapping[...]`,
109
+ or `frozenset[...]` MUST start with `all_` (e.g. `all_column_value_pairs`)
110
+ - exempt: dict/map names that follow the `X_by_Y` pattern (e.g.
111
+ `price_by_product`)
112
+ L. Library print / direct stdout. In any module that is not a CLI entry
113
+ point (`__main__`, `*_cli.py`, `scripts/*.py`), every `print(...)`,
114
+ `sys.stdout.write(...)`, `sys.stderr.write(...)` call is a finding.
115
+ The fix is to route through a `logger` call OR to make the output
116
+ stream an explicit parameter so callers can redirect it.
117
+ M. String-literal magic values. Treat domain-identifier string literals
118
+ (database column names, table names, HTTP header names, status enums,
119
+ environment-variable names) inside a function body as magic values
120
+ even when the existing number-only check would let them pass. The
121
+ fix is to extract them into `config/` and reference the imported
122
+ name. Do not flag plain log messages, error messages, or one-off
123
+ human-readable strings.
124
+ N. Wrapper plumb-through. When a public function delegates to an
125
+ inner function defined in the same package, every optional kwarg
126
+ accepted by the inner function MUST appear in the public wrapper
127
+ unless the wrapper docstring explicitly states the kwarg is fixed
128
+ to a sentinel default. Silently dropping `loud_banner_stream`,
129
+ `timeout`, `dry_run`, or any similar optional kwarg is a finding.
130
+ </bug_categories>
131
+
132
+ <copilot_derived_addendum_source>
133
+ The K–N categories were added after Copilot raised real findings on
134
+ PR #70 (writer.py / summary.py) and PR #73 (constants.py / writer.py /
135
+ tracker.py) that converged "0 P0 / 0 P1 / 0 P2" under the original
136
+ A–J rubric. See ~/.claude/skills/bugteam/reference/copilot-gap-analysis.md
137
+ for the inventory and the validators that now back categories K and L.
138
+ </copilot_derived_addendum_source>
139
+ ```
140
+
141
+ (Replace the existing closing `</bug_categories>` line with the literal text above so K–N live inside the same block as A–J.)
142
+
143
+ **Verification step (one line, no `$(...)`):**
144
+
145
+ ```
146
+ python C:/Users/jon/.claude/skills/bugteam/scripts/bugteam_code_rules_gate.py /tmp/pr70_writer.py /tmp/pr70_summary.py /tmp/pr73_constants.py /tmp/pr73_writer.py
147
+ ```
148
+
149
+ Run after the K and L deterministic detectors land in §c/d below; the categories M and N stay rubric-only and are exercised by replaying PR #70 / PR #73 through `/bugteam` with the new PROMPTS.md and observing that the audit posts findings keyed to lines 158 (M), 125 (rubric N — naming clarity), 263 (rubric N — PR-description drift), 361 (initial/final standards review — file length), and 206 (N — wrapper plumb-through).
150
+
151
+ ### b. `~/.claude/skills/bugteam/SKILL.md`
152
+
153
+ **Insertion site 1 — progress checklist (SKILL.md lines 72-81).** Add two rows so the checklist reads:
154
+
155
+ ```
156
+ [ ] Step 0: project permissions granted
157
+ [ ] Step 1: PR scope resolved
158
+ [ ] Step 2: agent team created + loop state set
159
+ [ ] Step 2.6: INITIAL standards review against cumulative PR diff
160
+ [ ] Step 3: cycle complete (converged | cap reached | stuck | error)
161
+ [ ] Step 3.5: FINAL standards review against cumulative PR diff
162
+ [ ] Step 4: team torn down + working tree clean
163
+ [ ] Step 4.5: PR description rewritten (or skip warning logged)
164
+ [ ] Step 5: project permissions revoked
165
+ [ ] Step 6: final report printed
166
+ ```
167
+
168
+ **Insertion site 2 — between the existing Step 2.5 ("PR comments") and Step 3 ("The cycle").** Add a new section:
169
+
170
+ ```
171
+ ### Step 2.6: INITIAL standards review (once, before Loop 1 audit)
172
+
173
+ Run BEFORE the first pre-audit gate fires. Spawn a fresh `code-quality-agent`
174
+ teammate inside the same team and drive it through the K–N addendum (see
175
+ PROMPTS.md `<copilot_derived_addendum_source>`). The teammate audits the
176
+ cumulative PR diff (`gh pr diff <N>`) instead of a single loop's incremental
177
+ patch; clean-room context is preserved by the same agent-team isolation as
178
+ the per-loop bugfind teammate. Findings are posted using the same Step 2.5
179
+ review-shape with body `## /bugteam INITIAL standards review against PR #<N>
180
+ cumulative diff: <P0>P0 / <P1>P1 / <P2>P2`. Findings advance the audit/fix
181
+ cycle exactly as if they had been raised in Loop 1: the lead increments
182
+ `loop_count` to 1, sets `last_action = "audited"` with the merged
183
+ `last_findings`, and Step 3 begins on the FIX branch. When the INITIAL
184
+ review returns zero findings, `loop_count` stays at 0 and Step 3 begins on
185
+ the AUDIT branch as before. Failure on this phase logs the error and
186
+ proceeds to Step 3 unchanged so the legacy A–J cycle still runs.
187
+ ```
188
+
189
+ **Insertion site 3 — between the existing Step 3 cycle exit and Step 4 ("Teardown").** Add a new section:
190
+
191
+ ```
192
+ ### Step 3.5: FINAL standards review (once, after convergence)
193
+
194
+ Run AFTER Step 3 exits with `converged`, `cap reached`, or `stuck`, and
195
+ BEFORE Step 4 teardown. Spawn one more fresh `code-quality-agent` teammate;
196
+ audit the cumulative PR diff against the K–N addendum a second time. Post
197
+ the review with body `## /bugteam FINAL standards review against PR #<N>
198
+ cumulative diff: <P0>P0 / <P1>P1 / <P2>P2`. When findings remain, the
199
+ exit reason is upgraded to `error: final standards review found <P0>+<P1>+<P2>
200
+ unresolved finding(s)` and the loop log gains an extra `final-review` line.
201
+ A clean FINAL review preserves the existing exit reason. Failure on this
202
+ phase logs the error and continues to Step 4 unchanged so teardown,
203
+ permission revoke, and the final report still run.
204
+ ```
205
+
206
+ **Insertion site 4 — Step 6 final report template (SKILL.md lines 308-320).** Extend the loop log section so both new phases appear:
207
+
208
+ ```
209
+ Loop log:
210
+ initial standards review: 1P0 0P1 2P2
211
+ 1 audit: 3P0 2P1 0P2
212
+ ...
213
+ final standards review: 0P0 0P1 0P2
214
+ ```
215
+
216
+ **Verification step:**
217
+
218
+ ```
219
+ gh pr diff 70 -R JonEcho/python-automation > /tmp/pr70.diff && gh pr diff 73 -R JonEcho/python-automation > /tmp/pr73.diff && /bugteam --dry-run --replay 70 73
220
+ ```
221
+
222
+ Followed by reading `<team_temp_dir>/pr-70/initial-review.outcomes.xml` and `final-review.outcomes.xml` to confirm Copilot finding ids 3153098661, 3153098689, 3153098727, 3153098762, 3153098782, 3153475246, 3153475297, 3153475331 are surfaced as new entries in either the INITIAL or FINAL review (or both) and that `Step 3.5` upgrades the exit reason when any P0/P1 finding remains.
223
+
224
+ ### c. `~/.claude/skills/bugteam/scripts/bugteam_code_rules_gate.py`
225
+
226
+ **Insertion site:** new module-level helper functions immediately after `is_code_path` (gate.py:237-239), and a new top-level call inside `run_gate` (gate.py:379-443) that augments `validate_content`'s output. The new detectors live in this file (not in `code_rules_enforcer.py`) when their false-positive rate is too high for write-time blocking but still acceptable for the bugteam gate's coarser granularity. The collection-prefix and library-print detectors below are also added to `code_rules_enforcer.py` (§d) so they reach Write/Edit; the column-name string-literal detector below is bugteam-only.
227
+
228
+ **Detector 1 — column-name string magic values (rubric M deterministic backstop):**
229
+
230
+ ```python
231
+ def check_database_column_string_magic(content: str, file_path: str) -> list[str]:
232
+ """Flag string literals that look like database/HTTP column or key names inside function bodies.
233
+
234
+ Triggers when a string literal matches the snake_case shape used by SQL
235
+ column names and table identifiers and appears as a tuple element, list
236
+ element, dict key, dict value, or function-call argument inside a
237
+ function body. Files under ``config/`` and test files are exempt.
238
+ """
239
+ if "/config/" in file_path.replace("\\", "/") or "\\config\\" in file_path:
240
+ return []
241
+ if "/tests/" in file_path.replace("\\", "/") or file_path.endswith(("_test.py", ".spec.py")):
242
+ return []
243
+ issues: list[str] = []
244
+ column_name_shape = re.compile(r'"([a-z][a-z0-9_]{2,})"|\'([a-z][a-z0-9_]{2,})\'')
245
+ inside_function = False
246
+ function_def_pattern = re.compile(r"^\s*(async\s+)?def\s+\w+")
247
+ class_def_pattern = re.compile(r"^\s*class\s+\w+")
248
+ builder_context_pattern = re.compile(r"\b(column|columns|fields|keys|select|insert|update|where|table)\b", re.IGNORECASE)
249
+ for line_number, each_line in enumerate(content.splitlines(), 1):
250
+ if function_def_pattern.match(each_line):
251
+ inside_function = True
252
+ continue
253
+ if class_def_pattern.match(each_line):
254
+ inside_function = False
255
+ continue
256
+ if not inside_function:
257
+ continue
258
+ if not builder_context_pattern.search(each_line):
259
+ continue
260
+ for first_quote_match, second_quote_match in column_name_shape.findall(each_line):
261
+ literal_text = first_quote_match or second_quote_match
262
+ if not literal_text:
263
+ continue
264
+ if literal_text in {"true", "false", "none", "null"}:
265
+ continue
266
+ issues.append(
267
+ f"Line {line_number}: Column-name string magic {literal_text!r} - extract to config"
268
+ )
269
+ if len(issues) >= 3:
270
+ return issues
271
+ return issues
272
+ ```
273
+
274
+ Wired into `run_gate` (replace the body of the per-file loop in gate.py:387-414 so the new detector's output joins `issues`):
275
+
276
+ ```python
277
+ issues = validate_content(content, str(relative).replace("\\", "/"), old_content=content)
278
+ issues.extend(check_database_column_string_magic(content, str(relative).replace("\\", "/")))
279
+ ```
280
+
281
+ **Detector 2 — wrapper plumb-through (rubric N deterministic backstop, file-local only):**
282
+
283
+ ```python
284
+ def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
285
+ """Flag public wrappers that drop optional kwargs of a same-file delegate.
286
+
287
+ Walks the AST. For every public function (name does not start with '_'),
288
+ if its body contains exactly one direct call to another same-file
289
+ function and that delegate's signature accepts optional kwargs that the
290
+ wrapper does not also accept, emit a finding with both line numbers.
291
+ """
292
+ if file_path.endswith((".js", ".ts", ".tsx", ".jsx")):
293
+ return []
294
+ try:
295
+ tree = ast.parse(content)
296
+ except SyntaxError:
297
+ return []
298
+ function_signatures: dict[str, set[str]] = {}
299
+ for node in ast.walk(tree):
300
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
301
+ optional_kwargs: set[str] = set()
302
+ for each_kwonly, each_default in zip(node.args.kwonlyargs, node.args.kw_defaults):
303
+ if each_default is not None:
304
+ optional_kwargs.add(each_kwonly.arg)
305
+ function_signatures[node.name] = optional_kwargs
306
+ issues: list[str] = []
307
+ for node in ast.walk(tree):
308
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
309
+ continue
310
+ if node.name.startswith("_"):
311
+ continue
312
+ wrapper_kwargs = function_signatures.get(node.name, set())
313
+ for each_call in ast.walk(node):
314
+ if not isinstance(each_call, ast.Call):
315
+ continue
316
+ if not isinstance(each_call.func, ast.Attribute):
317
+ continue
318
+ delegate_name = each_call.func.attr
319
+ delegate_kwargs = function_signatures.get(delegate_name)
320
+ if delegate_kwargs is None:
321
+ continue
322
+ missing = delegate_kwargs - wrapper_kwargs
323
+ if missing:
324
+ issues.append(
325
+ f"Line {node.lineno}: Wrapper {node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
326
+ )
327
+ if len(issues) >= 3:
328
+ return issues
329
+ return issues
330
+ ```
331
+
332
+ Wire into `run_gate` the same way as Detector 1, by appending its output to `issues` immediately after the column-magic call.
333
+
334
+ **Imports:** add `import ast` to the top of `bugteam_code_rules_gate.py` (currently absent).
335
+
336
+ **Verification step:**
337
+
338
+ ```
339
+ python C:/Users/jon/.claude/skills/bugteam/scripts/bugteam_code_rules_gate.py /tmp/pr70_writer.py /tmp/pr70_summary.py /tmp/pr73_constants.py /tmp/pr73_writer.py /tmp/pr73_tracker.py
340
+ ```
341
+
342
+ Expected output after the patch lands: at minimum one `Column-name string magic 'theme_name' - extract to config` line on `pr70_writer.py` (Copilot id 3153098661) and one `Wrapper 'flush' drops optional kwargs ['loud_banner_stream'] of delegate 'flush'` line on `pr73_tracker.py` (Copilot id 3153475331).
343
+
344
+ ### d. `~/.claude/hooks/blocking/code_rules_enforcer.py`
345
+
346
+ The root-cause statement names write-time enforcement as the right layer for the collection-prefix and library-print rules, because both have a low false-positive rate and produce concrete, mechanical fixes. Both detectors plug into `validate_content` (enforcer.py:1936-1978) so they reach Write/Edit (the harness PreToolUse path) and the bugteam gate (which reuses `validate_content` via `load_validate_content`).
347
+
348
+ **Detector 1 — collection-prefix (rubric K deterministic backstop):**
349
+
350
+ ```python
351
+ COLLECTION_TYPE_NAMES: frozenset[str] = frozenset({
352
+ "list", "tuple", "set", "frozenset", "dict",
353
+ "Iterable", "Sequence", "Mapping", "MutableMapping", "FrozenSet",
354
+ })
355
+ COLLECTION_BY_NAME_PATTERN: re.Pattern[str] = re.compile(r"^[a-z][a-z0-9]*_by_[a-z][a-z0-9_]*$")
356
+
357
+
358
+ def _annotation_names_collection(annotation_node: ast.expr | None) -> bool:
359
+ if annotation_node is None:
360
+ return False
361
+ if isinstance(annotation_node, ast.Name):
362
+ return annotation_node.id in COLLECTION_TYPE_NAMES
363
+ if isinstance(annotation_node, ast.Subscript):
364
+ return _annotation_names_collection(annotation_node.value)
365
+ if isinstance(annotation_node, ast.Attribute):
366
+ return annotation_node.attr in COLLECTION_TYPE_NAMES
367
+ return False
368
+
369
+
370
+ def check_collection_prefix(content: str, file_path: str) -> list[str]:
371
+ if is_test_file(file_path):
372
+ return []
373
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
374
+ return []
375
+ try:
376
+ tree = ast.parse(content)
377
+ except SyntaxError:
378
+ return []
379
+ issues: list[str] = []
380
+ for node in tree.body:
381
+ target_name: str | None = None
382
+ target_line = 0
383
+ is_collection_value = False
384
+ if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
385
+ target_name = node.target.id
386
+ target_line = node.lineno
387
+ is_collection_value = _annotation_names_collection(node.annotation)
388
+ elif isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
389
+ target_name = node.targets[0].id
390
+ target_line = node.lineno
391
+ is_collection_value = isinstance(node.value, (ast.Tuple, ast.List, ast.Set, ast.Dict))
392
+ if target_name is None or not is_collection_value:
393
+ continue
394
+ if not UPPER_SNAKE_CONSTANT_PATTERN.match(target_name):
395
+ continue
396
+ if target_name.startswith("ALL_") or COLLECTION_BY_NAME_PATTERN.match(target_name.lower()):
397
+ continue
398
+ issues.append(
399
+ f"Line {target_line}: Collection constant {target_name} - prefix with ALL_ (CODE_RULES §5)"
400
+ )
401
+ if len(issues) >= MAX_ISSUES_PER_CHECK:
402
+ break
403
+ for node in ast.walk(tree):
404
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
405
+ continue
406
+ for each_arg in _collect_annotated_arguments(node):
407
+ if not _annotation_names_collection(each_arg.annotation):
408
+ continue
409
+ if each_arg.arg in {"self", "cls"}:
410
+ continue
411
+ if each_arg.arg.startswith("all_") or COLLECTION_BY_NAME_PATTERN.match(each_arg.arg):
412
+ continue
413
+ issues.append(
414
+ f"Line {each_arg.lineno}: Collection parameter {each_arg.arg} - prefix with all_ (CODE_RULES §5)"
415
+ )
416
+ if len(issues) >= MAX_ISSUES_PER_CHECK:
417
+ return issues
418
+ return issues
419
+ ```
420
+
421
+ **File-path filter:** Python files only (`extension in PYTHON_EXTENSIONS`); `is_test_file` / `is_config_file` / `is_workflow_registry_file` / `is_migration_file` exempt families.
422
+
423
+ **Corrective error message:** `Line N: Collection constant FOO - prefix with ALL_ (CODE_RULES §5)` and `Line N: Collection parameter foo - prefix with all_ (CODE_RULES §5)`.
424
+
425
+ **Detector 2 — library print (rubric L deterministic backstop):**
426
+
427
+ ```python
428
+ CLI_FILE_PATH_MARKERS: tuple[str, ...] = ("/scripts/", "\\scripts\\", "_cli.py", "/cli.py", "\\cli.py")
429
+
430
+
431
+ def _is_cli_entry_point(file_path: str) -> bool:
432
+ path_lower = file_path.lower().replace("\\", "/")
433
+ return any(marker.replace("\\", "/") in path_lower for marker in CLI_FILE_PATH_MARKERS)
434
+
435
+
436
+ def check_library_print(content: str, file_path: str) -> list[str]:
437
+ if is_test_file(file_path) or is_config_file(file_path) or is_hook_infrastructure(file_path):
438
+ return []
439
+ if _is_cli_entry_point(file_path):
440
+ return []
441
+ if get_file_extension(file_path) not in PYTHON_EXTENSIONS:
442
+ return []
443
+ try:
444
+ tree = ast.parse(content)
445
+ except SyntaxError:
446
+ return []
447
+ issues: list[str] = []
448
+ for node in ast.walk(tree):
449
+ if not isinstance(node, ast.Call):
450
+ continue
451
+ function_reference = node.func
452
+ if isinstance(function_reference, ast.Name) and function_reference.id == "print":
453
+ issues.append(
454
+ f"Line {node.lineno}: Library print() - route through logger or accept an explicit stream parameter"
455
+ )
456
+ elif isinstance(function_reference, ast.Attribute) and function_reference.attr == "write":
457
+ value_node = function_reference.value
458
+ if isinstance(value_node, ast.Attribute) and isinstance(value_node.value, ast.Name):
459
+ if value_node.value.id == "sys" and value_node.attr in {"stdout", "stderr"}:
460
+ issues.append(
461
+ f"Line {node.lineno}: sys.{value_node.attr}.write - route through logger"
462
+ )
463
+ if len(issues) >= MAX_ISSUES_PER_CHECK:
464
+ break
465
+ return issues
466
+ ```
467
+
468
+ **File-path filter:** Python only; CLI entry points (`/scripts/`, `*_cli.py`, `cli.py`), hook infrastructure, config files, and test files exempt.
469
+
470
+ **Corrective error message:** `Line N: Library print() - route through logger or accept an explicit stream parameter` (and the parallel `sys.stdout.write` / `sys.stderr.write` form).
471
+
472
+ **Wire-up in `validate_content`** — append two new lines inside the `if extension in PYTHON_EXTENSIONS:` block of `validate_content` (enforcer.py:1948-1968), immediately after `check_unused_optional_parameters`:
473
+
474
+ ```python
475
+ all_issues.extend(check_collection_prefix(content, file_path))
476
+ all_issues.extend(check_library_print(content, file_path))
477
+ ```
478
+
479
+ **Verification step:**
480
+
481
+ ```
482
+ python -c "import importlib.util, sys; spec=importlib.util.spec_from_file_location('e','C:/Users/jon/.claude/hooks/blocking/code_rules_enforcer.py'); m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m); content=open('/tmp/pr73_constants.py').read(); print(m.check_collection_prefix(content,'shared_utils/theme_db/config/constants.py'))"
483
+ ```
484
+
485
+ Expected output after the patch lands: a list containing `Line 91: Collection constant THEMES_INSERT_REQUIRED_COLUMN_NAMES - prefix with ALL_ (CODE_RULES §5)`. The same command against `/tmp/pr73_writer.py` (Copilot id 3153475297) emits `Line 296: Collection parameter column_value_pairs - prefix with all_ (CODE_RULES §5)`. Replacing the call with `m.check_library_print` against `/tmp/pr70_summary.py` (Copilot id 3153098727) emits at minimum one `Line 256: Library print() - …` line.
486
+
487
+ **Justification for touching `code_rules_enforcer.py`:** the root-cause statement names write-time enforcement as the right layer for collection-prefix and library-print, because both rules in `CODE_RULES.md §5` are mechanical (no judgment), produce concrete fixes, and were the dominant source of follow-up Copilot findings (3 of 8 inventory rows). The bugteam pre-flight gate alone is insufficient — it only fires before each AUDIT, so a clean-coder fix pass that introduces a new violation lives unblocked until the next gate run; write-time enforcement closes that window.
488
+
489
+ ---
490
+
491
+ ## Cross-references
492
+
493
+ - Inventory data sources: live `gh api repos/JonEcho/python-automation/pulls/{70,73}/comments` filtered to `Copilot`; verbatim bodies preserved in the inventory table above.
494
+ - Original-commit content used to confirm violations: PR #70 head `29117309cf4ec1e83883160d8c819e0843f9c3ac`; PR #73 review-time commit `e4abf52c3a6c724b4e64bfed0d979cd60a2c8bf0`; PR #73 merged head `c9c935a96cc59d39d623dc7eddda3d341007607c`.
495
+ - CODE_RULES.md sections invoked by the patch plan: §⚡ Magic values; §5 Extended naming rules (collections `all_orders`, `all_users`); §6.5 File length guidance; §7 Right-Sized Engineering; §10 No redundant data fetches (used as analogue for wrapper plumb-through).
496
+ - Constraints honored: `gh-body-file` (no `gh ... --body` calls in the new code paths), `no-shell-substitution` (no `$(...)` in the verification commands above; multi-step shell flows are written as separate Bash invocations or `&&`-chained literal strings).
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import ast
4
5
  import importlib.util
5
6
  import re
6
7
  import subprocess
@@ -239,6 +240,97 @@ def is_code_path(file_path: Path) -> bool:
239
240
  return suffix in {".py", ".js", ".ts", ".tsx", ".jsx"}
240
241
 
241
242
 
243
+ def check_database_column_string_magic(content: str, file_path: str) -> list[str]:
244
+ """Flag string literals that look like database/HTTP column or key names inside function bodies.
245
+
246
+ Triggers when a snake_case string literal appears as the first element of a
247
+ two-element tuple inside a function body (the characteristic column-name/value
248
+ pair pattern). Files under ``config/`` and test files are exempt.
249
+ """
250
+ if "/config/" in file_path.replace("\\", "/") or "\\config\\" in file_path:
251
+ return []
252
+ if "/tests/" in file_path.replace("\\", "/") or file_path.endswith(("_test.py", ".spec.py")):
253
+ return []
254
+ try:
255
+ tree = ast.parse(content)
256
+ except SyntaxError:
257
+ return []
258
+ issues: list[str] = []
259
+ column_key_pattern = re.compile(r"^[a-z][a-z0-9_]{2,}$")
260
+ for node in ast.walk(tree):
261
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
262
+ continue
263
+ for each_child in ast.walk(node):
264
+ if not isinstance(each_child, ast.Tuple):
265
+ continue
266
+ if len(each_child.elts) != 2:
267
+ continue
268
+ first_element = each_child.elts[0]
269
+ if not isinstance(first_element, ast.Constant):
270
+ continue
271
+ if not isinstance(first_element.value, str):
272
+ continue
273
+ literal_text = first_element.value
274
+ if not column_key_pattern.match(literal_text):
275
+ continue
276
+ if literal_text in {"true", "false", "none", "null"}:
277
+ continue
278
+ issues.append(
279
+ f"Line {first_element.lineno}: Column-name string magic {literal_text!r} - extract to config"
280
+ )
281
+ if len(issues) >= 3:
282
+ return issues
283
+ return issues
284
+
285
+
286
+ def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
287
+ """Flag public wrappers that drop optional kwargs of a same-file delegate.
288
+
289
+ Walks the AST. For every public function (name does not start with '_'),
290
+ if its body contains exactly one direct call to another same-file
291
+ function and that delegate's signature accepts optional kwargs that the
292
+ wrapper does not also accept, emit a finding with both line numbers.
293
+ """
294
+ if file_path.endswith((".js", ".ts", ".tsx", ".jsx")):
295
+ return []
296
+ try:
297
+ tree = ast.parse(content)
298
+ except SyntaxError:
299
+ return []
300
+ function_signatures: dict[str, set[str]] = {}
301
+ for node in ast.walk(tree):
302
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
303
+ optional_kwargs: set[str] = set()
304
+ for each_kwonly, each_default in zip(node.args.kwonlyargs, node.args.kw_defaults):
305
+ if each_default is not None:
306
+ optional_kwargs.add(each_kwonly.arg)
307
+ function_signatures[node.name] = optional_kwargs
308
+ issues: list[str] = []
309
+ for node in ast.walk(tree):
310
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
311
+ continue
312
+ if node.name.startswith("_"):
313
+ continue
314
+ wrapper_kwargs = function_signatures.get(node.name, set())
315
+ for each_call in ast.walk(node):
316
+ if not isinstance(each_call, ast.Call):
317
+ continue
318
+ if not isinstance(each_call.func, ast.Attribute):
319
+ continue
320
+ delegate_name = each_call.func.attr
321
+ delegate_kwargs = function_signatures.get(delegate_name)
322
+ if delegate_kwargs is None:
323
+ continue
324
+ missing = delegate_kwargs - wrapper_kwargs
325
+ if missing:
326
+ issues.append(
327
+ f"Line {node.lineno}: Wrapper {node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
328
+ )
329
+ if len(issues) >= 3:
330
+ return issues
331
+ return issues
332
+
333
+
242
334
  def parse_added_line_numbers(unified_diff_text: str) -> set[int]:
243
335
  header_regex = hunk_header_pattern()
244
336
  added_line_numbers: set[int] = set()
@@ -404,6 +496,8 @@ def run_gate(
404
496
  continue
405
497
  relative = resolved.relative_to(repository_root.resolve())
406
498
  issues = validate_content(content, str(relative).replace("\\", "/"), old_content=content)
499
+ issues.extend(check_database_column_string_magic(content, str(relative).replace("\\", "/")))
500
+ issues.extend(check_wrapper_plumb_through(content, str(relative).replace("\\", "/")))
407
501
  if not issues:
408
502
  continue
409
503
  added_for_file = None if added_lines_map is None else added_lines_map.get(resolved)