claude-dev-env 1.65.0 → 1.66.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/agents/plan-packet-validator.md +34 -0
- package/audit-rubrics/category_rubrics/category-n-test-name-scenario-verifier.md +6 -0
- package/commands/plan.md +6 -52
- package/hooks/blocking/code_rules_dead_module_constant.py +111 -24
- package/hooks/blocking/code_rules_enforcer.py +2 -0
- package/hooks/blocking/code_rules_test_assertions.py +123 -1
- package/hooks/blocking/open_questions_in_plans_blocker.py +8 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +88 -0
- package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +90 -0
- package/hooks/blocking/test_open_questions_in_plans_blocker.py +43 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
- package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +4 -0
- package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +13 -1
- package/package.json +1 -1
- package/skills/anthropic-plan/SKILL.md +46 -85
- package/skills/anthropic-plan/scripts/anthropic_plan_scripts_constants/__init__.py +0 -0
- package/skills/anthropic-plan/scripts/anthropic_plan_scripts_constants/validate_packet_constants.py +33 -0
- package/skills/anthropic-plan/scripts/test_validate_packet.py +405 -0
- package/skills/anthropic-plan/scripts/validate_packet.py +397 -0
- package/skills/anthropic-plan/templates/README.md +20 -0
- package/skills/anthropic-plan/templates/build-prompt.md +9 -0
- package/skills/anthropic-plan/templates/source-map.md +5 -0
- package/skills/anthropic-plan/test_skill_contract.py +53 -0
- package/skills/anthropic-plan/workflow/plan-packet.contract.test.mjs +79 -0
- package/skills/anthropic-plan/workflow/plan-packet.mjs +299 -0
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +8 -1
- package/skills/autoconverge/workflow/converge.mjs +9 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plan-packet-validator
|
|
3
|
+
description: Fresh-context validator for workflow-generated plan packets. Use after a plan packet is written under docs/plans/<slug>/ to verify source accuracy, completeness, TDD readiness, scope control, handoff quality, and no invented repo behavior. Read-only; never edits files.
|
|
4
|
+
tools: Read, Grep, Glob, Bash
|
|
5
|
+
model: inherit
|
|
6
|
+
color: purple
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You validate plan packets. You are not the planner and you do not repair docs.
|
|
10
|
+
|
|
11
|
+
## Rules
|
|
12
|
+
|
|
13
|
+
- Never edit files.
|
|
14
|
+
- Require each material claim to be source-backed, user-confirmed, or explicitly listed as a packet assumption.
|
|
15
|
+
- Treat every packet claim as untrusted until you verify it against source files, repo docs, user-confirmed decisions, or packet assumptions.
|
|
16
|
+
- Return findings only for problems that would make a blind build agent implement the wrong thing or need to rediscover core context.
|
|
17
|
+
- Do not raise style-only findings.
|
|
18
|
+
|
|
19
|
+
## Checks
|
|
20
|
+
|
|
21
|
+
1. Read `README.md`, `packet.json`, `context/source-map.md`, `implementation/steps.md`, `implementation/tdd-plan.md`, `spec/acceptance.md`, and `handoff/build-prompt.md`.
|
|
22
|
+
2. Read or search the source files named in `source-map.md` and `packet.json`.
|
|
23
|
+
3. Verify referenced paths exist unless the packet clearly labels them as new files.
|
|
24
|
+
4. Verify source facts match actual files.
|
|
25
|
+
5. Verify the implementation steps are enough for a blind build agent.
|
|
26
|
+
6. Verify the TDD sequence starts with failing tests and names the behavior those tests prove.
|
|
27
|
+
7. Verify scope matches the user request and non-goals.
|
|
28
|
+
8. Verify no commands, APIs, schemas, hooks, workflows, agents, or repo conventions are invented.
|
|
29
|
+
9. Verify acceptance criteria prove the requested behavior end to end.
|
|
30
|
+
10. Verify `handoff/build-prompt.md` stands alone without chat history.
|
|
31
|
+
|
|
32
|
+
## Output
|
|
33
|
+
|
|
34
|
+
Return the requested structured schema. Set `allPassed` to true only when every check is clean. Each finding must name the packet file, the check, and the exact source-grounded problem.
|
|
@@ -37,6 +37,12 @@ Customize per-artifact: a pure-function test corpus with no scenario claims redu
|
|
|
37
37
|
|
|
38
38
|
---
|
|
39
39
|
|
|
40
|
+
## Write-time advisory for the flag-gated N1 slice
|
|
41
|
+
|
|
42
|
+
`check_flag_gated_scenario_test_naming` in `code_rules_test_assertions.py` (wired into `code_rules_enforcer.py`) catches the deterministic N1 slice at Write/Edit time. When two or more sibling tests in a file `monkeypatch.setattr` the same module-level UPPER_SNAKE flag, that flag governs which branch the code under test runs. A `test_*` whose name carries a scenario clause (`_when_`, `_passes`, `_succeeds`, `_on_clean`) but never patches that flag runs under the flag's default value, so its named condition may not be in effect. The check prints an advisory to stderr and never blocks the write — the breadth of the sibling-pattern heuristic suits a judgment lane, where the author decides whether to patch the flag (and assert the gated path runs) or rename the test. The audit still owns the full N1–N10 surface; the advisory only surfaces this one mechanically-detectable shape early.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
40
46
|
## Sample prompt
|
|
41
47
|
|
|
42
48
|
The reusable Variant C template for Category N is in [`../prompts/category-n-test-name-scenario-verifier.md`](../prompts/category-n-test-name-scenario-verifier.md). Inline every changed test function under `## Source material` along with the production function it claims to cover, so the audit can compare the named scenario against the body's act phase.
|
package/commands/plan.md
CHANGED
|
@@ -1,60 +1,14 @@
|
|
|
1
|
-
Plan a feature
|
|
1
|
+
Plan a feature through the workflow-backed `anthropic-plan` skill.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Invoke `anthropic-plan` with the user request. The skill launches the Claude Code Workflow at:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`$HOME/.claude/skills/anthropic-plan/workflow/plan-packet.mjs`
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
1. **Invoke `plan` skill** - Discover configs, design through dialogue
|
|
7
|
+
The workflow creates a validated `docs/plans/<slug>/` packet, spawns the fresh `plan-packet-validator` agent, repairs packet findings, and stops before implementation.
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
2. **Invoke `write-plan` skill** - Create detailed TDD implementation plan (CODE_RULES.md compliant)
|
|
12
|
-
3. **Invoke `review-plan` skill** - Validate plan against CODE_RULES.md standards
|
|
13
|
-
4. **Fix violations** - If review-plan finds issues, fix before proceeding
|
|
9
|
+
Usage:
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
5. **Invoke `plan-checkpoint` skill** - Generate summary gist for reviewer approval
|
|
17
|
-
6. **Wait for approval** - Do not proceed until approved
|
|
18
|
-
|
|
19
|
-
### Phase 4: Execute
|
|
20
|
-
7. **Invoke `plan-executor` agent** - Execute with full standards enforcement
|
|
21
|
-
8. **Invoke `readability-review` skill** - Validate written code against CODE_RULES.md
|
|
22
|
-
|
|
23
|
-
### Phase 5: Commit & Review
|
|
24
|
-
9. **Invoke `/commit`** - Create atomic commits
|
|
25
|
-
10. **Push branch** - Push to GitHub (NO PR yet); the git pre-push hook installed via `npx claude-dev-env` fires automatically and blocks on any violation
|
|
26
|
-
11. **Wait for commit review** - User reviews on GitHub
|
|
27
|
-
12. **Create PR** - Only after user approves commit
|
|
28
|
-
|
|
29
|
-
## Key Rules
|
|
30
|
-
|
|
31
|
-
- NEVER offer execution until review-plan passes with ZERO violations
|
|
32
|
-
- For NEW PRs: wait for reviewer approval on checkpoint before implementing
|
|
33
|
-
- For PR REVIEW FIXES: skip checkpoint, proceed directly to execution
|
|
34
|
-
- NEVER push if the git pre-push hook fails (it fires automatically via `npx claude-dev-env`)
|
|
35
|
-
- NEVER create PR until user explicitly approves pushed commit
|
|
36
|
-
|
|
37
|
-
## Skills & Agents Reference
|
|
38
|
-
|
|
39
|
-
| Step | Tool | Purpose |
|
|
40
|
-
|------|------|---------|
|
|
41
|
-
| Design | `plan` skill | Collaborative design with config discovery |
|
|
42
|
-
| Write | `write-plan` skill | TDD plan with CODE_RULES.md compliance |
|
|
43
|
-
| Review Plan | `review-plan` skill | Validate plan against standards |
|
|
44
|
-
|
|
45
|
-
## Standards Reference
|
|
46
|
-
|
|
47
|
-
All code quality standards are in `~/.claude/docs/CODE_RULES.md`:
|
|
48
|
-
- Self-documenting code (no comments)
|
|
49
|
-
- Centralized configs (reuse existing)
|
|
50
|
-
- No magic values
|
|
51
|
-
- No abbreviations
|
|
52
|
-
- Complete type hints
|
|
53
|
-
- All imports shown
|
|
54
|
-
|
|
55
|
-
## Usage
|
|
56
|
-
|
|
57
|
-
```
|
|
11
|
+
```text
|
|
58
12
|
/plan add user authentication
|
|
59
13
|
/plan refactor the payment system
|
|
60
14
|
```
|
|
@@ -6,8 +6,10 @@ project, so a constant defined there is never proven dead by a single-file scan
|
|
|
6
6
|
alone. This check resolves the enclosing package tree — the scan root — and
|
|
7
7
|
flags an UPPER_SNAKE constant defined in the written module whose name appears
|
|
8
8
|
in no ``.py`` module anywhere under that root: not as an imported name, not as a
|
|
9
|
-
read, not as a re-export.
|
|
10
|
-
|
|
9
|
+
read, not as a re-export. When a constant looks dead in the package tree, the
|
|
10
|
+
scan widens to the whole repository so a consumer in a sibling tree counts
|
|
11
|
+
before the constant is flagged. That is the ``MEDIUM_TEXT``-style dead constant
|
|
12
|
+
the CODE_RULES §9.8 dead-code rule targets, caught at Write/Edit time before the
|
|
11
13
|
unused constant lands.
|
|
12
14
|
|
|
13
15
|
The scan is deliberately conservative to keep false positives near zero:
|
|
@@ -18,11 +20,19 @@ The scan is deliberately conservative to keep false positives near zero:
|
|
|
18
20
|
surface explicitly, so a name listed there is live by declaration and a name
|
|
19
21
|
absent there is the author's stated intent, neither of which this check second
|
|
20
22
|
guesses.
|
|
21
|
-
- A constant is live when its name appears anywhere
|
|
23
|
+
- A constant is live when its name appears anywhere the scan reaches —
|
|
22
24
|
imported, read, listed in ``__all__``, or referenced in a string annotation —
|
|
23
25
|
in any ``.py`` module, including the constants module itself.
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
+
- When the package-tree scan leaves a constant unreferenced, the scan widens to
|
|
27
|
+
the repository root (the nearest ``.git`` ancestor) so a consumer in a sibling
|
|
28
|
+
tree of the same repository counts; a module outside any repository is judged
|
|
29
|
+
on the package-tree scan alone. The widened pass skips the package subtree the
|
|
30
|
+
first pass already covered, so no file is read twice.
|
|
31
|
+
- The combined file count of the package-tree and widened passes is bounded by a
|
|
32
|
+
cap, so a write under an unexpectedly large tree cannot stall the hook; a write
|
|
33
|
+
whose scan hits the cap is treated as "cannot prove dead" and flags nothing.
|
|
34
|
+
- Test modules under the scanned tree still count as references, so a constant
|
|
35
|
+
used only by a test stays live.
|
|
26
36
|
"""
|
|
27
37
|
|
|
28
38
|
import ast
|
|
@@ -48,6 +58,7 @@ from hooks_constants.dead_module_constant_constants import ( # noqa: E402
|
|
|
48
58
|
DEAD_MODULE_CONSTANT_GUIDANCE,
|
|
49
59
|
DUNDER_ALL_NAME,
|
|
50
60
|
DUNDER_INIT_FILENAME,
|
|
61
|
+
GIT_DIRECTORY_NAME,
|
|
51
62
|
MAX_DEAD_MODULE_CONSTANT_ISSUES,
|
|
52
63
|
MAX_SCAN_ROOT_FILE_COUNT,
|
|
53
64
|
MINIMUM_UPPER_SNAKE_LENGTH,
|
|
@@ -197,46 +208,100 @@ def _scan_root_for_constants_module(file_path: str) -> Path:
|
|
|
197
208
|
return enclosing_directory
|
|
198
209
|
|
|
199
210
|
|
|
211
|
+
def _is_under_directory(candidate_path: Path, ancestor_directory: Path) -> bool:
|
|
212
|
+
"""Return whether a resolved path lies inside a resolved ancestor directory.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
candidate_path: The resolved file path to test.
|
|
216
|
+
ancestor_directory: The resolved directory that may contain the path.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
True when ``candidate_path`` is the ancestor directory itself or a
|
|
220
|
+
descendant of it, False otherwise.
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
candidate_path.relative_to(ancestor_directory)
|
|
224
|
+
except ValueError:
|
|
225
|
+
return False
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
|
|
200
229
|
def _all_referenced_names_under_root(
|
|
201
230
|
scan_root: Path,
|
|
202
231
|
written_path: Path,
|
|
203
232
|
written_content: str,
|
|
204
|
-
|
|
205
|
-
|
|
233
|
+
already_scanned_count: int = 0,
|
|
234
|
+
excluded_subtree: Path | None = None,
|
|
235
|
+
) -> tuple[set[str], int, bool]:
|
|
236
|
+
"""Return referenced names under the scan root, the running count, and a cap flag.
|
|
206
237
|
|
|
207
238
|
The written module's on-disk text is replaced by ``written_content`` so the
|
|
208
|
-
post-edit view is judged, never the stale disk copy. Sibling modules are
|
|
209
|
-
|
|
210
|
-
an unexpectedly large tree cannot stall the hook; the
|
|
211
|
-
caller to treat that case as "cannot prove dead".
|
|
239
|
+
post-edit view is judged, never the stale disk copy. Sibling modules are read
|
|
240
|
+
from disk. Reading stops once the running file count exceeds the configured
|
|
241
|
+
cap so a write under an unexpectedly large tree cannot stall the hook; the
|
|
242
|
+
boolean signals the caller to treat that case as "cannot prove dead". When
|
|
243
|
+
``excluded_subtree`` is supplied, every ``.py`` module under that directory is
|
|
244
|
+
skipped, so the widened repository scan never re-reads a file the
|
|
245
|
+
package-tree scan already covered.
|
|
212
246
|
|
|
213
247
|
Args:
|
|
214
248
|
scan_root: The directory tree to scan.
|
|
215
249
|
written_path: The resolved path of the module being written.
|
|
216
250
|
written_content: The post-edit text of the written module.
|
|
251
|
+
already_scanned_count: The file count accumulated by a prior pass, so the
|
|
252
|
+
cap bounds the combined work of the package-tree and widened passes.
|
|
253
|
+
excluded_subtree: A resolved directory whose ``.py`` modules are skipped,
|
|
254
|
+
or None to scan every file under the root.
|
|
217
255
|
|
|
218
256
|
Returns:
|
|
219
|
-
A (referenced_names, cap_was_hit)
|
|
220
|
-
every scanned module
|
|
221
|
-
|
|
257
|
+
A (referenced_names, running_count, cap_was_hit) triple. The name set is
|
|
258
|
+
the union across every scanned module, unioned with the names the written
|
|
259
|
+
module itself references; running_count is the cumulative file count
|
|
260
|
+
including ``already_scanned_count``; cap_was_hit is True when the scan
|
|
261
|
+
stopped at the configured file cap before scanning the whole tree.
|
|
222
262
|
"""
|
|
223
263
|
all_referenced_names = _referenced_names_in_source(written_content, load_only=True)
|
|
224
264
|
written_path_key = os.path.normcase(str(written_path))
|
|
225
|
-
scanned_file_count =
|
|
265
|
+
scanned_file_count = already_scanned_count
|
|
226
266
|
for each_path in scan_root.rglob("*" + PYTHON_SOURCE_SUFFIX):
|
|
227
267
|
if not each_path.is_file():
|
|
228
268
|
continue
|
|
229
|
-
|
|
269
|
+
resolved_path = each_path.resolve()
|
|
270
|
+
if os.path.normcase(str(resolved_path)) == written_path_key:
|
|
271
|
+
continue
|
|
272
|
+
if excluded_subtree is not None and _is_under_directory(resolved_path, excluded_subtree):
|
|
230
273
|
continue
|
|
231
274
|
scanned_file_count += 1
|
|
232
275
|
if scanned_file_count > MAX_SCAN_ROOT_FILE_COUNT:
|
|
233
|
-
return all_referenced_names, True
|
|
276
|
+
return all_referenced_names, scanned_file_count, True
|
|
234
277
|
try:
|
|
235
278
|
sibling_source = each_path.read_text(encoding="utf-8")
|
|
236
279
|
except (OSError, UnicodeDecodeError):
|
|
237
280
|
continue
|
|
238
281
|
all_referenced_names |= _referenced_names_in_source(sibling_source)
|
|
239
|
-
return all_referenced_names, False
|
|
282
|
+
return all_referenced_names, scanned_file_count, False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _repository_root_for(written_path: Path) -> Path | None:
|
|
286
|
+
"""Return the nearest ancestor directory that holds a ``.git`` entry.
|
|
287
|
+
|
|
288
|
+
Walks upward from the written module toward the filesystem root. A normal
|
|
289
|
+
checkout carries a ``.git`` directory and a git worktree carries a ``.git``
|
|
290
|
+
file; both satisfy ``exists()``. The repository root bounds the widened
|
|
291
|
+
cross-tree reference scan.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
written_path: The resolved path of the constants module being written.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
The repository root directory, or ``None`` when no ancestor carries a
|
|
298
|
+
``.git`` entry, so a module outside any repository triggers no widened
|
|
299
|
+
scan.
|
|
300
|
+
"""
|
|
301
|
+
for each_ancestor in written_path.parents:
|
|
302
|
+
if (each_ancestor / GIT_DIRECTORY_NAME).exists():
|
|
303
|
+
return each_ancestor
|
|
304
|
+
return None
|
|
240
305
|
|
|
241
306
|
|
|
242
307
|
def _module_is_exempt_from_constant_check(file_path: str) -> bool:
|
|
@@ -269,10 +334,14 @@ def check_dead_module_constants(
|
|
|
269
334
|
Runs only on a dedicated constants module (``*_constants.py`` or a module
|
|
270
335
|
under ``config/``); every other production module's file-global constants
|
|
271
336
|
are governed by the use-count rule instead. A constant is dead when its name
|
|
272
|
-
appears in no ``.py`` module
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
337
|
+
appears in no ``.py`` module under the enclosing package tree, nor anywhere
|
|
338
|
+
in the repository the scan widens to when the package-tree scan leaves the
|
|
339
|
+
constant unreferenced — not imported, not read, not listed in an ``__all__``
|
|
340
|
+
literal, not named in a string annotation. A module declaring its own
|
|
341
|
+
``__all__`` is skipped so the author's explicit export surface is never
|
|
342
|
+
second-guessed. A scan whose combined package-tree and widened file count
|
|
343
|
+
exceeds the configured cap returns ``[]`` (cannot prove dead), bounding the
|
|
344
|
+
work so the blocking hook cannot stall under a large tree. Whole-file
|
|
276
345
|
analysis runs against ``full_file_content`` when supplied so an Edit fragment
|
|
277
346
|
is judged against the reconstructed post-edit file.
|
|
278
347
|
|
|
@@ -285,7 +354,9 @@ def check_dead_module_constants(
|
|
|
285
354
|
|
|
286
355
|
Returns:
|
|
287
356
|
One violation message per dead module-level constant, capped at the
|
|
288
|
-
configured maximum.
|
|
357
|
+
configured maximum. Returns an empty list when the file is exempt, no
|
|
358
|
+
constant is defined, the module declares ``__all__``, the scan exceeds the
|
|
359
|
+
file cap, or a SyntaxError prevents parsing.
|
|
289
360
|
"""
|
|
290
361
|
if _module_is_exempt_from_constant_check(file_path):
|
|
291
362
|
return []
|
|
@@ -301,13 +372,29 @@ def check_dead_module_constants(
|
|
|
301
372
|
return []
|
|
302
373
|
scan_root = _scan_root_for_constants_module(file_path)
|
|
303
374
|
written_path = Path(file_path).resolve()
|
|
304
|
-
all_referenced_names, cap_was_hit = _all_referenced_names_under_root(
|
|
375
|
+
all_referenced_names, scanned_file_count, cap_was_hit = _all_referenced_names_under_root(
|
|
305
376
|
scan_root,
|
|
306
377
|
written_path,
|
|
307
378
|
effective_content,
|
|
308
379
|
)
|
|
309
380
|
if cap_was_hit:
|
|
310
381
|
return []
|
|
382
|
+
has_unreferenced_constant = any(
|
|
383
|
+
each_name not in all_referenced_names for each_name, _ in constant_definitions
|
|
384
|
+
)
|
|
385
|
+
if has_unreferenced_constant:
|
|
386
|
+
repository_root = _repository_root_for(written_path)
|
|
387
|
+
if repository_root is not None and repository_root != scan_root:
|
|
388
|
+
widened_names, _widened_count, widened_cap_was_hit = _all_referenced_names_under_root(
|
|
389
|
+
repository_root,
|
|
390
|
+
written_path,
|
|
391
|
+
effective_content,
|
|
392
|
+
already_scanned_count=scanned_file_count,
|
|
393
|
+
excluded_subtree=scan_root,
|
|
394
|
+
)
|
|
395
|
+
if widened_cap_was_hit:
|
|
396
|
+
return []
|
|
397
|
+
all_referenced_names |= widened_names
|
|
311
398
|
issues: list[str] = []
|
|
312
399
|
for each_name, each_line in constant_definitions:
|
|
313
400
|
if each_name in all_referenced_names:
|
|
@@ -117,6 +117,7 @@ from code_rules_string_magic import ( # noqa: E402
|
|
|
117
117
|
from code_rules_test_assertions import ( # noqa: E402
|
|
118
118
|
check_constant_equality_tests,
|
|
119
119
|
check_existence_check_tests,
|
|
120
|
+
check_flag_gated_scenario_test_naming,
|
|
120
121
|
check_skip_decorators_in_tests,
|
|
121
122
|
)
|
|
122
123
|
from code_rules_test_branching_except import ( # noqa: E402
|
|
@@ -274,6 +275,7 @@ def validate_content(
|
|
|
274
275
|
)
|
|
275
276
|
all_issues.extend(check_existence_check_tests(content, file_path))
|
|
276
277
|
all_issues.extend(check_constant_equality_tests(content, file_path))
|
|
278
|
+
check_flag_gated_scenario_test_naming(content, file_path)
|
|
277
279
|
all_issues.extend(check_unused_optional_parameters(content, file_path))
|
|
278
280
|
all_issues.extend(check_collection_prefix(content, file_path))
|
|
279
281
|
all_issues.extend(check_stuttering_collection_prefix(content, file_path))
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
"""Skip-decorator, existence-only,
|
|
1
|
+
"""Skip-decorator, existence-only, constant-equality, and flag-gated scenario test-quality checks."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import sys
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
_SCENARIO_NAME_CLAUSES = ("_when_", "_passes", "_succeeds", "_on_clean")
|
|
8
|
+
_MINIMUM_SIBLING_PATCH_COUNT = 2
|
|
9
|
+
|
|
7
10
|
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
8
11
|
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
9
12
|
if _BLOCKING_DIRECTORY not in sys.path:
|
|
@@ -224,3 +227,122 @@ def check_constant_equality_tests(content: str, file_path: str) -> list[str]:
|
|
|
224
227
|
)
|
|
225
228
|
|
|
226
229
|
return issues
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _flag_symbol_from_setattr_target(target_node: ast.expr) -> str | None:
|
|
233
|
+
"""Return the UPPER_SNAKE flag symbol a monkeypatch.setattr target names.
|
|
234
|
+
|
|
235
|
+
Accepts both target shapes monkeypatch.setattr supports: a dotted string
|
|
236
|
+
path (``"pkg.module.FLAG"``) and an attribute access (``module.FLAG``). The
|
|
237
|
+
flag is the final dotted segment when that segment is UPPER_SNAKE_CASE; any
|
|
238
|
+
other segment shape returns None so only module-level boolean flags qualify.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
target_node: The first positional argument of a ``monkeypatch.setattr``
|
|
242
|
+
call.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
The UPPER_SNAKE flag name, or None when the target names no such symbol.
|
|
246
|
+
"""
|
|
247
|
+
if isinstance(target_node, ast.Constant) and isinstance(target_node.value, str):
|
|
248
|
+
final_segment = target_node.value.rsplit(".", 1)[-1]
|
|
249
|
+
return final_segment if _is_upper_snake_name(final_segment) else None
|
|
250
|
+
if isinstance(target_node, ast.Attribute):
|
|
251
|
+
return target_node.attr if _is_upper_snake_name(target_node.attr) else None
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _is_monkeypatch_setattr(call_node: ast.Call) -> bool:
|
|
256
|
+
"""Return True when a Call node is a ``monkeypatch.setattr(...)`` invocation."""
|
|
257
|
+
function_reference = call_node.func
|
|
258
|
+
return (
|
|
259
|
+
isinstance(function_reference, ast.Attribute)
|
|
260
|
+
and function_reference.attr == "setattr"
|
|
261
|
+
and isinstance(function_reference.value, ast.Name)
|
|
262
|
+
and function_reference.value.id == "monkeypatch"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _flags_patched_in_test(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
|
|
267
|
+
"""Return the set of UPPER_SNAKE flag symbols a test patches via monkeypatch.setattr."""
|
|
268
|
+
patched_flags: set[str] = set()
|
|
269
|
+
for each_node in ast.walk(function_node):
|
|
270
|
+
if not isinstance(each_node, ast.Call):
|
|
271
|
+
continue
|
|
272
|
+
if not _is_monkeypatch_setattr(each_node) or not each_node.args:
|
|
273
|
+
continue
|
|
274
|
+
flag_symbol = _flag_symbol_from_setattr_target(each_node.args[0])
|
|
275
|
+
if flag_symbol is not None:
|
|
276
|
+
patched_flags.add(flag_symbol)
|
|
277
|
+
return patched_flags
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _name_encodes_scenario(test_name: str) -> bool:
|
|
281
|
+
"""Return True when a test name carries a scenario clause asserting a condition."""
|
|
282
|
+
return any(each_clause in test_name for each_clause in _SCENARIO_NAME_CLAUSES)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def check_flag_gated_scenario_test_naming(content: str, file_path: str) -> list[str]:
|
|
286
|
+
"""Flag a scenario-named test that omits a flag its siblings establish.
|
|
287
|
+
|
|
288
|
+
When two or more sibling tests in a file monkeypatch the same module-level
|
|
289
|
+
UPPER_SNAKE flag, that flag governs which branch the code under test runs.
|
|
290
|
+
A test whose name asserts a scenario (``_when_``, ``_passes``, ``_succeeds``,
|
|
291
|
+
``_on_clean``) but never patches that flag runs under the flag's default
|
|
292
|
+
value, so its named condition may not be in effect — the audit category N
|
|
293
|
+
test-name-scenario mismatch. Advisory only; emitted to stderr, never blocks.
|
|
294
|
+
Only applies to test files; production files are exempt.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
content: The file body under validation.
|
|
298
|
+
file_path: Path to the file, used for the test-file gate.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
An empty list; advisories print to stderr so the write proceeds.
|
|
302
|
+
"""
|
|
303
|
+
if not is_test_file(file_path):
|
|
304
|
+
return []
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
syntax_tree = ast.parse(content)
|
|
308
|
+
except SyntaxError:
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
test_functions = [
|
|
312
|
+
each_node
|
|
313
|
+
for each_node in ast.walk(syntax_tree)
|
|
314
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
315
|
+
and each_node.name.startswith("test")
|
|
316
|
+
]
|
|
317
|
+
flags_patched_by_test = {
|
|
318
|
+
each_test.name: _flags_patched_in_test(each_test) for each_test in test_functions
|
|
319
|
+
}
|
|
320
|
+
sibling_patch_count_by_flag: dict[str, int] = {}
|
|
321
|
+
for patched_flags in flags_patched_by_test.values():
|
|
322
|
+
for each_flag in patched_flags:
|
|
323
|
+
sibling_patch_count_by_flag[each_flag] = (
|
|
324
|
+
sibling_patch_count_by_flag.get(each_flag, 0) + 1
|
|
325
|
+
)
|
|
326
|
+
established_flags = {
|
|
327
|
+
each_flag
|
|
328
|
+
for each_flag, patch_count in sibling_patch_count_by_flag.items()
|
|
329
|
+
if patch_count >= _MINIMUM_SIBLING_PATCH_COUNT
|
|
330
|
+
}
|
|
331
|
+
if not established_flags:
|
|
332
|
+
return []
|
|
333
|
+
|
|
334
|
+
for each_test in test_functions:
|
|
335
|
+
if not _name_encodes_scenario(each_test.name):
|
|
336
|
+
continue
|
|
337
|
+
unpatched_flags = established_flags - flags_patched_by_test[each_test.name]
|
|
338
|
+
if unpatched_flags:
|
|
339
|
+
flag_list = ", ".join(sorted(unpatched_flags))
|
|
340
|
+
print(
|
|
341
|
+
f"ADVISORY [CODE_RULES] Line {each_test.lineno}: scenario test"
|
|
342
|
+
f" {each_test.name!r} never patches {flag_list}, which sibling tests"
|
|
343
|
+
f" establish — the named scenario may run under the flag default."
|
|
344
|
+
f" Patch the flag (and assert the gated path runs) or rename the test.",
|
|
345
|
+
file=sys.stderr,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return []
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""PreToolUse:Write|Edit|MultiEdit hook — blocks plan files that contain an "Open Questions" section.
|
|
3
3
|
|
|
4
|
-
Plans under `~/.claude/plans/` (or any `.claude/plans/` directory)
|
|
4
|
+
Plans under `~/.claude/plans/` (or any `.claude/plans/` directory) and packet
|
|
5
|
+
docs under any repo-local `docs/plans/` directory must not be
|
|
5
6
|
written with an unresolved "Open Questions" section. When detected, the agent is
|
|
6
7
|
forced to (1) investigate the codebase for answers itself first, then (2) confirm
|
|
7
8
|
its interpretations via the AskUserQuestion tool in plain everyday language, and
|
|
@@ -20,6 +21,8 @@ if _hooks_dir not in sys.path:
|
|
|
20
21
|
|
|
21
22
|
from hooks_constants.open_questions_in_plans_blocker_constants import ( # noqa: E402
|
|
22
23
|
CODE_FENCE_PATTERN,
|
|
24
|
+
DOCS_PLANS_PATH_PREFIX,
|
|
25
|
+
DOCS_PLANS_PATH_SEGMENT,
|
|
23
26
|
INLINE_CODE_PATTERN,
|
|
24
27
|
MARKDOWN_EXTENSION,
|
|
25
28
|
OPEN_QUESTIONS_HEADING_PATTERN,
|
|
@@ -41,6 +44,10 @@ def _is_inside_plans_directory(file_path: str) -> bool:
|
|
|
41
44
|
return True
|
|
42
45
|
if normalized.startswith(PLANS_PATH_PREFIX):
|
|
43
46
|
return True
|
|
47
|
+
if DOCS_PLANS_PATH_SEGMENT in normalized:
|
|
48
|
+
return True
|
|
49
|
+
if normalized.startswith(DOCS_PLANS_PATH_PREFIX):
|
|
50
|
+
return True
|
|
44
51
|
return False
|
|
45
52
|
|
|
46
53
|
|
|
@@ -39,8 +39,13 @@ def neutral_root() -> Iterator[Path]:
|
|
|
39
39
|
own ``tmp_path`` directory name embeds the test name, which would make every
|
|
40
40
|
synthetic constants path look like a test file. A neutral ``mkdtemp`` root
|
|
41
41
|
mirrors how a production constants module path looks.
|
|
42
|
+
|
|
43
|
+
A ``.git`` marker is planted at the root so the cross-tree widening resolves
|
|
44
|
+
the repository root to this synthetic tree, never an enclosing real
|
|
45
|
+
checkout, keeping every test bounded and deterministic.
|
|
42
46
|
"""
|
|
43
47
|
neutral_directory = Path(tempfile.mkdtemp(prefix="deadconst-")).resolve()
|
|
48
|
+
(neutral_directory / ".git").mkdir()
|
|
44
49
|
try:
|
|
45
50
|
yield neutral_directory
|
|
46
51
|
finally:
|
|
@@ -186,3 +191,86 @@ def test_is_skipped_on_a_constants_test_file(neutral_root: Path) -> None:
|
|
|
186
191
|
test_constants_path.write_text(body, encoding="utf-8")
|
|
187
192
|
issues = _check(body, str(test_constants_path))
|
|
188
193
|
assert issues == [], f"Test files are exempt, got: {issues}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _build_cross_tree_repository(
|
|
197
|
+
repository_root: Path,
|
|
198
|
+
constants_body: str,
|
|
199
|
+
sibling_consumer_body: str,
|
|
200
|
+
) -> Path:
|
|
201
|
+
config_directory = repository_root / "shared" / "theme_db" / "config"
|
|
202
|
+
config_directory.mkdir(parents=True)
|
|
203
|
+
constants_path = config_directory / "constants.py"
|
|
204
|
+
constants_path.write_text(constants_body, encoding="utf-8")
|
|
205
|
+
sibling_directory = repository_root / "cdp"
|
|
206
|
+
sibling_directory.mkdir(parents=True)
|
|
207
|
+
(sibling_directory / "tally.py").write_text(sibling_consumer_body, encoding="utf-8")
|
|
208
|
+
return constants_path
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_does_not_flag_constant_used_only_in_a_sibling_tree(neutral_root: Path) -> None:
|
|
212
|
+
constants_body = 'CROSS_TREE_CONSTANT = "cross"\nLOCALLY_DEAD_CONSTANT = "dead"\n'
|
|
213
|
+
sibling_consumer_body = (
|
|
214
|
+
"from shared.theme_db.config.constants import CROSS_TREE_CONSTANT\n"
|
|
215
|
+
"\n"
|
|
216
|
+
"def tally() -> str:\n"
|
|
217
|
+
" return CROSS_TREE_CONSTANT\n"
|
|
218
|
+
)
|
|
219
|
+
constants_path = _build_cross_tree_repository(
|
|
220
|
+
neutral_root, constants_body, sibling_consumer_body
|
|
221
|
+
)
|
|
222
|
+
issues = _check(constants_body, str(constants_path))
|
|
223
|
+
assert not any("CROSS_TREE_CONSTANT" in each_issue for each_issue in issues), (
|
|
224
|
+
f"A constant consumed by a sibling tree in the repository must not be flagged, got: {issues}"
|
|
225
|
+
)
|
|
226
|
+
assert any("LOCALLY_DEAD_CONSTANT" in each_issue for each_issue in issues), (
|
|
227
|
+
f"A constant referenced nowhere in the repository stays flagged, got: {issues}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_returns_empty_list_at_file_cap(
|
|
232
|
+
neutral_root: Path, monkeypatch: pytest.MonkeyPatch
|
|
233
|
+
) -> None:
|
|
234
|
+
monkeypatch.setattr("code_rules_dead_module_constant.MAX_SCAN_ROOT_FILE_COUNT", 0)
|
|
235
|
+
constants_path = _build_constants_package(
|
|
236
|
+
neutral_root / "workflow",
|
|
237
|
+
CONSTANTS_BODY,
|
|
238
|
+
"def noop() -> None:\n pass\n",
|
|
239
|
+
)
|
|
240
|
+
issues = _check(CONSTANTS_BODY, str(constants_path))
|
|
241
|
+
assert issues == [], f"File cap hit must return [] (cannot prove dead), got: {issues}"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_widened_scan_reads_each_file_at_most_once(
|
|
245
|
+
neutral_root: Path, monkeypatch: pytest.MonkeyPatch
|
|
246
|
+
) -> None:
|
|
247
|
+
constants_body = 'CROSS_TREE_CONSTANT = "cross"\nLOCALLY_DEAD_CONSTANT = "dead"\n'
|
|
248
|
+
sibling_consumer_body = (
|
|
249
|
+
"from shared.theme_db.config.constants import CROSS_TREE_CONSTANT\n"
|
|
250
|
+
"\n"
|
|
251
|
+
"def tally() -> str:\n"
|
|
252
|
+
" return CROSS_TREE_CONSTANT\n"
|
|
253
|
+
)
|
|
254
|
+
constants_path = _build_cross_tree_repository(
|
|
255
|
+
neutral_root, constants_body, sibling_consumer_body
|
|
256
|
+
)
|
|
257
|
+
package_tree_neighbor = constants_path.parent.parent / "neighbor.py"
|
|
258
|
+
package_tree_neighbor.write_text(
|
|
259
|
+
"def neighbor() -> int:\n return 1\n", encoding="utf-8"
|
|
260
|
+
)
|
|
261
|
+
read_counts: dict[str, int] = {}
|
|
262
|
+
original_read_text = Path.read_text
|
|
263
|
+
|
|
264
|
+
def counting_read_text(self: Path, *positional: object, **keyword: object) -> str:
|
|
265
|
+
normalized_key = os.path.normcase(str(self.resolve()))
|
|
266
|
+
read_counts[normalized_key] = read_counts.get(normalized_key, 0) + 1
|
|
267
|
+
return original_read_text(self, *positional, **keyword) # type: ignore[arg-type] # forwards args
|
|
268
|
+
|
|
269
|
+
monkeypatch.setattr(Path, "read_text", counting_read_text)
|
|
270
|
+
_check(constants_body, str(constants_path))
|
|
271
|
+
over_read_paths = {
|
|
272
|
+
each_path: each_count for each_path, each_count in read_counts.items() if each_count > 1
|
|
273
|
+
}
|
|
274
|
+
assert not over_read_paths, (
|
|
275
|
+
f"Widening must read each .py file at most once, got over-reads: {over_read_paths}"
|
|
276
|
+
)
|