claude-dev-env 7.4.0 → 7.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/install.mjs +0 -1
- package/bin/install.policy-lint.test.mjs +1 -2
- package/bin/install.test.mjs +4 -4
- package/bin/pstack.mjs +24 -4
- package/bin/pstack.test.mjs +20 -10
- package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
- package/hooks/advisory/pr_done_reminder.py +7 -13
- package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
- package/hooks/advisory/test_pr_done_reminder.py +1 -0
- package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
- package/hooks/blocking/env_var_table_code_drift_blocker.py +11 -99
- package/hooks/blocking/env_var_table_rows.py +130 -0
- package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
- package/hooks/blocking/pii_scanner.py +19 -29
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
- package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +81 -3
- package/hooks/blocking/test_env_var_table_rows.py +100 -0
- package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
- package/hooks/blocking/test_pii_scanner.py +34 -24
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
- package/hooks/hooks_constants/env_var_table_code_drift_constants.py +12 -4
- package/hooks/hooks_constants/hardcoded_user_path_constants.py +12 -10
- package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
- package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
- package/hooks/hooks_constants/pii_prevention_constants.py +10 -25
- package/hooks/hooks_constants/post_tool_use_context.py +34 -0
- package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
- package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
- package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
- package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
- package/hooks/observability/test_failure_recorder.py +3 -1
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +3 -3
- package/rules/code-standards.md +3 -2
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/destructive-commands.md +19 -19
- package/rules/doc-inventory-integrity.md +4 -4
- package/rules/docstring-prose-matches-implementation.md +2 -2
- package/rules/failure-blast-radius.md +4 -2
- package/rules/filesystem-search.md +1 -1
- package/rules/git-workflow.md +34 -0
- package/rules/long-horizon-autonomy.md +2 -0
- package/rules/nas-ssh-invocation.md +3 -3
- package/rules/no-cross-skill-duplicate-helpers.md +6 -6
- package/rules/orphan-css-class.md +5 -5
- package/rules/paired-test-coverage.md +6 -6
- package/rules/plain-illustrative-docstrings.md +3 -3
- package/rules/re-stage-before-commit.md +7 -7
- package/rules/retired-hook-prose.md +16 -0
- package/rules/shell-invocation.md +17 -1
- package/rules/testing.md +3 -5
- package/rules/verify-runtime-state.md +1 -1
- package/rules/windows-filesystem-safe.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/durable_post_lint_config/config/constants.py +0 -3
- package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
- package/scripts/policy_lint/adapter_configuration.py +24 -0
- package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
- package/scripts/policy_lint/adapters.py +36 -1
- package/scripts/policy_lint/config/constants.py +74 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/policy_lint/selection.py +37 -18
- package/scripts/policy_lint/selection_git.py +6 -2
- package/scripts/pstack-adapters/common.md +1 -1
- package/scripts/repository_checks/config/constants.py +7 -17
- package/scripts/repository_checks/config/email_exemptions.py +10 -0
- package/scripts/repository_checks/email_exemptions.py +96 -0
- package/scripts/repository_checks/env_var_documentation.py +1 -9
- package/scripts/repository_checks/tracked_secrets.py +10 -4
- package/scripts/test_durable_post_lint.py +42 -9
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_email_exemptions.py +121 -0
- package/scripts/tests/test_env_var_documentation.py +26 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
- package/scripts/tests/test_policy_lint_selection.py +107 -0
- package/scripts/tests/test_tracked_secrets.py +13 -0
- package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
- package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
- package/hooks/hooks_constants/test_text_stripping.py +0 -39
- package/hooks/hooks_constants/text_stripping.py +0 -36
|
@@ -8,7 +8,7 @@ paths: "**/skills/*/scripts/**/*.py"
|
|
|
8
8
|
|
|
9
9
|
## The two duplication cases differ
|
|
10
10
|
|
|
11
|
-
CODE_RULES "Reuse before create" / DRY says one helper lives in one home and both call sites import it. That rule is
|
|
11
|
+
CODE_RULES "Reuse before create" / DRY says one helper lives in one home and both call sites import it. That rule is enforced **within one skill**. Two `.py` modules in the same skill's `scripts/` directory that carry the same top-level function body fail the `code_rules_duplicate_body` check, which the staged policy lint runs through `code_rules_enforcer.py`. The fix is a shared module both import.
|
|
12
12
|
|
|
13
13
|
Across **two skill folders** the same copy is a different call. Each skill folder installs on its own, so a shared module would couple two skills the install model keeps separate: deleting or reinstalling one skill would break a helper the other depends on. A small launch helper copied into each skill (for example a Chrome-open helper that reads the registry and runs `chrome.exe`) is a defensible skill-isolation tradeoff, not a regression.
|
|
14
14
|
|
|
@@ -16,14 +16,14 @@ Across **two skill folders** the same copy is a different call. Each skill folde
|
|
|
16
16
|
|
|
17
17
|
Before you copy a top-level helper from one skill's `scripts/` directory into another:
|
|
18
18
|
|
|
19
|
-
- **Same skill, two modules
|
|
20
|
-
- **Two skill folders, a small self-contained helper
|
|
19
|
+
- **Same skill, two modules.** Extract one shared module in that skill and import it from both. The `code_rules_duplicate_body` check reports the copy.
|
|
20
|
+
- **Two skill folders, a small self-contained helper.** Copy it, and the skill-isolation tradeoff stands. An advisory `[CODE_RULES advisory]` line names the source skill when the lint runs, so the copy is a deliberate choice on record, not an oversight.
|
|
21
21
|
- **Two skill folders, a large or behavior-bearing body** — when the copied body is large, holds business logic, or would drift in a way that changes behavior, raise the choice through `AskUserQuestion`: copy and accept drift, or stand up a shared dependency both skills declare (for example a published package both `requirements` files name, or a `_shared` module the install step writes into each skill). A shared dependency that survives independent install is the only shared-home path that does not break the install model.
|
|
22
22
|
|
|
23
23
|
## What the advisory tells you
|
|
24
24
|
|
|
25
|
-
The `advise_cross_skill_duplicate_helper` check in `code_rules_duplicate_body` prints to stderr (never
|
|
25
|
+
The `advise_cross_skill_duplicate_helper` check in `code_rules_duplicate_body` prints to stderr (never fails the lint) when a top-level function in a changed file has the same normalized body as a top-level function in another skill's `scripts/` directory. The message names the source skill and function so a reviewer can confirm the copy was intentional. It fires only across skill folders. Within one skill the reporting check already covers the copy.
|
|
26
26
|
|
|
27
|
-
## Why this is a rule, not a wider
|
|
27
|
+
## Why this is a rule, not a wider check
|
|
28
28
|
|
|
29
|
-
Extending the
|
|
29
|
+
Extending the duplicate-body check to span skill folders would report the exact skill-isolation copy that keeps skills independently installable, a false positive on a sanctioned pattern. The boundary between "same skill, report" and "two skills, signal" is a judgment the writer makes with the source skill named in front of them. The rule states the judgment, and the `[CODE_RULES advisory]` line surfaces the signal. Neither one fails the defensible copy.
|
|
@@ -13,16 +13,16 @@ Every class name a markup string references has a matching `.<class>` selector i
|
|
|
13
13
|
|
|
14
14
|
When you add a `class="..."` attribute, add its `.<class>` selector to the `<style>` block in the same change. When you drop a selector, drop the class attribute it styled.
|
|
15
15
|
|
|
16
|
-
## What the
|
|
16
|
+
## What the check covers
|
|
17
17
|
|
|
18
|
-
The `check_orphan_css_classes` check in `code_rules_orphan_css_class.py`
|
|
18
|
+
The `check_orphan_css_classes` check in `code_rules_orphan_css_class.py` reaches production Python through the staged policy lint, whose `code-rules` rule loads `code_rules_enforcer.py` and applies it to each changed file. Run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. CI runs the same lint against the merge base. It:
|
|
19
19
|
|
|
20
20
|
1. Collects each class name referenced in a `class="..."` attribute across the file's string literals.
|
|
21
21
|
2. Collects each class selector defined in a `<style>` block — both in the file under edit and in every Python module beside it (its own directory and immediate child directories), since a markup module commonly imports its style constant from a companion package directory.
|
|
22
22
|
3. Flags each referenced class with no matching selector in that whole set.
|
|
23
23
|
|
|
24
|
-
The check stays quiet for a file that emits no `class="..."` markup, and for a file whose markup has no `<style>` source nearby (its stylesheet lives outside the scan, so the
|
|
24
|
+
The check stays quiet for a file that emits no `class="..."` markup, and for a file whose markup has no `<style>` source nearby (its stylesheet lives outside the scan, so the check cannot judge it). Test files are exempt, since a fixture may carry intentional orphan markup.
|
|
25
25
|
|
|
26
|
-
## Why this is
|
|
26
|
+
## Why this check is mechanical
|
|
27
27
|
|
|
28
|
-
A class attribute with no matching selector reads as styled but renders unstyled. Native elements such as `<details>` stay functional without CSS, so the gap survives review as a cosmetic defect rather than a crash
|
|
28
|
+
A class attribute with no matching selector reads as styled but renders unstyled. Native elements such as `<details>` stay functional without CSS, so the gap survives review as a cosmetic defect rather than a crash. That is the class of issue a manual pass slips past, and it lands later as a deferred code-standard finding. Running the lint on every staged change keeps the markup and the stylesheet in step.
|
|
@@ -16,9 +16,9 @@ Every public function a production module defines is exercised by a test in the
|
|
|
16
16
|
|
|
17
17
|
When you add a public function to a module whose test suite already exercises that module — covering a sibling public function, or testing one of its private helpers — add a behavioral test that calls the new function and asserts on its return value or side effect — in the same change that adds the function. A suite that exercises only a private helper (such as a color-conversion helper) while leaving the module's public renderers untested is the exact gap this rule closes. This is the function-level half of the project rule "Every new production code path gets a paired behavioral test ... call the path and assert on what it does."
|
|
18
18
|
|
|
19
|
-
## What the
|
|
19
|
+
## What the check covers
|
|
20
20
|
|
|
21
|
-
Two complementary checks in `code_rules_paired_test.py`
|
|
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 run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. CI runs the same lint against the merge base. The two checks cover the two write orders.
|
|
22
22
|
|
|
23
23
|
`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
24
|
|
|
@@ -31,10 +31,10 @@ Two complementary checks in `code_rules_paired_test.py` (both dispatched from `c
|
|
|
31
31
|
|
|
32
32
|
A public function counts as covered when its name appears — imported, called, or named — in any `test_*.py` or `*_test.py` file in the suite directory, so a function exercised by a differently-named sibling test still counts. `main` and underscore-prefixed functions are never required to carry a test.
|
|
33
33
|
|
|
34
|
-
## Relationship to the file-level TDD
|
|
34
|
+
## Relationship to the file-level TDD order
|
|
35
35
|
|
|
36
|
-
`tdd_enforcer.py`
|
|
36
|
+
`tdd_enforcer.py` once required a fresh test file to exist before a production module was written, judging coverage one file at a time. Nothing runs it now, and the staged policy lint carries no replacement, so the test-first order is yours to hold. This check judges coverage one function at a time for a module that already carries such a test file. Write the test file first, and the lint then reports any public function that file leaves uncovered.
|
|
37
37
|
|
|
38
|
-
## Why this is
|
|
38
|
+
## Why this check is mechanical
|
|
39
39
|
|
|
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.
|
|
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 keeps the module's public surface and its test suite in step.
|
|
@@ -86,6 +86,6 @@ one — and you're debugging blind.
|
|
|
86
86
|
|
|
87
87
|
Two surfaces carry this standard:
|
|
88
88
|
|
|
89
|
-
- **
|
|
90
|
-
- **
|
|
91
|
-
- **Audit (the judgment lane).** Category O sub-bucket O9 in `~/.claude/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md` carries the illustrative-and-brief judgment the
|
|
89
|
+
- **Lint (the run-on backstop).** `check_docstring_runon_sentence` in `code_rules_docstrings.py` flags the one mechanical mark of a wall, a single narrative sentence that is both over the word limit and joined by an em-dash or a semicolon. The staged policy lint reaches it through `code_rules_enforcer.py`, and no write-time hook runs it. A mechanical check cannot judge whether prose paints a picture, so it catches only this structural mark. It reads the narrative through a shared partition that sets aside any `::` literal block and any doctest, so a diagram's own arrows and dashes never count against the sentence.
|
|
90
|
+
- **Lint (the prose-wall backstop).** `check_docstring_prose_wall_without_illustration` in the same module flags a narrative that runs more than six prose lines with no diagram block. It marks the wall so the writer shows the behavior with a `::` example or a doctest and trims the prose to a few short lines. It cannot judge whether the diagram illustrates well, and that stays with the audit lane.
|
|
91
|
+
- **Audit (the judgment lane).** Category O sub-bucket O9 in `~/.claude/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md` carries the illustrative-and-brief judgment the lint cannot. The audit teammate reads each changed docstring's narrative and asks whether a general developer follows it on the first read.
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Stage the files you edited this session right before you commit them. A plain `git commit` records only the staged snapshot; a tracked file this session changed but left unstaged stays behind in the working tree.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
No hook denies a commit that would drop tracked session edits. Run `git status` before you commit, then stage what you changed with `git add <paths>` or commit with `git commit -a`.
|
|
6
6
|
|
|
7
|
-
Staging covers tracked files you edited. Do not commit untracked files unless the user explicitly instructs it
|
|
7
|
+
Staging covers tracked files you edited. Do not commit untracked files unless the user explicitly instructs it. An untracked file in the working tree is outside the change until they say otherwise.
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Staging shapes
|
|
10
10
|
|
|
11
|
-
- **A pathspec
|
|
12
|
-
- **A preceding `git add`
|
|
11
|
+
- **A pathspec.** `git commit -- <paths>` or `git commit <paths>` commits only the named paths on purpose.
|
|
12
|
+
- **A preceding `git add` or `git stage`.** `git add <paths> && git commit …` stages the files in its own segment before the commit runs.
|
|
13
13
|
|
|
14
|
-
A `--amend`
|
|
14
|
+
A `--amend` carries the same risk. An amend records the staged snapshot too, so an unstaged session edit is dropped the same way a plain commit drops it.
|
|
15
15
|
|
|
16
|
-
`session_file_edit_tracker` (PostToolUse) records each Write/Edit/MultiEdit path
|
|
16
|
+
`session_file_edit_tracker` (PostToolUse) still records each Write/Edit/MultiEdit path, and `session_edit_tracker_cleanup` (SessionStart, SessionEnd) still clears the session's tracker. No gate reads that record today, so it serves as history rather than a precondition.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/rules/*.md"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rules Prose Names Only Hooks That Run
|
|
7
|
+
|
|
8
|
+
A hook module runs when `hooks/hooks.json` registers it or a dispatcher roster hosts it. A module can survive on disk while appearing in neither place, and it then reaches no tool call. Prose that gives such a module a present-tense action describes a gate the reader never faces, and a reader who trusts it skips the check they were meant to run by hand.
|
|
9
|
+
|
|
10
|
+
The staged policy lint carries this check as its `retired-hook-prose` rule. It reads each Markdown file under a `rules/` directory, takes every backticked hook module name, and reports the ones that are registered nowhere and that carry a present-tense action verb in the rest of the sentence. Run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. CI runs the same lint against the merge base.
|
|
11
|
+
|
|
12
|
+
A module name counts as a hook when it ends in one of the hook families, such as `_blocker`, `_enforcer`, `_gate`, `_tracker`, or `_dispatcher`, and when it either sits under `hooks/` or appears in `RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS` in `bin/install.mjs`. A support module inside the hooks tree carries no such family suffix, so it stays out of the check. A hook deleted from disk without an entry in that roster resolves nowhere, so add the roster entry when you retire a hook.
|
|
13
|
+
|
|
14
|
+
Two shapes pass. A past-tense sentence that records what a hook once did makes no live claim. A sentence that names the staged policy lint, `cde_lint`, or a repository check tells the reader where the work runs now, so the module name reads as a pointer to code.
|
|
15
|
+
|
|
16
|
+
When the rule reports a line, say what the reader faces now. Name the harness permission prompt, the staged policy lint, or the repository check that carries the work, or drop the claim.
|
|
@@ -18,4 +18,20 @@ When a script file's literal body needs `$(...)`, author it with the Write tool,
|
|
|
18
18
|
|
|
19
19
|
## Enforcement
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
No PreToolUse hook denies a Bash command. Commit `0f21faf8e` retired the blocking policy hooks and left the Bash PreToolUse roster empty. `shell_substitution_blocker.py` was one of them. The substitution constraint above is guidance a reader follows, and a permission prompt on a wrapped command is the signal that one slipped through.
|
|
22
|
+
|
|
23
|
+
One PreToolUse hook does run on a Bash command, and it only rewrites. `blocking/msys_rev_path_rewriter.py` (PreToolUse on Bash, hosted by `bash_pre_tool_use_dispatcher`) reads a git command before it runs and keeps Git Bash from converting a `<rev>:<path>` argument. That roster holds this one hook, and a test asserts its whole content, so a blocking hook added beside it fails the suite.
|
|
24
|
+
|
|
25
|
+
Git Bash rewrites that argument when the revision holds a slash and the path after the colon starts with a slash or a dot. It turns the colon into a semicolon and the slashes into backslashes, so `git show origin/main:.claude/settings.json` reaches git as `origin\main;.claude\settings.json` and git reports a revision that does not exist. A leading dot on a file is enough. `origin/main:.gitignore` reaches git as `origin\main;.gitignore`. `git show origin/main:packages/app.py` passes through untouched, and so does any path after the colon that starts with `./`, `../`, or `~/`. A revision without a slash, such as `HEAD:.claude/settings.json`, passes through too.
|
|
26
|
+
|
|
27
|
+
On that shape the rewriter names only the arguments it found, so `git show origin/main:.claude/settings.json` runs as:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
export MSYS2_ARG_CONV_EXCL='origin/main:'; git show origin/main:.claude/settings.json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`MSYS2_ARG_CONV_EXCL` takes a semicolon-separated list of argument prefixes. Naming one prefix per detected token leaves every other argument in the command converting as before. The blanket pair `MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'` turns conversion off for the whole command, which changes a path the command meant to convert, so the rewriter does not emit it.
|
|
34
|
+
|
|
35
|
+
A quoted token is not a revision. `git commit -m "fix a/b:.py"` carries a slash and a dot in one token, and a rewrite there would break the message, so the detection skips a token holding whitespace or a quote.
|
|
36
|
+
|
|
37
|
+
`advisory/msys_path_conversion_advisor.py` (PostToolUse on Bash, hosted by `bash_post_call_dispatcher`) stays behind it. It reads a failed git call, looks for the mark MSYS leaves, and names the fix. It covers a shape the rewriter misses.
|
package/rules/testing.md
CHANGED
|
@@ -23,10 +23,8 @@ Incomplete mocks make it impossible to distinguish "broken code" from "missing d
|
|
|
23
23
|
|
|
24
24
|
Tests exercise real behavior, real data, and production code paths. A test that asserts on a stand-in for the production path proves the stand-in works.
|
|
25
25
|
|
|
26
|
-
##
|
|
26
|
+
## No Gate Holds the Test-First Order
|
|
27
27
|
|
|
28
|
-
`tdd_enforcer.py`
|
|
28
|
+
`tdd_enforcer.py` once required a fresh, failing test before a production module was written. Nothing runs it now, and the staged policy lint carries no replacement. Hold the red-green-refactor order yourself, and let review check it on the diff.
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
`hooks/observability/test_failure_recorder.py` runs on every Bash call and records a single unchained pytest run that names a real test file path and reports a failing exit status. It stores the command, that exit status, and the path in the same content-hash store the gate reads. The gate consults that record before its freshness fallback, and honours it while the candidate's content still matches what failed. `content_hash_store.py`'s module docstring holds the full contract.
|
|
30
|
+
`hooks/observability/test_failure_recorder.py` still runs on every Bash call. It records a single unchained pytest run that names a test file path and reports a failing exit status, storing the command, that exit status, and the path in the content-hash store. `content_hash_store.py`'s module docstring holds the full contract. No gate reads that store today, so the record serves as history rather than a precondition.
|
|
@@ -22,7 +22,7 @@ Before stating a runtime claim, gather the matching live signal:
|
|
|
22
22
|
| The config is in effect | Print the loaded config at runtime and read the value. |
|
|
23
23
|
| The server is up | Probe the port; a refused connection means it is down. |
|
|
24
24
|
| The process is running | List processes and match the name or PID. |
|
|
25
|
-
| The change took effect | Drive the flow and watch the new behavior. |
|
|
25
|
+
| The change took effect | Drive the flow and watch the new behavior. A script that proves a branch's behavior names the module file it loaded, as its first step, because an installed copy of the same package shadows the checkout you meant to test. |
|
|
26
26
|
| The dependency is reachable | Send one real request and read the response. |
|
|
27
27
|
| The release or deploy shipped | Read what it makes: the tag, the published version, the file on disk. |
|
|
28
28
|
| The pipeline did the work | Read each job's own result. A run reports success while a job inside it is skipped. |
|
|
@@ -12,6 +12,6 @@ Never call `shutil.rmtree` with `ignore_errors=True` — Windows `ReadOnly` file
|
|
|
12
12
|
|
|
13
13
|
In Node, call `mkdirSync(targetPath, { recursive: true })` on possibly-existing paths — `ReadOnly` directories break the non-recursive form. When the call must be non-recursive, strip the attribute first (`(Get-Item $path -Force).Attributes = "Directory"` / `os.chmod(path, stat.S_IWRITE)`).
|
|
14
14
|
|
|
15
|
-
The `
|
|
15
|
+
The staged policy lint carries this check as its `rmtree-safety` rule. It reports the unsafe rmtree pattern and returns the full `force_rmtree` safe-pattern code. Run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. CI runs the same lint against the merge base.
|
|
16
16
|
|
|
17
|
-
Define the safe handler trio (`_strip_read_only_and_retry`, `_force_remove_tree` / `force_rmtree`, and the `inspect.signature` onexc/onerror guard) once in a shared Windows-filesystem utility module, and import it from every call site. A second local copy drifts from the first
|
|
17
|
+
Define the safe handler trio (`_strip_read_only_and_retry`, `_force_remove_tree` / `force_rmtree`, and the `inspect.signature` onexc/onerror guard) once in a shared Windows-filesystem utility module, and import it from every call site. A second local copy drifts from the first, so a fix lands in one and the other keeps the bug (CODE_RULES.md, CORE PRINCIPLES, "Reuse before create"). The same `rmtree-safety` lint rule reports a local re-definition of any trio member outside the shared home and points the writer at the import. This complements the same-directory `check_duplicate_function_body_across_files` check, which a copy between two distant packages slips past.
|
|
@@ -9,4 +9,4 @@ In a `.workflow.js` agent-prompt template, every per-call or per-iteration value
|
|
|
9
9
|
|
|
10
10
|
When a loop builds a per-iteration path or output key, write the index as a slot — `cand_<i>` — or spell out `replace <i> with the iteration index 0, 1, 2` in the step text. Every per-call value in a `.workflow.js` template carries angle brackets so an agent fills in a fresh value per call.
|
|
11
11
|
|
|
12
|
-
`
|
|
12
|
+
The staged policy lint carries this check as its `workflow-substitution` rule. It reports a `.workflow.js` file whose looped content holds a bare `<word>_<i|j|k>` token as a per-iteration path segment. Run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. CI runs the same lint against the merge base. No write-time hook reports this, so a bare token stays on disk until the lint runs.
|
|
@@ -55,10 +55,7 @@ CONVENTIONAL_TITLE_PATTERN: re.Pattern[str] = re.compile(
|
|
|
55
55
|
rf"^(?:{'|'.join(ALL_CONVENTIONAL_TITLE_TYPES)})(?:\([^)\r\n]+\))?!?: [^\s].*$"
|
|
56
56
|
)
|
|
57
57
|
ALL_REQUIRED_PR_DESCRIPTION_HEADINGS: tuple[str, ...] = (
|
|
58
|
-
"Summary",
|
|
59
|
-
"Description",
|
|
60
58
|
"Why",
|
|
61
|
-
"How",
|
|
62
59
|
"Verification",
|
|
63
60
|
)
|
|
64
61
|
ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS: tuple[str, ...] = (
|
|
@@ -4,6 +4,7 @@ from durable_post_lint_config.config.constants import (
|
|
|
4
4
|
ALL_CONVENTIONAL_TITLE_TYPES,
|
|
5
5
|
ALL_POST_ACTIONS,
|
|
6
6
|
ALL_RELEASE_BODY_MARKERS,
|
|
7
|
+
ALL_REQUIRED_PR_DESCRIPTION_HEADINGS,
|
|
7
8
|
CONVENTIONAL_TITLE_PATTERN,
|
|
8
9
|
RELEASE_BRANCH_PREFIX,
|
|
9
10
|
)
|
|
@@ -36,3 +37,7 @@ def should_carry_both_markers_release_automation_writes() -> None:
|
|
|
36
37
|
|
|
37
38
|
def should_name_the_branch_prefix_release_automation_pushes() -> None:
|
|
38
39
|
assert RELEASE_BRANCH_PREFIX == "release-please--branches--"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def should_require_only_the_headings_both_pull_request_rule_sets_name() -> None:
|
|
43
|
+
assert ALL_REQUIRED_PR_DESCRIPTION_HEADINGS == ("Why", "Verification")
|
|
@@ -121,7 +121,31 @@ def _registered_nested_strings(each_entry: object) -> tuple[str, ...]:
|
|
|
121
121
|
return tuple(all_registered_strings)
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
125
|
+
"""Return True when the registration names a chain that decides no policy.
|
|
126
|
+
|
|
127
|
+
::
|
|
128
|
+
|
|
129
|
+
hooks/blocking/bash_pre_tool_use_dispatcher.py -> exempt
|
|
130
|
+
hooks/blocking/some_new_blocker.py -> flagged
|
|
131
|
+
|
|
132
|
+
The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
|
|
133
|
+
while its roster hosts one allow-and-rewrite hook. Its path segment reads as
|
|
134
|
+
a policy boundary that the chain never carries.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
registered_string: One command, path, script, or entrypoint string.
|
|
138
|
+
"""
|
|
139
|
+
path_shaped_string = registered_string.lower().replace("\\", "/")
|
|
140
|
+
return any(
|
|
141
|
+
each_exempt_path in path_shaped_string
|
|
142
|
+
for each_exempt_path in constants.ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
124
146
|
def _contains_action_boundary_marker(registered_string: str) -> bool:
|
|
147
|
+
if _names_exempt_registration_path(registered_string):
|
|
148
|
+
return False
|
|
125
149
|
normalized_string = registered_string.lower().replace("\\", "/")
|
|
126
150
|
normalized_string = normalized_string.replace(".", "/").replace("-", "/")
|
|
127
151
|
return any(
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Detect rules prose that credits a hook module which runs nowhere.
|
|
2
|
+
|
|
3
|
+
A hook module runs when ``hooks/hooks.json`` registers it or a dispatcher
|
|
4
|
+
roster hosts it. A module that survives on disk while appearing in neither
|
|
5
|
+
one reaches no tool call, so prose that gives it a present-tense action
|
|
6
|
+
describes a gate the reader never faces.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
|
|
14
|
+
from .config import constants
|
|
15
|
+
from .model import Diagnostic, Document, Location, Severity
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def accepts_rules_markdown(document: Document) -> bool:
|
|
19
|
+
"""Return whether the document is Markdown in a rules directory.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
document: Candidate document.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
True for a Markdown file directly under a ``rules`` directory.
|
|
26
|
+
"""
|
|
27
|
+
return (
|
|
28
|
+
document.path.suffix.lower() in constants.ALL_MARKDOWN_SUFFIXES
|
|
29
|
+
and document.path.parent.name == constants.RULES_DIRECTORY_NAME
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _package_root(repository_root: Path, document: Document) -> Path:
|
|
34
|
+
return repository_root / document.path.parent.parent.as_posix()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_text(path: Path) -> str:
|
|
38
|
+
if not path.is_file():
|
|
39
|
+
return ""
|
|
40
|
+
return path.read_text(
|
|
41
|
+
encoding="utf-8", errors=constants.REPLACEMENT_CHARACTER_ERRORS
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _registered_hook_stems(hooks_root: Path) -> frozenset[str]:
|
|
46
|
+
all_stems = set(
|
|
47
|
+
re.findall(
|
|
48
|
+
constants.HOOKS_JSON_PATH_PATTERN,
|
|
49
|
+
_read_text(hooks_root / constants.HOOKS_CONFIGURATION_FILE_NAME),
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
constants_root = hooks_root / constants.HOOK_CONSTANTS_DIRECTORY_NAME
|
|
53
|
+
if constants_root.is_dir():
|
|
54
|
+
for each_path in sorted(
|
|
55
|
+
constants_root.glob(constants.DISPATCHER_CONSTANTS_GLOB)
|
|
56
|
+
):
|
|
57
|
+
all_stems.update(
|
|
58
|
+
PurePosixPath(each_relative_path).stem
|
|
59
|
+
for each_relative_path in re.findall(
|
|
60
|
+
constants.QUOTED_HOOK_PATH_PATTERN, _read_text(each_path)
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
return frozenset(all_stems)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _present_hook_stems(hooks_root: Path) -> frozenset[str]:
|
|
67
|
+
if not hooks_root.is_dir():
|
|
68
|
+
return frozenset()
|
|
69
|
+
return frozenset(
|
|
70
|
+
each_path.stem for each_path in hooks_root.rglob(constants.PYTHON_GLOB)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _retired_roster_stems(package_root: Path) -> frozenset[str]:
|
|
75
|
+
installer_path = package_root.joinpath(*constants.ALL_INSTALLER_PATH_SEGMENTS)
|
|
76
|
+
maybe_roster = re.search(
|
|
77
|
+
constants.RETIRED_ROSTER_PATTERN, _read_text(installer_path)
|
|
78
|
+
)
|
|
79
|
+
if maybe_roster is None:
|
|
80
|
+
return frozenset()
|
|
81
|
+
return frozenset(
|
|
82
|
+
PurePosixPath(each_relative_path).stem
|
|
83
|
+
for each_relative_path in re.findall(
|
|
84
|
+
constants.QUOTED_HOOK_PATH_PATTERN, maybe_roster.group(1)
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _hook_stem_in_span(span_text: str) -> str | None:
|
|
90
|
+
candidate_name = (
|
|
91
|
+
span_text.strip()
|
|
92
|
+
.rsplit(constants.PATH_SEPARATOR, 1)[-1]
|
|
93
|
+
.removesuffix(constants.PYTHON_SUFFIX)
|
|
94
|
+
)
|
|
95
|
+
if not re.fullmatch(constants.HOOK_MODULE_NAME_PATTERN, candidate_name):
|
|
96
|
+
return None
|
|
97
|
+
if not candidate_name.endswith(constants.ALL_HOOK_MODULE_NAME_SUFFIXES):
|
|
98
|
+
return None
|
|
99
|
+
return candidate_name
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _names_a_live_action(segment_text: str) -> bool:
|
|
103
|
+
return any(
|
|
104
|
+
each_word in constants.ALL_LIVE_CLAIM_VERBS
|
|
105
|
+
for each_word in re.findall(
|
|
106
|
+
constants.PROSE_WORD_PATTERN, segment_text.lower()
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _describes_a_replacement(line_text: str) -> bool:
|
|
112
|
+
normalized_line = line_text.lower()
|
|
113
|
+
return any(
|
|
114
|
+
each_phrase in normalized_line
|
|
115
|
+
for each_phrase in constants.ALL_REPLACEMENT_DELIVERY_PHRASES
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _claim_segment_end(
|
|
120
|
+
line_text: str, all_spans: tuple[re.Match[str], ...], span_index: int
|
|
121
|
+
) -> int:
|
|
122
|
+
maybe_sentence_end = re.compile(constants.SENTENCE_BOUNDARY_PATTERN).search(
|
|
123
|
+
line_text, all_spans[span_index].end()
|
|
124
|
+
)
|
|
125
|
+
segment_end = (
|
|
126
|
+
len(line_text) if maybe_sentence_end is None else maybe_sentence_end.start()
|
|
127
|
+
)
|
|
128
|
+
for each_span in all_spans[span_index + 1 :]:
|
|
129
|
+
if each_span.start() >= segment_end:
|
|
130
|
+
break
|
|
131
|
+
if _hook_stem_in_span(each_span.group(1)) is not None:
|
|
132
|
+
return each_span.start()
|
|
133
|
+
return segment_end
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _line_diagnostics(
|
|
137
|
+
document: Document,
|
|
138
|
+
line_text: str,
|
|
139
|
+
line_number: int,
|
|
140
|
+
all_known_stems: frozenset[str],
|
|
141
|
+
all_registered_stems: frozenset[str],
|
|
142
|
+
) -> tuple[Diagnostic, ...]:
|
|
143
|
+
all_spans = tuple(re.finditer(constants.INLINE_CODE_PATTERN, line_text))
|
|
144
|
+
all_diagnostics: list[Diagnostic] = []
|
|
145
|
+
for each_index, each_span in enumerate(all_spans):
|
|
146
|
+
maybe_stem = _hook_stem_in_span(each_span.group(1))
|
|
147
|
+
if maybe_stem is None or maybe_stem not in all_known_stems:
|
|
148
|
+
continue
|
|
149
|
+
if maybe_stem in all_registered_stems:
|
|
150
|
+
continue
|
|
151
|
+
claim_end = _claim_segment_end(line_text, all_spans, each_index)
|
|
152
|
+
if not _names_a_live_action(line_text[each_span.end() : claim_end]):
|
|
153
|
+
continue
|
|
154
|
+
all_diagnostics.append(
|
|
155
|
+
Diagnostic(
|
|
156
|
+
constants.RETIRED_HOOK_PROSE_RULE_ID,
|
|
157
|
+
Severity.ERROR,
|
|
158
|
+
constants.RETIRED_HOOK_PROSE_MESSAGE.format(hook_name=maybe_stem),
|
|
159
|
+
Location(document.path, line_number, each_span.start() + 1),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
return tuple(all_diagnostics)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _hook_stem_sets(package_root: Path) -> tuple[frozenset[str], frozenset[str]]:
|
|
166
|
+
hooks_root = package_root / constants.HOOKS_DIRECTORY_NAME
|
|
167
|
+
all_known_stems = _present_hook_stems(hooks_root) | _retired_roster_stems(
|
|
168
|
+
package_root
|
|
169
|
+
)
|
|
170
|
+
return all_known_stems, _registered_hook_stems(hooks_root)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def retired_hook_prose_diagnostics(
|
|
174
|
+
document: Document, repository_root: Path
|
|
175
|
+
) -> tuple[Diagnostic, ...]:
|
|
176
|
+
"""Report rules prose that credits an unregistered hook with a live action.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
document: Current rules Markdown text and path.
|
|
180
|
+
repository_root: Request repository root for package resolution.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
One diagnostic for each live claim about a hook that runs nowhere.
|
|
184
|
+
"""
|
|
185
|
+
all_known_stems, all_registered_stems = _hook_stem_sets(
|
|
186
|
+
_package_root(repository_root, document)
|
|
187
|
+
)
|
|
188
|
+
all_diagnostics: list[Diagnostic] = []
|
|
189
|
+
for each_line_number, each_line in enumerate(document.text.splitlines(), 1):
|
|
190
|
+
if _describes_a_replacement(each_line):
|
|
191
|
+
continue
|
|
192
|
+
all_diagnostics.extend(
|
|
193
|
+
_line_diagnostics(
|
|
194
|
+
document,
|
|
195
|
+
each_line,
|
|
196
|
+
each_line_number,
|
|
197
|
+
all_known_stems,
|
|
198
|
+
all_registered_stems,
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
return tuple(all_diagnostics)
|
|
@@ -8,7 +8,13 @@ from types import ModuleType
|
|
|
8
8
|
|
|
9
9
|
from shared_tree_paths import resolve_shared_scripts_directory
|
|
10
10
|
|
|
11
|
-
from . import
|
|
11
|
+
from . import (
|
|
12
|
+
adapter_configuration,
|
|
13
|
+
adapter_detectors,
|
|
14
|
+
adapter_pairing,
|
|
15
|
+
adapter_retired_hook_prose,
|
|
16
|
+
adapter_support,
|
|
17
|
+
)
|
|
12
18
|
from .config import constants
|
|
13
19
|
from .model import Diagnostic, Document, DocumentSet
|
|
14
20
|
|
|
@@ -377,3 +383,32 @@ def accepts_plans(document: Document) -> bool:
|
|
|
377
383
|
)
|
|
378
384
|
)
|
|
379
385
|
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def accepts_rules_markdown(document: Document) -> bool:
|
|
389
|
+
"""Return whether the document is Markdown in a rules directory.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
document: Candidate document.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
True for a Markdown file directly under a ``rules`` directory.
|
|
396
|
+
"""
|
|
397
|
+
return adapter_retired_hook_prose.accepts_rules_markdown(document)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def retired_hook_prose_diagnostics(
|
|
401
|
+
document: Document, repository_root: Path
|
|
402
|
+
) -> tuple[Diagnostic, ...]:
|
|
403
|
+
"""Report rules prose that credits an unregistered hook with a live action.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
document: Current rules Markdown text and path.
|
|
407
|
+
repository_root: Request repository root for package resolution.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Retired-hook prose diagnostics.
|
|
411
|
+
"""
|
|
412
|
+
return adapter_retired_hook_prose.retired_hook_prose_diagnostics(
|
|
413
|
+
document, repository_root
|
|
414
|
+
)
|
|
@@ -57,6 +57,9 @@ ALL_ACTION_BOUNDARY_SEGMENTS = frozenset(
|
|
|
57
57
|
}
|
|
58
58
|
)
|
|
59
59
|
ALL_ACTION_BOUNDARY_PREFIXES = ("deny_", "block_", "ask_")
|
|
60
|
+
ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
61
|
+
"blocking/bash_pre_tool_use_dispatcher.py",
|
|
62
|
+
)
|
|
60
63
|
ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
|
|
61
64
|
ALL_TEST_FILE_PREFIXES = ("test_",)
|
|
62
65
|
ALL_TEST_FILE_SUFFIXES = ("_test.py",)
|
|
@@ -80,3 +83,74 @@ TERMINOLOGY_MESSAGE_GROUP = "message"
|
|
|
80
83
|
|
|
81
84
|
PR_LOOP_DIRECTORY_NAME = "pr-loop"
|
|
82
85
|
SHARED_ROOT_PARENT_INDEX = 2
|
|
86
|
+
|
|
87
|
+
RETIRED_HOOK_PROSE_RULE_ID = "retired-hook-prose"
|
|
88
|
+
RULES_DIRECTORY_NAME = "rules"
|
|
89
|
+
HOOKS_DIRECTORY_NAME = "hooks"
|
|
90
|
+
HOOKS_CONFIGURATION_FILE_NAME = "hooks.json"
|
|
91
|
+
HOOK_CONSTANTS_DIRECTORY_NAME = "hooks_constants"
|
|
92
|
+
DISPATCHER_CONSTANTS_GLOB = "*dispatcher_constants.py"
|
|
93
|
+
ALL_INSTALLER_PATH_SEGMENTS = ("bin", "install.mjs")
|
|
94
|
+
PYTHON_GLOB = "*.py"
|
|
95
|
+
REPLACEMENT_CHARACTER_ERRORS = "replace"
|
|
96
|
+
ALL_HOOK_MODULE_NAME_SUFFIXES = (
|
|
97
|
+
"_advisor",
|
|
98
|
+
"_blocker",
|
|
99
|
+
"_dispatcher",
|
|
100
|
+
"_enforcer",
|
|
101
|
+
"_gate",
|
|
102
|
+
"_guard",
|
|
103
|
+
"_recorder",
|
|
104
|
+
"_rewriter",
|
|
105
|
+
"_tracker",
|
|
106
|
+
"_validator",
|
|
107
|
+
)
|
|
108
|
+
ALL_LIVE_CLAIM_VERBS = frozenset(
|
|
109
|
+
{
|
|
110
|
+
"allows",
|
|
111
|
+
"applies",
|
|
112
|
+
"blocks",
|
|
113
|
+
"catches",
|
|
114
|
+
"checks",
|
|
115
|
+
"denies",
|
|
116
|
+
"enforces",
|
|
117
|
+
"fires",
|
|
118
|
+
"flags",
|
|
119
|
+
"gates",
|
|
120
|
+
"hosts",
|
|
121
|
+
"matches",
|
|
122
|
+
"prevents",
|
|
123
|
+
"reads",
|
|
124
|
+
"records",
|
|
125
|
+
"refuses",
|
|
126
|
+
"rejects",
|
|
127
|
+
"reports",
|
|
128
|
+
"requires",
|
|
129
|
+
"runs",
|
|
130
|
+
"scans",
|
|
131
|
+
"stops",
|
|
132
|
+
"watches",
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
ALL_REPLACEMENT_DELIVERY_PHRASES = (
|
|
136
|
+
"staged policy lint",
|
|
137
|
+
"policy lint",
|
|
138
|
+
"cde_lint",
|
|
139
|
+
"repository_checks/",
|
|
140
|
+
"repository_policy.py",
|
|
141
|
+
"repository check",
|
|
142
|
+
)
|
|
143
|
+
INLINE_CODE_PATTERN = r"`([^`\n]+)`"
|
|
144
|
+
HOOK_MODULE_NAME_PATTERN = r"[a-z_][a-z0-9_]*"
|
|
145
|
+
PROSE_WORD_PATTERN = r"[a-z]+"
|
|
146
|
+
RETIRED_ROSTER_PATTERN = (
|
|
147
|
+
r"(?s)RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS\s*=\s*new Set\(\[(.*?)\]\)"
|
|
148
|
+
)
|
|
149
|
+
QUOTED_HOOK_PATH_PATTERN = r"['\"]([a-z_]+/[a-z_0-9]+\.py)['\"]"
|
|
150
|
+
HOOKS_JSON_PATH_PATTERN = r"hooks/[a-z_]+/([a-z_0-9]+)\.py"
|
|
151
|
+
SENTENCE_BOUNDARY_PATTERN = r"[.!?](?:\s|$)"
|
|
152
|
+
RETIRED_HOOK_PROSE_MESSAGE = (
|
|
153
|
+
"{hook_name} is named by no hooks.json entry and no dispatcher roster, so it "
|
|
154
|
+
"reaches no tool call. Say what the reader faces now, or drop the "
|
|
155
|
+
"present-tense action."
|
|
156
|
+
)
|
|
@@ -104,6 +104,11 @@ def _text_document_rules() -> tuple[model.Rule, ...]:
|
|
|
104
104
|
adapters.accepts_workflow,
|
|
105
105
|
adapters.workflow_substitution_diagnostics,
|
|
106
106
|
),
|
|
107
|
+
build_document_rule(
|
|
108
|
+
"retired-hook-prose",
|
|
109
|
+
adapters.accepts_rules_markdown,
|
|
110
|
+
adapters.retired_hook_prose_diagnostics,
|
|
111
|
+
),
|
|
107
112
|
)
|
|
108
113
|
|
|
109
114
|
|