claude-dev-env 7.5.0 → 7.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/everything-search/scripts/everything_search.py +11 -0
- package/.agents/skills/everything-search/scripts/test_everything_search.py +1 -0
- package/.agents/skills/fresh-branch/SKILL.md +1 -0
- package/.agents/skills/fresh-branch/scripts/fresh_branch_git_commands.py +3 -0
- package/.agents/skills/fresh-branch/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/session-continuity/README.md +19 -9
- package/.agents/skills/syncing-submodules/SKILL.md +1 -0
- package/.agents/skills/syncing-submodules/scripts/submodule_sync.py +4 -0
- package/.agents/skills/syncing-submodules/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/test-runner/SKILL.md +1 -0
- package/.agents/skills/test-runner/scripts/preflight_checks.py +5 -0
- package/.agents/skills/test-runner/scripts/run_tests.py +11 -0
- package/.agents/skills/test-runner/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/test-runner/scripts/test_run_tests.py +9 -7
- package/_shared/pr-loop/scripts/check_bugbot_ci.py +3 -0
- package/_shared/pr-loop/scripts/check_convergence_gates.py +3 -0
- package/_shared/pr-loop/scripts/check_pending_reviews.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +5 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +4 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +3 -0
- package/_shared/pr-loop/scripts/codex_usage_probe.py +3 -0
- package/_shared/pr-loop/scripts/copilot_quota.py +3 -0
- package/_shared/pr-loop/scripts/fetch_copilot_reviews.py +3 -0
- package/_shared/pr-loop/scripts/fix_hookspath.py +7 -0
- package/_shared/pr-loop/scripts/post_fix_reply.py +4 -0
- package/_shared/pr-loop/scripts/preflight.py +7 -0
- package/_shared/pr-loop/scripts/preflight_self_heal.py +5 -0
- package/_shared/pr-loop/scripts/run_codex_review.py +3 -0
- package/_shared/pr-loop/scripts/subprocess_window_access.py +41 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +5 -0
- package/_shared/process-tree/scripts/process_tree_kill.py +3 -0
- package/_shared/process-tree/scripts/subprocess_window_access.py +41 -0
- package/bin/install-constants.mjs +4 -2
- package/bin/install-session-continuity.mjs +73 -30
- package/bin/install-session-continuity.test.mjs +52 -1
- package/bin/install.cursor-rules.test.mjs +57 -15
- package/bin/install.mjs +80 -15
- package/bin/install.test.mjs +57 -0
- package/bin/pstack-model-preferences.claude.json +23 -0
- package/hooks/advisory/pr_done_reminder.py +7 -0
- package/hooks/advisory/refactor_guard.py +8 -0
- package/hooks/advisory/test_pr_done_reminder.py +21 -0
- package/hooks/advisory/test_refactor_guard.py +40 -0
- package/hooks/blocking/block_main_commit.py +4 -0
- package/hooks/blocking/code_rules_comments.py +39 -5
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +3 -0
- package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +19 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +4 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +25 -0
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +27 -0
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +10 -0
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +0 -2
- package/hooks/git-hooks/pull_request_handoff.py +3 -0
- package/hooks/git-hooks/subprocess_window_access.py +41 -0
- package/hooks/git-hooks/test_verification_start.py +4 -0
- package/hooks/git-hooks/verification_notice_context.py +3 -0
- package/hooks/git-hooks/verification_start.py +7 -8
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/subprocess_window.py +141 -0
- package/hooks/hooks_constants/subprocess_window_constants.py +9 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/test_subprocess_window.py +128 -0
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +3 -0
- package/hooks/validation/mypy_validator.py +4 -0
- package/hooks/validators/README.md +1 -0
- package/hooks/validators/git_checks.py +4 -0
- package/hooks/validators/health_check.py +3 -0
- package/hooks/validators/hook_timing_harness.py +3 -0
- package/hooks/validators/mypy_integration.py +4 -0
- package/hooks/validators/ruff_integration.py +6 -0
- package/hooks/validators/run_all_validators.py +7 -0
- package/hooks/validators/subprocess_window_access.py +41 -0
- package/hooks/workflow/auto_formatter.py +8 -0
- package/hooks/workflow/test_auto_formatter.py +39 -0
- package/package.json +1 -1
- package/rules/pstack-models.md +4 -3
- package/scripts/automatic_advisory/checkout.py +8 -1
- package/scripts/automatic_advisory/cli.py +9 -3
- package/scripts/automatic_advisory/config/constants.py +0 -2
- package/scripts/automatic_advisory/execution.py +7 -1
- package/scripts/automatic_advisory/git.py +9 -1
- package/scripts/automatic_advisory/process_host.py +3 -0
- package/scripts/claude_chain_runner.py +3 -0
- package/scripts/gh_artifact_upload.py +3 -0
- package/scripts/grok_headless_runner.py +3 -0
- package/scripts/grok_patch_artifacts.py +4 -0
- package/scripts/grok_worker_preflight.py +3 -0
- package/scripts/invoke_code_review.py +3 -0
- package/scripts/local_verification/command_runner.py +3 -0
- package/scripts/local_verification/git_state.py +3 -0
- package/scripts/policy_lint/adapter_detectors.py +48 -2
- package/scripts/policy_lint/adapter_pairing.py +150 -2
- package/scripts/policy_lint/adapters.py +46 -4
- package/scripts/policy_lint/config/approved_test_pairs.py +25 -9
- package/scripts/policy_lint/config/constants.py +3 -0
- package/scripts/policy_lint/registry.py +1 -1
- package/scripts/policy_lint/selection_git.py +11 -0
- package/scripts/repository_checks/config/constants.py +5 -0
- package/scripts/repository_checks/config/policy_document.py +19 -0
- package/scripts/repository_checks/email_exemptions.py +14 -71
- package/scripts/repository_checks/path_exemptions.py +35 -0
- package/scripts/repository_checks/policy_document.py +120 -0
- package/scripts/repository_checks/tracked_secrets.py +27 -4
- package/scripts/setup_project_paths.py +3 -0
- package/scripts/spawn_grok_batch.py +3 -0
- package/scripts/subprocess_window_access.py +41 -0
- package/scripts/test_dispatcher_profile_import.py +4 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +17 -11
- package/scripts/tests/test_email_exemptions.py +37 -0
- package/scripts/tests/test_path_exemptions.py +164 -0
- package/scripts/tests/test_policy_document.py +104 -0
- package/scripts/tests/test_policy_lint_rules_function_length_scope.py +101 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +375 -0
- package/scripts/tests/test_policy_lint_rules_registry.py +116 -0
- package/scripts/tests/test_policy_lint_selection.py +34 -0
- package/scripts/tests/test_subprocess_window_access.py +118 -0
- package/scripts/tests/test_tracked_secrets.py +79 -0
- package/scripts/validate_instruction_pairs.py +3 -0
- package/scripts/verify_installable_package.py +5 -0
- package/scripts/automatic_advisory/window_flags.py +0 -41
- package/scripts/repository_checks/config/email_exemptions.py +0 -10
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
|
+
from collections import Counter
|
|
4
5
|
from pathlib import PurePosixPath
|
|
5
6
|
|
|
6
7
|
from . import adapter_support
|
|
@@ -52,12 +53,19 @@ def _remove_docstrings(parsed_tree: ast.AST) -> None:
|
|
|
52
53
|
_remove_docstrings(each_child)
|
|
53
54
|
|
|
54
55
|
|
|
55
|
-
def
|
|
56
|
+
def _parsed_without_docstrings(source_text: str) -> ast.Module | None:
|
|
56
57
|
try:
|
|
57
58
|
parsed_tree = ast.parse(source_text)
|
|
58
|
-
except SyntaxError:
|
|
59
|
+
except (SyntaxError, ValueError):
|
|
59
60
|
return None
|
|
60
61
|
_remove_docstrings(parsed_tree)
|
|
62
|
+
return parsed_tree
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ast_without_docstrings(source_text: str) -> str | None:
|
|
66
|
+
parsed_tree = _parsed_without_docstrings(source_text)
|
|
67
|
+
if parsed_tree is None:
|
|
68
|
+
return None
|
|
61
69
|
return ast.dump(parsed_tree, include_attributes=False)
|
|
62
70
|
|
|
63
71
|
|
|
@@ -71,6 +79,144 @@ def _is_docstring_only_python_document(document: Document) -> bool:
|
|
|
71
79
|
return current_ast is not None and current_ast == prior_ast
|
|
72
80
|
|
|
73
81
|
|
|
82
|
+
def _is_import_statement(node: object) -> bool:
|
|
83
|
+
return isinstance(node, (ast.Import, ast.ImportFrom))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _dumped_import_tally(all_nodes: list[object]) -> Counter[str]:
|
|
87
|
+
return Counter(
|
|
88
|
+
ast.dump(each_node, include_attributes=False)
|
|
89
|
+
for each_node in all_nodes
|
|
90
|
+
if isinstance(each_node, ast.AST) and _is_import_statement(each_node)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _matches_positional_list(
|
|
95
|
+
all_current_nodes: list[object], all_prior_nodes: list[object]
|
|
96
|
+
) -> bool:
|
|
97
|
+
if len(all_current_nodes) != len(all_prior_nodes):
|
|
98
|
+
return False
|
|
99
|
+
return all(
|
|
100
|
+
_matches_field(each_current_node, each_prior_node)
|
|
101
|
+
for each_current_node, each_prior_node in zip(
|
|
102
|
+
all_current_nodes, all_prior_nodes, strict=True
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _matches_statement_list(
|
|
108
|
+
all_current_nodes: list[object], all_prior_nodes: list[object]
|
|
109
|
+
) -> bool:
|
|
110
|
+
if _dumped_import_tally(all_prior_nodes) - _dumped_import_tally(all_current_nodes):
|
|
111
|
+
return False
|
|
112
|
+
return _matches_positional_list(
|
|
113
|
+
[
|
|
114
|
+
each_node
|
|
115
|
+
for each_node in all_current_nodes
|
|
116
|
+
if not _is_import_statement(each_node)
|
|
117
|
+
],
|
|
118
|
+
[
|
|
119
|
+
each_node
|
|
120
|
+
for each_node in all_prior_nodes
|
|
121
|
+
if not _is_import_statement(each_node)
|
|
122
|
+
],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_statement_list(all_nodes: list[object]) -> bool:
|
|
127
|
+
return all(isinstance(each_node, ast.stmt) for each_node in all_nodes)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _matches_node_list(
|
|
131
|
+
all_current_nodes: list[object], all_prior_nodes: list[object]
|
|
132
|
+
) -> bool:
|
|
133
|
+
if _is_statement_list(all_current_nodes) and _is_statement_list(all_prior_nodes):
|
|
134
|
+
return _matches_statement_list(all_current_nodes, all_prior_nodes)
|
|
135
|
+
return _matches_positional_list(all_current_nodes, all_prior_nodes)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _matches_field(current_field: object, prior_field: object) -> bool:
|
|
139
|
+
if isinstance(current_field, ast.AST) and isinstance(prior_field, ast.AST):
|
|
140
|
+
return _matches_node(current_field, prior_field)
|
|
141
|
+
if isinstance(current_field, list) and isinstance(prior_field, list):
|
|
142
|
+
return _matches_node_list(current_field, prior_field)
|
|
143
|
+
if type(current_field) is not type(prior_field):
|
|
144
|
+
return False
|
|
145
|
+
return bool(current_field == prior_field)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _matches_keyword(current_keyword: ast.keyword, prior_keyword: ast.keyword) -> bool:
|
|
149
|
+
return current_keyword.arg == prior_keyword.arg and _matches_node(
|
|
150
|
+
current_keyword.value, prior_keyword.value
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _keywords_after_match(
|
|
155
|
+
all_candidate_keywords: list[ast.keyword], prior_keyword: ast.keyword
|
|
156
|
+
) -> list[ast.keyword] | None:
|
|
157
|
+
for each_index, each_candidate_keyword in enumerate(all_candidate_keywords):
|
|
158
|
+
if _matches_keyword(each_candidate_keyword, prior_keyword):
|
|
159
|
+
return all_candidate_keywords[each_index + 1 :]
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _prior_keywords_survive(
|
|
164
|
+
all_current_keywords: list[ast.keyword], all_prior_keywords: list[ast.keyword]
|
|
165
|
+
) -> bool:
|
|
166
|
+
all_remaining_keywords: list[ast.keyword] | None = list(all_current_keywords)
|
|
167
|
+
for each_prior_keyword in all_prior_keywords:
|
|
168
|
+
if all_remaining_keywords is None:
|
|
169
|
+
return False
|
|
170
|
+
all_remaining_keywords = _keywords_after_match(
|
|
171
|
+
all_remaining_keywords, each_prior_keyword
|
|
172
|
+
)
|
|
173
|
+
return all_remaining_keywords is not None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _mapping_unpacking_count(all_keywords: list[ast.keyword]) -> int:
|
|
177
|
+
return sum(1 for each_keyword in all_keywords if each_keyword.arg is None)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _matches_call(current_call: ast.Call, prior_call: ast.Call) -> bool:
|
|
181
|
+
if not _matches_node(current_call.func, prior_call.func):
|
|
182
|
+
return False
|
|
183
|
+
if not _matches_positional_list(list(current_call.args), list(prior_call.args)):
|
|
184
|
+
return False
|
|
185
|
+
if _mapping_unpacking_count(current_call.keywords) != _mapping_unpacking_count(
|
|
186
|
+
prior_call.keywords
|
|
187
|
+
):
|
|
188
|
+
return False
|
|
189
|
+
return _prior_keywords_survive(
|
|
190
|
+
list(current_call.keywords), list(prior_call.keywords)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _matches_node(current_node: ast.AST, prior_node: ast.AST) -> bool:
|
|
195
|
+
if type(current_node) is not type(prior_node):
|
|
196
|
+
return False
|
|
197
|
+
if isinstance(current_node, ast.Call) and isinstance(prior_node, ast.Call):
|
|
198
|
+
return _matches_call(current_node, prior_node)
|
|
199
|
+
return all(
|
|
200
|
+
_matches_field(
|
|
201
|
+
getattr(current_node, each_field_name, None),
|
|
202
|
+
getattr(prior_node, each_field_name, None),
|
|
203
|
+
)
|
|
204
|
+
for each_field_name in current_node._fields
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _is_import_and_keyword_addition_python_document(document: Document) -> bool:
|
|
209
|
+
if document.path.suffix.lower() != constants.PYTHON_SUFFIX:
|
|
210
|
+
return False
|
|
211
|
+
if document.prior_text is None:
|
|
212
|
+
return False
|
|
213
|
+
current_tree = _parsed_without_docstrings(document.text)
|
|
214
|
+
prior_tree = _parsed_without_docstrings(document.prior_text)
|
|
215
|
+
if current_tree is None or prior_tree is None:
|
|
216
|
+
return False
|
|
217
|
+
return _matches_node(current_tree, prior_tree)
|
|
218
|
+
|
|
219
|
+
|
|
74
220
|
def _candidate_test_names(path: PurePosixPath) -> frozenset[str]:
|
|
75
221
|
stem = path.stem
|
|
76
222
|
suffix = path.suffix.lower()
|
|
@@ -157,6 +303,8 @@ def _is_unpaired_production(
|
|
|
157
303
|
return False
|
|
158
304
|
if _is_docstring_only_python_document(production_document):
|
|
159
305
|
return False
|
|
306
|
+
if _is_import_and_keyword_addition_python_document(production_document):
|
|
307
|
+
return False
|
|
160
308
|
return not _has_changed_test(production_path, all_changed_test_paths)
|
|
161
309
|
|
|
162
310
|
|
|
@@ -33,9 +33,13 @@ def _hooks_module(module_name: str) -> ModuleType:
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def _pr_loop_script_module(module_name: str) -> ModuleType:
|
|
36
|
+
marker_relative_path = (
|
|
37
|
+
module_name.replace(".", constants.POSIX_PATH_SEPARATOR)
|
|
38
|
+
+ constants.PYTHON_SUFFIX
|
|
39
|
+
)
|
|
36
40
|
scripts_directory = str(resolve_shared_scripts_directory(
|
|
37
41
|
__file__, os.environ, constants.PR_LOOP_DIRECTORY_NAME,
|
|
38
|
-
|
|
42
|
+
marker_relative_path, constants.SHARED_ROOT_PARENT_INDEX
|
|
39
43
|
))
|
|
40
44
|
if scripts_directory not in sys.path:
|
|
41
45
|
sys.path.insert(0, scripts_directory)
|
|
@@ -72,7 +76,7 @@ def code_rule_diagnostics(
|
|
|
72
76
|
Diagnostics from the code-rule engine.
|
|
73
77
|
"""
|
|
74
78
|
return adapter_detectors.code_rule_diagnostics(
|
|
75
|
-
document, repository_root, _hooks_module
|
|
79
|
+
document, repository_root, _hooks_module, _pr_loop_script_module
|
|
76
80
|
)
|
|
77
81
|
|
|
78
82
|
|
|
@@ -276,6 +280,25 @@ def accepts_python(document: Document) -> bool:
|
|
|
276
280
|
return document.path.suffix.lower() == constants.PYTHON_SUFFIX
|
|
277
281
|
|
|
278
282
|
|
|
283
|
+
def accepts_production_python(document: Document) -> bool:
|
|
284
|
+
"""Return whether the document is Python outside the test tree.
|
|
285
|
+
|
|
286
|
+
The hook lane exempts a test module so a test can stage an undercounting
|
|
287
|
+
fixture freely. This gate reuses the hook lane's own ``is_test_file``
|
|
288
|
+
predicate, so the two lanes cannot drift on what counts as a test file.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
document: Candidate document.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
True for a Python document whose path is not a test path.
|
|
295
|
+
"""
|
|
296
|
+
if not accepts_python(document):
|
|
297
|
+
return False
|
|
298
|
+
shared_module = _hooks_module("blocking.code_rules_shared")
|
|
299
|
+
return not shared_module.is_test_file(f"/{document.path.as_posix()}")
|
|
300
|
+
|
|
301
|
+
|
|
279
302
|
def accepts_code(document: Document) -> bool:
|
|
280
303
|
"""Return whether the document contains supported source code.
|
|
281
304
|
|
|
@@ -292,6 +315,21 @@ def accepts_markdown(document: Document) -> bool:
|
|
|
292
315
|
return document.path.suffix.lower() in constants.ALL_MARKDOWN_SUFFIXES
|
|
293
316
|
|
|
294
317
|
|
|
318
|
+
def _is_generated_document(document: Document) -> bool:
|
|
319
|
+
"""Return whether a release tool writes the document from commit history.
|
|
320
|
+
|
|
321
|
+
A changelog is rebuilt from merged commit subjects, so its wording belongs
|
|
322
|
+
to the commits rather than to an author. Prose rules skip it.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
document: Candidate document.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
True for a generated document name.
|
|
329
|
+
"""
|
|
330
|
+
return document.path.name.lower() in constants.ALL_GENERATED_DOCUMENT_NAMES
|
|
331
|
+
|
|
332
|
+
|
|
295
333
|
def accepts_source_or_markdown(document: Document) -> bool:
|
|
296
334
|
"""Return whether a state-description rule can inspect the document.
|
|
297
335
|
|
|
@@ -299,8 +337,10 @@ def accepts_source_or_markdown(document: Document) -> bool:
|
|
|
299
337
|
document: Candidate document.
|
|
300
338
|
|
|
301
339
|
Returns:
|
|
302
|
-
True for
|
|
340
|
+
True for authored source or Markdown, and False for a generated document.
|
|
303
341
|
"""
|
|
342
|
+
if _is_generated_document(document):
|
|
343
|
+
return False
|
|
304
344
|
return accepts_code(document) or accepts_markdown(document)
|
|
305
345
|
|
|
306
346
|
|
|
@@ -311,8 +351,10 @@ def accepts_stored_prompt(document: Document) -> bool:
|
|
|
311
351
|
document: Candidate document.
|
|
312
352
|
|
|
313
353
|
Returns:
|
|
314
|
-
True for Markdown in an instruction directory.
|
|
354
|
+
True for authored Markdown in an instruction directory.
|
|
315
355
|
"""
|
|
356
|
+
if _is_generated_document(document):
|
|
357
|
+
return False
|
|
316
358
|
normalized_path = f"/{document.path.as_posix().lower()}"
|
|
317
359
|
return accepts_markdown(document) and any(
|
|
318
360
|
each_segment in normalized_path
|
|
@@ -5,6 +5,31 @@ from pathlib import PurePosixPath
|
|
|
5
5
|
APPROVED_TEST_PATHS_BY_PRODUCTION_PATH: dict[
|
|
6
6
|
PurePosixPath, frozenset[PurePosixPath]
|
|
7
7
|
] = {
|
|
8
|
+
PurePosixPath("packages/claude-dev-env/bin/install-constants.mjs"): frozenset(
|
|
9
|
+
{
|
|
10
|
+
PurePosixPath("packages/claude-dev-env/bin/install.cursor-rules.test.mjs"),
|
|
11
|
+
}
|
|
12
|
+
),
|
|
13
|
+
PurePosixPath("packages/claude-dev-env/bin/install.mjs"): frozenset(
|
|
14
|
+
{
|
|
15
|
+
PurePosixPath("packages/claude-dev-env/bin/install.cursor-rules.test.mjs"),
|
|
16
|
+
}
|
|
17
|
+
),
|
|
18
|
+
PurePosixPath(
|
|
19
|
+
"packages/claude-dev-env/hooks/blocking/code_rules_comments.py"
|
|
20
|
+
): frozenset(
|
|
21
|
+
{
|
|
22
|
+
PurePosixPath(
|
|
23
|
+
"packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py"
|
|
24
|
+
),
|
|
25
|
+
PurePosixPath(
|
|
26
|
+
"packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py"
|
|
27
|
+
),
|
|
28
|
+
PurePosixPath(
|
|
29
|
+
"packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_javascript_comments.py"
|
|
30
|
+
),
|
|
31
|
+
}
|
|
32
|
+
),
|
|
8
33
|
PurePosixPath(
|
|
9
34
|
"packages/claude-dev-env/hooks/git-hooks/verification_notice_context.py"
|
|
10
35
|
): frozenset(
|
|
@@ -152,15 +177,6 @@ APPROVED_TEST_PATHS_BY_PRODUCTION_PATH: dict[
|
|
|
152
177
|
),
|
|
153
178
|
}
|
|
154
179
|
),
|
|
155
|
-
PurePosixPath(
|
|
156
|
-
"packages/claude-dev-env/scripts/automatic_advisory/window_flags.py"
|
|
157
|
-
): frozenset(
|
|
158
|
-
{
|
|
159
|
-
PurePosixPath(
|
|
160
|
-
"packages/claude-dev-env/scripts/tests/test_automatic_advisory_window_flags.py"
|
|
161
|
-
),
|
|
162
|
-
}
|
|
163
|
-
),
|
|
164
180
|
PurePosixPath(
|
|
165
181
|
"packages/claude-dev-env/scripts/automatic_advisory/windows_job.py"
|
|
166
182
|
): frozenset(
|
|
@@ -34,6 +34,7 @@ RENAME_PATH_COUNT = 2
|
|
|
34
34
|
PYTHON_SUFFIX = ".py"
|
|
35
35
|
ALL_CODE_SUFFIXES = frozenset({".py", ".js", ".jsx", ".ts", ".tsx", ".mjs"})
|
|
36
36
|
ALL_MARKDOWN_SUFFIXES = frozenset({".md", ".mdx"})
|
|
37
|
+
ALL_GENERATED_DOCUMENT_NAMES = frozenset({"changelog.md"})
|
|
37
38
|
ALL_STORED_PROMPT_SEGMENTS = (
|
|
38
39
|
"/.agents/",
|
|
39
40
|
"/commands/",
|
|
@@ -83,6 +84,8 @@ TERMINOLOGY_MESSAGE_GROUP = "message"
|
|
|
83
84
|
|
|
84
85
|
PR_LOOP_DIRECTORY_NAME = "pr-loop"
|
|
85
86
|
SHARED_ROOT_PARENT_INDEX = 2
|
|
87
|
+
POSIX_PATH_SEPARATOR = "/"
|
|
88
|
+
VIOLATION_SCOPING_MODULE_NAME = "code_rules_gate_parts.violation_scoping"
|
|
86
89
|
|
|
87
90
|
RETIRED_HOOK_PROSE_RULE_ID = "retired-hook-prose"
|
|
88
91
|
RULES_DIRECTORY_NAME = "rules"
|
|
@@ -16,6 +16,14 @@ from .config.constants import (
|
|
|
16
16
|
UTF8_ENCODING,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
_subprocess_window_hooks_directory = str(Path(__file__).resolve().parents[2] / "hooks")
|
|
22
|
+
if _subprocess_window_hooks_directory not in sys.path:
|
|
23
|
+
sys.path.append(_subprocess_window_hooks_directory)
|
|
24
|
+
|
|
25
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
26
|
+
|
|
19
27
|
|
|
20
28
|
class GitSelectionError(ValueError):
|
|
21
29
|
"""Raised when Git cannot provide the requested source bytes."""
|
|
@@ -44,6 +52,7 @@ def git_bytes_for(repository_root: Path, all_arguments: tuple[str, ...]) -> byte
|
|
|
44
52
|
capture_output=True,
|
|
45
53
|
check=False,
|
|
46
54
|
env=_git_subprocess_environment(),
|
|
55
|
+
creationflags=hidden_window_creation_flags(),
|
|
47
56
|
)
|
|
48
57
|
if completed.returncode != 0:
|
|
49
58
|
error_text = completed.stderr.decode(UTF8_ENCODING, errors="replace").strip()
|
|
@@ -69,6 +78,7 @@ def head_revision(repository_root: Path) -> str | None:
|
|
|
69
78
|
capture_output=True,
|
|
70
79
|
check=False,
|
|
71
80
|
env=_git_subprocess_environment(),
|
|
81
|
+
creationflags=hidden_window_creation_flags(),
|
|
72
82
|
)
|
|
73
83
|
if completed.returncode != 0:
|
|
74
84
|
error_text = completed.stderr.decode(UTF8_ENCODING, errors="replace").strip()
|
|
@@ -125,6 +135,7 @@ def _read_blob_bytes(repository_root: Path, revision: str, relative_path: str) -
|
|
|
125
135
|
capture_output=True,
|
|
126
136
|
check=False,
|
|
127
137
|
env=_git_subprocess_environment(),
|
|
138
|
+
creationflags=hidden_window_creation_flags(),
|
|
128
139
|
)
|
|
129
140
|
if completed.returncode == 0:
|
|
130
141
|
return completed.stdout
|
|
@@ -131,5 +131,10 @@ ALL_TRACKED_SECRET_EXACT_EXEMPTIONS: frozenset[tuple[str, str, str]] = frozenset
|
|
|
131
131
|
"home-path",
|
|
132
132
|
"5d956228802fbd4a65af8fa7fb4183f5a9f8793dbe218877aefe887b905f726d",
|
|
133
133
|
),
|
|
134
|
+
(
|
|
135
|
+
"shared_utils/theme_assets/contracts/evidence/theme_editor_asset_resolution_master.oneui9.v1.json",
|
|
136
|
+
"home-path",
|
|
137
|
+
"ec69fe88e2f9d3f1ed3504532c7484120591cbec7ae6fda017b0f9ca57a3fc25",
|
|
138
|
+
),
|
|
134
139
|
)
|
|
135
140
|
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Repository policy document and exception-entry schema."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
CONFIG_RELATIVE_PATH = "config/repository-policy.json"
|
|
6
|
+
DIGEST_PATTERN = re.compile(r"[0-9a-f]{64}")
|
|
7
|
+
FORBIDDEN_PATH_CHARACTERS = frozenset("\\:*?[]")
|
|
8
|
+
ALL_ENTRY_FIELDS = frozenset({"path", "sha256", "reason"})
|
|
9
|
+
ALL_FORBIDDEN_PATH_SEGMENTS = frozenset({"", ".", ".."})
|
|
10
|
+
FIRST_PRINTABLE_CODEPOINT = 32
|
|
11
|
+
VERSION_FIELD = "version"
|
|
12
|
+
SUPPORTED_DOCUMENT_VERSION = 1
|
|
13
|
+
EMAIL_EXEMPTIONS_FIELD = "email_exemptions"
|
|
14
|
+
PATH_EXEMPTIONS_FIELD = "path_exemptions"
|
|
15
|
+
ALL_EXEMPTION_FIELDS = frozenset({EMAIL_EXEMPTIONS_FIELD, PATH_EXEMPTIONS_FIELD})
|
|
16
|
+
ALL_DOCUMENT_FIELDS = ALL_EXEMPTION_FIELDS | {VERSION_FIELD}
|
|
17
|
+
EMAIL_EXEMPTION_SUBJECT = "Email exemption"
|
|
18
|
+
PATH_EXEMPTION_SUBJECT = "Path exemption"
|
|
19
|
+
EMAIL_CATEGORY = "email"
|
|
@@ -2,21 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
|
|
8
|
-
from repository_checks.config.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
from repository_checks.config.policy_document import (
|
|
8
|
+
EMAIL_CATEGORY,
|
|
9
|
+
EMAIL_EXEMPTION_SUBJECT,
|
|
10
|
+
EMAIL_EXEMPTIONS_FIELD,
|
|
11
|
+
)
|
|
12
|
+
from repository_checks.policy_document import (
|
|
13
|
+
PolicyConfigurationRunFatal,
|
|
14
|
+
entry_path_and_digest,
|
|
15
|
+
read_policy_entries,
|
|
15
16
|
)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class PolicyConfigurationRunFatal(ValueError):
|
|
19
|
-
"""Reject the entire policy run when exception configuration is invalid."""
|
|
20
17
|
|
|
21
18
|
|
|
22
19
|
def load_email_exemptions(repository_root: Path) -> frozenset[tuple[str, str, str]]:
|
|
@@ -31,66 +28,12 @@ def load_email_exemptions(repository_root: Path) -> frozenset[tuple[str, str, st
|
|
|
31
28
|
OSError: Configuration cannot be read.
|
|
32
29
|
"""
|
|
33
30
|
exemptions: set[tuple[str, str, str]] = set()
|
|
34
|
-
for each_entry in
|
|
35
|
-
|
|
31
|
+
for each_entry in read_policy_entries(repository_root, EMAIL_EXEMPTIONS_FIELD):
|
|
32
|
+
relative_path, digest = entry_path_and_digest(
|
|
33
|
+
each_entry, EMAIL_EXEMPTION_SUBJECT
|
|
34
|
+
)
|
|
35
|
+
identity = (relative_path, EMAIL_CATEGORY, digest)
|
|
36
36
|
if identity in exemptions:
|
|
37
37
|
raise PolicyConfigurationRunFatal("Duplicate repository email exemption")
|
|
38
38
|
exemptions.add(identity)
|
|
39
39
|
return frozenset(exemptions)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _read_entries(repository_root: Path) -> list[object]:
|
|
43
|
-
config_path = repository_root / CONFIG_RELATIVE_PATH
|
|
44
|
-
if not config_path.exists() and not config_path.is_symlink():
|
|
45
|
-
return []
|
|
46
|
-
if not config_path.resolve().is_relative_to(repository_root.resolve()):
|
|
47
|
-
raise ValueError(
|
|
48
|
-
"Repository policy configuration must remain inside the repository"
|
|
49
|
-
)
|
|
50
|
-
document = json.loads(
|
|
51
|
-
config_path.read_text(encoding="utf-8"), object_pairs_hook=_unique_fields
|
|
52
|
-
)
|
|
53
|
-
if (
|
|
54
|
-
not isinstance(document, dict)
|
|
55
|
-
or set(document) != {"version", "email_exemptions"}
|
|
56
|
-
or type(document["version"]) is not int
|
|
57
|
-
or document["version"] != 1
|
|
58
|
-
or not isinstance(document["email_exemptions"], list)
|
|
59
|
-
):
|
|
60
|
-
raise ValueError("Invalid repository policy configuration schema")
|
|
61
|
-
return document["email_exemptions"]
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _unique_fields(all_pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
65
|
-
fields: dict[str, object] = {}
|
|
66
|
-
for each_key, each_content in all_pairs:
|
|
67
|
-
if each_key in fields:
|
|
68
|
-
raise PolicyConfigurationRunFatal("Duplicate repository policy field")
|
|
69
|
-
fields[each_key] = each_content
|
|
70
|
-
return fields
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _entry_identity(entry: object) -> tuple[str, str, str]:
|
|
74
|
-
if not isinstance(entry, dict) or set(entry) != ALL_ENTRY_FIELDS:
|
|
75
|
-
raise ValueError("Email exemptions require path, sha256, and reason")
|
|
76
|
-
relative_path, digest, reason = entry["path"], entry["sha256"], entry["reason"]
|
|
77
|
-
if not isinstance(relative_path, str) or not relative_path:
|
|
78
|
-
raise ValueError("Email exemption path must name one repository file")
|
|
79
|
-
if (
|
|
80
|
-
any(character in FORBIDDEN_PATH_CHARACTERS for character in relative_path)
|
|
81
|
-
or any(
|
|
82
|
-
segment in ALL_FORBIDDEN_PATH_SEGMENTS
|
|
83
|
-
for segment in relative_path.split("/")
|
|
84
|
-
)
|
|
85
|
-
or any(
|
|
86
|
-
ord(character) < FIRST_PRINTABLE_CODEPOINT for character in relative_path
|
|
87
|
-
)
|
|
88
|
-
):
|
|
89
|
-
raise ValueError("Email exemption path must be a literal relative file path")
|
|
90
|
-
if not isinstance(digest, str) or DIGEST_PATTERN.fullmatch(digest) is None:
|
|
91
|
-
raise ValueError("Email exemption sha256 must contain 64 lowercase hex digits")
|
|
92
|
-
if not isinstance(reason, str) or not reason.strip():
|
|
93
|
-
raise ValueError(
|
|
94
|
-
"Email exemption reason must explain its public contact purpose"
|
|
95
|
-
)
|
|
96
|
-
return relative_path, "email", digest
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Load frozen-file path exceptions owned by a repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from repository_checks.config.policy_document import (
|
|
8
|
+
PATH_EXEMPTION_SUBJECT,
|
|
9
|
+
PATH_EXEMPTIONS_FIELD,
|
|
10
|
+
)
|
|
11
|
+
from repository_checks.policy_document import (
|
|
12
|
+
PolicyConfigurationRunFatal,
|
|
13
|
+
entry_path_and_digest,
|
|
14
|
+
read_policy_entries,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_path_exemptions(repository_root: Path) -> frozenset[tuple[str, str]]:
|
|
19
|
+
"""Read validated frozen-file path exceptions.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
repository_root: Repository containing the optional policy configuration.
|
|
23
|
+
Returns:
|
|
24
|
+
Exact path and recorded file-content digest identities.
|
|
25
|
+
Raises:
|
|
26
|
+
ValueError: Configuration contains invalid JSON or schema fields.
|
|
27
|
+
OSError: Configuration cannot be read.
|
|
28
|
+
"""
|
|
29
|
+
exemptions: set[tuple[str, str]] = set()
|
|
30
|
+
for each_entry in read_policy_entries(repository_root, PATH_EXEMPTIONS_FIELD):
|
|
31
|
+
identity = entry_path_and_digest(each_entry, PATH_EXEMPTION_SUBJECT)
|
|
32
|
+
if identity in exemptions:
|
|
33
|
+
raise PolicyConfigurationRunFatal("Duplicate repository path exemption")
|
|
34
|
+
exemptions.add(identity)
|
|
35
|
+
return frozenset(exemptions)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Read and validate the repository policy document."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from repository_checks.config.policy_document import (
|
|
9
|
+
ALL_DOCUMENT_FIELDS,
|
|
10
|
+
ALL_ENTRY_FIELDS,
|
|
11
|
+
ALL_EXEMPTION_FIELDS,
|
|
12
|
+
ALL_FORBIDDEN_PATH_SEGMENTS,
|
|
13
|
+
CONFIG_RELATIVE_PATH,
|
|
14
|
+
DIGEST_PATTERN,
|
|
15
|
+
FIRST_PRINTABLE_CODEPOINT,
|
|
16
|
+
FORBIDDEN_PATH_CHARACTERS,
|
|
17
|
+
SUPPORTED_DOCUMENT_VERSION,
|
|
18
|
+
VERSION_FIELD,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PolicyConfigurationRunFatal(ValueError):
|
|
23
|
+
"""Reject the entire policy run when exception configuration is invalid."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_policy_entries(repository_root: Path, field_name: str) -> list[object]:
|
|
27
|
+
"""Read one exception list from the validated policy document.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
repository_root: Repository containing the optional policy configuration.
|
|
31
|
+
field_name: Top-level document field holding the exception list.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Raw entries recorded under the requested field.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ValueError: Configuration contains invalid JSON or schema fields.
|
|
38
|
+
OSError: Configuration cannot be read.
|
|
39
|
+
"""
|
|
40
|
+
config_path = repository_root / CONFIG_RELATIVE_PATH
|
|
41
|
+
if not config_path.exists() and not config_path.is_symlink():
|
|
42
|
+
return []
|
|
43
|
+
if not config_path.resolve().is_relative_to(repository_root.resolve()):
|
|
44
|
+
raise ValueError(
|
|
45
|
+
"Repository policy configuration must remain inside the repository"
|
|
46
|
+
)
|
|
47
|
+
document = json.loads(
|
|
48
|
+
config_path.read_text(encoding="utf-8"), object_pairs_hook=_unique_fields
|
|
49
|
+
)
|
|
50
|
+
_require_valid_document(document)
|
|
51
|
+
return document.get(field_name, [])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def entry_path_and_digest(entry: object, subject: str) -> tuple[str, str]:
|
|
55
|
+
"""Return the validated path and digest of one exception entry.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
entry: Raw entry read from the policy document.
|
|
59
|
+
subject: Exception family named in each rejection message.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Repository-relative path and its recorded sha256 digest.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: The entry omits a field or carries an invalid value.
|
|
66
|
+
"""
|
|
67
|
+
if not isinstance(entry, dict) or set(entry) != ALL_ENTRY_FIELDS:
|
|
68
|
+
raise ValueError(f"{subject}s require path, sha256, and reason")
|
|
69
|
+
relative_path, digest, reason = entry["path"], entry["sha256"], entry["reason"]
|
|
70
|
+
if not isinstance(relative_path, str) or not relative_path:
|
|
71
|
+
raise ValueError(f"{subject} path must name one repository file")
|
|
72
|
+
if _is_nonliteral_relative_path(relative_path):
|
|
73
|
+
raise ValueError(f"{subject} path must be a literal relative file path")
|
|
74
|
+
if not isinstance(digest, str) or DIGEST_PATTERN.fullmatch(digest) is None:
|
|
75
|
+
raise ValueError(f"{subject} sha256 must contain 64 lowercase hex digits")
|
|
76
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
77
|
+
raise ValueError(f"{subject} reason must explain why the exception is owned")
|
|
78
|
+
return relative_path, digest
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _require_valid_document(document: object) -> None:
|
|
82
|
+
if (
|
|
83
|
+
not isinstance(document, dict)
|
|
84
|
+
or not set(document) <= ALL_DOCUMENT_FIELDS
|
|
85
|
+
or VERSION_FIELD not in document
|
|
86
|
+
or type(document[VERSION_FIELD]) is not int
|
|
87
|
+
or document[VERSION_FIELD] != SUPPORTED_DOCUMENT_VERSION
|
|
88
|
+
or not _has_only_list_exemption_fields(document)
|
|
89
|
+
):
|
|
90
|
+
raise ValueError("Invalid repository policy configuration schema")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _has_only_list_exemption_fields(all_document_fields: dict[str, object]) -> bool:
|
|
94
|
+
return all(
|
|
95
|
+
isinstance(all_document_fields[each_field], list)
|
|
96
|
+
for each_field in ALL_EXEMPTION_FIELDS
|
|
97
|
+
if each_field in all_document_fields
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_nonliteral_relative_path(relative_path: str) -> bool:
|
|
102
|
+
return (
|
|
103
|
+
any(character in FORBIDDEN_PATH_CHARACTERS for character in relative_path)
|
|
104
|
+
or any(
|
|
105
|
+
segment in ALL_FORBIDDEN_PATH_SEGMENTS
|
|
106
|
+
for segment in relative_path.split("/")
|
|
107
|
+
)
|
|
108
|
+
or any(
|
|
109
|
+
ord(character) < FIRST_PRINTABLE_CODEPOINT for character in relative_path
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _unique_fields(all_pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
115
|
+
fields: dict[str, object] = {}
|
|
116
|
+
for each_key, each_content in all_pairs:
|
|
117
|
+
if each_key in fields:
|
|
118
|
+
raise PolicyConfigurationRunFatal("Duplicate repository policy field")
|
|
119
|
+
fields[each_key] = each_content
|
|
120
|
+
return fields
|