claude-dev-env 2.0.1 → 2.0.2
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/clean-coder.md +31 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
- package/bin/install.test.mjs +2 -2
- package/docs/CLAUDE.md +6 -3
- package/docs/CODE_RULES.md +5 -1
- package/docs/agent-spawn-protocol.md +39 -0
- package/docs/nas-ssh-invocation.md +23 -0
- package/docs/worker-completion-gate.md +33 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +11 -5
- package/hooks/blocking/config/verified_commit_constants.py +8 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +58 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +133 -0
- package/hooks/blocking/test_verification_verdict_store.py +12 -0
- package/hooks/blocking/test_verified_commit_docs_delta.py +176 -0
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -1
- package/hooks/hooks_constants/dead_module_constant_constants.py +8 -0
- package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +2 -3
- package/rules/agent-spawn-protocol.md +5 -43
- package/rules/code-standards.md +1 -36
- package/rules/env-var-table-code-drift.md +2 -21
- package/rules/hook-prose-matches-detector.md +5 -16
- package/rules/nas-ssh-invocation.md +3 -15
- package/rules/no-historical-clutter.md +7 -49
- package/rules/no-inline-destructive-literals.md +3 -5
- package/rules/package-inventory-stale-entry.md +7 -32
- package/rules/re-stage-before-commit.md +6 -23
- package/rules/shell-invocation-policy.md +1 -1
- package/rules/vault-context.md +3 -3
- package/rules/workers-done-before-complete.md +2 -30
- package/scripts/claude_chain_runner.py +39 -3
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +3 -0
- package/scripts/test_claude_chain_runner.py +112 -0
- package/scripts/test_grok_headless_runner.py +0 -1
- package/skills/auditing-claude-config/SKILL.md +1 -1
- package/skills/everything-search/SKILL.md +7 -1
- package/skills/orchestrator/SKILL.md +161 -147
- package/system-prompts/CLAUDE.md +3 -3
- package/docs/agents-md-alignment-plan.md +0 -123
- package/docs/emotion-informed-prompt-design.md +0 -362
- package/rules/es-exe-file-search.md +0 -17
package/agents/clean-coder.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: clean-coder
|
|
3
3
|
description: "Use PROACTIVELY for ALL code generation — feature development, bug fixes, refactoring, hook creation, automation scripts, and any task that produces code. Internalizes CODE_RULES.md and the 8-dimension readability standard so thoroughly that /check finds zero issues. The definitive code-writing agent."
|
|
4
4
|
model: opus
|
|
5
|
-
tools: Read, Write, Edit, Bash, Grep, Glob, Task, Skill
|
|
5
|
+
tools: Read, Write, Edit, Bash, Grep, Glob, Task, Skill, SendMessage
|
|
6
6
|
color: green
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -389,6 +389,36 @@ BEFORE writing:
|
|
|
389
389
|
[12] One abstraction level throughout?
|
|
390
390
|
```
|
|
391
391
|
|
|
392
|
+
## Pre-Check Loop — Validate Candidate Content Before Each Write
|
|
393
|
+
|
|
394
|
+
The write gate reports every violation in one denial, so a blocked write is a poor place to find a rule. Run the checker on the candidate content first, read the verdict, fix each line, and re-run until the verdict is clean. Then call Write or Edit, and the write lands on the first try.
|
|
395
|
+
|
|
396
|
+
### The two local surfaces
|
|
397
|
+
|
|
398
|
+
- **`code_rules_enforcer.py --check <candidate> [--as <target>]`** — the full CODE_RULES verdict on one complete candidate file. Stage the candidate at any path and point `--as` at the real destination. The `--as` target drives every path-based decision (test-file detection, `config/` exemption, hook-infrastructure exemption), so a candidate staged in a temp folder is judged as if it sat at its destination. Exit `0` is clean or an exempt target; `1` is one or more violations, each on its own line, or an unreadable candidate; `2` is a malformed flag sequence.
|
|
399
|
+
- **`run_all_validators.py`** — the validator suite over the git-changed files: Ruff, Mypy, type safety, magic values, abbreviations, and more. `--pre-tool-use` runs the same suite as a gate on one proposed write from stdin. `--health` reports whether the suite runs.
|
|
400
|
+
|
|
401
|
+
Point `--as` at the file's real production path. When `--as` names an exempt path, the check returns `0` for a candidate that carries violations, so a wrong target hides the verdict you want.
|
|
402
|
+
|
|
403
|
+
### The loop
|
|
404
|
+
|
|
405
|
+
1. Build the full candidate content.
|
|
406
|
+
2. Stage it at a temp path with the Write tool or the PowerShell tool.
|
|
407
|
+
3. Run `code_rules_enforcer.py --check <staged path> --as <real destination>`.
|
|
408
|
+
4. Read every reported line and fix each one in the candidate.
|
|
409
|
+
5. Re-run step 3. A fix can raise a fresh violation: a magic number pulled to a module constant needs the `ALL_` collection prefix, and that rename can leave the source constant with no reader; a long function split to meet the length target can push the file past the length advisory. Loop steps 3 through 5 until the verdict is clean.
|
|
410
|
+
6. Call Write or Edit with the clean content.
|
|
411
|
+
|
|
412
|
+
Re-running the checker after each fix catches the rework a single pass misses, so the write clears the gate one time.
|
|
413
|
+
|
|
414
|
+
### Windows shell shape
|
|
415
|
+
|
|
416
|
+
Create every multi-line script with the PowerShell tool or the Write tool. A bash heredoc on Windows strips backslashes from paths and garbles multi-line Python, so a heredoc-built script fails in ways its source text hides. For a wait-for-condition step, drive a Monitor until-loop; a `sleep && tail` poll chain blocks the turn and misses the moment the condition flips.
|
|
417
|
+
|
|
418
|
+
### Mining session transcripts
|
|
419
|
+
|
|
420
|
+
When reading transcripts under the user projects directory, pass explicit file paths to the reader, or search through Python or `Select-String`. A `.gitignore` holding `*` in that tree makes ripgrep and Grep directory recursion return zero matches with no error, so a recursive pattern reads as a clean miss even when files match.
|
|
421
|
+
|
|
392
422
|
## Constants Protocol
|
|
393
423
|
|
|
394
424
|
Decision tree before writing any constant:
|
|
@@ -66,7 +66,7 @@ Inline the artifact under this section using the section types defined in the ch
|
|
|
66
66
|
**E8. Stub / placeholder code without TODO**
|
|
67
67
|
- Distinguish real-behavior `pass` / `continue` / empty-handler bodies (intentional swallowing of expected exceptions, no-op branches that exist to satisfy a contract) from scaffolding stubs.
|
|
68
68
|
- Real-behavior bodies do NOT require a TODO; the audit must state the rationale (e.g., "rmdir race with concurrent writer is intentionally swallowed").
|
|
69
|
-
- Scaffolding bodies (`pass`, `...`, `raise NotImplementedError`, empty `else { }`, single-statement `return None` placeholders) without a `# TODO` comment ARE Category E findings under the project's
|
|
69
|
+
- Scaffolding bodies (`pass`, `...`, `raise NotImplementedError`, empty `else { }`, single-statement `return None` placeholders) without a `# TODO` comment ARE Category E findings under the project's `CODE_RULES.md` → "COMMENT PRESERVATION" rule.
|
|
70
70
|
- Adversarial probes for proof-of-absence: (a) any empty brace block in PowerShell / TypeScript / Go (`{ }` with no statements)? (b) any function whose entire body is `pass` / `return` / `return None`? (c) any branch that exits cleanly only because the surrounding loop is no-op for an empty input — is the no-op intentional or a placeholder?
|
|
71
71
|
|
|
72
72
|
**E9. Constants-module exports with no importer**
|
|
@@ -165,7 +165,7 @@ ID prefix: `find`.
|
|
|
165
165
|
- `sweep_empty_dirs.py` line 28: the second `except OSError: continue` similarly is intended behavior (skip the directory whose ctime is unreadable), not a stub.
|
|
166
166
|
- No `...` literal anywhere in the four files.
|
|
167
167
|
- No `raise NotImplementedError` anywhere.
|
|
168
|
-
- No `# TODO` markers in the diff — the project's own rule (`
|
|
168
|
+
- No `# TODO` markers in the diff — the project's own rule (`CODE_RULES.md` → "COMMENT PRESERVATION") requires TODOs only for scaffolding/placeholder code. The two `pass`/`continue` bodies above are production behavior, not scaffolding.
|
|
169
169
|
- Adversarial probes for proof-of-absence: (a) does the PowerShell script have an empty `else { }` or empty branch body? — scan lines 14-71 for any `{ }` with no statements between the braces. (b) does any function body consist of a single `pass` or `return` with no work done? — every function body in this PR performs at least one statement. (c) does the `Status` branch (lines 14-31) exit cleanly even when `$task.Triggers` is empty? — the `foreach` loop at line 26 is a no-op for an empty collection, which is correct behavior, not a stub.
|
|
170
170
|
|
|
171
171
|
**E9. Constants-module exports with no importer**
|
package/bin/install.test.mjs
CHANGED
|
@@ -803,13 +803,13 @@ test('managedHookScriptRelativePathsFromSourceRoots unions managed scripts acros
|
|
|
803
803
|
hooks: { Stop: [{ matcher: '', hooks: [{ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py' }] }] },
|
|
804
804
|
});
|
|
805
805
|
writeHooksJsonAtRoot(dependencyRoot, {
|
|
806
|
-
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/
|
|
806
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/example_hook.py' }] }] },
|
|
807
807
|
});
|
|
808
808
|
|
|
809
809
|
const relativePaths = managedHookScriptRelativePathsFromSourceRoots([builtinRoot, dependencyRoot]);
|
|
810
810
|
|
|
811
811
|
assert.ok(relativePaths.has('blocking/code_rules_enforcer.py'));
|
|
812
|
-
assert.ok(relativePaths.has('blocking/
|
|
812
|
+
assert.ok(relativePaths.has('blocking/example_hook.py'));
|
|
813
813
|
for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
|
|
814
814
|
assert.ok(relativePaths.has(foldedPath), `folded hook ${foldedPath} must always be in the managed set`);
|
|
815
815
|
}
|
package/docs/CLAUDE.md
CHANGED
|
@@ -14,8 +14,9 @@ Reference documentation installed into `~/.claude/docs/` by `bin/install.mjs`. T
|
|
|
14
14
|
| `PR_DESCRIPTION_GUIDE.md` | Authoritative reference for the `pr-description-writer` agent; PR body shapes derived from a 120-PR Anthropic corpus |
|
|
15
15
|
| `DJANGO_PATTERNS.md` | Django-specific coding patterns |
|
|
16
16
|
| `REACT_PATTERNS.md` | React-specific coding patterns |
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
17
|
+
| `agent-spawn-protocol.md` | Full agent-spawn protocol behind the `rules/agent-spawn-protocol.md` kernel: context-sufficiency check, `/prompt-generator` prompt crafting, and the spawn step |
|
|
18
|
+
| `nas-ssh-invocation.md` | Full NAS ssh policy behind the `rules/nas-ssh-invocation.md` kernel: the OpenSSH binary form, config sources, and hook enforcement |
|
|
19
|
+
| `worker-completion-gate.md` | Full worker-completion gate behind the `rules/workers-done-before-complete.md` kernel: the checklist, examples, and run-state records |
|
|
19
20
|
|
|
20
21
|
## Subdirectory
|
|
21
22
|
|
|
@@ -25,4 +26,6 @@ Reference documentation installed into `~/.claude/docs/` by `bin/install.mjs`. T
|
|
|
25
26
|
|
|
26
27
|
## Load pattern
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
A rule points to a doc with the path wrapped in backticks, such as `@~/.claude/docs/<file>.md`. The backticks make it a plain pointer: Claude Code reads the doc only when a rule, skill, or agent opens it, so the doc stays out of session-start context. The same path without backticks expands into context at launch when it sits in a file that loads at session start.
|
|
30
|
+
|
|
31
|
+
The `InstructionsLoaded` hook confirms this: a bare `@`-import fires an `include` load event; a backtick-wrapped path fires none.
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -8,6 +8,8 @@ Compact reference for agents. ⚡ marks rules enforced by `code_rules_enforcer.p
|
|
|
8
8
|
|
|
9
9
|
**Keep existing comments in place.** Only evaluate comments on lines you are actively changing. Do not add new inline comments in production code — write self-documenting code. Docstrings (module/class/function) are always allowed. Test files are exempt. The hook treats the two directions differently: adding a new inline comment blocks the edit, while removing an existing comment prints a stderr advisory and lets the edit through.
|
|
10
10
|
|
|
11
|
+
Scaffolding and placeholder code carry a `TODO:` comment naming the permanent implementation and why the code is temporary — the documented exception to the no-new-comments rule.
|
|
12
|
+
|
|
11
13
|
---
|
|
12
14
|
|
|
13
15
|
## CORE PRINCIPLES
|
|
@@ -16,6 +18,7 @@ Compact reference for agents. ⚡ marks rules enforced by `code_rules_enforcer.p
|
|
|
16
18
|
- **Centralized configuration** — every constant lives in ONE place (`config/`).
|
|
17
19
|
- **Reuse before create** — search first, import second, create last.
|
|
18
20
|
- **Encapsulation enables cleaner naming** — `isMaxLevel(level)` > `level >= MAXIMUM_LEVEL`.
|
|
21
|
+
- **Construction logic lives in the model** — path/URL building, formatting, and transformations belong on the model or service that owns the data; a string pattern built at two or more call sites moves to a method there.
|
|
19
22
|
|
|
20
23
|
---
|
|
21
24
|
|
|
@@ -37,7 +40,7 @@ Before writing ANY constant: search `config/` for the exact value → semantic m
|
|
|
37
40
|
|
|
38
41
|
## 5. NO ABBREVIATIONS
|
|
39
42
|
|
|
40
|
-
Full words only (`context`, not `ctx`). Exceptions: `i`/`j`/`k` in loops, `e` for exception. Naming patterns: loop vars `each_*`; booleans `is_/has_/should_/can_/was_/did_`; collections `all_*`; maps `X_by_Y`; preposition params (`from_path=`, `to=`, `into=`). Banned names: `result`, `data`, `output`, `response`, `value`, `item`, `temp`. Banned prefixes: `handle`, `process`, `manage`, `do`.
|
|
43
|
+
Full words only (`context`, not `ctx`). Exceptions: `i`/`j`/`k` in loops, `e` for exception. Naming patterns: loop vars `each_*`; booleans `is_/has_/should_/can_/was_/did_`; collections `all_*`; maps `X_by_Y`; preposition params (`from_path=`, `to=`, `into=`). Banned names: `result`, `data`, `output`, `response`, `value`, `item`, `temp`. Banned prefixes: `handle`, `process`, `manage`, `do`. Name a component for what it IS — `Overlay`, `Validator`, `InvoicePreview`.
|
|
41
44
|
|
|
42
45
|
---
|
|
43
46
|
|
|
@@ -55,6 +58,7 @@ Advisory only, never blocking: soft advisory at >= 400 lines, strong nudge at >=
|
|
|
55
58
|
|
|
56
59
|
**Simple > Clever. Functions > Classes. Concrete > Abstract.**
|
|
57
60
|
Never: ABC for single impl, DI frameworks, factory for single type. Always: functions when no state, concrete classes, simple imports.
|
|
61
|
+
Parameters follow YAGNI: add an optional parameter when a caller varies the value; when every call site passes the same value, make it required or inline the constant. Remove parameters no caller passes and no body reads.
|
|
58
62
|
|
|
59
63
|
## 7.5 SOLID PRINCIPLES
|
|
60
64
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Agent Spawn Protocol
|
|
2
|
+
|
|
3
|
+
Full protocol behind the always-on `rules/agent-spawn-protocol.md` kernel. It applies before any Agent or Task tool invocation — Explore, implementation, research, or team subagents.
|
|
4
|
+
|
|
5
|
+
## Step 1: Context sufficiency check
|
|
6
|
+
|
|
7
|
+
Before writing any agent prompt, confirm you can answer all of these:
|
|
8
|
+
|
|
9
|
+
- What specific files, directories, or areas of the codebase are involved?
|
|
10
|
+
- What constraints apply — patterns to follow, things to leave untouched, boundaries?
|
|
11
|
+
- What does success look like — expected output, acceptance criteria?
|
|
12
|
+
- Is the task unambiguous enough to delegate?
|
|
13
|
+
|
|
14
|
+
When any answer is "I don't know", investigate first (read files, search code) or ask the user. Do not spawn with incomplete context.
|
|
15
|
+
|
|
16
|
+
## Step 2: Craft the prompt with /prompt-generator
|
|
17
|
+
|
|
18
|
+
Run the `/prompt-generator` skill to produce a structured prompt. Feed it:
|
|
19
|
+
|
|
20
|
+
- The task description and goal
|
|
21
|
+
- Target files and directories found in Step 1
|
|
22
|
+
- Constraints and boundaries
|
|
23
|
+
- Expected output format
|
|
24
|
+
- Acceptance criteria
|
|
25
|
+
|
|
26
|
+
The skill asks one to three clarifying questions when information is missing — this is the built-in context verification. Use the skill's output as the agent's `prompt` parameter.
|
|
27
|
+
|
|
28
|
+
## Step 3: Spawn the agent
|
|
29
|
+
|
|
30
|
+
Pass the structured prompt from Step 2 to the Agent or Task tool.
|
|
31
|
+
|
|
32
|
+
## Why
|
|
33
|
+
|
|
34
|
+
An agent that receives a vague prompt wastes tokens exploring in circles, produces code that misses constraints, and needs a second pass. A small investment in prompt quality through `/prompt-generator` saves a long agent failure. This holds for Explore agents, which waste context on unfocused searches, and for execution agents, which write wrong code.
|
|
35
|
+
|
|
36
|
+
## Relationship to other rules
|
|
37
|
+
|
|
38
|
+
- `conservative-action.md` gates acting when intent is ambiguous. This protocol extends that: an ambiguous task goes to investigation or a user question first, never straight to a subagent.
|
|
39
|
+
- Project-specific rules or `~/.claude/CLAUDE.md` may decide whether to use subagents at all; this protocol governs how to craft the prompt once you delegate.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# NAS SSH Invocation Policy
|
|
2
|
+
|
|
3
|
+
Full detail behind the always-on `rules/nas-ssh-invocation.md` kernel. It applies to any `ssh`, `scp`, or `sftp` command against the NAS.
|
|
4
|
+
|
|
5
|
+
## Why the Windows OpenSSH binary
|
|
6
|
+
|
|
7
|
+
Git Bash's MSYS `ssh` reads `~/.ssh/id_ed25519` as world-readable through its ACL mapping, rejects the key as bad permissions, offers no key, and falls back to an interactive password prompt. In an unattended session no one answers that prompt, so the session hangs. The `System32/OpenSSH` binary authenticates the same key without a prompt.
|
|
8
|
+
|
|
9
|
+
## The form to use
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
"/c/Windows/System32/OpenSSH/ssh.exe" -o BatchMode=yes -o ConnectTimeout=10 -p 22 operator@nas.example.local "<cmd>"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`scp` and `sftp` take the matching `System32/OpenSSH` binary, `-o BatchMode=yes`, and the same port (`-P` for `scp`).
|
|
16
|
+
|
|
17
|
+
The host, ssh user, and port come from the `CLAUDE_NAS_*` environment variables or `~/.claude/local-identity.json`; the committed examples show placeholders (`nas.example.local`, `operator`, `22`).
|
|
18
|
+
|
|
19
|
+
`-o BatchMode=yes` is required, not optional: it turns a key-authentication failure into a loud non-zero exit rather than a silent password prompt, so an auth regression surfaces as an error you can read.
|
|
20
|
+
|
|
21
|
+
## Enforcement
|
|
22
|
+
|
|
23
|
+
`nas_ssh_binary_enforcer.py` (PreToolUse on Bash) denies a bare `ssh`/`scp`/`sftp` command word aimed at the NAS host and points at the full-binary form. It also denies the full `System32/OpenSSH` binary to that host when the command omits `-o BatchMode=yes`. Commands to any other host, and commands that mention the address without an ssh-family command word, pass.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Worker Completion Gate
|
|
2
|
+
|
|
3
|
+
Full detail behind the always-on `rules/workers-done-before-complete.md` kernel. It applies before marking any task `completed` when the task spawned workers — subagents, workflow agents, or background shells.
|
|
4
|
+
|
|
5
|
+
## The gate
|
|
6
|
+
|
|
7
|
+
A task reaches `completed` only when two things hold: every worker it spawned has finished, and each worker's result is merged into run state — `state.json`, `pr-converge-state.json`, the task list, or whatever record the task keeps. A worker that still runs, or one that finished but whose output never landed in run state, leaves the task open.
|
|
8
|
+
|
|
9
|
+
List the live workers before you mark the task complete. When a worker is dead or hung, that is a finding to record and report, not a result to drop in silence. A step that waits on workers ends its turn `in_progress` with a wakeup scheduled, so the run picks the workers back up rather than closing the task without them.
|
|
10
|
+
|
|
11
|
+
## Checklist before marking complete
|
|
12
|
+
|
|
13
|
+
| Check | Action |
|
|
14
|
+
|---|---|
|
|
15
|
+
| Are any spawned workers still running? | List them; when yes, stay `in_progress` and schedule a wakeup. |
|
|
16
|
+
| Did every finished worker return a result? | Read each result; a dead or hung worker is a finding to report. |
|
|
17
|
+
| Is each result merged into run state? | Write it to `state.json` or the task list before closing. |
|
|
18
|
+
| Does the task's own goal now hold? | Confirm against the merged state, not a worker's self-report. |
|
|
19
|
+
|
|
20
|
+
Mark `completed` only when every row passes.
|
|
21
|
+
|
|
22
|
+
## Examples
|
|
23
|
+
|
|
24
|
+
**Wrong:** Marking the audit task complete while two bugteam workers still run in the background.
|
|
25
|
+
**Right:** List the workers, see two still running, keep the task `in_progress`, and schedule a wakeup to collect them.
|
|
26
|
+
|
|
27
|
+
**Wrong:** A worker crashes; the task closes as complete because the other workers finished.
|
|
28
|
+
**Right:** Record the crashed worker as a finding, report it, and hold the task open until its work is covered.
|
|
29
|
+
|
|
30
|
+
## Relationship to other rules
|
|
31
|
+
|
|
32
|
+
- `long-horizon-autonomy` covers acting on what you have and not ending a turn on a promise. This gate names the specific completion condition: workers finished and their results merged.
|
|
33
|
+
- `skills/pr-converge/reference/state-schema.md` defines the run-state records a worker's result lands in before the task closes.
|
|
@@ -74,6 +74,7 @@ from hooks_constants.dead_module_constant_constants import ( # noqa: E402
|
|
|
74
74
|
CONFIG_DIRECTORY_SEGMENT,
|
|
75
75
|
CONSTANTS_MODULE_SUFFIX,
|
|
76
76
|
DEAD_MODULE_CONSTANT_GUIDANCE,
|
|
77
|
+
DEAD_MODULE_CONSTANT_RETRY_GUIDANCE,
|
|
77
78
|
DUNDER_ALL_NAME,
|
|
78
79
|
DUNDER_INIT_FILENAME,
|
|
79
80
|
GIT_DIRECTORY_NAME,
|
|
@@ -204,9 +205,11 @@ def _referenced_names_in_source(
|
|
|
204
205
|
"""Return every name a module references — imported, read, or re-exported.
|
|
205
206
|
|
|
206
207
|
Collects imported binding names, ``from`` import member names, name
|
|
207
|
-
references,
|
|
208
|
-
|
|
209
|
-
|
|
208
|
+
references, both the root and the member name of each attribute access (so
|
|
209
|
+
``module.CONSTANT`` counts ``CONSTANT`` as read), and (when
|
|
210
|
+
``collect_string_literals`` is set) string literals, so a name listed in an
|
|
211
|
+
``__all__`` literal or named in a string annotation counts as a reference. A
|
|
212
|
+
module that fails to parse
|
|
210
213
|
contributes no names. With ``load_only`` set, only ``Load``-context names
|
|
211
214
|
count, so a constant's own assignment target in the module being judged does
|
|
212
215
|
not count as a reference to itself.
|
|
@@ -234,6 +237,8 @@ def _referenced_names_in_source(
|
|
|
234
237
|
if load_only and not isinstance(each_node.ctx, ast.Load):
|
|
235
238
|
continue
|
|
236
239
|
referenced_names.add(each_node.id)
|
|
240
|
+
elif isinstance(each_node, ast.Attribute):
|
|
241
|
+
referenced_names.add(each_node.attr)
|
|
237
242
|
elif isinstance(each_node, ast.Import | ast.ImportFrom):
|
|
238
243
|
for each_alias in each_node.names:
|
|
239
244
|
referenced_names.add(each_alias.asname or each_alias.name)
|
|
@@ -631,8 +636,9 @@ def check_dead_module_constants(
|
|
|
631
636
|
if each_name in all_referenced_names:
|
|
632
637
|
continue
|
|
633
638
|
issues.append(
|
|
634
|
-
f"Line {each_line}: module-level constant {each_name!r}"
|
|
635
|
-
f" - {DEAD_MODULE_CONSTANT_GUIDANCE}"
|
|
639
|
+
f"Line {each_line}: module-level constant {each_name!r} in"
|
|
640
|
+
f" {written_path.name} - {DEAD_MODULE_CONSTANT_GUIDANCE}"
|
|
641
|
+
f" {DEAD_MODULE_CONSTANT_RETRY_GUIDANCE}"
|
|
636
642
|
)
|
|
637
643
|
if len(issues) >= MAX_DEAD_MODULE_CONSTANT_ISSUES:
|
|
638
644
|
break
|
|
@@ -131,7 +131,14 @@ CORRECTIVE_MESSAGE = (
|
|
|
131
131
|
"and recorded baselines; when it finishes with a clean verdict the "
|
|
132
132
|
"SubagentStop hook mints the verdict and this command will pass. Any "
|
|
133
133
|
"file change after verification invalidates the verdict, so verify "
|
|
134
|
-
"last.
|
|
134
|
+
"last. The verdict binds to the exact surface content (every changed "
|
|
135
|
+
"and untracked file's bytes), not to a work-tree path, so a commit "
|
|
136
|
+
"from a different work tree passes only when its surface is "
|
|
137
|
+
"byte-identical; a work tree carrying any extra or later edit has a "
|
|
138
|
+
"surface no verdict covers. To land this change, run this command "
|
|
139
|
+
"from the work tree that holds the verified surface, or verify this "
|
|
140
|
+
"surface here. Exempt automatically: docs/image files, pytest test "
|
|
141
|
+
"files, and "
|
|
135
142
|
"Python files whose docstring- and comment-stripped AST is unchanged "
|
|
136
143
|
"(comment-only edits in non-Python files are not exempt)."
|
|
137
144
|
)
|
|
@@ -448,3 +448,61 @@ def test_widened_scan_reads_each_file_at_most_once(
|
|
|
448
448
|
assert not over_read_paths, (
|
|
449
449
|
f"Widening must read each .py file at most once, got over-reads: {over_read_paths}"
|
|
450
450
|
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _build_settings_package(workflow_directory: Path, constants_body: str) -> Path:
|
|
454
|
+
constants_package = workflow_directory / "app_settings"
|
|
455
|
+
constants_package.mkdir(parents=True)
|
|
456
|
+
(constants_package / "__init__.py").write_text("", encoding="utf-8")
|
|
457
|
+
constants_path = constants_package / "app_settings_constants.py"
|
|
458
|
+
constants_path.write_text(constants_body, encoding="utf-8")
|
|
459
|
+
return constants_path
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def test_deny_once_records_intent_then_retry_passes_after_consumer(
|
|
463
|
+
neutral_root: Path,
|
|
464
|
+
) -> None:
|
|
465
|
+
constants_body = 'PATH_ENV_NAME = "THEME_PATH"\n'
|
|
466
|
+
workflow_directory = neutral_root / "workflow"
|
|
467
|
+
constants_path = _build_settings_package(workflow_directory, constants_body)
|
|
468
|
+
first_issues = _check(constants_body, str(constants_path))
|
|
469
|
+
assert any("PATH_ENV_NAME" in each_issue for each_issue in first_issues), first_issues
|
|
470
|
+
assert any(
|
|
471
|
+
"app_settings_constants.py" in each_issue for each_issue in first_issues
|
|
472
|
+
), f"Deny message must record the target constants module, got: {first_issues}"
|
|
473
|
+
assert any(
|
|
474
|
+
"re-issue this write" in each_issue for each_issue in first_issues
|
|
475
|
+
), f"Deny message must instruct the retry escape, got: {first_issues}"
|
|
476
|
+
consumer_body = (
|
|
477
|
+
"from app_settings.app_settings_constants import PATH_ENV_NAME\n"
|
|
478
|
+
"\n"
|
|
479
|
+
"def resolve() -> str:\n"
|
|
480
|
+
" return PATH_ENV_NAME\n"
|
|
481
|
+
)
|
|
482
|
+
(workflow_directory / "consumer_service.py").write_text(
|
|
483
|
+
consumer_body, encoding="utf-8"
|
|
484
|
+
)
|
|
485
|
+
retry_issues = _check(constants_body, str(constants_path))
|
|
486
|
+
assert retry_issues == [], (
|
|
487
|
+
f"Once the consumer is on disk the retry passes, got: {retry_issues}"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def test_dead_constant_stays_denied_on_every_attempt(neutral_root: Path) -> None:
|
|
492
|
+
constants_body = 'PATH_ENV_NAME = "THEME_PATH"\n'
|
|
493
|
+
workflow_directory = neutral_root / "workflow"
|
|
494
|
+
constants_path = _build_settings_package(workflow_directory, constants_body)
|
|
495
|
+
(workflow_directory / "unrelated.py").write_text(
|
|
496
|
+
"def noop() -> int:\n return 1\n", encoding="utf-8"
|
|
497
|
+
)
|
|
498
|
+
first_issues = _check(constants_body, str(constants_path))
|
|
499
|
+
second_issues = _check(constants_body, str(constants_path))
|
|
500
|
+
for each_attempt in (first_issues, second_issues):
|
|
501
|
+
assert any("PATH_ENV_NAME" in each_issue for each_issue in each_attempt), each_attempt
|
|
502
|
+
assert any(
|
|
503
|
+
"app_settings_constants.py" in each_issue for each_issue in each_attempt
|
|
504
|
+
), f"Every deny attempt records the target module, got: {each_attempt}"
|
|
505
|
+
assert any(
|
|
506
|
+
"re-issue this write" in each_issue for each_issue in each_attempt
|
|
507
|
+
), f"A dead constant stays denied with the retry escape, got: {each_attempt}"
|
|
508
|
+
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Callable, Iterator
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
ENFORCER_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
|
|
14
|
+
specification = importlib.util.spec_from_file_location("code_rules_enforcer", ENFORCER_PATH)
|
|
15
|
+
assert specification is not None and specification.loader is not None
|
|
16
|
+
code_rules_enforcer = importlib.util.module_from_spec(specification)
|
|
17
|
+
specification.loader.exec_module(code_rules_enforcer)
|
|
18
|
+
|
|
19
|
+
CONSTANTS_BODY = 'MEDIUM_TERMINAL = "terminal"\nMEDIUM_CODE = "code"\nMEDIUM_TEXT = "text"\n'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _strip_read_only_and_retry(
|
|
23
|
+
removal_function: Callable[[str], object],
|
|
24
|
+
target_path: str,
|
|
25
|
+
_exc_info: BaseException,
|
|
26
|
+
) -> None:
|
|
27
|
+
try:
|
|
28
|
+
os.chmod(target_path, stat.S_IWRITE)
|
|
29
|
+
removal_function(target_path)
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def neutral_root() -> Iterator[Path]:
|
|
36
|
+
"""Yield a temp directory whose path carries no ``test_`` segment.
|
|
37
|
+
|
|
38
|
+
::
|
|
39
|
+
|
|
40
|
+
tmp_path (pytest default) -> embeds "test_..." in its name
|
|
41
|
+
-> is_test_file() misreads the path
|
|
42
|
+
mkdtemp(prefix="deadconst-") -> no "test_" segment -> reads as
|
|
43
|
+
a production path ok
|
|
44
|
+
|
|
45
|
+
A ``.git`` marker is planted at the root so the cross-tree widening
|
|
46
|
+
resolves the repository root to this synthetic tree.
|
|
47
|
+
"""
|
|
48
|
+
neutral_directory = Path(tempfile.mkdtemp(prefix="deadconst-")).resolve()
|
|
49
|
+
(neutral_directory / ".git").mkdir()
|
|
50
|
+
try:
|
|
51
|
+
yield neutral_directory
|
|
52
|
+
finally:
|
|
53
|
+
shutil.rmtree(neutral_directory, onexc=_strip_read_only_and_retry)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _check(source: str, file_path: str) -> list[str]:
|
|
57
|
+
return code_rules_enforcer.check_dead_module_constants(source, file_path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_constants_package(
|
|
61
|
+
workflow_directory: Path,
|
|
62
|
+
constants_body: str,
|
|
63
|
+
consumer_body: str,
|
|
64
|
+
) -> Path:
|
|
65
|
+
constants_package = workflow_directory / "report_constants"
|
|
66
|
+
constants_package.mkdir(parents=True)
|
|
67
|
+
(constants_package / "__init__.py").write_text("", encoding="utf-8")
|
|
68
|
+
constants_path = constants_package / "render_report_constants.py"
|
|
69
|
+
constants_path.write_text(constants_body, encoding="utf-8")
|
|
70
|
+
(workflow_directory / "render_report.py").write_text(consumer_body, encoding="utf-8")
|
|
71
|
+
return constants_path
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _assert_alias_read_constants_live(issues: list[str]) -> None:
|
|
75
|
+
assert not any(
|
|
76
|
+
"MEDIUM_TERMINAL" in each_issue or "MEDIUM_CODE" in each_issue for each_issue in issues
|
|
77
|
+
), f"A constant read as attribute of an imported module stays live, got: {issues}"
|
|
78
|
+
assert any("MEDIUM_TEXT" in each_issue for each_issue in issues), (
|
|
79
|
+
f"A constant read by no module must still be flagged, got: {issues}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_does_not_flag_constant_read_via_from_import_module_alias_attribute(
|
|
84
|
+
neutral_root: Path,
|
|
85
|
+
) -> None:
|
|
86
|
+
consumer_body = (
|
|
87
|
+
"from report_constants import render_report_constants as constants\n"
|
|
88
|
+
"\n"
|
|
89
|
+
"def panel_class(medium: str) -> str:\n"
|
|
90
|
+
" if medium == constants.MEDIUM_TERMINAL:\n"
|
|
91
|
+
" return 'terminal'\n"
|
|
92
|
+
" return 'code' if medium == constants.MEDIUM_CODE else 'other'\n"
|
|
93
|
+
)
|
|
94
|
+
constants_path = _build_constants_package(
|
|
95
|
+
neutral_root / "workflow", CONSTANTS_BODY, consumer_body
|
|
96
|
+
)
|
|
97
|
+
_assert_alias_read_constants_live(_check(CONSTANTS_BODY, str(constants_path)))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_does_not_flag_constant_read_via_import_as_alias_attribute(
|
|
101
|
+
neutral_root: Path,
|
|
102
|
+
) -> None:
|
|
103
|
+
consumer_body = (
|
|
104
|
+
"import report_constants.render_report_constants as constants\n"
|
|
105
|
+
"\n"
|
|
106
|
+
"def label(medium: str) -> str:\n"
|
|
107
|
+
" if medium == constants.MEDIUM_TERMINAL:\n"
|
|
108
|
+
" return 'terminal'\n"
|
|
109
|
+
" return 'code' if medium == constants.MEDIUM_CODE else 'other'\n"
|
|
110
|
+
)
|
|
111
|
+
constants_path = _build_constants_package(
|
|
112
|
+
neutral_root / "workflow", CONSTANTS_BODY, consumer_body
|
|
113
|
+
)
|
|
114
|
+
_assert_alias_read_constants_live(_check(CONSTANTS_BODY, str(constants_path)))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_does_not_flag_constant_read_via_plain_module_name_attribute(
|
|
118
|
+
neutral_root: Path,
|
|
119
|
+
) -> None:
|
|
120
|
+
consumer_body = (
|
|
121
|
+
"from report_constants import render_report_constants\n"
|
|
122
|
+
"\n"
|
|
123
|
+
"def label(medium: str) -> str:\n"
|
|
124
|
+
" if medium == render_report_constants.MEDIUM_TERMINAL:\n"
|
|
125
|
+
" return 'terminal'\n"
|
|
126
|
+
" if medium == render_report_constants.MEDIUM_CODE:\n"
|
|
127
|
+
" return 'code'\n"
|
|
128
|
+
" return 'other'\n"
|
|
129
|
+
)
|
|
130
|
+
constants_path = _build_constants_package(
|
|
131
|
+
neutral_root / "workflow", CONSTANTS_BODY, consumer_body
|
|
132
|
+
)
|
|
133
|
+
_assert_alias_read_constants_live(_check(CONSTANTS_BODY, str(constants_path)))
|
|
@@ -211,6 +211,18 @@ def test_corrective_message_scopes_comment_exemption_to_python() -> None:
|
|
|
211
211
|
assert "comment-, and test-only surfaces are exempt" not in lowered_message
|
|
212
212
|
|
|
213
213
|
|
|
214
|
+
def test_corrective_message_names_worktree_surface_keying() -> None:
|
|
215
|
+
lowered_message = CORRECTIVE_MESSAGE.lower()
|
|
216
|
+
assert "work tree" in lowered_message
|
|
217
|
+
assert "byte-identical" in lowered_message
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_corrective_message_gives_cross_worktree_remedy() -> None:
|
|
221
|
+
lowered_message = CORRECTIVE_MESSAGE.lower()
|
|
222
|
+
assert "run this command from the work tree" in lowered_message
|
|
223
|
+
assert "verify this surface here" in lowered_message
|
|
224
|
+
|
|
225
|
+
|
|
214
226
|
def test_untracked_claude_production_hook_is_gated(tmp_path: pathlib.Path) -> None:
|
|
215
227
|
work_dir = _make_repo_with_origin(tmp_path)
|
|
216
228
|
new_hook_dir = work_dir / ".claude" / "hooks" / "blocking"
|