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.
- package/CLAUDE.md +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
- package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/bin/install.mjs +100 -27
- package/bin/install.test.mjs +133 -1
- package/docs/CODE_RULES.md +3 -3
- package/hooks/blocking/code_rules_annotations_length.py +153 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +439 -0
- package/hooks/blocking/code_rules_enforcer.py +190 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
- package/hooks/blocking/config/__init__.py +5 -0
- package/hooks/blocking/config/verified_commit_constants.py +106 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
- package/hooks/blocking/subprocess_budget_completeness.py +380 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
- package/hooks/blocking/test_destructive_command_blocker.py +622 -3
- package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +278 -0
- package/hooks/blocking/test_verified_commit_gate.py +368 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +446 -0
- package/hooks/blocking/verified_commit_gate.py +523 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +299 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +58 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +43 -0
- package/rules/file-global-constants.md +7 -1
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
- package/skills/autoconverge/SKILL.md +67 -19
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +234 -42
- package/skills/autoconverge/workflow/convergence_summary.py +110 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
- package/skills/autoconverge/workflow/render_report.py +488 -397
- package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
- package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
- package/skills/autoconverge/workflow/test_render_report.py +488 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- package/skills/rebase/SKILL.md +2 -4
- package/skills/update/SKILL.md +37 -5
- package/system-prompts/software-engineer.xml +2 -6
- package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
- package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
- 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
|
-
|
|
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
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
}
|
package/bin/install.test.mjs
CHANGED
|
@@ -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: [
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -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 []
|