claude-dev-env 1.58.0 → 1.60.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 (106) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  9. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  10. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  11. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  12. package/bin/install.mjs +100 -27
  13. package/bin/install.test.mjs +133 -1
  14. package/docs/CODE_RULES.md +3 -3
  15. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  16. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  17. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  18. package/hooks/blocking/code_rules_duplicate_body.py +439 -0
  19. package/hooks/blocking/code_rules_enforcer.py +190 -21
  20. package/hooks/blocking/code_rules_magic_values.py +98 -0
  21. package/hooks/blocking/code_rules_shared.py +41 -0
  22. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  23. package/hooks/blocking/config/__init__.py +5 -0
  24. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  25. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  26. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  27. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  28. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  29. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  30. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  31. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  32. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  33. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  34. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  35. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  36. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  37. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  38. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  39. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  40. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  41. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  42. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  43. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  44. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  45. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  46. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  47. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  48. package/hooks/blocking/verification_verdict_store.py +446 -0
  49. package/hooks/blocking/verified_commit_gate.py +523 -0
  50. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  51. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  52. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  53. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  54. package/hooks/hooks.json +58 -1
  55. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  56. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  57. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  58. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  59. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  60. package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
  61. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  62. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  63. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  64. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  65. package/package.json +1 -1
  66. package/rules/docstring-prose-matches-implementation.md +43 -0
  67. package/rules/file-global-constants.md +7 -1
  68. package/rules/hook-prose-matches-detector.md +26 -0
  69. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  70. package/rules/no-inline-destructive-literals.md +11 -0
  71. package/rules/workflow-substitution-slots.md +7 -0
  72. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  73. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  74. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  75. package/skills/autoconverge/SKILL.md +67 -19
  76. package/skills/autoconverge/reference/closing-report.md +59 -17
  77. package/skills/autoconverge/reference/convergence.md +7 -3
  78. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  79. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  80. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  81. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  82. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  83. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  84. package/skills/autoconverge/workflow/converge.mjs +234 -42
  85. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  86. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  87. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  88. package/skills/autoconverge/workflow/render_report.py +488 -397
  89. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  90. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  91. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  92. package/skills/pr-converge/reference/per-tick.md +28 -8
  93. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  94. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  95. package/skills/rebase/SKILL.md +2 -4
  96. package/skills/update/SKILL.md +37 -5
  97. package/system-prompts/software-engineer.xml +2 -6
  98. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  99. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  100. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  101. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  102. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  103. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  104. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  105. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  106. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
package/bin/install.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync, unlinkSync, rmSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
4
4
  import { join, dirname, resolve, relative } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { execSync, execFileSync } from 'node:child_process';
@@ -188,14 +188,53 @@ export function pythonCandidatesForPlatform(platform) {
188
188
  return platform === 'win32' ? windowsOrder : defaultOrder;
189
189
  }
190
190
 
191
+ /**
192
+ * Reports whether a resolved interpreter path belongs to the Microsoft Store
193
+ * Python, whose `python.exe` App Execution Alias reparse stub cannot be spawned
194
+ * as a hook subprocess. Both the alias under `Microsoft\WindowsApps` and the
195
+ * package executable under `Program Files\WindowsApps` sit beneath a
196
+ * `WindowsApps` directory, so the installer skips any candidate resolving there.
197
+ *
198
+ * @param {string} executablePath Absolute interpreter path from sys.executable.
199
+ * @returns {boolean} True when the path lives under a WindowsApps directory.
200
+ */
201
+ export function isWindowsStorePythonStub(executablePath) {
202
+ return /[\\/]windowsapps[\\/]/i.test(executablePath);
203
+ }
204
+
205
+ /**
206
+ * Formats an absolute interpreter path as a settings.json hook command prefix:
207
+ * forward-slash separators, double-quoted when the path contains a space so the
208
+ * harness parses the interpreter as a single argument.
209
+ *
210
+ * @param {string} executablePath Absolute interpreter path from sys.executable.
211
+ * @returns {string} The command-prefix form of the interpreter path.
212
+ */
213
+ export function interpreterCommandFromPath(executablePath) {
214
+ const forwardSlashedPath = executablePath.replace(/\\/g, '/');
215
+ return forwardSlashedPath.includes(' ') ? `"${forwardSlashedPath}"` : forwardSlashedPath;
216
+ }
217
+
218
+ /**
219
+ * Picks the interpreter command baked into every managed hook in settings.json.
220
+ * On win32 the first working candidate is resolved to its absolute
221
+ * sys.executable and that path is baked in, so a later PATH change or Microsoft
222
+ * Store update that re-points the `py`/`python` launcher cannot silently break
223
+ * the hooks; candidates resolving to the non-spawnable WindowsApps stub are
224
+ * skipped. Other platforms keep the bare command (e.g. `python3`).
225
+ *
226
+ * @returns {string|null} The interpreter command, or null when none is usable.
227
+ */
191
228
  function detectPython() {
192
229
  const candidates = pythonCandidatesForPlatform(process.platform);
193
230
  for (const { command, versionFlag } of candidates) {
194
231
  try {
195
232
  const version = execSync(`${command} ${versionFlag}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
196
- if (version.includes('Python 3.')) {
197
- return command;
198
- }
233
+ if (!version.includes('Python 3.')) continue;
234
+ if (process.platform !== 'win32') return command;
235
+ const executablePath = execSync(`${command} -c "import sys; print(sys.executable)"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
236
+ if (!executablePath || isWindowsStorePythonStub(executablePath)) continue;
237
+ return interpreterCommandFromPath(executablePath);
199
238
  } catch { /* try next */ }
200
239
  }
201
240
  return null;
@@ -501,7 +540,7 @@ function install(selectedGroups, options = {}) {
501
540
  abortWhenPackageSourceHasConflicts(PACKAGE_ROOT);
502
541
  const pythonCommand = detectPython();
503
542
  if (!pythonCommand) {
504
- console.error('ERROR: Python 3 not found. Install Python 3.8+ and ensure python3, python, or py is on PATH.');
543
+ console.error('ERROR: No usable Python 3 found. Install Python 3.8+ from python.org and ensure py, python3, or python is on PATH. On Windows the Microsoft Store python.exe alias is rejected because it cannot run hooks.');
505
544
  process.exit(1);
506
545
  }
507
546
  console.log(` Python: ${pythonCommand}`);
@@ -815,28 +854,62 @@ writes the previous contents to ~/.claude/backups/CLAUDE.md.<timestamp>.bak firs
815
854
  `);
816
855
  }
817
856
 
818
- const rawArgs = process.argv.slice(2);
819
- const args = rawArgs.filter((flag) => flag !== '--update');
820
- const isUpdateRefresh = rawArgs.includes('--update');
821
- if (args.includes('--help') || args.includes('-h')) {
822
- printHelp();
823
- } else if (args.includes('--uninstall')) {
824
- uninstall();
825
- } else {
826
- const onlyIndex = args.indexOf('--only');
827
- let selectedGroups = null;
828
- if (onlyIndex !== -1) {
829
- const onlyValue = args[onlyIndex + 1];
830
- if (!onlyValue || onlyValue.startsWith('--')) {
831
- console.error(`ERROR: --only requires a comma-separated list of groups.\nAvailable groups: ${Object.keys(INSTALL_GROUPS).join(', ')}`);
832
- process.exit(1);
833
- }
834
- selectedGroups = onlyValue.split(',').map(name => name.trim());
835
- const invalidGroups = selectedGroups.filter(name => !INSTALL_GROUPS[name]);
836
- if (invalidGroups.length > 0) {
837
- console.error(`ERROR: Unknown group(s): ${invalidGroups.join(', ')}\nAvailable groups: ${Object.keys(INSTALL_GROUPS).join(', ')}`);
838
- process.exit(1);
857
+ /**
858
+ * Reports whether this module is the process entry point (run as
859
+ * `node install.mjs`, or through a bin symlink such as the npm-installed
860
+ * `claude-dev-env` launcher) rather than imported by another module such as the
861
+ * test suite. The install/uninstall dispatch runs only when true, so importing
862
+ * the module carries no side effects.
863
+ *
864
+ * Both sides resolve to their real on-disk paths before comparison, so a
865
+ * symlinked launcher whose target is this module still counts as the entry
866
+ * point even though `process.argv[1]` keeps the symlink path while
867
+ * `import.meta.url` reports the resolved target. When either path cannot be
868
+ * resolved on disk (for example a synthetic path in a unit test), the raw
869
+ * paths are compared instead.
870
+ *
871
+ * @param {string} moduleUrl The module's import.meta.url.
872
+ * @param {string|undefined} entryScriptPath The invoked script path (process.argv[1]).
873
+ * @returns {boolean} True when the module is the process entry point.
874
+ */
875
+ export function invokedAsEntryPoint(moduleUrl, entryScriptPath) {
876
+ if (!entryScriptPath) return false;
877
+ const modulePath = fileURLToPath(moduleUrl);
878
+ return realPathOrSelf(modulePath) === realPathOrSelf(entryScriptPath);
879
+ }
880
+
881
+ function realPathOrSelf(filesystemPath) {
882
+ try {
883
+ return realpathSync(filesystemPath);
884
+ } catch {
885
+ return filesystemPath;
886
+ }
887
+ }
888
+
889
+ if (invokedAsEntryPoint(import.meta.url, process.argv[1])) {
890
+ const rawArgs = process.argv.slice(2);
891
+ const args = rawArgs.filter((flag) => flag !== '--update');
892
+ const isUpdateRefresh = rawArgs.includes('--update');
893
+ if (args.includes('--help') || args.includes('-h')) {
894
+ printHelp();
895
+ } else if (args.includes('--uninstall')) {
896
+ uninstall();
897
+ } else {
898
+ const onlyIndex = args.indexOf('--only');
899
+ let selectedGroups = null;
900
+ if (onlyIndex !== -1) {
901
+ const onlyValue = args[onlyIndex + 1];
902
+ if (!onlyValue || onlyValue.startsWith('--')) {
903
+ console.error(`ERROR: --only requires a comma-separated list of groups.\nAvailable groups: ${Object.keys(INSTALL_GROUPS).join(', ')}`);
904
+ process.exit(1);
905
+ }
906
+ selectedGroups = onlyValue.split(',').map(name => name.trim());
907
+ const invalidGroups = selectedGroups.filter(name => !INSTALL_GROUPS[name]);
908
+ if (invalidGroups.length > 0) {
909
+ console.error(`ERROR: Unknown group(s): ${invalidGroups.join(', ')}\nAvailable groups: ${Object.keys(INSTALL_GROUPS).join(', ')}`);
910
+ process.exit(1);
911
+ }
839
912
  }
913
+ install(selectedGroups, { isUpdateRefresh });
840
914
  }
841
- install(selectedGroups, { isUpdateRefresh });
842
915
  }
@@ -1,14 +1,18 @@
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 } from 'node:fs';
4
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
7
8
 
8
9
  import {
9
10
  collectPackageSourceConflicts,
10
11
  CONTENT_DIRECTORIES,
11
12
  pythonCandidatesForPlatform,
13
+ isWindowsStorePythonStub,
14
+ interpreterCommandFromPath,
15
+ invokedAsEntryPoint,
12
16
  managedHookScriptRelativePaths,
13
17
  managedHookScriptRelativePathsFromSourceRoots,
14
18
  commandReferencesManagedHook,
@@ -202,6 +206,134 @@ test('pythonCandidatesForPlatform still offers python as a win32 fallback when p
202
206
  });
203
207
 
204
208
 
209
+ test('isWindowsStorePythonStub flags the Microsoft Store WindowsApps alias paths', () => {
210
+ assert.equal(
211
+ isWindowsStorePythonStub('C:\\Program Files\\WindowsApps\\PythonSoftwareFoundation.Python.3.13_3.13.3824.0_x64__qbz5n2kfra8p0\\python3.13.exe'),
212
+ true,
213
+ );
214
+ assert.equal(
215
+ isWindowsStorePythonStub('C:/Users/jon/AppData/Local/Microsoft/WindowsApps/python.exe'),
216
+ true,
217
+ );
218
+ });
219
+
220
+
221
+ test('isWindowsStorePythonStub does not flag a real interpreter install path', () => {
222
+ assert.equal(isWindowsStorePythonStub('C:\\Python313\\python.exe'), false);
223
+ assert.equal(isWindowsStorePythonStub('/usr/bin/python3'), false);
224
+ });
225
+
226
+
227
+ test('interpreterCommandFromPath forward-slashes a Windows interpreter path and leaves a space-free path unquoted', () => {
228
+ assert.equal(interpreterCommandFromPath('C:\\Python313\\python.exe'), 'C:/Python313/python.exe');
229
+ });
230
+
231
+
232
+ test('interpreterCommandFromPath quotes an interpreter path that contains a space', () => {
233
+ assert.equal(
234
+ interpreterCommandFromPath('C:\\Program Files\\Python313\\python.exe'),
235
+ '"C:/Program Files/Python313/python.exe"',
236
+ );
237
+ });
238
+
239
+
240
+ test('mergeHooksIntoSettings substitutes a quoted absolute interpreter path for the python3 prefix', () => {
241
+ const hooksConfig = {
242
+ hooks: {
243
+ PostToolUse: [
244
+ {
245
+ matcher: 'Edit',
246
+ hooks: [{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/workflow/auto_formatter.py' }],
247
+ },
248
+ ],
249
+ },
250
+ };
251
+ const settings = {};
252
+ mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/x/.claude', '"C:/Program Files/Python313/python.exe"');
253
+ assert.equal(
254
+ settings.hooks.PostToolUse[0].hooks[0].command,
255
+ '"C:/Program Files/Python313/python.exe" C:/Users/x/.claude/hooks/workflow/auto_formatter.py',
256
+ );
257
+ });
258
+
259
+
260
+ test('mergeHooksIntoSettings prunes a prior py -3 managed hook when reinstalling with an absolute interpreter path', () => {
261
+ const hooksConfig = {
262
+ hooks: {
263
+ PostToolUse: [
264
+ {
265
+ matcher: 'Edit',
266
+ hooks: [{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/workflow/auto_formatter.py' }],
267
+ },
268
+ ],
269
+ },
270
+ };
271
+ const settings = {
272
+ hooks: {
273
+ PostToolUse: [
274
+ {
275
+ matcher: 'Edit',
276
+ hooks: [{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/workflow/auto_formatter.py' }],
277
+ },
278
+ ],
279
+ },
280
+ };
281
+ mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/x/.claude', 'C:/Python313/python.exe');
282
+ assert.deepEqual(
283
+ settings.hooks.PostToolUse[0].hooks.map(hook => hook.command),
284
+ ['C:/Python313/python.exe C:/Users/x/.claude/hooks/workflow/auto_formatter.py'],
285
+ );
286
+ });
287
+
288
+
289
+ test('invokedAsEntryPoint is true when the module url matches the invoked script path', () => {
290
+ const scriptPath = process.platform === 'win32' ? 'C:\\pkg\\bin\\install.mjs' : '/pkg/bin/install.mjs';
291
+ assert.equal(invokedAsEntryPoint(pathToFileURL(scriptPath).href, scriptPath), true);
292
+ });
293
+
294
+
295
+ test('invokedAsEntryPoint is false when the module is imported by another script', () => {
296
+ const modulePath = process.platform === 'win32' ? 'C:\\pkg\\bin\\install.mjs' : '/pkg/bin/install.mjs';
297
+ const entryScriptPath = process.platform === 'win32' ? 'C:\\pkg\\bin\\install.test.mjs' : '/pkg/bin/install.test.mjs';
298
+ assert.equal(invokedAsEntryPoint(pathToFileURL(modulePath).href, entryScriptPath), false);
299
+ });
300
+
301
+
302
+ test('invokedAsEntryPoint is false when there is no invoked script path', () => {
303
+ assert.equal(invokedAsEntryPoint('file:///pkg/bin/install.mjs', undefined), false);
304
+ });
305
+
306
+
307
+ test('invokedAsEntryPoint is true when the module is reached through a bin symlink', () => {
308
+ const linkRoot = mkdtempSync(join(tmpdir(), 'cdev-bin-symlink-'));
309
+ try {
310
+ const realModulePath = join(linkRoot, 'install.mjs');
311
+ const symlinkLauncherPath = join(linkRoot, 'claude-dev-env');
312
+ writeFileSync(realModulePath, 'export const sentinel = true;\n');
313
+ symlinkSync(realModulePath, symlinkLauncherPath);
314
+ const realModuleUrl = pathToFileURL(realModulePath).href;
315
+ assert.equal(invokedAsEntryPoint(realModuleUrl, symlinkLauncherPath), true);
316
+ } finally {
317
+ rmSync(linkRoot, { recursive: true, force: true });
318
+ }
319
+ });
320
+
321
+
322
+ test('invokedAsEntryPoint is false when a sibling script imports the real module', () => {
323
+ const importerRoot = mkdtempSync(join(tmpdir(), 'cdev-bin-importer-'));
324
+ try {
325
+ const realModulePath = join(importerRoot, 'install.mjs');
326
+ const importerScriptPath = join(importerRoot, 'install.test.mjs');
327
+ writeFileSync(realModulePath, 'export const sentinel = true;\n');
328
+ writeFileSync(importerScriptPath, 'import "./install.mjs";\n');
329
+ const realModuleUrl = pathToFileURL(realModulePath).href;
330
+ assert.equal(invokedAsEntryPoint(realModuleUrl, importerScriptPath), false);
331
+ } finally {
332
+ rmSync(importerRoot, { recursive: true, force: true });
333
+ }
334
+ });
335
+
336
+
205
337
  const SAMPLE_HOOKS_CONFIG = {
206
338
  hooks: {
207
339
  Stop: [
@@ -23,9 +23,9 @@ 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
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`, …)
27
27
 
28
- Test files are exempt from most checks. See also the file-global constants use-count rule: [`rules/file-global-constants.md`](../rules/file-global-constants.md).
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. Ordinary test parameters stay exempt. See also the file-global constants use-count rule: [`rules/file-global-constants.md`](../rules/file-global-constants.md).
29
29
 
30
30
  ---
31
31
 
@@ -94,4 +94,4 @@ If you already have the data, don't fetch it again.
94
94
 
95
95
  ## 11. ENFORCEMENT SURFACES
96
96
 
97
- ⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–P) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md` and `skills/code/SKILL.md`; each names its own promotion path.
97
+ ⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–P) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md` and `skills/code/SKILL.md`; each names its own promotion path. The docstring-prose standard (free-form enumerations match the body) lives in `packages/claude-dev-env/rules/docstring-prose-matches-implementation.md`, enforced via Category O6 audit.
@@ -13,6 +13,7 @@ if _hooks_directory not in sys.path:
13
13
 
14
14
  from code_rules_shared import ( # noqa: E402
15
15
  _collect_annotated_arguments,
16
+ _collect_fixture_injection_arguments,
16
17
  _definition_docstring_line_span,
17
18
  _function_definition_line_span,
18
19
  _scope_violations_to_changed_lines,
@@ -24,8 +25,10 @@ from code_rules_shared import ( # noqa: E402
24
25
 
25
26
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
26
27
  ALL_SELF_AND_CLS_PARAMETER_NAMES,
28
+ ANNOTATION_BY_PYTEST_FIXTURE,
27
29
  FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX,
28
30
  FUNCTION_LENGTH_BLOCKING_THRESHOLD,
31
+ KNOWN_PYTEST_FIXTURE_ANNOTATION_MESSAGE_SUFFIX,
29
32
  )
30
33
 
31
34
 
@@ -52,6 +55,156 @@ def check_parameter_annotations(content: str, file_path: str) -> list[str]:
52
55
  return issues
53
56
 
54
57
 
58
+ def _is_pytest_fixture_injection_site(
59
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
60
+ ) -> bool:
61
+ """Return True when a function node is a valid pytest fixture injection site.
62
+
63
+ A function qualifies as a fixture injection site when either its name begins
64
+ with the ``test`` prefix (matching pytest's default ``python_functions = test*``
65
+ collection rule) or it carries a ``@pytest.fixture`` / ``@fixture`` decorator,
66
+ with or without call arguments. Ordinary helper functions that happen to share
67
+ a parameter name with a known pytest fixture are excluded by this predicate so
68
+ that ``check_known_pytest_fixture_annotations`` only enforces annotation
69
+ requirements on the functions where pytest actually performs fixture injection.
70
+
71
+ Args:
72
+ node: The function definition AST node to inspect.
73
+
74
+ Returns:
75
+ True when the node is a pytest test function or a fixture-decorated
76
+ function; False otherwise.
77
+ """
78
+ if node.name.startswith("test"):
79
+ return True
80
+ for each_decorator in node.decorator_list:
81
+ unwrapped = each_decorator.func if isinstance(each_decorator, ast.Call) else each_decorator
82
+ if isinstance(unwrapped, ast.Name) and unwrapped.id == "fixture":
83
+ return True
84
+ if isinstance(unwrapped, ast.Attribute) and unwrapped.attr == "fixture":
85
+ return True
86
+ return False
87
+
88
+
89
+ def _normalize_fixture_annotation_text(annotation_text: str) -> str:
90
+ """Strip forward-reference string quoting from an unparsed annotation.
91
+
92
+ ``ast.unparse`` renders a forward-reference annotation such as
93
+ ``tmp_path: "Path"`` as the quoted literal ``'Path'``. Removing the
94
+ surrounding quotes recovers the bare type name so the quoted spelling
95
+ compares equal to its unquoted form.
96
+
97
+ Args:
98
+ annotation_text: The annotation as rendered by ``ast.unparse``.
99
+
100
+ Returns:
101
+ The annotation text with any single surrounding quote pair removed.
102
+ """
103
+ if len(annotation_text) >= 2 and annotation_text[0] in {'"', "'"}:
104
+ if annotation_text[-1] == annotation_text[0]:
105
+ return annotation_text[1:-1]
106
+ return annotation_text
107
+
108
+
109
+ def _fixture_annotation_matches_expected(
110
+ actual_annotation: str, expected_annotation: str
111
+ ) -> bool:
112
+ """Return True when an annotation matches its fixture's documented type.
113
+
114
+ The match accepts every equally-correct spelling of the documented type:
115
+ the exact text, a forward-reference string form, and either the bare
116
+ attribute tail or the fully-qualified dotted form. Both ``tmp_path: Path``
117
+ and ``tmp_path: pathlib.Path`` satisfy an expected ``Path``, and both
118
+ ``monkeypatch: pytest.MonkeyPatch`` and ``monkeypatch: MonkeyPatch``
119
+ satisfy an expected ``pytest.MonkeyPatch``.
120
+
121
+ Args:
122
+ actual_annotation: The annotation as rendered by ``ast.unparse``.
123
+ expected_annotation: The fixture's single documented type spelling.
124
+
125
+ Returns:
126
+ True when the actual annotation is an accepted spelling of the
127
+ expected type; False otherwise.
128
+ """
129
+ normalized_actual = _normalize_fixture_annotation_text(actual_annotation)
130
+ if normalized_actual == expected_annotation:
131
+ return True
132
+ return normalized_actual.rsplit(".", 1)[-1] == expected_annotation.rsplit(
133
+ ".", 1
134
+ )[-1]
135
+
136
+
137
+ def check_known_pytest_fixture_annotations(content: str, file_path: str) -> list[str]:
138
+ """Flag well-known pytest fixture parameters lacking their type annotation.
139
+
140
+ The broad parameter-annotation rule exempts test files, so an ordinary
141
+ test parameter never needs a type hint. This narrower check restores
142
+ enforcement for exactly the pytest builtin fixtures whose injected type is
143
+ fixed and documented — ``tmp_path: Path``, ``monkeypatch:
144
+ pytest.MonkeyPatch``, and the rest of
145
+ ``ANNOTATION_BY_PYTEST_FIXTURE``. For these names the
146
+ correct annotation is unambiguous, so requiring it costs the author one
147
+ token and removes a recurring class of reviewer noise on test fixtures.
148
+ A non-test file produces no findings here: the broad check already covers
149
+ every parameter outside test files.
150
+
151
+ A known fixture parameter is flagged both when it carries no annotation and
152
+ when its annotation source differs from the fixture's single documented
153
+ type, so ``tmp_path: str`` is flagged exactly like ``tmp_path``. Only the
154
+ named injection slots pytest actually fills — undefaulted
155
+ positional-or-keyword and keyword-only parameters — are inspected. A
156
+ positional-only parameter is skipped because pytest passes fixtures by
157
+ keyword and can never bind one positionally; a defaulted parameter is
158
+ skipped because pytest leaves its default in place rather than injecting a
159
+ fixture; and a ``*args`` or ``**kwargs`` parameter that happens to share a
160
+ fixture name is never a fixture injection.
161
+
162
+ Args:
163
+ content: The Python source to analyze.
164
+ file_path: The path of the file being checked.
165
+
166
+ Returns:
167
+ One blocking issue per known fixture injection parameter whose
168
+ annotation is missing or differs from its single documented type,
169
+ naming the parameter and its expected type.
170
+ """
171
+ if not is_test_file(file_path):
172
+ return []
173
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
174
+ return []
175
+ try:
176
+ tree = ast.parse(content)
177
+ except SyntaxError:
178
+ return []
179
+ issues: list[str] = []
180
+ for each_node in ast.walk(tree):
181
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
182
+ continue
183
+ if not _is_pytest_fixture_injection_site(each_node):
184
+ continue
185
+ for each_arg in _collect_fixture_injection_arguments(each_node):
186
+ expected_annotation = ANNOTATION_BY_PYTEST_FIXTURE.get(
187
+ each_arg.arg
188
+ )
189
+ if expected_annotation is None:
190
+ continue
191
+ actual_annotation = (
192
+ ast.unparse(each_arg.annotation)
193
+ if each_arg.annotation is not None
194
+ else None
195
+ )
196
+ if actual_annotation is not None and _fixture_annotation_matches_expected(
197
+ actual_annotation, expected_annotation
198
+ ):
199
+ continue
200
+ issues.append(
201
+ f"Line {each_arg.lineno}: parameter {each_arg.arg!r} on "
202
+ f"{each_node.name!r} - {KNOWN_PYTEST_FIXTURE_ANNOTATION_MESSAGE_SUFFIX} "
203
+ f"(annotate as {expected_annotation!r})"
204
+ )
205
+ return issues
206
+
207
+
55
208
  def check_return_annotations(content: str, file_path: str) -> list[str]:
56
209
  if is_test_file(file_path):
57
210
  return []