claude-dev-env 2.4.0 → 2.7.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 +26 -59
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +6 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/CLAUDE.md +3 -2
- package/agents/caveman.md +0 -1
- package/agents/clasp-deployment-orchestrator.md +0 -1
- package/agents/clean-coder.md +0 -1
- package/agents/code-advisor.md +0 -1
- package/agents/code-quality-agent.md +1 -2
- package/agents/code-verifier.md +36 -8
- package/agents/deep-research.md +0 -1
- package/agents/docs-agent.md +0 -1
- package/agents/git-commit-crafter.md +0 -1
- package/agents/issue-tracker.md +42 -0
- package/agents/plan-packet-validator.md +0 -1
- package/agents/pr-description-writer.md +0 -1
- package/agents/test_agent_frontmatter.py +67 -18
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
- package/bin/CLAUDE.md +68 -5
- package/bin/codex-compat.mjs +104 -0
- package/bin/codex-compat.test.mjs +51 -0
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install-constants.mjs +88 -0
- package/bin/install.mjs +1138 -114
- package/bin/install.prune.test.mjs +869 -19
- package/bin/install.test.mjs +906 -2
- package/codex-capability-map.json +13 -0
- package/commands/implement.md +1 -1
- package/commands/right-size.md +1 -1
- package/docs/CLAUDE.md +1 -0
- package/docs/CODE_RULES.md +2 -0
- package/docs/codex-compatibility.md +25 -0
- package/docs/host-pool-health-monitor.md +102 -0
- package/docs/nas-ssh-invocation.md +96 -12
- package/docs/references/CLAUDE.md +4 -2
- package/docs/references/advisor-tool.md +13 -0
- package/docs/references/code-review-enforcement.md +35 -0
- package/docs/references/team-advisor-skill.md +14 -0
- package/hooks/blocking/CLAUDE.md +4 -0
- package/hooks/blocking/code_review_pr_create_gate.py +7 -3
- package/hooks/blocking/code_review_push_gate.py +9 -4
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
- package/hooks/blocking/config/__init__.py +5 -5
- package/hooks/blocking/config/code_review_enforcement_constants.py +40 -7
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +58 -0
- package/hooks/blocking/config/verified_commit_constants.py +160 -159
- package/hooks/blocking/eli11_reply_enforcer.py +479 -0
- package/hooks/blocking/gh_body_arg_blocker.py +1 -1
- package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
- package/hooks/blocking/shell_substitution_blocker.py +129 -0
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/stop_dispatcher.py +1 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
- package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
- package/hooks/blocking/test_code_review_push_gate.py +16 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
- package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
- package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
- package/hooks/blocking/test_stop_dispatcher.py +23 -0
- package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +804 -808
- package/hooks/blocking/test_verification_verdict_store.py +54 -0
- package/hooks/blocking/test_verified_commit_gate.py +581 -581
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
- package/hooks/blocking/unscoped_search_blocker.py +391 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
- package/hooks/blocking/verification_verdict_store.py +1039 -1036
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
- package/hooks/blocking/verifier_verdict_minter.py +280 -280
- package/hooks/git-hooks/CLAUDE.md +3 -0
- package/hooks/git-hooks/conftest.py +30 -0
- package/hooks/git-hooks/gate_utils.py +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
- package/hooks/git-hooks/pre_push.py +75 -4
- package/hooks/git-hooks/pre_push_base_reference.py +166 -0
- package/hooks/git-hooks/test_config.py +0 -15
- package/hooks/git-hooks/test_gate_utils.py +3 -15
- package/hooks/git-hooks/test_pre_commit.py +1 -15
- package/hooks/git-hooks/test_pre_push.py +257 -23
- package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
- package/hooks/hooks.json +10 -12
- package/hooks/hooks_constants/CLAUDE.md +7 -2
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
- package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
- package/hooks/hooks_constants/shell_command_segments.py +82 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
- package/hooks/pyproject.toml +1 -0
- package/hooks/validators/CLAUDE.md +1 -0
- package/hooks/validators/config/__init__.py +0 -0
- package/hooks/validators/config/directory_exemption_constants.py +183 -0
- package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
- package/hooks/validators/conftest.py +4 -0
- package/hooks/validators/ruff_integration.py +49 -5
- package/hooks/validators/run_all_validators.py +206 -9
- package/hooks/validators/test_directory_exemption_constants.py +185 -0
- package/hooks/validators/test_python_antipattern_checks.py +110 -5
- package/hooks/validators/test_ruff_integration.py +92 -1
- package/hooks/validators/test_run_all_validators.py +115 -68
- package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
- package/package.json +13 -3
- package/rules/CLAUDE.md +17 -22
- package/rules/agent-spawn-protocol.md +6 -6
- package/rules/anti-corollary-tests.md +1 -1
- package/rules/bdd.md +1 -1
- package/rules/cleanup-temp-files.md +10 -4
- package/rules/code-standards.md +7 -0
- package/rules/conservative-action.md +1 -5
- package/rules/context7.md +0 -4
- package/rules/destructive-commands.md +47 -0
- package/rules/doc-inventory-integrity.md +48 -0
- package/rules/doc-prose-cuts.md +58 -0
- package/rules/docstring-prose-matches-implementation.md +53 -44
- package/rules/durable-post-artifacts.md +0 -4
- package/rules/eli11-replies.md +31 -0
- package/rules/explore-thoroughly.md +4 -4
- package/rules/falsify-before-green.md +68 -0
- package/rules/file-global-constants.md +1 -1
- package/rules/filesystem-search.md +51 -0
- package/rules/gh-cli-conventions.md +27 -0
- package/rules/git-workflow.md +26 -0
- package/rules/hedging-claims.md +9 -0
- package/rules/long-horizon-autonomy.md +0 -4
- package/rules/measurement-denominators.md +48 -0
- package/rules/nas-ssh-invocation.md +23 -5
- package/rules/parallel-tools.md +2 -2
- package/rules/plain-illustrative-docstrings.md +3 -7
- package/rules/plain-language.md +2 -0
- package/rules/proof-of-work-pr-comments.md +0 -4
- package/rules/re-stage-before-commit.md +2 -0
- package/rules/research-mode.md +10 -0
- package/rules/shell-invocation.md +21 -0
- package/rules/testing.md +4 -0
- package/rules/verified-commit-gate-skip.md +3 -27
- package/rules/verify-before-asking.md +5 -0
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +4 -0
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Capture-PoolHealth.ps1 +410 -0
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/_code_review_test_support.py +404 -0
- package/scripts/claude_chain_runner.py +141 -1
- package/scripts/codex_capability_bridge.py +171 -0
- package/scripts/codex_compat_materializer.py +1087 -0
- package/scripts/codex_compat_watcher.py +502 -0
- package/scripts/conftest.py +16 -1
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
- package/scripts/invoke_code_review.py +11 -4
- package/scripts/resolve_worker_spawn.py +626 -626
- package/scripts/spawn_grok_batch.py +672 -672
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/test_claude_chain_runner.py +131 -0
- package/scripts/test_invoke_code_review.py +85 -908
- package/scripts/test_invoke_code_review_chain.py +70 -0
- package/scripts/test_invoke_code_review_cli.py +192 -0
- package/scripts/test_invoke_code_review_contract.py +256 -0
- package/scripts/test_invoke_code_review_git.py +123 -0
- package/scripts/test_invoke_code_review_mode.py +99 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -1014
- package/scripts/tests/test_code_review_constants.py +80 -0
- package/scripts/tests/test_codex_capability_bridge.py +91 -0
- package/scripts/tests/test_codex_compat_materializer.py +632 -0
- package/scripts/tests/test_codex_compat_watcher.py +599 -0
- package/scripts/tests/test_sync_to_cursor.py +0 -1
- package/skills/CLAUDE.md +2 -0
- package/skills/auditing-claude-config/SKILL.md +114 -114
- package/skills/autoconverge/SKILL.md +427 -427
- package/skills/autoconverge/reference/convergence.md +24 -3
- package/skills/autoconverge/workflow/CLAUDE.md +1 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
- package/skills/autoconverge/workflow/converge.mjs +168 -1
- package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
- package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
- package/skills/closeout/SKILL.md +33 -50
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
- package/skills/codex-review/scripts/run_codex_review.py +233 -1
- package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
- package/skills/condensing-instructions/SKILL.md +72 -0
- package/skills/copilot-review/SKILL.md +119 -119
- package/skills/e-code-review/SKILL.md +52 -0
- package/skills/e-code-review/reference/fix.md +54 -0
- package/skills/e-code-review/reference/loop.md +43 -0
- package/skills/e-code-review/reference/low.md +57 -0
- package/skills/e-code-review/reference/medium.md +153 -0
- package/skills/e-code-review/reference/xhigh.md +182 -0
- package/skills/e-simplify/SKILL.md +97 -0
- package/skills/fresh-branch/CLAUDE.md +1 -1
- package/skills/fresh-branch/SKILL.md +5 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
- package/skills/issue-tracker/SKILL.md +92 -0
- package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
- package/skills/issue-tracker/reference/handoff-schema.md +64 -0
- package/skills/issue-tracker/reference/operation-matrix.md +41 -0
- package/skills/orchestrator/SKILL.md +177 -22
- package/skills/orchestrator/scripts/status_gate.py +625 -0
- package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
- package/skills/orchestrator/scripts/test_status_gate.py +439 -0
- package/skills/orchestrator-refresh/SKILL.md +129 -35
- package/skills/plan-to-pr/SKILL.md +155 -0
- package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
- package/skills/plan-to-pr/reference/model-routing.md +36 -0
- package/skills/plan-to-pr/reference/packet-contract.md +43 -0
- package/skills/plan-to-pr/reference/packet-schema.json +57 -0
- package/skills/plan-to-pr/reference/process-inventory.md +22 -0
- package/skills/plan-to-pr/reference/review-loop.md +33 -0
- package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
- package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
- package/skills/plan-to-pr/reference/task-seeds.md +14 -0
- package/skills/plan-to-pr/reference/task-ticket.md +38 -0
- package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
- package/skills/plan-to-pr/scripts/config/constants.py +193 -0
- package/skills/plan-to-pr/scripts/create_packet.py +173 -0
- package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
- package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
- package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
- package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
- package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
- package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
- package/skills/plan-to-pr/scripts/validate_run.py +173 -0
- package/skills/plan-to-pr/test_skill_contract.py +207 -0
- package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
- package/skills/pr-converge/SKILL.md +472 -469
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +7 -4
- package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/pr-fix-protocol/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -68
- package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/skills/prototype/workflows/promotion.md +1 -1
- package/skills/release-notes-html/SKILL.md +164 -0
- package/skills/session-log/SKILL.md +1 -1
- package/skills/task-build/CLAUDE.md +8 -7
- package/skills/task-build/SKILL.md +16 -8
- package/skills/task-build/reference/tool-routing.md +19 -0
- package/rules/claude-md-orphan-file.md +0 -28
- package/rules/cleanup-command-forms.md +0 -23
- package/rules/code-reviews.md +0 -11
- package/rules/env-var-table-code-drift.md +0 -10
- package/rules/gh-body-file.md +0 -5
- package/rules/gh-paginate.md +0 -3
- package/rules/hook-prose-matches-detector.md +0 -15
- package/rules/no-historical-clutter.md +0 -26
- package/rules/no-inline-destructive-literals.md +0 -9
- package/rules/no-justification-noise.md +0 -61
- package/rules/package-inventory-stale-entry.md +0 -25
- package/rules/right-sized-engineering.md +0 -28
- package/rules/self-contained-docs.md +0 -17
- package/rules/shell-invocation-policy.md +0 -5
- package/rules/tdd.md +0 -7
- package/skills/closeout/reference/issue-body-templates.md +0 -108
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Safe, optional link planning and debounced compatibility reconciliation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import filecmp
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import threading
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Callable, Iterable, Literal
|
|
15
|
+
|
|
16
|
+
from codex_compat_materializer import (
|
|
17
|
+
MaterializerConfig,
|
|
18
|
+
MaterializerError,
|
|
19
|
+
build_plan,
|
|
20
|
+
publish_plan,
|
|
21
|
+
validate_target_path,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
LinkKind = Literal["symlink", "junction", "copy", "materialize"]
|
|
25
|
+
LinkClassification = Literal["byte_compatible", "source_only", "unsupported"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _WatcherConfiguration:
|
|
29
|
+
materialize_link_kind = "materialize"
|
|
30
|
+
copy_link_kind = "copy"
|
|
31
|
+
symlink_link_kind = "symlink"
|
|
32
|
+
junction_link_kind = "junction"
|
|
33
|
+
all_configured_root_names = ("agents", "commands", "hooks", "rules", "skills")
|
|
34
|
+
all_unsafe_event_kinds = {"overflow", "ambiguous", "inaccessible", "error", "unknown"}
|
|
35
|
+
reparse_point_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class LinkSpec:
|
|
40
|
+
source: str
|
|
41
|
+
target: str
|
|
42
|
+
is_byte_compatible: bool = True
|
|
43
|
+
classification: LinkClassification | None = None
|
|
44
|
+
requested_kind: LinkKind = "symlink"
|
|
45
|
+
should_activate_hook: bool = False
|
|
46
|
+
should_activate_script: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class LinkDecision:
|
|
51
|
+
source: str
|
|
52
|
+
target: str
|
|
53
|
+
kind: LinkKind
|
|
54
|
+
action: str
|
|
55
|
+
reason: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class LinkPlan:
|
|
60
|
+
decisions: list[LinkDecision] = field(default_factory=list)
|
|
61
|
+
errors: list[str] = field(default_factory=list)
|
|
62
|
+
is_dry_run: bool = True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class LinkReport:
|
|
67
|
+
decisions: list[LinkDecision] = field(default_factory=list)
|
|
68
|
+
applied: int = 0
|
|
69
|
+
skipped: int = 0
|
|
70
|
+
errors: list[str] = field(default_factory=list)
|
|
71
|
+
is_reconcile_required: bool = False
|
|
72
|
+
reconcile_reasons: list[str] = field(default_factory=list)
|
|
73
|
+
reconcile_paths: list[str] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def reconcile_required(self) -> bool:
|
|
77
|
+
return self.is_reconcile_required
|
|
78
|
+
|
|
79
|
+
@reconcile_required.setter
|
|
80
|
+
def reconcile_required(self, is_required: bool) -> None:
|
|
81
|
+
self.is_reconcile_required = is_required
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class LinkCapabilities:
|
|
86
|
+
symlink: bool
|
|
87
|
+
junction: bool
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class WatchEvent:
|
|
92
|
+
source_root: str
|
|
93
|
+
kind: str = "changed"
|
|
94
|
+
is_self_event: bool = False
|
|
95
|
+
path: str = ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def probe_link_capabilities(
|
|
99
|
+
symlink_probe: Callable[[], bool] | None = None,
|
|
100
|
+
junction_probe: Callable[[], bool] | None = None,
|
|
101
|
+
should_allow_temporary_probe: bool = False,
|
|
102
|
+
) -> LinkCapabilities:
|
|
103
|
+
"""Probe link support while preserving a conservative default.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
symlink_probe: Optional injected symlink capability probe.
|
|
107
|
+
junction_probe: Optional injected junction capability probe.
|
|
108
|
+
should_allow_temporary_probe: Whether injected probes may be executed.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The observed capabilities, or unavailable capabilities by default.
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
if not should_allow_temporary_probe:
|
|
115
|
+
return LinkCapabilities(False, False)
|
|
116
|
+
if symlink_probe is None or junction_probe is None:
|
|
117
|
+
return LinkCapabilities(False, False)
|
|
118
|
+
return LinkCapabilities(
|
|
119
|
+
symlink_probe(),
|
|
120
|
+
junction_probe(),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _select_kind(spec: LinkSpec, capabilities: LinkCapabilities) -> tuple[LinkKind, str]:
|
|
125
|
+
classification = spec.classification or ("byte_compatible" if spec.is_byte_compatible else "source_only")
|
|
126
|
+
if classification == "source_only":
|
|
127
|
+
return _WatcherConfiguration.materialize_link_kind, "converted asset requires materializer"
|
|
128
|
+
if classification == "unsupported":
|
|
129
|
+
return _WatcherConfiguration.materialize_link_kind, "unsupported asset cannot be linked"
|
|
130
|
+
if spec.should_activate_hook or spec.should_activate_script:
|
|
131
|
+
return _WatcherConfiguration.copy_link_kind, "activation is prohibited"
|
|
132
|
+
if spec.requested_kind == _WatcherConfiguration.junction_link_kind and capabilities.junction:
|
|
133
|
+
return _WatcherConfiguration.junction_link_kind, "requested capability available"
|
|
134
|
+
if spec.requested_kind in (_WatcherConfiguration.junction_link_kind, _WatcherConfiguration.symlink_link_kind) and capabilities.symlink:
|
|
135
|
+
return _WatcherConfiguration.symlink_link_kind, "requested capability unavailable; symlink available"
|
|
136
|
+
return _WatcherConfiguration.copy_link_kind, "link capability unavailable"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _validate_source_path(source_root: Path, source_name: str) -> Path:
|
|
140
|
+
normalized_source = source_name.replace("\\", "/")
|
|
141
|
+
if Path(normalized_source).is_absolute():
|
|
142
|
+
raise ValueError("rooted path is not allowed")
|
|
143
|
+
return validate_target_path(source_root, source_name)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _validate_source_root(source_root: Path) -> Path:
|
|
147
|
+
"""Reject link-like roots and return the canonical source root."""
|
|
148
|
+
expanded_root = source_root.expanduser()
|
|
149
|
+
if expanded_root.is_symlink() or (
|
|
150
|
+
expanded_root.exists() and _is_reparse_point(expanded_root)
|
|
151
|
+
):
|
|
152
|
+
raise ValueError("source_root_path cannot be a symlink or reparse point")
|
|
153
|
+
return expanded_root.resolve()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _validate_publication_source(source_path: Path) -> None:
|
|
157
|
+
if source_path.is_symlink():
|
|
158
|
+
raise ValueError("publication source cannot be a symlink or reparse point")
|
|
159
|
+
if not source_path.exists():
|
|
160
|
+
raise ValueError("publication source does not exist")
|
|
161
|
+
if _is_reparse_point(source_path):
|
|
162
|
+
raise ValueError("publication source cannot be a symlink or reparse point")
|
|
163
|
+
if not source_path.is_dir():
|
|
164
|
+
return
|
|
165
|
+
for each_path in source_path.rglob("*"):
|
|
166
|
+
if each_path.is_symlink() or _is_reparse_point(each_path):
|
|
167
|
+
raise ValueError("publication source tree contains a symlink or reparse point")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def build_link_plan(
|
|
171
|
+
all_specs: Iterable[LinkSpec],
|
|
172
|
+
source_root: Path,
|
|
173
|
+
target_root: Path,
|
|
174
|
+
capabilities: LinkCapabilities | None = None,
|
|
175
|
+
should_apply: bool = False,
|
|
176
|
+
should_allow_links: bool = False,
|
|
177
|
+
should_allow_activation: bool = False,
|
|
178
|
+
symlink_probe: Callable[[], bool] | None = None,
|
|
179
|
+
junction_probe: Callable[[], bool] | None = None,
|
|
180
|
+
should_allow_temporary_probe: bool = False,
|
|
181
|
+
) -> LinkPlan:
|
|
182
|
+
"""Build a relative-path-only plan without changing the filesystem.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
all_specs: Link specifications to classify.
|
|
186
|
+
source_root: Source assets are read beneath this source_root_path.
|
|
187
|
+
target_root: Root receiving managed targets.
|
|
188
|
+
capabilities: Optional precomputed link capabilities.
|
|
189
|
+
should_apply: Whether eligible decisions may be authorized.
|
|
190
|
+
should_allow_links: Whether link publication is authorized.
|
|
191
|
+
should_allow_activation: Whether hook or script activation is authorized.
|
|
192
|
+
symlink_probe: Optional injected symlink capability probe.
|
|
193
|
+
junction_probe: Optional injected junction capability probe.
|
|
194
|
+
should_allow_temporary_probe: Whether injected probes may be executed.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
A plan containing decisions and validation errors.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
ValueError: If a source path is rooted.
|
|
201
|
+
"""
|
|
202
|
+
plan = LinkPlan(is_dry_run=not should_apply)
|
|
203
|
+
try:
|
|
204
|
+
source_base = _validate_source_root(source_root)
|
|
205
|
+
except ValueError as error:
|
|
206
|
+
plan.errors.append(str(error))
|
|
207
|
+
return plan
|
|
208
|
+
target_base = target_root.expanduser().resolve()
|
|
209
|
+
if source_base == target_base or source_base in target_base.parents or target_base in source_base.parents:
|
|
210
|
+
plan.errors.append("source and target roots overlap")
|
|
211
|
+
return plan
|
|
212
|
+
available = capabilities or probe_link_capabilities(
|
|
213
|
+
symlink_probe=symlink_probe,
|
|
214
|
+
junction_probe=junction_probe,
|
|
215
|
+
should_allow_temporary_probe=should_allow_temporary_probe,
|
|
216
|
+
)
|
|
217
|
+
all_seen_targets: set[str] = set()
|
|
218
|
+
for each_spec in all_specs:
|
|
219
|
+
source_name = each_spec.source
|
|
220
|
+
try:
|
|
221
|
+
_validate_source_path(source_base, source_name)
|
|
222
|
+
except ValueError as error:
|
|
223
|
+
if Path(source_name.replace("\\", "/")).is_absolute():
|
|
224
|
+
raise
|
|
225
|
+
plan.errors.append(str(error))
|
|
226
|
+
continue
|
|
227
|
+
try:
|
|
228
|
+
target_name = each_spec.target
|
|
229
|
+
target_path = validate_target_path(target_base, target_name)
|
|
230
|
+
target_key = target_path.as_posix().casefold()
|
|
231
|
+
if target_key in all_seen_targets or target_path.exists() or target_path.is_symlink():
|
|
232
|
+
raise ValueError("unmanaged target collision")
|
|
233
|
+
all_seen_targets.add(target_key)
|
|
234
|
+
selected_kind, reason = _select_kind(each_spec, available)
|
|
235
|
+
is_linkable = (each_spec.classification or ("byte_compatible" if each_spec.is_byte_compatible else "source_only")) == "byte_compatible"
|
|
236
|
+
can_apply = is_linkable and should_apply and should_allow_links and (should_allow_activation or not (each_spec.should_activate_hook or each_spec.should_activate_script))
|
|
237
|
+
plan.decisions.append(LinkDecision(source_name, target_name, selected_kind, "apply" if can_apply else "report", reason))
|
|
238
|
+
except (OSError, ValueError, MaterializerError) as error:
|
|
239
|
+
plan.errors.append(str(error))
|
|
240
|
+
return plan
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _apply_decision(
|
|
244
|
+
decision: LinkDecision,
|
|
245
|
+
source_root: Path,
|
|
246
|
+
target_root: Path,
|
|
247
|
+
copier: Callable[[Path, Path], None],
|
|
248
|
+
symlink_creator: Callable[[Path, Path, bool], None],
|
|
249
|
+
junction_creator: Callable[[Path, Path], None],
|
|
250
|
+
) -> None:
|
|
251
|
+
source_path = _validate_source_path(source_root, decision.source)
|
|
252
|
+
target_path = validate_target_path(target_root, decision.target)
|
|
253
|
+
if decision.kind == "symlink":
|
|
254
|
+
symlink_creator(source_path, target_path, source_path.is_dir())
|
|
255
|
+
return
|
|
256
|
+
if decision.kind == "junction":
|
|
257
|
+
junction_creator(source_path, target_path)
|
|
258
|
+
return
|
|
259
|
+
copier(source_path, target_path)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _report_unsupported_junction(source_path: Path, target_path: Path) -> None:
|
|
263
|
+
raise RuntimeError("Windows junction creation is unsupported without an injected junction creator")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _is_reparse_point(file_path: Path) -> bool:
|
|
267
|
+
file_stat = file_path.stat(follow_symlinks=False)
|
|
268
|
+
return bool(
|
|
269
|
+
getattr(file_stat, "st_file_attributes", 0)
|
|
270
|
+
& _WatcherConfiguration.reparse_point_attribute
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _validate_copy_source(source_path: Path) -> None:
|
|
275
|
+
if source_path.is_symlink() or _is_reparse_point(source_path):
|
|
276
|
+
raise ValueError("copy source cannot be a symlink or reparse point")
|
|
277
|
+
if source_path.is_file():
|
|
278
|
+
return
|
|
279
|
+
if not source_path.is_dir():
|
|
280
|
+
raise ValueError("copy source must be a regular file or directory")
|
|
281
|
+
for each_path in source_path.rglob("*"):
|
|
282
|
+
if each_path.is_symlink() or _is_reparse_point(each_path):
|
|
283
|
+
raise ValueError("copy source tree contains a symlink or reparse point")
|
|
284
|
+
if not each_path.is_file() and not each_path.is_dir():
|
|
285
|
+
raise ValueError("copy source tree contains a non-regular entry")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _verify_copy(source_path: Path, target_path: Path) -> bool:
|
|
289
|
+
if source_path.is_file():
|
|
290
|
+
return target_path.is_file() and filecmp.cmp(source_path, target_path, shallow=False)
|
|
291
|
+
if not target_path.is_dir():
|
|
292
|
+
return False
|
|
293
|
+
all_source_files = {each_path.relative_to(source_path) for each_path in source_path.rglob("*") if each_path.is_file()}
|
|
294
|
+
all_target_files = {each_path.relative_to(target_path) for each_path in target_path.rglob("*") if each_path.is_file()}
|
|
295
|
+
return all_source_files == all_target_files and all(
|
|
296
|
+
filecmp.cmp(source_path / each_file, target_path / each_file, shallow=False)
|
|
297
|
+
for each_file in all_source_files
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _copy_asset(source_path: Path, target_path: Path) -> None:
|
|
302
|
+
_validate_copy_source(source_path)
|
|
303
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
if target_path.exists() or target_path.is_symlink():
|
|
305
|
+
raise FileExistsError(target_path)
|
|
306
|
+
with tempfile.TemporaryDirectory(dir=target_path.parent) as staging_directory:
|
|
307
|
+
staging_path = Path(staging_directory) / target_path.name
|
|
308
|
+
if source_path.is_file():
|
|
309
|
+
shutil.copy2(source_path, staging_path)
|
|
310
|
+
else:
|
|
311
|
+
shutil.copytree(source_path, staging_path, symlinks=False)
|
|
312
|
+
if not _verify_copy(source_path, staging_path):
|
|
313
|
+
raise OSError("copied asset failed content verification")
|
|
314
|
+
os.rename(staging_path, target_path)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def publish_link_plan(
|
|
318
|
+
plan: LinkPlan,
|
|
319
|
+
source_root: Path,
|
|
320
|
+
target_root: Path,
|
|
321
|
+
copier: Callable[[Path, Path], None] | None = None,
|
|
322
|
+
symlink_creator: Callable[[Path, Path, bool], None] | None = None,
|
|
323
|
+
junction_creator: Callable[[Path, Path], None] | None = None,
|
|
324
|
+
materializer: Callable[[], object] | None = None,
|
|
325
|
+
) -> LinkReport:
|
|
326
|
+
"""Publish explicitly authorized decisions and report failures safely.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
plan: Decisions produced by ``build_link_plan``.
|
|
330
|
+
source_root: Source assets are read beneath this source_root_path.
|
|
331
|
+
target_root: Root receiving managed targets.
|
|
332
|
+
copier: Optional injected copy operation.
|
|
333
|
+
symlink_creator: Optional injected symlink operation.
|
|
334
|
+
junction_creator: Optional injected junction operation.
|
|
335
|
+
materializer: Optional callback for materialization decisions.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
A report of applied, skipped, and failed decisions.
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
ValueError: If a decision contains an unsafe rooted path.
|
|
342
|
+
"""
|
|
343
|
+
report = LinkReport(list(plan.decisions), errors=list(plan.errors), is_reconcile_required=bool(plan.errors))
|
|
344
|
+
try:
|
|
345
|
+
source_base = _validate_source_root(source_root)
|
|
346
|
+
except ValueError as error:
|
|
347
|
+
report.errors.append(str(error))
|
|
348
|
+
report.is_reconcile_required = True
|
|
349
|
+
return report
|
|
350
|
+
copy_asset = copier or _copy_asset
|
|
351
|
+
create_symlink = symlink_creator or os.symlink
|
|
352
|
+
create_junction = junction_creator or _report_unsupported_junction
|
|
353
|
+
for each_decision in plan.decisions:
|
|
354
|
+
if each_decision.action != "apply":
|
|
355
|
+
report.skipped += 1
|
|
356
|
+
continue
|
|
357
|
+
try:
|
|
358
|
+
source_path = _validate_source_path(source_base, each_decision.source)
|
|
359
|
+
_validate_publication_source(source_path)
|
|
360
|
+
if each_decision.kind == _WatcherConfiguration.materialize_link_kind:
|
|
361
|
+
if materializer is None:
|
|
362
|
+
raise RuntimeError("materializer callback is required")
|
|
363
|
+
materializer()
|
|
364
|
+
else:
|
|
365
|
+
_apply_decision(each_decision, source_base, target_root, copy_asset, create_symlink, create_junction)
|
|
366
|
+
report.applied += 1
|
|
367
|
+
except (OSError, RuntimeError, ValueError) as error:
|
|
368
|
+
report.errors.append(str(error))
|
|
369
|
+
report.is_reconcile_required = True
|
|
370
|
+
report.reconcile_reasons.append(type(error).__name__)
|
|
371
|
+
report.reconcile_paths.append(each_decision.target)
|
|
372
|
+
return report
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class DebouncedReconciler:
|
|
376
|
+
"""Coalesce scoped watcher events and serialize reconciliation callbacks.
|
|
377
|
+
|
|
378
|
+
The reconciler accepts events from five configured source roots, suppresses
|
|
379
|
+
self-generated events first, and adds sorted unsafe-event details to reports.
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
default_debounce_seconds = 0.25
|
|
383
|
+
|
|
384
|
+
def __init__(
|
|
385
|
+
self,
|
|
386
|
+
reconcile: Callable[[], object],
|
|
387
|
+
debounce_seconds: float | None = None,
|
|
388
|
+
all_configured_roots: Iterable[str] | None = None,
|
|
389
|
+
) -> None:
|
|
390
|
+
self._reconcile = reconcile
|
|
391
|
+
self._debounce_seconds = (
|
|
392
|
+
debounce_seconds
|
|
393
|
+
if debounce_seconds is not None
|
|
394
|
+
else self.default_debounce_seconds
|
|
395
|
+
)
|
|
396
|
+
self._lock = threading.Lock()
|
|
397
|
+
self._pending = False
|
|
398
|
+
self._last_event = 0.0
|
|
399
|
+
self._running = False
|
|
400
|
+
self._stopped = False
|
|
401
|
+
self._reconcile_reasons: list[str] = []
|
|
402
|
+
self._reconcile_paths: list[str] = []
|
|
403
|
+
self._configured_roots = self._normalize_configured_roots(
|
|
404
|
+
all_configured_roots or _WatcherConfiguration.all_configured_root_names
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
@staticmethod
|
|
408
|
+
def _normalize_configured_roots(all_configured_roots: Iterable[str]) -> frozenset[str]:
|
|
409
|
+
normalized_roots: set[str] = set()
|
|
410
|
+
for each_root in all_configured_roots:
|
|
411
|
+
normalized_roots.add(each_root.casefold())
|
|
412
|
+
normalized_roots.add(Path(each_root).expanduser().resolve().as_posix().casefold())
|
|
413
|
+
return frozenset(normalized_roots)
|
|
414
|
+
|
|
415
|
+
def start(self) -> None:
|
|
416
|
+
with self._lock:
|
|
417
|
+
self._stopped = False
|
|
418
|
+
|
|
419
|
+
def stop(self) -> None:
|
|
420
|
+
with self._lock:
|
|
421
|
+
self._stopped = True
|
|
422
|
+
self._pending = False
|
|
423
|
+
self._reconcile_reasons.clear()
|
|
424
|
+
self._reconcile_paths.clear()
|
|
425
|
+
|
|
426
|
+
def notify(self, event: WatchEvent) -> None:
|
|
427
|
+
"""Record a scoped filesystem event for later reconciliation.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
event: Filesystem event to coalesce or classify.
|
|
431
|
+
"""
|
|
432
|
+
if event.is_self_event:
|
|
433
|
+
return
|
|
434
|
+
source_root_name = event.source_root.casefold()
|
|
435
|
+
source_root_path = Path(event.source_root).expanduser().resolve().as_posix().casefold()
|
|
436
|
+
if source_root_name not in self._configured_roots and source_root_path not in self._configured_roots:
|
|
437
|
+
return
|
|
438
|
+
if event.kind in _WatcherConfiguration.all_unsafe_event_kinds:
|
|
439
|
+
with self._lock:
|
|
440
|
+
if not self._stopped:
|
|
441
|
+
self._pending = True
|
|
442
|
+
self._reconcile_reasons.append(event.kind)
|
|
443
|
+
self._reconcile_paths.append(event.path or event.source_root)
|
|
444
|
+
self._last_event = time.monotonic()
|
|
445
|
+
return
|
|
446
|
+
with self._lock:
|
|
447
|
+
if not self._stopped:
|
|
448
|
+
self._pending = True
|
|
449
|
+
self._last_event = time.monotonic()
|
|
450
|
+
|
|
451
|
+
def flush(self, should_force: bool = False) -> object | None:
|
|
452
|
+
"""Run the pending reconciliation when debounce and state allow it.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
should_force: Whether to bypass the debounce interval.
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
The reconciliation report, or ``None`` when no run is eligible.
|
|
459
|
+
"""
|
|
460
|
+
with self._lock:
|
|
461
|
+
if self._stopped or not self._pending or self._running or (not should_force and time.monotonic() - self._last_event < self._debounce_seconds):
|
|
462
|
+
return None
|
|
463
|
+
self._pending = False
|
|
464
|
+
self._running = True
|
|
465
|
+
try:
|
|
466
|
+
reconciliation_report = self._reconcile()
|
|
467
|
+
return self._report_with_event_details(reconciliation_report)
|
|
468
|
+
except (OSError, RuntimeError, ValueError) as error:
|
|
469
|
+
return self._report_with_event_details({"reconcile_required": True, "errors": [str(error)]})
|
|
470
|
+
finally:
|
|
471
|
+
with self._lock:
|
|
472
|
+
self._running = False
|
|
473
|
+
|
|
474
|
+
def _report_with_event_details(self, reconciliation_report: object) -> object:
|
|
475
|
+
with self._lock:
|
|
476
|
+
event_reasons = sorted(self._reconcile_reasons)
|
|
477
|
+
event_paths = sorted(self._reconcile_paths)
|
|
478
|
+
self._reconcile_reasons.clear()
|
|
479
|
+
self._reconcile_paths.clear()
|
|
480
|
+
if isinstance(reconciliation_report, dict):
|
|
481
|
+
reconciliation_report = dict(reconciliation_report)
|
|
482
|
+
if event_reasons:
|
|
483
|
+
reconciliation_report["reconcile_required"] = True
|
|
484
|
+
reconciliation_report["reconcile_reasons"] = event_reasons
|
|
485
|
+
reconciliation_report["reconcile_paths"] = event_paths
|
|
486
|
+
return reconciliation_report
|
|
487
|
+
if not event_reasons:
|
|
488
|
+
return reconciliation_report
|
|
489
|
+
return {"reconciliation": reconciliation_report, "reconcile_required": True, "reconcile_reasons": event_reasons, "reconcile_paths": event_paths}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def reconcile_materialized_agents(materializer_config: MaterializerConfig) -> object:
|
|
493
|
+
"""Run the materializer's complete deterministic public reconciliation.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
materializer_config: Configuration for the materialized compatibility files.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
The materializer publication report.
|
|
500
|
+
"""
|
|
501
|
+
planned_files, publication_report = build_plan(materializer_config)
|
|
502
|
+
return publish_plan(materializer_config, planned_files, publication_report)
|
package/scripts/conftest.py
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
"""Put the scripts directory on sys.path
|
|
1
|
+
"""Put the scripts directory on ``sys.path`` for every test in this folder.
|
|
2
|
+
|
|
3
|
+
The code-review invoker and its constants package live beside these tests
|
|
4
|
+
rather than on the default pytest path, so each test module can import them
|
|
5
|
+
by bare name once this directory is registered.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
import invoke_code_review as invoker # resolves here
|
|
10
|
+
from dev_env_scripts_constants... # resolves here
|
|
11
|
+
|
|
12
|
+
pytest loads this file before collecting sibling test modules, so the path
|
|
13
|
+
is in place by the time any test module runs its top-level imports.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
2
17
|
|
|
3
18
|
import sys
|
|
4
19
|
from pathlib import Path
|
|
@@ -11,7 +11,7 @@ Named constants for scripts in `scripts/`. Follows the project convention that t
|
|
|
11
11
|
| `claude_chain_constants.py` | `claude_chain_runner.py` - the chain config filename and home subdirectory, the usage-limit signature text, the per-binary status labels, the default timeout, CLI flag and separator tokens, config JSON keys (including optional `credentials_path`), invalid-shape reason text, config-error and exhausted-chain message templates, and CLI exit codes; `invoke_code_review.py` and `resolve_worker_spawn.py` - the text-codec keyword names and the `collect_forwarded_text_codec` helper that forwards them to the subprocess runner for text-mode capture |
|
|
12
12
|
| `claude_chain_usage_constants.py` | `claude_chain_usage.py` - full weekly percent scale, usage-pause skill path segments, CLI config-path flag, JSON report keys, and probe error message templates |
|
|
13
13
|
| `grok_worker_constants.py` | `grok_worker_preflight.py`, `grok_headless_runner.py`, and `spawn_grok_batch.py` - the `grok` binary name and CLI flags, model and subcommand tokens, leader-socket and scratch-file name parts, auth and usage-limit signature lists, outcome classifications, fallthrough reasons, tool-profile names and prompt headers, timeouts and turn caps (including launch-failure return code and post-kill grace), ping-cache keys and TTL, batch-spec and summary JSON keys, the prompt-part and report-stream join separators, and the CLI launch-error stderr prefix |
|
|
14
|
-
| `code_review_constants.py` | `invoke_code_review.py` - the `/code-review
|
|
14
|
+
| `code_review_constants.py` | `invoke_code_review.py` - the `/code-review ultra --fix` prompt, opus model alias, permission-mode flag and value, result mode and JSON keys, session-model CLI flag, git dirty-check tokens, and in-session return markers |
|
|
15
15
|
| `__init__.py` | Empty package marker |
|
|
16
16
|
|
|
17
17
|
## Convention
|
|
@@ -15,6 +15,15 @@ UTF8_ENCODING: str = "utf-8"
|
|
|
15
15
|
CODEC_ERROR_STRATEGY: str = "replace"
|
|
16
16
|
"""Codec error handler that maps any unencodable or undecodable value to a marker."""
|
|
17
17
|
|
|
18
|
+
CRLF_NEWLINE: str = "\r\n"
|
|
19
|
+
"""Windows-style newline sequence normalized away when decoding spool captures."""
|
|
20
|
+
|
|
21
|
+
CARRIAGE_RETURN: str = "\r"
|
|
22
|
+
"""Bare carriage return normalized to LF when decoding spool captures."""
|
|
23
|
+
|
|
24
|
+
LINE_FEED: str = "\n"
|
|
25
|
+
"""Unix-style newline retained after universal-newline normalization."""
|
|
26
|
+
|
|
18
27
|
SUBPROCESS_ENCODING_KEYWORD: str = "encoding"
|
|
19
28
|
"""Keyword name for text encoding when forwarding chain subprocess runner kwargs."""
|
|
20
29
|
|
|
@@ -15,10 +15,35 @@ Scalar flags, JSON keys, and mint-loop messages live here for the invoker.
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import importlib.util
|
|
18
|
+
import os
|
|
18
19
|
import sys
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from types import ModuleType
|
|
21
22
|
|
|
23
|
+
ROOT_EFFECTIVE_USER_ID: int = 0
|
|
24
|
+
"""Effective user id the review binary treats as root."""
|
|
25
|
+
|
|
26
|
+
NON_ROOT_EFFECTIVE_USER_ID: int = 1000
|
|
27
|
+
"""Effective user id stood in for a platform that reports no user id."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _read_effective_user_id() -> int:
|
|
31
|
+
"""Read this process's effective user id, or a non-root stand-in.
|
|
32
|
+
|
|
33
|
+
Windows exposes no ``os.geteuid``, so reading it there raises and the
|
|
34
|
+
whole constants module fails to import — taking the review invoker with
|
|
35
|
+
it. A caller on such a platform is never the root the review binary
|
|
36
|
+
refuses, so it reads as an ordinary caller.
|
|
37
|
+
"""
|
|
38
|
+
read_effective_user_id = getattr(os, "geteuid", None)
|
|
39
|
+
if read_effective_user_id is None:
|
|
40
|
+
return NON_ROOT_EFFECTIVE_USER_ID
|
|
41
|
+
return int(read_effective_user_id())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
IS_ROOT_CALLER: bool = _read_effective_user_id() == ROOT_EFFECTIVE_USER_ID
|
|
45
|
+
"""Whether this process runs as root, which limits the permission modes."""
|
|
46
|
+
|
|
22
47
|
|
|
23
48
|
def _load_enforcement_constants_module() -> ModuleType:
|
|
24
49
|
"""Load the hooks enforcement constants by explicit file path.
|
|
@@ -80,6 +105,18 @@ PERMISSION_MODE_FLAG: str = "--permission-mode"
|
|
|
80
105
|
PERMISSION_MODE_BYPASS: str = "bypassPermissions"
|
|
81
106
|
"""Permission-mode value that auto-approves tools for unattended chain runs."""
|
|
82
107
|
|
|
108
|
+
PERMISSION_MODE_ACCEPT_EDITS: str = "acceptEdits"
|
|
109
|
+
"""Permission mode asked for when the caller is root.
|
|
110
|
+
|
|
111
|
+
The review binary refuses ``bypassPermissions`` outright for a root caller,
|
|
112
|
+
so asking for it there means no review runs and no stamp is ever minted.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
REVIEW_PERMISSION_MODE: str = (
|
|
116
|
+
PERMISSION_MODE_ACCEPT_EDITS if IS_ROOT_CALLER else PERMISSION_MODE_BYPASS
|
|
117
|
+
)
|
|
118
|
+
"""Permission mode this caller asks the review binary for."""
|
|
119
|
+
|
|
83
120
|
MODE_IN_SESSION: str = "in_session"
|
|
84
121
|
"""Result mode when the host is Claude and the session already runs opus."""
|
|
85
122
|
|
|
@@ -8,7 +8,8 @@ Mode decision::
|
|
|
8
8
|
host=ThirdParty, any model -> mode chain
|
|
9
9
|
|
|
10
10
|
Chain mode runs ``run_claude`` with argv from ``build_code_review_arguments``
|
|
11
|
-
(single-turn prompt, model opus, json output,
|
|
11
|
+
(single-turn prompt, model opus, json output, and the permission mode this
|
|
12
|
+
caller is allowed to ask the review binary for).
|
|
12
13
|
|
|
13
14
|
cwd is the PR working tree and stdin is redirected from the empty stream so
|
|
14
15
|
the spawn does not wait for interactive input. Result JSON on stdout only::
|
|
@@ -80,7 +81,7 @@ from dev_env_scripts_constants.code_review_constants import ( # noqa: E402
|
|
|
80
81
|
MAXIMUM_STAMP_MINT_PASSES,
|
|
81
82
|
MODE_CHAIN,
|
|
82
83
|
MODE_IN_SESSION,
|
|
83
|
-
PERMISSION_MODE_BYPASS,
|
|
84
|
+
REVIEW_PERMISSION_MODE as PERMISSION_MODE_BYPASS,
|
|
84
85
|
PERMISSION_MODE_FLAG,
|
|
85
86
|
RECORD_STAMP_FLAG,
|
|
86
87
|
RESULT_KEY_BOUND_HASH,
|
|
@@ -418,6 +419,7 @@ def is_code_review_clean_stamp_allowed(review_outcome: CodeReviewOutcome) -> boo
|
|
|
418
419
|
return False
|
|
419
420
|
return True
|
|
420
421
|
|
|
422
|
+
|
|
421
423
|
def _run_claude_with_empty_stdin(
|
|
422
424
|
all_claude_arguments: list[str],
|
|
423
425
|
*,
|
|
@@ -495,6 +497,7 @@ def _failure_code_review_outcome(returncode: int) -> CodeReviewOutcome:
|
|
|
495
497
|
is_dirty_tree=False,
|
|
496
498
|
)
|
|
497
499
|
|
|
500
|
+
|
|
498
501
|
def _run_chain_review(
|
|
499
502
|
*,
|
|
500
503
|
working_directory: Path,
|
|
@@ -507,6 +510,8 @@ def _run_chain_review(
|
|
|
507
510
|
timeout_seconds=timeout_seconds,
|
|
508
511
|
working_directory=working_directory,
|
|
509
512
|
)
|
|
513
|
+
if chain_outcome.returncode != SUCCESSFUL_REVIEW_RETURNCODE and chain_outcome.stderr:
|
|
514
|
+
sys.stderr.write(chain_outcome.stderr.strip() + "\n")
|
|
510
515
|
return _chain_outcome(chain_outcome, working_directory=working_directory)
|
|
511
516
|
|
|
512
517
|
|
|
@@ -869,9 +874,11 @@ def _mint_or_config_outcome(
|
|
|
869
874
|
timeout_seconds=timeout_seconds,
|
|
870
875
|
effort=effort,
|
|
871
876
|
)
|
|
872
|
-
except ChainConfigurationError:
|
|
877
|
+
except ChainConfigurationError as configuration_error:
|
|
878
|
+
sys.stderr.write(str(configuration_error) + "\n")
|
|
873
879
|
return _no_mint_outcome(CHAIN_CONFIG_ERROR_EXIT_CODE)
|
|
874
|
-
except ValueError:
|
|
880
|
+
except ValueError as host_profile_error:
|
|
881
|
+
sys.stderr.write(str(host_profile_error) + "\n")
|
|
875
882
|
return _no_mint_outcome(HOST_PROFILE_ERROR_RETURNCODE)
|
|
876
883
|
|
|
877
884
|
|