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,37 @@
|
|
|
1
|
+
# pstack model configuration
|
|
2
|
+
|
|
3
|
+
feature, refactoring: gpt-6-sol
|
|
4
|
+
bug-fix: gpt-6-astra
|
|
5
|
+
perf-issue: gpt-6-sol
|
|
6
|
+
hillclimb: gpt-6-sol
|
|
7
|
+
judgment and prose: gpt-6-astra
|
|
8
|
+
strongest judgment: gpt-6-astra
|
|
9
|
+
how explorer: gpt-6-luna
|
|
10
|
+
how explainer: gpt-6-luna
|
|
11
|
+
why investigators: gpt-6-luna
|
|
12
|
+
why synthesizer: gpt-6-luna
|
|
13
|
+
reflect tooling: gpt-6-sol
|
|
14
|
+
reflect judgment, divergent, synthesizer: gpt-6-astra
|
|
15
|
+
arena runners: gpt-6-astra, gpt-6-sol, gpt-6-luna
|
|
16
|
+
arena cross-judge pool: gpt-6-astra, gpt-6-sol, gpt-6-luna
|
|
17
|
+
swarm workers: gpt-6-luna
|
|
18
|
+
architect runners: gpt-6-astra, gpt-6-sol, gpt-6-luna
|
|
19
|
+
interrogate reviewers: gpt-6-astra, gpt-6-sol, gpt-6-luna
|
|
20
|
+
|
|
21
|
+
## Reasoning effort
|
|
22
|
+
|
|
23
|
+
Single-model Luna roles use max. Astra roles use low. Sol roles use medium.
|
|
24
|
+
Panel entries use low, max, xhigh in the listed order.
|
|
25
|
+
Keep duplicate panel entries. Each entry specifies one candidate.
|
|
26
|
+
The cross-judge pool retains the effort paired with each entry.
|
|
27
|
+
|
|
28
|
+
Apply these substitutions once to incoming model requests:
|
|
29
|
+
- Sol medium through Luna max requests use gpt-6-luna with max effort.
|
|
30
|
+
- Sol high or higher requests use gpt-6-astra with low effort.
|
|
31
|
+
- Terra requests use gpt-6-luna with xhigh effort.
|
|
32
|
+
- Existing Luna requests use gpt-6-luna with high effort.
|
|
33
|
+
|
|
34
|
+
Explicit role and panel settings above take precedence over substitutions.
|
|
35
|
+
Do not apply substitutions again to their output.
|
|
36
|
+
|
|
37
|
+
session hook: on
|
package/rules/bdd.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
1. **Deliberate Discovery** — Reduce uncertainty before code; surface what you do not know (Smart & Molak §5.4).
|
|
14
14
|
2. **Illustrate** — Explore goals, constraints, and concrete examples in chat; "given … when … then …" style outcomes.
|
|
15
15
|
3. **Formulate** — Express behavior as narrow **"should …"** specifications the user can approve.
|
|
16
|
-
4. **Automate** — Build each formulated behavior
|
|
16
|
+
4. **Automate** — Build each formulated behavior with tests. Red-green-refactor is the default loop, and the TDD skill (`pstack:tdd`) carries it: CODE_RULES §8, as stated in [`code-standards.md`](code-standards.md). A prototype may run ahead of its tests and adds them before the pull request goes ready.
|
|
17
17
|
|
|
18
18
|
Conversation is the essential practice: if discovery is skipped, structured formats do not rescue the workflow (Minimal BDD).
|
|
19
19
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Build Needs a User
|
|
2
|
+
|
|
3
|
+
**When this applies:** Before you build a new tool, script, hook, gate, skill,
|
|
4
|
+
command, or workflow step. That includes a hook or check built to answer a
|
|
5
|
+
correction.
|
|
6
|
+
|
|
7
|
+
## Rule
|
|
8
|
+
|
|
9
|
+
Answer three questions before the first line of the build:
|
|
10
|
+
|
|
11
|
+
1. **Who calls it?** Name the hook entry, workflow step, skill, command, or
|
|
12
|
+
module that will run it in this same change.
|
|
13
|
+
2. **When does it first run?** Name the date or the event of first use.
|
|
14
|
+
3. **When was this job last done by hand?** Name the last time, and how often it
|
|
15
|
+
comes up.
|
|
16
|
+
|
|
17
|
+
Build when all three have a concrete answer. When the only answer is "it might
|
|
18
|
+
help" or "we could use it later", stop and send the owner one line: what the
|
|
19
|
+
build is for and which question has no answer. Keep the rest of the task moving
|
|
20
|
+
while that line waits.
|
|
21
|
+
|
|
22
|
+
Search first, per [`prefer-existing-tools.md`](prefer-existing-tools.md). A
|
|
23
|
+
tool that already exists answers the need, and the build is the glue.
|
|
24
|
+
|
|
25
|
+
## Size the first build to the first use
|
|
26
|
+
|
|
27
|
+
Build the smallest version that serves the first caller. A correction starts
|
|
28
|
+
as a row in a rule file. It becomes a hook or a lint only when the same
|
|
29
|
+
correction arrives a second time, per
|
|
30
|
+
[`correction-lens.md`](correction-lens.md).
|
|
31
|
+
|
|
32
|
+
## Where it is enforced
|
|
33
|
+
|
|
34
|
+
The staged policy lint's `uncalled-new-file` rule reads each code file a change
|
|
35
|
+
adds under `scripts/`, `hooks/`, `bin/`, `ci/`, or `tools/`. It reports the file when its name
|
|
36
|
+
appears only in its own tests, `CHANGELOG.md`, `README.md`, and
|
|
37
|
+
`bin/ever-shipped-skills.mjs`. CI runs that lint against the merge base, so a
|
|
38
|
+
file with no caller turns the pull request red.
|
|
39
|
+
|
|
40
|
+
## Why
|
|
41
|
+
|
|
42
|
+
The history of this package holds files that lived weeks and ran never. The
|
|
43
|
+
Codex compatibility watcher shipped 502 lines and 599 lines of tests in July,
|
|
44
|
+
and PR 1488 deleted it in September. Its own test was the only file that named
|
|
45
|
+
it.
|
|
46
|
+
|
|
47
|
+
## Sibling rules
|
|
48
|
+
|
|
49
|
+
| Rule | Role |
|
|
50
|
+
|---|---|
|
|
51
|
+
| [`prefer-existing-tools.md`](prefer-existing-tools.md) | Search before you build |
|
|
52
|
+
| [`correction-lens.md`](correction-lens.md) | A repeated correction moves up a layer |
|
|
53
|
+
| [`archiving-agent-config.md`](archiving-agent-config.md) | How an unused capability leaves service |
|
package/rules/code-standards.md
CHANGED
|
@@ -18,7 +18,7 @@ Load `CODE_RULES.md` when reviewing a PR, resolving a policy conflict, or genera
|
|
|
18
18
|
|
|
19
19
|
Two standards live in `CODE_RULES.md` in full:
|
|
20
20
|
|
|
21
|
-
- **TDD** — CODE_RULES §8
|
|
21
|
+
- **TDD** — CODE_RULES §8: red, green, refactor is the default loop for a bug fix and for new behavior, and the TDD skill (`pstack:tdd`) carries the procedure. 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.
|
|
22
22
|
- **Right-sized engineering** — CODE_RULES §7 / AGENTS Design: functions over classes; concrete over abstract; add an abstraction at the commit that introduces its second concrete implementation. That count is a house call, one occurrence earlier than the rule of three Fowler credits to Don Roberts. The direction comes from the literature; the number does not, so read it as this package's setting rather than as a cited standard.
|
|
23
23
|
|
|
24
24
|
BDD is the outer process and TDD is the inner loop: [`bdd.md`](bdd.md) discovers and formulates the behavior a feature needs, then each formulated behavior is built through the TDD cycle.
|
|
@@ -108,6 +108,13 @@ naming a file that does not exist, an env-var row naming a file that never
|
|
|
108
108
|
reads the variable, a test outside the testpaths allowlist, and a tracked
|
|
109
109
|
secret.
|
|
110
110
|
|
|
111
|
+
`scripts/cde_lint.py` splits the policy-lint findings the same way.
|
|
112
|
+
`SEVERITY_BY_CHECK_ID` in `scripts/policy_lint/config/check_catalog_constants.py`
|
|
113
|
+
carries one row per smell, and a check id with no row reads as breaking. Three
|
|
114
|
+
checks are smells there: `test-pairing` and the two paired-test coverage
|
|
115
|
+
checks. The lint prints each as a warning, records it in the ledger, and exits
|
|
116
|
+
zero when no error remains.
|
|
117
|
+
|
|
111
118
|
## Sibling rules
|
|
112
119
|
|
|
113
120
|
| Rule | Role |
|
|
@@ -20,6 +20,8 @@ When you add a public function to a module whose test suite already exercises th
|
|
|
20
20
|
|
|
21
21
|
Two complementary checks in `code_rules_paired_test.py` reach changed files through `code_rules_enforcer.py`, which the staged policy lint runs under its `code-rules` rule. No write-time hook runs them, so CI is where they report, against the merge base. The two checks cover the two write orders.
|
|
22
22
|
|
|
23
|
+
Both checks record smells, per [`flag-non-breaking-findings.md`](flag-non-breaking-findings.md). `SEVERITY_BY_CHECK_ID` in `scripts/policy_lint/config/check_catalog_constants.py` declares `code-rules/paired-test-missing-function` and `code-rules/paired-test-omitted-function` as smells. The lint prints each finding as a warning, records it in `.claude/followups/smells.jsonl`, and exits zero when every finding is a warning. A later pull request adds the missing tests.
|
|
24
|
+
|
|
23
25
|
`check_public_function_missing_paired_test` runs on a production Python write or edit and flags a public function when all of these hold:
|
|
24
26
|
|
|
25
27
|
1. The target is production code — not a test module, hook infrastructure, config module, migration, workflow registry, or `__init__.py`.
|
|
@@ -33,8 +35,8 @@ A public function counts as covered when its name appears — imported, called,
|
|
|
33
35
|
|
|
34
36
|
## Relationship to the file-level TDD order
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
No hook or lint checks the order in which a test and its module are written. Red-green-refactor is the default loop, and the TDD skill (`pstack:tdd`) carries it; a prototype may run ahead of its tests and adds them before the pull request goes ready. This check judges coverage one function at a time for a module that already carries a stem-matched test file, and reports any public function that file leaves uncovered.
|
|
37
39
|
|
|
38
40
|
## Why this check is mechanical
|
|
39
41
|
|
|
40
|
-
A public function with no test reads as covered when the module's test file sits right beside it and exercises its siblings. The gap survives review because the suite looks complete. Running the lint on every staged change
|
|
42
|
+
A public function with no test reads as covered when the module's test file sits right beside it and exercises its siblings. The gap survives review because the suite looks complete. Running the lint on every staged change records each gap in the follow-up ledger, where `cde followup list` names it until a pull request closes it.
|
package/rules/testing.md
CHANGED
|
@@ -25,4 +25,8 @@ Tests exercise production behavior, production data, and production code paths.
|
|
|
25
25
|
|
|
26
26
|
## No Gate Holds the Test-First Order
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Red, green, refactor is the default loop for a bug fix and for new behavior. The TDD skill (`pstack:tdd`) carries the procedure, and 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. Review reads the tests on the diff.
|
|
29
|
+
|
|
30
|
+
## A Fix Carries Its Proof Test
|
|
31
|
+
|
|
32
|
+
The `Fix test proof` job in `.github/workflows/pr-check.yml` runs `_shared/pr-loop/scripts/fix_pr_test_proof.py` on every pull request whose title starts with `fix`. A fix that changes production code must change at least one Python or Node test that fails on the base and passes on the head. Node tests are files ending in `.test.mjs`, `.test.js`, or `.test.cjs`, and the job runs them with `node --test`. A fix that changes only docs or CI config passes. A fix proven by a PowerShell test needs a Python or Node test beside it.
|
package/scripts/cde_lint.py
CHANGED
|
@@ -11,11 +11,24 @@ from typing import Never, TextIO
|
|
|
11
11
|
|
|
12
12
|
from policy_lint.config.constants import INVALID_INPUT_EXIT_CODE
|
|
13
13
|
from policy_lint.engine import lint
|
|
14
|
-
from policy_lint.model import
|
|
14
|
+
from policy_lint.model import (
|
|
15
|
+
LintReport,
|
|
16
|
+
LintRequest,
|
|
17
|
+
SelectionKind,
|
|
18
|
+
Severity,
|
|
19
|
+
TextDocument,
|
|
20
|
+
)
|
|
15
21
|
from policy_lint.render import ReportFormat, render
|
|
16
22
|
from policy_lint.selection import SelectionRunFatal
|
|
17
23
|
from policy_lint.selection_git import GitSelectionError, git_bytes_for
|
|
18
24
|
|
|
25
|
+
_hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
|
|
26
|
+
if _hooks_directory not in sys.path:
|
|
27
|
+
sys.path.insert(0, _hooks_directory)
|
|
28
|
+
|
|
29
|
+
from followup_ledger import FollowupFinding, head_commit, record_followup_finding
|
|
30
|
+
from hooks_constants.followup_ledger_constants import SEVERITY_SMELL
|
|
31
|
+
|
|
19
32
|
|
|
20
33
|
class _LintUsageError(ValueError):
|
|
21
34
|
"""Raised when command arguments cannot form a source selection."""
|
|
@@ -201,6 +214,38 @@ def _write_invalid_input(error: Exception, stderr: TextIO) -> int:
|
|
|
201
214
|
return INVALID_INPUT_EXIT_CODE
|
|
202
215
|
|
|
203
216
|
|
|
217
|
+
def _record_warning_followups(report: LintReport, repository_root: Path) -> None:
|
|
218
|
+
"""Append each warning in the report to the repository's follow-up ledger.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
report: The findings this run produced.
|
|
222
|
+
repository_root: The repository whose ledger receives the warnings.
|
|
223
|
+
"""
|
|
224
|
+
all_warnings = [
|
|
225
|
+
each_diagnostic
|
|
226
|
+
for each_diagnostic in report.diagnostics
|
|
227
|
+
if each_diagnostic.severity is Severity.WARNING
|
|
228
|
+
]
|
|
229
|
+
if not all_warnings:
|
|
230
|
+
return
|
|
231
|
+
origin_commit = head_commit(repository_root)
|
|
232
|
+
for each_warning in all_warnings:
|
|
233
|
+
file_path = (
|
|
234
|
+
"" if each_warning.location is None else each_warning.location.path.as_posix()
|
|
235
|
+
)
|
|
236
|
+
record_followup_finding(
|
|
237
|
+
repository_root,
|
|
238
|
+
FollowupFinding(
|
|
239
|
+
each_warning.rule_id,
|
|
240
|
+
file_path,
|
|
241
|
+
each_warning.message,
|
|
242
|
+
each_warning.check_id or each_warning.rule_id,
|
|
243
|
+
SEVERITY_SMELL,
|
|
244
|
+
origin_commit,
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
204
249
|
def _render_lint(
|
|
205
250
|
all_arguments: Sequence[str],
|
|
206
251
|
repository_root: Path,
|
|
@@ -212,6 +257,7 @@ def _render_lint(
|
|
|
212
257
|
all_arguments, stdin, repository_root, lint_runner
|
|
213
258
|
)
|
|
214
259
|
stdout.write(render(report, report_format))
|
|
260
|
+
_record_warning_followups(report, repository_root)
|
|
215
261
|
return report.exit_code
|
|
216
262
|
|
|
217
263
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Report a new code file that nothing outside its own tests calls.
|
|
2
|
+
|
|
3
|
+
A script, hook or skill script earns its place when something runs it: a
|
|
4
|
+
``hooks.json`` entry, a workflow step, a skill or command, or an import from
|
|
5
|
+
live code. A file whose only mention is its own test ships work nobody uses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
|
|
13
|
+
from .config import constants
|
|
14
|
+
from .model import Diagnostic, Document, DocumentSet, Location, SelectionKind, Severity
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_test_path(path: PurePosixPath) -> bool:
|
|
18
|
+
return (
|
|
19
|
+
path.name.startswith("test_")
|
|
20
|
+
or path.stem.endswith("_test")
|
|
21
|
+
or ".test." in path.name
|
|
22
|
+
or "tests" in path.parts
|
|
23
|
+
or "test" in path.parts
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_new_code_file(document: Document) -> bool:
|
|
28
|
+
if document.prior_text is not None or document.prior_path is not None:
|
|
29
|
+
return False
|
|
30
|
+
path = document.path
|
|
31
|
+
if path.suffix.lower() not in constants.ALL_CALLED_CODE_SUFFIXES:
|
|
32
|
+
return False
|
|
33
|
+
if path.name in constants.ALL_UNCALLED_EXEMPT_FILE_NAMES or _is_test_path(path):
|
|
34
|
+
return False
|
|
35
|
+
normalized_path = f"/{path.as_posix()}"
|
|
36
|
+
return any(
|
|
37
|
+
each_segment in normalized_path
|
|
38
|
+
for each_segment in constants.ALL_CALLED_CODE_SEGMENTS
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_caller_source(
|
|
43
|
+
relative_path: PurePosixPath, all_candidate_paths: frozenset[PurePosixPath]
|
|
44
|
+
) -> bool:
|
|
45
|
+
if constants.ALL_CALLER_SEARCH_SKIPPED_DIRECTORIES.intersection(
|
|
46
|
+
relative_path.parts
|
|
47
|
+
):
|
|
48
|
+
return False
|
|
49
|
+
if relative_path.name in constants.ALL_INVENTORY_FILE_NAMES:
|
|
50
|
+
return False
|
|
51
|
+
return relative_path not in all_candidate_paths and not _is_test_path(
|
|
52
|
+
relative_path
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_caller_text(file_path: Path) -> str:
|
|
57
|
+
try:
|
|
58
|
+
return file_path.read_text("utf-8")
|
|
59
|
+
except (OSError, UnicodeDecodeError):
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _caller_texts(
|
|
64
|
+
repository_root: Path, all_candidate_paths: frozenset[PurePosixPath]
|
|
65
|
+
) -> tuple[str, ...]:
|
|
66
|
+
return tuple(
|
|
67
|
+
_read_caller_text(each_path)
|
|
68
|
+
for each_path in repository_root.rglob("*")
|
|
69
|
+
if each_path.is_file()
|
|
70
|
+
and _is_caller_source(
|
|
71
|
+
PurePosixPath(each_path.relative_to(repository_root).as_posix()),
|
|
72
|
+
all_candidate_paths,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _has_caller(document: Document, all_caller_texts: tuple[str, ...]) -> bool:
|
|
78
|
+
name_pattern = re.compile(
|
|
79
|
+
constants.CALLER_NAME_PATTERN_TEMPLATE.format(
|
|
80
|
+
name=re.escape(document.path.stem)
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return any(name_pattern.search(each_text) for each_text in all_caller_texts)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _uncalled_diagnostic(document: Document) -> Diagnostic:
|
|
87
|
+
return Diagnostic(
|
|
88
|
+
constants.UNCALLED_NEW_FILE_RULE_ID,
|
|
89
|
+
Severity.ERROR,
|
|
90
|
+
constants.UNCALLED_NEW_FILE_MESSAGE.format(file_name=document.path.name),
|
|
91
|
+
Location(document.path, 1, 1),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def uncalled_new_file_diagnostics(
|
|
96
|
+
document_set: DocumentSet,
|
|
97
|
+
) -> tuple[Diagnostic, ...]:
|
|
98
|
+
"""Report each new code file with no caller outside its tests.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
document_set: Staged or base change selection.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
One diagnostic for each added script, hook or skill script whose name
|
|
105
|
+
appears only in tests and inventory files.
|
|
106
|
+
"""
|
|
107
|
+
if document_set.selection not in {SelectionKind.STAGED, SelectionKind.BASE}:
|
|
108
|
+
return ()
|
|
109
|
+
all_new_documents = tuple(
|
|
110
|
+
each_document
|
|
111
|
+
for each_document in document_set.documents
|
|
112
|
+
if _is_new_code_file(each_document)
|
|
113
|
+
)
|
|
114
|
+
if not all_new_documents:
|
|
115
|
+
return ()
|
|
116
|
+
all_caller_texts = _caller_texts(
|
|
117
|
+
document_set.repository_root,
|
|
118
|
+
frozenset(each_document.path for each_document in all_new_documents),
|
|
119
|
+
)
|
|
120
|
+
return tuple(
|
|
121
|
+
_uncalled_diagnostic(each_document)
|
|
122
|
+
for each_document in all_new_documents
|
|
123
|
+
if not _has_caller(each_document, all_caller_texts)
|
|
124
|
+
)
|
|
@@ -5,9 +5,10 @@ from collections import Counter
|
|
|
5
5
|
from pathlib import PurePosixPath
|
|
6
6
|
|
|
7
7
|
from . import adapter_support
|
|
8
|
+
from .check_catalog import severity_for_check_id
|
|
8
9
|
from .config import constants
|
|
9
10
|
from .config.approved_test_pairs import APPROVED_TEST_PATHS_BY_PRODUCTION_PATH
|
|
10
|
-
from .model import Diagnostic, Document, DocumentSet, Location, SelectionKind
|
|
11
|
+
from .model import Diagnostic, Document, DocumentSet, Location, SelectionKind
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def _is_test_path(path: PurePosixPath) -> bool:
|
|
@@ -319,9 +320,10 @@ def _changed_test_paths(document_set: DocumentSet) -> frozenset[PurePosixPath]:
|
|
|
319
320
|
def _pairing_diagnostic(document: Document) -> Diagnostic:
|
|
320
321
|
return Diagnostic(
|
|
321
322
|
"test-pairing",
|
|
322
|
-
|
|
323
|
+
severity_for_check_id("test-pairing"),
|
|
323
324
|
"Changed production file has no changed matching test",
|
|
324
325
|
Location(document.path, 1, 1),
|
|
326
|
+
"test-pairing",
|
|
325
327
|
)
|
|
326
328
|
|
|
327
329
|
|
|
@@ -4,7 +4,7 @@ from collections.abc import Callable, Iterable
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from types import ModuleType
|
|
6
6
|
|
|
7
|
-
from .check_catalog import check_id_for_message
|
|
7
|
+
from .check_catalog import check_id_for_message, severity_for_check_id
|
|
8
8
|
from .model import Diagnostic, Document, Location, Severity
|
|
9
9
|
|
|
10
10
|
HookModuleLoader = Callable[[str], ModuleType]
|
|
@@ -69,13 +69,14 @@ def _diagnostics_for_messages(
|
|
|
69
69
|
location = (
|
|
70
70
|
None if maybe_line is None else Location(document.path, maybe_line, 1)
|
|
71
71
|
)
|
|
72
|
+
check_id = check_id_for_message(rule_id, each_message)
|
|
72
73
|
all_diagnostics.append(
|
|
73
74
|
Diagnostic(
|
|
74
75
|
rule_id,
|
|
75
|
-
|
|
76
|
+
severity_for_check_id(check_id),
|
|
76
77
|
each_message,
|
|
77
78
|
location,
|
|
78
|
-
|
|
79
|
+
check_id,
|
|
79
80
|
)
|
|
80
81
|
)
|
|
81
82
|
return tuple(all_diagnostics)
|
|
@@ -9,6 +9,7 @@ from types import ModuleType
|
|
|
9
9
|
from shared_tree_paths import resolve_shared_scripts_directory
|
|
10
10
|
|
|
11
11
|
from . import (
|
|
12
|
+
adapter_callers,
|
|
12
13
|
adapter_configuration,
|
|
13
14
|
adapter_contrast_framing,
|
|
14
15
|
adapter_detectors,
|
|
@@ -245,6 +246,20 @@ def test_pairing_diagnostics(document_set: DocumentSet) -> tuple[Diagnostic, ...
|
|
|
245
246
|
return adapter_pairing.test_pairing_diagnostics(document_set, _hooks_module)
|
|
246
247
|
|
|
247
248
|
|
|
249
|
+
def uncalled_new_file_diagnostics(
|
|
250
|
+
document_set: DocumentSet,
|
|
251
|
+
) -> tuple[Diagnostic, ...]:
|
|
252
|
+
"""Report new code files that nothing outside their tests calls.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
document_set: Staged or base change selection.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
One diagnostic for each uncalled new file.
|
|
259
|
+
"""
|
|
260
|
+
return adapter_callers.uncalled_new_file_diagnostics(document_set)
|
|
261
|
+
|
|
262
|
+
|
|
248
263
|
def terminology_diagnostics(document_set: DocumentSet) -> tuple[Diagnostic, ...]:
|
|
249
264
|
"""Report staged prose that near-misses a newly introduced identifier.
|
|
250
265
|
|
|
@@ -21,8 +21,12 @@ from .config.check_catalog_constants import (
|
|
|
21
21
|
ALL_BUNDLING_RULE_IDS,
|
|
22
22
|
ALL_CHECK_CATALOG_ENTRIES,
|
|
23
23
|
CHECK_ID_SEPARATOR,
|
|
24
|
+
SEVERITY_BREAKING,
|
|
25
|
+
SEVERITY_BY_CHECK_ID,
|
|
26
|
+
SEVERITY_SMELL,
|
|
24
27
|
UNCLASSIFIED_CHECK_NAME,
|
|
25
28
|
)
|
|
29
|
+
from .model import Severity
|
|
26
30
|
|
|
27
31
|
|
|
28
32
|
def check_id_for_message(rule_id: str, message: str) -> str:
|
|
@@ -42,3 +46,23 @@ def check_id_for_message(rule_id: str, message: str) -> str:
|
|
|
42
46
|
if each_entry.message_marker in message:
|
|
43
47
|
return rule_id + CHECK_ID_SEPARATOR + each_entry.check_name
|
|
44
48
|
return rule_id + CHECK_ID_SEPARATOR + UNCLASSIFIED_CHECK_NAME
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def severity_for_check_id(check_id: str) -> Severity:
|
|
52
|
+
"""Return the severity ``SEVERITY_BY_CHECK_ID`` declares for one check.
|
|
53
|
+
|
|
54
|
+
::
|
|
55
|
+
|
|
56
|
+
"code-rules/paired-test-missing-function" -> Severity.WARNING
|
|
57
|
+
"code-rules/unclassified" -> Severity.ERROR
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
check_id: The identifier ``check_id_for_message`` returned.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
``Severity.WARNING`` for a smell, which the lint records and passes,
|
|
64
|
+
and ``Severity.ERROR`` for every other check.
|
|
65
|
+
"""
|
|
66
|
+
if SEVERITY_BY_CHECK_ID.get(check_id, SEVERITY_BREAKING) == SEVERITY_SMELL:
|
|
67
|
+
return Severity.WARNING
|
|
68
|
+
return Severity.ERROR
|
|
@@ -6,11 +6,11 @@ APPROVED_TEST_PATHS_BY_PRODUCTION_PATH: dict[
|
|
|
6
6
|
PurePosixPath, frozenset[PurePosixPath]
|
|
7
7
|
] = {
|
|
8
8
|
PurePosixPath(
|
|
9
|
-
".
|
|
9
|
+
".agents/skills/verify/scripts/verify_installer_constants/constants.mjs"
|
|
10
10
|
): frozenset(
|
|
11
11
|
{
|
|
12
12
|
PurePosixPath(
|
|
13
|
-
".
|
|
13
|
+
".agents/skills/verify/scripts/verify-installer.test.mjs"
|
|
14
14
|
),
|
|
15
15
|
}
|
|
16
16
|
),
|
|
@@ -21,6 +21,7 @@ APPROVED_TEST_PATHS_BY_PRODUCTION_PATH: dict[
|
|
|
21
21
|
),
|
|
22
22
|
PurePosixPath("packages/claude-dev-env/bin/install.mjs"): frozenset(
|
|
23
23
|
{
|
|
24
|
+
PurePosixPath("packages/claude-dev-env/bin/install.agents-home.test.mjs"),
|
|
24
25
|
PurePosixPath("packages/claude-dev-env/bin/install.cursor-rules.test.mjs"),
|
|
25
26
|
}
|
|
26
27
|
),
|
|
@@ -56,4 +56,17 @@ ALL_CHECK_CATALOG_ENTRIES: tuple[CheckCatalogEntry, ...] = (
|
|
|
56
56
|
"missing type annotation (CODE_RULES §6)", "missing-type-annotation"
|
|
57
57
|
),
|
|
58
58
|
CheckCatalogEntry("Single-letter variable", "single-letter-variable"),
|
|
59
|
+
CheckCatalogEntry(
|
|
60
|
+
"is exercised by no test in the module's paired test suite",
|
|
61
|
+
"paired-test-missing-function",
|
|
62
|
+
),
|
|
63
|
+
CheckCatalogEntry("but exercises nowhere", "paired-test-omitted-function"),
|
|
59
64
|
)
|
|
65
|
+
|
|
66
|
+
SEVERITY_BREAKING: str = "breaking"
|
|
67
|
+
SEVERITY_SMELL: str = "smell"
|
|
68
|
+
SEVERITY_BY_CHECK_ID: dict[str, str] = {
|
|
69
|
+
"code-rules/paired-test-missing-function": SEVERITY_SMELL,
|
|
70
|
+
"code-rules/paired-test-omitted-function": SEVERITY_SMELL,
|
|
71
|
+
"test-pairing": SEVERITY_SMELL,
|
|
72
|
+
}
|
|
@@ -6,6 +6,7 @@ GIT_EXECUTABLE = "git"
|
|
|
6
6
|
UTF8_ENCODING = "utf-8"
|
|
7
7
|
PATH_SEPARATOR = "/"
|
|
8
8
|
ARCHIVED_SKILLS_DIRECTORY_NAME = "skill-archive"
|
|
9
|
+
ARCHIVED_HOOKS_DIRECTORY_NAME = "hooks-archived"
|
|
9
10
|
NUL_BYTE = b"\0"
|
|
10
11
|
ALL_GIT_ROOT_ARGUMENTS = ("rev-parse", "--show-toplevel")
|
|
11
12
|
ALL_GIT_HEAD_ARGUMENTS = ("rev-parse", "--verify", "HEAD")
|
|
@@ -20,6 +21,7 @@ ALL_MISSING_BLOB_ERROR_FRAGMENTS = (
|
|
|
20
21
|
"does not exist in",
|
|
21
22
|
"does not exist (at stage",
|
|
22
23
|
"exists on disk, but not in",
|
|
24
|
+
": bad file",
|
|
23
25
|
)
|
|
24
26
|
GIT_DIFF_ARGUMENT = "diff"
|
|
25
27
|
GIT_CACHED_FLAG = "--cached"
|
|
@@ -170,3 +172,19 @@ RETIRED_HOOK_PROSE_MESSAGE = (
|
|
|
170
172
|
)
|
|
171
173
|
|
|
172
174
|
CONTRAST_FRAMING_RULE_ID = "contrast-framing"
|
|
175
|
+
|
|
176
|
+
UNCALLED_NEW_FILE_RULE_ID = "uncalled-new-file"
|
|
177
|
+
UNCALLED_NEW_FILE_MESSAGE = (
|
|
178
|
+
"New file {file_name} has no caller outside its tests. Wire it into a hook,"
|
|
179
|
+
" workflow, skill, command or import in this change, or drop it."
|
|
180
|
+
)
|
|
181
|
+
ALL_CALLED_CODE_SUFFIXES = frozenset({".py", ".mjs", ".js", ".sh", ".ps1"})
|
|
182
|
+
ALL_CALLED_CODE_SEGMENTS = ("/scripts/", "/hooks/", "/bin/", "/ci/", "/tools/")
|
|
183
|
+
ALL_CALLER_SEARCH_SKIPPED_DIRECTORIES = frozenset(
|
|
184
|
+
{".git", "node_modules", "__pycache__", ".venv"}
|
|
185
|
+
)
|
|
186
|
+
ALL_INVENTORY_FILE_NAMES = frozenset(
|
|
187
|
+
{"CHANGELOG.md", "README.md", "ever-shipped-skills.mjs"}
|
|
188
|
+
)
|
|
189
|
+
ALL_UNCALLED_EXEMPT_FILE_NAMES = frozenset({"__init__.py", "conftest.py"})
|
|
190
|
+
CALLER_NAME_PATTERN_TEMPLATE = r"(?<![\w-]){name}(?![\w-])"
|
|
@@ -3,7 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
from collections.abc import Iterable, Sequence
|
|
4
4
|
from pathlib import Path, PurePosixPath
|
|
5
5
|
|
|
6
|
-
from .config.constants import
|
|
6
|
+
from .config.constants import (
|
|
7
|
+
ARCHIVED_HOOKS_DIRECTORY_NAME,
|
|
8
|
+
ARCHIVED_SKILLS_DIRECTORY_NAME,
|
|
9
|
+
)
|
|
7
10
|
from .model import (
|
|
8
11
|
ChangeSetRule,
|
|
9
12
|
Diagnostic,
|
|
@@ -27,7 +30,7 @@ def lint(
|
|
|
27
30
|
"""Run the selected rules on one source selection.
|
|
28
31
|
|
|
29
32
|
Repository and diff selections check active code. File and editor
|
|
30
|
-
selections include archived skills.
|
|
33
|
+
selections include archived skills and archived hooks.
|
|
31
34
|
|
|
32
35
|
Args:
|
|
33
36
|
request: Repository root, source, and rule sets.
|
|
@@ -55,7 +58,10 @@ def lint(
|
|
|
55
58
|
|
|
56
59
|
|
|
57
60
|
def _is_runtime_path(path: PurePosixPath) -> bool:
|
|
58
|
-
return
|
|
61
|
+
return (
|
|
62
|
+
path.parts[0] != ARCHIVED_SKILLS_DIRECTORY_NAME
|
|
63
|
+
and ARCHIVED_HOOKS_DIRECTORY_NAME not in path.parts
|
|
64
|
+
)
|
|
59
65
|
|
|
60
66
|
|
|
61
67
|
def _runtime_document_set(document_set: DocumentSet) -> DocumentSet:
|
|
@@ -341,11 +341,15 @@ class LintReport:
|
|
|
341
341
|
"""Return the process exit code for this report.
|
|
342
342
|
|
|
343
343
|
Returns:
|
|
344
|
-
Zero for clean output
|
|
344
|
+
Zero for clean output or warnings only, one for an error finding,
|
|
345
|
+
or three for rule failure.
|
|
345
346
|
"""
|
|
346
347
|
if self.failed_rules:
|
|
347
348
|
return INCOMPLETE_EXIT_CODE
|
|
348
|
-
if
|
|
349
|
+
if any(
|
|
350
|
+
each_diagnostic.severity is Severity.ERROR
|
|
351
|
+
for each_diagnostic in self.diagnostics
|
|
352
|
+
):
|
|
349
353
|
return 1
|
|
350
354
|
return 0
|
|
351
355
|
|
|
@@ -154,6 +154,12 @@ def default_registry() -> tuple[model.Rule, ...]:
|
|
|
154
154
|
frozenset({model.SelectionKind.STAGED, model.SelectionKind.BASE}),
|
|
155
155
|
adapters.terminology_diagnostics,
|
|
156
156
|
),
|
|
157
|
+
model.ChangeSetRule(
|
|
158
|
+
"uncalled-new-file",
|
|
159
|
+
frozenset({"changed"}),
|
|
160
|
+
frozenset({model.SelectionKind.STAGED, model.SelectionKind.BASE}),
|
|
161
|
+
adapters.uncalled_new_file_diagnostics,
|
|
162
|
+
),
|
|
157
163
|
model.RepositoryRule(
|
|
158
164
|
"repository-path-collision",
|
|
159
165
|
frozenset({"repository"}),
|
|
@@ -205,6 +205,8 @@ def _append_change(
|
|
|
205
205
|
all_renamed_paths: list[tuple[PurePosixPath, PurePosixPath]],
|
|
206
206
|
) -> None:
|
|
207
207
|
old_path, new_path = all_paths if status[:1] in {"R", "C"} else (all_paths[0], all_paths[0])
|
|
208
|
+
if status != "D" and (repository_root / new_path).is_symlink():
|
|
209
|
+
return
|
|
208
210
|
normalized_old_path = _normalize_path(repository_root, old_path)
|
|
209
211
|
normalized_new_path = _normalize_path(repository_root, new_path)
|
|
210
212
|
if status == "D":
|