claude-dev-env 8.10.0 → 8.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
- package/_shared/pr-loop/scripts/fix_pr_test_proof.py +233 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +47 -0
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +186 -0
- package/bin/codex-skill-load-block.mjs +93 -0
- package/bin/codex-skill-load-block.test.mjs +134 -0
- package/bin/install.agents-home.test.mjs +76 -1
- package/bin/install.mjs +55 -31
- package/bin/install.test.mjs +57 -0
- package/bin/install.transaction.test.mjs +29 -7
- package/bin/seed-codex-pstack-models.mjs +27 -0
- package/bin/seed-codex-pstack-models.test.mjs +72 -0
- package/docs/CODE_RULES.md +5 -1
- package/hooks/blocking/code_rules_enforcer.py +1 -3
- package/hooks/blocking/code_rules_magic_values.py +1 -3
- package/hooks/blocking/code_rules_paired_test.py +5 -6
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -3
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
- package/hooks/blocking/tests/test_repository_exemption.py +7 -0
- package/hooks/followup_ledger.py +8 -1
- package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
- package/hooks/hooks_constants/messages.py +0 -1
- package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
- package/hooks/hooks_constants/test_messages.py +1 -7
- package/hooks/lifecycle/nested_project_hooks.py +395 -0
- package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
- package/hooks/test_followup_ledger.py +18 -0
- package/package.json +2 -1
- package/pstack-codex-models.md +37 -0
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +53 -0
- package/rules/code-standards.md +1 -1
- package/rules/flag-non-breaking-findings.md +7 -0
- package/rules/paired-test-coverage.md +4 -2
- package/rules/testing.md +5 -1
- package/scripts/cde_lint.py +47 -1
- package/scripts/policy_lint/adapter_callers.py +124 -0
- package/scripts/policy_lint/adapter_pairing.py +4 -2
- package/scripts/policy_lint/adapter_support.py +4 -3
- package/scripts/policy_lint/adapters.py +15 -0
- package/scripts/policy_lint/check_catalog.py +24 -0
- package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
- package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
- package/scripts/policy_lint/config/constants.py +18 -0
- package/scripts/policy_lint/engine.py +9 -3
- package/scripts/policy_lint/model.py +6 -2
- package/scripts/policy_lint/registry.py +6 -0
- package/scripts/policy_lint/selection.py +2 -0
- package/scripts/repository_checks/tracked_private_terms.py +15 -1
- package/scripts/tests/test_cde_lint.py +50 -0
- package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
- package/scripts/tests/test_policy_lint_rules_callers.py +140 -0
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
- package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
- package/scripts/tests/test_policy_lint_selection.py +35 -0
- package/scripts/tests/test_tracked_private_terms.py +35 -0
- package/hooks/blocking/tdd_enforcer.py +0 -195
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
- package/hooks/blocking/test_tdd_enforcer.py +0 -1506
- package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
- package/scripts/codex_compat_watcher.py +0 -502
- package/scripts/tests/test_codex_compat_watcher.py +0 -599
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { seedCodexPstackModels } from './seed-codex-pstack-models.mjs';
|
|
7
|
+
|
|
8
|
+
test('fresh Codex home gets the selected roles and hook setting', (context) => {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
|
|
10
|
+
context.after(() => rmSync(root, { recursive: true, force: true }));
|
|
11
|
+
const codexHome = join(root, '.codex');
|
|
12
|
+
const claudeHome = join(root, '.claude');
|
|
13
|
+
mkdirSync(claudeHome);
|
|
14
|
+
writeFileSync(join(claudeHome, 'CLAUDE.md'), 'Claude guidance\n');
|
|
15
|
+
|
|
16
|
+
const paths = seedCodexPstackModels(codexHome);
|
|
17
|
+
const sheet = readFileSync(join(codexHome, 'pstack-models.md'), 'utf8');
|
|
18
|
+
const agents = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
|
|
19
|
+
assert.deepEqual(paths, [join(codexHome, 'pstack-models.md'), join(codexHome, 'AGENTS.md')]);
|
|
20
|
+
assert.match(sheet, /^feature, refactoring: gpt-6-sol$/m);
|
|
21
|
+
assert.match(sheet, /^bug-fix: gpt-6-astra$/m);
|
|
22
|
+
assert.match(sheet, /^how explorer: gpt-6-luna$/m);
|
|
23
|
+
assert.match(sheet, /^arena runners: gpt-6-astra, gpt-6-sol, gpt-6-luna$/m);
|
|
24
|
+
assert.match(sheet, /^session hook: on$/m);
|
|
25
|
+
assert.match(agents, /^feature, refactoring: gpt-6-sol$/m);
|
|
26
|
+
assert.doesNotMatch(agents, /^session hook:/m);
|
|
27
|
+
assert.equal(readFileSync(join(claudeHome, 'CLAUDE.md'), 'utf8'), 'Claude guidance\n');
|
|
28
|
+
assert.equal(existsSync(join(claudeHome, 'pstack-models.md')), false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('existing Codex configuration stays untouched on repeat installs', (context) => {
|
|
32
|
+
const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
|
|
33
|
+
context.after(() => rmSync(root, { recursive: true, force: true }));
|
|
34
|
+
const codexHome = join(root, '.codex');
|
|
35
|
+
seedCodexPstackModels(codexHome);
|
|
36
|
+
writeFileSync(join(codexHome, 'AGENTS.md'), 'Custom agents\n');
|
|
37
|
+
|
|
38
|
+
assert.equal(seedCodexPstackModels(codexHome), null);
|
|
39
|
+
assert.equal(readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'), 'Custom agents\n');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a failed AGENTS write removes only the sheet created by that attempt', (context) => {
|
|
43
|
+
const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
|
|
44
|
+
context.after(() => rmSync(root, { recursive: true, force: true }));
|
|
45
|
+
const codexHome = join(root, '.codex');
|
|
46
|
+
const claudeHome = join(root, '.claude');
|
|
47
|
+
mkdirSync(claudeHome);
|
|
48
|
+
writeFileSync(join(claudeHome, 'CLAUDE.md'), 'Existing Claude guidance\n');
|
|
49
|
+
|
|
50
|
+
const competingWrite = (agentsPath) => {
|
|
51
|
+
writeFileSync(agentsPath, 'Existing Codex guidance\n');
|
|
52
|
+
throw new Error('AGENTS.md already exists');
|
|
53
|
+
};
|
|
54
|
+
assert.throws(() => seedCodexPstackModels(codexHome, competingWrite), /already exists/);
|
|
55
|
+
assert.equal(existsSync(join(codexHome, 'pstack-models.md')), false);
|
|
56
|
+
assert.equal(readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'), 'Existing Codex guidance\n');
|
|
57
|
+
assert.equal(readFileSync(join(claudeHome, 'CLAUDE.md'), 'utf8'), 'Existing Claude guidance\n');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
for (const existingFile of ['AGENTS.md', 'pstack-models.md']) {
|
|
61
|
+
test(`partial Codex setup with ${existingFile} is preserved`, (context) => {
|
|
62
|
+
const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
|
|
63
|
+
context.after(() => rmSync(root, { recursive: true, force: true }));
|
|
64
|
+
const codexHome = join(root, '.codex');
|
|
65
|
+
mkdirSync(codexHome);
|
|
66
|
+
writeFileSync(join(codexHome, existingFile), 'Custom configuration\n');
|
|
67
|
+
|
|
68
|
+
assert.equal(seedCodexPstackModels(codexHome), null);
|
|
69
|
+
assert.equal(readFileSync(join(codexHome, existingFile), 'utf8'), 'Custom configuration\n');
|
|
70
|
+
assert.equal(existsSync(join(codexHome, existingFile === 'AGENTS.md' ? 'pstack-models.md' : 'AGENTS.md')), false);
|
|
71
|
+
});
|
|
72
|
+
}
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -75,7 +75,11 @@ Parameters follow YAGNI: add an optional parameter when a caller varies the valu
|
|
|
75
75
|
|
|
76
76
|
## 8. TDD PROCESS
|
|
77
77
|
|
|
78
|
-
1. **RED** — failing test
|
|
78
|
+
1. **RED** — a failing test. 2. **GREEN** — minimum code to pass. 3. **REFACTOR** — only if valuable.
|
|
79
|
+
|
|
80
|
+
This loop is the default for a bug fix and for new behavior, and the TDD skill (`pstack:tdd`) carries the procedure. No hook or lint checks the order. A prototype may run ahead of its tests and adds them before the pull request goes ready. A bug fix ships with a test that reproduces the bug.
|
|
81
|
+
|
|
82
|
+
**Proof of check.** Each pull request body states how the change was checked: a test, a run, a screenshot, or a measurement, named so a reviewer can repeat it. A reviewer flags a pull request whose body names no proof.
|
|
79
83
|
|
|
80
84
|
## 9. SELF-CONTAINED COMPONENTS
|
|
81
85
|
|
|
@@ -11,9 +11,7 @@ Advisory only (non-blocking):
|
|
|
11
11
|
|
|
12
12
|
Companion tests live alongside this file as
|
|
13
13
|
``test_code_rules_enforcer_<suffix>.py``; the ``<suffix>`` split keeps each
|
|
14
|
-
concern focused.
|
|
15
|
-
``test_code_rules_enforcer_*.py`` sibling as a test candidate for the
|
|
16
|
-
``code_rules_*`` module family, so the suffix files satisfy its gate.
|
|
14
|
+
concern focused.
|
|
17
15
|
"""
|
|
18
16
|
import json
|
|
19
17
|
import sys
|
|
@@ -36,9 +36,7 @@ def _mask_string_literals_preserving_length(source_line: str) -> str:
|
|
|
36
36
|
|
|
37
37
|
Matching tests live in
|
|
38
38
|
``test_code_rules_enforcer_magic_string_masking.py``, one of the
|
|
39
|
-
``test_code_rules_enforcer_<suffix>.py`` family files
|
|
40
|
-
``tdd_enforcer.py`` hook accepts as test candidates for the
|
|
41
|
-
``code_rules_*`` module family.
|
|
39
|
+
``test_code_rules_enforcer_<suffix>.py`` family files.
|
|
42
40
|
"""
|
|
43
41
|
|
|
44
42
|
string_literal_pattern = re.compile(
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
"""Public-function paired-test coverage check for ``code_rules_enforcer``.
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
orders, so a forgotten function gets a behavioral test before the
|
|
3
|
+
The staged policy lint's test-pairing rule judges coverage at file
|
|
4
|
+
granularity: a module whose dedicated test file exercises some public functions
|
|
5
|
+
while leaving one untested still satisfies it. This check closes that gap from
|
|
6
|
+
both write orders, so a forgotten function gets a behavioral test before the
|
|
8
7
|
partially-covered module lands.
|
|
9
8
|
|
|
10
9
|
``check_public_function_missing_paired_test`` fires on the production-module
|
|
@@ -25,7 +24,7 @@ Both checks stay conservative to keep false positives near zero:
|
|
|
25
24
|
|
|
26
25
|
- The production-side check runs only on a production module whose stem-matched
|
|
27
26
|
test file already exists; a module with no dedicated test file is out of scope
|
|
28
|
-
and left to the
|
|
27
|
+
and left to the staged lint's test-pairing rule. The test-side check runs only on a
|
|
29
28
|
stem-matched test file whose paired production module exists on disk.
|
|
30
29
|
- The production-side check fires only when the suite already exercises the
|
|
31
30
|
module — covering at least one of its public functions, or referencing one of
|
|
@@ -64,7 +64,7 @@ def repository_allowlisted_values(repository_root: Path) -> frozenset[str]:
|
|
|
64
64
|
The exact literal values the repository's commits and writes may carry
|
|
65
65
|
past the PII scan, or an empty set when the repository has none.
|
|
66
66
|
"""
|
|
67
|
-
origin_slug =
|
|
67
|
+
origin_slug = repository_origin_slug(repository_root)
|
|
68
68
|
if origin_slug is None:
|
|
69
69
|
return frozenset()
|
|
70
70
|
return pii_allowlisted_values_by_repository().get(origin_slug, frozenset())
|
|
@@ -171,7 +171,7 @@ def _owner_repo_slug_from_origin_url(origin_url: str) -> str | None:
|
|
|
171
171
|
return _owner_repo_slug_from_path(repository_path)
|
|
172
172
|
|
|
173
173
|
|
|
174
|
-
def
|
|
174
|
+
def repository_origin_slug(repository_root: Path) -> str | None:
|
|
175
175
|
"""Return the lowercased owner/repo slug of the origin remote, or None.
|
|
176
176
|
|
|
177
177
|
Args:
|
|
@@ -211,7 +211,7 @@ def _is_repository_exempt_from_pii_scan(repository_root: Path) -> bool:
|
|
|
211
211
|
Returns:
|
|
212
212
|
True when the repository's origin slug is in the exempt set.
|
|
213
213
|
"""
|
|
214
|
-
origin_slug =
|
|
214
|
+
origin_slug = repository_origin_slug(repository_root)
|
|
215
215
|
if origin_slug is None:
|
|
216
216
|
return False
|
|
217
217
|
return origin_slug in pii_exempt_repository_slugs()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Content analysis the policy lint's adapter pairing rule loads to exempt constants-only and import-only edits."""
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
import os
|
|
7
6
|
import subprocess
|
|
8
7
|
import sys
|
|
@@ -21,13 +20,11 @@ if _HOOKS_DIRECTORY not in sys.path:
|
|
|
21
20
|
from code_rules_enforcer import main as enforcer_main # noqa: E402
|
|
22
21
|
from code_rules_shared import is_ephemeral_script_path # noqa: E402
|
|
23
22
|
from code_rules_enforcer_test_support import ( # noqa: E402
|
|
24
|
-
build_write_payload,
|
|
25
23
|
run_enforcer_cli,
|
|
26
24
|
run_write_entrypoint,
|
|
27
25
|
)
|
|
28
26
|
|
|
29
27
|
_ENFORCER_SCRIPT = Path(__file__).resolve().parent / "code_rules_enforcer.py"
|
|
30
|
-
_TDD_SCRIPT = Path(__file__).resolve().parent / "tdd_enforcer.py"
|
|
31
28
|
|
|
32
29
|
_VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
|
|
33
30
|
|
|
@@ -56,44 +53,6 @@ def _run_main_with_write_payload(
|
|
|
56
53
|
return run_write_entrypoint(enforcer_main, file_path, content)
|
|
57
54
|
|
|
58
55
|
|
|
59
|
-
def _run_tdd_with_write_payload(
|
|
60
|
-
file_path: str,
|
|
61
|
-
content: str,
|
|
62
|
-
) -> subprocess.CompletedProcess[str]:
|
|
63
|
-
"""Drive the TDD enforcer through subprocess with a Write payload.
|
|
64
|
-
|
|
65
|
-
Args:
|
|
66
|
-
file_path: The destination path the Write targets.
|
|
67
|
-
content: The production-looking content to write.
|
|
68
|
-
|
|
69
|
-
Returns:
|
|
70
|
-
The completed process carrying stdout, stderr, and the exit code.
|
|
71
|
-
"""
|
|
72
|
-
write_payload = build_write_payload(file_path, content)
|
|
73
|
-
return subprocess.run(
|
|
74
|
-
[sys.executable, str(_TDD_SCRIPT)],
|
|
75
|
-
input=write_payload,
|
|
76
|
-
capture_output=True,
|
|
77
|
-
text=True,
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
|
|
82
|
-
"""Extract the permissionDecision from a hook's JSON stdout.
|
|
83
|
-
|
|
84
|
-
Args:
|
|
85
|
-
completed: The completed subprocess carrying the hook's stdout.
|
|
86
|
-
|
|
87
|
-
Returns:
|
|
88
|
-
The permissionDecision string, or None when stdout is empty.
|
|
89
|
-
"""
|
|
90
|
-
if not completed.stdout:
|
|
91
|
-
return None
|
|
92
|
-
parsed = json.loads(completed.stdout)
|
|
93
|
-
hook_output = parsed.get("hookSpecificOutput", {})
|
|
94
|
-
return hook_output.get("permissionDecision")
|
|
95
|
-
|
|
96
|
-
|
|
97
56
|
def test_should_return_true_for_claude_job_dir_tmp_path(
|
|
98
57
|
tmp_path: Path,
|
|
99
58
|
monkeypatch: pytest.MonkeyPatch,
|
|
@@ -320,7 +279,7 @@ def test_should_exempt_same_path_set_on_both_gates(
|
|
|
320
279
|
tmp_path: Path,
|
|
321
280
|
monkeypatch: pytest.MonkeyPatch,
|
|
322
281
|
) -> None:
|
|
323
|
-
"""B21: every ephemeral path exits 0 on
|
|
282
|
+
"""B21: every ephemeral path exits 0 on enforcer main."""
|
|
324
283
|
monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
|
|
325
284
|
all_ephemeral_paths = [
|
|
326
285
|
str(tmp_path / "tmp" / "scratch.py"),
|
|
@@ -339,7 +298,3 @@ def test_should_exempt_same_path_set_on_both_gates(
|
|
|
339
298
|
assert "deny" not in captured_stdout.lower(), (
|
|
340
299
|
f"enforcer must not deny ephemeral path {each_ephemeral_path!r}"
|
|
341
300
|
)
|
|
342
|
-
completed = _run_tdd_with_write_payload(each_ephemeral_path, _VIOLATING_PRODUCTION_SOURCE)
|
|
343
|
-
assert _decision_from(completed) != "deny", (
|
|
344
|
-
f"TDD enforcer must not deny ephemeral path {each_ephemeral_path!r}"
|
|
345
|
-
)
|
|
@@ -496,8 +496,8 @@ def test_aggregate_exit_code_zero_with_no_output_allows() -> None:
|
|
|
496
496
|
def test_aggregate_explicit_allow_payload_signals_allow_decision() -> None:
|
|
497
497
|
"""An explicit permissionDecision allow from a hosted hook signals an allow decision.
|
|
498
498
|
|
|
499
|
-
|
|
500
|
-
|
|
499
|
+
A hosted hook can write an explicit allow payload, which auto-approves the
|
|
500
|
+
write standalone. The aggregator must surface that as an
|
|
501
501
|
explicit allow decision so the dispatcher re-emits it rather than silently
|
|
502
502
|
falling back to the default permission flow.
|
|
503
503
|
"""
|
|
@@ -680,43 +680,6 @@ def _orphan_claude_md_payload(tmp_path: Path) -> str:
|
|
|
680
680
|
return _write_payload(claude_md_path, orphan_table)
|
|
681
681
|
|
|
682
682
|
|
|
683
|
-
def _parse_hook_allow(completed_process: subprocess.CompletedProcess[str]) -> bool:
|
|
684
|
-
"""Parse one hook's subprocess result for an explicit permissionDecision allow.
|
|
685
|
-
|
|
686
|
-
Args:
|
|
687
|
-
completed_process: The completed subprocess from running a hook.
|
|
688
|
-
|
|
689
|
-
Returns:
|
|
690
|
-
True when the hook emitted an explicit allow decision.
|
|
691
|
-
"""
|
|
692
|
-
stdout_text = completed_process.stdout.strip()
|
|
693
|
-
if not stdout_text:
|
|
694
|
-
return False
|
|
695
|
-
try:
|
|
696
|
-
parsed_output = json.loads(stdout_text)
|
|
697
|
-
except json.JSONDecodeError:
|
|
698
|
-
return False
|
|
699
|
-
hook_specific = parsed_output.get("hookSpecificOutput", {})
|
|
700
|
-
if not isinstance(hook_specific, dict):
|
|
701
|
-
return False
|
|
702
|
-
return hook_specific.get("permissionDecision") == "allow"
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
def test_retired_tdd_hook_cannot_auto_approve_the_write(tmp_path: Path) -> None:
|
|
706
|
-
"""Pairing belongs to staged lint; its edit-time auto-approval is absent."""
|
|
707
|
-
config_target_path = str(tmp_path / "config" / "timing.py")
|
|
708
|
-
constants_only_content = (
|
|
709
|
-
'"""Timing constants."""\n\nMAXIMUM_RETRIES = 3\nRETRY_DELAY_SECONDS = 5\n'
|
|
710
|
-
)
|
|
711
|
-
payload_text = _write_payload(config_target_path, constants_only_content)
|
|
712
|
-
standalone_result = _run_hook_subprocess("blocking/tdd_enforcer.py", payload_text)
|
|
713
|
-
assert _parse_hook_allow(standalone_result)
|
|
714
|
-
dispatcher_result = _run_dispatcher(payload_text)
|
|
715
|
-
dispatcher_is_deny, reason = _parse_hook_decision(dispatcher_result)
|
|
716
|
-
assert not dispatcher_is_deny, reason
|
|
717
|
-
assert not _parse_hook_allow(dispatcher_result)
|
|
718
|
-
|
|
719
|
-
|
|
720
683
|
def test_runpy_hosted_hook_sees_its_own_argv_not_the_dispatchers(tmp_path: Path) -> None:
|
|
721
684
|
"""A runpy-hosted hook resolves its own script path as sys.argv, not the dispatcher's.
|
|
722
685
|
|
|
@@ -851,7 +814,6 @@ ALL_RETAINED_PATHS = {
|
|
|
851
814
|
ALL_RETIRED_PATHS = {
|
|
852
815
|
"validation/hook_format_validator.py",
|
|
853
816
|
"blocking/code_rules_enforcer.py",
|
|
854
|
-
"blocking/tdd_enforcer.py",
|
|
855
817
|
"blocking/windows_rmtree_blocker.py",
|
|
856
818
|
"blocking/state_description_blocker.py",
|
|
857
819
|
"blocking/subprocess_budget_completeness.py",
|
|
@@ -19,6 +19,7 @@ from pii_prevention_blocker_parts.repository_exemption import (
|
|
|
19
19
|
_is_repository_exempt_from_pii_scan,
|
|
20
20
|
_owner_repo_slug_from_origin_url,
|
|
21
21
|
repository_allowlisted_values,
|
|
22
|
+
repository_origin_slug,
|
|
22
23
|
)
|
|
23
24
|
|
|
24
25
|
_ALLOWED_VALUE = "owner.fixture" + "@" + "acme-corp" + ".example" + ".io"
|
|
@@ -103,3 +104,9 @@ def test_repository_without_origin_is_never_exempt(tmp_path: Path) -> None:
|
|
|
103
104
|
|
|
104
105
|
def test_owner_repo_slug_strips_a_trailing_slash_origin() -> None:
|
|
105
106
|
assert _owner_repo_slug_from_origin_url("https://github.com/Owner/Repo/") == "owner/repo"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_repository_origin_slug_reads_the_origin_remote(tmp_path: Path) -> None:
|
|
110
|
+
repository_root = tmp_path / "repo"
|
|
111
|
+
_init_repo_with_github_origin(repository_root, "SlugOwner/slug-repo")
|
|
112
|
+
assert repository_origin_slug(repository_root) == "slugowner/slug-repo"
|
package/hooks/followup_ledger.py
CHANGED
|
@@ -31,6 +31,8 @@ from hooks_constants.followup_ledger_constants import (
|
|
|
31
31
|
GIT_REFERENCE_PREFIX,
|
|
32
32
|
LEDGER_APPEND_MODE,
|
|
33
33
|
LEDGER_ENCODING,
|
|
34
|
+
LEDGER_IGNORE_FILE_NAME,
|
|
35
|
+
LEDGER_IGNORE_TEXT,
|
|
34
36
|
MESSAGE_KEY,
|
|
35
37
|
ORIGIN_COMMIT_KEY,
|
|
36
38
|
RULE_ID_KEY,
|
|
@@ -88,7 +90,9 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
|
|
|
88
90
|
"""Append one finding to the repository's ledger, once.
|
|
89
91
|
|
|
90
92
|
A finding already present in the ledger is left alone, so a gate that runs
|
|
91
|
-
on every commit records a standing smell a single time.
|
|
93
|
+
on every commit records a standing smell a single time. The ledger
|
|
94
|
+
directory carries a ``.gitignore`` matching every file in it, so the
|
|
95
|
+
ledger stays out of ``git status`` in any repository. Every filesystem
|
|
92
96
|
error is swallowed, so a ledger failure leaves the caller's gate decision
|
|
93
97
|
unchanged.
|
|
94
98
|
|
|
@@ -116,6 +120,9 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
|
|
|
116
120
|
)
|
|
117
121
|
try:
|
|
118
122
|
ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
ignore_path = ledger_path.parent / LEDGER_IGNORE_FILE_NAME
|
|
124
|
+
if not ignore_path.exists():
|
|
125
|
+
ignore_path.write_text(LEDGER_IGNORE_TEXT, encoding=LEDGER_ENCODING)
|
|
119
126
|
with ledger_path.open(LEDGER_APPEND_MODE, encoding=LEDGER_ENCODING) as ledger_file:
|
|
120
127
|
ledger_file.write(record_text + "\n")
|
|
121
128
|
except OSError:
|
|
@@ -14,7 +14,6 @@ from test_native_hook_support import run_git
|
|
|
14
14
|
|
|
15
15
|
ALL_RETIRED_HOOK_PATHS = (
|
|
16
16
|
"blocking/code_rules_enforcer.py",
|
|
17
|
-
"blocking/tdd_enforcer.py",
|
|
18
17
|
"blocking/windows_rmtree_blocker.py",
|
|
19
18
|
"blocking/state_description_blocker.py",
|
|
20
19
|
"blocking/subprocess_budget_completeness.py",
|
|
@@ -26,6 +25,7 @@ ALL_RETIRED_HOOK_PATHS = (
|
|
|
26
25
|
"blocking/plain_language_blocker.py",
|
|
27
26
|
"lifecycle/config_change_guard.py",
|
|
28
27
|
)
|
|
28
|
+
ALL_ARCHIVED_HOOK_PATHS = ("blocking/tdd_enforcer.py",)
|
|
29
29
|
PACKAGE_ROOT = Path(__file__).resolve().parents[2]
|
|
30
30
|
ALL_RULE_FIXTURES = (
|
|
31
31
|
("code-rules", "src/worker.py", "from typing import Any\n\ndef worker() -> Any:\n return None\n"),
|
|
@@ -80,7 +80,7 @@ def _legacy_settings_bytes(managed_root: Path, foreign_command: str) -> bytes:
|
|
|
80
80
|
"type": "command",
|
|
81
81
|
"command": f"python {managed_root.as_posix()}/hooks/{each_path}",
|
|
82
82
|
"timeout": 10,
|
|
83
|
-
} for each_path in ALL_RETIRED_HOOK_PATHS),
|
|
83
|
+
} for each_path in (*ALL_RETIRED_HOOK_PATHS, *ALL_ARCHIVED_HOOK_PATHS)),
|
|
84
84
|
],
|
|
85
85
|
}]},
|
|
86
86
|
}
|
|
@@ -135,6 +135,8 @@ def _assert_retired_registrations(managed_root: Path, settings_bytes: bytes, for
|
|
|
135
135
|
source = PACKAGE_ROOT / "hooks" / each_relative_path
|
|
136
136
|
installed = managed_root / "hooks" / each_relative_path
|
|
137
137
|
assert hashlib.sha256(installed.read_bytes()).digest() == hashlib.sha256(source.read_bytes()).digest()
|
|
138
|
+
for each_relative_path in ALL_ARCHIVED_HOOK_PATHS:
|
|
139
|
+
assert not any(each_relative_path in each_command for each_command in all_commands)
|
|
138
140
|
|
|
139
141
|
|
|
140
142
|
@pytest.fixture(scope="module")
|
package/hooks/hooks.json
CHANGED
|
@@ -51,6 +51,16 @@
|
|
|
51
51
|
"timeout": 25
|
|
52
52
|
}
|
|
53
53
|
]
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"matcher": "*",
|
|
57
|
+
"hooks": [
|
|
58
|
+
{
|
|
59
|
+
"type": "command",
|
|
60
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
|
|
61
|
+
"timeout": 120
|
|
62
|
+
}
|
|
63
|
+
]
|
|
54
64
|
}
|
|
55
65
|
],
|
|
56
66
|
"UserPromptSubmit": [
|
|
@@ -108,6 +118,11 @@
|
|
|
108
118
|
"type": "command",
|
|
109
119
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/issue_tracker_session_starter.py",
|
|
110
120
|
"timeout": 10
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"type": "command",
|
|
124
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
|
|
125
|
+
"timeout": 1800
|
|
111
126
|
}
|
|
112
127
|
]
|
|
113
128
|
},
|
|
@@ -6,6 +6,8 @@ __all__ = [
|
|
|
6
6
|
"ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS",
|
|
7
7
|
"LEDGER_ENCODING",
|
|
8
8
|
"LEDGER_APPEND_MODE",
|
|
9
|
+
"LEDGER_IGNORE_FILE_NAME",
|
|
10
|
+
"LEDGER_IGNORE_TEXT",
|
|
9
11
|
"RULE_ID_KEY",
|
|
10
12
|
"FILE_PATH_KEY",
|
|
11
13
|
"MESSAGE_KEY",
|
|
@@ -24,6 +26,8 @@ __all__ = [
|
|
|
24
26
|
ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS: tuple[str, ...] = (".claude", "followups", "smells.jsonl")
|
|
25
27
|
LEDGER_ENCODING: str = "utf-8"
|
|
26
28
|
LEDGER_APPEND_MODE: str = "a"
|
|
29
|
+
LEDGER_IGNORE_FILE_NAME: str = ".gitignore"
|
|
30
|
+
LEDGER_IGNORE_TEXT: str = "*\n"
|
|
27
31
|
|
|
28
32
|
RULE_ID_KEY: str = "rule_id"
|
|
29
33
|
FILE_PATH_KEY: str = "file_path"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Configuration constants for the ``nested_project_hooks`` forwarder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE: str = "CLAUDE_PROJECT_DIR"
|
|
6
|
+
CHECKOUT_MARKER_NAME: str = ".git"
|
|
7
|
+
SETTINGS_DIRECTORY_NAME: str = ".claude"
|
|
8
|
+
SETTINGS_FILE_NAME: str = "settings.json"
|
|
9
|
+
SETTINGS_ENCODING: str = "utf-8"
|
|
10
|
+
|
|
11
|
+
HOOKS_KEY: str = "hooks"
|
|
12
|
+
MATCHER_KEY: str = "matcher"
|
|
13
|
+
HOOK_TYPE_KEY: str = "type"
|
|
14
|
+
COMMAND_HOOK_TYPE: str = "command"
|
|
15
|
+
COMMAND_KEY: str = "command"
|
|
16
|
+
TIMEOUT_KEY: str = "timeout"
|
|
17
|
+
DEFAULT_HOOK_TIMEOUT_SECONDS: float = 600.0
|
|
18
|
+
ALL_MATCH_EVERYTHING_MATCHERS: frozenset[str] = frozenset({"", "*"})
|
|
19
|
+
|
|
20
|
+
HOOK_EVENT_NAME_KEY: str = "hook_event_name"
|
|
21
|
+
PRE_TOOL_USE_EVENT: str = "PreToolUse"
|
|
22
|
+
SESSION_START_EVENT: str = "SessionStart"
|
|
23
|
+
ALL_MATCH_TARGET_KEYS_BY_EVENT: dict[str, str] = {
|
|
24
|
+
PRE_TOOL_USE_EVENT: "tool_name",
|
|
25
|
+
SESSION_START_EVENT: "source",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ALL_SHELL_PROGRAM_NAMES: tuple[str, ...] = ("bash", "sh")
|
|
29
|
+
SHELL_COMMAND_FLAG: str = "-c"
|
|
30
|
+
|
|
31
|
+
BLOCKING_EXIT_CODE: int = 2
|
|
32
|
+
HOOK_SPECIFIC_OUTPUT_KEY: str = "hookSpecificOutput"
|
|
33
|
+
HOOK_EVENT_NAME_OUTPUT_KEY: str = "hookEventName"
|
|
34
|
+
PERMISSION_DECISION_KEY: str = "permissionDecision"
|
|
35
|
+
ALL_FORWARDED_PERMISSION_DECISIONS: frozenset[str] = frozenset({"deny", "ask"})
|
|
36
|
+
ADDITIONAL_CONTEXT_KEY: str = "additionalContext"
|
|
37
|
+
CONTEXT_SECTION_SEPARATOR: str = "\n\n"
|
|
@@ -4,17 +4,11 @@ from hooks_constants import messages
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
def test_module_carries_no_hedging_or_intent_ending_notices() -> None:
|
|
7
|
-
"""The hedging
|
|
8
|
-
dedicated notices no longer live here."""
|
|
7
|
+
"""The module carries no hedging or intent-only-ending notice."""
|
|
9
8
|
assert not hasattr(messages, "USER_FACING_NOTICE")
|
|
10
9
|
assert not hasattr(messages, "USER_FACING_INTENT_ENDING_NOTICE")
|
|
11
10
|
|
|
12
11
|
|
|
13
|
-
def test_user_facing_tdd_notice_is_nonempty_string() -> None:
|
|
14
|
-
assert isinstance(messages.USER_FACING_TDD_NOTICE, str)
|
|
15
|
-
assert messages.USER_FACING_TDD_NOTICE
|
|
16
|
-
|
|
17
|
-
|
|
18
12
|
def test_user_facing_askuserquestion_notice_is_nonempty_string() -> None:
|
|
19
13
|
assert isinstance(messages.USER_FACING_ASKUSERQUESTION_NOTICE, str)
|
|
20
14
|
assert messages.USER_FACING_ASKUSERQUESTION_NOTICE
|