claude-dev-env 1.83.0 → 1.85.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/_shared/pr-loop/audit-contract.md +24 -12
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +8 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +85 -39
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +94 -4
- package/agents/clean-coder.md +3 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +16 -8
- package/bin/install.test.mjs +17 -6
- package/commands/CLAUDE.md +0 -1
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +0 -4
- package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
- package/hooks/blocking/code_rules_constants_config.py +5 -0
- package/hooks/blocking/code_rules_docstrings.py +133 -19
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_imports_logging.py +10 -6
- package/hooks/blocking/code_rules_magic_values.py +5 -0
- package/hooks/blocking/code_rules_naming_collection.py +15 -8
- package/hooks/blocking/code_rules_paired_test.py +3 -2
- package/hooks/blocking/code_rules_shared.py +34 -0
- package/hooks/blocking/code_rules_string_magic.py +1 -1
- package/hooks/blocking/code_rules_unused_imports.py +2 -2
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
- package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
- package/hooks/blocking/test_code_rules_magic_values.py +54 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
- package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
- package/hooks/hooks_constants/CLAUDE.md +0 -3
- package/hooks/hooks_constants/blocking_check_limits.py +4 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
- package/hooks/validation/post_tool_use_dispatcher.py +1 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
- package/hooks/workflow/CLAUDE.md +2 -8
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/env-var-table-code-drift.md +1 -1
- package/rules/package-inventory-stale-entry.md +1 -1
- package/rules/paired-test-coverage.md +1 -1
- package/rules/plain-illustrative-docstrings.md +35 -1
- package/skills/CLAUDE.md +6 -1
- package/skills/_shared/pr-loop/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
- package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
- package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
- package/skills/autoconverge/SKILL.md +82 -58
- package/skills/autoconverge/reference/closing-report.md +6 -6
- package/skills/autoconverge/reference/convergence.md +17 -15
- package/skills/autoconverge/reference/gotchas.md +6 -3
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
- package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
- package/skills/autoconverge/workflow/converge.mjs +71 -74
- package/skills/autoconverge/workflow/render_report.py +7 -11
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +25 -72
- package/skills/bugteam/reference/CLAUDE.md +1 -3
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -7
- package/skills/bugteam/scripts/CLAUDE.md +0 -6
- package/skills/bugteam/scripts/README.md +0 -4
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
- package/skills/code/SKILL.md +2 -0
- package/skills/copilot-review/CLAUDE.md +1 -1
- package/skills/copilot-review/SKILL.md +25 -23
- package/skills/findbugs/CLAUDE.md +2 -2
- package/skills/findbugs/SKILL.md +22 -83
- package/skills/fixbugs/SKILL.md +2 -4
- package/skills/log-audit/CLAUDE.md +20 -0
- package/skills/log-audit/SKILL.md +68 -0
- package/skills/log-audit/reference/CLAUDE.md +9 -0
- package/skills/log-audit/reference/charter.md +52 -0
- package/skills/log-audit/scripts/CLAUDE.md +27 -0
- package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
- package/skills/log-audit/scripts/collect_log_window.py +199 -0
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
- package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
- package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
- package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
- package/skills/monitor-open-prs/SKILL.md +2 -2
- package/skills/post-audit-findings/SKILL.md +84 -0
- package/skills/pr-converge/CLAUDE.md +2 -0
- package/skills/pr-converge/SKILL.md +99 -60
- package/skills/pr-converge/reference/CLAUDE.md +1 -1
- package/skills/pr-converge/reference/convergence-gates.md +16 -19
- package/skills/pr-converge/reference/examples.md +5 -5
- package/skills/pr-converge/reference/fix-protocol.md +16 -43
- package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +24 -45
- package/skills/pr-converge/reference/state-schema.md +27 -0
- package/skills/pr-converge/scripts/README.md +3 -5
- package/skills/pr-fix-protocol/SKILL.md +70 -0
- package/skills/pr-loop-lifecycle/SKILL.md +73 -0
- package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
- package/skills/pr-scope-resolve/SKILL.md +48 -0
- package/skills/qbug/CLAUDE.md +4 -4
- package/skills/qbug/SKILL.md +46 -144
- package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
- package/skills/reviewer-gates/SKILL.md +96 -0
- package/skills/session-log/CLAUDE.md +7 -7
- package/skills/session-log/SKILL.md +27 -44
- package/skills/test_markdown_link_integrity.py +103 -0
- package/commands/doc-gist.md +0 -16
- package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
- package/hooks/blocking/conftest.py +0 -30
- package/hooks/blocking/md_path_exemptions.py +0 -224
- package/hooks/blocking/md_to_html_blocker.py +0 -155
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
- package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
- package/hooks/hooks_constants/html_companion_constants.py +0 -20
- package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
- package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
- package/hooks/workflow/doc_gist_auto_publish.py +0 -144
- package/hooks/workflow/md_to_html_companion.py +0 -358
- package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
- package/hooks/workflow/test_md_to_html_companion.py +0 -613
- package/skills/bugteam/reference/audit-contract.md +0 -163
- package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
- package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
- package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
- package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
- package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
- package/skills/doc-gist/CLAUDE.md +0 -25
- package/skills/doc-gist/SKILL.md +0 -97
- package/skills/doc-gist/references/CLAUDE.md +0 -9
- package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
- package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
- package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
- package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
- package/skills/doc-gist/references/examples/05-design-system.html +0 -629
- package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
- package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
- package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
- package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
- package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
- package/skills/doc-gist/references/examples/11-status-report.html +0 -528
- package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
- package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
- package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
- package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
- package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
- package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
- package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
- package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
- package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
- package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
- package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
- package/skills/doc-gist/references/examples/README.md +0 -5
- package/skills/doc-gist/scripts/CLAUDE.md +0 -27
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
- package/skills/doc-gist/scripts/gist_upload.py +0 -177
- package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
- /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
|
@@ -8,7 +8,8 @@ Shared output schema and audit-loop contract used by `/bugteam`, `/qbug`, `/find
|
|
|
8
8
|
- Adversarial second pass
|
|
9
9
|
- Haiku secondary auditor
|
|
10
10
|
- Post-fix self-audit
|
|
11
|
-
-
|
|
11
|
+
- De-dup and merge
|
|
12
|
+
- Persistence (loop-<L>-audit.json, loop-<L>-diagnostics.json)
|
|
12
13
|
|
|
13
14
|
## Finding schema
|
|
14
15
|
|
|
@@ -18,7 +19,7 @@ Each finding an audit produces MUST be one of exactly two shapes.
|
|
|
18
19
|
|
|
19
20
|
```json
|
|
20
21
|
{
|
|
21
|
-
"id": "loop<
|
|
22
|
+
"id": "loop<L>-<K>",
|
|
22
23
|
"file": "path/relative/to/repo/root.py",
|
|
23
24
|
"line": 123,
|
|
24
25
|
"category": "A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P",
|
|
@@ -29,7 +30,7 @@ Each finding an audit produces MUST be one of exactly two shapes.
|
|
|
29
30
|
}
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
`id` is `loop<
|
|
33
|
+
`id` is `loop<L>-<K>` where `L` is the loop counter (1-based) and `K` is the 1-based index within the loop. For `/findbugs` which runs once, use `find<K>`.
|
|
33
34
|
|
|
34
35
|
### Shape B — structured proof-of-absence
|
|
35
36
|
|
|
@@ -98,16 +99,16 @@ For single-subagent skills (`/qbug`, `/findbugs`) the LEAD spawns two `Agent()`
|
|
|
98
99
|
|
|
99
100
|
Both audit the same diff. The secondary returns findings to the LEAD only — never posted to the PR.
|
|
100
101
|
|
|
101
|
-
Merge rules:
|
|
102
|
+
Merge rules — applied whenever the LEAD combines findings from multiple sources (primary auditor, Haiku secondary auditor, adversarial pass):
|
|
102
103
|
|
|
103
|
-
- **De-dup key**: `(file, line, category)`.
|
|
104
|
-
- **Severity conflict**: max wins (P0 > P1 > P2).
|
|
105
|
-
- **Unique-to-
|
|
104
|
+
- **De-dup key**: `(file, line, category)`. Two findings sharing the same `(file, line, category)` tuple are the same finding and collapse into one entry.
|
|
105
|
+
- **Severity conflict**: max wins (`P0 > P1 > P2`). When sources disagree on severity for the same de-dup key, the merged entry keeps the highest severity.
|
|
106
|
+
- **Unique-to-secondary findings**: added to the merged set with the secondary's severity and source annotation.
|
|
106
107
|
- **Unique-to-primary findings**: kept as-is.
|
|
107
|
-
- **Zero
|
|
108
|
-
- **Malformed or non-parseable
|
|
108
|
+
- **Zero secondary findings**: the primary set is trusted and the audit moves on.
|
|
109
|
+
- **Malformed or non-parseable secondary output**: lead trusts the primary set and logs the event in `loop-<L>-diagnostics.json` under `haiku_findings` as `[{"parse_error": "<message>"}]`.
|
|
109
110
|
|
|
110
|
-
For
|
|
111
|
+
For `/bugteam`, the single audit agent provides per-category coverage by walking all A–P rubrics in one invocation.
|
|
111
112
|
|
|
112
113
|
## Post-fix self-audit
|
|
113
114
|
|
|
@@ -128,11 +129,22 @@ Sequence:
|
|
|
128
129
|
|
|
129
130
|
`converged` exit condition: `primary_audit_clean AND post_fix_audit_clean` for the committing loop.
|
|
130
131
|
|
|
132
|
+
## De-dup and merge
|
|
133
|
+
|
|
134
|
+
Findings from primary, adversarial, Haiku secondary, and post-fix passes are merged into a single deduped finding list before persistence.
|
|
135
|
+
|
|
136
|
+
- **De-dup key:** `(file, line, category)`. Two findings sharing the same `(file, line, category)` tuple collapse into a single deduped entry.
|
|
137
|
+
- **Severity conflict resolution:** `max wins`. When merged findings disagree on severity, the deduped entry carries the highest severity (`P0 > P1 > P2`).
|
|
138
|
+
- **Excerpt and failure_mode:** the deduped entry inherits these fields from the highest-severity contributing finding. Ties keep the first observed contributor.
|
|
139
|
+
- **`evidence_files`:** the deduped entry carries the union of every contributor's `evidence_files`, deduplicated and sorted.
|
|
140
|
+
|
|
141
|
+
The merged list lands in `loop-<L>-diagnostics.json` under both `merged` (one entry per contributing finding) and `deduped` (one entry per unique `(file, line, category)` tuple).
|
|
142
|
+
|
|
131
143
|
## Persistence
|
|
132
144
|
|
|
133
145
|
Every audit loop writes two JSON files under the skill's scoped temp directory (resolved via `tempfile.gettempdir()`):
|
|
134
146
|
|
|
135
|
-
### `loop-<
|
|
147
|
+
### `loop-<L>-audit.json`
|
|
136
148
|
|
|
137
149
|
```json
|
|
138
150
|
{
|
|
@@ -142,7 +154,7 @@ Every audit loop writes two JSON files under the skill's scoped temp directory (
|
|
|
142
154
|
}
|
|
143
155
|
```
|
|
144
156
|
|
|
145
|
-
### `loop-<
|
|
157
|
+
### `loop-<L>-diagnostics.json`
|
|
146
158
|
|
|
147
159
|
```json
|
|
148
160
|
{
|
|
@@ -299,9 +299,11 @@ def write_atomically_with_mode(
|
|
|
299
299
|
|
|
300
300
|
Raises:
|
|
301
301
|
OSError: When os.open or os.fdopen fails. The raw file descriptor
|
|
302
|
-
is closed
|
|
302
|
+
is closed and the on-disk temporary file is unlinked before
|
|
303
|
+
re-raising so neither the descriptor nor the file leaks.
|
|
303
304
|
MemoryError: When os.fdopen runs out of buffer memory; the file
|
|
304
|
-
descriptor is closed
|
|
305
|
+
descriptor is closed and the temporary file is unlinked
|
|
306
|
+
before re-raising.
|
|
305
307
|
"""
|
|
306
308
|
file_descriptor = os.open(
|
|
307
309
|
str(temporary_path),
|
|
@@ -312,6 +314,10 @@ def write_atomically_with_mode(
|
|
|
312
314
|
writer = os.fdopen(file_descriptor, "w", encoding=TEXT_FILE_ENCODING)
|
|
313
315
|
except (OSError, MemoryError):
|
|
314
316
|
os.close(file_descriptor)
|
|
317
|
+
try:
|
|
318
|
+
os.unlink(str(temporary_path))
|
|
319
|
+
except OSError:
|
|
320
|
+
pass
|
|
315
321
|
raise
|
|
316
322
|
with writer:
|
|
317
323
|
writer.write(serialized_content)
|
|
@@ -321,7 +327,8 @@ def save_settings(settings_path: Path, all_settings: dict[str, object]) -> None:
|
|
|
321
327
|
"""Write settings to a JSON file atomically with permission preservation.
|
|
322
328
|
|
|
323
329
|
Creates a temporary sibling file, writes content, then atomically
|
|
324
|
-
replaces the target. Cleans up the temporary file in a finally block
|
|
330
|
+
replaces the target. Cleans up the temporary file in a finally block,
|
|
331
|
+
printing a warning to stderr when that cleanup unlink fails.
|
|
325
332
|
|
|
326
333
|
Args:
|
|
327
334
|
settings_path: Path to the target settings JSON file.
|
|
@@ -352,8 +359,12 @@ def save_settings(settings_path: Path, all_settings: dict[str, object]) -> None:
|
|
|
352
359
|
if temporary_path.exists():
|
|
353
360
|
try:
|
|
354
361
|
temporary_path.unlink()
|
|
355
|
-
except OSError:
|
|
356
|
-
|
|
362
|
+
except OSError as unlink_error:
|
|
363
|
+
print(
|
|
364
|
+
f"Warning: could not remove temp file {temporary_path}: "
|
|
365
|
+
f"{type(unlink_error).__name__}: {unlink_error}",
|
|
366
|
+
file=sys.stderr,
|
|
367
|
+
)
|
|
357
368
|
|
|
358
369
|
|
|
359
370
|
def append_if_missing(all_target_list: list[object], new_value: str) -> bool:
|
|
@@ -1673,7 +1673,6 @@ def main(all_arguments: list[str]) -> int:
|
|
|
1673
1673
|
if arguments.staged:
|
|
1674
1674
|
all_terminology_findings = staged_terminology_findings(repository_root)
|
|
1675
1675
|
_report_terminology_findings(all_terminology_findings)
|
|
1676
|
-
terminology_exit_code = 1 if all_terminology_findings else 0
|
|
1677
1676
|
staged_test_exit_code = run_staged_test_files(repository_root)
|
|
1678
1677
|
staged_file_paths = paths_from_git_staged(repository_root)
|
|
1679
1678
|
staged_file_paths = filter_paths_under_prefixes(
|
|
@@ -1682,7 +1681,7 @@ def main(all_arguments: list[str]) -> int:
|
|
|
1682
1681
|
arguments.only_under,
|
|
1683
1682
|
)
|
|
1684
1683
|
if not staged_file_paths:
|
|
1685
|
-
return
|
|
1684
|
+
return staged_test_exit_code
|
|
1686
1685
|
staged_added_lines = added_lines_by_file_staged(
|
|
1687
1686
|
repository_root, staged_file_paths
|
|
1688
1687
|
)
|
|
@@ -1693,7 +1692,7 @@ def main(all_arguments: list[str]) -> int:
|
|
|
1693
1692
|
all_added_lines_by_path=staged_added_lines,
|
|
1694
1693
|
read_staged_content_flag=True,
|
|
1695
1694
|
)
|
|
1696
|
-
return gate_exit_code or
|
|
1695
|
+
return gate_exit_code or staged_test_exit_code
|
|
1697
1696
|
file_paths = paths_from_git_diff(repository_root, arguments.base)
|
|
1698
1697
|
file_paths = filter_paths_under_prefixes(
|
|
1699
1698
|
file_paths,
|
|
@@ -16,7 +16,7 @@ Named constants for every script in `_shared/pr-loop/scripts/`. Each module owns
|
|
|
16
16
|
| `preflight_self_heal_constants.py` | `preflight_self_heal.py` — git config keys and local-scope detection strings |
|
|
17
17
|
| `reviews_disabled_constants.py` | `reviews_disabled.py` — `CLAUDE_REVIEWS_DISABLED` token taxonomy |
|
|
18
18
|
| `copilot_quota_constants.py` | `copilot_quota.py` — `COPILOT_QUOTA_ACCOUNT` env-var name, `copilot_internal/user` API path, the `premium_interactions` gating field names, the four skip/run exit codes, and the default `.env` path |
|
|
19
|
-
| `terminology_sweep_constants.py` | `terminology_sweep.py` — identifier and prose-token regexes, diff-parsing prefixes, code-file extensions, and the finding-message template |
|
|
19
|
+
| `terminology_sweep_constants.py` | `terminology_sweep.py` — identifier and prose-token regexes, diff-parsing prefixes, code-file extensions, test-file detection patterns, and the finding-message template |
|
|
20
20
|
| `reviewer_availability_constants.py` | `reviewer_availability.py` — the available/down exit codes for the unified reviewer-availability pre-check |
|
|
21
21
|
| `__init__.py` | Empty package marker |
|
|
22
22
|
|
|
@@ -8,6 +8,8 @@ ALL_SWEEP_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
|
|
|
8
8
|
|
|
9
9
|
MARKDOWN_FILE_EXTENSION: str = ".md"
|
|
10
10
|
|
|
11
|
+
PYTHON_FILE_EXTENSION: str = ".py"
|
|
12
|
+
|
|
11
13
|
SNAKE_CASE_IDENTIFIER_PATTERN: re.Pattern[str] = re.compile(
|
|
12
14
|
r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b"
|
|
13
15
|
)
|
|
@@ -28,6 +30,12 @@ STRING_LITERAL_CONTENT_PATTERN: re.Pattern[str] = re.compile(
|
|
|
28
30
|
r"\"([^\"]*)\"|'([^']*)'|`([^`]*)`"
|
|
29
31
|
)
|
|
30
32
|
|
|
33
|
+
TEST_DIRECTORY_PATH_SEGMENT: str = "/tests/"
|
|
34
|
+
|
|
35
|
+
TEST_FILE_NAME_PREFIX: str = "test_"
|
|
36
|
+
|
|
37
|
+
ALL_TEST_FILE_NAME_INFIX_MARKERS: tuple[str, ...] = ("_test.", ".test.", ".spec.")
|
|
38
|
+
|
|
31
39
|
DIFF_FILE_HEADER_PREFIX: str = "+++ "
|
|
32
40
|
|
|
33
41
|
DIFF_HUNK_HEADER_PATTERN: re.Pattern[str] = re.compile(
|
|
@@ -103,6 +103,7 @@ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
|
|
|
103
103
|
choices=[
|
|
104
104
|
CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN,
|
|
105
105
|
CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN,
|
|
106
|
+
CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN,
|
|
106
107
|
],
|
|
107
108
|
help="Reviewer token to test against CLAUDE_REVIEWS_DISABLED",
|
|
108
109
|
)
|
|
@@ -122,6 +123,7 @@ def main(all_arguments: list[str]) -> int:
|
|
|
122
123
|
disabled_checker_by_reviewer = {
|
|
123
124
|
CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN: is_bugbot_disabled_via_env,
|
|
124
125
|
CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN: is_bugteam_disabled_via_env,
|
|
126
|
+
CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN: is_copilot_disabled_via_env,
|
|
125
127
|
}
|
|
126
128
|
return 0 if disabled_checker_by_reviewer[arguments.reviewer]() else 1
|
|
127
129
|
|
|
@@ -1,40 +1,27 @@
|
|
|
1
|
-
"""Flag prose
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Only hyphenated
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
A shared leading token alone is too weak a signal: ordinary English compounds
|
|
23
|
-
such as ``read-only`` and ``data-driven`` collide with unrelated identifiers
|
|
24
|
-
(``read_config``, ``data_source``) and would block a commit falsely. To keep the
|
|
25
|
-
grounding case (``premium-request`` against ``premium_interactions``) firing
|
|
26
|
-
while sparing those compounds, a candidate whose final token is a common English
|
|
27
|
-
compound tail word (``only``, ``driven``, ``safe``, and the rest listed in
|
|
28
|
-
``ALL_COMMON_ENGLISH_COMPOUND_TAIL_WORDS``) is treated as ordinary prose, not a
|
|
29
|
-
near-miss.
|
|
30
|
-
|
|
31
|
-
Ordinary singular/plural prose is spared the same way. A candidate that differs
|
|
32
|
-
from an identifier only by a singular/plural form of one or more tokens
|
|
33
|
-
(``test files`` against ``test_file``, ``retry policies`` against
|
|
34
|
-
``retry_policy``) is treated as the same term, not a near-miss.
|
|
1
|
+
"""Flag a prose term that near-misses a code identifier a diff introduces.
|
|
2
|
+
|
|
3
|
+
A change adds the field ``premium_interactions`` while the same branch's docs
|
|
4
|
+
call it the ``premium-request`` budget: two names for one thing, and a reader
|
|
5
|
+
who searches one never finds the other.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
code adds: premium_interactions (the identifier)
|
|
10
|
+
prose adds: "the premium-request budget" (a hyphenated term)
|
|
11
|
+
flag: premium-request vs premium_interactions -- shared first token, tail diverges
|
|
12
|
+
ok: premium-interactions -- exact hyphen form, no drift
|
|
13
|
+
ok: read-only, test files -- English compound or plural, not drift
|
|
14
|
+
|
|
15
|
+
Only hyphenated prose is a candidate, since a hyphen marks a deliberate compound
|
|
16
|
+
while a spaced word run is ordinary prose. Markdown scans in full; a JavaScript
|
|
17
|
+
or TypeScript line contributes its comments and string literals; a Python file
|
|
18
|
+
contributes identifiers only; a test code file is skipped whole. Each near-miss
|
|
19
|
+
prints one ``file:line`` finding and the run exits non-zero, so a commit gate
|
|
20
|
+
blocks on it.
|
|
35
21
|
"""
|
|
36
22
|
|
|
37
23
|
import argparse
|
|
24
|
+
import os
|
|
38
25
|
import subprocess
|
|
39
26
|
import sys
|
|
40
27
|
from pathlib import Path
|
|
@@ -48,6 +35,7 @@ from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
|
|
|
48
35
|
ALL_DIFF_FILE_PATH_STRIP_PREFIXES,
|
|
49
36
|
ALL_GIT_DIFF_CACHED_UNIFIED_ZERO_COMMAND,
|
|
50
37
|
ALL_SWEEP_CODE_FILE_EXTENSIONS,
|
|
38
|
+
ALL_TEST_FILE_NAME_INFIX_MARKERS,
|
|
51
39
|
CAMEL_CASE_IDENTIFIER_PATTERN,
|
|
52
40
|
CAMEL_CASE_WORD_PATTERN,
|
|
53
41
|
DIFF_ADDED_LINE_PREFIX,
|
|
@@ -64,10 +52,13 @@ from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
|
|
|
64
52
|
MARKDOWN_FILE_EXTENSION,
|
|
65
53
|
MINIMUM_IDENTIFIER_TOKEN_COUNT,
|
|
66
54
|
PYTHON_COMMENT_MARKER,
|
|
55
|
+
PYTHON_FILE_EXTENSION,
|
|
67
56
|
SNAKE_CASE_IDENTIFIER_PATTERN,
|
|
68
57
|
STRING_LITERAL_CONTENT_PATTERN,
|
|
69
58
|
TERMINOLOGY_FINDING_TEMPLATE,
|
|
70
59
|
TERMINOLOGY_SWEEP_DESCRIPTION,
|
|
60
|
+
TEST_DIRECTORY_PATH_SEGMENT,
|
|
61
|
+
TEST_FILE_NAME_PREFIX,
|
|
71
62
|
)
|
|
72
63
|
|
|
73
64
|
IdentifierTuple = tuple[str, ...]
|
|
@@ -124,6 +115,35 @@ def _file_extension(file_path: str) -> str:
|
|
|
124
115
|
return Path(file_path).suffix.lower()
|
|
125
116
|
|
|
126
117
|
|
|
118
|
+
def _is_test_file(file_path: str) -> bool:
|
|
119
|
+
"""Return True when the diff path is a test code file the sweep should skip.
|
|
120
|
+
|
|
121
|
+
A test code file's prose is scaffolding: its docstrings and string fixtures
|
|
122
|
+
name the identifiers under test on purpose, so scanning it only adds noise. A
|
|
123
|
+
Markdown or other non-code file is prose worth scanning whatever its name, so
|
|
124
|
+
a ``test_plan.md`` design doc is scanned rather than skipped.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
file_path: The repository-relative diff path.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
True for a code file under a ``tests`` directory, a ``test_`` prefixed
|
|
131
|
+
code file, or a code file whose name carries a
|
|
132
|
+
``_test.``/``.test.``/``.spec.`` marker. False for any non-code file.
|
|
133
|
+
"""
|
|
134
|
+
if _file_extension(file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
|
|
135
|
+
return False
|
|
136
|
+
normalized_path = file_path.replace("\\", "/").lower()
|
|
137
|
+
if TEST_DIRECTORY_PATH_SEGMENT in f"/{normalized_path}":
|
|
138
|
+
return True
|
|
139
|
+
basename = normalized_path.rsplit("/", 1)[-1]
|
|
140
|
+
if basename.startswith(TEST_FILE_NAME_PREFIX):
|
|
141
|
+
return True
|
|
142
|
+
return any(
|
|
143
|
+
each_marker in basename for each_marker in ALL_TEST_FILE_NAME_INFIX_MARKERS
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
127
147
|
def _identifier_token_tuple(identifier: str) -> IdentifierTuple:
|
|
128
148
|
"""Return the lowercase word tokens of a snake_case or camelCase identifier."""
|
|
129
149
|
if "_" in identifier:
|
|
@@ -175,8 +195,11 @@ def _collect_introduced_identifiers(
|
|
|
175
195
|
def _prose_fragments(file_path: str, line_text: str) -> list[str]:
|
|
176
196
|
"""Return the prose fragments of an added line worth scanning for terms.
|
|
177
197
|
|
|
178
|
-
A Markdown line is prose in full. A
|
|
179
|
-
its JSDoc continuation text, and the contents of its string
|
|
198
|
+
A Markdown line is prose in full. A JavaScript or TypeScript line contributes
|
|
199
|
+
its comment tail, its JSDoc continuation text, and the contents of its string
|
|
200
|
+
literals. A Python line contributes no prose: to avoid flagging a module's own
|
|
201
|
+
vocabulary as drift, the sweep ignores Python comments, docstrings, and string
|
|
202
|
+
literals and scans Python files for identifiers only.
|
|
180
203
|
|
|
181
204
|
Args:
|
|
182
205
|
file_path: The path the added line belongs to.
|
|
@@ -185,9 +208,12 @@ def _prose_fragments(file_path: str, line_text: str) -> list[str]:
|
|
|
185
208
|
Returns:
|
|
186
209
|
The prose fragments to scan for near-miss terms.
|
|
187
210
|
"""
|
|
188
|
-
|
|
211
|
+
extension = _file_extension(file_path)
|
|
212
|
+
if extension == MARKDOWN_FILE_EXTENSION:
|
|
189
213
|
return [line_text]
|
|
190
|
-
if
|
|
214
|
+
if extension not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
|
|
215
|
+
return []
|
|
216
|
+
if extension == PYTHON_FILE_EXTENSION:
|
|
191
217
|
return []
|
|
192
218
|
all_fragments: list[str] = []
|
|
193
219
|
stripped_line = line_text.strip()
|
|
@@ -352,7 +378,11 @@ def sweep_diff(diff_text: str) -> list[str]:
|
|
|
352
378
|
Returns:
|
|
353
379
|
One finding string per near-miss term on an added prose line.
|
|
354
380
|
"""
|
|
355
|
-
all_added_lines =
|
|
381
|
+
all_added_lines = [
|
|
382
|
+
each_added_line
|
|
383
|
+
for each_added_line in _parse_added_lines(diff_text)
|
|
384
|
+
if not _is_test_file(each_added_line[0])
|
|
385
|
+
]
|
|
356
386
|
all_identifier_tuples, identifiers_by_first_token = _collect_introduced_identifiers(
|
|
357
387
|
all_added_lines
|
|
358
388
|
)
|
|
@@ -372,6 +402,21 @@ def sweep_diff(diff_text: str) -> list[str]:
|
|
|
372
402
|
return all_findings
|
|
373
403
|
|
|
374
404
|
|
|
405
|
+
def _git_environment() -> dict[str, str]:
|
|
406
|
+
"""Return the process environment with every GIT_ variable removed.
|
|
407
|
+
|
|
408
|
+
A git commit hook runs this sweep with GIT_INDEX_FILE pointed at the commit
|
|
409
|
+
index. Stripping every GIT_ variable makes ``git diff --cached`` read the
|
|
410
|
+
repository's own index, so the sweep sees the staged diff of the repository
|
|
411
|
+
it was handed rather than an ambient override.
|
|
412
|
+
"""
|
|
413
|
+
return {
|
|
414
|
+
each_key: each_setting
|
|
415
|
+
for each_key, each_setting in os.environ.items()
|
|
416
|
+
if not each_key.startswith("GIT_")
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
375
420
|
def staged_terminology_findings(repository_root: Path) -> list[str]:
|
|
376
421
|
"""Return terminology near-miss findings for a repository's staged diff.
|
|
377
422
|
|
|
@@ -391,6 +436,7 @@ def staged_terminology_findings(repository_root: Path) -> list[str]:
|
|
|
391
436
|
errors="replace",
|
|
392
437
|
timeout=GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS,
|
|
393
438
|
check=False,
|
|
439
|
+
env=_git_environment(),
|
|
394
440
|
)
|
|
395
441
|
if diff_process.returncode != 0:
|
|
396
442
|
return []
|
|
@@ -133,3 +133,215 @@ def test_path_contains_glob_metacharacters_accepts_windows_paths_with_parens() -
|
|
|
133
133
|
assert common.path_contains_glob_metacharacters("C:/Users/Jon (Admin)/project") is False
|
|
134
134
|
assert common.path_contains_glob_metacharacters("C:/Projects/a,b/file.py") is False
|
|
135
135
|
|
|
136
|
+
|
|
137
|
+
def test_get_mode_to_preserve_returns_existing_file_mode(tmp_path: Path) -> None:
|
|
138
|
+
"""When the file exists, the actual filesystem mode must be returned (not the default)."""
|
|
139
|
+
target_path = tmp_path / "settings.json"
|
|
140
|
+
target_path.write_text("{}", encoding="utf-8")
|
|
141
|
+
actual_filesystem_mode = target_path.stat().st_mode & 0o777
|
|
142
|
+
returned_mode = common.get_mode_to_preserve(target_path)
|
|
143
|
+
assert returned_mode == actual_filesystem_mode
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_get_mode_to_preserve_returns_secure_default_when_file_missing(
|
|
147
|
+
tmp_path: Path,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""A missing settings file must fall back to the secure 0o600 default mode."""
|
|
150
|
+
missing_settings_path = tmp_path / "no_such_file.json"
|
|
151
|
+
returned_mode = common.get_mode_to_preserve(missing_settings_path)
|
|
152
|
+
assert returned_mode == 0o600
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_write_atomically_with_mode_raises_oserror_when_open_fails(
|
|
156
|
+
tmp_path: Path,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""OSError from os.open must propagate to the caller."""
|
|
159
|
+
target_path = tmp_path / "subdirectory" / "missing" / "settings.json.tmp"
|
|
160
|
+
with pytest.raises(OSError):
|
|
161
|
+
common.write_atomically_with_mode(target_path, "payload", file_mode=0o600)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_write_atomically_with_mode_unlinks_temp_when_fdopen_raises(
|
|
165
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
166
|
+
) -> None:
|
|
167
|
+
"""A failure inside os.fdopen must remove the on-disk temp file.
|
|
168
|
+
|
|
169
|
+
The file exists the moment os.open returns; closing only the raw
|
|
170
|
+
descriptor would leave an empty temp sibling on disk after the
|
|
171
|
+
exception propagates to the caller.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def failing_fdopen(*_args: object, **_kwargs: object) -> object:
|
|
175
|
+
raise MemoryError("fdopen failure")
|
|
176
|
+
|
|
177
|
+
monkeypatch.setattr(common.os, "fdopen", failing_fdopen)
|
|
178
|
+
target_path = tmp_path / "settings.json.tmp"
|
|
179
|
+
with pytest.raises(MemoryError):
|
|
180
|
+
common.write_atomically_with_mode(target_path, "payload", file_mode=0o600)
|
|
181
|
+
assert not target_path.exists(), (
|
|
182
|
+
"the temp file created by os.open must be unlinked before re-raising"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_save_settings_warns_on_stderr_when_temp_unlink_fails(
|
|
187
|
+
tmp_path: Path,
|
|
188
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
189
|
+
capsys: pytest.CaptureFixture[str],
|
|
190
|
+
) -> None:
|
|
191
|
+
"""A swallowed unlink in the finally block must surface to stderr.
|
|
192
|
+
|
|
193
|
+
Forces a write success followed by os.replace failure so the temp file
|
|
194
|
+
survives into the finally branch, then makes Path.unlink raise.
|
|
195
|
+
"""
|
|
196
|
+
settings_path = tmp_path / "settings.json"
|
|
197
|
+
settings_path.write_text('{"existing": true}\n', encoding="utf-8")
|
|
198
|
+
|
|
199
|
+
def failing_replace(*_args: object, **_kwargs: object) -> None:
|
|
200
|
+
raise OSError("replace blocked by AV")
|
|
201
|
+
|
|
202
|
+
def failing_unlink(_self: Path, *_args: object, **_kwargs: object) -> None:
|
|
203
|
+
raise PermissionError("temp file held by AV")
|
|
204
|
+
|
|
205
|
+
monkeypatch.setattr(common.os, "replace", failing_replace)
|
|
206
|
+
monkeypatch.setattr(Path, "unlink", failing_unlink)
|
|
207
|
+
with pytest.raises(SystemExit):
|
|
208
|
+
common.save_settings(settings_path, {"new_key": "value"})
|
|
209
|
+
captured = capsys.readouterr()
|
|
210
|
+
assert ".tmp" in captured.err
|
|
211
|
+
assert "PermissionError" in captured.err or "held by AV" in captured.err
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_save_settings_finally_skips_unlink_when_no_temp_was_created(
|
|
215
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
216
|
+
) -> None:
|
|
217
|
+
"""When this invocation never created the temp file, finally must not unlink it."""
|
|
218
|
+
settings_path = tmp_path / "settings.json"
|
|
219
|
+
settings_path.write_text('{"existing": true}\n', encoding="utf-8")
|
|
220
|
+
|
|
221
|
+
unlink_call_paths: list[Path] = []
|
|
222
|
+
original_unlink = Path.unlink
|
|
223
|
+
|
|
224
|
+
def recording_unlink(unlink_target: Path, *args: object, **kwargs: object) -> None:
|
|
225
|
+
unlink_call_paths.append(unlink_target)
|
|
226
|
+
original_unlink(unlink_target, *args, **kwargs)
|
|
227
|
+
|
|
228
|
+
def write_raises(*_args: object, **_kwargs: object) -> None:
|
|
229
|
+
raise FileExistsError("another writer's temp")
|
|
230
|
+
|
|
231
|
+
monkeypatch.setattr(common, "write_atomically_with_mode", write_raises)
|
|
232
|
+
monkeypatch.setattr(Path, "unlink", recording_unlink)
|
|
233
|
+
with pytest.raises(SystemExit):
|
|
234
|
+
common.save_settings(settings_path, {"new_key": "value"})
|
|
235
|
+
assert not any(
|
|
236
|
+
".tmp." in each_path.name for each_path in unlink_call_paths
|
|
237
|
+
), "finally must not unlink a temp file this invocation never created"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_exit_with_error_prints_prefixed_message_and_exits_nonzero(
|
|
241
|
+
capsys: pytest.CaptureFixture[str],
|
|
242
|
+
) -> None:
|
|
243
|
+
with pytest.raises(SystemExit) as raised_exit:
|
|
244
|
+
common.exit_with_error("boom")
|
|
245
|
+
assert raised_exit.value.code == 1
|
|
246
|
+
assert "Error: boom" in capsys.readouterr().err
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_build_permission_rules_builds_one_rule_per_tool() -> None:
|
|
250
|
+
built_rules = common.build_permission_rules("/proj", ("Edit", "Read"))
|
|
251
|
+
assert built_rules == ["Edit(/proj/.claude/**)", "Read(/proj/.claude/**)"]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_build_agent_config_deny_rule_embeds_tool_path_and_pattern() -> None:
|
|
255
|
+
built_rule = common.build_agent_config_deny_rule("Write", "/proj", "hooks/**")
|
|
256
|
+
assert built_rule == "Write(/proj/.claude/hooks/**)"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_remove_matching_entries_from_list_filters_in_place_and_counts() -> None:
|
|
260
|
+
all_entries: list[object] = ["keep", "drop", "drop", 7]
|
|
261
|
+
removed_count = common.remove_matching_entries_from_list(
|
|
262
|
+
all_entries, lambda each_entry: each_entry == "drop"
|
|
263
|
+
)
|
|
264
|
+
assert removed_count == 2
|
|
265
|
+
assert all_entries == ["keep", 7]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_load_settings_returns_empty_dict_when_file_missing(tmp_path: Path) -> None:
|
|
269
|
+
assert common.load_settings(tmp_path / "absent.json") == {}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_load_settings_parses_existing_json_object(tmp_path: Path) -> None:
|
|
273
|
+
settings_path = tmp_path / "settings.json"
|
|
274
|
+
settings_path.write_text('{"permissions": {"allow": []}}', encoding="utf-8")
|
|
275
|
+
assert common.load_settings(settings_path) == {"permissions": {"allow": []}}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_load_settings_exits_when_file_holds_invalid_json(
|
|
279
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
280
|
+
) -> None:
|
|
281
|
+
settings_path = tmp_path / "settings.json"
|
|
282
|
+
settings_path.write_text("not json", encoding="utf-8")
|
|
283
|
+
with pytest.raises(SystemExit):
|
|
284
|
+
common.load_settings(settings_path)
|
|
285
|
+
assert "not valid JSON" in capsys.readouterr().err
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_serialize_settings_to_json_text_sorts_keys() -> None:
|
|
289
|
+
serialized_text = common.serialize_settings_to_json_text({"b": 1, "a": 2})
|
|
290
|
+
assert serialized_text.index('"a"') < serialized_text.index('"b"')
|
|
291
|
+
assert json.loads(serialized_text) == {"a": 2, "b": 1}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_append_if_missing_appends_only_new_values() -> None:
|
|
295
|
+
all_entries: list[object] = ["existing"]
|
|
296
|
+
assert common.append_if_missing(all_entries, "fresh") is True
|
|
297
|
+
assert common.append_if_missing(all_entries, "fresh") is False
|
|
298
|
+
assert all_entries == ["existing", "fresh"]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_ensure_dict_section_creates_missing_and_returns_existing() -> None:
|
|
302
|
+
all_settings: dict[str, object] = {}
|
|
303
|
+
created_section = common.ensure_dict_section(all_settings, "permissions")
|
|
304
|
+
assert created_section == {}
|
|
305
|
+
assert all_settings["permissions"] is created_section
|
|
306
|
+
assert common.ensure_dict_section(all_settings, "permissions") is created_section
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_ensure_dict_section_exits_on_non_dict_value(
|
|
310
|
+
capsys: pytest.CaptureFixture[str],
|
|
311
|
+
) -> None:
|
|
312
|
+
all_settings: dict[str, object] = {"permissions": "oops"}
|
|
313
|
+
with pytest.raises(SystemExit):
|
|
314
|
+
common.ensure_dict_section(all_settings, "permissions")
|
|
315
|
+
assert "not a JSON object" in capsys.readouterr().err
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_ensure_list_entry_creates_missing_and_returns_existing() -> None:
|
|
319
|
+
permissions_section: dict[str, object] = {}
|
|
320
|
+
created_entry = common.ensure_list_entry(permissions_section, "allow")
|
|
321
|
+
assert created_entry == []
|
|
322
|
+
assert permissions_section["allow"] is created_entry
|
|
323
|
+
assert common.ensure_list_entry(permissions_section, "allow") is created_entry
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_ensure_list_entry_exits_on_non_list_value(
|
|
327
|
+
capsys: pytest.CaptureFixture[str],
|
|
328
|
+
) -> None:
|
|
329
|
+
permissions_section: dict[str, object] = {"allow": {}}
|
|
330
|
+
with pytest.raises(SystemExit):
|
|
331
|
+
common.ensure_list_entry(permissions_section, "allow")
|
|
332
|
+
assert "not a JSON array" in capsys.readouterr().err
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_prune_empty_list_then_empty_section_removes_empty_structures() -> None:
|
|
336
|
+
all_settings: dict[str, object] = {"permissions": {"allow": []}}
|
|
337
|
+
common.prune_empty_list_then_empty_section(all_settings, "permissions", "allow")
|
|
338
|
+
assert all_settings == {}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_prune_empty_list_then_empty_section_keeps_populated_structures() -> None:
|
|
342
|
+
all_settings: dict[str, object] = {
|
|
343
|
+
"permissions": {"allow": ["Edit(/proj/.claude/**)"], "deny": []}
|
|
344
|
+
}
|
|
345
|
+
common.prune_empty_list_then_empty_section(all_settings, "permissions", "deny")
|
|
346
|
+
assert all_settings == {"permissions": {"allow": ["Edit(/proj/.claude/**)"]}}
|
|
347
|
+
|
|
@@ -355,6 +355,24 @@ def test_is_trust_entry_rejects_cross_project_path_suffix_collision() -> None:
|
|
|
355
355
|
)
|
|
356
356
|
|
|
357
357
|
|
|
358
|
+
def test_is_valid_project_root_shared_by_grant_and_revoke_modules(
|
|
359
|
+
tmp_path: Path,
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Grant and revoke import the same helper, so both agree on the marker contract."""
|
|
362
|
+
grant_module = _load_grant_module()
|
|
363
|
+
revoke_module = _load_revoke_module()
|
|
364
|
+
git_marker_project_root = tmp_path / "git_project"
|
|
365
|
+
(git_marker_project_root / ".git").mkdir(parents=True)
|
|
366
|
+
claude_marker_project_root = tmp_path / "claude_project"
|
|
367
|
+
(claude_marker_project_root / ".claude").mkdir(parents=True)
|
|
368
|
+
bare_directory = tmp_path / "no_marker"
|
|
369
|
+
bare_directory.mkdir()
|
|
370
|
+
for each_module in (grant_module, revoke_module):
|
|
371
|
+
assert each_module.is_valid_project_root(git_marker_project_root) is True
|
|
372
|
+
assert each_module.is_valid_project_root(claude_marker_project_root) is True
|
|
373
|
+
assert each_module.is_valid_project_root(bare_directory) is False
|
|
374
|
+
|
|
375
|
+
|
|
358
376
|
def test_second_grant_is_idempotent_when_no_other_settings_changed(
|
|
359
377
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
360
378
|
) -> None:
|