autoforge-cli 0.1.0a0__py3-none-any.whl
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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1253 @@
|
|
|
1
|
+
"""Independent verification of the local working tree (LOCAL mode).
|
|
2
|
+
|
|
3
|
+
In REMOTE mode GitHub is the source of truth: a `CONTROL_RESULT` claiming a
|
|
4
|
+
PR exists is checked against `gh`. LOCAL mode has no GitHub, so the working
|
|
5
|
+
tree itself is the source of truth and this module is the only place that
|
|
6
|
+
reads it. The engine never shells out to `git` directly.
|
|
7
|
+
|
|
8
|
+
Everything here is a *read*. The controller never commits, stages, resets,
|
|
9
|
+
checks out, stashes or discards anything: a local run leaves the operator's
|
|
10
|
+
work exactly where it found it, and an implementation may legitimately
|
|
11
|
+
consist of uncommitted (even untracked) files.
|
|
12
|
+
|
|
13
|
+
Workspace identity
|
|
14
|
+
------------------
|
|
15
|
+
A review is only meaningful if the controller can say *which bytes* were
|
|
16
|
+
reviewed and prove they did not change afterwards. That is the workspace
|
|
17
|
+
snapshot, and it is defined by one rule:
|
|
18
|
+
|
|
19
|
+
The snapshot covers **every entry in the working tree**, found by the
|
|
20
|
+
controller walking the tree itself, and the only entries it does not
|
|
21
|
+
cover are the ones an explicitly enumerated, fingerprinted exclusion rule
|
|
22
|
+
removes. There is no third category.
|
|
23
|
+
|
|
24
|
+
This is deliberately *not* built on ``git status``. Git reports what is
|
|
25
|
+
interesting to git, which is a moving target: an ignored file, a file marked
|
|
26
|
+
``assume-unchanged`` or ``skip-worktree``, a mode change under
|
|
27
|
+
``core.fileMode=false``, a clean tracked file, the contents of a submodule —
|
|
28
|
+
each is invisible to some git invocation while being perfectly able to change
|
|
29
|
+
what a reviewer concluded. Asking git "what changed?" and fingerprinting the
|
|
30
|
+
answer makes the snapshot's completeness a property of git's configuration,
|
|
31
|
+
which the agent under review can edit. The controller walks the tree instead,
|
|
32
|
+
so no git setting, index flag or ignore rule can subtract from the snapshot.
|
|
33
|
+
|
|
34
|
+
Git keeps exactly one job in LOCAL mode: telling the controller where the
|
|
35
|
+
repository is (its working tree and its git directory) and what its *anchor*
|
|
36
|
+
is (HEAD and the checked-out branch). Those are separate facts, checked
|
|
37
|
+
separately; no decision about which bytes are bound depends on git.
|
|
38
|
+
|
|
39
|
+
Classification is total. Every entry the walk meets is exactly one of:
|
|
40
|
+
|
|
41
|
+
=================== =========================================================
|
|
42
|
+
regular file included: permission bits + SHA-256 of its bytes
|
|
43
|
+
directory included: permission bits; the walk descends into it
|
|
44
|
+
symbolic link included: SHA-256 of its *link text*, and the target must
|
|
45
|
+
resolve inside the working tree and must neither lie in
|
|
46
|
+
an excluded region nor *contain* one — a link to bytes
|
|
47
|
+
the snapshot does not cover, or to a directory through
|
|
48
|
+
which such bytes can be reached at an unexcluded path,
|
|
49
|
+
would let those bytes change without the fingerprint
|
|
50
|
+
moving
|
|
51
|
+
excluded an enumerated rule matched: the rule and the path are
|
|
52
|
+
recorded *in* the fingerprint, so what is not covered is
|
|
53
|
+
itself part of the workspace's identity
|
|
54
|
+
anything else refused (fail closed): FIFOs, sockets, devices,
|
|
55
|
+
unreadable entries, and nested git repositories
|
|
56
|
+
=================== =========================================================
|
|
57
|
+
|
|
58
|
+
There is no "git did not report it, so the controller did not see it" state.
|
|
59
|
+
|
|
60
|
+
Exclusion rules
|
|
61
|
+
---------------
|
|
62
|
+
Exactly two kinds of rule exist, and both are recorded in the fingerprint:
|
|
63
|
+
|
|
64
|
+
``gitdir``
|
|
65
|
+
This repository's own git directory, matched by ``(st_dev, st_ino)``
|
|
66
|
+
rather than by the name ``.git`` — a name is not evidence. (A linked
|
|
67
|
+
worktree's ``.git`` *file* at the top level is matched too: it is the
|
|
68
|
+
pointer git itself put there.) Any **other** entry named ``.git`` is
|
|
69
|
+
refused: a submodule or a nested repository holds a whole second tree
|
|
70
|
+
whose contents git will not show through the outer one, and LOCAL v1
|
|
71
|
+
does not support binding it. Fail closed, not silently unbound.
|
|
72
|
+
|
|
73
|
+
``exclude:<pattern>``
|
|
74
|
+
An operator-declared pattern from ``local.exclude``. This exists because
|
|
75
|
+
a complete walk really is complete: ``.venv/``, ``node_modules/`` and
|
|
76
|
+
``__pycache__/`` are in the working tree, the test suite the controller
|
|
77
|
+
itself runs rewrites them, and no amount of cleverness makes "the
|
|
78
|
+
reviewer changed nothing" true over bytes that a build tool rewrites.
|
|
79
|
+
The honest answer is an explicit, operator-visible list that is hashed
|
|
80
|
+
into the fingerprint and named to the reviewer in its prompt — not a
|
|
81
|
+
built-in default that quietly decides which bytes do not count.
|
|
82
|
+
|
|
83
|
+
A LOCAL state directory is **not** an exclusion rule, because it is not
|
|
84
|
+
inside the reviewed tree: see :meth:`LocalWorkspace.check_state_dir_location`.
|
|
85
|
+
|
|
86
|
+
Cost
|
|
87
|
+
----
|
|
88
|
+
A complete walk is bounded by ``local.max_workspace_entries`` and
|
|
89
|
+
``local.max_workspace_bytes``. Exceeding either is a *refusal* naming the
|
|
90
|
+
largest subtrees seen so far and the exact YAML to paste — never a silent
|
|
91
|
+
downgrade to metadata-only hashing, which would accept an equal-sized
|
|
92
|
+
replacement with a restored mtime.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
from __future__ import annotations
|
|
96
|
+
|
|
97
|
+
import hashlib
|
|
98
|
+
import os
|
|
99
|
+
import posixpath
|
|
100
|
+
import re
|
|
101
|
+
import stat
|
|
102
|
+
from collections.abc import Callable, Iterable
|
|
103
|
+
from dataclasses import dataclass
|
|
104
|
+
from fnmatch import fnmatchcase
|
|
105
|
+
from pathlib import Path
|
|
106
|
+
|
|
107
|
+
from .errors import ConfigurationError, VerificationError
|
|
108
|
+
from .executor import ExecutionRequest, ExecutionResult, execute
|
|
109
|
+
from .git_transport import local_git_request
|
|
110
|
+
from .prompts import escape_inline
|
|
111
|
+
from .run_contract import WorkspacePolicy
|
|
112
|
+
from .safefs import (
|
|
113
|
+
ReadLimitExceeded,
|
|
114
|
+
SafeRoot,
|
|
115
|
+
UnreadableEntryError,
|
|
116
|
+
WalkBudgetExceeded,
|
|
117
|
+
entry_kind,
|
|
118
|
+
open_regular_at,
|
|
119
|
+
readlink_at,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
Runner = Callable[[ExecutionRequest], ExecutionResult]
|
|
123
|
+
|
|
124
|
+
GIT_TIMEOUT_SECONDS = 60
|
|
125
|
+
|
|
126
|
+
# Bytes read per file when hashing working-tree content.
|
|
127
|
+
_CHUNK = 1024 * 1024
|
|
128
|
+
|
|
129
|
+
FEATURE_SPEC_SUFFIXES = (".md", ".markdown")
|
|
130
|
+
|
|
131
|
+
#: Bumped whenever the *meaning* of a fingerprint changes, so a fingerprint
|
|
132
|
+
#: persisted by an older controller can never compare equal to a new one.
|
|
133
|
+
SNAPSHOT_TAG = "autoforge-workspace-v4"
|
|
134
|
+
|
|
135
|
+
DEFAULT_MAX_ENTRIES = 50_000
|
|
136
|
+
DEFAULT_MAX_BYTES = 512 * 1024 * 1024
|
|
137
|
+
|
|
138
|
+
# A full object name as `git rev-parse` prints it (40 hex for SHA-1, 64 for a
|
|
139
|
+
# SHA-256 repository). Anything else from a successful read is not an anchor.
|
|
140
|
+
_OBJECT_NAME_RE = re.compile(r"^[0-9a-f]{40}(?:[0-9a-f]{24})?$")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def hash_bytes(data: bytes) -> str:
|
|
144
|
+
return hashlib.sha256(data).hexdigest()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# -- exclusion rules ---------------------------------------------------------
|
|
148
|
+
def normalize_exclude_pattern(pattern: str) -> str:
|
|
149
|
+
"""Canonical text of one ``local.exclude`` pattern (ConfigurationError if unusable).
|
|
150
|
+
|
|
151
|
+
The canonical form is what gets hashed into the fingerprint, so two
|
|
152
|
+
spellings of the same rule must not produce two different workspace
|
|
153
|
+
identities.
|
|
154
|
+
"""
|
|
155
|
+
if not isinstance(pattern, str):
|
|
156
|
+
raise ConfigurationError(f"local.exclude entry must be a string, got {pattern!r}")
|
|
157
|
+
text = pattern.strip().replace(os.sep, "/").strip("/")
|
|
158
|
+
if not text:
|
|
159
|
+
raise ConfigurationError("local.exclude entry must not be empty")
|
|
160
|
+
if "\0" in text:
|
|
161
|
+
raise ConfigurationError(f"local.exclude entry {pattern!r} contains a NUL byte")
|
|
162
|
+
parts = [p for p in text.split("/") if p != ""]
|
|
163
|
+
if any(p in (".", "..") for p in parts):
|
|
164
|
+
raise ConfigurationError(
|
|
165
|
+
f"local.exclude entry {pattern!r} may not contain '.' or '..': patterns are "
|
|
166
|
+
"matched against paths relative to the repository root"
|
|
167
|
+
)
|
|
168
|
+
return "/".join(parts)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _match_components(pattern: tuple[str, ...], path: tuple[str, ...]) -> bool:
|
|
172
|
+
"""Component-wise glob match with ``**`` spanning zero or more components."""
|
|
173
|
+
if not pattern:
|
|
174
|
+
return not path
|
|
175
|
+
head, rest = pattern[0], pattern[1:]
|
|
176
|
+
if head == "**":
|
|
177
|
+
# Zero components, or one component consumed and try again.
|
|
178
|
+
if _match_components(rest, path):
|
|
179
|
+
return True
|
|
180
|
+
return bool(path) and _match_components(pattern, path[1:])
|
|
181
|
+
if not path:
|
|
182
|
+
return False
|
|
183
|
+
if not fnmatchcase(path[0], head):
|
|
184
|
+
return False
|
|
185
|
+
return _match_components(rest, path[1:])
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class Exclusion:
|
|
190
|
+
"""One reason a path is not covered by the snapshot."""
|
|
191
|
+
|
|
192
|
+
path: str # repository-relative, "/"-separated
|
|
193
|
+
rule: str # canonical rule text, e.g. "gitdir" or "exclude:.venv"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# -- snapshot ----------------------------------------------------------------
|
|
197
|
+
KIND_FILE = "file"
|
|
198
|
+
KIND_DIR = "dir"
|
|
199
|
+
KIND_LINK = "link"
|
|
200
|
+
KIND_EXCLUDED = "excluded"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True)
|
|
204
|
+
class WorkspaceEntry:
|
|
205
|
+
"""One classified entry of the working tree.
|
|
206
|
+
|
|
207
|
+
``digest`` is the SHA-256 of a regular file's bytes, or of a symbolic
|
|
208
|
+
link's target text; it is empty for a directory and for an excluded path.
|
|
209
|
+
``detail`` carries a directory's permission bits and, for an excluded
|
|
210
|
+
path, the rule that excluded it.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
path: str
|
|
214
|
+
kind: str
|
|
215
|
+
mode: str
|
|
216
|
+
digest: str
|
|
217
|
+
detail: str = ""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass(frozen=True)
|
|
221
|
+
class WorkspaceSnapshot:
|
|
222
|
+
"""Exactly which bytes the working tree held when this was taken."""
|
|
223
|
+
|
|
224
|
+
root: Path
|
|
225
|
+
head_sha: str
|
|
226
|
+
branch: str
|
|
227
|
+
entries: tuple[WorkspaceEntry, ...]
|
|
228
|
+
rules: tuple[str, ...]
|
|
229
|
+
fingerprint: str
|
|
230
|
+
total_bytes: int
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def anchor(self) -> str:
|
|
234
|
+
"""HEAD + branch, the git fact bound independently of the tree bytes."""
|
|
235
|
+
head = self.head_sha or "(unborn)"
|
|
236
|
+
branch = self.branch or "(detached)"
|
|
237
|
+
return f"{head}@{branch}"
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def file_count(self) -> int:
|
|
241
|
+
return sum(1 for e in self.entries if e.kind == KIND_FILE)
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def exclusions(self) -> tuple[Exclusion, ...]:
|
|
245
|
+
return tuple(
|
|
246
|
+
Exclusion(path=e.path, rule=e.detail) for e in self.entries if e.kind == KIND_EXCLUDED
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def describe(self, limit: int = 12) -> str:
|
|
250
|
+
"""One line for a prompt or a block reason."""
|
|
251
|
+
files = self.file_count
|
|
252
|
+
dirs = sum(1 for e in self.entries if e.kind == KIND_DIR)
|
|
253
|
+
links = sum(1 for e in self.entries if e.kind == KIND_LINK)
|
|
254
|
+
excluded = self.exclusions
|
|
255
|
+
parts = [
|
|
256
|
+
f"{files} file(s), {dirs} director(ies), {links} symlink(s), "
|
|
257
|
+
f"{self.total_bytes} byte(s) hashed"
|
|
258
|
+
]
|
|
259
|
+
if excluded:
|
|
260
|
+
shown = ", ".join(
|
|
261
|
+
f"{escape_inline(x.path)} [{escape_inline(x.rule)}]" for x in excluded[:limit]
|
|
262
|
+
)
|
|
263
|
+
more = "" if len(excluded) <= limit else f" and {len(excluded) - limit} more"
|
|
264
|
+
parts.append(f"excluded: {shown}{more}")
|
|
265
|
+
return " | ".join(parts)
|
|
266
|
+
|
|
267
|
+
def describe_exclusions(self) -> str:
|
|
268
|
+
"""What the reviewer must be told is *not* bound by the fingerprint."""
|
|
269
|
+
excluded = self.exclusions
|
|
270
|
+
if not excluded:
|
|
271
|
+
return "(none — every entry in the working tree is covered by the fingerprint)"
|
|
272
|
+
return "; ".join(f"{escape_inline(x.path)} [{escape_inline(x.rule)}]" for x in excluded)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _fingerprint(
|
|
276
|
+
entries: Iterable[WorkspaceEntry],
|
|
277
|
+
rules: Iterable[str],
|
|
278
|
+
) -> str:
|
|
279
|
+
"""SHA-256 over the classified tree and the rules that shaped it.
|
|
280
|
+
|
|
281
|
+
Every field is length-prefixed before it is fed to the hash, because a
|
|
282
|
+
path may contain any byte except NUL and ``/`` — including a newline —
|
|
283
|
+
and a separator-delimited encoding would let two different trees produce
|
|
284
|
+
one digest.
|
|
285
|
+
|
|
286
|
+
The git anchor is deliberately *not* part of this: "which bytes are in the
|
|
287
|
+
tree" and "which commit is checked out" are two facts with two different
|
|
288
|
+
failure modes, and mixing them would report a plain ``git commit`` (which
|
|
289
|
+
changes HEAD and nothing else) as "the reviewer modified the working
|
|
290
|
+
tree".
|
|
291
|
+
"""
|
|
292
|
+
h = hashlib.sha256()
|
|
293
|
+
|
|
294
|
+
def feed(value: str) -> None:
|
|
295
|
+
raw = value.encode("utf-8", errors="surrogateescape")
|
|
296
|
+
h.update(str(len(raw)).encode("ascii"))
|
|
297
|
+
h.update(b"\0")
|
|
298
|
+
h.update(raw)
|
|
299
|
+
h.update(b"\0")
|
|
300
|
+
|
|
301
|
+
feed(SNAPSHOT_TAG)
|
|
302
|
+
ordered_rules = sorted(rules)
|
|
303
|
+
feed(str(len(ordered_rules)))
|
|
304
|
+
for rule in ordered_rules:
|
|
305
|
+
feed(rule)
|
|
306
|
+
ordered = sorted(entries, key=lambda e: e.path.encode("utf-8", errors="surrogateescape"))
|
|
307
|
+
feed(str(len(ordered)))
|
|
308
|
+
for entry in ordered:
|
|
309
|
+
feed(entry.kind)
|
|
310
|
+
feed(entry.mode)
|
|
311
|
+
feed(entry.digest)
|
|
312
|
+
feed(entry.detail)
|
|
313
|
+
feed(entry.path)
|
|
314
|
+
return h.hexdigest()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _mode_text(st: os.stat_result) -> str:
|
|
318
|
+
return f"{stat.S_IMODE(st.st_mode):04o}"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _refuse(path: str, what: str, remedy: str) -> VerificationError:
|
|
322
|
+
return VerificationError(
|
|
323
|
+
f"cannot bind the working tree: {path} is {what}. A LOCAL run must be able to say "
|
|
324
|
+
f"exactly which bytes the reviewer reviewed, and it cannot for this entry. {remedy}"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@dataclass(frozen=True)
|
|
329
|
+
class StatusEntry:
|
|
330
|
+
"""One record of ``git status --porcelain=v1 -z``.
|
|
331
|
+
|
|
332
|
+
``code`` is git's two-letter status (``" M"``, ``"A "``, ``"??"``, ...),
|
|
333
|
+
``path`` the path the record names and ``orig_path`` the path a rename or
|
|
334
|
+
copy came from (``""`` for every other record). Paths are as git prints
|
|
335
|
+
them, relative to the working tree's root, and are not verified to exist.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
code: str
|
|
339
|
+
path: str
|
|
340
|
+
orig_path: str = ""
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class LocalWorkspace:
|
|
344
|
+
"""Read-only view of the git working tree a LOCAL run operates on."""
|
|
345
|
+
|
|
346
|
+
def __init__(
|
|
347
|
+
self,
|
|
348
|
+
workdir: str | Path = ".",
|
|
349
|
+
runner: Runner | None = None,
|
|
350
|
+
timeout_seconds: int = GIT_TIMEOUT_SECONDS,
|
|
351
|
+
exclude: Iterable[str] = (),
|
|
352
|
+
max_entries: int = DEFAULT_MAX_ENTRIES,
|
|
353
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
354
|
+
) -> None:
|
|
355
|
+
self.workdir = Path(workdir)
|
|
356
|
+
self._runner: Runner = runner or execute
|
|
357
|
+
self.timeout = timeout_seconds
|
|
358
|
+
self.exclude: tuple[str, ...] = tuple(
|
|
359
|
+
sorted({normalize_exclude_pattern(p) for p in exclude})
|
|
360
|
+
)
|
|
361
|
+
self.max_entries = max_entries
|
|
362
|
+
self.max_bytes = max_bytes
|
|
363
|
+
self._root: Path | None = None
|
|
364
|
+
self._git_dirs: tuple[Path, ...] | None = None
|
|
365
|
+
# Content-digest memo, keyed by identity *and* change indicators. The
|
|
366
|
+
# key is a cache key only: it never becomes part of the fingerprint,
|
|
367
|
+
# so a forged mtime can at worst cost a re-hash, never bind the wrong
|
|
368
|
+
# bytes... and ctime cannot be set backwards without root at all.
|
|
369
|
+
self._digests: dict[tuple[int, int, int, int, int, int], str] = {}
|
|
370
|
+
|
|
371
|
+
# -- reader policy ----------------------------------------------------
|
|
372
|
+
def policy(self) -> WorkspacePolicy:
|
|
373
|
+
"""Everything about this reader that shapes a snapshot, as contract data.
|
|
374
|
+
|
|
375
|
+
A fingerprint binds the working tree *as this reader classifies it*,
|
|
376
|
+
so the reader's configuration is part of what a review means (see
|
|
377
|
+
:class:`autoforge.run_contract.WorkspacePolicy`). The snapshot tag
|
|
378
|
+
is included because the walk's classification rules are part of the
|
|
379
|
+
reader too.
|
|
380
|
+
"""
|
|
381
|
+
return WorkspacePolicy(
|
|
382
|
+
exclude=self.exclude,
|
|
383
|
+
max_entries=self.max_entries,
|
|
384
|
+
max_bytes=self.max_bytes,
|
|
385
|
+
snapshot_tag=SNAPSHOT_TAG,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# -- git plumbing -----------------------------------------------------
|
|
389
|
+
def _git(self, argv: list[str], *, allow_failure: bool = False) -> ExecutionResult:
|
|
390
|
+
res = self._runner(
|
|
391
|
+
# --no-optional-locks: every call here is a read, and a
|
|
392
|
+
# `git status` that refreshes the index would write
|
|
393
|
+
# `.git/index` — a side effect a `--dry-run` must not have.
|
|
394
|
+
# Hardened like every controller git process (ADR 0004 D7.3):
|
|
395
|
+
# the repository's hooks and file-system monitor are the agent's
|
|
396
|
+
# to write, so neither runs, and nothing git starts holds a
|
|
397
|
+
# credential. None of this changes what a read reports.
|
|
398
|
+
local_git_request(
|
|
399
|
+
["--no-optional-locks", *argv],
|
|
400
|
+
cwd=str(self.workdir),
|
|
401
|
+
timeout_seconds=self.timeout,
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
if res.timed_out:
|
|
405
|
+
raise VerificationError(f"`git {' '.join(argv)}` timed out in {self.workdir}")
|
|
406
|
+
if res.truncated:
|
|
407
|
+
# Head + marker + tail of a listing is not the working tree.
|
|
408
|
+
raise VerificationError(
|
|
409
|
+
f"`git {' '.join(argv)}` output in {self.workdir} was truncated at the "
|
|
410
|
+
"executor's capture bound"
|
|
411
|
+
)
|
|
412
|
+
if res.exit_code != 0 and not allow_failure:
|
|
413
|
+
detail = (res.stderr or res.stdout).strip().splitlines()
|
|
414
|
+
raise VerificationError(
|
|
415
|
+
f"`git {' '.join(argv)}` failed in {self.workdir}: "
|
|
416
|
+
f"{detail[-1] if detail else f'exit {res.exit_code}'}"
|
|
417
|
+
)
|
|
418
|
+
return res
|
|
419
|
+
|
|
420
|
+
def root(self) -> Path:
|
|
421
|
+
"""Absolute path of the repository working tree containing ``workdir``."""
|
|
422
|
+
if self._root is None:
|
|
423
|
+
res = self._git(["rev-parse", "--show-toplevel"], allow_failure=True)
|
|
424
|
+
if res.exit_code != 0:
|
|
425
|
+
detail = (res.stderr or res.stdout).strip().splitlines()
|
|
426
|
+
raise VerificationError(
|
|
427
|
+
f"{Path(self.workdir).resolve()} is not inside a git repository "
|
|
428
|
+
f"({detail[-1] if detail else 'git rev-parse failed'}); "
|
|
429
|
+
"local mode operates on a git working tree"
|
|
430
|
+
)
|
|
431
|
+
top = (res.stdout or "").strip()
|
|
432
|
+
if not top:
|
|
433
|
+
raise VerificationError(
|
|
434
|
+
f"git did not report a working tree for {Path(self.workdir).resolve()}"
|
|
435
|
+
)
|
|
436
|
+
self._root = Path(os.path.realpath(top))
|
|
437
|
+
return self._root
|
|
438
|
+
|
|
439
|
+
def git_dirs(self) -> tuple[Path, ...]:
|
|
440
|
+
"""This repository's git directories (per-worktree and common), resolved.
|
|
441
|
+
|
|
442
|
+
Both are needed: in a linked worktree ``--git-dir`` is
|
|
443
|
+
``<main>/.git/worktrees/<name>`` while ``--git-common-dir`` is
|
|
444
|
+
``<main>/.git``, and either can be the directory that physically sits
|
|
445
|
+
inside the tree being walked.
|
|
446
|
+
"""
|
|
447
|
+
if self._git_dirs is None:
|
|
448
|
+
found: list[Path] = []
|
|
449
|
+
for flag in ("--git-dir", "--git-common-dir"):
|
|
450
|
+
res = self._git(["rev-parse", flag], allow_failure=True)
|
|
451
|
+
if res.exit_code != 0:
|
|
452
|
+
raise VerificationError(
|
|
453
|
+
f"cannot locate the git directory of {self.workdir} "
|
|
454
|
+
f"(`git rev-parse {flag}` exited {res.exit_code}); the controller "
|
|
455
|
+
"will not walk a working tree whose git directory it cannot identify, "
|
|
456
|
+
"because it could not then tell the repository's own metadata apart "
|
|
457
|
+
"from reviewable content"
|
|
458
|
+
)
|
|
459
|
+
text = (res.stdout or "").strip()
|
|
460
|
+
if not text:
|
|
461
|
+
raise VerificationError(
|
|
462
|
+
f"`git rev-parse {flag}` returned nothing for {self.workdir}"
|
|
463
|
+
)
|
|
464
|
+
found.append(Path(os.path.realpath(Path(self.workdir) / text)))
|
|
465
|
+
self._git_dirs = tuple(dict.fromkeys(found))
|
|
466
|
+
return self._git_dirs
|
|
467
|
+
|
|
468
|
+
def local_state_dir(self) -> Path:
|
|
469
|
+
"""Where a LOCAL run keeps its runtime state by default.
|
|
470
|
+
|
|
471
|
+
Inside the git directory, which is *outside* the reviewed tree by
|
|
472
|
+
construction. That is the whole point: a state directory in the
|
|
473
|
+
working tree would have to be carved out of the workspace snapshot by
|
|
474
|
+
name, and "these particular file names do not count" is exactly the
|
|
475
|
+
kind of rule an agent writing into the tree can exploit.
|
|
476
|
+
"""
|
|
477
|
+
return self.git_dirs()[0] / "autoforge" / "state"
|
|
478
|
+
|
|
479
|
+
def head_sha(self) -> str:
|
|
480
|
+
"""Current HEAD, or "" when the repository has no commit yet.
|
|
481
|
+
|
|
482
|
+
``rev-parse --verify --quiet`` rather than plain ``rev-parse``: it
|
|
483
|
+
separates "this ref does not resolve" (exit 1, the unborn HEAD of a
|
|
484
|
+
repository with no commit) from "the read failed" (exit 128: a damaged
|
|
485
|
+
repository, a permissions change, a missing object store). Both used
|
|
486
|
+
to collapse into ``""``, which is also the legitimate value for an
|
|
487
|
+
unborn HEAD — so a failing read looked exactly like "HEAD has not
|
|
488
|
+
moved", and a run whose git identity could no longer be established
|
|
489
|
+
continued instead of failing closed.
|
|
490
|
+
"""
|
|
491
|
+
res = self._git(["rev-parse", "--verify", "--quiet", "HEAD"], allow_failure=True)
|
|
492
|
+
if res.exit_code == 1:
|
|
493
|
+
return "" # unborn HEAD: the ref does not resolve, and that is a fact
|
|
494
|
+
if res.exit_code != 0:
|
|
495
|
+
raise VerificationError(self._anchor_read_failure("HEAD", res))
|
|
496
|
+
sha = (res.stdout or "").strip().lower()
|
|
497
|
+
if not _OBJECT_NAME_RE.match(sha):
|
|
498
|
+
raise VerificationError(
|
|
499
|
+
f"`git rev-parse --verify HEAD` succeeded in {self.workdir} but returned "
|
|
500
|
+
f"{sha!r}, which is not an object name; the run's git anchor cannot be "
|
|
501
|
+
"established and the controller will not continue without it"
|
|
502
|
+
)
|
|
503
|
+
return sha
|
|
504
|
+
|
|
505
|
+
def branch(self) -> str:
|
|
506
|
+
"""Checked-out branch name, or "" when HEAD is detached.
|
|
507
|
+
|
|
508
|
+
``symbolic-ref`` rather than ``rev-parse --abbrev-ref HEAD``: the
|
|
509
|
+
latter reports the literal string "HEAD" for a detached HEAD, which is
|
|
510
|
+
indistinguishable from a branch actually named ``HEAD``. An unborn
|
|
511
|
+
HEAD still has a symbolic ref, so a repository with no commit reports
|
|
512
|
+
its branch normally.
|
|
513
|
+
|
|
514
|
+
``--quiet`` makes a detached HEAD exit 1 silently, which is the one
|
|
515
|
+
non-zero status that means "not a symbolic ref" rather than "the read
|
|
516
|
+
failed"; every other status fails closed, for the reason given in
|
|
517
|
+
:meth:`head_sha`.
|
|
518
|
+
"""
|
|
519
|
+
res = self._git(["symbolic-ref", "--quiet", "--short", "HEAD"], allow_failure=True)
|
|
520
|
+
if res.exit_code == 1:
|
|
521
|
+
return "" # detached HEAD: not a symbolic ref, and that is a fact
|
|
522
|
+
if res.exit_code != 0:
|
|
523
|
+
raise VerificationError(self._anchor_read_failure("the checked-out branch", res))
|
|
524
|
+
name = (res.stdout or "").strip()
|
|
525
|
+
if not name:
|
|
526
|
+
raise VerificationError(
|
|
527
|
+
f"`git symbolic-ref HEAD` succeeded in {self.workdir} but named no branch; "
|
|
528
|
+
"the run's git anchor cannot be established"
|
|
529
|
+
)
|
|
530
|
+
return name
|
|
531
|
+
|
|
532
|
+
def _anchor_read_failure(self, what: str, res: ExecutionResult) -> str:
|
|
533
|
+
detail = (res.stderr or res.stdout or "").strip().splitlines()
|
|
534
|
+
return (
|
|
535
|
+
f"cannot read {what} in {self.workdir} (git exited {res.exit_code}: "
|
|
536
|
+
f"{detail[-1] if detail else 'no output'}). A local run is pinned to the HEAD "
|
|
537
|
+
"and branch it started on, and a failed read is not evidence that they are "
|
|
538
|
+
"unchanged — treating it as one would let the run continue over a repository "
|
|
539
|
+
"whose identity the controller can no longer establish."
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def dirty_paths(self) -> list[str]:
|
|
543
|
+
"""Paths git considers changed, for the *start-up dirty-tree policy* only.
|
|
544
|
+
|
|
545
|
+
This is a policy question ("is the operator's work in the way?"), not
|
|
546
|
+
an identity question: nothing derived from it enters the fingerprint.
|
|
547
|
+
Git's own notion of "changed" is exactly right here — it is the
|
|
548
|
+
operator's mental model of their uncommitted work — and it is exactly
|
|
549
|
+
wrong for identity, which is why the two are separate methods.
|
|
550
|
+
"""
|
|
551
|
+
paths: list[str] = []
|
|
552
|
+
for entry in self.status_entries():
|
|
553
|
+
if entry.orig_path:
|
|
554
|
+
paths.append(entry.orig_path)
|
|
555
|
+
paths.append(entry.path)
|
|
556
|
+
return sorted(dict.fromkeys(paths))
|
|
557
|
+
|
|
558
|
+
def status_entries(self) -> list[StatusEntry]:
|
|
559
|
+
"""The records behind :meth:`dirty_paths`, in git's order, with their codes.
|
|
560
|
+
|
|
561
|
+
The same single ``git status`` read, kept as git reports it rather
|
|
562
|
+
than reduced to a set of paths, for a caller that has to say *how*
|
|
563
|
+
each path is uncommitted (#187). It is the operator's notion of
|
|
564
|
+
uncommitted work, never an identity.
|
|
565
|
+
"""
|
|
566
|
+
res = self._git(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
|
|
567
|
+
out = res.stdout or ""
|
|
568
|
+
fields = out.split("\0")
|
|
569
|
+
entries: list[StatusEntry] = []
|
|
570
|
+
index = 0
|
|
571
|
+
while index < len(fields):
|
|
572
|
+
record = fields[index]
|
|
573
|
+
index += 1
|
|
574
|
+
if not record:
|
|
575
|
+
continue
|
|
576
|
+
if len(record) < 4:
|
|
577
|
+
raise VerificationError(f"cannot parse `git status` record {record!r}")
|
|
578
|
+
code, path, old = record[:2], record[3:], ""
|
|
579
|
+
if code[0] in "RC" or code[1] in "RC":
|
|
580
|
+
# A rename or a copy (`status.renames=copies`) is two
|
|
581
|
+
# NUL-separated fields: "<code> <new>\0<old>". Read as a
|
|
582
|
+
# record of its own, the source path would be cut into a
|
|
583
|
+
# made-up code and path.
|
|
584
|
+
if index < len(fields):
|
|
585
|
+
old = fields[index]
|
|
586
|
+
index += 1
|
|
587
|
+
entries.append(StatusEntry(code=code, path=path, orig_path=old))
|
|
588
|
+
return entries
|
|
589
|
+
|
|
590
|
+
# -- state directory --------------------------------------------------
|
|
591
|
+
def check_state_dir_location(self, state_dir: str | Path) -> None:
|
|
592
|
+
"""Require the state directory to sit outside the reviewed tree.
|
|
593
|
+
|
|
594
|
+
LOCAL mode's workspace snapshot covers the *whole* working tree. A
|
|
595
|
+
state directory inside it would be rewritten by the controller on
|
|
596
|
+
every step, so it would have to be excluded — and an exclusion carved
|
|
597
|
+
out by path is a region of the tree an agent can write into knowing
|
|
598
|
+
the fingerprint will not move. Putting runtime state in the git
|
|
599
|
+
directory removes the problem instead of managing it: the git
|
|
600
|
+
directory is already excluded, by inode identity, for reasons that
|
|
601
|
+
have nothing to do with AutoForge.
|
|
602
|
+
"""
|
|
603
|
+
root = self.root()
|
|
604
|
+
resolved = Path(os.path.realpath(state_dir))
|
|
605
|
+
if not _is_within(resolved, root):
|
|
606
|
+
return
|
|
607
|
+
for git_dir in self.git_dirs():
|
|
608
|
+
if resolved == git_dir or _is_within(resolved, git_dir):
|
|
609
|
+
return
|
|
610
|
+
raise ConfigurationError(
|
|
611
|
+
f"state directory {resolved} is inside the LOCAL working tree {root}. A LOCAL "
|
|
612
|
+
"run fingerprints every byte of the working tree, so runtime state kept there "
|
|
613
|
+
"would either invalidate its own fingerprint on every step or have to be carved "
|
|
614
|
+
"out of it by path — a region an agent could then write into unnoticed. Leave "
|
|
615
|
+
f"--state-dir unset (the default is {self.local_state_dir()}) or point it "
|
|
616
|
+
"outside the repository."
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# -- the snapshot -----------------------------------------------------
|
|
620
|
+
def snapshot(self) -> WorkspaceSnapshot:
|
|
621
|
+
"""Classify every entry of the working tree and fingerprint the result."""
|
|
622
|
+
root = self.root()
|
|
623
|
+
head = self.head_sha()
|
|
624
|
+
branch = self.branch()
|
|
625
|
+
git_ids = self._git_dir_identities()
|
|
626
|
+
entries: list[WorkspaceEntry] = []
|
|
627
|
+
# Every link's resolved target (repository-relative, "." for the
|
|
628
|
+
# root), checked against the excluded entries once the walk is
|
|
629
|
+
# complete: see :meth:`_check_link_reach`.
|
|
630
|
+
link_targets: list[tuple[str, str]] = []
|
|
631
|
+
total_bytes = 0
|
|
632
|
+
by_top: dict[str, int] = {}
|
|
633
|
+
|
|
634
|
+
# One translation point for "the controller may not read this".
|
|
635
|
+
# Whether it is a file it cannot open or a directory it cannot list,
|
|
636
|
+
# unreadable means unbindable, and the answer is the same refusal.
|
|
637
|
+
# The entry budget is the walk's own: it is charged as directory
|
|
638
|
+
# entries are *listed*, before they are sorted, stat'ed or handed
|
|
639
|
+
# here, so a directory of a million names or a nest ten thousand deep
|
|
640
|
+
# costs at most the budget in work and never a RecursionError.
|
|
641
|
+
try:
|
|
642
|
+
with SafeRoot.open(root) as tree:
|
|
643
|
+
for entry in tree.walk(max_entries=self.max_entries):
|
|
644
|
+
top = entry.relpath.split("/", 1)[0]
|
|
645
|
+
by_top[top] = by_top.get(top, 0) + 1
|
|
646
|
+
rule = self._exclusion_rule(entry.relpath, entry.st, git_ids)
|
|
647
|
+
if rule is not None:
|
|
648
|
+
entries.append(
|
|
649
|
+
WorkspaceEntry(
|
|
650
|
+
path=entry.relpath,
|
|
651
|
+
kind=KIND_EXCLUDED,
|
|
652
|
+
mode="",
|
|
653
|
+
digest="",
|
|
654
|
+
detail=rule,
|
|
655
|
+
)
|
|
656
|
+
)
|
|
657
|
+
entry.skip = True
|
|
658
|
+
continue
|
|
659
|
+
if entry.name == ".git":
|
|
660
|
+
raise _refuse(
|
|
661
|
+
entry.relpath,
|
|
662
|
+
"a nested git repository or submodule",
|
|
663
|
+
"LOCAL mode binds one working tree and cannot see inside a second one, "
|
|
664
|
+
"so it refuses rather than reviewing code it cannot pin. Remove it, or "
|
|
665
|
+
f"declare it unreviewed by adding '{posixpath.dirname(entry.relpath)}' "
|
|
666
|
+
"to local.exclude.",
|
|
667
|
+
)
|
|
668
|
+
if entry.is_dir:
|
|
669
|
+
entries.append(
|
|
670
|
+
WorkspaceEntry(
|
|
671
|
+
path=entry.relpath,
|
|
672
|
+
kind=KIND_DIR,
|
|
673
|
+
mode=_mode_text(entry.st),
|
|
674
|
+
digest="",
|
|
675
|
+
)
|
|
676
|
+
)
|
|
677
|
+
elif entry.is_symlink:
|
|
678
|
+
target = readlink_at(entry.dir_fd, entry.name)
|
|
679
|
+
link_targets.append(
|
|
680
|
+
(
|
|
681
|
+
entry.relpath,
|
|
682
|
+
self._check_link_target(entry.relpath, target, root, git_ids),
|
|
683
|
+
)
|
|
684
|
+
)
|
|
685
|
+
entries.append(
|
|
686
|
+
WorkspaceEntry(
|
|
687
|
+
path=entry.relpath,
|
|
688
|
+
kind=KIND_LINK,
|
|
689
|
+
mode="",
|
|
690
|
+
digest=hash_bytes(target.encode("utf-8", errors="surrogateescape")),
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
elif entry.is_regular:
|
|
694
|
+
total_bytes += entry.st.st_size
|
|
695
|
+
if total_bytes > self.max_bytes:
|
|
696
|
+
raise self._too_big(
|
|
697
|
+
f"the working tree holds more than {self.max_bytes} bytes of "
|
|
698
|
+
"regular-file content",
|
|
699
|
+
"local.max_workspace_bytes",
|
|
700
|
+
by_top,
|
|
701
|
+
)
|
|
702
|
+
entries.append(
|
|
703
|
+
WorkspaceEntry(
|
|
704
|
+
path=entry.relpath,
|
|
705
|
+
kind=KIND_FILE,
|
|
706
|
+
mode=_mode_text(entry.st),
|
|
707
|
+
digest=self._digest(
|
|
708
|
+
entry.dir_fd, entry.name, entry.relpath, entry.st
|
|
709
|
+
),
|
|
710
|
+
)
|
|
711
|
+
)
|
|
712
|
+
else:
|
|
713
|
+
raise _refuse(
|
|
714
|
+
entry.relpath,
|
|
715
|
+
f"a {entry_kind(entry.st.st_mode)}",
|
|
716
|
+
"Its contents are not a sequence of bytes the controller can hash, so "
|
|
717
|
+
"it cannot be part of a reviewed snapshot. Move it out of the working "
|
|
718
|
+
"tree, or add it to local.exclude to declare it unreviewed.",
|
|
719
|
+
)
|
|
720
|
+
except WalkBudgetExceeded as exc:
|
|
721
|
+
raise self._too_big(
|
|
722
|
+
f"the working tree has more than {self.max_entries} entries "
|
|
723
|
+
f"(the budget ran out while listing {exc.where})",
|
|
724
|
+
"local.max_workspace_entries",
|
|
725
|
+
by_top,
|
|
726
|
+
) from exc
|
|
727
|
+
except UnreadableEntryError as exc:
|
|
728
|
+
raise _refuse(
|
|
729
|
+
exc.path,
|
|
730
|
+
"not readable by the controller",
|
|
731
|
+
"An entry the controller cannot read is an entry it cannot prove "
|
|
732
|
+
"unchanged. Fix its permissions, or add it to local.exclude to declare "
|
|
733
|
+
"it unreviewed.",
|
|
734
|
+
) from exc
|
|
735
|
+
|
|
736
|
+
self._check_link_reach(link_targets, entries)
|
|
737
|
+
|
|
738
|
+
rules = ("gitdir",) + tuple(f"exclude:{p}" for p in self.exclude)
|
|
739
|
+
return WorkspaceSnapshot(
|
|
740
|
+
root=root,
|
|
741
|
+
head_sha=head,
|
|
742
|
+
branch=branch,
|
|
743
|
+
entries=tuple(entries),
|
|
744
|
+
rules=rules,
|
|
745
|
+
fingerprint=_fingerprint(entries, rules),
|
|
746
|
+
total_bytes=total_bytes,
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
def _git_dir_identities(self) -> set[tuple[int, int]]:
|
|
750
|
+
ids: set[tuple[int, int]] = set()
|
|
751
|
+
for git_dir in self.git_dirs():
|
|
752
|
+
try:
|
|
753
|
+
st = os.lstat(git_dir)
|
|
754
|
+
except OSError as exc:
|
|
755
|
+
raise VerificationError(
|
|
756
|
+
f"cannot inspect the git directory {git_dir}: {exc}. The controller "
|
|
757
|
+
"identifies it by inode, not by name, and will not walk the working tree "
|
|
758
|
+
"without that identity."
|
|
759
|
+
) from exc
|
|
760
|
+
ids.add((st.st_dev, st.st_ino))
|
|
761
|
+
return ids
|
|
762
|
+
|
|
763
|
+
def _exclusion_rule(
|
|
764
|
+
self, relpath: str, st: os.stat_result | None, git_ids: set[tuple[int, int]]
|
|
765
|
+
) -> str | None:
|
|
766
|
+
"""The rule excluding ``relpath``, or ``None`` when it is covered.
|
|
767
|
+
|
|
768
|
+
This is the *only* definition of "excluded from the snapshot": the
|
|
769
|
+
walk asks it about every entry, and the link-target check asks it
|
|
770
|
+
about every ancestor of a resolved target, so the two can never
|
|
771
|
+
disagree about what a reviewer may read unbound. ``st`` is the
|
|
772
|
+
entry's ``lstat``, or ``None`` for a path that does not exist (a
|
|
773
|
+
dangling link's target), for which only the path rules apply.
|
|
774
|
+
|
|
775
|
+
Identity first: the git directory is recognised by ``(st_dev,
|
|
776
|
+
st_ino)``, so a decoy named ``.git`` is not excluded and a git
|
|
777
|
+
directory under any other name still is.
|
|
778
|
+
"""
|
|
779
|
+
if st is not None:
|
|
780
|
+
if stat.S_ISDIR(st.st_mode) and (st.st_dev, st.st_ino) in git_ids:
|
|
781
|
+
return "gitdir"
|
|
782
|
+
if relpath == ".git" and stat.S_ISREG(st.st_mode):
|
|
783
|
+
# A linked worktree's pointer file. It is git's own metadata
|
|
784
|
+
# and it names a directory the walk never enters.
|
|
785
|
+
return "gitdir"
|
|
786
|
+
return self._config_rule(relpath)
|
|
787
|
+
|
|
788
|
+
def _config_rule(self, relpath: str) -> str | None:
|
|
789
|
+
"""The ``local.exclude`` rule covering ``relpath`` or any ancestor of it."""
|
|
790
|
+
parts = tuple(p for p in relpath.split("/") if p)
|
|
791
|
+
for pattern in self.exclude:
|
|
792
|
+
pat = tuple(pattern.split("/"))
|
|
793
|
+
for depth in range(1, len(parts) + 1):
|
|
794
|
+
if _match_components(pat, parts[:depth]):
|
|
795
|
+
return f"exclude:{pattern}"
|
|
796
|
+
return None
|
|
797
|
+
|
|
798
|
+
def _check_link_target(
|
|
799
|
+
self, relpath: str, target: str, root: Path, git_ids: set[tuple[int, int]]
|
|
800
|
+
) -> str:
|
|
801
|
+
"""A symbolic link may only point at bytes this snapshot also covers.
|
|
802
|
+
|
|
803
|
+
The link *text* is what the snapshot binds, so the link itself cannot
|
|
804
|
+
change without moving the fingerprint. What the link resolves to is a
|
|
805
|
+
different question: if it leaves the working tree, or lands in an
|
|
806
|
+
excluded region, the reviewer read bytes that can be replaced with the
|
|
807
|
+
fingerprint standing still.
|
|
808
|
+
|
|
809
|
+
The resolution uses ``realpath``, which follows other links — and that
|
|
810
|
+
is sound here rather than a hole, because *every* link in the tree
|
|
811
|
+
gets this check: if any link on the resolved path escaped, that link
|
|
812
|
+
is itself refused. The conjunction over the whole walk is the
|
|
813
|
+
invariant, not each check in isolation.
|
|
814
|
+
|
|
815
|
+
"Excluded" is decided by :meth:`_exclusion_rule`, asked about every
|
|
816
|
+
ancestor of the resolved target exactly as the walk asks it about
|
|
817
|
+
every entry: a target the walk would have skipped (a git directory
|
|
818
|
+
by inode, a linked worktree's ``.git`` pointer file, a
|
|
819
|
+
``local.exclude`` region) is a target whose bytes are unbound,
|
|
820
|
+
whichever of those rules would have skipped it.
|
|
821
|
+
|
|
822
|
+
Returns the resolved target, repository-relative (``"."`` for the
|
|
823
|
+
root itself). This checks the target and its *ancestors*; what the
|
|
824
|
+
target *contains* is checked by :meth:`_check_link_reach` once the
|
|
825
|
+
walk has listed every excluded entry. The root is not special: it
|
|
826
|
+
has no ancestors, and it contains everything.
|
|
827
|
+
"""
|
|
828
|
+
parent = root / posixpath.dirname(relpath) if posixpath.dirname(relpath) else root
|
|
829
|
+
resolved = Path(os.path.realpath(os.path.join(str(parent), target)))
|
|
830
|
+
if resolved != root and not _is_within(resolved, root):
|
|
831
|
+
raise _refuse(
|
|
832
|
+
relpath,
|
|
833
|
+
f"a symbolic link to {resolved}, which is outside the working tree",
|
|
834
|
+
"The reviewer would read bytes the snapshot cannot bind, so they could be "
|
|
835
|
+
"replaced without the fingerprint changing. Point it inside the repository, "
|
|
836
|
+
"or add it to local.exclude to declare it unreviewed.",
|
|
837
|
+
)
|
|
838
|
+
rel = os.path.relpath(resolved, root).replace(os.sep, "/")
|
|
839
|
+
if rel in (".", ""):
|
|
840
|
+
return "."
|
|
841
|
+
parts = rel.split("/")
|
|
842
|
+
for depth in range(1, len(parts) + 1):
|
|
843
|
+
prefix = "/".join(parts[:depth])
|
|
844
|
+
try:
|
|
845
|
+
st: os.stat_result | None = os.lstat(root.joinpath(*parts[:depth]))
|
|
846
|
+
except OSError:
|
|
847
|
+
# Nothing exists there (yet): only the path rules can apply.
|
|
848
|
+
st = None
|
|
849
|
+
rule = self._exclusion_rule(prefix, st, git_ids)
|
|
850
|
+
if rule is not None:
|
|
851
|
+
raise _refuse(
|
|
852
|
+
relpath,
|
|
853
|
+
f"a symbolic link to {prefix}, which '{rule}' excludes from the snapshot",
|
|
854
|
+
"Its bytes are not bound, so they could change without the fingerprint "
|
|
855
|
+
"moving. Remove the link, or exclude the link itself as well.",
|
|
856
|
+
)
|
|
857
|
+
return rel
|
|
858
|
+
|
|
859
|
+
@staticmethod
|
|
860
|
+
def _check_link_reach(
|
|
861
|
+
link_targets: Iterable[tuple[str, str]], entries: Iterable[WorkspaceEntry]
|
|
862
|
+
) -> None:
|
|
863
|
+
"""No symbolic link may *contain* an excluded entry.
|
|
864
|
+
|
|
865
|
+
:meth:`_check_link_target` refuses a link whose target is excluded or
|
|
866
|
+
lies under an exclusion. The converse escape is a link to a directory
|
|
867
|
+
*above* one: with ``local.exclude: [build]`` and ``src/escape -> ..``,
|
|
868
|
+
the reviewer reads ``src/escape/build/out`` — a path no rule matches
|
|
869
|
+
— and gets the bytes of ``build/out``, which no entry binds. A link
|
|
870
|
+
to the root reaches every exclusion at once (the git directory
|
|
871
|
+
included), so it is refused by the same rule rather than by a
|
|
872
|
+
special case.
|
|
873
|
+
|
|
874
|
+
The excluded entries are exactly the ones the walk recorded as
|
|
875
|
+
:data:`KIND_EXCLUDED`, so "reachable through the link" and "skipped by
|
|
876
|
+
the walk" cannot disagree; the check runs after the walk because an
|
|
877
|
+
exclusion may be listed after the link that reaches it. A link to a
|
|
878
|
+
regular file contains nothing and passes. An exclusion that does not
|
|
879
|
+
exist yet cannot be reached; when it appears, the excluded entry's
|
|
880
|
+
*path* enters the fingerprint (see the module docstring) and the next
|
|
881
|
+
snapshot refuses the link.
|
|
882
|
+
"""
|
|
883
|
+
excluded = [e.path for e in entries if e.kind == KIND_EXCLUDED]
|
|
884
|
+
if not excluded:
|
|
885
|
+
return
|
|
886
|
+
for relpath, rel in link_targets:
|
|
887
|
+
for path in excluded:
|
|
888
|
+
if rel == "." or path == rel or path.startswith(rel + "/"):
|
|
889
|
+
where = "the working tree root" if rel == "." else rel
|
|
890
|
+
raise _refuse(
|
|
891
|
+
relpath,
|
|
892
|
+
f"a symbolic link to {where}, through which {path} (excluded from "
|
|
893
|
+
"the snapshot) can be reached at an unexcluded path",
|
|
894
|
+
"Its bytes are not bound, so they could change without the "
|
|
895
|
+
"fingerprint moving. Remove the link, point it below every "
|
|
896
|
+
"exclusion, or exclude the link itself as well.",
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
def _digest(self, dir_fd: int, name: str, relpath: str, st: os.stat_result) -> str:
|
|
900
|
+
key = (
|
|
901
|
+
st.st_dev,
|
|
902
|
+
st.st_ino,
|
|
903
|
+
st.st_size,
|
|
904
|
+
st.st_mtime_ns,
|
|
905
|
+
st.st_ctime_ns,
|
|
906
|
+
stat.S_IMODE(st.st_mode),
|
|
907
|
+
)
|
|
908
|
+
cached = self._digests.get(key)
|
|
909
|
+
if cached is not None:
|
|
910
|
+
return cached
|
|
911
|
+
try:
|
|
912
|
+
fd = open_regular_at(dir_fd, name, os.O_RDONLY, where=relpath)
|
|
913
|
+
except FileNotFoundError as exc:
|
|
914
|
+
raise _refuse(
|
|
915
|
+
relpath,
|
|
916
|
+
"gone between being listed and being read",
|
|
917
|
+
"Something is writing the working tree while the controller is binding it; "
|
|
918
|
+
"stop it and re-run.",
|
|
919
|
+
) from exc
|
|
920
|
+
h = hashlib.sha256()
|
|
921
|
+
try:
|
|
922
|
+
with os.fdopen(fd, "rb") as fh:
|
|
923
|
+
remaining = st.st_size
|
|
924
|
+
while remaining:
|
|
925
|
+
chunk = fh.read(min(_CHUNK, remaining))
|
|
926
|
+
if not chunk:
|
|
927
|
+
break
|
|
928
|
+
h.update(chunk)
|
|
929
|
+
remaining -= len(chunk)
|
|
930
|
+
if remaining or fh.read(1):
|
|
931
|
+
raise _refuse(
|
|
932
|
+
relpath,
|
|
933
|
+
"grew or shrank while it was being read",
|
|
934
|
+
"Something is writing the working tree while the controller is binding it; "
|
|
935
|
+
"stop it and re-run.",
|
|
936
|
+
)
|
|
937
|
+
final = os.fstat(fh.fileno())
|
|
938
|
+
final_key = (
|
|
939
|
+
final.st_dev,
|
|
940
|
+
final.st_ino,
|
|
941
|
+
final.st_size,
|
|
942
|
+
final.st_mtime_ns,
|
|
943
|
+
final.st_ctime_ns,
|
|
944
|
+
stat.S_IMODE(final.st_mode),
|
|
945
|
+
)
|
|
946
|
+
if final_key != key:
|
|
947
|
+
raise _refuse(
|
|
948
|
+
relpath,
|
|
949
|
+
"changed while it was being read",
|
|
950
|
+
"Something is writing the working tree while the controller is binding it; "
|
|
951
|
+
"stop it and re-run.",
|
|
952
|
+
)
|
|
953
|
+
except OSError as exc:
|
|
954
|
+
raise _refuse(
|
|
955
|
+
relpath, f"unreadable ({exc})", "Fix it, or add it to local.exclude."
|
|
956
|
+
) from exc
|
|
957
|
+
digest = h.hexdigest()
|
|
958
|
+
self._digests[key] = digest
|
|
959
|
+
return digest
|
|
960
|
+
|
|
961
|
+
def _too_big(self, what: str, setting: str, by_top: dict[str, int]) -> VerificationError:
|
|
962
|
+
biggest = sorted(by_top.items(), key=lambda kv: -kv[1])[:8]
|
|
963
|
+
listing = "\n".join(f" - {name} ({n} entries seen so far)" for name, n in biggest)
|
|
964
|
+
return VerificationError(
|
|
965
|
+
f"refusing to fingerprint the working tree: {what}.\n"
|
|
966
|
+
"A LOCAL run binds the review to every byte of the tree, and it will not fall "
|
|
967
|
+
"back to hashing metadata instead — an equal-sized replacement with a restored "
|
|
968
|
+
"mtime would then pass. Either exclude what does not need reviewing:\n"
|
|
969
|
+
" local:\n"
|
|
970
|
+
" exclude:\n"
|
|
971
|
+
" - .venv\n"
|
|
972
|
+
" - '**/__pycache__'\n"
|
|
973
|
+
f"or raise {setting} deliberately.\n"
|
|
974
|
+
f"Largest top-level entries seen so far:\n{listing}"
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
# -- writes into the working tree -------------------------------------
|
|
978
|
+
def tree_root(self) -> SafeRoot:
|
|
979
|
+
"""A write capability on the working tree (caller closes it).
|
|
980
|
+
|
|
981
|
+
The only controller writes into the working tree are the ones
|
|
982
|
+
``autoforge local init`` makes, and they go through the same boundary
|
|
983
|
+
as every other controller write.
|
|
984
|
+
"""
|
|
985
|
+
return SafeRoot.open(self.root())
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _is_within(path: Path, base: Path) -> bool:
|
|
989
|
+
"""True when ``path`` is ``base`` or lies beneath it (both already resolved).
|
|
990
|
+
|
|
991
|
+
Compared component-wise: a textual prefix test would say ``/repo-backup``
|
|
992
|
+
is inside ``/repo``.
|
|
993
|
+
"""
|
|
994
|
+
try:
|
|
995
|
+
path.relative_to(base)
|
|
996
|
+
except ValueError:
|
|
997
|
+
return False
|
|
998
|
+
return True
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def uncommitted_paths(
|
|
1002
|
+
workdir: str | Path,
|
|
1003
|
+
runner: Runner | None = None,
|
|
1004
|
+
*,
|
|
1005
|
+
timeout_seconds: int = GIT_TIMEOUT_SECONDS,
|
|
1006
|
+
) -> list[str]:
|
|
1007
|
+
"""What ``git status`` reports as changed or untracked in ``workdir``, sorted.
|
|
1008
|
+
|
|
1009
|
+
The one read of "what uncommitted work is here" outside the LOCAL
|
|
1010
|
+
dirty-tree policy (:meth:`LocalWorkspace.dirty_paths`, which it is):
|
|
1011
|
+
a single ``git status --porcelain=v1 -z --untracked-files=all``, with no
|
|
1012
|
+
optional locks, so the index is not refreshed, and with hooks and the
|
|
1013
|
+
file-system monitor off. An agent failure that leaves the state
|
|
1014
|
+
unchanged reports its count for the REMOTE agent worktree (#189), and a
|
|
1015
|
+
write phase's prompt lists the same read's records, codes included
|
|
1016
|
+
(:meth:`LocalWorkspace.status_entries`, #187). Like the policy, it is
|
|
1017
|
+
the operator's notion of uncommitted work, never an identity. A read
|
|
1018
|
+
that fails, times out or is truncated raises VerificationError; a
|
|
1019
|
+
caller that cannot use the answer omits it rather than guessing.
|
|
1020
|
+
"""
|
|
1021
|
+
return LocalWorkspace(workdir, runner=runner, timeout_seconds=timeout_seconds).dirty_paths()
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
# -- feature specifications ---------------------------------------------------
|
|
1025
|
+
@dataclass(frozen=True)
|
|
1026
|
+
class FeatureSpec:
|
|
1027
|
+
"""A resolved, frozen feature specification."""
|
|
1028
|
+
|
|
1029
|
+
path: Path # absolute, real path
|
|
1030
|
+
relative_path: str # repository-relative, "/"-separated
|
|
1031
|
+
content: str
|
|
1032
|
+
sha256: str
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def resolve_feature_spec(workspace: LocalWorkspace, spec_path: str | Path) -> Path:
|
|
1036
|
+
"""Resolve ``spec_path`` to a regular Markdown file inside the repository.
|
|
1037
|
+
|
|
1038
|
+
Rejects (with ConfigurationError): a path that escapes the repository via
|
|
1039
|
+
``..`` or an absolute path elsewhere, a symbolic link, a directory, a
|
|
1040
|
+
FIFO/socket/device, a non-Markdown suffix, and anything unreadable. The
|
|
1041
|
+
feature specification is *untrusted project data*; the controller only
|
|
1042
|
+
guarantees where it came from, never what it says.
|
|
1043
|
+
"""
|
|
1044
|
+
root = workspace.root()
|
|
1045
|
+
raw = Path(spec_path)
|
|
1046
|
+
candidate = raw if raw.is_absolute() else Path(workspace.workdir) / raw
|
|
1047
|
+
# realpath on the *parent* only: the file itself must not be a symlink,
|
|
1048
|
+
# and resolving it would silently accept one pointing outside the repo.
|
|
1049
|
+
parent = Path(os.path.realpath(candidate.parent))
|
|
1050
|
+
resolved = parent / candidate.name
|
|
1051
|
+
try:
|
|
1052
|
+
rel = os.path.relpath(resolved, root)
|
|
1053
|
+
except ValueError:
|
|
1054
|
+
raise ConfigurationError(
|
|
1055
|
+
f"feature specification {spec_path!r} is not inside the repository {root}"
|
|
1056
|
+
) from None
|
|
1057
|
+
if rel.startswith("..") or os.path.isabs(rel):
|
|
1058
|
+
raise ConfigurationError(
|
|
1059
|
+
f"feature specification {spec_path!r} resolves to {resolved}, which is outside "
|
|
1060
|
+
f"the repository {root}; local runs only accept a specification inside the "
|
|
1061
|
+
"working repository"
|
|
1062
|
+
)
|
|
1063
|
+
if resolved.suffix.lower() not in FEATURE_SPEC_SUFFIXES:
|
|
1064
|
+
raise ConfigurationError(
|
|
1065
|
+
f"feature specification {resolved} must be a Markdown file "
|
|
1066
|
+
f"({' or '.join(FEATURE_SPEC_SUFFIXES)})"
|
|
1067
|
+
)
|
|
1068
|
+
try:
|
|
1069
|
+
st = os.lstat(resolved)
|
|
1070
|
+
except FileNotFoundError:
|
|
1071
|
+
raise ConfigurationError(f"feature specification not found: {resolved}") from None
|
|
1072
|
+
except OSError as exc:
|
|
1073
|
+
raise ConfigurationError(f"cannot read feature specification {resolved}: {exc}") from exc
|
|
1074
|
+
if stat.S_ISLNK(st.st_mode):
|
|
1075
|
+
raise ConfigurationError(
|
|
1076
|
+
f"feature specification {resolved} is a symbolic link; local runs require a "
|
|
1077
|
+
"regular file inside the repository so the frozen specification cannot be "
|
|
1078
|
+
"redirected during the run"
|
|
1079
|
+
)
|
|
1080
|
+
if not stat.S_ISREG(st.st_mode):
|
|
1081
|
+
raise ConfigurationError(
|
|
1082
|
+
f"feature specification {resolved} is not a regular file "
|
|
1083
|
+
f"(mode {st.st_mode:o}); local runs require a regular Markdown file"
|
|
1084
|
+
)
|
|
1085
|
+
return resolved
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def read_feature_spec(workspace: LocalWorkspace, spec_path: str | Path) -> FeatureSpec:
|
|
1089
|
+
"""Resolve + read + hash a feature specification (ConfigurationError on any problem)."""
|
|
1090
|
+
resolved = resolve_feature_spec(workspace, spec_path)
|
|
1091
|
+
rel = os.path.relpath(resolved, workspace.root()).replace(os.sep, "/")
|
|
1092
|
+
# The specification is bounded by the same budget as the tree it lives
|
|
1093
|
+
# in: a run's prompts carry it verbatim, and a spec the snapshot would
|
|
1094
|
+
# refuse to fingerprint is not one the controller should read whole.
|
|
1095
|
+
with workspace.tree_root() as tree:
|
|
1096
|
+
try:
|
|
1097
|
+
data = tree.read_bytes(rel, limit=workspace.max_bytes)
|
|
1098
|
+
except ReadLimitExceeded as exc:
|
|
1099
|
+
raise ConfigurationError(
|
|
1100
|
+
f"feature specification {resolved} is larger than {exc.limit} bytes "
|
|
1101
|
+
"(local.max_workspace_bytes); a specification is a short Markdown "
|
|
1102
|
+
"document, not a corpus"
|
|
1103
|
+
) from exc
|
|
1104
|
+
except OSError as exc:
|
|
1105
|
+
raise ConfigurationError(
|
|
1106
|
+
f"cannot read feature specification {resolved}: {exc}"
|
|
1107
|
+
) from exc
|
|
1108
|
+
if data is None:
|
|
1109
|
+
raise ConfigurationError(f"feature specification not found: {resolved}")
|
|
1110
|
+
try:
|
|
1111
|
+
content = data.decode("utf-8")
|
|
1112
|
+
except UnicodeDecodeError as exc:
|
|
1113
|
+
raise ConfigurationError(
|
|
1114
|
+
f"feature specification {resolved} is not valid UTF-8 ({exc})"
|
|
1115
|
+
) from exc
|
|
1116
|
+
if not content.strip():
|
|
1117
|
+
raise ConfigurationError(
|
|
1118
|
+
f"feature specification {resolved} is empty; describe the feature first "
|
|
1119
|
+
"(see 'autoforge local init')"
|
|
1120
|
+
)
|
|
1121
|
+
return FeatureSpec(
|
|
1122
|
+
path=resolved,
|
|
1123
|
+
relative_path=rel,
|
|
1124
|
+
content=content,
|
|
1125
|
+
sha256=hash_bytes(data),
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def verify_feature_spec_unchanged(
|
|
1130
|
+
workspace: LocalWorkspace, relative_path: str, expected_sha256: str, *, when: str
|
|
1131
|
+
) -> FeatureSpec:
|
|
1132
|
+
"""Re-read the frozen specification and require its hash to be unchanged.
|
|
1133
|
+
|
|
1134
|
+
``when`` names the moment for the error message ("before REVIEW", "after
|
|
1135
|
+
ANALYZE_EXECUTE", ...). Raises :class:`VerificationError` — an agent that
|
|
1136
|
+
rewrote its own acceptance criteria is a controller-invariant violation,
|
|
1137
|
+
not a configuration mistake.
|
|
1138
|
+
"""
|
|
1139
|
+
target = workspace.root() / relative_path
|
|
1140
|
+
try:
|
|
1141
|
+
spec = read_feature_spec(workspace, target)
|
|
1142
|
+
except ConfigurationError as exc:
|
|
1143
|
+
raise VerificationError(
|
|
1144
|
+
f"feature specification {relative_path} is no longer usable {when}: {exc}"
|
|
1145
|
+
) from exc
|
|
1146
|
+
if spec.sha256 != expected_sha256:
|
|
1147
|
+
raise VerificationError(
|
|
1148
|
+
f"feature specification {relative_path} changed {when}: expected SHA-256 "
|
|
1149
|
+
f"{expected_sha256[:16]}..., found {spec.sha256[:16]}.... The specification is "
|
|
1150
|
+
"frozen for the whole run so an agent cannot rewrite its own acceptance "
|
|
1151
|
+
"criteria. Restore the file, or start a new run for the changed requirements."
|
|
1152
|
+
)
|
|
1153
|
+
return spec
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
# -- `autoforge local init` ---------------------------------------------------
|
|
1157
|
+
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
|
1158
|
+
|
|
1159
|
+
FEATURE_TEMPLATE = """# Feature: {title}
|
|
1160
|
+
|
|
1161
|
+
## Problem
|
|
1162
|
+
|
|
1163
|
+
<!-- What is wrong or missing today, and for whom? One short paragraph. -->
|
|
1164
|
+
|
|
1165
|
+
## Requirements
|
|
1166
|
+
|
|
1167
|
+
- [ ] <!-- Concrete, checkable behaviour the implementation must provide. -->
|
|
1168
|
+
|
|
1169
|
+
## Acceptance Criteria
|
|
1170
|
+
|
|
1171
|
+
- [ ] <!-- How a reviewer decides this is done. One line per criterion. -->
|
|
1172
|
+
|
|
1173
|
+
## Non-goals
|
|
1174
|
+
|
|
1175
|
+
- <!-- Explicitly out of scope, so the agent does not invent work. -->
|
|
1176
|
+
|
|
1177
|
+
## Notes / Decisions
|
|
1178
|
+
|
|
1179
|
+
<!-- Constraints, prior art, files worth reading first, decisions already made. -->
|
|
1180
|
+
"""
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def slug_to_title(slug: str) -> str:
|
|
1184
|
+
"""``add-transaction-filter`` -> ``Add Transaction Filter``."""
|
|
1185
|
+
words = [w for w in re.split(r"[-_.]+", slug) if w]
|
|
1186
|
+
return " ".join(w[:1].upper() + w[1:] for w in words) or slug
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def feature_template(slug: str) -> str:
|
|
1190
|
+
"""The starting Markdown for ``autoforge local init <slug>``."""
|
|
1191
|
+
return FEATURE_TEMPLATE.format(title=slug_to_title(slug))
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def init_feature_file(
|
|
1195
|
+
workspace: LocalWorkspace,
|
|
1196
|
+
slug: str,
|
|
1197
|
+
feature_dir: str = "features",
|
|
1198
|
+
overwrite: bool = False,
|
|
1199
|
+
) -> Path:
|
|
1200
|
+
"""Create ``<feature_dir>/<slug>.md`` from the template and return its path.
|
|
1201
|
+
|
|
1202
|
+
Refuses to overwrite an existing file unless ``overwrite`` is set: the
|
|
1203
|
+
feature specification is the operator's own work, and clobbering it on a
|
|
1204
|
+
mistyped slug would destroy exactly the input a run depends on.
|
|
1205
|
+
|
|
1206
|
+
The write goes through the working tree's :class:`~autoforge.safefs
|
|
1207
|
+
.SafeRoot`, which is what makes this safe rather than a fourth hand-rolled
|
|
1208
|
+
set of checks: no component of ``features/<slug>.md`` can be a symbolic
|
|
1209
|
+
link, ``--force`` *replaces the name* instead of truncating an inode (so a
|
|
1210
|
+
hard link planted at the target keeps its contents), and without
|
|
1211
|
+
``--force`` the create is ``O_EXCL``, which is atomic rather than a
|
|
1212
|
+
check a racing writer can slip past.
|
|
1213
|
+
"""
|
|
1214
|
+
if not SLUG_RE.match(slug):
|
|
1215
|
+
raise ConfigurationError(
|
|
1216
|
+
f"invalid feature slug {slug!r}: use lowercase letters, digits, '-', '_' or '.' "
|
|
1217
|
+
"(no path separators), e.g. 'add-transaction-filter'"
|
|
1218
|
+
)
|
|
1219
|
+
root = workspace.root()
|
|
1220
|
+
directory = Path(feature_dir)
|
|
1221
|
+
if directory.is_absolute():
|
|
1222
|
+
try:
|
|
1223
|
+
rel_dir = directory.relative_to(root)
|
|
1224
|
+
except ValueError:
|
|
1225
|
+
raise ConfigurationError(
|
|
1226
|
+
f"feature directory {feature_dir!r} is outside the repository {root}"
|
|
1227
|
+
) from None
|
|
1228
|
+
else:
|
|
1229
|
+
rel_dir = directory
|
|
1230
|
+
rel = f"{rel_dir.as_posix()}/{slug}.md".lstrip("/")
|
|
1231
|
+
if rel.startswith("..") or "/../" in f"/{rel}":
|
|
1232
|
+
raise ConfigurationError(f"feature directory {feature_dir!r} escapes the repository {root}")
|
|
1233
|
+
content = feature_template(slug).encode("utf-8")
|
|
1234
|
+
with workspace.tree_root() as tree:
|
|
1235
|
+
if overwrite:
|
|
1236
|
+
existing = tree.lstat(rel)
|
|
1237
|
+
if existing is not None and not stat.S_ISREG(existing.st_mode):
|
|
1238
|
+
raise ConfigurationError(
|
|
1239
|
+
f"refusing to overwrite {root / rel}: it is a "
|
|
1240
|
+
f"{entry_kind(existing.st_mode)}, not a regular file. --force replaces "
|
|
1241
|
+
"a specification AutoForge could have written; it does not write through "
|
|
1242
|
+
"an entry the operator put there. Move it aside, or choose another slug."
|
|
1243
|
+
)
|
|
1244
|
+
tree.write_bytes(rel, content, mode=0o644)
|
|
1245
|
+
else:
|
|
1246
|
+
try:
|
|
1247
|
+
tree.create_exclusive(rel, content, mode=0o644)
|
|
1248
|
+
except FileExistsError:
|
|
1249
|
+
raise ConfigurationError(
|
|
1250
|
+
f"feature specification already exists: {root / rel} — edit it, choose "
|
|
1251
|
+
"another slug, or pass --force to overwrite it"
|
|
1252
|
+
) from None
|
|
1253
|
+
return root / rel
|