263 lines
13 KiB
Python
263 lines
13 KiB
Python
"""Stdlib/Git composition primitives; no runtime or acceptance execution.
|
|
|
|
Re-expressed from P23 checker blob 8a62d1e1070688ced445e48d49b831fc7e9cfea3,
|
|
SHA256 e41479ae51bccdfab3f31bd348794b441c2f341179bbe33222e2aacdf88f987f.
|
|
Source filter matches run_all_tests.source_refs (reference blob
|
|
502ad8c1f6ba371446ecfd8f37f0f9194cdb10ea); no imports from Main-only modules.
|
|
"""
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
|
|
CONTRACT = 'docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json'
|
|
SPEC = 'docs/spec/acceptance/P24-02-code-r01.json'
|
|
RECONCILED_MAIN = '54cade4181f6c3c4f5701ba54ac5fcbeca695945'
|
|
MAIN_INPUTS = {
|
|
'scenes/main/main.gd': 'fff37f082d750bb91c71f2aa54d168c9e2cb8fe7',
|
|
'scenes/main/world_preview.gd': 'fa32fe0344d20565b6d542da1256a94e8dee1845',
|
|
CONTRACT: '2eecb803f5eb6774ff38cd353de1f02847aeb0f9',
|
|
'tools/regression/suite-policy.json': 'ad194ae425fb8123fa191ed3635f404c098f692d',
|
|
}
|
|
KNOWN_CONFLICTS = set(MAIN_INPUTS)
|
|
OVERRIDES = {'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',
|
|
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_COMMON_DIR'}
|
|
|
|
|
|
def require(ok, message):
|
|
if not ok:
|
|
raise ValueError(message)
|
|
|
|
|
|
def clean_env():
|
|
# Config injection can also redirect Git. Do not inherit any Git overrides.
|
|
return {k: v for k, v in os.environ.items() if not k.startswith('GIT_')}
|
|
|
|
|
|
def git_process(root, *args, private=None, input=None):
|
|
env = clean_env()
|
|
if private:
|
|
env.update(private)
|
|
return subprocess.run(['git', '-C', str(root), *args], input=input,
|
|
capture_output=True, env=env, timeout=120)
|
|
|
|
|
|
def git_bytes(root, *args, private=None):
|
|
p = git_process(root, *args, private=private)
|
|
require(p.returncode == 0, 'Git failed: ' + ' '.join(args) + ': ' + p.stderr.decode(errors='replace'))
|
|
return p.stdout
|
|
|
|
|
|
def root_identity(script_root, cwd):
|
|
root = Path(git_bytes(script_root, 'rev-parse', '--show-toplevel').decode().strip()).resolve()
|
|
require(Path(git_bytes(cwd, 'rev-parse', '--show-toplevel').decode().strip()).resolve() == root,
|
|
'cwd differs from ROOT')
|
|
head = git_bytes(root, 'rev-parse', 'HEAD').strip()
|
|
if any(k in OVERRIDES or k.startswith('GIT_CEILING') for k in os.environ):
|
|
inherited = subprocess.run(['git', '-C', str(root), 'rev-parse', 'HEAD'],
|
|
capture_output=True, timeout=10)
|
|
require(inherited.returncode == 0 and inherited.stdout.strip() == head,
|
|
'inherited Git override changes HEAD')
|
|
for name in ('CANDIDATE_COMMIT', 'TARGET_HEAD'):
|
|
require(name not in os.environ or os.environ[name].strip().encode() == head,
|
|
name + ' mismatches HEAD')
|
|
return root
|
|
|
|
|
|
def strict_json(raw):
|
|
def pairs(rows):
|
|
d = {}
|
|
for k, v in rows:
|
|
require(k not in d, 'duplicate JSON key: ' + k)
|
|
d[k] = v
|
|
return d
|
|
return json.loads(raw, object_pairs_hook=pairs)
|
|
|
|
|
|
def safe_path(root, name):
|
|
p = Path(name)
|
|
require(not p.is_absolute() and '..' not in p.parts and p.as_posix() == name, 'unsafe relative path')
|
|
target = root / p
|
|
require(target.resolve().is_relative_to(root.resolve()), 'path escapes root')
|
|
require(not any((root / Path(*p.parts[:i])).is_symlink() for i in range(1, len(p.parts)+1)),
|
|
'source/artifact symlink')
|
|
return target
|
|
|
|
|
|
def root_context(directory, index):
|
|
path = safe_path(directory, 'root-composition.json')
|
|
require(path.is_file(), 'missing regular root-composition.json')
|
|
raw = path.read_bytes()
|
|
require(hashlib.sha256(raw).hexdigest() == index.get('root_composition_sha256'), 'composition SHA mismatch')
|
|
return strict_json(raw)
|
|
|
|
|
|
def blob_oid(raw):
|
|
return hashlib.sha1(b'blob %d\0' % len(raw) + raw).hexdigest()
|
|
|
|
|
|
def git_blob_stream(root, oids):
|
|
process = subprocess.Popen(['git', '-C', str(root), 'cat-file', '--batch'],
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=clean_env())
|
|
try:
|
|
for oid in oids:
|
|
require(re.fullmatch('[0-9a-f]{40}', oid), 'invalid blob OID')
|
|
process.stdin.write((oid + '\n').encode()); process.stdin.flush()
|
|
match = re.fullmatch(rb'([0-9a-f]{40}) blob (0|[1-9][0-9]*)\n', process.stdout.readline(128))
|
|
require(match is not None and match[1].decode() == oid, 'invalid blob header')
|
|
raw = process.stdout.read(int(match[2]))
|
|
require(len(raw) == int(match[2]) and process.stdout.read(1) == b'\n', 'truncated blob')
|
|
require(blob_oid(raw) == oid, 'blob SHA1 mismatch')
|
|
yield oid, raw
|
|
process.stdin.close()
|
|
require(process.stdout.read(1) == b'' and process.wait() == 0, 'extra blob output/Git failure')
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait()
|
|
process.stdin.close(); process.stdout.close()
|
|
|
|
|
|
def tree(root, revision):
|
|
result = {}
|
|
for row in git_bytes(root, 'ls-tree', '-r', '-z', revision).split(b'\0'):
|
|
if not row:
|
|
continue
|
|
meta, path = row.split(b'\t', 1)
|
|
path = path.decode()
|
|
mode, kind, oid = meta.decode().split()
|
|
require(path not in result, 'duplicate tree path')
|
|
require(not Path(path).is_absolute() and '..' not in Path(path).parts, 'unsafe tree path')
|
|
result[path] = (mode, kind, oid)
|
|
return result
|
|
|
|
|
|
def source_paths(manifest):
|
|
folders = {'autoload', 'scripts', 'scenes', 'data', 'tests', 'assets', 'tools'}
|
|
fixed = {'project.godot', 'export_presets.cfg', 'Makefile', 'AGENTS.md'}
|
|
require(fixed <= set(manifest), 'missing fixed source settings')
|
|
return {p for p in manifest if (Path(p).parts[0] in folders and Path(p).name != '.DS_Store'
|
|
and '__pycache__' not in Path(p).parts)} | fixed
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def tested_checkout(root, t):
|
|
# Only a private writable allocation; never fall back to global temp.
|
|
temporary = os.environ.get('TMPDIR')
|
|
require(temporary and Path(temporary).is_dir(), 'existing task TMPDIR required')
|
|
with tempfile.TemporaryDirectory(prefix='p24-view-', dir=temporary) as name:
|
|
view = Path(name)
|
|
manifest = tree(root, t)
|
|
paths = sorted(manifest)
|
|
for path, (_, raw) in zip(paths, git_blob_stream(root, [manifest[p][2] for p in paths])):
|
|
require(manifest[path][0] in ('100644', '100755') and manifest[path][1] == 'blob',
|
|
'non-regular tested source')
|
|
target = safe_path(view, path)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(raw)
|
|
yield view
|
|
|
|
|
|
def deterministic_tree(root, m, s, main_inputs=MAIN_INPUTS):
|
|
main, submitted = tree(root, m), tree(root, s)
|
|
require(set(main_inputs) == KNOWN_CONFLICTS, 'reconciliation must bind all four inputs')
|
|
for p, oid in main_inputs.items():
|
|
require(main.get(p, (None, None, None))[2] == oid,
|
|
'Main conflict input changed; new reconciliation required: ' + p)
|
|
temporary = os.environ.get('TMPDIR')
|
|
require(temporary and Path(temporary).is_dir(), 'existing task TMPDIR required')
|
|
objects = Path(git_bytes(root, 'rev-parse', '--git-path', 'objects').decode().strip())
|
|
if not objects.is_absolute():
|
|
objects = root / objects
|
|
with tempfile.TemporaryDirectory(prefix='p24-compose-', dir=temporary) as name:
|
|
private = Path(name)
|
|
(private / 'objects').mkdir()
|
|
env = {'GIT_OBJECT_DIRECTORY': str(private / 'objects'),
|
|
'GIT_ALTERNATE_OBJECT_DIRECTORIES': str(objects.resolve()),
|
|
'GIT_INDEX_FILE': str(private / 'index')}
|
|
result = git_process(root, 'merge-tree', '--write-tree', '-z', '--name-only', m, s, private=env)
|
|
require(result.returncode == 1, 'expected exactly four content conflicts')
|
|
fields = result.stdout.split(b'\0')
|
|
merged = fields.pop(0).decode()
|
|
require(re.fullmatch('[0-9a-f]{40}', merged), 'missing merge tree')
|
|
conflict_paths = []
|
|
while fields and fields[0]:
|
|
conflict_paths.append(fields.pop(0).decode())
|
|
require(fields and fields.pop(0) == b'', 'missing conflict separator')
|
|
require(len(conflict_paths) == 4 and set(conflict_paths) == KNOWN_CONFLICTS, 'unexpected conflict set')
|
|
# Structured merge-tree messages: path count, paths, type, message.
|
|
seen = set()
|
|
while fields and fields[0]:
|
|
count = int(fields.pop(0))
|
|
paths = [fields.pop(0).decode() for _ in range(count)]
|
|
kind = fields.pop(0).decode()
|
|
message = fields.pop(0).decode()
|
|
if kind == 'Auto-merging':
|
|
continue
|
|
require(kind == 'CONFLICT (contents)' and count == 1 and paths[0] in KNOWN_CONFLICTS
|
|
and 'binary' not in message.lower(), 'unsupported conflict type: ' + kind)
|
|
seen.update(paths)
|
|
require(seen == KNOWN_CONFLICTS, 'missing content conflict messages')
|
|
for p in KNOWN_CONFLICTS:
|
|
require(main[p][0] == submitted[p][0] == '100644'
|
|
and main[p][1] == submitted[p][1] == 'blob', 'mode/type conflict')
|
|
git_bytes(root, 'read-tree', merged, private=env)
|
|
for p in sorted(KNOWN_CONFLICTS):
|
|
mode, _, oid = submitted[p]
|
|
git_bytes(root, 'update-index', '--add', '--cacheinfo', mode, oid, p, private=env)
|
|
return git_bytes(root, 'write-tree', private=env).decode().strip()
|
|
|
|
|
|
def verify_composition(root, roles, source_map, inputs, base, allowed_paths, main_inputs=MAIN_INPUTS):
|
|
require(isinstance(roles, dict), 'missing composition roles')
|
|
for k in ('S', 'M', 'Q', 'T'):
|
|
require(isinstance(roles.get(k), str) and re.fullmatch('[0-9a-f]{40}', roles[k]), 'invalid role ' + k)
|
|
s, m, q, t = (roles[k] for k in ('S', 'M', 'Q', 'T'))
|
|
for c in (base, s, m, q):
|
|
require(git_bytes(root, 'cat-file', '-t', c).strip() == b'commit', 'role is not commit')
|
|
require(git_bytes(root, 'rev-list', '--parents', '-n', '1', q).decode().split() == [q, m, s],
|
|
'Q parents must be exactly current [M,S]')
|
|
require(git_bytes(root, 'rev-parse', q+'^{tree}').decode().strip() == t, 'T differs from Q tree')
|
|
head = git_bytes(root, 'rev-parse', 'HEAD').decode().strip()
|
|
if roles.get('mode') == 'normal_validation':
|
|
require(head == s, 'normal validation HEAD must be S')
|
|
elif roles.get('mode') == 'integration_final':
|
|
require(roles.get('C') == head, 'final HEAD must be C')
|
|
require(git_bytes(root, 'rev-list', '--parents', '-n', '1', head).decode().split() == [head, m, s],
|
|
'C parents must be exactly [M,S]')
|
|
require(git_bytes(root, 'rev-parse', head+'^{tree}').decode().strip() == t, 'C tree differs')
|
|
require(not git_bytes(root, 'status', '--porcelain'), 'final checkout dirty')
|
|
else:
|
|
raise ValueError('invalid composition mode')
|
|
require(deterministic_tree(root, m, s, main_inputs) == t, 'Q/T differs from deterministic composition')
|
|
b, st, mt, tt = (tree(root, rev) for rev in (base, s, m, t))
|
|
changed_s = {p for p in b.keys() | st.keys() if b.get(p) != st.get(p)}
|
|
changed_m = {p for p in b.keys() | mt.keys() if b.get(p) != mt.get(p)}
|
|
def allowed(p):
|
|
return any(p.startswith(a[:-2]) if a.endswith('/**') else p == a for a in allowed_paths)
|
|
require(all(allowed(p) for p in changed_s), 'S changed outside allowed_paths')
|
|
for paths, authority in ((changed_s-changed_m, st), (changed_m-changed_s, mt), (KNOWN_CONFLICTS, st)):
|
|
require(all(tt.get(p) == authority.get(p) for p in paths), 'S/M-only or conflict provenance mismatch')
|
|
require(isinstance(source_map, dict) and set(source_map) == source_paths(tt), 'incomplete source_refs map')
|
|
require(isinstance(inputs, dict) and {SPEC, CONTRACT, 'project.godot', 'tools/regression/suite-policy.json'} <= set(inputs),
|
|
'missing settings/spec/marker/policy input pins')
|
|
required = set(source_map) | set(inputs)
|
|
bindings = roles.get('blob_bindings')
|
|
require(isinstance(bindings, dict) and set(bindings) == required, 'missing/extra blob_bindings')
|
|
paths = sorted(required)
|
|
for p in paths:
|
|
require(p in tt and tt[p][0] in ('100644', '100755') and tt[p][1] == 'blob', 'not regular bound blob')
|
|
require(bindings[p] == tt[p][2], 'changed blob binding: ' + p)
|
|
for p, (oid, raw) in zip(paths, git_blob_stream(root, [tt[p][2] for p in paths])):
|
|
digest = hashlib.sha256(raw).hexdigest()
|
|
require(blob_oid(raw) == bindings[p] == oid, 'SHA1 binding mismatch')
|
|
for mapping in (source_map, inputs):
|
|
require(p not in mapping or mapping[p] == digest, 'SHA256 binding mismatch: ' + p)
|
|
if head != s or p in changed_s-changed_m or p in KNOWN_CONFLICTS:
|
|
require(safe_path(root, p).read_bytes() == raw, 'checkout bytes mismatch: ' + p)
|
|
return roles
|