claude-dev-env 1.70.0 → 1.72.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/CLAUDE.md +8 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
- package/agents/clean-coder.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/claude_md_orphan_file_blocker.py +39 -7
- package/hooks/blocking/code_rules_docstrings.py +60 -0
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_test_assertions.py +152 -1
- package/hooks/blocking/code_rules_type_escape.py +447 -2
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +36 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
- package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
- package/hooks/git-hooks/CLAUDE.md +1 -1
- package/hooks/git-hooks/git_hooks_constants/__init__.py +13 -0
- package/hooks/git-hooks/pre_push.py +74 -15
- package/hooks/git-hooks/test_pre_push.py +118 -0
- package/hooks/hooks_constants/blocking_check_limits.py +14 -0
- package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +13 -2
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +5 -2
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Show-Asset.ps1 +106 -0
- package/skills/autoconverge/SKILL.md +30 -3
- package/skills/autoconverge/reference/convergence.md +41 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
- package/skills/autoconverge/workflow/converge.mjs +176 -6
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -0
package/CLAUDE.md
CHANGED
|
@@ -27,6 +27,14 @@ When making code changes, make sure you are working in the proper worktree path
|
|
|
27
27
|
|
|
28
28
|
`Edit` changes existing files; `Write` creates new ones. Default to `Edit` — reach for `Write` only for a genuinely new path. For a true full rewrite, delete the file first, then `Write`.
|
|
29
29
|
|
|
30
|
+
## Showing Files: Open Them, Don't Print the Path
|
|
31
|
+
|
|
32
|
+
When I ask you to "show me", "open", "display", "let me see", or "pull up" a file — an image, PDF, HTML page, document, anything — open it on my screen. Launch the viewer so each image window matches the asset's size:
|
|
33
|
+
|
|
34
|
+
`Start-Process pwsh -WindowStyle Hidden -ArgumentList '-NoProfile','-File',"$HOME\.claude\scripts\Show-Asset.ps1",'<path 1>','<path 2>'`
|
|
35
|
+
|
|
36
|
+
It sizes each image window to the image (scaled down to fit the screen) and opens non-image files in their default app; pass every path I name. Printing a path or attaching the file is not showing it — do that only when the file truly cannot be opened, and say why.
|
|
37
|
+
|
|
30
38
|
## Test Philosophy
|
|
31
39
|
|
|
32
40
|
When writing tests, always write tests that actually test the behavior of the function against actual, real data and environments.
|
|
@@ -594,11 +594,11 @@ def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
|
|
|
594
594
|
Args:
|
|
595
595
|
content: File content as a single string for AST parsing.
|
|
596
596
|
file_path: Repository-relative POSIX path of the file (used to
|
|
597
|
-
skip non-Python code extensions early).
|
|
597
|
+
skip non-Python code extensions and test files early).
|
|
598
598
|
|
|
599
599
|
Returns:
|
|
600
|
-
List of violation strings, one per dropped optional kwarg.
|
|
601
|
-
|
|
600
|
+
List of violation strings, one per dropped optional kwarg. Empty for
|
|
601
|
+
a non-Python file, a test file, or a file with a syntax error.
|
|
602
602
|
"""
|
|
603
603
|
non_python_code_extensions = ALL_CODE_FILE_EXTENSIONS - {PYTHON_FILE_EXTENSION}
|
|
604
604
|
lowercase_file_path = file_path.lower()
|
|
@@ -607,6 +607,8 @@ def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
|
|
|
607
607
|
for each_extension in non_python_code_extensions
|
|
608
608
|
):
|
|
609
609
|
return []
|
|
610
|
+
if is_test_path(file_path):
|
|
611
|
+
return []
|
|
610
612
|
try:
|
|
611
613
|
tree = ast.parse(content)
|
|
612
614
|
except SyntaxError:
|
|
@@ -430,6 +430,45 @@ def test_check_wrapper_plumb_through_still_flags_attribute_call() -> None:
|
|
|
430
430
|
)
|
|
431
431
|
|
|
432
432
|
|
|
433
|
+
def test_check_wrapper_plumb_through_exempts_test_files() -> None:
|
|
434
|
+
source = (
|
|
435
|
+
"def _helper(name, *, clean_name=None):\n"
|
|
436
|
+
" return (name, clean_name)\n"
|
|
437
|
+
"\n"
|
|
438
|
+
"def test_uses_helper():\n"
|
|
439
|
+
" return _helper('a', clean_name='b')\n"
|
|
440
|
+
)
|
|
441
|
+
shared_issues = gate_module.check_wrapper_plumb_through(source, "pkg/test_thing.py")
|
|
442
|
+
bugteam_gate = _load_bugteam_gate_module()
|
|
443
|
+
bugteam_issues = bugteam_gate.check_wrapper_plumb_through(source, "pkg/test_thing.py")
|
|
444
|
+
assert shared_issues == [], (
|
|
445
|
+
"a test_* function in a test-file path that calls a module-level helper "
|
|
446
|
+
"exposing an optional kwarg is not a wrapper; the shared gate must exempt "
|
|
447
|
+
"test files and emit zero findings"
|
|
448
|
+
)
|
|
449
|
+
assert bugteam_issues == [], (
|
|
450
|
+
"the bugteam gate copy must apply the identical test-file exemption"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_check_wrapper_plumb_through_still_flags_non_test_path_with_test_shape() -> None:
|
|
455
|
+
source = (
|
|
456
|
+
"def _helper(name, *, clean_name=None):\n"
|
|
457
|
+
" return (name, clean_name)\n"
|
|
458
|
+
"\n"
|
|
459
|
+
"def test_uses_helper():\n"
|
|
460
|
+
" return _helper('a', clean_name='b')\n"
|
|
461
|
+
)
|
|
462
|
+
issues = gate_module.check_wrapper_plumb_through(source, "pkg/module.py")
|
|
463
|
+
assert any(
|
|
464
|
+
"test_uses_helper" in each_issue and "clean_name" in each_issue
|
|
465
|
+
for each_issue in issues
|
|
466
|
+
), (
|
|
467
|
+
"the test-file exemption is scoped to test paths only; the same wrapper "
|
|
468
|
+
"shape on a non-test path must still be flagged"
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
433
472
|
def test_split_violations_by_scope_accepts_all_added_line_numbers_param_name() -> None:
|
|
434
473
|
blocking_issues, advisory_issues = gate_module.split_violations_by_scope(
|
|
435
474
|
["Line 5: violation"],
|
package/agents/clean-coder.md
CHANGED
|
@@ -264,6 +264,7 @@ Tests document behavior. The hook layer enforces several constraints on test fil
|
|
|
264
264
|
- **No decorators named `skip*` on test functions.** Tests fail with a clear error rather than skip when a system dependency is missing. The hook fires on any decorator (whether `@skip_if_missing_dependency`, `@unittest.skipIf`, `@pytest.mark.skip`, or any custom variant) whose identifier contains the substring `skip`.
|
|
265
265
|
- **No existence-only tests.** A test whose entire body is `assert callable(x)`, `assert hasattr(module, "name")`, or `assert obj is not None` covers no behavior. Replace with an assertion that exercises the behavior — call the function and assert on its return value or side effect.
|
|
266
266
|
- **No constant-equality tests.** A test whose sole assertion is `assert CACHE_DIR == "cache"` (or any `UPPER_SNAKE == LITERAL` pattern) just verifies the constant has not changed. Delete it or replace with a behavior assertion.
|
|
267
|
+
- **No stale test names after a rename.** When you rename a function the tests exercise, rename the test functions in the same edit. The `check_stale_test_name_target` hook fires on a `test_*` name that embeds a snake_case run the file never imports, defines, or calls while the body calls a same-shape sibling — the signature of a producer rename that updated the bodies but left the test identifiers naming the deleted function.
|
|
267
268
|
- **No tautological assertions.** `assert CONSTANT == CONSTANT` and `assert hasattr(module, "name")` pass regardless of the implementation. Replace with assertions that would fail if the implementation regressed.
|
|
268
269
|
- **Test through the public API.** Do not assert on private state, hook return values, internal class fields, `_protected_field`, `__private_field`, or `component.state.X`. If the test needs visibility the public API does not provide, the public API needs a method, not the test.
|
|
269
270
|
- **For React components**, query in this priority order: `getByRole > getByLabelText > getByText > getByTestId`. Use `userEvent` over `fireEvent` (more realistic). Mock at API boundaries (network calls, external services), not internal hooks or utilities.
|
|
@@ -27,6 +27,7 @@ Decomposition is by the **kind of docstring claim** that needs to be cross-check
|
|
|
27
27
|
| O5 | Named-sentinel / filename references | A docstring names a sentinel marker, environment variable, filename, or magic string. Confirm the named token actually exists in the module body or in the repo's naming convention. |
|
|
28
28
|
| O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
29
29
|
| O7 | Module-doc-vs-split-module after refactor | When a refactor moves a responsibility to a sibling module, the originating module's docstring and the receiving module's docstring both describe the home of that responsibility. A module docstring should describe only the responsibilities it owns. |
|
|
30
|
+
| O8 | Companion-doc ordering/content vs producer | When a PR changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the PR diff never touched the `.md` file, because the behavior change orphaned the doc claim. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
30
31
|
|
|
31
32
|
---
|
|
32
33
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A–N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into
|
|
1
|
+
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A–N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into 8 sub-buckets below. Each sub-bucket REQUIRES 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
2
|
|
|
3
3
|
[ARTIFACT METADATA — include every changed module's docstring AND the exported symbols of that module so the audit can compare claim vs body]
|
|
4
4
|
|
|
@@ -47,9 +47,13 @@ ID prefix: `find`.
|
|
|
47
47
|
- When the diff includes a module split (one file becomes two), verify both modules' docstrings describe the responsibility each one actually owns after the split.
|
|
48
48
|
- Adversarial probes: (a) for each module in the split, list its exported symbols and compare to the docstring's claimed responsibilities; (b) grep the responsibility's verb against the originating module — does the originating docstring still claim what moved; (c) check for cross-module imports that reveal which file hosts each responsibility.
|
|
49
49
|
|
|
50
|
+
**O8. Companion-doc ordering/content vs producer**
|
|
51
|
+
- When the diff changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the diff never touched the `.md` file, because the behavior change orphaned the doc claim.
|
|
52
|
+
- Adversarial probes: (a) for each changed producer, name the artifact it builds and grep the skill's `SKILL.md` and sibling `.md` files for any sentence naming that artifact; (b) walk the producer body's build step — does it sort, or does it merge stored names and append in file order — and compare against the doc's order word (`sorted`, `alphabetical`); (c) check whether the doc's content claim (`just the at-risk names`, `only the current set`) hides merged-in prior entries the producer carries over from the stored file.
|
|
53
|
+
|
|
50
54
|
## Cross-bucket questions to answer at the end
|
|
51
55
|
|
|
52
|
-
Q1: Across all
|
|
56
|
+
Q1: Across all 8 sub-buckets, which docstring claim is the most misleading — i.e., a future maintainer reading only the docstring would write or change code that contradicts the body? Cite file:line of the docstring AND the body line(s) that contradict it.
|
|
53
57
|
|
|
54
58
|
Q2: Which docstring claim is at highest risk of becoming load-bearing — i.e., a future caller or test author would rely on the claim to skip reading the body? Cite the claim and the use case.
|
|
55
59
|
|
|
@@ -57,13 +61,13 @@ Q3: Of the changed docstrings, which one most clearly shows a refactor was incom
|
|
|
57
61
|
|
|
58
62
|
## Output
|
|
59
63
|
|
|
60
|
-
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket O1-
|
|
64
|
+
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket O1-O8, produce Shape A or Shape B (with ≥3 probes). Each Shape A finding must cite (a) the docstring file:line, (b) the body file:line that contradicts it, and (c) one sentence describing the contradiction in concrete terms. Cross-bucket Q1-Q3 answers after the per-sub-bucket walk. Adversarial second pass: "assume your first pass missed at least 3 module-level docstring claims whose implementation moved during a refactor — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
|
|
61
65
|
|
|
62
66
|
---
|
|
63
67
|
|
|
64
68
|
# Worked example: jl-cmd/claude-code-config PR #522
|
|
65
69
|
|
|
66
|
-
Audit jl-cmd/claude-code-config PR #522 for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A-N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into
|
|
70
|
+
Audit jl-cmd/claude-code-config PR #522 for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A-N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into 8 sub-buckets below.
|
|
67
71
|
|
|
68
72
|
PR #522 split `pr_description_command_parser.py` into two modules — the original parser and a new `pr_description_pr_number.py` — but the originating module's docstring still claims the PR-number recovery responsibility. A sibling change to `pr_description_body_audit.py` introduced a module docstring whose verb (`detects vague language`) overstates the module's actual responsibility (it only exposes `_extract_vague_scan_text()`; detection runs elsewhere).
|
|
69
73
|
|
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 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`) 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 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)
|
|
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
|
|
|
@@ -24,6 +24,7 @@ if _hooks_dir not in sys.path:
|
|
|
24
24
|
sys.path.insert(0, _hooks_dir)
|
|
25
25
|
|
|
26
26
|
from hooks_constants.claude_md_orphan_file_blocker_constants import ( # noqa: E402
|
|
27
|
+
ALL_NOISE_DIRECTORY_NAMES,
|
|
27
28
|
ALL_REFERENCED_FILE_EXTENSIONS,
|
|
28
29
|
CLAUDE_MD_FILENAME,
|
|
29
30
|
CODE_FENCE_PATTERN,
|
|
@@ -237,13 +238,35 @@ class _SubtreeScan:
|
|
|
237
238
|
self.was_scan_complete = was_scan_complete
|
|
238
239
|
|
|
239
240
|
|
|
241
|
+
def _is_under_noise_directory(scan_root: Path, candidate_path: Path) -> bool:
|
|
242
|
+
"""Return whether *candidate_path* lies inside a pruned noise directory.
|
|
243
|
+
|
|
244
|
+
A noise directory (``.git``, ``__pycache__``, ``node_modules``, and the test
|
|
245
|
+
and lint caches) holds volatile generated files that no CLAUDE.md table
|
|
246
|
+
documents, so the walk skips them. This keeps generated files out of the
|
|
247
|
+
basename set and keeps them from consuming the scan budget.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
scan_root: The directory the walk descends from.
|
|
251
|
+
candidate_path: A path the walk yielded under the scan root.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
True when any path segment below *scan_root* names a noise directory.
|
|
255
|
+
"""
|
|
256
|
+
try:
|
|
257
|
+
relative_segments = candidate_path.relative_to(scan_root).parts
|
|
258
|
+
except ValueError:
|
|
259
|
+
relative_segments = candidate_path.parts
|
|
260
|
+
return any(each_segment in ALL_NOISE_DIRECTORY_NAMES for each_segment in relative_segments)
|
|
261
|
+
|
|
262
|
+
|
|
240
263
|
def _scan_subtree_basenames(scan_root: Path) -> _SubtreeScan:
|
|
241
264
|
"""Return the bounded basename scan of *scan_root*, skipping unreadable entries.
|
|
242
265
|
|
|
243
266
|
Walks the subtree collecting each file's basename, stopping once the scan
|
|
244
|
-
budget is reached. A
|
|
245
|
-
|
|
246
|
-
set is authoritative.
|
|
267
|
+
budget is reached. A path inside a noise directory is pruned, and a per-entry
|
|
268
|
+
stat error skips that entry. The result records whether the walk completed
|
|
269
|
+
within the budget, so the caller knows whether the set is authoritative.
|
|
247
270
|
|
|
248
271
|
Args:
|
|
249
272
|
scan_root: The directory whose subtree bounds the existence search.
|
|
@@ -254,6 +277,8 @@ def _scan_subtree_basenames(scan_root: Path) -> _SubtreeScan:
|
|
|
254
277
|
all_basenames: set[str] = set()
|
|
255
278
|
scanned_count = 0
|
|
256
279
|
for each_path in scan_root.rglob("*"):
|
|
280
|
+
if _is_under_noise_directory(scan_root, each_path):
|
|
281
|
+
continue
|
|
257
282
|
try:
|
|
258
283
|
if not each_path.is_file():
|
|
259
284
|
continue
|
|
@@ -270,7 +295,9 @@ def _filename_exists_under(scan_root: Path, filename: str) -> bool:
|
|
|
270
295
|
"""Return whether a file with basename *filename* exists anywhere under root.
|
|
271
296
|
|
|
272
297
|
A direct probe that resolves one filename deterministically even when the
|
|
273
|
-
bounded subtree walk was truncated.
|
|
298
|
+
bounded subtree walk was truncated. A match inside a noise directory is pruned
|
|
299
|
+
so the probe agrees with the bounded walk, and an unreadable entry mid-walk is
|
|
300
|
+
skipped.
|
|
274
301
|
|
|
275
302
|
Args:
|
|
276
303
|
scan_root: The directory whose subtree bounds the existence search.
|
|
@@ -280,6 +307,8 @@ def _filename_exists_under(scan_root: Path, filename: str) -> bool:
|
|
|
280
307
|
True when at least one matching file is reachable under the scan root.
|
|
281
308
|
"""
|
|
282
309
|
for each_match in scan_root.rglob(filename):
|
|
310
|
+
if _is_under_noise_directory(scan_root, each_match):
|
|
311
|
+
continue
|
|
283
312
|
try:
|
|
284
313
|
if each_match.is_file():
|
|
285
314
|
return True
|
|
@@ -327,9 +356,10 @@ def find_missing_filenames(content: str, claude_md_directory: Path) -> list[str]
|
|
|
327
356
|
siblings. A table block that declares an explicit relative-path source (a
|
|
328
357
|
``../`` token in the block or the prose that introduces it) yields no findings
|
|
329
358
|
for that block's rows, since those files legitimately live elsewhere; an
|
|
330
|
-
unrelated block in the same file is still checked.
|
|
331
|
-
|
|
332
|
-
|
|
359
|
+
unrelated block in the same file is still checked. When the content references
|
|
360
|
+
no bare filename, no findings result and the subtree walk is skipped. A
|
|
361
|
+
filesystem error that halts the whole subtree walk yields no findings (fail
|
|
362
|
+
open), so an unreadable tree never blocks a write.
|
|
333
363
|
|
|
334
364
|
Args:
|
|
335
365
|
content: The CLAUDE.md content being written.
|
|
@@ -340,6 +370,8 @@ def find_missing_filenames(content: str, claude_md_directory: Path) -> list[str]
|
|
|
340
370
|
first-seen order with duplicates removed, capped at the issue budget.
|
|
341
371
|
"""
|
|
342
372
|
referenced_filenames = find_referenced_filenames(content)
|
|
373
|
+
if not referenced_filenames:
|
|
374
|
+
return []
|
|
343
375
|
scan_root = _resolve_scan_root(claude_md_directory)
|
|
344
376
|
try:
|
|
345
377
|
present_filenames = _present_referenced_filenames(referenced_filenames, scan_root)
|
|
@@ -24,12 +24,14 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
24
24
|
ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES,
|
|
25
25
|
ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES,
|
|
26
26
|
ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES,
|
|
27
|
+
ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES,
|
|
27
28
|
DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
|
|
28
29
|
DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
|
|
29
30
|
MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
|
|
30
31
|
MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
|
|
31
32
|
MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
|
|
32
33
|
MAX_DOCSTRING_FORMAT_ISSUES,
|
|
34
|
+
MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES,
|
|
33
35
|
MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
|
|
34
36
|
)
|
|
35
37
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
@@ -559,3 +561,61 @@ def check_class_docstring_names_public_methods(
|
|
|
559
561
|
if len(issues) >= MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES:
|
|
560
562
|
break
|
|
561
563
|
return issues[:MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES]
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _docstring_claims_no_consumer(docstring_text: str) -> str:
|
|
567
|
+
lowered_docstring = docstring_text.lower()
|
|
568
|
+
for each_phrase in ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES:
|
|
569
|
+
if each_phrase in lowered_docstring:
|
|
570
|
+
return each_phrase
|
|
571
|
+
return ""
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def check_docstring_no_consumer_claim(content: str, file_path: str) -> list[str]:
|
|
575
|
+
"""Flag a docstring that asserts no consumer reads its produced artifact yet.
|
|
576
|
+
|
|
577
|
+
A producer docstring claiming "no consumer reads it yet" (or
|
|
578
|
+
"producer-only artifact") is a transitional statement that drifts the moment
|
|
579
|
+
a consumer lands. Once a submission run, gate, or any reader loads the
|
|
580
|
+
artifact, the claim contradicts both the live behavior and any companion
|
|
581
|
+
SKILL.md that documents the consumer — the Category O8 docstring /
|
|
582
|
+
companion-doc producer-consumer drift. The claim is also a no-historical /
|
|
583
|
+
no-transitional-language violation in its own right: a docstring describes
|
|
584
|
+
the contract that exists, not a not-yet-wired future. Rephrase to state what
|
|
585
|
+
reads the artifact, or drop the no-consumer sentence entirely.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
content: The source text to inspect.
|
|
589
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
One issue per function whose docstring claims no consumer reads its
|
|
593
|
+
output, capped at the module limit.
|
|
594
|
+
"""
|
|
595
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
596
|
+
return []
|
|
597
|
+
try:
|
|
598
|
+
parsed_tree = ast.parse(content)
|
|
599
|
+
except SyntaxError:
|
|
600
|
+
return []
|
|
601
|
+
issues: list[str] = []
|
|
602
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
603
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
604
|
+
continue
|
|
605
|
+
if _function_has_exempt_decorator(each_node):
|
|
606
|
+
continue
|
|
607
|
+
docstring_text = _function_docstring_text(each_node)
|
|
608
|
+
if not docstring_text:
|
|
609
|
+
continue
|
|
610
|
+
matched_phrase = _docstring_claims_no_consumer(docstring_text)
|
|
611
|
+
if not matched_phrase:
|
|
612
|
+
continue
|
|
613
|
+
issues.append(
|
|
614
|
+
f"Line {each_node.lineno}: {each_node.name}() docstring claims "
|
|
615
|
+
f"'{matched_phrase}' — a no-consumer-yet claim drifts the moment a reader "
|
|
616
|
+
"lands and contradicts any companion SKILL.md; state what reads the artifact "
|
|
617
|
+
"or drop the sentence (Category O8 docstring / companion-doc drift)"
|
|
618
|
+
)
|
|
619
|
+
if len(issues) >= MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES:
|
|
620
|
+
break
|
|
621
|
+
return issues[:MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES]
|
|
@@ -69,6 +69,7 @@ from code_rules_docstrings import ( # noqa: E402
|
|
|
69
69
|
check_docstring_args_match_signature,
|
|
70
70
|
check_docstring_fallback_branch_coverage,
|
|
71
71
|
check_docstring_format,
|
|
72
|
+
check_docstring_no_consumer_claim,
|
|
72
73
|
)
|
|
73
74
|
from code_rules_duplicate_body import ( # noqa: E402
|
|
74
75
|
advise_cross_skill_duplicate_helper,
|
|
@@ -122,6 +123,7 @@ from code_rules_test_assertions import ( # noqa: E402
|
|
|
122
123
|
check_existence_check_tests,
|
|
123
124
|
check_flag_gated_scenario_test_naming,
|
|
124
125
|
check_skip_decorators_in_tests,
|
|
126
|
+
check_stale_test_name_target,
|
|
125
127
|
)
|
|
126
128
|
from code_rules_test_branching_except import ( # noqa: E402
|
|
127
129
|
check_bare_except,
|
|
@@ -252,6 +254,7 @@ def validate_content(
|
|
|
252
254
|
all_issues.extend(check_docstring_format(effective_content, file_path))
|
|
253
255
|
all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
|
|
254
256
|
all_issues.extend(check_docstring_fallback_branch_coverage(effective_content, file_path))
|
|
257
|
+
all_issues.extend(check_docstring_no_consumer_claim(effective_content, file_path))
|
|
255
258
|
all_issues.extend(
|
|
256
259
|
check_class_docstring_names_public_methods(effective_content, file_path)
|
|
257
260
|
)
|
|
@@ -282,6 +285,7 @@ def validate_content(
|
|
|
282
285
|
)
|
|
283
286
|
all_issues.extend(check_existence_check_tests(content, file_path))
|
|
284
287
|
all_issues.extend(check_constant_equality_tests(content, file_path))
|
|
288
|
+
all_issues.extend(check_stale_test_name_target(content, file_path))
|
|
285
289
|
check_flag_gated_scenario_test_naming(content, file_path)
|
|
286
290
|
all_issues.extend(check_unused_optional_parameters(content, file_path))
|
|
287
291
|
all_issues.extend(check_collection_prefix(content, file_path))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Skip-decorator, existence-only, constant-equality, and flag-gated scenario test-quality checks."""
|
|
1
|
+
"""Skip-decorator, existence-only, constant-equality, stale-test-name, and flag-gated scenario test-quality checks."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import sys
|
|
@@ -18,6 +18,10 @@ from code_rules_shared import ( # noqa: E402
|
|
|
18
18
|
is_test_file,
|
|
19
19
|
)
|
|
20
20
|
|
|
21
|
+
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
22
|
+
MAX_STALE_TEST_NAME_TARGET_ISSUES,
|
|
23
|
+
STALE_TEST_NAME_MINIMUM_SHARED_TOKEN_COUNT,
|
|
24
|
+
)
|
|
21
25
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
22
26
|
UPPER_SNAKE_CONSTANT_PATTERN,
|
|
23
27
|
)
|
|
@@ -346,3 +350,150 @@ def check_flag_gated_scenario_test_naming(content: str, file_path: str) -> list[
|
|
|
346
350
|
)
|
|
347
351
|
|
|
348
352
|
return []
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _called_function_names(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
|
|
356
|
+
"""Return the bare names of every function the test body calls."""
|
|
357
|
+
called_names: set[str] = set()
|
|
358
|
+
for each_node in ast.walk(function_node):
|
|
359
|
+
if not isinstance(each_node, ast.Call):
|
|
360
|
+
continue
|
|
361
|
+
callee = each_node.func
|
|
362
|
+
if isinstance(callee, ast.Name):
|
|
363
|
+
called_names.add(callee.id)
|
|
364
|
+
elif isinstance(callee, ast.Attribute):
|
|
365
|
+
called_names.add(callee.attr)
|
|
366
|
+
return called_names
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _module_known_callable_names(syntax_tree: ast.Module) -> set[str]:
|
|
370
|
+
"""Return every callable-like name the module imports, defines, or calls.
|
|
371
|
+
|
|
372
|
+
A stale test name embeds a function that has been renamed away, so its name
|
|
373
|
+
appears nowhere as a real symbol. This set is the universe of names that DO
|
|
374
|
+
exist in the file, used to confirm the embedded name is absent.
|
|
375
|
+
"""
|
|
376
|
+
known_names: set[str] = set()
|
|
377
|
+
for each_node in ast.walk(syntax_tree):
|
|
378
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
379
|
+
known_names.add(each_node.name)
|
|
380
|
+
elif isinstance(each_node, ast.ImportFrom):
|
|
381
|
+
for each_alias in each_node.names:
|
|
382
|
+
known_names.add(each_alias.asname or each_alias.name)
|
|
383
|
+
elif isinstance(each_node, ast.Import):
|
|
384
|
+
for each_alias in each_node.names:
|
|
385
|
+
known_names.add((each_alias.asname or each_alias.name).split(".")[0])
|
|
386
|
+
elif isinstance(each_node, ast.Call):
|
|
387
|
+
callee = each_node.func
|
|
388
|
+
if isinstance(callee, ast.Name):
|
|
389
|
+
known_names.add(callee.id)
|
|
390
|
+
elif isinstance(callee, ast.Attribute):
|
|
391
|
+
known_names.add(callee.attr)
|
|
392
|
+
return known_names
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _leading_token_overlap(first_name: str, second_name: str) -> int:
|
|
396
|
+
"""Return how many leading underscore-separated tokens two names share."""
|
|
397
|
+
first_tokens = first_name.split("_")
|
|
398
|
+
second_tokens = second_name.split("_")
|
|
399
|
+
shared = 0
|
|
400
|
+
for first_token, second_token in zip(first_tokens, second_tokens):
|
|
401
|
+
if first_token != second_token:
|
|
402
|
+
break
|
|
403
|
+
shared += 1
|
|
404
|
+
return shared
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _renamed_sibling_for_candidate(candidate_name: str, called_names: set[str]) -> str | None:
|
|
408
|
+
"""Return a called function that looks like the renamed form of the candidate.
|
|
409
|
+
|
|
410
|
+
A rename keeps the token count and the leading tokens but swaps one or more
|
|
411
|
+
interior or trailing tokens (``collect_skip_theme_names`` to
|
|
412
|
+
``collect_skip_clean_names``). The match requires an equal token count and a
|
|
413
|
+
shared leading run, which excludes an ordinary descriptive test suffix where
|
|
414
|
+
the called function is a strict shorter prefix of the embedded name.
|
|
415
|
+
"""
|
|
416
|
+
candidate_token_count = len(candidate_name.split("_"))
|
|
417
|
+
for each_called in sorted(called_names):
|
|
418
|
+
if each_called == candidate_name:
|
|
419
|
+
continue
|
|
420
|
+
if len(each_called.split("_")) != candidate_token_count:
|
|
421
|
+
continue
|
|
422
|
+
if (
|
|
423
|
+
_leading_token_overlap(candidate_name, each_called)
|
|
424
|
+
>= STALE_TEST_NAME_MINIMUM_SHARED_TOKEN_COUNT
|
|
425
|
+
):
|
|
426
|
+
return each_called
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _embedded_target_candidates(test_name: str) -> list[str]:
|
|
431
|
+
"""Return the function-name candidates a test name embeds after its test_ prefix.
|
|
432
|
+
|
|
433
|
+
For ``test_collect_skip_theme_names_keeps_only_sorted_at_risk`` the candidates
|
|
434
|
+
are the successive leading runs ``collect_skip_theme_names``,
|
|
435
|
+
``collect_skip_theme``, ``collect_skip`` — longest first — so the embedded
|
|
436
|
+
function name is matched before its shorter prefixes.
|
|
437
|
+
"""
|
|
438
|
+
if not test_name.startswith("test_"):
|
|
439
|
+
return []
|
|
440
|
+
remainder_tokens = test_name[len("test_"):].split("_")
|
|
441
|
+
candidates: list[str] = []
|
|
442
|
+
for token_count in range(len(remainder_tokens), STALE_TEST_NAME_MINIMUM_SHARED_TOKEN_COUNT - 1, -1):
|
|
443
|
+
candidates.append("_".join(remainder_tokens[:token_count]))
|
|
444
|
+
return candidates
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def check_stale_test_name_target(content: str, file_path: str) -> list[str]:
|
|
448
|
+
"""Flag a test whose name embeds a renamed-away function the body no longer calls.
|
|
449
|
+
|
|
450
|
+
When a producer function is renamed (``collect_skip_theme_names`` to
|
|
451
|
+
``collect_skip_clean_names``) the test bodies are updated to call the new
|
|
452
|
+
name but the test function identifiers keep the old one. The result is a test
|
|
453
|
+
name advertising a function that exists nowhere in the file. This catches that
|
|
454
|
+
Category N test-name-versus-scenario drift: a ``test_*`` name embeds a
|
|
455
|
+
snake_case run of at least two tokens that names nothing the module imports,
|
|
456
|
+
defines, or calls, while the same test body calls a function sharing the
|
|
457
|
+
embedded run's leading tokens — the renamed sibling. Only applies to test
|
|
458
|
+
files; production files are exempt.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
content: The file body under validation.
|
|
462
|
+
file_path: Path to the file, used for the test-file gate.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
One issue per test whose name embeds a renamed-away target, capped at the
|
|
466
|
+
module limit.
|
|
467
|
+
"""
|
|
468
|
+
if not is_test_file(file_path):
|
|
469
|
+
return []
|
|
470
|
+
try:
|
|
471
|
+
syntax_tree = ast.parse(content)
|
|
472
|
+
except SyntaxError:
|
|
473
|
+
return []
|
|
474
|
+
|
|
475
|
+
known_names = _module_known_callable_names(syntax_tree)
|
|
476
|
+
issues: list[str] = []
|
|
477
|
+
for each_node in ast.walk(syntax_tree):
|
|
478
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
479
|
+
continue
|
|
480
|
+
if not each_node.name.startswith("test"):
|
|
481
|
+
continue
|
|
482
|
+
called_names = _called_function_names(each_node)
|
|
483
|
+
for each_candidate in _embedded_target_candidates(each_node.name):
|
|
484
|
+
if each_candidate in known_names:
|
|
485
|
+
break
|
|
486
|
+
renamed_sibling = _renamed_sibling_for_candidate(each_candidate, called_names)
|
|
487
|
+
if renamed_sibling is None:
|
|
488
|
+
continue
|
|
489
|
+
issues.append(
|
|
490
|
+
f"Line {each_node.lineno}: test {each_node.name!r} names "
|
|
491
|
+
f"{each_candidate!r}, which the file never imports, defines, or calls; "
|
|
492
|
+
f"the body calls {renamed_sibling!r} instead — rename the test to match "
|
|
493
|
+
"the function it exercises (Category N test-name-vs-scenario drift)"
|
|
494
|
+
)
|
|
495
|
+
if len(issues) >= MAX_STALE_TEST_NAME_TARGET_ISSUES:
|
|
496
|
+
return issues[:MAX_STALE_TEST_NAME_TARGET_ISSUES]
|
|
497
|
+
break
|
|
498
|
+
|
|
499
|
+
return issues[:MAX_STALE_TEST_NAME_TARGET_ISSUES]
|