claude-dev-env 1.71.0 → 1.73.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.
Files changed (68) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
  3. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
  4. package/agents/clean-coder.md +1 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  6. package/bin/install.mjs +73 -5
  7. package/bin/install.test.mjs +360 -4
  8. package/docs/CODE_RULES.md +1 -1
  9. package/hooks/blocking/CLAUDE.md +3 -1
  10. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  11. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  12. package/hooks/blocking/code_rules_docstrings.py +676 -0
  13. package/hooks/blocking/code_rules_enforcer.py +26 -0
  14. package/hooks/blocking/code_rules_shared.py +19 -0
  15. package/hooks/blocking/code_rules_test_assertions.py +152 -1
  16. package/hooks/blocking/code_rules_type_escape.py +447 -2
  17. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  18. package/hooks/blocking/md_to_html_blocker.py +7 -8
  19. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  20. package/hooks/blocking/plain_language_blocker.py +51 -16
  21. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  22. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  23. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  24. package/hooks/blocking/state_description_blocker.py +75 -36
  25. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  26. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  27. package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
  28. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  29. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  30. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  31. package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
  32. package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
  33. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  34. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  36. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  37. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  38. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  39. package/hooks/hooks.json +9 -79
  40. package/hooks/hooks_constants/CLAUDE.md +3 -1
  41. package/hooks/hooks_constants/blocking_check_limits.py +75 -0
  42. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  43. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  44. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  45. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  46. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  47. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  48. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  49. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  50. package/hooks/validation/mypy_validator.py +215 -17
  51. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  52. package/hooks/validation/test_mypy_validator.py +184 -1
  53. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  54. package/hooks/workflow/test_auto_formatter.py +10 -9
  55. package/package.json +1 -1
  56. package/rules/docstring-prose-matches-implementation.md +3 -2
  57. package/scripts/CLAUDE.md +1 -0
  58. package/scripts/Show-Asset.ps1 +106 -0
  59. package/skills/autoconverge/SKILL.md +123 -3
  60. package/skills/autoconverge/reference/convergence.md +41 -1
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
  62. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
  63. package/skills/autoconverge/workflow/converge.mjs +203 -8
  64. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  65. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  66. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
  67. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
  68. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -0
package/CLAUDE.md CHANGED
@@ -27,6 +27,14 @@ When making code changes, make sure you are working in the proper worktree path
27
27
 
28
28
  `Edit` changes existing files; `Write` creates new ones. Default to `Edit` — reach for `Write` only for a genuinely new path. For a true full rewrite, delete the file first, then `Write`.
29
29
 
30
+ ## Showing Files: Open Them, Don't Print the Path
31
+
32
+ When I ask you to "show me", "open", "display", "let me see", or "pull up" a file — an image, PDF, HTML page, document, anything — open it on my screen. Launch the viewer so each image window matches the asset's size:
33
+
34
+ `Start-Process pwsh -WindowStyle Hidden -ArgumentList '-NoProfile','-File',"$HOME\.claude\scripts\Show-Asset.ps1",'<path 1>','<path 2>'`
35
+
36
+ It sizes each image window to the image (scaled down to fit the screen) and opens non-image files in their default app; pass every path I name. Printing a path or attaching the file is not showing it — do that only when the file truly cannot be opened, and say why.
37
+
30
38
  ## Test Philosophy
31
39
 
32
40
  When writing tests, always write tests that actually test the behavior of the function against actual, real data and environments.
@@ -594,11 +594,11 @@ def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
594
594
  Args:
595
595
  content: File content as a single string for AST parsing.
596
596
  file_path: Repository-relative POSIX path of the file (used to
597
- skip non-Python code extensions early).
597
+ skip non-Python code extensions and test files early).
598
598
 
599
599
  Returns:
600
- List of violation strings, one per dropped optional kwarg. Returns
601
- an empty list when the file is not Python or has a syntax error.
600
+ List of violation strings, one per dropped optional kwarg. Empty for
601
+ a non-Python file, a test file, or a file with a syntax error.
602
602
  """
603
603
  non_python_code_extensions = ALL_CODE_FILE_EXTENSIONS - {PYTHON_FILE_EXTENSION}
604
604
  lowercase_file_path = file_path.lower()
@@ -607,6 +607,8 @@ def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
607
607
  for each_extension in non_python_code_extensions
608
608
  ):
609
609
  return []
610
+ if is_test_path(file_path):
611
+ return []
610
612
  try:
611
613
  tree = ast.parse(content)
612
614
  except SyntaxError:
@@ -430,6 +430,45 @@ def test_check_wrapper_plumb_through_still_flags_attribute_call() -> None:
430
430
  )
431
431
 
432
432
 
433
+ def test_check_wrapper_plumb_through_exempts_test_files() -> None:
434
+ source = (
435
+ "def _helper(name, *, clean_name=None):\n"
436
+ " return (name, clean_name)\n"
437
+ "\n"
438
+ "def test_uses_helper():\n"
439
+ " return _helper('a', clean_name='b')\n"
440
+ )
441
+ shared_issues = gate_module.check_wrapper_plumb_through(source, "pkg/test_thing.py")
442
+ bugteam_gate = _load_bugteam_gate_module()
443
+ bugteam_issues = bugteam_gate.check_wrapper_plumb_through(source, "pkg/test_thing.py")
444
+ assert shared_issues == [], (
445
+ "a test_* function in a test-file path that calls a module-level helper "
446
+ "exposing an optional kwarg is not a wrapper; the shared gate must exempt "
447
+ "test files and emit zero findings"
448
+ )
449
+ assert bugteam_issues == [], (
450
+ "the bugteam gate copy must apply the identical test-file exemption"
451
+ )
452
+
453
+
454
+ def test_check_wrapper_plumb_through_still_flags_non_test_path_with_test_shape() -> None:
455
+ source = (
456
+ "def _helper(name, *, clean_name=None):\n"
457
+ " return (name, clean_name)\n"
458
+ "\n"
459
+ "def test_uses_helper():\n"
460
+ " return _helper('a', clean_name='b')\n"
461
+ )
462
+ issues = gate_module.check_wrapper_plumb_through(source, "pkg/module.py")
463
+ assert any(
464
+ "test_uses_helper" in each_issue and "clean_name" in each_issue
465
+ for each_issue in issues
466
+ ), (
467
+ "the test-file exemption is scoped to test paths only; the same wrapper "
468
+ "shape on a non-test path must still be flagged"
469
+ )
470
+
471
+
433
472
  def test_split_violations_by_scope_accepts_all_added_line_numbers_param_name() -> None:
434
473
  blocking_issues, advisory_issues = gate_module.split_violations_by_scope(
435
474
  ["Line 5: violation"],
@@ -264,6 +264,7 @@ Tests document behavior. The hook layer enforces several constraints on test fil
264
264
  - **No decorators named `skip*` on test functions.** Tests fail with a clear error rather than skip when a system dependency is missing. The hook fires on any decorator (whether `@skip_if_missing_dependency`, `@unittest.skipIf`, `@pytest.mark.skip`, or any custom variant) whose identifier contains the substring `skip`.
265
265
  - **No existence-only tests.** A test whose entire body is `assert callable(x)`, `assert hasattr(module, "name")`, or `assert obj is not None` covers no behavior. Replace with an assertion that exercises the behavior — call the function and assert on its return value or side effect.
266
266
  - **No constant-equality tests.** A test whose sole assertion is `assert CACHE_DIR == "cache"` (or any `UPPER_SNAKE == LITERAL` pattern) just verifies the constant has not changed. Delete it or replace with a behavior assertion.
267
+ - **No stale test names after a rename.** When you rename a function the tests exercise, rename the test functions in the same edit. The `check_stale_test_name_target` hook fires on a `test_*` name that embeds a snake_case run the file never imports, defines, or calls while the body calls a same-shape sibling — the signature of a producer rename that updated the bodies but left the test identifiers naming the deleted function.
267
268
  - **No tautological assertions.** `assert CONSTANT == CONSTANT` and `assert hasattr(module, "name")` pass regardless of the implementation. Replace with assertions that would fail if the implementation regressed.
268
269
  - **Test through the public API.** Do not assert on private state, hook return values, internal class fields, `_protected_field`, `__private_field`, or `component.state.X`. If the test needs visibility the public API does not provide, the public API needs a method, not the test.
269
270
  - **For React components**, query in this priority order: `getByRole > getByLabelText > getByText > getByTestId`. Use `userEvent` over `fireEvent` (more realistic). Mock at API boundaries (network calls, external services), not internal hooks or utilities.
@@ -23,9 +23,9 @@ Decomposition is by the **kind of docstring claim** that needs to be cross-check
23
23
  | O1 | Module-level responsibility verbs | A module docstring uses verbs (`detects`, `validates`, `enforces`, `recovers`, `parses`, `routes`) — every claimed responsibility is implemented by an exported symbol in the same module. Symbols absent from the module body should not appear as this module's responsibilities. |
24
24
  | O2 | Fixture docstring vs sibling-test behavior | An autouse / module-scope fixture docstring asserts an invariant (`readability is disabled`, `network is mocked`, `tmp_path is empty`). No sibling test in the same module explicitly opts out of the invariant. |
25
25
  | O3 | Predicate-name and -docstring vs body breadth | A boolean helper's name and docstring promise a narrow predicate. Walk the body's branches: every branch's `return True` path is consistent with the promised name. Bodies that accept inputs broader than the name (`_dir_value_resolves_to_shared_temp` also accepting HOME/TMP env-derived paths) are O3 findings. |
26
- | O4 | Step-ordering narrative | A docstring describes processing as `A then B then C`. Walk the body and confirm the call order matches. Mismatched order is an O4 finding regardless of whether the final output is the same. |
26
+ | O4 | Step-ordering narrative | A docstring describes processing as `A then B then C`. Walk the body and confirm the call order matches. Mismatched order is an O4 finding regardless of whether the final output is the same. A docstring step enumeration that names the body's linear steps but omits a corrective workflow step the body guards inside an `if`/`elif` branch (`if not await cancel_and_reinitiate_update(...): return`) is also an O4 finding: the reader trusts the step list to be complete and misses the conditional path. The branch-guarded-dispatch shape of this drift — a docstring that names two or more linear-step callees while the body guards a two-or-more-token dispatch callee inside a branch whose name the prose never spells out — is gated deterministically at Write/Edit time by `check_docstring_step_enumeration_dispatch_coverage` (`packages/claude-dev-env/hooks/blocking/code_rules_docstrings.py`), so the audit lane focuses on the step-ordering shapes the gate cannot match (re-ordered steps, plain unguarded steps the prose omits). |
27
27
  | O5 | Named-sentinel / filename references | A docstring names a sentinel marker, environment variable, filename, or magic string. Confirm the named token actually exists in the module body or in the repo's naming convention. |
28
- | O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
28
+ | O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. When a docstring sentence excludes a named category of input from what the function flags (`X are not dispatch steps`, `Y is not a match`), confirm the axis the prose excludes on is the axis the body's branch condition actually keys on. A body that flags a call when it sits inside an `If.test` guard, paired with prose that excludes by the call's receiver shape (`method-on-local calls inside a branch are not dispatch steps`), is an O6 finding: a guarded method-on-local call is flagged even though the prose lists it as excluded — the exclusion is keyed to the wrong axis. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
29
29
  | O7 | Module-doc-vs-split-module after refactor | When a refactor moves a responsibility to a sibling module, the originating module's docstring and the receiving module's docstring both describe the home of that responsibility. A module docstring should describe only the responsibilities it owns. |
30
30
  | O8 | Companion-doc ordering/content vs producer | When a PR changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the PR diff never touched the `.md` file, because the behavior change orphaned the doc claim. See `../../rules/docstring-prose-matches-implementation.md`. |
31
31
 
package/bin/install.mjs CHANGED
@@ -293,18 +293,59 @@ function backupClaudeHubBeforeOverwrite(destPath, incomingPath) {
293
293
  return backupPath;
294
294
  }
295
295
 
296
+ /**
297
+ * Hook script paths that were folded into the PreToolUse dispatcher in Stage 1.
298
+ * These entries no longer appear in hooks.json but must still be recognized as
299
+ * managed so a reinstall from an older settings shape prunes them and they do
300
+ * not double-run alongside the dispatcher.
301
+ */
302
+ export const FOLDED_HOOK_RELATIVE_PATHS = new Set([
303
+ 'blocking/write_existing_file_blocker.py',
304
+ 'blocking/sensitive_file_protector.py',
305
+ 'validation/hook_format_validator.py',
306
+ 'blocking/code_rules_enforcer.py',
307
+ 'blocking/tdd_enforcer.py',
308
+ 'blocking/windows_rmtree_blocker.py',
309
+ 'blocking/state_description_blocker.py',
310
+ 'blocking/subprocess_budget_completeness.py',
311
+ 'blocking/hook_prose_detector_consistency.py',
312
+ 'blocking/verified_commit_message_accuracy_blocker.py',
313
+ 'blocking/workflow_substitution_slot_blocker.py',
314
+ 'blocking/claude_md_orphan_file_blocker.py',
315
+ 'blocking/pytest_testpaths_orphan_blocker.py',
316
+ 'blocking/open_questions_in_plans_blocker.py',
317
+ 'blocking/plain_language_blocker.py',
318
+ ]);
319
+
320
+ /**
321
+ * Hook script paths that were folded into the PostToolUse dispatcher. These
322
+ * after-write hooks no longer appear in hooks.json but must still be recognized
323
+ * as managed so a reinstall from an older settings shape prunes them and they do
324
+ * not double-run alongside the PostToolUse dispatcher.
325
+ */
326
+ export const POST_FOLDED_HOOK_RELATIVE_PATHS = new Set([
327
+ 'validation/mypy_validator.py',
328
+ 'workflow/auto_formatter.py',
329
+ 'workflow/doc_gist_auto_publish.py',
330
+ ]);
331
+
296
332
  /**
297
333
  * Builds the set of hook script paths this installer manages, each relative to
298
334
  * the hooks directory (e.g. 'blocking/code_rules_enforcer.py'), parsed from the
299
335
  * `${CLAUDE_PLUGIN_ROOT}/hooks/<path>` references in hooks.json. Inline
300
336
  * `python3 -c` commands reference the hooks directory without a script tail and
301
- * contribute nothing.
337
+ * contribute nothing. Also includes every path from FOLDED_HOOK_RELATIVE_PATHS
338
+ * and POST_FOLDED_HOOK_RELATIVE_PATHS so a reinstall from an older settings shape
339
+ * prunes both the PreToolUse and the PostToolUse folded entries.
302
340
  *
303
341
  * @param {{hooks: object}} hooksConfig Parsed hooks.json.
304
342
  * @returns {Set<string>} Forward-slash relative script paths under hooks/.
305
343
  */
306
344
  export function managedHookScriptRelativePaths(hooksConfig) {
307
- const relativePaths = new Set();
345
+ const relativePaths = new Set([
346
+ ...FOLDED_HOOK_RELATIVE_PATHS,
347
+ ...POST_FOLDED_HOOK_RELATIVE_PATHS,
348
+ ]);
308
349
  const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.py)/g;
309
350
  for (const matcherGroups of Object.values(hooksConfig.hooks)) {
310
351
  for (const sourceGroup of matcherGroups) {
@@ -426,12 +467,38 @@ export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
426
467
  );
427
468
  }
428
469
 
470
+ /**
471
+ * Strips every managed hook (standalone script or inline validators runner) from
472
+ * all existing matcher groups of one event in a settings object, dropping any
473
+ * group left empty. Run before the per-group merge so a managed hook that an
474
+ * upgrade moves to a different matcher group is pruned from its old group rather
475
+ * than left to double-run. User-authored hooks outside the managed set stay.
476
+ *
477
+ * @param {object} settings The parsed settings.json object (mutated in place).
478
+ * @param {string} eventType The lifecycle event whose groups are pruned.
479
+ * @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
480
+ * @returns {void}
481
+ */
482
+ function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths) {
483
+ const existingGroups = settings.hooks[eventType];
484
+ if (!existingGroups) return;
485
+ settings.hooks[eventType] = existingGroups
486
+ .map(group => ({
487
+ ...group,
488
+ hooks: group.hooks.filter(
489
+ hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
490
+ ),
491
+ }))
492
+ .filter(group => group.hooks.length > 0);
493
+ }
494
+
429
495
  /**
430
496
  * Merges the installer's managed hook groups into a settings object in memory,
431
497
  * pruning every prior managed hook (standalone script or inline validators
432
- * runner) from each matcher group before appending the freshly rewritten copies
433
- * so repeated merges stay idempotent. User-authored hooks in the same group are
434
- * preserved untouched.
498
+ * runner) from each event's existing matcher groups before appending the freshly
499
+ * rewritten copies so repeated merges stay idempotent and a managed hook moved to
500
+ * a new matcher group does not double-run. User-authored hooks are preserved
501
+ * untouched.
435
502
  *
436
503
  * @param {object} settings The parsed settings.json object (mutated in place).
437
504
  * @param {{hooks: object}} hooksConfig Parsed hooks.json.
@@ -445,6 +512,7 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
445
512
  let groupCount = 0;
446
513
  for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
447
514
  if (!settings.hooks[eventType]) settings.hooks[eventType] = [];
515
+ pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths);
448
516
  for (const sourceGroup of matcherGroups) {
449
517
  const rewrittenHooks = sourceGroup.hooks.map(hook => {
450
518
  let command = hook.command;
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
3
  import { execFileSync } from 'node:child_process';
4
- import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
4
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, readFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { pathToFileURL } from 'node:url';
@@ -9,6 +9,8 @@ import { pathToFileURL } from 'node:url';
9
9
  import {
10
10
  collectPackageSourceConflicts,
11
11
  CONTENT_DIRECTORIES,
12
+ FOLDED_HOOK_RELATIVE_PATHS,
13
+ POST_FOLDED_HOOK_RELATIVE_PATHS,
12
14
  pythonCandidatesForPlatform,
13
15
  isWindowsStorePythonStub,
14
16
  interpreterCommandFromPath,
@@ -361,7 +363,9 @@ test('managedHookScriptRelativePaths collects every installed hook script path a
361
363
  const relativePaths = managedHookScriptRelativePaths(SAMPLE_HOOKS_CONFIG);
362
364
  assert.ok(relativePaths.has('notification/attention_needed_notify.py'));
363
365
  assert.ok(relativePaths.has('blocking/hedging_language_blocker.py'));
364
- assert.equal([...relativePaths].length, 2);
366
+ for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
367
+ assert.ok(relativePaths.has(foldedPath), `folded hook ${foldedPath} must always be in the managed set`);
368
+ }
365
369
  });
366
370
 
367
371
 
@@ -566,7 +570,9 @@ test('managedHookScriptRelativePathsFromSourceRoots reads each root hooks.json s
566
570
 
567
571
  assert.ok(relativePaths.has('notification/attention_needed_notify.py'));
568
572
  assert.ok(relativePaths.has('blocking/hedging_language_blocker.py'));
569
- assert.equal([...relativePaths].length, 2);
573
+ for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
574
+ assert.ok(relativePaths.has(foldedPath), `folded hook ${foldedPath} must always be in the managed set`);
575
+ }
570
576
  } finally {
571
577
  rmSync(sourceRoot, { recursive: true, force: true });
572
578
  }
@@ -588,7 +594,9 @@ test('managedHookScriptRelativePathsFromSourceRoots unions managed scripts acros
588
594
 
589
595
  assert.ok(relativePaths.has('blocking/code_rules_enforcer.py'));
590
596
  assert.ok(relativePaths.has('blocking/pwsh_enforcer.py'));
591
- assert.equal([...relativePaths].length, 2);
597
+ for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
598
+ assert.ok(relativePaths.has(foldedPath), `folded hook ${foldedPath} must always be in the managed set`);
599
+ }
592
600
  } finally {
593
601
  rmSync(builtinRoot, { recursive: true, force: true });
594
602
  rmSync(dependencyRoot, { recursive: true, force: true });
@@ -647,3 +655,351 @@ test('purge set sourced from package hooks.json prunes standalone managed script
647
655
  rmSync(sourceRoot, { recursive: true, force: true });
648
656
  }
649
657
  });
658
+
659
+
660
+ const DISPATCHER_HOOKS_CONFIG = {
661
+ hooks: {
662
+ PreToolUse: [
663
+ {
664
+ matcher: 'Write|Edit',
665
+ hooks: [
666
+ {
667
+ type: 'command',
668
+ command: 'python3 -c "import sys; sys.path.insert(0, r\'${CLAUDE_PLUGIN_ROOT}/hooks\'); from validators.run_all_validators import main; sys.exit(main())"',
669
+ timeout: 15,
670
+ },
671
+ ],
672
+ },
673
+ {
674
+ matcher: 'Write|Edit|MultiEdit',
675
+ hooks: [
676
+ {
677
+ type: 'command',
678
+ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py',
679
+ timeout: 60,
680
+ },
681
+ ],
682
+ },
683
+ ],
684
+ },
685
+ };
686
+
687
+ const OLD_FOLDED_HOOKS_SETTINGS = {
688
+ hooks: {
689
+ PreToolUse: [
690
+ {
691
+ matcher: 'Write|Edit',
692
+ hooks: [
693
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/write_existing_file_blocker.py', timeout: 10 },
694
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/sensitive_file_protector.py', timeout: 10 },
695
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/validation/hook_format_validator.py', timeout: 15 },
696
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/code_rules_enforcer.py', timeout: 30 },
697
+ { type: 'command', command: 'py -3 -c "import sys; sys.path.insert(0, r\'C:/Users/x/.claude/hooks\'); from validators.run_all_validators import main; sys.exit(main())"', timeout: 15 },
698
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/tdd_enforcer.py', timeout: 10 },
699
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/windows_rmtree_blocker.py', timeout: 10 },
700
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/state_description_blocker.py', timeout: 10 },
701
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/subprocess_budget_completeness.py', timeout: 10 },
702
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/hook_prose_detector_consistency.py', timeout: 10 },
703
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/verified_commit_message_accuracy_blocker.py', timeout: 10 },
704
+ ],
705
+ },
706
+ {
707
+ matcher: 'Write|Edit|MultiEdit',
708
+ hooks: [
709
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/workflow_substitution_slot_blocker.py', timeout: 10 },
710
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/claude_md_orphan_file_blocker.py', timeout: 10 },
711
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/pytest_testpaths_orphan_blocker.py', timeout: 10 },
712
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/open_questions_in_plans_blocker.py', timeout: 10 },
713
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/plain_language_blocker.py', timeout: 10 },
714
+ ],
715
+ },
716
+ ],
717
+ },
718
+ };
719
+
720
+
721
+ test('FOLDED_HOOK_RELATIVE_PATHS contains all 15 hooks removed from hooks.json', () => {
722
+ assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 15);
723
+ assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/write_existing_file_blocker.py'));
724
+ assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/plain_language_blocker.py'));
725
+ assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/code_rules_enforcer.py'));
726
+ assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/pytest_testpaths_orphan_blocker.py'));
727
+ });
728
+
729
+
730
+ test('FOLDED_HOOK_RELATIVE_PATHS lists every hook the PreToolUse dispatcher hosts', () => {
731
+ const dispatcherHostedHooks = [
732
+ 'blocking/write_existing_file_blocker.py',
733
+ 'blocking/sensitive_file_protector.py',
734
+ 'validation/hook_format_validator.py',
735
+ 'blocking/code_rules_enforcer.py',
736
+ 'blocking/tdd_enforcer.py',
737
+ 'blocking/windows_rmtree_blocker.py',
738
+ 'blocking/state_description_blocker.py',
739
+ 'blocking/subprocess_budget_completeness.py',
740
+ 'blocking/hook_prose_detector_consistency.py',
741
+ 'blocking/verified_commit_message_accuracy_blocker.py',
742
+ 'blocking/workflow_substitution_slot_blocker.py',
743
+ 'blocking/claude_md_orphan_file_blocker.py',
744
+ 'blocking/pytest_testpaths_orphan_blocker.py',
745
+ 'blocking/open_questions_in_plans_blocker.py',
746
+ 'blocking/plain_language_blocker.py',
747
+ ];
748
+ for (const hostedPath of dispatcherHostedHooks) {
749
+ assert.ok(
750
+ FOLDED_HOOK_RELATIVE_PATHS.has(hostedPath),
751
+ `dispatcher-hosted hook ${hostedPath} must be in FOLDED_HOOK_RELATIVE_PATHS so a reinstall prunes its stale standalone entry and it does not double-run`
752
+ );
753
+ }
754
+ assert.equal(
755
+ FOLDED_HOOK_RELATIVE_PATHS.size,
756
+ dispatcherHostedHooks.length,
757
+ 'FOLDED_HOOK_RELATIVE_PATHS must hold exactly the dispatcher-hosted hooks, no more, no fewer'
758
+ );
759
+ });
760
+
761
+
762
+ test('managedHookScriptRelativePaths includes the dispatcher and all folded hooks so old entries are prunable', () => {
763
+ const relativePaths = managedHookScriptRelativePaths(DISPATCHER_HOOKS_CONFIG);
764
+ assert.ok(relativePaths.has('blocking/pre_tool_use_dispatcher.py'), 'dispatcher must be in managed set');
765
+ for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
766
+ assert.ok(relativePaths.has(foldedPath), `folded hook ${foldedPath} must be in managed set`);
767
+ }
768
+ });
769
+
770
+
771
+ test('mergeHooksIntoSettings into old folded-hooks settings yields exactly one dispatcher entry and no folded entries', () => {
772
+ const settings = JSON.parse(JSON.stringify(OLD_FOLDED_HOOKS_SETTINGS));
773
+ mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
774
+
775
+ const allPreToolUseGroups = settings.hooks.PreToolUse || [];
776
+ const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
777
+
778
+ const allDispatcherCommands = allHookCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
779
+ assert.equal(allDispatcherCommands.length, 1, 'exactly one dispatcher entry must be present');
780
+
781
+ for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
782
+ const foldedBasename = foldedPath.split('/').pop();
783
+ const foldedCommands = allHookCommands.filter(
784
+ cmd => cmd.includes(foldedBasename) && !cmd.includes('pre_tool_use_dispatcher')
785
+ );
786
+ assert.equal(foldedCommands.length, 0, `folded hook ${foldedBasename} must not appear as a separate entry`);
787
+ }
788
+ });
789
+
790
+
791
+ test('mergeHooksIntoSettings into old folded-hooks settings preserves the inline validators runner', () => {
792
+ const settings = JSON.parse(JSON.stringify(OLD_FOLDED_HOOKS_SETTINGS));
793
+ mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
794
+
795
+ assert.equal(
796
+ countManagedRunAllValidatorsHooks(settings),
797
+ 1,
798
+ 'exactly one run_all_validators hook must remain in Write|Edit',
799
+ );
800
+ });
801
+
802
+
803
+ test('mergeHooksIntoSettings is idempotent when run twice against an already-updated settings shape', () => {
804
+ const settings = JSON.parse(JSON.stringify(OLD_FOLDED_HOOKS_SETTINGS));
805
+ mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
806
+ mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
807
+
808
+ const allPreToolUseGroups = settings.hooks.PreToolUse || [];
809
+ const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
810
+
811
+ const allDispatcherCommands = allHookCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
812
+ assert.equal(allDispatcherCommands.length, 1, 'dispatcher must appear exactly once after two merges');
813
+ assert.equal(countManagedRunAllValidatorsHooks(settings), 1, 'run_all_validators must appear exactly once after two merges');
814
+ });
815
+
816
+
817
+ test('shipped hooks.json matches the dispatcher design: dispatchers registered, run_all_validators retained, no folded hook standalone', () => {
818
+ const shippedHooksConfig = JSON.parse(
819
+ readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
820
+ );
821
+
822
+ const allPreToolUseGroups = shippedHooksConfig.hooks.PreToolUse || [];
823
+ const allPreCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
824
+ const preDispatcherCommands = allPreCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
825
+ assert.equal(preDispatcherCommands.length, 1, 'shipped hooks.json must register the PreToolUse dispatcher exactly once');
826
+
827
+ assert.equal(
828
+ countManagedRunAllValidatorsHooks(shippedHooksConfig),
829
+ 1,
830
+ 'shipped hooks.json must retain the inline run_all_validators runner in Write|Edit',
831
+ );
832
+
833
+ const allPostToolUseGroups = shippedHooksConfig.hooks.PostToolUse || [];
834
+ const postDispatcherCommands = allPostToolUseGroups
835
+ .flatMap(group => group.hooks.map(hook => hook.command))
836
+ .filter(cmd => cmd.includes('post_tool_use_dispatcher.py'));
837
+ assert.equal(postDispatcherCommands.length, 1, 'shipped hooks.json must register the PostToolUse dispatcher exactly once');
838
+
839
+ const writePathCommands = allPreToolUseGroups
840
+ .filter(group => /Write|Edit|MultiEdit/.test(group.matcher || ''))
841
+ .flatMap(group => group.hooks.map(hook => hook.command));
842
+ for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
843
+ const foldedBasename = foldedPath.split('/').pop();
844
+ const standaloneFoldedCommands = writePathCommands.filter(
845
+ cmd => cmd.includes(foldedBasename) && !cmd.includes('pre_tool_use_dispatcher')
846
+ );
847
+ assert.equal(
848
+ standaloneFoldedCommands.length,
849
+ 0,
850
+ `folded hook ${foldedBasename} must not ship as a standalone write-path PreToolUse entry`,
851
+ );
852
+ }
853
+ });
854
+
855
+
856
+ const POST_DISPATCHER_HOOKS_CONFIG = {
857
+ hooks: {
858
+ PostToolUse: [
859
+ {
860
+ matcher: 'Write|Edit',
861
+ hooks: [
862
+ {
863
+ type: 'command',
864
+ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/validation/post_tool_use_dispatcher.py',
865
+ timeout: 60,
866
+ },
867
+ ],
868
+ },
869
+ ],
870
+ },
871
+ };
872
+
873
+ const OLD_POST_FOLDED_HOOKS_SETTINGS = {
874
+ hooks: {
875
+ PostToolUse: [
876
+ {
877
+ matcher: 'Write|Edit',
878
+ hooks: [
879
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/validation/mypy_validator.py', timeout: 30 },
880
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/workflow/auto_formatter.py', timeout: 30 },
881
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/workflow/doc_gist_auto_publish.py C:/Users/x/.claude', timeout: 60 },
882
+ ],
883
+ },
884
+ ],
885
+ },
886
+ };
887
+
888
+
889
+ test('POST_FOLDED_HOOK_RELATIVE_PATHS contains the three after-write hooks folded into the PostToolUse dispatcher', () => {
890
+ assert.equal(POST_FOLDED_HOOK_RELATIVE_PATHS.size, 3);
891
+ assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('validation/mypy_validator.py'));
892
+ assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/auto_formatter.py'));
893
+ assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/doc_gist_auto_publish.py'));
894
+ });
895
+
896
+
897
+ test('managedHookScriptRelativePaths includes the PostToolUse dispatcher and all post-folded hooks so old entries are prunable', () => {
898
+ const relativePaths = managedHookScriptRelativePaths(POST_DISPATCHER_HOOKS_CONFIG);
899
+ assert.ok(relativePaths.has('validation/post_tool_use_dispatcher.py'), 'PostToolUse dispatcher must be in managed set');
900
+ for (const foldedPath of POST_FOLDED_HOOK_RELATIVE_PATHS) {
901
+ assert.ok(relativePaths.has(foldedPath), `post-folded hook ${foldedPath} must be in managed set`);
902
+ }
903
+ });
904
+
905
+
906
+ test('mergeHooksIntoSettings into the old three PostToolUse entries yields exactly one post_tool_use_dispatcher entry and none of the three', () => {
907
+ const settings = JSON.parse(JSON.stringify(OLD_POST_FOLDED_HOOKS_SETTINGS));
908
+ mergeHooksIntoSettings(settings, POST_DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
909
+
910
+ const writeEditGroup = settings.hooks.PostToolUse.find(group => group.matcher === 'Write|Edit');
911
+ const allCommands = writeEditGroup.hooks.map(hook => hook.command);
912
+
913
+ const dispatcherCommands = allCommands.filter(cmd => cmd.includes('post_tool_use_dispatcher.py'));
914
+ assert.equal(dispatcherCommands.length, 1, 'exactly one PostToolUse dispatcher entry must be present');
915
+
916
+ for (const foldedPath of POST_FOLDED_HOOK_RELATIVE_PATHS) {
917
+ const foldedBasename = foldedPath.split('/').pop();
918
+ const foldedCommands = allCommands.filter(cmd => cmd.includes(foldedBasename));
919
+ assert.equal(foldedCommands.length, 0, `post-folded hook ${foldedBasename} must not appear as a separate entry`);
920
+ }
921
+ });
922
+
923
+
924
+ test('mergeHooksIntoSettings installs the PostToolUse dispatcher cleanly into an empty settings object', () => {
925
+ const settings = {};
926
+ mergeHooksIntoSettings(settings, POST_DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
927
+
928
+ const writeEditGroup = settings.hooks.PostToolUse.find(group => group.matcher === 'Write|Edit');
929
+ assert.equal(writeEditGroup.hooks.length, 1);
930
+ assert.equal(
931
+ writeEditGroup.hooks[0].command,
932
+ 'py -3 C:/Users/x/.claude/hooks/validation/post_tool_use_dispatcher.py',
933
+ );
934
+ });
935
+
936
+
937
+ test('mergeHooksIntoSettings is idempotent for the PostToolUse dispatcher across two installs', () => {
938
+ const settings = JSON.parse(JSON.stringify(OLD_POST_FOLDED_HOOKS_SETTINGS));
939
+ mergeHooksIntoSettings(settings, POST_DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
940
+ mergeHooksIntoSettings(settings, POST_DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
941
+
942
+ const writeEditGroup = settings.hooks.PostToolUse.find(group => group.matcher === 'Write|Edit');
943
+ const dispatcherCommands = writeEditGroup.hooks.filter(hook => hook.command.includes('post_tool_use_dispatcher.py'));
944
+ assert.equal(dispatcherCommands.length, 1, 'PostToolUse dispatcher must appear exactly once after two merges');
945
+ assert.equal(writeEditGroup.hooks.length, 1);
946
+ });
947
+
948
+
949
+ const PRE_DISPATCHER_ONLY_HOOKS_CONFIG = {
950
+ hooks: {
951
+ PreToolUse: [
952
+ {
953
+ matcher: 'Write|Edit|MultiEdit',
954
+ hooks: [
955
+ {
956
+ type: 'command',
957
+ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py',
958
+ timeout: 60,
959
+ },
960
+ ],
961
+ },
962
+ ],
963
+ },
964
+ };
965
+
966
+ const SETTINGS_WITH_INLINE_RUNNER = {
967
+ hooks: {
968
+ PreToolUse: [
969
+ {
970
+ matcher: 'Write|Edit',
971
+ hooks: [
972
+ {
973
+ type: 'command',
974
+ command: "py -3 -c \"import sys; sys.path.insert(0, r'C:/Users/x/.claude/hooks'); from validators.run_all_validators import main; sys.exit(main())\"",
975
+ timeout: 15,
976
+ },
977
+ ],
978
+ },
979
+ {
980
+ matcher: 'Write|Edit|MultiEdit',
981
+ hooks: [
982
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/pre_tool_use_dispatcher.py', timeout: 60 },
983
+ ],
984
+ },
985
+ ],
986
+ },
987
+ };
988
+
989
+
990
+ test('mergeHooksIntoSettings prunes the inline run_all_validators runner when the new shape no longer ships it', () => {
991
+ const settings = JSON.parse(JSON.stringify(SETTINGS_WITH_INLINE_RUNNER));
992
+ mergeHooksIntoSettings(settings, PRE_DISPATCHER_ONLY_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
993
+
994
+ assert.equal(countManagedRunAllValidatorsHooks(settings), 0, 'the inline validators runner must be pruned');
995
+
996
+ const writeEditGroup = (settings.hooks.PreToolUse || []).find(group => group.matcher === 'Write|Edit');
997
+ if (writeEditGroup) {
998
+ const runnerSurvivors = writeEditGroup.hooks.filter(hook => hook.command.includes('run_all_validators'));
999
+ assert.equal(runnerSurvivors.length, 0);
1000
+ }
1001
+
1002
+ const dispatcherGroup = settings.hooks.PreToolUse.find(group => group.matcher === 'Write|Edit|MultiEdit');
1003
+ const dispatcherCommands = dispatcherGroup.hooks.filter(hook => hook.command.includes('pre_tool_use_dispatcher.py'));
1004
+ assert.equal(dispatcherCommands.length, 1, 'the PreToolUse dispatcher must remain exactly once');
1005
+ });
@@ -23,7 +23,7 @@ Compact reference for agents. ⚡ marks rules enforced by `code_rules_enforcer.p
23
23
 
24
24
  `code_rules_enforcer.py` blocks each of these at Write/Edit and explains the specific violation when it fires; exact patterns and exemption lists live in the hook:
25
25
 
26
- no new comments · imports at top · logging format args (`log_*("...", arg)`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …) · known pytest fixture parameters a test function declares but never references (drop the unused parameter — pytest still pays its setup cost)
26
+ no new comments · imports at top · logging format args (`log_*("...", arg)`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`, a parameter typed bare `object` whose body reads `param.attribute`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …) · known pytest fixture parameters a test function declares but never references (drop the unused parameter — pytest still pays its setup cost)
27
27
 
28
28
  Test files are exempt from most checks. The one annotation the test-file exemption does NOT cover is a known pytest builtin fixture parameter: `tmp_path`, `monkeypatch`, `capsys`, `capfd`, `caplog`, `request`, and `tmp_path_factory` each have a single documented injected type, so the gate requires that annotation (`tmp_path: Path`) even inside a test file. The same set of fixtures is also subject to a use check: a pytest-collected test function that declares one of these parameters and never references it in its body fails the gate, because pytest materializes the fixture's setup (the temp directory, the monkeypatch context, the output capture) on every run whether or not the body reads the value — drop the unused parameter. A parameter counts as referenced when its name is read, augmented-assigned, or deleted anywhere in the body, including inside a nested function or comprehension. Only pytest-collectable functions are inspected — those at module top level or defined directly in a class body; a function nested inside another function's body is a local helper pytest never collects, so its fixture-named parameter is exempt. A `@pytest.fixture`-decorated function is exempt from the use check, since injecting one fixture into another purely to order its setup is intentional. Ordinary test parameters stay exempt from both checks. See also the file-global constants use-count rule: [`rules/file-global-constants.md`](../rules/file-global-constants.md).
29
29