@homericintelligence/athena-opencode 0.5.0 → 0.5.2
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/README.md +30 -17
- package/package.json +2 -2
- package/plugin.js +20 -5
- package/skills/TECHNICAL_ENGLISH.md +78 -0
- package/skills/_cli.py +33 -22
- package/skills/_plugin.json +1 -0
- package/skills/_support/docs/dependency-resolution.md +203 -0
- package/skills/_support/docs/policies/development.md +131 -0
- package/skills/_support/docs/policies/evidence-integrity.md +40 -0
- package/skills/_support/docs/principles/README.md +565 -0
- package/skills/_support/docs/principles/details/p001-kiss.md +114 -0
- package/skills/_support/docs/principles/details/p002-yagni.md +109 -0
- package/skills/_support/docs/principles/details/p003-dry.md +126 -0
- package/skills/_support/docs/principles/details/p004-solid.md +165 -0
- package/skills/_support/docs/principles/details/p005-modularity.md +123 -0
- package/skills/_support/docs/principles/details/p006-principle-of-least-astonishment.md +115 -0
- package/skills/_support/docs/principles/details/p007-subtraction-over-addition.md +113 -0
- package/skills/_support/docs/principles/details/p008-understand-before-subtracting.md +115 -0
- package/skills/_support/docs/principles/details/p009-general-mechanisms-over-special-cases.md +108 -0
- package/skills/_support/docs/principles/details/p010-scope-fidelity.md +117 -0
- package/skills/_support/docs/principles/details/p011-minimal-coherent-change.md +128 -0
- package/skills/_support/docs/principles/details/p012-evidence-before-modification.md +115 -0
- package/skills/_support/docs/principles/details/p013-avoid-hasty-abstractions.md +112 -0
- package/skills/_support/docs/principles/details/p014-preserve-unrequested-behavior.md +110 -0
- package/skills/_support/docs/principles/details/p015-architecture-conformance.md +124 -0
- package/skills/_support/docs/principles/details/p016-separation-of-concerns.md +125 -0
- package/skills/_support/docs/principles/details/p017-high-cohesion-low-coupling.md +130 -0
- package/skills/_support/docs/principles/details/p018-information-hiding.md +128 -0
- package/skills/_support/docs/principles/details/p019-explicit-contracts.md +134 -0
- package/skills/_support/docs/principles/details/p020-executable-architecture.md +130 -0
- package/skills/_support/docs/principles/details/p021-evolutionary-and-reversible-design.md +126 -0
- package/skills/_support/docs/principles/details/p022-test-behavior-not-implementation.md +129 -0
- package/skills/_support/docs/principles/details/p023-parameterized-table-driven-testing.md +129 -0
- package/skills/_support/docs/principles/details/p024-boundary-value-testing.md +128 -0
- package/skills/_support/docs/principles/details/p025-property-based-testing-for-invariants.md +133 -0
- package/skills/_support/docs/principles/details/p026-regression-before-repair.md +134 -0
- package/skills/_support/docs/principles/details/p027-deterministic-and-hermetic-tests.md +128 -0
- package/skills/_support/docs/principles/details/p028-test-failure-paths.md +133 -0
- package/skills/_support/docs/principles/details/p029-generalize-error-policy-preserve-specific-cause.md +131 -0
- package/skills/_support/docs/principles/details/p030-nearest-responsible-error-boundary.md +133 -0
- package/skills/_support/docs/principles/details/p031-propagate-rather-than-swallow.md +131 -0
- package/skills/_support/docs/principles/details/p032-handle-once-preserve-causality.md +137 -0
- package/skills/_support/docs/principles/details/p033-state-safe-failure-semantics.md +140 -0
- package/skills/_support/docs/principles/details/p034-fail-fast.md +132 -0
- package/skills/_support/docs/principles/details/p035-fail-secure-fail-closed.md +137 -0
- package/skills/_support/docs/principles/details/p036-graceful-degradation.md +137 -0
- package/skills/_support/docs/principles/details/p037-idempotency-before-retry.md +139 -0
- package/skills/_support/docs/principles/details/p038-bounded-retry.md +145 -0
- package/skills/_support/docs/principles/details/p039-bounded-waiting.md +151 -0
- package/skills/_support/docs/principles/details/p040-bounded-resources.md +138 -0
- package/skills/_support/docs/principles/details/p041-backpressure-and-load-shedding.md +141 -0
- package/skills/_support/docs/principles/details/p042-fault-isolation-bulkheads.md +140 -0
- package/skills/_support/docs/principles/details/p043-circuit-breakers.md +148 -0
- package/skills/_support/docs/principles/details/p044-atomicity-where-possible.md +141 -0
- package/skills/_support/docs/principles/details/p045-compensation-where-atomicity-is-impossible.md +163 -0
- package/skills/_support/docs/principles/details/p046-resumability.md +138 -0
- package/skills/_support/docs/principles/details/p047-observability-is-part-of-correctness.md +134 -0
- package/skills/_support/docs/principles/details/p048-secure-by-design.md +132 -0
- package/skills/_support/docs/principles/details/p049-secure-by-default.md +126 -0
- package/skills/_support/docs/principles/details/p050-least-privilege.md +123 -0
- package/skills/_support/docs/principles/details/p051-complete-mediation.md +124 -0
- package/skills/_support/docs/principles/details/p052-separation-of-duties.md +129 -0
- package/skills/_support/docs/principles/details/p053-validate-at-trust-boundaries.md +126 -0
- package/skills/_support/docs/principles/details/p054-defense-in-depth.md +135 -0
- package/skills/_support/docs/principles/details/p055-minimize-attack-surface.md +133 -0
- package/skills/_support/docs/principles/details/p056-secrets-stay-out-of-code-and-context.md +137 -0
- package/skills/_support/docs/principles/details/p057-supply-chain-integrity.md +135 -0
- package/skills/_support/docs/principles/details/p058-bounded-agent-authority.md +137 -0
- package/skills/_support/docs/principles/details/p059-data-is-not-instruction.md +130 -0
- package/skills/_support/docs/principles/details/p060-constrain-sub-agents.md +136 -0
- package/skills/_support/docs/principles/details/p061-separate-decision-from-high-impact-execution.md +131 -0
- package/skills/_support/docs/principles/details/p062-human-approval-for-irreversible-or-high-risk-actions.md +129 -0
- package/skills/_support/docs/principles/details/p063-requirement-to-code-traceability.md +116 -0
- package/skills/_support/docs/principles/details/p064-requirement-to-test-traceability.md +116 -0
- package/skills/_support/docs/principles/details/p065-verify-before-claiming-completion.md +131 -0
- package/skills/_support/docs/principles/details/p066-preserve-existing-work.md +114 -0
- package/skills/_support/docs/principles/details/p067-no-test-cheating.md +126 -0
- package/skills/_support/docs/principles/details/p068-no-validation-bypass.md +119 -0
- package/skills/_support/docs/principles/details/p069-independent-review-for-high-risk-changes.md +129 -0
- package/skills/_support/docs/principles/details/p070-code-health-must-not-regress.md +116 -0
- package/skills/_support/docs/principles/details/p071-consistency-over-personal-preference.md +117 -0
- package/skills/_support/docs/principles/details/p072-technical-evidence-over-preference.md +122 -0
- package/skills/_support/docs/principles/details/p073-optimize-only-with-evidence.md +123 -0
- package/skills/_support/docs/principles/details/p074-prefer-existing-mechanisms.md +121 -0
- package/skills/_support/docs/principles/details/p075-make-invalid-states-hard-to-represent.md +128 -0
- package/skills/_support/docs/principles/details/p076-parse-then-validate-then-operate.md +133 -0
- package/skills/_support/docs/principles/details/p077-separate-policy-from-mechanism.md +112 -0
- package/skills/_support/docs/principles/details/p078-single-source-of-truth.md +131 -0
- package/skills/_support/docs/principles/details/p079-explicit-ownership-and-lifetimes.md +124 -0
- package/skills/_support/docs/principles/details/p080-make-concurrency-deliberate.md +122 -0
- package/skills/_support/docs/principles/details/p081-forward-progress-with-safety.md +125 -0
- package/skills/_support/docs/principles/details/p082-design-for-cancellation.md +133 -0
- package/skills/_support/docs/principles/details/p083-irreversible-actions-last.md +124 -0
- package/skills/_support/docs/principles/details/p084-prefer-local-reasoning.md +131 -0
- package/skills/_support/docs/principles/details/p085-explicit-is-better-than-implicit.md +123 -0
- package/skills/_support/docs/principles/details/p086-readability-counts.md +113 -0
- package/skills/_support/docs/principles/details/p087-comments-explain-why-code-explains-what.md +120 -0
- package/skills/_support/docs/principles/details/p088-delete-dead-code.md +125 -0
- package/skills/_support/docs/principles/details/p089-delete-obsolete-configuration-and-dependencies.md +125 -0
- package/skills/_support/docs/principles/details/p090-prefer-negative-code.md +134 -0
- package/skills/_support/docs/principles/details/p091-test-driven-development.md +149 -0
- package/skills/_support/docs/review/README.md +63 -0
- package/skills/_support/docs/review/behavior-first-testing.md +78 -0
- package/skills/_support/docs/review/common.md +400 -0
- package/skills/_support/docs/review/design-docs.md +74 -0
- package/skills/_support/docs/review/issue-planning.md +247 -0
- package/skills/_support/docs/review/language-routing.md +141 -0
- package/skills/_support/docs/review/repository-scorecard.md +170 -0
- package/skills/advise/SKILL.md +108 -74
- package/skills/advise/scripts/list_retrievable_skills.py +20 -8
- package/skills/advise/scripts/resolve_knowledge_checkout.py +533 -0
- package/skills/brainstorm/SKILL.md +163 -127
- package/skills/change-review/SKILL.md +91 -71
- package/skills/change-review/references/scope-resolution.md +69 -46
- package/skills/change-review/scripts/resolve_scope.py +144 -84
- package/skills/finalize-plan/SKILL.md +205 -139
- package/skills/git-worktrees/SKILL.md +100 -80
- package/skills/git-worktrees/scripts/prepare_worktree.py +34 -15
- package/skills/issue-review/SKILL.md +136 -79
- package/skills/learn/SKILL.md +359 -192
- package/skills/myrmidon-swarm/SKILL.md +126 -86
- package/skills/plan-issue/SKILL.md +122 -81
- package/skills/pr-review/SKILL.md +184 -116
- package/skills/pr-review/references/criteria.md +61 -37
- package/skills/pr-review/references/delivery.md +277 -101
- package/skills/pr-review/references/evidence.md +346 -149
- package/skills/pr-review/references/prevalidated.md +93 -61
- package/skills/pr-review/scripts/collect_evidence.py +312 -162
- package/skills/pr-review/scripts/deliver_go.py +701 -0
- package/skills/pr-review/scripts/diff_context.py +34 -15
- package/skills/pr-review/scripts/materialize_snapshot.py +141 -130
- package/skills/pr-review/scripts/pr_identity.py +27 -15
- package/skills/pr-review/scripts/resolve_pr.py +77 -41
- package/skills/realign/SKILL.md +516 -0
- package/skills/realign/references/aislop-integration.md +215 -0
- package/skills/realign/references/architecture-and-structure.md +271 -0
- package/skills/realign/references/control-flow-and-errors.md +344 -0
- package/skills/realign/references/tests-dependencies-and-security.md +261 -0
- package/skills/realign/scripts/resolve_assessment.py +1525 -0
- package/skills/repo-review/SKILL.md +202 -131
- package/skills/simplify/SKILL.md +174 -0
- package/skills/systematic-debugging/SKILL.md +212 -192
- package/skills/systematic-debugging/scripts/repository_evidence.py +26 -8
- package/skills/test-driven-development/SKILL.md +126 -96
- package/skills/tidy/SKILL.md +104 -59
- package/skills/tidy/scripts/run_tidy.py +54 -6
|
@@ -0,0 +1,1525 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bind a realign assessment to one selected commit or worktree overlay."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import errno
|
|
7
|
+
import hashlib
|
|
8
|
+
import importlib.util
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import queue
|
|
12
|
+
import re
|
|
13
|
+
import signal
|
|
14
|
+
import stat
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path, PureWindowsPath
|
|
22
|
+
from typing import Any, cast
|
|
23
|
+
|
|
24
|
+
if __package__ not in {None, ""}:
|
|
25
|
+
from skills._cli import (
|
|
26
|
+
argument_parser,
|
|
27
|
+
git_read_arguments,
|
|
28
|
+
git_read_environment,
|
|
29
|
+
)
|
|
30
|
+
else:
|
|
31
|
+
_cli_path = Path(__file__).resolve().parents[2] / "_cli.py"
|
|
32
|
+
_cli_spec = importlib.util.spec_from_file_location(
|
|
33
|
+
"athena_installed_cli", _cli_path
|
|
34
|
+
)
|
|
35
|
+
if _cli_spec is None or _cli_spec.loader is None:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
f"The installed Athena CLI helper is unavailable: '{_cli_path}'."
|
|
38
|
+
)
|
|
39
|
+
_cli = importlib.util.module_from_spec(_cli_spec)
|
|
40
|
+
_cli_spec.loader.exec_module(_cli)
|
|
41
|
+
argument_parser = _cli.argument_parser
|
|
42
|
+
git_read_arguments = _cli.git_read_arguments
|
|
43
|
+
git_read_environment = _cli.git_read_environment
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
SELECTOR_FORBIDDEN = ("..", "^@", "^!", "@{", ":")
|
|
47
|
+
CANDIDATE_ID = re.compile(r"[A-Z][A-Z0-9_-]{2,63}\Z")
|
|
48
|
+
REALIGN_CANDIDATE_ID = re.compile(r"RLG-[0-9]{3}\Z")
|
|
49
|
+
MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024
|
|
50
|
+
MAX_FILE_BYTES = 64 * 1024 * 1024
|
|
51
|
+
MAX_TOTAL_BYTES = 512 * 1024 * 1024
|
|
52
|
+
MAX_PATH_COUNT = 250_000
|
|
53
|
+
GIT_TIMEOUT_SECONDS = 30.0
|
|
54
|
+
ASSESSMENT_TIMEOUT_SECONDS = 300.0
|
|
55
|
+
ASSESSMENT_SCHEMA_VERSION = 1
|
|
56
|
+
INTERNAL_FILE_READ_COMMAND = "__athena_read_bound_file"
|
|
57
|
+
INTERNAL_FILE_ABSENT = "ATHENA_INTERNAL_FILE_ABSENT"
|
|
58
|
+
INTERNAL_FILE_SYMLINK = "ATHENA_INTERNAL_FILE_SYMLINK"
|
|
59
|
+
INTERNAL_FILE_BOUNDARY = "ATHENA_INTERNAL_FILE_BOUNDARY"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class SnapshotFileEntry:
|
|
64
|
+
"""One regular file read from an explicitly bound source."""
|
|
65
|
+
|
|
66
|
+
path: str
|
|
67
|
+
source_kind: str
|
|
68
|
+
content: bytes
|
|
69
|
+
object_id: str | None = None
|
|
70
|
+
mode: str | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SourcePathAbsentError(RuntimeError):
|
|
74
|
+
"""A selected worktree path disappeared during capture."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SourcePathSymlinkError(RuntimeError):
|
|
78
|
+
"""A selected worktree path is a symbolic-link boundary."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, message: str, *, target: bytes | None = None) -> None:
|
|
81
|
+
super().__init__(message)
|
|
82
|
+
self.target = target
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SourcePathBoundaryError(RuntimeError):
|
|
86
|
+
"""A selected worktree path is not a regular file."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _run_bounded_process(
|
|
90
|
+
command: Sequence[str],
|
|
91
|
+
*,
|
|
92
|
+
environment: Mapping[str, str],
|
|
93
|
+
output_limit: int,
|
|
94
|
+
deadline: float | None,
|
|
95
|
+
operation: str,
|
|
96
|
+
) -> bytes:
|
|
97
|
+
"""Run one contained process with bounded output, time, and cleanup."""
|
|
98
|
+
if os.name != "posix":
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
f"The host cannot provide contained process cleanup for the {operation}."
|
|
101
|
+
)
|
|
102
|
+
failure: BaseException | None = None
|
|
103
|
+
try:
|
|
104
|
+
process = subprocess.Popen(
|
|
105
|
+
list(command),
|
|
106
|
+
stdout=subprocess.PIPE,
|
|
107
|
+
stderr=subprocess.PIPE,
|
|
108
|
+
env=dict(environment),
|
|
109
|
+
start_new_session=True,
|
|
110
|
+
)
|
|
111
|
+
except FileNotFoundError as error:
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
f"The required command is not available: '{command[0]}'."
|
|
114
|
+
) from error
|
|
115
|
+
if process.stdout is None or process.stderr is None:
|
|
116
|
+
process.kill()
|
|
117
|
+
process.wait()
|
|
118
|
+
raise RuntimeError(f"The {operation} did not expose output.")
|
|
119
|
+
chunks: queue.Queue[tuple[str, str, bytes | Exception | None]] = queue.Queue(
|
|
120
|
+
maxsize=4
|
|
121
|
+
)
|
|
122
|
+
stop_readers = threading.Event()
|
|
123
|
+
|
|
124
|
+
def enqueue_output(
|
|
125
|
+
item: tuple[str, str, bytes | Exception | None],
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Queue output while allowing bounded failure cleanup."""
|
|
128
|
+
while not stop_readers.is_set():
|
|
129
|
+
try:
|
|
130
|
+
chunks.put(item, timeout=0.05)
|
|
131
|
+
except queue.Full:
|
|
132
|
+
continue
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
def read_output(name: str, stream: Any) -> None:
|
|
136
|
+
try:
|
|
137
|
+
while chunk := stream.read(64 * 1024):
|
|
138
|
+
enqueue_output((name, "data", chunk))
|
|
139
|
+
if stop_readers.is_set():
|
|
140
|
+
return
|
|
141
|
+
# Transfer each stream failure to the coordinating thread.
|
|
142
|
+
except Exception as error: # noqa: BLE001
|
|
143
|
+
enqueue_output((name, "error", error))
|
|
144
|
+
else:
|
|
145
|
+
enqueue_output((name, "done", None))
|
|
146
|
+
|
|
147
|
+
readers = [
|
|
148
|
+
threading.Thread(
|
|
149
|
+
target=read_output,
|
|
150
|
+
args=("stdout", process.stdout),
|
|
151
|
+
name="realign-process-stdout-reader",
|
|
152
|
+
daemon=True,
|
|
153
|
+
),
|
|
154
|
+
threading.Thread(
|
|
155
|
+
target=read_output,
|
|
156
|
+
args=("stderr", process.stderr),
|
|
157
|
+
name="realign-process-stderr-reader",
|
|
158
|
+
daemon=True,
|
|
159
|
+
),
|
|
160
|
+
]
|
|
161
|
+
for reader in readers:
|
|
162
|
+
reader.start()
|
|
163
|
+
stdout: list[bytes] = []
|
|
164
|
+
stderr: list[bytes] = []
|
|
165
|
+
byte_count = 0
|
|
166
|
+
completed_readers = 0
|
|
167
|
+
command_deadline = time.monotonic() + GIT_TIMEOUT_SECONDS
|
|
168
|
+
if deadline is not None:
|
|
169
|
+
command_deadline = min(command_deadline, deadline)
|
|
170
|
+
|
|
171
|
+
def stop_process_tree() -> None:
|
|
172
|
+
"""Stop the isolated process group or the direct process."""
|
|
173
|
+
pid = getattr(process, "pid", None)
|
|
174
|
+
if isinstance(pid, int):
|
|
175
|
+
try:
|
|
176
|
+
os.killpg(pid, signal.SIGKILL)
|
|
177
|
+
return
|
|
178
|
+
except (PermissionError, ProcessLookupError):
|
|
179
|
+
pass
|
|
180
|
+
if process.poll() is None:
|
|
181
|
+
process.kill()
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
while completed_readers < len(readers):
|
|
185
|
+
remaining = command_deadline - time.monotonic()
|
|
186
|
+
if remaining <= 0:
|
|
187
|
+
raise RuntimeError(f"The {operation} exceeded its time limit.")
|
|
188
|
+
try:
|
|
189
|
+
event = chunks.get(timeout=remaining)
|
|
190
|
+
except queue.Empty as error:
|
|
191
|
+
raise RuntimeError(
|
|
192
|
+
f"The {operation} exceeded its time limit."
|
|
193
|
+
) from error
|
|
194
|
+
name, kind, payload = event
|
|
195
|
+
if kind == "error":
|
|
196
|
+
assert isinstance(payload, Exception)
|
|
197
|
+
raise RuntimeError(f"The {operation} {name} read failed.") from payload
|
|
198
|
+
if kind == "done":
|
|
199
|
+
completed_readers += 1
|
|
200
|
+
continue
|
|
201
|
+
assert kind == "data" and isinstance(payload, bytes)
|
|
202
|
+
content = payload
|
|
203
|
+
byte_count += len(content)
|
|
204
|
+
if byte_count > output_limit:
|
|
205
|
+
raise RuntimeError(f"The {operation} exceeded its output limit.")
|
|
206
|
+
(stdout if name == "stdout" else stderr).append(content)
|
|
207
|
+
remaining = command_deadline - time.monotonic()
|
|
208
|
+
if remaining <= 0:
|
|
209
|
+
raise RuntimeError(f"The {operation} exceeded its time limit.")
|
|
210
|
+
try:
|
|
211
|
+
returncode = process.wait(timeout=remaining)
|
|
212
|
+
except subprocess.TimeoutExpired as error:
|
|
213
|
+
raise RuntimeError(f"The {operation} exceeded its time limit.") from error
|
|
214
|
+
if returncode != 0:
|
|
215
|
+
message = b"".join(stderr).decode("utf-8", errors="replace").strip()
|
|
216
|
+
raise RuntimeError(message or f"The {operation} failed.")
|
|
217
|
+
except BaseException as error:
|
|
218
|
+
failure = error
|
|
219
|
+
stop_process_tree()
|
|
220
|
+
try:
|
|
221
|
+
process.wait(timeout=1.0)
|
|
222
|
+
except subprocess.TimeoutExpired:
|
|
223
|
+
raise RuntimeError(
|
|
224
|
+
f"The failed {operation} process could not be reaped."
|
|
225
|
+
) from error
|
|
226
|
+
raise
|
|
227
|
+
finally:
|
|
228
|
+
stop_readers.set()
|
|
229
|
+
if failure is not None:
|
|
230
|
+
stop_process_tree()
|
|
231
|
+
cleanup_deadline = time.monotonic() + 1.0
|
|
232
|
+
for reader in readers:
|
|
233
|
+
reader.join(timeout=max(0.0, cleanup_deadline - time.monotonic()))
|
|
234
|
+
if any(reader.is_alive() for reader in readers):
|
|
235
|
+
cleanup_error = RuntimeError(
|
|
236
|
+
f"The {operation} output readers could not be stopped."
|
|
237
|
+
)
|
|
238
|
+
if failure is not None:
|
|
239
|
+
raise cleanup_error from failure
|
|
240
|
+
raise cleanup_error
|
|
241
|
+
close_errors: list[OSError] = []
|
|
242
|
+
for stream in (process.stdout, process.stderr):
|
|
243
|
+
try:
|
|
244
|
+
stream.close()
|
|
245
|
+
except OSError as error:
|
|
246
|
+
close_errors.append(error)
|
|
247
|
+
if close_errors:
|
|
248
|
+
cleanup_error = RuntimeError(
|
|
249
|
+
f"The {operation} output streams could not be closed: {close_errors[0]}"
|
|
250
|
+
)
|
|
251
|
+
if failure is not None:
|
|
252
|
+
raise cleanup_error from failure
|
|
253
|
+
raise cleanup_error from close_errors[0]
|
|
254
|
+
return b"".join(stdout)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _git_bytes(
|
|
258
|
+
repository_root: Path, *arguments: str, deadline: float | None = None
|
|
259
|
+
) -> bytes:
|
|
260
|
+
"""Run one sanitized read-only Git command and return raw output."""
|
|
261
|
+
command = [
|
|
262
|
+
"git",
|
|
263
|
+
"-c",
|
|
264
|
+
"core.fsmonitor=false",
|
|
265
|
+
*git_read_arguments(),
|
|
266
|
+
"-C",
|
|
267
|
+
os.fspath(repository_root),
|
|
268
|
+
*arguments,
|
|
269
|
+
]
|
|
270
|
+
return _run_bounded_process(
|
|
271
|
+
command,
|
|
272
|
+
environment=git_read_environment(),
|
|
273
|
+
output_limit=MAX_GIT_OUTPUT_BYTES,
|
|
274
|
+
deadline=deadline,
|
|
275
|
+
operation="read-only Git command",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _isolated_python_environment() -> dict[str, str]:
|
|
280
|
+
"""Return a process environment without Python startup controls."""
|
|
281
|
+
return {
|
|
282
|
+
key: value
|
|
283
|
+
for key, value in git_read_environment().items()
|
|
284
|
+
if not key.startswith("PYTHON")
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _git_text(
|
|
289
|
+
repository_root: Path, *arguments: str, deadline: float | None = None
|
|
290
|
+
) -> str:
|
|
291
|
+
"""Run one sanitized Git command and return one textual value."""
|
|
292
|
+
return (
|
|
293
|
+
_git_bytes(repository_root, *arguments, deadline=deadline)
|
|
294
|
+
.decode("utf-8", errors="surrogateescape")
|
|
295
|
+
.removesuffix("\n")
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _repository_root(repository_root: Path, *, deadline: float | None = None) -> Path:
|
|
300
|
+
"""Return and verify the canonical repository root."""
|
|
301
|
+
root = Path(os.path.realpath(os.fspath(repository_root)))
|
|
302
|
+
observed = Path(_git_text(root, "rev-parse", "--show-toplevel", deadline=deadline))
|
|
303
|
+
if Path(os.path.realpath(os.fspath(observed))) != root:
|
|
304
|
+
raise RuntimeError(f"The path is not the repository root: '{root}'.")
|
|
305
|
+
return root
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def normalize_repo_tree_path(raw_path: str) -> str:
|
|
309
|
+
"""Return one confined repository-tree path without pathspec semantics."""
|
|
310
|
+
if not isinstance(raw_path, str) or not raw_path or "\x00" in raw_path:
|
|
311
|
+
raise RuntimeError(f"The repository path is not valid: {raw_path!r}.")
|
|
312
|
+
if (
|
|
313
|
+
raw_path.startswith(":")
|
|
314
|
+
or Path(raw_path).is_absolute()
|
|
315
|
+
or PureWindowsPath(raw_path).is_absolute()
|
|
316
|
+
):
|
|
317
|
+
raise RuntimeError(f"The repository path is not valid: {raw_path!r}.")
|
|
318
|
+
components = raw_path.split("/")
|
|
319
|
+
if any(component in {"", ".", ".."} for component in components):
|
|
320
|
+
raise RuntimeError(f"The repository path is not valid: {raw_path!r}.")
|
|
321
|
+
return "/".join(components)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _target_path(raw_path: str | None) -> str:
|
|
325
|
+
"""Return the internal repository-root sentinel or one confined target."""
|
|
326
|
+
if raw_path in {None, "."}:
|
|
327
|
+
return "."
|
|
328
|
+
return normalize_repo_tree_path(raw_path)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _pathspec(path: str) -> tuple[str, ...]:
|
|
332
|
+
"""Return an exact repository-rooted literal Git pathspec."""
|
|
333
|
+
if path == ".":
|
|
334
|
+
return ()
|
|
335
|
+
return (f":(top,literal){path}",)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _selected_commit(
|
|
339
|
+
repository_root: Path, selector: str, *, deadline: float | None = None
|
|
340
|
+
) -> str:
|
|
341
|
+
"""Resolve one safe selector once to an immutable commit OID."""
|
|
342
|
+
if (
|
|
343
|
+
not selector
|
|
344
|
+
or selector.startswith("-")
|
|
345
|
+
or any(fragment in selector for fragment in SELECTOR_FORBIDDEN)
|
|
346
|
+
):
|
|
347
|
+
raise RuntimeError(f"The selected Git reference is not valid: {selector!r}.")
|
|
348
|
+
commit_oid = _git_text(
|
|
349
|
+
repository_root,
|
|
350
|
+
"rev-parse",
|
|
351
|
+
"--verify",
|
|
352
|
+
"--end-of-options",
|
|
353
|
+
f"{selector}^{{commit}}",
|
|
354
|
+
deadline=deadline,
|
|
355
|
+
)
|
|
356
|
+
if not re.fullmatch(r"[0-9a-f]{40,64}", commit_oid):
|
|
357
|
+
raise RuntimeError("The selected Git reference did not resolve to one commit.")
|
|
358
|
+
return commit_oid
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _commit_tree(
|
|
362
|
+
repository_root: Path, commit_oid: str, *, deadline: float | None = None
|
|
363
|
+
) -> str:
|
|
364
|
+
"""Return the immutable tree OID for one already resolved commit."""
|
|
365
|
+
tree_oid = _git_text(
|
|
366
|
+
repository_root,
|
|
367
|
+
"rev-parse",
|
|
368
|
+
"--verify",
|
|
369
|
+
"--end-of-options",
|
|
370
|
+
f"{commit_oid}^{{tree}}",
|
|
371
|
+
deadline=deadline,
|
|
372
|
+
)
|
|
373
|
+
if not re.fullmatch(r"[0-9a-f]{40,64}", tree_oid):
|
|
374
|
+
raise RuntimeError("The selected commit did not resolve to one tree.")
|
|
375
|
+
return tree_oid
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _parse_tree_records(document: bytes) -> list[dict[str, str]]:
|
|
379
|
+
"""Parse null-delimited ls-tree records without interpreting path bytes."""
|
|
380
|
+
entries: list[dict[str, str]] = []
|
|
381
|
+
offset = 0
|
|
382
|
+
while offset < len(document):
|
|
383
|
+
record_end = document.find(b"\0", offset)
|
|
384
|
+
if record_end == -1:
|
|
385
|
+
record_end = len(document)
|
|
386
|
+
record = document[offset:record_end]
|
|
387
|
+
offset = record_end + 1
|
|
388
|
+
if not record:
|
|
389
|
+
continue
|
|
390
|
+
if len(entries) >= MAX_PATH_COUNT:
|
|
391
|
+
raise RuntimeError("The assessment exceeded its path limit.")
|
|
392
|
+
try:
|
|
393
|
+
metadata, raw_path = record.split(b"\t", maxsplit=1)
|
|
394
|
+
mode, object_type, object_id = metadata.decode("ascii").split(" ")
|
|
395
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
396
|
+
raise RuntimeError("Git returned malformed tree metadata.") from error
|
|
397
|
+
entries.append(
|
|
398
|
+
{
|
|
399
|
+
"path": os.fsdecode(raw_path),
|
|
400
|
+
"mode": mode,
|
|
401
|
+
"object_type": object_type,
|
|
402
|
+
"object_id": object_id,
|
|
403
|
+
}
|
|
404
|
+
)
|
|
405
|
+
return entries
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _selected_inventory_entries(
|
|
409
|
+
repository_root: Path,
|
|
410
|
+
commit_oid: str,
|
|
411
|
+
target: str,
|
|
412
|
+
*,
|
|
413
|
+
deadline: float | None = None,
|
|
414
|
+
) -> list[dict[str, Any]]:
|
|
415
|
+
"""Return the recursively selected immutable tree inventory."""
|
|
416
|
+
records = _parse_tree_records(
|
|
417
|
+
_git_bytes(
|
|
418
|
+
repository_root,
|
|
419
|
+
"ls-tree",
|
|
420
|
+
"-r",
|
|
421
|
+
"-z",
|
|
422
|
+
"--full-tree",
|
|
423
|
+
commit_oid,
|
|
424
|
+
"--",
|
|
425
|
+
*_pathspec(target),
|
|
426
|
+
deadline=deadline,
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
entries: list[dict[str, Any]] = []
|
|
430
|
+
for record in records:
|
|
431
|
+
kind = "file"
|
|
432
|
+
if record["mode"] == "120000":
|
|
433
|
+
kind = "symlink"
|
|
434
|
+
elif record["mode"] == "160000" or record["object_type"] == "commit":
|
|
435
|
+
kind = "submodule"
|
|
436
|
+
elif record["object_type"] != "blob":
|
|
437
|
+
kind = "other"
|
|
438
|
+
entries.append(
|
|
439
|
+
{
|
|
440
|
+
**record,
|
|
441
|
+
"kind": kind,
|
|
442
|
+
"source_kind": "selected_commit_tree",
|
|
443
|
+
"commit_oid": commit_oid,
|
|
444
|
+
}
|
|
445
|
+
)
|
|
446
|
+
return entries
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _canonical_digest(value: Any) -> str:
|
|
450
|
+
"""Return a deterministic SHA-256 digest for one JSON-compatible value."""
|
|
451
|
+
document = json.dumps(
|
|
452
|
+
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
453
|
+
).encode("utf-8", errors="surrogateescape")
|
|
454
|
+
return hashlib.sha256(document).hexdigest()
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def assessment_report_digest(report: Mapping[str, Any]) -> str:
|
|
458
|
+
"""Return the identity that an approval must bind separately from a report."""
|
|
459
|
+
return _canonical_digest(dict(report))
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _open_parent(repository_root: Path, relative_path: str) -> tuple[int, str]:
|
|
463
|
+
"""Open a repository path parent without following a symbolic link."""
|
|
464
|
+
if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"):
|
|
465
|
+
raise RuntimeError(
|
|
466
|
+
"The host cannot inspect repository paths without following symbolic links."
|
|
467
|
+
)
|
|
468
|
+
components = normalize_repo_tree_path(relative_path).split("/")
|
|
469
|
+
descriptor = os.open(repository_root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
470
|
+
try:
|
|
471
|
+
for component in components[:-1]:
|
|
472
|
+
child = os.open(
|
|
473
|
+
component,
|
|
474
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
475
|
+
dir_fd=descriptor,
|
|
476
|
+
)
|
|
477
|
+
os.close(descriptor)
|
|
478
|
+
descriptor = child
|
|
479
|
+
except BaseException:
|
|
480
|
+
os.close(descriptor)
|
|
481
|
+
raise
|
|
482
|
+
return descriptor, components[-1]
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _write_all(descriptor: int, document: bytes) -> None:
|
|
486
|
+
"""Write all bytes to one internal worker descriptor."""
|
|
487
|
+
offset = 0
|
|
488
|
+
while offset < len(document):
|
|
489
|
+
offset += os.write(descriptor, document[offset:])
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _internal_read_bound_file(
|
|
493
|
+
repository_root: Path, relative_path: str, byte_limit: int
|
|
494
|
+
) -> int:
|
|
495
|
+
"""Read one confined file or symbolic-link target inside a killable worker."""
|
|
496
|
+
try:
|
|
497
|
+
parent, filename = _open_parent(repository_root, relative_path)
|
|
498
|
+
try:
|
|
499
|
+
path_mode = os.lstat(filename, dir_fd=parent).st_mode
|
|
500
|
+
if stat.S_ISLNK(path_mode):
|
|
501
|
+
raw_target = os.fsencode(os.readlink(filename, dir_fd=parent))
|
|
502
|
+
if len(raw_target) > byte_limit:
|
|
503
|
+
print(
|
|
504
|
+
"The symbolic-link target exceeded the file byte limit.",
|
|
505
|
+
file=sys.stderr,
|
|
506
|
+
)
|
|
507
|
+
return 23
|
|
508
|
+
_write_all(1, b"symlink\n")
|
|
509
|
+
_write_all(1, raw_target)
|
|
510
|
+
return 0
|
|
511
|
+
descriptor = os.open(
|
|
512
|
+
filename,
|
|
513
|
+
os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0),
|
|
514
|
+
dir_fd=parent,
|
|
515
|
+
)
|
|
516
|
+
finally:
|
|
517
|
+
os.close(parent)
|
|
518
|
+
except FileNotFoundError:
|
|
519
|
+
print(INTERNAL_FILE_ABSENT, file=sys.stderr)
|
|
520
|
+
return 20
|
|
521
|
+
except OSError as error:
|
|
522
|
+
if error.errno == errno.ELOOP:
|
|
523
|
+
print(INTERNAL_FILE_SYMLINK, file=sys.stderr)
|
|
524
|
+
return 21
|
|
525
|
+
raise
|
|
526
|
+
try:
|
|
527
|
+
opened_mode = os.fstat(descriptor).st_mode
|
|
528
|
+
if not stat.S_ISREG(opened_mode):
|
|
529
|
+
print(INTERNAL_FILE_BOUNDARY, file=sys.stderr)
|
|
530
|
+
return 22
|
|
531
|
+
_write_all(1, f"{stat.S_IMODE(opened_mode):04o}\n".encode("ascii"))
|
|
532
|
+
byte_count = 0
|
|
533
|
+
while chunk := os.read(descriptor, 1024 * 1024):
|
|
534
|
+
byte_count += len(chunk)
|
|
535
|
+
if byte_count > byte_limit:
|
|
536
|
+
print(
|
|
537
|
+
"The source file exceeded the file byte limit.",
|
|
538
|
+
file=sys.stderr,
|
|
539
|
+
)
|
|
540
|
+
return 23
|
|
541
|
+
_write_all(1, chunk)
|
|
542
|
+
finally:
|
|
543
|
+
os.close(descriptor)
|
|
544
|
+
return 0
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _bounded_file_snapshot(
|
|
548
|
+
repository_root: Path,
|
|
549
|
+
relative_path: str,
|
|
550
|
+
*,
|
|
551
|
+
deadline: float | None = None,
|
|
552
|
+
byte_limit: int | None = None,
|
|
553
|
+
) -> SnapshotFileEntry:
|
|
554
|
+
"""Read one confined regular file through a contained worker process."""
|
|
555
|
+
limit = MAX_FILE_BYTES if byte_limit is None else byte_limit
|
|
556
|
+
command = [
|
|
557
|
+
sys.executable,
|
|
558
|
+
"-I",
|
|
559
|
+
os.path.abspath(__file__),
|
|
560
|
+
INTERNAL_FILE_READ_COMMAND,
|
|
561
|
+
os.fspath(repository_root),
|
|
562
|
+
relative_path,
|
|
563
|
+
str(limit),
|
|
564
|
+
]
|
|
565
|
+
try:
|
|
566
|
+
document = _run_bounded_process(
|
|
567
|
+
command,
|
|
568
|
+
environment=_isolated_python_environment(),
|
|
569
|
+
output_limit=limit + 1024,
|
|
570
|
+
deadline=deadline,
|
|
571
|
+
operation="bounded source-file read",
|
|
572
|
+
)
|
|
573
|
+
except RuntimeError as error:
|
|
574
|
+
message = str(error)
|
|
575
|
+
if message == INTERNAL_FILE_ABSENT:
|
|
576
|
+
raise SourcePathAbsentError(
|
|
577
|
+
f"The source path does not exist: '{relative_path}'."
|
|
578
|
+
) from error
|
|
579
|
+
if message == INTERNAL_FILE_SYMLINK:
|
|
580
|
+
raise SourcePathSymlinkError(
|
|
581
|
+
f"The source path is a symbolic link: '{relative_path}'."
|
|
582
|
+
) from error
|
|
583
|
+
if message == INTERNAL_FILE_BOUNDARY:
|
|
584
|
+
raise SourcePathBoundaryError(
|
|
585
|
+
f"The source path is not a regular file: '{relative_path}'."
|
|
586
|
+
) from error
|
|
587
|
+
raise
|
|
588
|
+
symlink_header = b"symlink\n"
|
|
589
|
+
if document.startswith(symlink_header):
|
|
590
|
+
raw_target = document[len(symlink_header) :]
|
|
591
|
+
if not raw_target:
|
|
592
|
+
raise RuntimeError(
|
|
593
|
+
"The bounded source-file worker returned malformed output."
|
|
594
|
+
)
|
|
595
|
+
raise SourcePathSymlinkError(
|
|
596
|
+
f"The source path is a symbolic link: '{relative_path}'.",
|
|
597
|
+
target=raw_target,
|
|
598
|
+
)
|
|
599
|
+
raw_mode, separator, content = document.partition(b"\n")
|
|
600
|
+
if separator != b"\n" or re.fullmatch(rb"[0-7]{4}", raw_mode) is None:
|
|
601
|
+
raise RuntimeError("The bounded source-file worker returned malformed output.")
|
|
602
|
+
return SnapshotFileEntry(
|
|
603
|
+
path=relative_path,
|
|
604
|
+
source_kind="worktree_overlay",
|
|
605
|
+
content=content,
|
|
606
|
+
mode=raw_mode.decode("ascii"),
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _worktree_snapshot_entry(
|
|
611
|
+
repository_root: Path,
|
|
612
|
+
relative_path: str,
|
|
613
|
+
*,
|
|
614
|
+
deadline: float | None = None,
|
|
615
|
+
) -> SnapshotFileEntry:
|
|
616
|
+
"""Read one regular worktree file without following symbolic links."""
|
|
617
|
+
return _bounded_file_snapshot(
|
|
618
|
+
repository_root,
|
|
619
|
+
relative_path,
|
|
620
|
+
deadline=deadline,
|
|
621
|
+
byte_limit=MAX_FILE_BYTES,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _worktree_paths(
|
|
626
|
+
repository_root: Path,
|
|
627
|
+
scopes: Sequence[str],
|
|
628
|
+
*,
|
|
629
|
+
deadline: float | None = None,
|
|
630
|
+
) -> list[str]:
|
|
631
|
+
"""Return selected tracked and non-ignored untracked worktree paths."""
|
|
632
|
+
pathspecs = (
|
|
633
|
+
()
|
|
634
|
+
if "." in scopes
|
|
635
|
+
else tuple(pathspec for scope in scopes for pathspec in _pathspec(scope))
|
|
636
|
+
)
|
|
637
|
+
tracked = _git_bytes(
|
|
638
|
+
repository_root, "ls-files", "-z", "--", *pathspecs, deadline=deadline
|
|
639
|
+
)
|
|
640
|
+
untracked = _git_bytes(
|
|
641
|
+
repository_root,
|
|
642
|
+
"ls-files",
|
|
643
|
+
"--others",
|
|
644
|
+
"--exclude-standard",
|
|
645
|
+
"-z",
|
|
646
|
+
"--",
|
|
647
|
+
*pathspecs,
|
|
648
|
+
deadline=deadline,
|
|
649
|
+
)
|
|
650
|
+
paths: set[str] = set()
|
|
651
|
+
for document in (tracked, untracked):
|
|
652
|
+
offset = 0
|
|
653
|
+
while offset < len(document):
|
|
654
|
+
path_end = document.find(b"\0", offset)
|
|
655
|
+
if path_end == -1:
|
|
656
|
+
path_end = len(document)
|
|
657
|
+
raw_path = document[offset:path_end]
|
|
658
|
+
offset = path_end + 1
|
|
659
|
+
if not raw_path:
|
|
660
|
+
continue
|
|
661
|
+
paths.add(os.fsdecode(raw_path))
|
|
662
|
+
if len(paths) > MAX_PATH_COUNT:
|
|
663
|
+
raise RuntimeError("The assessment exceeded its path limit.")
|
|
664
|
+
return sorted(paths)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _worktree_inventory(
|
|
668
|
+
repository_root: Path,
|
|
669
|
+
head_oid: str,
|
|
670
|
+
target: str,
|
|
671
|
+
evidence_paths: Sequence[str] = (),
|
|
672
|
+
*,
|
|
673
|
+
deadline: float | None = None,
|
|
674
|
+
) -> dict[str, Any]:
|
|
675
|
+
"""Return a content-bound worktree overlay inventory."""
|
|
676
|
+
scopes = tuple(dict.fromkeys((target, *evidence_paths)))
|
|
677
|
+
paths = _worktree_paths(repository_root, scopes, deadline=deadline)
|
|
678
|
+
entries: list[dict[str, Any]] = []
|
|
679
|
+
total_bytes = 0
|
|
680
|
+
for path in paths:
|
|
681
|
+
try:
|
|
682
|
+
snapshot = _worktree_snapshot_entry(
|
|
683
|
+
repository_root, path, deadline=deadline
|
|
684
|
+
)
|
|
685
|
+
except SourcePathAbsentError:
|
|
686
|
+
entries.append(
|
|
687
|
+
{
|
|
688
|
+
"path": path,
|
|
689
|
+
"kind": "absent",
|
|
690
|
+
"source_kind": "worktree_overlay",
|
|
691
|
+
"head_oid": head_oid,
|
|
692
|
+
}
|
|
693
|
+
)
|
|
694
|
+
continue
|
|
695
|
+
except SourcePathSymlinkError as error:
|
|
696
|
+
if error.target is None:
|
|
697
|
+
raise RuntimeError(
|
|
698
|
+
f"The symbolic-link path changed during capture: '{path}'."
|
|
699
|
+
) from error
|
|
700
|
+
entries.append(
|
|
701
|
+
{
|
|
702
|
+
"path": path,
|
|
703
|
+
"kind": "symlink",
|
|
704
|
+
"target": os.fsdecode(error.target),
|
|
705
|
+
"source_kind": "worktree_overlay",
|
|
706
|
+
"head_oid": head_oid,
|
|
707
|
+
}
|
|
708
|
+
)
|
|
709
|
+
total_bytes += len(error.target)
|
|
710
|
+
if total_bytes > MAX_TOTAL_BYTES:
|
|
711
|
+
raise RuntimeError("The assessment exceeded its aggregate byte limit.")
|
|
712
|
+
continue
|
|
713
|
+
except SourcePathBoundaryError:
|
|
714
|
+
entries.append(
|
|
715
|
+
{
|
|
716
|
+
"path": path,
|
|
717
|
+
"kind": "boundary",
|
|
718
|
+
"source_kind": "worktree_overlay",
|
|
719
|
+
"head_oid": head_oid,
|
|
720
|
+
}
|
|
721
|
+
)
|
|
722
|
+
continue
|
|
723
|
+
entries.append(
|
|
724
|
+
{
|
|
725
|
+
"path": path,
|
|
726
|
+
"kind": "file",
|
|
727
|
+
"mode": snapshot.mode,
|
|
728
|
+
"byte_length": len(snapshot.content),
|
|
729
|
+
"content_sha256": hashlib.sha256(snapshot.content).hexdigest(),
|
|
730
|
+
"source_kind": "worktree_overlay",
|
|
731
|
+
"head_oid": head_oid,
|
|
732
|
+
}
|
|
733
|
+
)
|
|
734
|
+
total_bytes += len(snapshot.content)
|
|
735
|
+
if total_bytes > MAX_TOTAL_BYTES:
|
|
736
|
+
raise RuntimeError("The assessment exceeded its aggregate byte limit.")
|
|
737
|
+
pathspecs = (
|
|
738
|
+
()
|
|
739
|
+
if "." in scopes
|
|
740
|
+
else tuple(pathspec for scope in scopes for pathspec in _pathspec(scope))
|
|
741
|
+
)
|
|
742
|
+
status = _git_bytes(
|
|
743
|
+
repository_root,
|
|
744
|
+
"status",
|
|
745
|
+
"--porcelain=v1",
|
|
746
|
+
"-z",
|
|
747
|
+
"--untracked-files=all",
|
|
748
|
+
"--",
|
|
749
|
+
*pathspecs,
|
|
750
|
+
deadline=deadline,
|
|
751
|
+
)
|
|
752
|
+
identity = {
|
|
753
|
+
"entries": entries,
|
|
754
|
+
"status_sha256": hashlib.sha256(status).hexdigest(),
|
|
755
|
+
"target": target,
|
|
756
|
+
"evidence_paths": list(evidence_paths),
|
|
757
|
+
}
|
|
758
|
+
return {
|
|
759
|
+
**identity,
|
|
760
|
+
"inventory_digest": _canonical_digest(entries),
|
|
761
|
+
"overlay_digest": _canonical_digest(identity),
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _binding_evidence_paths(binding: Mapping[str, Any]) -> tuple[str, ...]:
|
|
766
|
+
"""Return the exact declared source-evidence paths from one binding."""
|
|
767
|
+
raw_paths = binding.get("evidence_paths")
|
|
768
|
+
if not isinstance(raw_paths, list) or not all(
|
|
769
|
+
isinstance(path, str) for path in raw_paths
|
|
770
|
+
):
|
|
771
|
+
raise TypeError("The source evidence-path binding is malformed.")
|
|
772
|
+
paths = tuple(normalize_repo_tree_path(path) for path in raw_paths)
|
|
773
|
+
if len(paths) != len(set(paths)) or list(paths) != raw_paths:
|
|
774
|
+
raise RuntimeError("The source evidence-path binding is stale.")
|
|
775
|
+
return paths
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def resolve_source_binding(
|
|
779
|
+
repository_root: Path,
|
|
780
|
+
*,
|
|
781
|
+
reference: str | None = None,
|
|
782
|
+
target: str | None = ".",
|
|
783
|
+
evidence_paths: Sequence[str] = (),
|
|
784
|
+
_deadline: float | None = None,
|
|
785
|
+
) -> dict[str, Any]:
|
|
786
|
+
"""Bind one selected commit tree or the current worktree overlay."""
|
|
787
|
+
root = _repository_root(repository_root, deadline=_deadline)
|
|
788
|
+
normalized_target = _target_path(target)
|
|
789
|
+
normalized_evidence_paths = tuple(
|
|
790
|
+
dict.fromkeys(normalize_repo_tree_path(path) for path in evidence_paths)
|
|
791
|
+
)
|
|
792
|
+
if reference is not None:
|
|
793
|
+
commit_oid = _selected_commit(root, reference, deadline=_deadline)
|
|
794
|
+
tree_oid = _commit_tree(root, commit_oid, deadline=_deadline)
|
|
795
|
+
entries = _selected_inventory_entries(
|
|
796
|
+
root, commit_oid, normalized_target, deadline=_deadline
|
|
797
|
+
)
|
|
798
|
+
inventory_digest = _canonical_digest(entries)
|
|
799
|
+
binding: dict[str, Any] = {
|
|
800
|
+
"repository_root": os.fspath(root),
|
|
801
|
+
"source_kind": "selected_commit_tree",
|
|
802
|
+
"selector": reference,
|
|
803
|
+
"commit_oid": commit_oid,
|
|
804
|
+
"tree_oid": tree_oid,
|
|
805
|
+
"target": normalized_target,
|
|
806
|
+
"evidence_paths": list(normalized_evidence_paths),
|
|
807
|
+
"inventory_digest": inventory_digest,
|
|
808
|
+
}
|
|
809
|
+
return {**binding, "source_digest": _canonical_digest(binding)}
|
|
810
|
+
|
|
811
|
+
head_oid = _selected_commit(root, "HEAD", deadline=_deadline)
|
|
812
|
+
tree_oid = _commit_tree(root, head_oid, deadline=_deadline)
|
|
813
|
+
first_inventory = _worktree_inventory(
|
|
814
|
+
root,
|
|
815
|
+
head_oid,
|
|
816
|
+
normalized_target,
|
|
817
|
+
normalized_evidence_paths,
|
|
818
|
+
deadline=_deadline,
|
|
819
|
+
)
|
|
820
|
+
observed_head_oid = _selected_commit(root, "HEAD", deadline=_deadline)
|
|
821
|
+
observed_tree_oid = _commit_tree(root, observed_head_oid, deadline=_deadline)
|
|
822
|
+
inventory = _worktree_inventory(
|
|
823
|
+
root,
|
|
824
|
+
observed_head_oid,
|
|
825
|
+
normalized_target,
|
|
826
|
+
normalized_evidence_paths,
|
|
827
|
+
deadline=_deadline,
|
|
828
|
+
)
|
|
829
|
+
final_head_oid = _selected_commit(root, "HEAD", deadline=_deadline)
|
|
830
|
+
final_tree_oid = _commit_tree(root, final_head_oid, deadline=_deadline)
|
|
831
|
+
if (
|
|
832
|
+
head_oid != observed_head_oid
|
|
833
|
+
or head_oid != final_head_oid
|
|
834
|
+
or tree_oid != observed_tree_oid
|
|
835
|
+
or tree_oid != final_tree_oid
|
|
836
|
+
or first_inventory != inventory
|
|
837
|
+
):
|
|
838
|
+
raise RuntimeError("The worktree assessment source changed during binding.")
|
|
839
|
+
binding = {
|
|
840
|
+
"repository_root": os.fspath(root),
|
|
841
|
+
"source_kind": "worktree_overlay",
|
|
842
|
+
"head_oid": head_oid,
|
|
843
|
+
"tree_oid": tree_oid,
|
|
844
|
+
"target": normalized_target,
|
|
845
|
+
"evidence_paths": list(normalized_evidence_paths),
|
|
846
|
+
"inventory_digest": inventory["inventory_digest"],
|
|
847
|
+
"overlay_digest": inventory["overlay_digest"],
|
|
848
|
+
"overlay_paths": [entry["path"] for entry in inventory["entries"]],
|
|
849
|
+
}
|
|
850
|
+
return {**binding, "source_digest": _canonical_digest(binding)}
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _verify_selected_binding(
|
|
854
|
+
repository_root: Path,
|
|
855
|
+
binding: Mapping[str, Any],
|
|
856
|
+
*,
|
|
857
|
+
deadline: float | None = None,
|
|
858
|
+
) -> tuple[str, str]:
|
|
859
|
+
"""Verify the recorded commit and tree without resolving the original ref again."""
|
|
860
|
+
bound_root = binding.get("repository_root")
|
|
861
|
+
selector = binding.get("selector")
|
|
862
|
+
commit_oid = binding.get("commit_oid")
|
|
863
|
+
tree_oid = binding.get("tree_oid")
|
|
864
|
+
inventory_digest = binding.get("inventory_digest")
|
|
865
|
+
source_digest = binding.get("source_digest")
|
|
866
|
+
if not all(
|
|
867
|
+
isinstance(value, str)
|
|
868
|
+
for value in (
|
|
869
|
+
bound_root,
|
|
870
|
+
selector,
|
|
871
|
+
commit_oid,
|
|
872
|
+
tree_oid,
|
|
873
|
+
inventory_digest,
|
|
874
|
+
source_digest,
|
|
875
|
+
)
|
|
876
|
+
):
|
|
877
|
+
raise TypeError("The selected source binding is malformed.")
|
|
878
|
+
assert isinstance(bound_root, str)
|
|
879
|
+
assert isinstance(selector, str)
|
|
880
|
+
assert isinstance(commit_oid, str)
|
|
881
|
+
assert isinstance(tree_oid, str)
|
|
882
|
+
assert isinstance(inventory_digest, str)
|
|
883
|
+
assert isinstance(source_digest, str)
|
|
884
|
+
if bound_root != os.fspath(repository_root):
|
|
885
|
+
raise RuntimeError("The selected source repository binding is stale.")
|
|
886
|
+
target = _target_path(cast(str | None, binding.get("target")))
|
|
887
|
+
evidence_paths = _binding_evidence_paths(binding)
|
|
888
|
+
expected_binding = {
|
|
889
|
+
"repository_root": bound_root,
|
|
890
|
+
"source_kind": "selected_commit_tree",
|
|
891
|
+
"selector": selector,
|
|
892
|
+
"commit_oid": commit_oid,
|
|
893
|
+
"tree_oid": tree_oid,
|
|
894
|
+
"target": target,
|
|
895
|
+
"evidence_paths": list(evidence_paths),
|
|
896
|
+
"inventory_digest": inventory_digest,
|
|
897
|
+
}
|
|
898
|
+
if source_digest != _canonical_digest(expected_binding):
|
|
899
|
+
raise RuntimeError("The selected source binding is stale.")
|
|
900
|
+
observed_commit = _selected_commit(repository_root, commit_oid, deadline=deadline)
|
|
901
|
+
observed_tree = _commit_tree(repository_root, observed_commit, deadline=deadline)
|
|
902
|
+
if observed_commit != commit_oid or observed_tree != tree_oid:
|
|
903
|
+
raise RuntimeError("The selected source binding is stale.")
|
|
904
|
+
entries = _selected_inventory_entries(
|
|
905
|
+
repository_root, commit_oid, target, deadline=deadline
|
|
906
|
+
)
|
|
907
|
+
if _canonical_digest(entries) != inventory_digest:
|
|
908
|
+
raise RuntimeError("The selected source inventory is stale.")
|
|
909
|
+
return commit_oid, tree_oid
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _verify_worktree_binding(
|
|
913
|
+
repository_root: Path,
|
|
914
|
+
binding: Mapping[str, Any],
|
|
915
|
+
*,
|
|
916
|
+
deadline: float | None = None,
|
|
917
|
+
) -> dict[str, Any]:
|
|
918
|
+
"""Require the current worktree to match one complete recorded binding."""
|
|
919
|
+
target = _target_path(cast(str | None, binding.get("target")))
|
|
920
|
+
evidence_paths = _binding_evidence_paths(binding)
|
|
921
|
+
current = resolve_source_binding(
|
|
922
|
+
repository_root,
|
|
923
|
+
target=target,
|
|
924
|
+
evidence_paths=evidence_paths,
|
|
925
|
+
_deadline=deadline,
|
|
926
|
+
)
|
|
927
|
+
if dict(binding) != current:
|
|
928
|
+
raise RuntimeError("The worktree assessment source is stale.")
|
|
929
|
+
return current
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def snapshot_file_entry(
|
|
933
|
+
repository_root: Path,
|
|
934
|
+
binding: Mapping[str, Any],
|
|
935
|
+
raw_path: str,
|
|
936
|
+
*,
|
|
937
|
+
deadline: float | None = None,
|
|
938
|
+
) -> SnapshotFileEntry:
|
|
939
|
+
"""Read one selected-commit blob without reading checkout bytes."""
|
|
940
|
+
root = _repository_root(repository_root, deadline=deadline)
|
|
941
|
+
if binding.get("source_kind") != "selected_commit_tree":
|
|
942
|
+
raise RuntimeError("A selected-commit binding is required for this read.")
|
|
943
|
+
commit_oid, _ = _verify_selected_binding(root, binding, deadline=deadline)
|
|
944
|
+
path = normalize_repo_tree_path(raw_path)
|
|
945
|
+
records = _parse_tree_records(
|
|
946
|
+
_git_bytes(
|
|
947
|
+
root,
|
|
948
|
+
"ls-tree",
|
|
949
|
+
"-z",
|
|
950
|
+
"--full-tree",
|
|
951
|
+
commit_oid,
|
|
952
|
+
"--",
|
|
953
|
+
*_pathspec(path),
|
|
954
|
+
deadline=deadline,
|
|
955
|
+
)
|
|
956
|
+
)
|
|
957
|
+
exact = [record for record in records if record["path"] == path]
|
|
958
|
+
if len(exact) != 1:
|
|
959
|
+
raise RuntimeError(f"The selected source path is not one file: '{path}'.")
|
|
960
|
+
record = exact[0]
|
|
961
|
+
if record["mode"] == "120000":
|
|
962
|
+
raise RuntimeError(f"The selected source path is a symbolic link: '{path}'.")
|
|
963
|
+
if record["mode"] == "160000" or record["object_type"] == "commit":
|
|
964
|
+
raise RuntimeError(f"The selected source path is a submodule: '{path}'.")
|
|
965
|
+
if record["object_type"] != "blob":
|
|
966
|
+
raise RuntimeError(f"The selected source path is not a blob: '{path}'.")
|
|
967
|
+
byte_length = int(
|
|
968
|
+
_git_text(root, "cat-file", "-s", record["object_id"], deadline=deadline)
|
|
969
|
+
)
|
|
970
|
+
if byte_length > MAX_FILE_BYTES:
|
|
971
|
+
raise RuntimeError(f"The source file exceeded the file byte limit: '{path}'.")
|
|
972
|
+
content = _git_bytes(
|
|
973
|
+
root, "cat-file", "blob", record["object_id"], deadline=deadline
|
|
974
|
+
)
|
|
975
|
+
if len(content) != byte_length:
|
|
976
|
+
raise RuntimeError(f"The selected source file changed size: '{path}'.")
|
|
977
|
+
return SnapshotFileEntry(
|
|
978
|
+
path=path,
|
|
979
|
+
source_kind="selected_commit_tree",
|
|
980
|
+
content=content,
|
|
981
|
+
object_id=record["object_id"],
|
|
982
|
+
mode=record["mode"],
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def inventory_manifest(
|
|
987
|
+
repository_root: Path, binding: Mapping[str, Any]
|
|
988
|
+
) -> dict[str, Any]:
|
|
989
|
+
"""Return the complete inventory for the recorded assessment target."""
|
|
990
|
+
root = _repository_root(repository_root)
|
|
991
|
+
target = _target_path(cast(str | None, binding.get("target")))
|
|
992
|
+
source_kind = binding.get("source_kind")
|
|
993
|
+
if source_kind == "selected_commit_tree":
|
|
994
|
+
commit_oid, _ = _verify_selected_binding(root, binding)
|
|
995
|
+
entries = _selected_inventory_entries(root, commit_oid, target)
|
|
996
|
+
digest = _canonical_digest(entries)
|
|
997
|
+
return {
|
|
998
|
+
"source_kind": source_kind,
|
|
999
|
+
"target": target,
|
|
1000
|
+
"entries": entries,
|
|
1001
|
+
"inventory_digest": digest,
|
|
1002
|
+
}
|
|
1003
|
+
if source_kind == "worktree_overlay":
|
|
1004
|
+
current = _verify_worktree_binding(root, binding)
|
|
1005
|
+
evidence_paths = _binding_evidence_paths(binding)
|
|
1006
|
+
inventory = {
|
|
1007
|
+
"source_kind": source_kind,
|
|
1008
|
+
"target": target,
|
|
1009
|
+
**_worktree_inventory(
|
|
1010
|
+
root, cast(str, current["head_oid"]), target, evidence_paths
|
|
1011
|
+
),
|
|
1012
|
+
}
|
|
1013
|
+
_verify_worktree_binding(root, binding)
|
|
1014
|
+
if inventory["overlay_digest"] != binding.get("overlay_digest"):
|
|
1015
|
+
raise RuntimeError("The worktree assessment source is stale.")
|
|
1016
|
+
return inventory
|
|
1017
|
+
raise RuntimeError("The assessment source kind is not valid.")
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def guidance_snapshot_manifest(
|
|
1021
|
+
repository_root: Path,
|
|
1022
|
+
binding: Mapping[str, Any],
|
|
1023
|
+
paths: Sequence[str],
|
|
1024
|
+
) -> list[dict[str, Any]]:
|
|
1025
|
+
"""Read guidance and architecture files from only the bound source."""
|
|
1026
|
+
root = _repository_root(repository_root)
|
|
1027
|
+
source_kind = binding.get("source_kind")
|
|
1028
|
+
normalized_paths = tuple(normalize_repo_tree_path(path) for path in paths)
|
|
1029
|
+
if len(normalized_paths) > MAX_PATH_COUNT:
|
|
1030
|
+
raise RuntimeError("The assessment exceeded its path limit.")
|
|
1031
|
+
scopes = (
|
|
1032
|
+
_target_path(cast(str | None, binding.get("target"))),
|
|
1033
|
+
*_binding_evidence_paths(binding),
|
|
1034
|
+
)
|
|
1035
|
+
if any(not _path_is_in_scope(path, scopes) for path in normalized_paths):
|
|
1036
|
+
raise RuntimeError("A guidance path is outside the bound source evidence.")
|
|
1037
|
+
if source_kind == "worktree_overlay":
|
|
1038
|
+
_verify_worktree_binding(root, binding)
|
|
1039
|
+
manifest: list[dict[str, Any]] = []
|
|
1040
|
+
total_bytes = 0
|
|
1041
|
+
for path in normalized_paths:
|
|
1042
|
+
if source_kind == "selected_commit_tree":
|
|
1043
|
+
entry = snapshot_file_entry(root, binding, path)
|
|
1044
|
+
identity = {
|
|
1045
|
+
"commit_oid": binding["commit_oid"],
|
|
1046
|
+
"tree_oid": binding["tree_oid"],
|
|
1047
|
+
}
|
|
1048
|
+
elif source_kind == "worktree_overlay":
|
|
1049
|
+
entry = _worktree_snapshot_entry(root, path)
|
|
1050
|
+
identity = {
|
|
1051
|
+
"head_oid": binding["head_oid"],
|
|
1052
|
+
"overlay_digest": binding["overlay_digest"],
|
|
1053
|
+
}
|
|
1054
|
+
else:
|
|
1055
|
+
raise RuntimeError("The assessment source kind is not valid.")
|
|
1056
|
+
manifest.append(
|
|
1057
|
+
{
|
|
1058
|
+
"path": path,
|
|
1059
|
+
"source_kind": source_kind,
|
|
1060
|
+
**identity,
|
|
1061
|
+
"byte_length": len(entry.content),
|
|
1062
|
+
"content_sha256": hashlib.sha256(entry.content).hexdigest(),
|
|
1063
|
+
}
|
|
1064
|
+
)
|
|
1065
|
+
total_bytes += len(entry.content)
|
|
1066
|
+
if total_bytes > MAX_TOTAL_BYTES:
|
|
1067
|
+
raise RuntimeError("The assessment exceeded its aggregate byte limit.")
|
|
1068
|
+
if source_kind == "worktree_overlay":
|
|
1069
|
+
_verify_worktree_binding(root, binding)
|
|
1070
|
+
return manifest
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def validation_manifest(
|
|
1074
|
+
*,
|
|
1075
|
+
status: str,
|
|
1076
|
+
source_digest: str | None = None,
|
|
1077
|
+
reason: str | None = None,
|
|
1078
|
+
receipts: Sequence[Mapping[str, Any]] = (),
|
|
1079
|
+
) -> dict[str, Any]:
|
|
1080
|
+
"""Separate static-assessment continuation from repair validation eligibility."""
|
|
1081
|
+
if status not in ("not_run", "available", "unavailable"):
|
|
1082
|
+
raise RuntimeError("The validation status is not supported.")
|
|
1083
|
+
copied_receipts = [dict(receipt) for receipt in receipts]
|
|
1084
|
+
if status != "available":
|
|
1085
|
+
if copied_receipts:
|
|
1086
|
+
raise RuntimeError(
|
|
1087
|
+
"Execution receipts require available validation status."
|
|
1088
|
+
)
|
|
1089
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
1090
|
+
raise RuntimeError("Validation without execution requires a reason.")
|
|
1091
|
+
return {
|
|
1092
|
+
"status": status,
|
|
1093
|
+
"source_digest": source_digest,
|
|
1094
|
+
"reason": reason,
|
|
1095
|
+
"receipts": [],
|
|
1096
|
+
"static_assessment": {"continue": True},
|
|
1097
|
+
"repair_eligibility": False,
|
|
1098
|
+
}
|
|
1099
|
+
if not isinstance(source_digest, str) or not re.fullmatch(
|
|
1100
|
+
r"[0-9a-f]{64}", source_digest
|
|
1101
|
+
):
|
|
1102
|
+
raise RuntimeError("Available validation requires one source digest.")
|
|
1103
|
+
required_fields = {
|
|
1104
|
+
"source_digest",
|
|
1105
|
+
"argv",
|
|
1106
|
+
"environment",
|
|
1107
|
+
"exit_status",
|
|
1108
|
+
"stdout",
|
|
1109
|
+
"stderr",
|
|
1110
|
+
}
|
|
1111
|
+
for receipt in copied_receipts:
|
|
1112
|
+
argv = receipt.get("argv")
|
|
1113
|
+
environment = receipt.get("environment")
|
|
1114
|
+
exit_status = receipt.get("exit_status")
|
|
1115
|
+
if (
|
|
1116
|
+
not required_fields.issubset(receipt)
|
|
1117
|
+
or receipt.get("source_digest") != source_digest
|
|
1118
|
+
or not isinstance(argv, list)
|
|
1119
|
+
or not argv
|
|
1120
|
+
or not all(isinstance(argument, str) and argument for argument in argv)
|
|
1121
|
+
or not isinstance(environment, Mapping)
|
|
1122
|
+
or not environment
|
|
1123
|
+
or not all(
|
|
1124
|
+
isinstance(key, str) and isinstance(value, str)
|
|
1125
|
+
for key, value in environment.items()
|
|
1126
|
+
)
|
|
1127
|
+
or isinstance(exit_status, bool)
|
|
1128
|
+
or not isinstance(exit_status, int)
|
|
1129
|
+
or not isinstance(receipt.get("stdout"), str)
|
|
1130
|
+
or not isinstance(receipt.get("stderr"), str)
|
|
1131
|
+
):
|
|
1132
|
+
raise RuntimeError("A validation receipt is incomplete or mismatched.")
|
|
1133
|
+
successful = bool(copied_receipts) and all(
|
|
1134
|
+
receipt["exit_status"] == 0 for receipt in copied_receipts
|
|
1135
|
+
)
|
|
1136
|
+
return {
|
|
1137
|
+
"status": "available",
|
|
1138
|
+
"source_digest": source_digest,
|
|
1139
|
+
"reason": None,
|
|
1140
|
+
"receipts": copied_receipts,
|
|
1141
|
+
"static_assessment": {"continue": True},
|
|
1142
|
+
"repair_eligibility": successful,
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _selected_candidates(
|
|
1147
|
+
report: Mapping[str, Any], candidate_ids: Sequence[str]
|
|
1148
|
+
) -> tuple[list[str], list[str], list[Mapping[str, Any]]]:
|
|
1149
|
+
"""Validate explicit candidate IDs and return their confined paths."""
|
|
1150
|
+
if not candidate_ids or len(set(candidate_ids)) != len(candidate_ids):
|
|
1151
|
+
raise RuntimeError("Repair candidate IDs must be explicit and unique.")
|
|
1152
|
+
if any(
|
|
1153
|
+
CANDIDATE_ID.fullmatch(candidate_id) is None for candidate_id in candidate_ids
|
|
1154
|
+
):
|
|
1155
|
+
raise RuntimeError("A repair candidate ID is malformed.")
|
|
1156
|
+
raw_candidates = report.get("candidates")
|
|
1157
|
+
if not isinstance(raw_candidates, list):
|
|
1158
|
+
raise TypeError("The assessment report does not contain candidates.")
|
|
1159
|
+
if len(raw_candidates) > MAX_PATH_COUNT:
|
|
1160
|
+
raise RuntimeError("The assessment exceeded its candidate limit.")
|
|
1161
|
+
candidates: dict[str, tuple[list[str], Mapping[str, Any]]] = {}
|
|
1162
|
+
candidate_path_count = 0
|
|
1163
|
+
source = report.get("source")
|
|
1164
|
+
source_digest = source.get("source_digest") if isinstance(source, Mapping) else None
|
|
1165
|
+
for raw_candidate in raw_candidates:
|
|
1166
|
+
if not isinstance(raw_candidate, Mapping):
|
|
1167
|
+
raise TypeError("The assessment report contains a malformed candidate.")
|
|
1168
|
+
candidate_id = raw_candidate.get("id")
|
|
1169
|
+
raw_paths = raw_candidate.get("paths")
|
|
1170
|
+
dependencies = raw_candidate.get("dependencies")
|
|
1171
|
+
evidence = raw_candidate.get("evidence")
|
|
1172
|
+
correction = raw_candidate.get("correction")
|
|
1173
|
+
route = raw_candidate.get("route")
|
|
1174
|
+
if (
|
|
1175
|
+
not isinstance(candidate_id, str)
|
|
1176
|
+
or CANDIDATE_ID.fullmatch(candidate_id) is None
|
|
1177
|
+
or candidate_id in candidates
|
|
1178
|
+
or not isinstance(raw_paths, list)
|
|
1179
|
+
or not raw_paths
|
|
1180
|
+
or not all(isinstance(path, str) for path in raw_paths)
|
|
1181
|
+
or route not in {"realign", "simplify"}
|
|
1182
|
+
or (
|
|
1183
|
+
route == "realign"
|
|
1184
|
+
and REALIGN_CANDIDATE_ID.fullmatch(candidate_id) is None
|
|
1185
|
+
)
|
|
1186
|
+
or raw_candidate.get("status") not in {"open", "resolved"}
|
|
1187
|
+
or not isinstance(dependencies, list)
|
|
1188
|
+
or not all(isinstance(item, str) for item in dependencies)
|
|
1189
|
+
or not isinstance(evidence, list)
|
|
1190
|
+
or not evidence
|
|
1191
|
+
or not isinstance(correction, Mapping)
|
|
1192
|
+
or not correction
|
|
1193
|
+
):
|
|
1194
|
+
raise RuntimeError("The assessment report contains a malformed candidate.")
|
|
1195
|
+
if route == "simplify" and (
|
|
1196
|
+
raw_candidate.get("category") != "simplification"
|
|
1197
|
+
or raw_candidate.get("action")
|
|
1198
|
+
not in {"delete", "consolidate", "reuse", "simplify"}
|
|
1199
|
+
or not isinstance(raw_candidate.get("binding"), Mapping)
|
|
1200
|
+
or raw_candidate["binding"].get("source_digest") != source_digest
|
|
1201
|
+
or not isinstance(raw_candidate.get("validation"), Mapping)
|
|
1202
|
+
or not raw_candidate["validation"]
|
|
1203
|
+
or not isinstance(raw_candidate.get("public_interface"), Mapping)
|
|
1204
|
+
or raw_candidate["public_interface"].get("published") is not False
|
|
1205
|
+
or not isinstance(raw_candidate.get("rollback"), Mapping)
|
|
1206
|
+
or not raw_candidate["rollback"]
|
|
1207
|
+
):
|
|
1208
|
+
raise RuntimeError(
|
|
1209
|
+
"The assessment report contains an incompatible simplify candidate."
|
|
1210
|
+
)
|
|
1211
|
+
candidate_path_count += len(raw_paths)
|
|
1212
|
+
if candidate_path_count > MAX_PATH_COUNT:
|
|
1213
|
+
raise RuntimeError("The assessment exceeded its candidate-path limit.")
|
|
1214
|
+
if (
|
|
1215
|
+
any(CANDIDATE_ID.fullmatch(item) is None for item in dependencies)
|
|
1216
|
+
or len(dependencies) != len(set(dependencies))
|
|
1217
|
+
or candidate_id in dependencies
|
|
1218
|
+
):
|
|
1219
|
+
raise RuntimeError("A repair candidate dependency is malformed.")
|
|
1220
|
+
candidates[candidate_id] = (
|
|
1221
|
+
[normalize_repo_tree_path(path) for path in raw_paths],
|
|
1222
|
+
raw_candidate,
|
|
1223
|
+
)
|
|
1224
|
+
unknown = [
|
|
1225
|
+
candidate_id for candidate_id in candidate_ids if candidate_id not in candidates
|
|
1226
|
+
]
|
|
1227
|
+
if unknown:
|
|
1228
|
+
raise RuntimeError(f"The repair candidate is unknown: '{unknown[0]}'.")
|
|
1229
|
+
selected_set = set(candidate_ids)
|
|
1230
|
+
ordered_closure_ids: list[str] = []
|
|
1231
|
+
visiting: set[str] = set()
|
|
1232
|
+
visited: set[str] = set()
|
|
1233
|
+
|
|
1234
|
+
def visit(candidate_id: str) -> None:
|
|
1235
|
+
if candidate_id in visiting:
|
|
1236
|
+
raise RuntimeError("The repair candidate dependency graph is cyclic.")
|
|
1237
|
+
if candidate_id in visited:
|
|
1238
|
+
return
|
|
1239
|
+
visiting.add(candidate_id)
|
|
1240
|
+
candidate = candidates[candidate_id][1]
|
|
1241
|
+
for dependency in cast(list[str], candidate["dependencies"]):
|
|
1242
|
+
dependency_record = candidates.get(dependency)
|
|
1243
|
+
if dependency_record is None or (
|
|
1244
|
+
dependency not in selected_set
|
|
1245
|
+
and dependency_record[1].get("status") != "resolved"
|
|
1246
|
+
):
|
|
1247
|
+
raise RuntimeError("A repair candidate dependency is not satisfied.")
|
|
1248
|
+
visit(dependency)
|
|
1249
|
+
visiting.remove(candidate_id)
|
|
1250
|
+
visited.add(candidate_id)
|
|
1251
|
+
ordered_closure_ids.append(candidate_id)
|
|
1252
|
+
|
|
1253
|
+
for candidate_id in candidate_ids:
|
|
1254
|
+
visit(candidate_id)
|
|
1255
|
+
ordered_ids = [
|
|
1256
|
+
candidate_id
|
|
1257
|
+
for candidate_id in ordered_closure_ids
|
|
1258
|
+
if candidate_id in selected_set
|
|
1259
|
+
]
|
|
1260
|
+
paths = sorted(
|
|
1261
|
+
{path for candidate_id in ordered_ids for path in candidates[candidate_id][0]}
|
|
1262
|
+
)
|
|
1263
|
+
if len(paths) > MAX_PATH_COUNT:
|
|
1264
|
+
raise RuntimeError("The assessment exceeded its path limit.")
|
|
1265
|
+
selected = [candidates[candidate_id][1] for candidate_id in ordered_ids]
|
|
1266
|
+
if any(candidate.get("status") != "open" for candidate in selected):
|
|
1267
|
+
raise RuntimeError("A selected repair candidate is not open.")
|
|
1268
|
+
closure = [candidates[candidate_id][1] for candidate_id in ordered_closure_ids]
|
|
1269
|
+
return ordered_ids, paths, closure
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _path_is_in_scope(path: str, scopes: Sequence[str]) -> bool:
|
|
1273
|
+
"""Return whether a path is inside one declared repository-tree scope."""
|
|
1274
|
+
return "." in scopes or any(
|
|
1275
|
+
path == scope or path.startswith(f"{scope}/") for scope in scopes
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def _verify_candidate_evidence(
|
|
1280
|
+
repository_root: Path,
|
|
1281
|
+
source: Mapping[str, Any],
|
|
1282
|
+
candidates: Sequence[Mapping[str, Any]],
|
|
1283
|
+
*,
|
|
1284
|
+
deadline: float | None = None,
|
|
1285
|
+
) -> None:
|
|
1286
|
+
"""Re-read exact candidate files and verify their recorded content evidence."""
|
|
1287
|
+
target = _target_path(cast(str | None, source.get("target")))
|
|
1288
|
+
scopes = (target, *_binding_evidence_paths(source))
|
|
1289
|
+
source_kind = source.get("source_kind")
|
|
1290
|
+
if source_kind == "worktree_overlay":
|
|
1291
|
+
_verify_worktree_binding(repository_root, source, deadline=deadline)
|
|
1292
|
+
total_bytes = 0
|
|
1293
|
+
for candidate in candidates:
|
|
1294
|
+
paths = [normalize_repo_tree_path(path) for path in candidate["paths"]]
|
|
1295
|
+
if any(not _path_is_in_scope(path, scopes) for path in paths):
|
|
1296
|
+
raise RuntimeError("A repair candidate path is outside the assessed scope.")
|
|
1297
|
+
raw_evidence = candidate["evidence"]
|
|
1298
|
+
if not isinstance(raw_evidence, list):
|
|
1299
|
+
raise TypeError("A repair candidate has malformed evidence.")
|
|
1300
|
+
evidence: dict[str, str] = {}
|
|
1301
|
+
for item in raw_evidence:
|
|
1302
|
+
if (
|
|
1303
|
+
not isinstance(item, Mapping)
|
|
1304
|
+
or not isinstance(item.get("path"), str)
|
|
1305
|
+
or not isinstance(item.get("content_sha256"), str)
|
|
1306
|
+
):
|
|
1307
|
+
raise TypeError("A repair candidate has malformed evidence.")
|
|
1308
|
+
path = normalize_repo_tree_path(cast(str, item["path"]))
|
|
1309
|
+
digest = cast(str, item["content_sha256"])
|
|
1310
|
+
if path in evidence or re.fullmatch(r"[0-9a-f]{64}", digest) is None:
|
|
1311
|
+
raise RuntimeError("A repair candidate has malformed evidence.")
|
|
1312
|
+
evidence[path] = digest
|
|
1313
|
+
if set(evidence) != set(paths):
|
|
1314
|
+
raise RuntimeError("A repair candidate does not bind each affected path.")
|
|
1315
|
+
for path in paths:
|
|
1316
|
+
if source_kind == "selected_commit_tree":
|
|
1317
|
+
content = snapshot_file_entry(
|
|
1318
|
+
repository_root, source, path, deadline=deadline
|
|
1319
|
+
).content
|
|
1320
|
+
else:
|
|
1321
|
+
content = _worktree_snapshot_entry(
|
|
1322
|
+
repository_root, path, deadline=deadline
|
|
1323
|
+
).content
|
|
1324
|
+
total_bytes += len(content)
|
|
1325
|
+
if total_bytes > MAX_TOTAL_BYTES:
|
|
1326
|
+
raise RuntimeError("The assessment exceeded its aggregate byte limit.")
|
|
1327
|
+
if hashlib.sha256(content).hexdigest() != evidence[path]:
|
|
1328
|
+
raise RuntimeError("A repair candidate has stale content evidence.")
|
|
1329
|
+
if source_kind == "worktree_overlay":
|
|
1330
|
+
_verify_worktree_binding(repository_root, source, deadline=deadline)
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def _verify_validation_manifest(validation: Any, source_digest: str) -> None:
|
|
1334
|
+
"""Require complete successful receipts for the exact assessment source."""
|
|
1335
|
+
if not isinstance(validation, Mapping):
|
|
1336
|
+
raise TypeError("The assessment report is not repair-eligible.")
|
|
1337
|
+
receipts = validation.get("receipts")
|
|
1338
|
+
if not isinstance(receipts, list):
|
|
1339
|
+
raise TypeError("The assessment report is not repair-eligible.")
|
|
1340
|
+
rebuilt = validation_manifest(
|
|
1341
|
+
status=cast(str, validation.get("status")),
|
|
1342
|
+
source_digest=source_digest,
|
|
1343
|
+
reason=cast(str | None, validation.get("reason")),
|
|
1344
|
+
receipts=receipts,
|
|
1345
|
+
)
|
|
1346
|
+
if dict(validation) != rebuilt or not rebuilt["repair_eligibility"]:
|
|
1347
|
+
raise RuntimeError("The assessment report is not repair-eligible.")
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def _candidate_overlap(
|
|
1351
|
+
repository_root: Path,
|
|
1352
|
+
paths: Sequence[str],
|
|
1353
|
+
*,
|
|
1354
|
+
deadline: float | None = None,
|
|
1355
|
+
) -> bool:
|
|
1356
|
+
"""Return whether candidate paths overlap mutable checkout state."""
|
|
1357
|
+
status = _git_bytes(
|
|
1358
|
+
repository_root,
|
|
1359
|
+
"status",
|
|
1360
|
+
"--porcelain=v1",
|
|
1361
|
+
"-z",
|
|
1362
|
+
"--untracked-files=all",
|
|
1363
|
+
"--",
|
|
1364
|
+
*(f":(top,literal){path}" for path in paths),
|
|
1365
|
+
deadline=deadline,
|
|
1366
|
+
)
|
|
1367
|
+
return bool(status)
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def repair_preflight(
|
|
1371
|
+
repository_root: Path,
|
|
1372
|
+
report: Mapping[str, Any],
|
|
1373
|
+
candidate_ids: Sequence[str],
|
|
1374
|
+
*,
|
|
1375
|
+
approved_report_digest: str | None = None,
|
|
1376
|
+
_deadline: float | None = None,
|
|
1377
|
+
) -> dict[str, Any]:
|
|
1378
|
+
"""Rebind a report and reject stale evidence or overlapping user work."""
|
|
1379
|
+
deadline = (
|
|
1380
|
+
time.monotonic() + ASSESSMENT_TIMEOUT_SECONDS
|
|
1381
|
+
if _deadline is None
|
|
1382
|
+
else _deadline
|
|
1383
|
+
)
|
|
1384
|
+
root = _repository_root(repository_root, deadline=deadline)
|
|
1385
|
+
schema_version = report.get("schema_version")
|
|
1386
|
+
if isinstance(schema_version, bool) or schema_version != ASSESSMENT_SCHEMA_VERSION:
|
|
1387
|
+
raise RuntimeError("The assessment report schema version is not supported.")
|
|
1388
|
+
source = report.get("source")
|
|
1389
|
+
if not isinstance(source, Mapping):
|
|
1390
|
+
raise TypeError("The assessment report source binding is malformed.")
|
|
1391
|
+
if not isinstance(
|
|
1392
|
+
approved_report_digest, str
|
|
1393
|
+
) or approved_report_digest != assessment_report_digest(report):
|
|
1394
|
+
raise RuntimeError(
|
|
1395
|
+
"The approved assessment report binding is missing or stale."
|
|
1396
|
+
)
|
|
1397
|
+
selected_ids, paths, candidates = _selected_candidates(report, candidate_ids)
|
|
1398
|
+
source_digest = source.get("source_digest")
|
|
1399
|
+
if not isinstance(source_digest, str):
|
|
1400
|
+
raise TypeError("The assessment source digest is malformed.")
|
|
1401
|
+
_verify_validation_manifest(report.get("validation"), source_digest)
|
|
1402
|
+
source_kind = source.get("source_kind")
|
|
1403
|
+
isolated_start: str | None = None
|
|
1404
|
+
if source_kind == "selected_commit_tree":
|
|
1405
|
+
isolated_start, _ = _verify_selected_binding(root, source, deadline=deadline)
|
|
1406
|
+
elif source_kind == "worktree_overlay":
|
|
1407
|
+
_verify_worktree_binding(root, source, deadline=deadline)
|
|
1408
|
+
else:
|
|
1409
|
+
raise RuntimeError("The assessment report source kind is not valid.")
|
|
1410
|
+
_verify_candidate_evidence(root, source, candidates, deadline=deadline)
|
|
1411
|
+
if _candidate_overlap(root, paths, deadline=deadline):
|
|
1412
|
+
raise RuntimeError("A repair candidate path overlaps existing work.")
|
|
1413
|
+
return {
|
|
1414
|
+
"status": "eligible",
|
|
1415
|
+
"source_kind": source_kind,
|
|
1416
|
+
"candidate_ids": selected_ids,
|
|
1417
|
+
"candidate_paths": paths,
|
|
1418
|
+
"isolated_worktree_start_oid": isolated_start,
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def _read_report(path: Path, *, deadline: float | None = None) -> Mapping[str, Any]:
|
|
1423
|
+
"""Read one JSON assessment report."""
|
|
1424
|
+
absolute_path = Path(os.path.abspath(os.fspath(path)))
|
|
1425
|
+
root = absolute_path.parent
|
|
1426
|
+
relative_path = absolute_path.name
|
|
1427
|
+
if not relative_path:
|
|
1428
|
+
raise RuntimeError("The assessment report path does not name a file.")
|
|
1429
|
+
raw_document = _bounded_file_snapshot(
|
|
1430
|
+
root,
|
|
1431
|
+
relative_path,
|
|
1432
|
+
deadline=deadline,
|
|
1433
|
+
byte_limit=MAX_FILE_BYTES,
|
|
1434
|
+
).content
|
|
1435
|
+
document = json.loads(raw_document.decode("utf-8"))
|
|
1436
|
+
if not isinstance(document, Mapping):
|
|
1437
|
+
raise TypeError("The assessment report must be a JSON object.")
|
|
1438
|
+
return document
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
1442
|
+
"""Emit one source binding or verify one repair preflight."""
|
|
1443
|
+
raw_arguments = list(sys.argv[1:] if argv is None else argv)
|
|
1444
|
+
if raw_arguments[:1] == [INTERNAL_FILE_READ_COMMAND]:
|
|
1445
|
+
try:
|
|
1446
|
+
if len(raw_arguments) != 4:
|
|
1447
|
+
raise RuntimeError("The bounded source-file request is malformed.")
|
|
1448
|
+
byte_limit = int(raw_arguments[3])
|
|
1449
|
+
if byte_limit < 0:
|
|
1450
|
+
raise RuntimeError("The bounded source-file limit is not valid.")
|
|
1451
|
+
return _internal_read_bound_file(
|
|
1452
|
+
Path(raw_arguments[1]), raw_arguments[2], byte_limit
|
|
1453
|
+
)
|
|
1454
|
+
except (OSError, RuntimeError, ValueError) as error:
|
|
1455
|
+
print(f"error: {error}", file=sys.stderr)
|
|
1456
|
+
return 1
|
|
1457
|
+
parser = argument_parser(description=__doc__)
|
|
1458
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1459
|
+
bind_parser = subparsers.add_parser(
|
|
1460
|
+
"bind", help="Bind one read-only assessment source."
|
|
1461
|
+
)
|
|
1462
|
+
bind_parser.add_argument("target", nargs="?", default=".")
|
|
1463
|
+
bind_parser.add_argument("--ref", dest="reference")
|
|
1464
|
+
bind_parser.add_argument("--guidance", action="append", default=[], metavar="PATH")
|
|
1465
|
+
preflight_parser = subparsers.add_parser(
|
|
1466
|
+
"repair-preflight", help="Verify selected repair candidates."
|
|
1467
|
+
)
|
|
1468
|
+
preflight_parser.add_argument("report", type=Path)
|
|
1469
|
+
preflight_parser.add_argument(
|
|
1470
|
+
"--candidate", action="append", required=True, dest="candidate_ids"
|
|
1471
|
+
)
|
|
1472
|
+
preflight_parser.add_argument("--approved-report-digest", required=True)
|
|
1473
|
+
arguments = parser.parse_args(raw_arguments)
|
|
1474
|
+
try:
|
|
1475
|
+
deadline = (
|
|
1476
|
+
time.monotonic() + ASSESSMENT_TIMEOUT_SECONDS
|
|
1477
|
+
if arguments.command == "repair-preflight"
|
|
1478
|
+
else None
|
|
1479
|
+
)
|
|
1480
|
+
repository_root = Path(
|
|
1481
|
+
_git_text(Path.cwd(), "rev-parse", "--show-toplevel", deadline=deadline)
|
|
1482
|
+
)
|
|
1483
|
+
if arguments.command == "bind":
|
|
1484
|
+
source = resolve_source_binding(
|
|
1485
|
+
repository_root,
|
|
1486
|
+
reference=arguments.reference,
|
|
1487
|
+
target=arguments.target,
|
|
1488
|
+
evidence_paths=arguments.guidance,
|
|
1489
|
+
)
|
|
1490
|
+
result = {
|
|
1491
|
+
"schema_version": ASSESSMENT_SCHEMA_VERSION,
|
|
1492
|
+
"source": source,
|
|
1493
|
+
"inventory": inventory_manifest(repository_root, source),
|
|
1494
|
+
"guidance": guidance_snapshot_manifest(
|
|
1495
|
+
repository_root, source, arguments.guidance
|
|
1496
|
+
),
|
|
1497
|
+
"validation": validation_manifest(
|
|
1498
|
+
status="not_run",
|
|
1499
|
+
source_digest=source["source_digest"],
|
|
1500
|
+
reason="Validation execution was not requested by this binding command.",
|
|
1501
|
+
),
|
|
1502
|
+
}
|
|
1503
|
+
else:
|
|
1504
|
+
result = repair_preflight(
|
|
1505
|
+
repository_root,
|
|
1506
|
+
_read_report(arguments.report, deadline=deadline),
|
|
1507
|
+
arguments.candidate_ids,
|
|
1508
|
+
approved_report_digest=arguments.approved_report_digest,
|
|
1509
|
+
_deadline=deadline,
|
|
1510
|
+
)
|
|
1511
|
+
except (
|
|
1512
|
+
OSError,
|
|
1513
|
+
RuntimeError,
|
|
1514
|
+
TypeError,
|
|
1515
|
+
UnicodeError,
|
|
1516
|
+
json.JSONDecodeError,
|
|
1517
|
+
) as error:
|
|
1518
|
+
print(f"error: {error}", file=sys.stderr)
|
|
1519
|
+
return 1
|
|
1520
|
+
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
1521
|
+
return 0
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
if __name__ == "__main__":
|
|
1525
|
+
raise SystemExit(main())
|