@arbiterforge/ca-pi 0.15.12 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +33 -0
  4. package/plugins/ca-pi/SKILLS.md +1 -1
  5. package/plugins/ca-pi/agents/infra-author.md +7 -1
  6. package/plugins/ca-pi/agents/scout.md +40 -8
  7. package/plugins/ca-pi/generated/command-catalog.json +1 -1
  8. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
  9. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
  10. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
  11. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
  12. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
  13. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
  14. package/plugins/ca-pi/helpers/artifacts/release.json +104 -6
  15. package/plugins/ca-pi/hooks/_activationlib.py +97 -13
  16. package/plugins/ca-pi/hooks/_arbiterstatelib.py +7 -2
  17. package/plugins/ca-pi/hooks/_artifactauthoritylib.py +313 -76
  18. package/plugins/ca-pi/hooks/_artifactlib.py +166 -2
  19. package/plugins/ca-pi/hooks/_artifactpromptlib.py +106 -2
  20. package/plugins/ca-pi/hooks/_bashguardlib.py +14 -0
  21. package/plugins/ca-pi/hooks/_contextreportlib.py +564 -6
  22. package/plugins/ca-pi/hooks/_contextselectlib.py +464 -0
  23. package/plugins/ca-pi/hooks/_contextsnapshotlib.py +999 -0
  24. package/plugins/ca-pi/hooks/_debughandofflib.py +767 -0
  25. package/plugins/ca-pi/hooks/_hooklib.py +1 -0
  26. package/plugins/ca-pi/hooks/_host.py +1 -1
  27. package/plugins/ca-pi/hooks/_intentlib.py +5 -5
  28. package/plugins/ca-pi/hooks/_protectedlib.py +3 -3
  29. package/plugins/ca-pi/hooks/_protectedstatelib.py +152 -2
  30. package/plugins/ca-pi/hooks/_provenancelib.py +604 -34
  31. package/plugins/ca-pi/hooks/_readinjectlib.py +131 -1
  32. package/plugins/ca-pi/hooks/artifact-authority.py +20 -1
  33. package/plugins/ca-pi/hooks/debug-handoff.py +85 -0
  34. package/plugins/ca-pi/hooks/doctor.py +4 -2
  35. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  36. package/plugins/ca-pi/hooks/init-codearbiter.py +143 -15
  37. package/plugins/ca-pi/hooks/prompt-submit.py +14 -0
  38. package/plugins/ca-pi/hooks/session-start.py +26 -7
  39. package/plugins/ca-pi/includes/author-tdd-workflow.md +16 -4
  40. package/plugins/ca-pi/includes/helper-invocation.md +128 -0
  41. package/plugins/ca-pi/includes/pi-host-notes.md +13 -0
  42. package/plugins/ca-pi/includes/routing-table.md +1 -1
  43. package/plugins/ca-pi/package.json +1 -1
  44. package/plugins/ca-pi/routines/INDEX.md +1 -1
  45. package/plugins/ca-pi/routines/commit-gate/SKILL.md +5 -3
  46. package/plugins/ca-pi/routines/context-check/SKILL.md +103 -17
  47. package/plugins/ca-pi/routines/context-creation/SKILL.md +290 -54
  48. package/plugins/ca-pi/routines/debug/SKILL.md +303 -55
  49. package/plugins/ca-pi/routines/debug/references/debug-handoff.schema.json +879 -0
  50. package/plugins/ca-pi/routines/debug/references/handoff.md +155 -0
  51. package/plugins/ca-pi/routines/decompose/SKILL.md +13 -0
  52. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +34 -0
  53. package/plugins/ca-pi/routines/tdd/SKILL.md +91 -17
  54. package/plugins/ca-pi/skills/ca-commit/SKILL.md +5 -3
  55. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +103 -17
  56. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +290 -54
  57. package/plugins/ca-pi/skills/ca-debug/SKILL.md +303 -55
  58. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +13 -0
  59. package/plugins/ca-pi/skills/ca-feature/SKILL.md +1 -1
  60. package/plugins/ca-pi/skills/ca-fix/SKILL.md +36 -3
  61. package/plugins/ca-pi/skills/ca-init/SKILL.md +19 -0
  62. package/plugins/ca-pi/skills/ca-review/SKILL.md +33 -4
  63. package/plugins/ca-pi/skills/ca-status/SKILL.md +5 -0
  64. package/plugins/ca-pi/skills/ca-task/SKILL.md +55 -8
@@ -0,0 +1,999 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter -- inert, bounded source digest acquisition for scout snapshots.
3
+ #
4
+ # Public API: digest_file(root, relative_path, *, scope_paths, max_bytes,
5
+ # denied_paths) -> dict with path, digest_method, digest.
6
+ # DigestError(code) reports a bounded, code-owned failure without source bytes.
7
+ # Read only approved regular files beneath the actual worktree. Never invoke Git,
8
+ # filters, repository helpers, or network. Identity checks are cooperative drift
9
+ # checks, not protection against a hostile same-user ABA replacement.
10
+ # context_snapshot(start, *, source_paths, source_scopes, membership,
11
+ # target_paths, allowed_effect_paths) -> dict
12
+ # compare_context_snapshot(saved, start, *, boundary) -> dict
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+ import stat
21
+ import subprocess
22
+ import threading
23
+
24
+ import _contextreportlib as _report
25
+ from _gitexec import git_executable, root_bound_git_env
26
+
27
+ DIGEST_METHOD = 'sha256-raw'
28
+ DEFAULT_MAX_BYTES = 1024 * 1024
29
+ HARD_MAX_BYTES = 16 * 1024 * 1024
30
+ _CHUNK_BYTES = 64 * 1024
31
+ _SENSITIVE_NAMES = frozenset({'.env', '.ssh', '.aws', '.gnupg', '.npmrc', '.pypirc', 'credentials',
32
+ 'credentials.json', 'id_rsa', 'id_ed25519',
33
+ 'known_hosts', 'secrets.json'})
34
+ _SENSITIVE_SUFFIXES = ('.pem', '.key', '.p12', '.pfx')
35
+
36
+
37
+ class DigestError(ValueError):
38
+ """No usable identity was acquired; diagnostics never include file content."""
39
+
40
+ def __init__(self, code):
41
+ self.code = code
42
+ super().__init__(code)
43
+
44
+
45
+ class MembershipError(ValueError):
46
+ """No safe, bounded membership identity could be acquired."""
47
+
48
+ def __init__(self, code):
49
+ self.code = code
50
+ super().__init__(code)
51
+
52
+
53
+ # Closed acquisition vocabulary, deliberately narrower than report transport.
54
+ MEMBERSHIP_PREDICATES = frozenset({'manifests', 'instructions', 'configuration',
55
+ 'tests', 'infrastructure', 'security', 'data'})
56
+ MEMBERSHIP_METHOD = 'sha256-membership-v1'
57
+ HARD_MAX_FILES = 4096
58
+ HARD_MAX_DIRECTORIES = 512
59
+ SOURCE_SNAPSHOT_METHOD = 'sha256-source-snapshot-v1'
60
+ _MAX_SNAPSHOT_PATHS = 64
61
+ _MAX_GIT_OUTPUT = 4 * 1024 * 1024
62
+ _GIT_TIMEOUT = 8
63
+ _MANIFESTS = frozenset({'package.json', 'pyproject.toml', 'cargo.toml', 'go.mod',
64
+ 'pom.xml', 'build.gradle', 'build.gradle.kts', 'requirements.txt',
65
+ 'pipfile', 'gemfile', 'composer.json'})
66
+ _INSTRUCTIONS = frozenset({'agents.md', 'claude.md', 'gemini.md', 'copilot-instructions.md'})
67
+ _CONFIGURATION = frozenset({'.editorconfig', '.gitattributes', 'tsconfig.json',
68
+ 'pytest.ini', 'tox.ini', 'setup.cfg', 'mypy.ini'})
69
+ _INFRASTRUCTURE = frozenset({'dockerfile', 'compose.yaml', 'compose.yml'})
70
+ _SECURITY = frozenset({'security.md', 'codeowners', 'dependabot.yml', 'dependabot.yaml'})
71
+ _DATA = frozenset({'schema.sql', 'schema.prisma'})
72
+ _BOUNDARY_DIRECTORIES = {'tests': frozenset({'tests', 'test', '__tests__'}),
73
+ 'infrastructure': frozenset({'infra', 'infrastructure', 'workflows'}),
74
+ 'security': frozenset({'security'}),
75
+ 'data': frozenset({'migrations', 'schemas'})}
76
+ _VENDOR_DIRECTORIES = frozenset({'vendor', 'node_modules', 'third_party'})
77
+ _GENERATED_DIRECTORIES = frozenset({'generated', 'dist', 'build', '__pycache__', 'target'})
78
+
79
+
80
+ def _membership_path(value, *, root=False):
81
+ try:
82
+ return _report._path(value, 'path', root=root)
83
+ except _report.ReportError:
84
+ raise MembershipError('UNSAFE_PATH') from None
85
+
86
+
87
+ def _membership_match(predicate, path, directory):
88
+ name = path.rsplit('/', 1)[-1].casefold()
89
+ if directory:
90
+ return name in _BOUNDARY_DIRECTORIES.get(predicate, ())
91
+ if predicate == 'manifests':
92
+ return name in _MANIFESTS or name.endswith(('.csproj', '.fsproj', '.vbproj'))
93
+ if predicate == 'instructions':
94
+ return name in _INSTRUCTIONS
95
+ if predicate == 'configuration':
96
+ return name in _CONFIGURATION
97
+ if predicate == 'tests':
98
+ return ((name.startswith('test_') and name.endswith('.py')) or
99
+ name.endswith(('.test.ts', '.test.tsx', '.test.js', '.test.jsx',
100
+ '.spec.ts', '.spec.js')))
101
+ if predicate == 'infrastructure':
102
+ return name in _INFRASTRUCTURE or name.endswith(('.tf', '.tfvars'))
103
+ if predicate == 'security':
104
+ return name in _SECURITY
105
+ if predicate == 'data':
106
+ return name in _DATA or name.endswith(('.sql', '.prisma'))
107
+ return False
108
+
109
+
110
+ def _membership_info(path):
111
+ try:
112
+ info = path.lstat()
113
+ except OSError:
114
+ return None
115
+ if (stat.S_ISLNK(info.st_mode) or
116
+ getattr(info, 'st_file_attributes', 0) &
117
+ getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0)):
118
+ return None
119
+ return info
120
+
121
+
122
+ def _repository_boundary(directory):
123
+ """A nested .git marker is a boundary; its contents are never read."""
124
+ try:
125
+ marker = (directory / '.git').lstat()
126
+ except FileNotFoundError:
127
+ return None
128
+ except OSError:
129
+ return 'unreadable'
130
+ if (stat.S_ISLNK(marker.st_mode) or
131
+ getattr(marker, 'st_file_attributes', 0) &
132
+ getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0)):
133
+ return 'unreadable'
134
+ if stat.S_ISDIR(marker.st_mode):
135
+ return 'nested_repository'
136
+ if stat.S_ISREG(marker.st_mode):
137
+ return 'submodule'
138
+ return 'unreadable'
139
+
140
+
141
+ def _scope_boundary(actual_root, relative):
142
+ """Classify every ancestor before constructing a scoped traversal root."""
143
+ if relative == '.':
144
+ return None
145
+ current = actual_root
146
+ for part in relative.split('/'):
147
+ current = current / part
148
+ info = _membership_info(current)
149
+ if info is None or not stat.S_ISDIR(info.st_mode):
150
+ return 'unreadable'
151
+ folded = part.casefold()
152
+ if folded in _VENDOR_DIRECTORIES:
153
+ return 'vendor'
154
+ if folded in _GENERATED_DIRECTORIES:
155
+ return 'generated'
156
+ marker = _repository_boundary(current)
157
+ if marker:
158
+ return marker
159
+ return None
160
+
161
+
162
+ def _validated_path(value, *, root=False):
163
+ try:
164
+ return _report._path(value, 'path', root=root)
165
+ except _report.ReportError:
166
+ raise DigestError('UNSAFE_PATH') from None
167
+
168
+
169
+ def _identity(info, *, content):
170
+ core = (info.st_dev, info.st_ino, info.st_mode)
171
+ if content:
172
+ return core + (info.st_size, info.st_mtime_ns, info.st_ctime_ns)
173
+ return core
174
+
175
+
176
+ def _components(root, parts):
177
+ """Collect path identities without following an in-worktree link or reparse point."""
178
+ result = []
179
+ for index in range(len(parts) + 1):
180
+ path = root.joinpath(*parts[:index])
181
+ try:
182
+ info = path.lstat()
183
+ except OSError:
184
+ raise DigestError('UNREADABLE') from None
185
+ if stat.S_ISLNK(info.st_mode) or (
186
+ getattr(info, 'st_file_attributes', 0) &
187
+ getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0)
188
+ ):
189
+ raise DigestError('UNSAFE_LINK')
190
+ final = index == len(parts)
191
+ if final:
192
+ if not stat.S_ISREG(info.st_mode):
193
+ raise DigestError('NOT_REGULAR')
194
+ elif not stat.S_ISDIR(info.st_mode):
195
+ raise DigestError('NOT_REGULAR')
196
+ result.append(_identity(info, content=final))
197
+ return result
198
+
199
+
200
+ def digest_file(root, relative_path, *, scope_paths=('.',),
201
+ max_bytes=DEFAULT_MAX_BYTES, denied_paths=()):
202
+ """Return an explicit SHA-256 identity of bounded raw bytes, or raise.
203
+
204
+ `root` is a caller-trusted worktree, resolved before path construction.
205
+ `relative_path`, scope and deny paths use the report envelope's lexical
206
+ grammar. Missing, short, oversized, changing or nonregular files never
207
+ yield a digest. No Git normalization or filter is consulted.
208
+ """
209
+ path = _validated_path(relative_path)
210
+ if type(max_bytes) is not int or not 0 <= max_bytes <= HARD_MAX_BYTES:
211
+ raise DigestError('BYTE_LIMIT')
212
+ if type(scope_paths) not in (tuple, list) or not scope_paths:
213
+ raise DigestError('OUT_OF_SCOPE')
214
+ scopes = tuple(_validated_path(value, root=True) for value in scope_paths)
215
+ if not any(_report._within(path, scope) for scope in scopes):
216
+ raise DigestError('OUT_OF_SCOPE')
217
+ if type(denied_paths) not in (tuple, list):
218
+ raise DigestError('UNSAFE_PATH')
219
+ denied = tuple(_validated_path(value, root=True) for value in denied_paths)
220
+ parts = path.split('/')
221
+ if (any(part.casefold() in _SENSITIVE_NAMES or
222
+ part.casefold().startswith('.env.') or
223
+ part.casefold().endswith(_SENSITIVE_SUFFIXES) for part in parts)
224
+ or any(_report._within(path, value) for value in denied)):
225
+ raise DigestError('SENSITIVE_PATH')
226
+ try:
227
+ actual_root = Path(root).resolve(strict=True)
228
+ if not actual_root.is_dir():
229
+ raise DigestError('UNREADABLE')
230
+ except (OSError, RuntimeError, TypeError, ValueError):
231
+ raise DigestError('UNREADABLE') from None
232
+ candidate = actual_root.joinpath(*parts)
233
+ before = _components(actual_root, parts)
234
+ try:
235
+ candidate.resolve(strict=True).relative_to(actual_root)
236
+ except (OSError, RuntimeError, ValueError):
237
+ raise DigestError('UNSAFE_LINK') from None
238
+ if before[-1][3] > max_bytes:
239
+ raise DigestError('BYTE_LIMIT')
240
+ flags = os.O_RDONLY | getattr(os, 'O_BINARY', 0) | getattr(os, 'O_NOFOLLOW', 0)
241
+ try:
242
+ descriptor = os.open(candidate, flags)
243
+ except OSError:
244
+ raise DigestError('UNREADABLE') from None
245
+ try:
246
+ opened = os.fstat(descriptor)
247
+ if not stat.S_ISREG(opened.st_mode):
248
+ raise DigestError('NOT_REGULAR')
249
+ # Windows descriptor ctime can differ from path ctime for the same file.
250
+ # Compare it path-to-path below; descriptor identity still binds the open.
251
+ if _identity(opened, content=True)[:5] != before[-1][:5]:
252
+ raise DigestError('SOURCE_CHANGED')
253
+ digest = hashlib.sha256()
254
+ total = 0
255
+ while True:
256
+ chunk = os.read(descriptor, min(_CHUNK_BYTES, max_bytes - total + 1))
257
+ if not chunk:
258
+ break
259
+ total += len(chunk)
260
+ if total > max_bytes:
261
+ raise DigestError('BYTE_LIMIT')
262
+ digest.update(chunk)
263
+ if total != opened.st_size:
264
+ raise DigestError('PARTIAL_READ')
265
+ # Stable timestamps can conceal a same-size rewrite during the read.
266
+ # Re-read the bound descriptor before accepting its first byte digest.
267
+ os.lseek(descriptor, 0, os.SEEK_SET)
268
+ confirmation = hashlib.sha256()
269
+ confirmed = 0
270
+ while True:
271
+ chunk = os.read(descriptor, min(_CHUNK_BYTES, max_bytes - confirmed + 1))
272
+ if not chunk:
273
+ break
274
+ confirmed += len(chunk)
275
+ if confirmed > max_bytes:
276
+ raise DigestError('BYTE_LIMIT')
277
+ confirmation.update(chunk)
278
+ if confirmed != total or confirmation.digest() != digest.digest():
279
+ raise DigestError('SOURCE_CHANGED')
280
+ if _identity(os.fstat(descriptor), content=True)[:5] != before[-1][:5]:
281
+ raise DigestError('SOURCE_CHANGED')
282
+ if _components(actual_root, parts) != before:
283
+ raise DigestError('SOURCE_CHANGED')
284
+ try:
285
+ candidate.resolve(strict=True).relative_to(actual_root)
286
+ except (OSError, RuntimeError, ValueError):
287
+ raise DigestError('SOURCE_CHANGED') from None
288
+ return {'path': path, 'digest_method': DIGEST_METHOD, 'digest': digest.hexdigest()}
289
+ except OSError:
290
+ raise DigestError('UNREADABLE') from None
291
+ finally:
292
+ os.close(descriptor)
293
+
294
+
295
+ def membership_snapshot(root, predicate, *, scope_paths=('.',), excluded=(),
296
+ max_files=1024, max_directories=128):
297
+ """Inventory code-owned path membership in caller-authorized directory scopes.
298
+
299
+ Exclusions are caller declarations, not verified ignore rules. Root .git
300
+ metadata is omitted. No file content, Git, helper, shell or network is used.
301
+ A digest identifies only a complete searched subset; any gap prevents a
302
+ broad negative claim. This does not bind T-008 worktree/HEAD identity.
303
+ """
304
+ if type(predicate) is not str or predicate not in MEMBERSHIP_PREDICATES:
305
+ raise MembershipError('UNKNOWN_PREDICATE')
306
+ if (type(max_files) is not int or not 1 <= max_files <= HARD_MAX_FILES or
307
+ type(max_directories) is not int or not 1 <= max_directories <= HARD_MAX_DIRECTORIES):
308
+ raise MembershipError('INVALID_BOUND')
309
+ if type(scope_paths) not in (tuple, list) or not 1 <= len(scope_paths) <= _report.MAX_PATHS:
310
+ raise MembershipError('INVALID_SCOPE')
311
+ scopes = sorted(_membership_path(p, root=True) for p in scope_paths)
312
+ if len({p.casefold() for p in scopes}) != len(scopes) or any(
313
+ _report._within(a, b) or _report._within(b, a)
314
+ for index, a in enumerate(scopes) for b in scopes[index + 1:]):
315
+ raise MembershipError('OVERLAPPING_SCOPE')
316
+ if type(excluded) not in (tuple, list) or len(excluded) > _report.MAX_PATHS:
317
+ raise MembershipError('INVALID_EXCLUSION')
318
+ declared = {}
319
+ for entry in excluded:
320
+ if type(entry) is not dict or set(entry) != {'path', 'reason'}:
321
+ raise MembershipError('INVALID_EXCLUSION')
322
+ path = _membership_path(entry['path'], root=True)
323
+ if type(entry['reason']) is not str or entry['reason'] not in _report.EXCLUSIONS:
324
+ raise MembershipError('UNKNOWN_EXCLUSION')
325
+ if not any(_report._within(path, scope) for scope in scopes):
326
+ raise MembershipError('OUT_OF_SCOPE')
327
+ if path.casefold() in {p.casefold() for p in declared}:
328
+ raise MembershipError('DUPLICATE_EXCLUSION')
329
+ declared[path] = entry['reason']
330
+ paths = list(declared)
331
+ if any(_report._within(a, b) or _report._within(b, a)
332
+ for index, a in enumerate(paths) for b in paths[index + 1:]):
333
+ raise MembershipError('OVERLAPPING_EXCLUSION')
334
+ try:
335
+ actual_root = Path(root).resolve(strict=True)
336
+ if not actual_root.is_dir():
337
+ raise MembershipError('UNREADABLE_ROOT')
338
+ except (OSError, RuntimeError, TypeError, ValueError):
339
+ raise MembershipError('UNREADABLE_ROOT') from None
340
+
341
+ matches, dispositions = set(), dict(declared)
342
+ files = directories = 0
343
+ stack = list(reversed(scopes))
344
+ while stack:
345
+ relative = stack.pop()
346
+ if any(_report._within(relative, boundary) for boundary in dispositions):
347
+ continue
348
+ boundary = _scope_boundary(actual_root, relative)
349
+ if boundary:
350
+ dispositions[relative] = boundary
351
+ continue
352
+ path = actual_root if relative == '.' else actual_root.joinpath(*relative.split('/'))
353
+ info = _membership_info(path)
354
+ if info is None or not stat.S_ISDIR(info.st_mode):
355
+ dispositions[relative] = 'unreadable'
356
+ continue
357
+ directories += 1
358
+ if directories > max_directories:
359
+ raise MembershipError('BOUND_EXCEEDED')
360
+ try:
361
+ with os.scandir(path) as stream:
362
+ names = []
363
+ for entry in stream:
364
+ if relative == '.' and entry.name == '.git':
365
+ continue
366
+ names.append(entry.name)
367
+ if len(names) > max_files + max_directories:
368
+ raise MembershipError('BOUND_EXCEEDED')
369
+ except OSError:
370
+ dispositions[relative] = 'unreadable'
371
+ continue
372
+ if len({name.casefold() for name in names}) != len(names):
373
+ dispositions[relative] = 'unreadable'
374
+ continue
375
+ children = []
376
+ for name in sorted(names, key=lambda value: (value.casefold(), value)):
377
+ child = name if relative == '.' else relative + '/' + name
378
+ try:
379
+ _membership_path(child)
380
+ except MembershipError:
381
+ dispositions[relative] = 'unreadable'
382
+ break
383
+ if any(_report._within(child, boundary) for boundary in dispositions):
384
+ continue
385
+ child_info = _membership_info(path / name)
386
+ if child_info is None:
387
+ files += 1
388
+ dispositions[child] = 'unreadable'
389
+ elif stat.S_ISDIR(child_info.st_mode):
390
+ folded = name.casefold()
391
+ if folded in _VENDOR_DIRECTORIES:
392
+ dispositions[child] = 'vendor'
393
+ elif folded in _GENERATED_DIRECTORIES:
394
+ dispositions[child] = 'generated'
395
+ else:
396
+ if _membership_match(predicate, child, True):
397
+ matches.add(child)
398
+ children.append(child)
399
+ elif stat.S_ISREG(child_info.st_mode):
400
+ files += 1
401
+ if _membership_match(predicate, child, False):
402
+ matches.add(child)
403
+ else:
404
+ files += 1
405
+ dispositions[child] = 'unreadable'
406
+ if files > max_files:
407
+ raise MembershipError('BOUND_EXCEEDED')
408
+ else:
409
+ stack.extend(reversed(children))
410
+ after = _membership_info(path)
411
+ if after is None or _identity(after, content=True) != _identity(info, content=True):
412
+ dispositions[relative] = 'unreadable'
413
+ continue
414
+ # An unsafe spelling means no membership claim for this directory.
415
+ matches.difference_update({p for p in matches if _report._within(p, relative)})
416
+
417
+ ordered = sorted(matches, key=lambda value: (value.casefold(), value))
418
+ gaps = [{'path': path, 'reason': reason} for path, reason in sorted(
419
+ dispositions.items(), key=lambda item: (item[0].casefold(), item[0]))]
420
+ complete = not any(e['reason'] in ('sparse', 'unreadable', 'outside_authorized_scope')
421
+ for e in gaps)
422
+ identity = None
423
+ if complete:
424
+ payload = json.dumps({'method': MEMBERSHIP_METHOD, 'predicate': predicate,
425
+ 'scope_paths': scopes, 'matches': ordered, 'excluded': gaps},
426
+ sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode('utf-8')
427
+ identity = hashlib.sha256(payload).hexdigest()
428
+ return {'status': 'complete' if complete else 'not_inspected',
429
+ 'predicate': predicate, 'scope_paths': scopes, 'matches': ordered,
430
+ 'excluded': gaps, 'digest_method': MEMBERSHIP_METHOD, 'digest': identity,
431
+ 'negative_proof': complete and not gaps and not ordered}
432
+
433
+
434
+ class SnapshotError(ValueError):
435
+ """No complete source/target snapshot was acquired; code contains no source bytes."""
436
+
437
+ def __init__(self, code):
438
+ self.code = code
439
+ super().__init__(code)
440
+
441
+
442
+ def _snapshot_paths(values, *, root=False):
443
+ if type(values) not in (list, tuple) or len(values) > _MAX_SNAPSHOT_PATHS:
444
+ raise SnapshotError('INVALID_PATHS')
445
+ try:
446
+ paths = [_report._path(value, 'path', root=root) for value in values]
447
+ except _report.ReportError:
448
+ raise SnapshotError('INVALID_PATHS') from None
449
+ if len({value.casefold() for value in paths}) != len(paths):
450
+ raise SnapshotError('DUPLICATE_PATH')
451
+ return sorted(paths)
452
+
453
+
454
+ def _snapshot_membership(values):
455
+ if type(values) not in (list, tuple) or len(values) > 16:
456
+ raise SnapshotError('INVALID_MEMBERSHIP')
457
+ result = []
458
+ for item in values:
459
+ if type(item) not in (list, tuple) or len(item) != 2 or item[0] not in MEMBERSHIP_PREDICATES:
460
+ raise SnapshotError('INVALID_MEMBERSHIP')
461
+ scopes = _snapshot_paths(item[1], root=True)
462
+ if not scopes:
463
+ raise SnapshotError('INVALID_MEMBERSHIP')
464
+ result.append([item[0], scopes])
465
+ if len({json.dumps(item) for item in result}) != len(result):
466
+ raise SnapshotError('INVALID_MEMBERSHIP')
467
+ return sorted(result, key=lambda item: (item[0], item[1]))
468
+
469
+
470
+ def _git_read(root, args, *, limit=_MAX_GIT_OUTPUT, optional=False):
471
+ """Fixed inert Git read with no shell, filters, hooks, fsmonitor or auto writes."""
472
+ env = root_bound_git_env()
473
+ for name in tuple(env):
474
+ if name.startswith('GIT_CONFIG_') or name in ('GIT_CONFIG', 'GIT_EXTERNAL_DIFF',
475
+ 'GIT_DIFF_OPTS', 'GIT_ASKPASS',
476
+ 'GIT_TRACE', 'GIT_NAMESPACE',
477
+ 'GIT_EXEC_PATH'):
478
+ env.pop(name, None)
479
+ env.update({'GIT_OPTIONAL_LOCKS': '0', 'GIT_NO_REPLACE_OBJECTS': '1',
480
+ 'GIT_CONFIG_NOSYSTEM': '1', 'GIT_CONFIG_GLOBAL': os.devnull,
481
+ 'GIT_TERMINAL_PROMPT': '0', 'GIT_NO_LAZY_FETCH': '1',
482
+ 'GIT_PROTOCOL_FROM_USER': '0'})
483
+ argv = [git_executable(), '-C', str(root), '--literal-pathspecs',
484
+ '-c', 'core.fsmonitor=false', *args]
485
+ try:
486
+ process = subprocess.Popen(argv, stdin=subprocess.DEVNULL,
487
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env)
488
+ except (OSError, RuntimeError):
489
+ raise SnapshotError('GIT_UNAVAILABLE') from None
490
+ output = []
491
+ def read_bounded():
492
+ output.append(process.stdout.read(limit + 1))
493
+ worker = threading.Thread(target=read_bounded, daemon=True)
494
+ worker.start()
495
+ worker.join(_GIT_TIMEOUT)
496
+ if worker.is_alive():
497
+ process.kill()
498
+ worker.join(_GIT_TIMEOUT)
499
+ process.wait()
500
+ process.stdout.close()
501
+ raise SnapshotError('GIT_UNAVAILABLE')
502
+ if not output or len(output[0]) > limit:
503
+ process.kill()
504
+ process.wait()
505
+ process.stdout.close()
506
+ raise SnapshotError('GIT_BOUND_EXCEEDED')
507
+ try:
508
+ code = process.wait(timeout=_GIT_TIMEOUT)
509
+ except subprocess.TimeoutExpired:
510
+ process.kill()
511
+ process.wait()
512
+ process.stdout.close()
513
+ raise SnapshotError('GIT_UNAVAILABLE') from None
514
+ process.stdout.close()
515
+ if code and not optional:
516
+ raise SnapshotError('GIT_UNAVAILABLE')
517
+ return code, output[0]
518
+
519
+
520
+ def _git_text(root, args, *, optional=False):
521
+ code, output = _git_read(root, args, optional=optional)
522
+ try:
523
+ return code, output.decode('utf-8', 'strict').strip()
524
+ except UnicodeDecodeError:
525
+ raise SnapshotError('GIT_UNAVAILABLE') from None
526
+
527
+
528
+ def _worktree_identity(start):
529
+ try:
530
+ start = Path(start).resolve(strict=True)
531
+ if not start.is_dir():
532
+ raise SnapshotError('UNREADABLE_ROOT')
533
+ except (OSError, RuntimeError, TypeError, ValueError):
534
+ raise SnapshotError('UNREADABLE_ROOT') from None
535
+ _, locations = _git_text(start, ['rev-parse', '--path-format=absolute',
536
+ '--show-toplevel', '--absolute-git-dir', '--git-common-dir'])
537
+ lines = locations.splitlines()
538
+ if len(lines) != 3:
539
+ raise SnapshotError('GIT_UNAVAILABLE')
540
+ try:
541
+ root, git_dir, common_dir = (str(Path(value).resolve(strict=True)) for value in lines)
542
+ if not start.is_relative_to(Path(root)):
543
+ raise SnapshotError('UNREADABLE_ROOT')
544
+ if not Path(git_dir).is_dir() or not Path(common_dir).is_dir():
545
+ raise SnapshotError('GIT_UNAVAILABLE')
546
+ except (OSError, RuntimeError, ValueError):
547
+ raise SnapshotError('GIT_UNAVAILABLE') from None
548
+ head_code, head = _git_text(root, ['rev-parse', '--verify', 'HEAD'], optional=True)
549
+ branch_code, branch = _git_text(root, ['symbolic-ref', '-q', '--short', 'HEAD'], optional=True)
550
+ head = head if head_code == 0 else None
551
+ branch = branch if branch_code == 0 else None
552
+ if head is not None and (len(head) not in (40, 64) or
553
+ any(ch not in '0123456789abcdef' for ch in head)):
554
+ raise SnapshotError('GIT_UNAVAILABLE')
555
+ upstream = {'status': 'none', 'ref': None, 'relation': 'unknown',
556
+ 'freshness': 'local_only'}
557
+ if branch:
558
+ upstream['status'] = 'unknown'
559
+ code, ref = _git_text(root, ['rev-parse', '--symbolic-full-name',
560
+ '@{upstream}'], optional=True)
561
+ if code == 0:
562
+ if not ref.startswith('refs/') or any(ch not in
563
+ 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-'
564
+ for ch in ref):
565
+ raise SnapshotError('GIT_UNAVAILABLE')
566
+ _, oid = _git_text(root, ['rev-parse', '--verify', ref])
567
+ relation = 'unknown'
568
+ if head:
569
+ _, counts = _git_text(root, ['rev-list', '--left-right', '--count',
570
+ head + '...' + oid])
571
+ parts = counts.split()
572
+ if len(parts) != 2 or not all(part.isdecimal() for part in parts):
573
+ raise SnapshotError('GIT_UNAVAILABLE')
574
+ ahead, behind = map(int, parts)
575
+ relation = ('same' if not ahead and not behind else
576
+ 'ahead' if ahead and not behind else
577
+ 'behind' if behind and not ahead else 'diverged')
578
+ upstream = {'status': 'known_local', 'ref': ref, 'relation': relation,
579
+ 'freshness': 'local_only'}
580
+ return {'root': root, 'git_dir': git_dir, 'common_dir': common_dir,
581
+ 'head': head, 'branch': branch, 'detached': branch is None and head is not None,
582
+ 'upstream': upstream,
583
+ 'fork': {'status': 'unknown', 'basis': 'local_refs_only'}}
584
+
585
+
586
+ def _git_entries(root, args, *, tree=False):
587
+ _, data = _git_read(root, args)
588
+ result = {}
589
+ for raw in data.split(b'\0'):
590
+ if not raw:
591
+ continue
592
+ try:
593
+ prefix, path = raw.split(b'\t', 1)
594
+ path = _report._path(path.decode('utf-8', 'strict'), 'path')
595
+ fields = prefix.decode('ascii').split()
596
+ except (ValueError, UnicodeDecodeError, _report.ReportError):
597
+ raise SnapshotError('GIT_UNAVAILABLE') from None
598
+ if (len(fields) != 3 or fields[1] != 'blob') if tree else (len(fields) != 3 or fields[2] != '0'):
599
+ raise SnapshotError('GIT_UNAVAILABLE')
600
+ oid = fields[2] if tree else fields[1]
601
+ if path in result or len(oid) not in (40, 64) or any(
602
+ ch not in '0123456789abcdef' for ch in oid):
603
+ raise SnapshotError('GIT_UNAVAILABLE')
604
+ result[path] = {'mode': fields[0], 'oid': oid}
605
+ return result
606
+
607
+
608
+ def _preimage(root, path):
609
+ candidate = Path(root)
610
+ for part in path.split('/'):
611
+ candidate = candidate / part
612
+ try:
613
+ info = candidate.lstat()
614
+ except FileNotFoundError:
615
+ return {'status': 'absent', 'digest_method': None, 'digest': None}
616
+ except OSError:
617
+ raise SnapshotError('UNREADABLE_TARGET') from None
618
+ if stat.S_ISLNK(info.st_mode) or getattr(info, 'st_file_attributes', 0) & getattr(
619
+ stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0):
620
+ raise SnapshotError('UNSAFE_TARGET')
621
+ try:
622
+ result = digest_file(root, path)
623
+ except DigestError:
624
+ raise SnapshotError('UNREADABLE_TARGET') from None
625
+ return {'status': 'file', 'digest_method': result['digest_method'],
626
+ 'digest': result['digest']}
627
+
628
+
629
+ def _member_info(root, path):
630
+ """Recheck a T-007 match without following a changed ancestor or leaf."""
631
+ candidate = Path(root)
632
+ root_info = _membership_info(candidate)
633
+ if root_info is None or not stat.S_ISDIR(root_info.st_mode):
634
+ raise SnapshotError('MEMBERSHIP_UNKNOWN')
635
+ parts = path.split('/')
636
+ for index, part in enumerate(parts):
637
+ candidate = candidate / part
638
+ info = _membership_info(candidate)
639
+ if info is None:
640
+ raise SnapshotError('MEMBERSHIP_UNKNOWN')
641
+ if index < len(parts) - 1 and not stat.S_ISDIR(info.st_mode):
642
+ raise SnapshotError('MEMBERSHIP_UNKNOWN')
643
+ try:
644
+ candidate.resolve(strict=True).relative_to(Path(root))
645
+ except (OSError, RuntimeError, ValueError):
646
+ raise SnapshotError('MEMBERSHIP_UNKNOWN') from None
647
+ after = _membership_info(candidate)
648
+ if after is None or _identity(after, content=True) != _identity(info, content=True):
649
+ raise SnapshotError('SOURCE_CHANGED')
650
+ return info
651
+
652
+
653
+ def _capture_context(start, selectors):
654
+ worktree = _worktree_identity(start)
655
+ root = worktree['root']
656
+ source_paths = set(selectors['source_paths'])
657
+ target_paths = selectors['target_paths']
658
+ effects = selectors['allowed_effect_paths']
659
+ membership = []
660
+ directory_checks = {}
661
+ for predicate, scopes in selectors['membership']:
662
+ try:
663
+ unexcluded = membership_snapshot(root, predicate, scope_paths=scopes)
664
+ except MembershipError:
665
+ raise SnapshotError('MEMBERSHIP_UNKNOWN') from None
666
+ if unexcluded['status'] != 'complete' or unexcluded['digest'] is None:
667
+ raise SnapshotError('MEMBERSHIP_UNKNOWN')
668
+ if any(_report._within(path, role) or _report._within(role, path)
669
+ for path in unexcluded['matches'] for role in target_paths + effects):
670
+ raise SnapshotError('OVERLAPPING_ROLE')
671
+ excluded = [{'path': path, 'reason': 'generated'} for path in target_paths + effects
672
+ if any(_report._within(path, scope) for scope in scopes)]
673
+ try:
674
+ result = membership_snapshot(root, predicate, scope_paths=scopes, excluded=excluded)
675
+ except MembershipError:
676
+ raise SnapshotError('MEMBERSHIP_UNKNOWN') from None
677
+ if result['status'] != 'complete' or result['digest'] is None:
678
+ raise SnapshotError('MEMBERSHIP_UNKNOWN')
679
+ for path in result['matches']:
680
+ if any(_report._within(path, role) or _report._within(role, path)
681
+ for role in target_paths + effects):
682
+ raise SnapshotError('OVERLAPPING_ROLE')
683
+ info = _member_info(root, path)
684
+ if stat.S_ISREG(info.st_mode):
685
+ source_paths.add(path)
686
+ elif stat.S_ISDIR(info.st_mode):
687
+ directory_checks[path] = _identity(info, content=True)
688
+ else:
689
+ raise SnapshotError('MEMBERSHIP_UNKNOWN')
690
+ membership.append(result)
691
+ if len(source_paths) > _MAX_SNAPSHOT_PATHS:
692
+ raise SnapshotError('BOUND_EXCEEDED')
693
+ for path in source_paths:
694
+ if any(_report._within(path, role) or _report._within(role, path)
695
+ for role in target_paths + effects):
696
+ raise SnapshotError('OVERLAPPING_ROLE')
697
+ files = {}
698
+ for path in sorted(source_paths):
699
+ try:
700
+ files[path] = digest_file(root, path, scope_paths=selectors['source_scopes'])
701
+ except DigestError:
702
+ raise SnapshotError('SOURCE_UNKNOWN') from None
703
+ wanted = sorted(files)
704
+ if wanted:
705
+ index = _git_entries(root, ['ls-files', '--stage', '-z', '--', *wanted])
706
+ tree = (_git_entries(root, ['ls-tree', '-r', '-z', 'HEAD', '--', *wanted], tree=True)
707
+ if worktree['head'] else {})
708
+ else:
709
+ index, tree = {}, {}
710
+ staged, untracked, raw_dirty = [], [], []
711
+ for path in wanted:
712
+ entry = index.get(path)
713
+ if entry is None:
714
+ untracked.append(path)
715
+ else:
716
+ if entry != tree.get(path):
717
+ staged.append(path)
718
+ _, blob = _git_read(root, ['cat-file', 'blob', entry['oid']],
719
+ limit=HARD_MAX_BYTES + 1)
720
+ if hashlib.sha256(blob).hexdigest() != files[path]['digest']:
721
+ raw_dirty.append(path)
722
+ for path, identity in directory_checks.items():
723
+ info = _member_info(root, path)
724
+ if not stat.S_ISDIR(info.st_mode) or _identity(info, content=True) != identity:
725
+ raise SnapshotError('SOURCE_CHANGED')
726
+ git_state = {'staged': staged, 'untracked': untracked, 'raw_dirty': raw_dirty,
727
+ 'index': index, 'head_tree': tree}
728
+ source = {'digest_method': SOURCE_SNAPSHOT_METHOD, 'files': files,
729
+ 'membership': membership, 'git_state': git_state}
730
+ payload = {'worktree': worktree, 'selectors': selectors, 'source': source}
731
+ source['digest'] = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':'),
732
+ ensure_ascii=True).encode('utf-8')).hexdigest()
733
+ targets = {path: _preimage(root, path) for path in target_paths}
734
+ return {'method': SOURCE_SNAPSHOT_METHOD, 'selectors': selectors,
735
+ 'worktree': worktree, 'source': source, 'targets': targets,
736
+ 'allowed_effects': effects}
737
+
738
+
739
+ def context_snapshot(start, *, source_paths=(), source_scopes=('.',), membership=(),
740
+ target_paths=(), allowed_effect_paths=()):
741
+ """Bind declared external inputs separately from target and allowed output paths."""
742
+ selectors = {'source_paths': _snapshot_paths(source_paths),
743
+ 'source_scopes': _snapshot_paths(source_scopes, root=True),
744
+ 'membership': _snapshot_membership(membership),
745
+ 'target_paths': _snapshot_paths(target_paths),
746
+ 'allowed_effect_paths': _snapshot_paths(allowed_effect_paths)}
747
+ if not selectors['source_scopes']:
748
+ raise SnapshotError('INVALID_SCOPE')
749
+ if not selectors['source_paths'] and not selectors['membership']:
750
+ raise SnapshotError('EMPTY_SOURCE')
751
+ roles = selectors['source_paths'] + selectors['target_paths'] + selectors['allowed_effect_paths']
752
+ if any(_report._within(a, b) or _report._within(b, a)
753
+ for index, a in enumerate(roles) for b in roles[index + 1:]):
754
+ raise SnapshotError('OVERLAPPING_ROLE')
755
+ if any(not any(_report._within(path, scope) for scope in selectors['source_scopes'])
756
+ for path in selectors['source_paths']):
757
+ raise SnapshotError('OUT_OF_SCOPE')
758
+ first = _capture_context(start, selectors)
759
+ second = _capture_context(start, selectors)
760
+ if first != second:
761
+ raise SnapshotError('SOURCE_CHANGED')
762
+ return first
763
+
764
+
765
+ def compare_context_snapshot(saved, start=None, *, boundary='join'):
766
+ """Reacquire a complete snapshot; action additionally requires target preimages."""
767
+ if boundary not in ('join', 'action'):
768
+ raise SnapshotError('INVALID_BOUNDARY')
769
+ if type(saved) is not dict or set(saved) != {
770
+ 'method', 'selectors', 'worktree', 'source', 'targets', 'allowed_effects'} or (
771
+ saved['method'] != SOURCE_SNAPSHOT_METHOD or type(saved['source']) is not dict or
772
+ saved['source'].get('digest_method') != SOURCE_SNAPSHOT_METHOD):
773
+ raise SnapshotError('INVALID_SNAPSHOT')
774
+ selectors = saved['selectors']
775
+ if type(selectors) is not dict or set(selectors) != {
776
+ 'source_paths', 'source_scopes', 'membership', 'target_paths',
777
+ 'allowed_effect_paths'}:
778
+ raise SnapshotError('INVALID_SNAPSHOT')
779
+ if (type(saved['worktree']) is not dict or set(saved['worktree']) != {
780
+ 'root', 'git_dir', 'common_dir', 'head', 'branch', 'detached',
781
+ 'upstream', 'fork'} or
782
+ type(saved['source']) is not dict or set(saved['source']) != {
783
+ 'digest_method', 'digest', 'files', 'membership', 'git_state'} or
784
+ type(saved['targets']) is not dict or
785
+ type(saved['allowed_effects']) is not list or
786
+ saved['allowed_effects'] != selectors['allowed_effect_paths'] or
787
+ not _sha256_hex(saved['source']['digest'])):
788
+ raise SnapshotError('INVALID_SNAPSHOT')
789
+ if (set(saved['targets']) != set(selectors['target_paths']) or
790
+ any(type(value) is not dict or set(value) != {
791
+ 'status', 'digest_method', 'digest'} or
792
+ (value['status'] == 'absent' and (
793
+ value['digest_method'] is not None or value['digest'] is not None)) or
794
+ (value['status'] == 'file' and (
795
+ value['digest_method'] != DIGEST_METHOD or
796
+ not _sha256_hex(value['digest']))) or
797
+ value['status'] not in ('absent', 'file')
798
+ for value in saved['targets'].values())):
799
+ raise SnapshotError('INVALID_SNAPSHOT')
800
+ if (type(saved['source']['files']) is not dict or
801
+ any(type(value) is not dict or set(value) != {'path', 'digest_method', 'digest'} or
802
+ value['path'] != path or value['digest_method'] != DIGEST_METHOD or
803
+ not _sha256_hex(value['digest'])
804
+ for path, value in saved['source']['files'].items()) or
805
+ type(saved['source']['membership']) is not list or
806
+ any(type(value) is not dict or value.get('digest_method') != MEMBERSHIP_METHOD or
807
+ value.get('status') != 'complete' or not _sha256_hex(value.get('digest'))
808
+ for value in saved['source']['membership']) or
809
+ type(saved['source']['git_state']) is not dict or
810
+ set(saved['source']['git_state']) != {
811
+ 'staged', 'untracked', 'raw_dirty', 'index', 'head_tree'}):
812
+ raise SnapshotError('INVALID_SNAPSHOT')
813
+ if (type(saved['worktree']['upstream']) is not dict or
814
+ set(saved['worktree']['upstream']) != {
815
+ 'status', 'ref', 'relation', 'freshness'} or
816
+ saved['worktree']['upstream']['freshness'] != 'local_only' or
817
+ saved['worktree']['fork'] != {
818
+ 'status': 'unknown', 'basis': 'local_refs_only'}):
819
+ raise SnapshotError('INVALID_SNAPSHOT')
820
+ try:
821
+ saved_root = saved['worktree']['root']
822
+ if type(saved_root) is not str:
823
+ raise SnapshotError('INVALID_SNAPSHOT')
824
+ except (KeyError, TypeError):
825
+ raise SnapshotError('INVALID_SNAPSHOT') from None
826
+ current = context_snapshot(start if start is not None else saved['worktree']['root'],
827
+ source_paths=selectors['source_paths'],
828
+ source_scopes=selectors['source_scopes'],
829
+ membership=selectors['membership'],
830
+ target_paths=selectors['target_paths'],
831
+ allowed_effect_paths=selectors['allowed_effect_paths'])
832
+ source_current = (saved['selectors'] == current['selectors'] and
833
+ saved['worktree'] == current['worktree'] and
834
+ saved['source'] == current['source'] and
835
+ saved['allowed_effects'] == current['allowed_effects'])
836
+ targets_current = saved['targets'] == current['targets']
837
+ return {'boundary': boundary, 'admitted': source_current and (
838
+ boundary == 'join' or targets_current), 'source_current': source_current,
839
+ 'targets_current': targets_current}
840
+
841
+
842
+ def compare_context_dependencies(saved, *, source_paths=(), membership=(),
843
+ target_paths=(), start=None):
844
+ """Recheck only declared input and target identities for a context claim.
845
+
846
+ This is for incremental context assessment. It never substitutes for the
847
+ strict whole-snapshot action/join admission above. The physical worktree
848
+ and Git directory remain bound, while unrelated HEAD and source changes
849
+ cannot make a separate claim stale.
850
+ """
851
+ if type(saved) is not dict or saved.get('method') != SOURCE_SNAPSHOT_METHOD:
852
+ raise SnapshotError('INVALID_SNAPSHOT')
853
+ try:
854
+ selectors = saved['selectors']
855
+ original = saved['source']
856
+ worktree = saved['worktree']
857
+ targets = saved['targets']
858
+ files = original['files']
859
+ members = original['membership']
860
+ scopes = selectors['source_scopes']
861
+ effects = selectors['allowed_effect_paths']
862
+ paths = _snapshot_paths(source_paths)
863
+ selected_members = _snapshot_membership(membership)
864
+ selected_targets = _snapshot_paths(target_paths)
865
+ signed_source = dict(original)
866
+ signed_source.pop('digest', None)
867
+ payload = {'worktree': worktree, 'selectors': selectors, 'source': signed_source}
868
+ expected_digest = hashlib.sha256(json.dumps(
869
+ payload, sort_keys=True, separators=(',', ':'),
870
+ ensure_ascii=True).encode('utf-8')).hexdigest()
871
+ if (original['digest_method'] != SOURCE_SNAPSHOT_METHOD or
872
+ original['digest'] != expected_digest or
873
+ not set(paths) <= set(files) or
874
+ not set(selected_targets) <= set(targets) or
875
+ not {(item[0], tuple(item[1])) for item in selected_members} <=
876
+ {(item['predicate'], tuple(item['scope_paths'])) for item in members}):
877
+ raise SnapshotError('INVALID_SNAPSHOT')
878
+ root = start if start is not None else worktree['root']
879
+ actual = _worktree_identity(root)
880
+ if any(actual[key] != worktree[key]
881
+ for key in ('root', 'git_dir', 'common_dir')):
882
+ raise SnapshotError('WORKTREE_CHANGED')
883
+ current = context_snapshot(root, source_paths=paths, source_scopes=scopes,
884
+ membership=selected_members,
885
+ target_paths=selected_targets,
886
+ allowed_effect_paths=effects)
887
+ original_members = {(item['predicate'], tuple(item['scope_paths'])): item
888
+ for item in members}
889
+ current_members = {(item['predicate'], tuple(item['scope_paths'])): item
890
+ for item in current['source']['membership']}
891
+ source_current = (all(files[path] == current['source']['files'].get(path)
892
+ for path in paths) and
893
+ all(original_members[(item[0], tuple(item[1]))] ==
894
+ current_members.get((item[0], tuple(item[1])))
895
+ for item in selected_members))
896
+ targets_current = all(targets[path] == current['targets'].get(path)
897
+ for path in selected_targets)
898
+ return {'source_current': source_current, 'targets_current': targets_current,
899
+ 'admitted': source_current and targets_current}
900
+ except (KeyError, TypeError, ValueError, AttributeError):
901
+ raise SnapshotError('INVALID_SNAPSHOT') from None
902
+
903
+
904
+ def _sha256_hex(value):
905
+ return type(value) is str and len(value) == 64 and all(
906
+ character in '0123456789abcdef' for character in value)
907
+
908
+
909
+ def capture_context_effects(saved):
910
+ """Read declared output preimages and mtimes for a bounded no-op check.
911
+
912
+ This is an observation of files at one instant, not a writer or a proof
913
+ that no transient write occurred between observations.
914
+ """
915
+ try:
916
+ if type(saved) is not dict or saved.get('method') != SOURCE_SNAPSHOT_METHOD:
917
+ raise SnapshotError('INVALID_SNAPSHOT')
918
+ selectors = saved['selectors']
919
+ worktree = saved['worktree']
920
+ paths = _snapshot_paths(selectors['target_paths'] +
921
+ selectors['allowed_effect_paths'])
922
+ if (type(worktree) is not dict or type(worktree.get('root')) is not str or
923
+ not paths):
924
+ raise SnapshotError('INVALID_SNAPSHOT')
925
+ actual = _worktree_identity(worktree['root'])
926
+ if any(actual[key] != worktree[key] for key in
927
+ ('root', 'git_dir', 'common_dir')):
928
+ raise SnapshotError('WORKTREE_CHANGED')
929
+ root = Path(worktree['root'])
930
+ files = {}
931
+ for path in paths:
932
+ candidate = root.joinpath(*path.split('/'))
933
+ try:
934
+ before = candidate.lstat()
935
+ except FileNotFoundError:
936
+ before = None
937
+ except OSError:
938
+ raise SnapshotError('UNREADABLE_TARGET') from None
939
+ preimage = _preimage(root, path)
940
+ try:
941
+ after = candidate.lstat()
942
+ except FileNotFoundError:
943
+ after = None
944
+ except OSError:
945
+ raise SnapshotError('UNREADABLE_TARGET') from None
946
+ if (before is None) != (after is None) or (before is not None and
947
+ _identity(before, content=True) != _identity(after, content=True)):
948
+ raise SnapshotError('TARGET_CHANGED')
949
+ if preimage['status'] == 'absent':
950
+ if after is not None:
951
+ raise SnapshotError('TARGET_CHANGED')
952
+ files[path] = {'status': 'absent', 'digest': None,
953
+ 'size': None, 'mtime_ns': None}
954
+ else:
955
+ if after is None or not stat.S_ISREG(after.st_mode):
956
+ raise SnapshotError('TARGET_CHANGED')
957
+ files[path] = {'status': 'file', 'digest': preimage['digest'],
958
+ 'size': after.st_size, 'mtime_ns': after.st_mtime_ns}
959
+ return {'root': str(root), 'files': files}
960
+ except SnapshotError:
961
+ raise
962
+ except (KeyError, TypeError, ValueError, AttributeError):
963
+ raise SnapshotError('INVALID_SNAPSHOT') from None
964
+
965
+
966
+ def compare_context_effects(before, after):
967
+ """Classify final byte and timestamp differences without inferring writes."""
968
+ if (type(before) is not dict or type(after) is not dict or
969
+ set(before) != {'root', 'files'} or set(after) != {'root', 'files'} or
970
+ before['root'] != after['root'] or type(before['files']) is not dict or
971
+ type(after['files']) is not dict or
972
+ set(before['files']) != set(after['files'])):
973
+ raise SnapshotError('EFFECT_CONTEXT_MISMATCH')
974
+ bytes_changed, timestamp_only = [], []
975
+ for path in sorted(before['files']):
976
+ _snapshot_paths((path,))
977
+ old, new = before['files'][path], after['files'][path]
978
+ if (type(old) is not dict or type(new) is not dict or
979
+ set(old) != {'status', 'digest', 'size', 'mtime_ns'} or
980
+ set(new) != {'status', 'digest', 'size', 'mtime_ns'}):
981
+ raise SnapshotError('INVALID_EFFECTS')
982
+ for item in (old, new):
983
+ if item['status'] == 'absent':
984
+ if any(item[key] is not None for key in ('digest','size','mtime_ns')):
985
+ raise SnapshotError('INVALID_EFFECTS')
986
+ elif item['status'] == 'file':
987
+ if (not _sha256_hex(item['digest']) or
988
+ type(item['size']) is not int or item['size'] < 0 or
989
+ type(item['mtime_ns']) is not int or item['mtime_ns'] < 0):
990
+ raise SnapshotError('INVALID_EFFECTS')
991
+ else:
992
+ raise SnapshotError('INVALID_EFFECTS')
993
+ if any(old[key] != new[key] for key in ('status', 'digest', 'size')):
994
+ bytes_changed.append(path)
995
+ elif old['mtime_ns'] != new['mtime_ns']:
996
+ timestamp_only.append(path)
997
+ return {'no_op': not bytes_changed and not timestamp_only,
998
+ 'byte_changes': bytes_changed,
999
+ 'timestamp_only_changes': timestamp_only}