devlyn-cli 3.1.0 → 3.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/bin/devlyn.js +69 -23
- package/config/skills/_shared/adapters/claude.md +3 -2
- package/config/skills/_shared/adapters/codex.md +1 -1
- package/config/skills/_shared/archive_run.py +5 -3
- package/config/skills/_shared/codex-config.md +13 -8
- package/config/skills/_shared/codex-monitored.sh +12 -19
- package/config/skills/_shared/collect-codex-findings.py +1 -0
- package/config/skills/_shared/engine-preflight.md +2 -0
- package/config/skills/_shared/finish-gate.py +4 -1
- package/config/skills/_shared/invocation-receipt.py +194 -5
- package/config/skills/_shared/judge-role-evidence.py +33 -6
- package/config/skills/_shared/phase-prompt-render.py +2 -0
- package/config/skills/_shared/platform-support.py +370 -0
- package/config/skills/_shared/process-evidence.py +20 -3
- package/config/skills/_shared/resolve-bootstrap.py +131 -63
- package/config/skills/_shared/resolve-stop-hook.py +3 -1
- package/config/skills/_shared/role-config.py +7 -5
- package/config/skills/_shared/run-bounded.py +35 -21
- package/config/skills/_shared/spec-verify-check.py +393 -167
- package/config/skills/_shared/state-phase-write.py +26 -9
- package/config/skills/_shared/task-complete.py +337 -97
- package/config/skills/_shared/verify-merge-findings.py +9 -8
- package/config/skills/devlyn:ideate/references/spec-template.md +1 -1
- package/config/skills/devlyn:resolve/SKILL.md +10 -7
- package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/verify.md +14 -10
- package/config/skills/devlyn:resolve/references/state-schema.md +1 -1
- package/config/skills/devlyn:resolve/references/task-completion.md +28 -1
- package/package.json +1 -1
- package/scripts/lint-skills.sh +6 -5
|
@@ -8,7 +8,6 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
10
|
import contextlib
|
|
11
|
-
import fcntl
|
|
12
11
|
import functools
|
|
13
12
|
import hashlib
|
|
14
13
|
import json
|
|
@@ -35,7 +34,7 @@ def require(condition, message):
|
|
|
35
34
|
|
|
36
35
|
|
|
37
36
|
def command(argv, *, cwd=None, ok=(0,)):
|
|
38
|
-
result = subprocess.run(argv, cwd=cwd, capture_output=True, text=True)
|
|
37
|
+
result = subprocess.run(argv, cwd=cwd, capture_output=True, text=True, encoding="utf-8")
|
|
39
38
|
require(result.returncode in ok, f"{shlex.join(argv[:4])}: {result.stderr.strip() or result.stdout.strip()}")
|
|
40
39
|
return result.stdout.strip()
|
|
41
40
|
|
|
@@ -51,23 +50,24 @@ def shared(name):
|
|
|
51
50
|
|
|
52
51
|
def read_json(path):
|
|
53
52
|
require(stat.S_ISREG(path.lstat().st_mode), f"JSON must be a nonsymlink regular file: {path}")
|
|
54
|
-
return shared("archive_run")["loads_strict_json"](path.read_text())
|
|
53
|
+
return shared("archive_run")["loads_strict_json"](path.read_text(encoding="utf-8"))
|
|
55
54
|
|
|
56
55
|
|
|
57
56
|
def atomic_json(path, value):
|
|
58
57
|
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".receipt-")
|
|
59
58
|
try:
|
|
60
|
-
with os.fdopen(fd, "w") as stream:
|
|
59
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
61
60
|
json.dump(value, stream, sort_keys=True, indent=2)
|
|
62
61
|
stream.write("\n")
|
|
63
62
|
stream.flush()
|
|
64
63
|
os.fsync(stream.fileno())
|
|
65
64
|
os.replace(temporary, path)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
if os.name != "nt":
|
|
66
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
67
|
+
try:
|
|
68
|
+
os.fsync(directory)
|
|
69
|
+
finally:
|
|
70
|
+
os.close(directory)
|
|
71
71
|
finally:
|
|
72
72
|
Path(temporary).unlink(missing_ok=True)
|
|
73
73
|
|
|
@@ -167,7 +167,7 @@ def repository_from_url(url):
|
|
|
167
167
|
|
|
168
168
|
|
|
169
169
|
def policy(receipt, override):
|
|
170
|
-
config = subprocess.run(["git", "--git-dir", receipt["common_gitdir"], "config", "--local", "--get-all", "devlyn.completionMode"], capture_output=True, text=True)
|
|
170
|
+
config = subprocess.run(["git", "--git-dir", receipt["common_gitdir"], "config", "--local", "--get-all", "devlyn.completionMode"], capture_output=True, text=True, encoding="utf-8")
|
|
171
171
|
require(config.returncode in {0, 1}, "cannot read local completion policy: " + config.stderr.strip())
|
|
172
172
|
require(config.returncode == 1 or config.stdout in {"auto\n", "pr\n"}, "invalid local devlyn.completionMode; use auto|pr")
|
|
173
173
|
value = config.stdout.strip() if config.returncode == 0 else None
|
|
@@ -204,6 +204,9 @@ def allocate(args):
|
|
|
204
204
|
path = directory / "receipt.json"
|
|
205
205
|
receipt["id"] = key
|
|
206
206
|
receipt["recovery_ref"] = "refs/devlyn/completed/" + key
|
|
207
|
+
scratch = directory / "scratch"
|
|
208
|
+
scratch.mkdir()
|
|
209
|
+
receipt["scratch_identity"] = workspace_identity(scratch, directory)
|
|
207
210
|
# Persist prospective intent BEFORE native creation. An interrupted allocation
|
|
208
211
|
# stays visibly incomplete; a later call may not adopt whatever now exists.
|
|
209
212
|
atomic_json(path, receipt)
|
|
@@ -215,7 +218,7 @@ def allocate(args):
|
|
|
215
218
|
receipt["worktree_identity"] = workspace_identity(target, Path(receipt["worktree_gitdir"]))
|
|
216
219
|
receipt["allocation"] = "owned"
|
|
217
220
|
atomic_json(path, receipt)
|
|
218
|
-
return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target)}
|
|
221
|
+
return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch)}
|
|
219
222
|
|
|
220
223
|
|
|
221
224
|
def pipeline_acceptance(work, acceptance, files, directory):
|
|
@@ -269,7 +272,7 @@ def pipeline_acceptance(work, acceptance, files, directory):
|
|
|
269
272
|
require(evidence_module["bound_carrier_outcome"](reconstructed, carrier)["verdict"] == "PASS", "required process evidence failed")
|
|
270
273
|
source = state.get("source") or {}
|
|
271
274
|
if source.get("spec_path"):
|
|
272
|
-
expected = Path(source["spec_path"]).
|
|
275
|
+
expected = Path(source["spec_path"]).with_name("spec.expected.json")
|
|
273
276
|
original = safe_path(work, str(expected))
|
|
274
277
|
if original.exists():
|
|
275
278
|
dest = safe_path(reconstructed, str(expected))
|
|
@@ -342,7 +345,8 @@ def bind_acceptance(receipt, path, supplied):
|
|
|
342
345
|
require(committed.returncode == 0 and committed.stdout == source_file.read_bytes(), "source contract changed since accepted commit")
|
|
343
346
|
bound_digest = source.get(key.replace("_path", "_sha256"))
|
|
344
347
|
require(file_record(source_file)["sha256"] == bound_digest, "source contract differs from run binding")
|
|
345
|
-
|
|
348
|
+
source_path = Path(source[key])
|
|
349
|
+
expected = str(source_path.with_name("spec.expected.json") if key == "spec_path" else source_path.with_suffix(".expected.json"))
|
|
346
350
|
if safe_path(work, expected).exists():
|
|
347
351
|
paths.append(expected)
|
|
348
352
|
else:
|
|
@@ -412,7 +416,7 @@ def stopped_writers(work, *, linked):
|
|
|
412
416
|
# The owner assertion covers its actual children; an OS observation catches
|
|
413
417
|
# other currently open files/cwds, not future writers or a universal lease.
|
|
414
418
|
if sys.platform == "darwin":
|
|
415
|
-
result = subprocess.run(["lsof", "-Fpn", "+D", str(work)], capture_output=True, text=True)
|
|
419
|
+
result = subprocess.run(["lsof", "-Fpn", "+D", str(work)], capture_output=True, text=True, encoding="utf-8")
|
|
416
420
|
require(result.returncode in {0, 1} and not result.stderr.strip(), "writer observation unavailable; retain tree and inspect writers")
|
|
417
421
|
pid = None
|
|
418
422
|
for line in result.stdout.splitlines():
|
|
@@ -437,6 +441,75 @@ def stopped_writers(work, *, linked):
|
|
|
437
441
|
raise CompletionError("writer observation unsupported on this platform; retain workspace")
|
|
438
442
|
|
|
439
443
|
|
|
444
|
+
def scratch_mounts():
|
|
445
|
+
if sys.platform.startswith("linux"):
|
|
446
|
+
rows = Path("/proc/self/mountinfo").read_text(encoding="utf-8").splitlines()
|
|
447
|
+
paths = []
|
|
448
|
+
for line in rows:
|
|
449
|
+
fields = line.split()
|
|
450
|
+
require(len(fields) >= 6, "cannot parse mount table; retain scratch")
|
|
451
|
+
paths.append(re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), fields[4]))
|
|
452
|
+
elif sys.platform == "darwin":
|
|
453
|
+
paths = []
|
|
454
|
+
for line in command(["mount"]).splitlines():
|
|
455
|
+
description, separator, options = line.rpartition(" (")
|
|
456
|
+
candidates = [description[match.end():] for match in re.finditer(" on ", description)]
|
|
457
|
+
require(separator and options.endswith(")") and candidates, "cannot parse mount table; retain scratch")
|
|
458
|
+
# Darwin prints literal paths. Consider every possible separator so
|
|
459
|
+
# ' on ' in a device or directory name can only cause retention.
|
|
460
|
+
paths.extend(candidates)
|
|
461
|
+
else:
|
|
462
|
+
raise CompletionError("mount observation unsupported; retain scratch")
|
|
463
|
+
return [Path(value).resolve() for value in paths]
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def raise_walk_error(error):
|
|
467
|
+
raise error
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def clean_scratch(receipt, path, writers_stopped):
|
|
471
|
+
"""Empty the owned build directory; keep its identity across resume/reuse."""
|
|
472
|
+
scratch = path.parent / "scratch"
|
|
473
|
+
if "scratch_identity" not in receipt:
|
|
474
|
+
return {"status": "NOT_OWNED"}
|
|
475
|
+
require(writers_stopped, "stop/yield scratch writers, then use --writers-stopped")
|
|
476
|
+
require(scratch == scratch.resolve() and scratch.is_dir(), "owned scratch missing or redirected; retain and inspect")
|
|
477
|
+
identity = receipt["scratch_identity"]
|
|
478
|
+
require(workspace_identity(scratch, path.parent) == identity, "scratch directory was replaced; retain")
|
|
479
|
+
require(shutil.rmtree.avoids_symlink_attacks, "safe scratch removal unsupported on this platform; retain")
|
|
480
|
+
require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch contains a mounted filesystem; retain")
|
|
481
|
+
stopped_writers(scratch, linked=True)
|
|
482
|
+
size = 0
|
|
483
|
+
for directory, dirs, files in os.walk(scratch, followlinks=False, onerror=raise_walk_error):
|
|
484
|
+
require(not any(name.casefold() == ".git" for name in dirs + files), "scratch contains Git recovery data; move it to retained custody before cleanup")
|
|
485
|
+
for name in dirs + files:
|
|
486
|
+
item = Path(directory) / name
|
|
487
|
+
info = item.lstat()
|
|
488
|
+
require(info.st_dev == identity[0][0], "scratch contains a mounted filesystem; retain")
|
|
489
|
+
size += info.st_size if stat.S_ISREG(info.st_mode) else 0
|
|
490
|
+
stopped_writers(scratch, linked=True)
|
|
491
|
+
require(workspace_identity(scratch, path.parent) == identity, "scratch changed before removal; retain")
|
|
492
|
+
require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch mount appeared before removal; retain")
|
|
493
|
+
for item in scratch.iterdir():
|
|
494
|
+
if item.is_dir() and not item.is_symlink():
|
|
495
|
+
shutil.rmtree(item)
|
|
496
|
+
else:
|
|
497
|
+
item.unlink()
|
|
498
|
+
require(workspace_identity(scratch, path.parent) == identity and not any(scratch.iterdir()), "scratch changed during cleanup; retain and inspect")
|
|
499
|
+
result = {"status": "CLEAN", "logical_bytes_removed": size}
|
|
500
|
+
receipt["scratch_cleanup"] = result
|
|
501
|
+
atomic_json(path, receipt)
|
|
502
|
+
return result
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def clean_scratch_command(args):
|
|
506
|
+
path = Path(args.receipt).absolute()
|
|
507
|
+
with locked_receipt(path) as receipt:
|
|
508
|
+
result = clean_scratch(receipt, path, args.writers_stopped)
|
|
509
|
+
return {"status": "SCRATCH_CLEAN" if result["status"] == "CLEAN" else "SCRATCH_NOT_OWNED",
|
|
510
|
+
"receipt": str(path), "scratch_cleanup": result, "product_verdict_unchanged": True}
|
|
511
|
+
|
|
512
|
+
|
|
440
513
|
def cleanup(receipt, path, pr, writers_stopped):
|
|
441
514
|
require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
|
|
442
515
|
require(writers_stopped, "wait actual children, stop/yield known writers, then resume with --writers-stopped")
|
|
@@ -508,8 +581,11 @@ def cleanup(receipt, path, pr, writers_stopped):
|
|
|
508
581
|
def locked_receipt(path):
|
|
509
582
|
require(path.name == "receipt.json" and path.parent.parent.name == "devlyn-completion" and path == path.resolve(), "receipt must be its original external nonsymlink path")
|
|
510
583
|
require(not (path.parent / "lock").is_symlink(), "receipt lock must not be a symlink")
|
|
511
|
-
with
|
|
512
|
-
|
|
584
|
+
with contextlib.ExitStack() as stack:
|
|
585
|
+
try:
|
|
586
|
+
stack.enter_context(shared("platform-support")["file_lock"](path.parent / "lock", blocking=True))
|
|
587
|
+
except (ImportError, OSError, AttributeError) as exc:
|
|
588
|
+
raise CompletionError(f"receipt lock unavailable: {path}: {exc}") from exc
|
|
513
589
|
receipt = read_json(path)
|
|
514
590
|
require(path == Path(receipt["common_gitdir"]) / "devlyn-completion" / receipt["id"] / "receipt.json", "receipt/common Gitdir binding mismatch")
|
|
515
591
|
require(receipt["id"] == hashlib.sha256(receipt["branch"].encode()).hexdigest()[:24] and receipt["branch"] not in {receipt["base"], "main", "master"}, "receipt branch ownership changed")
|
|
@@ -519,11 +595,27 @@ def locked_receipt(path):
|
|
|
519
595
|
|
|
520
596
|
def complete(args):
|
|
521
597
|
path = Path(args.receipt).absolute()
|
|
522
|
-
resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)]
|
|
598
|
+
resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)] +
|
|
599
|
+
(["--writers-stopped"] if args.writers_stopped else []))
|
|
523
600
|
with locked_receipt(path) as receipt:
|
|
524
601
|
def result(status):
|
|
525
|
-
|
|
526
|
-
|
|
602
|
+
scratch = {"status": "NOT_OWNED"}
|
|
603
|
+
if "scratch_identity" in receipt:
|
|
604
|
+
try:
|
|
605
|
+
scratch = clean_scratch(receipt, path, args.writers_stopped)
|
|
606
|
+
except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError) as error:
|
|
607
|
+
scratch = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
|
|
608
|
+
sys.executable, str(Path(__file__).resolve()), "clean-scratch", "--receipt", str(path), "--writers-stopped"])}
|
|
609
|
+
receipt["scratch_cleanup"] = scratch
|
|
610
|
+
try:
|
|
611
|
+
atomic_json(path, receipt)
|
|
612
|
+
except OSError as record_error:
|
|
613
|
+
scratch["record_error"] = str(record_error)
|
|
614
|
+
return {"status": "CLEANUP_PENDING" if status == "COMPLETE" and scratch["status"] == "RETAINED" else status,
|
|
615
|
+
"delivery_status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
|
|
616
|
+
"acceptance": (receipt.get("acceptance") or {}).get("kind"), "product_verdict_unchanged": True,
|
|
617
|
+
"scratch_path": str(path.parent / "scratch") if "scratch_identity" in receipt else None,
|
|
618
|
+
"scratch_cleanup": scratch}
|
|
527
619
|
if args.local_only or receipt.get("local_only"):
|
|
528
620
|
receipt["local_only"] = True
|
|
529
621
|
atomic_json(path, receipt)
|
|
@@ -607,20 +699,24 @@ def main():
|
|
|
607
699
|
completion.add_argument("--mode", choices=("auto", "pr"))
|
|
608
700
|
completion.add_argument("--local-only", "--no-push", action="store_true")
|
|
609
701
|
completion.add_argument("--writers-stopped", action="store_true")
|
|
702
|
+
scratch_cleanup = actions.add_parser("clean-scratch")
|
|
703
|
+
scratch_cleanup.add_argument("--receipt", required=True)
|
|
704
|
+
scratch_cleanup.add_argument("--writers-stopped", action="store_true")
|
|
610
705
|
args = parser.parse_args()
|
|
611
706
|
if args.self_test:
|
|
612
707
|
return self_test()
|
|
613
708
|
if args.action is None:
|
|
614
|
-
parser.error("allocate or
|
|
709
|
+
parser.error("allocate, complete or clean-scratch is required")
|
|
615
710
|
try:
|
|
616
|
-
result = allocate(args) if args.action == "allocate" else
|
|
711
|
+
result = (allocate(args) if args.action == "allocate" else
|
|
712
|
+
clean_scratch_command(args) if args.action == "clean-scratch" else complete(args))
|
|
617
713
|
print(json.dumps(result, sort_keys=True))
|
|
618
714
|
return 0
|
|
619
|
-
except (CompletionError, OSError, ValueError, KeyError, TypeError, SystemExit) as exc:
|
|
715
|
+
except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError, SystemExit) as exc:
|
|
620
716
|
result = {"status": "BLOCKED", "reason": str(exc)}
|
|
621
|
-
if args.action
|
|
717
|
+
if args.action in {"complete", "clean-scratch"}:
|
|
622
718
|
result["receipt"] = str(Path(args.receipt).absolute())
|
|
623
|
-
result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()),
|
|
719
|
+
result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), args.action, "--receipt", result["receipt"], *(["--writers-stopped"] if args.writers_stopped else [])])
|
|
624
720
|
print(json.dumps(result, sort_keys=True))
|
|
625
721
|
return 1
|
|
626
722
|
|
|
@@ -639,10 +735,10 @@ def self_test(names=None):
|
|
|
639
735
|
FAKE_GH = r'''#!/usr/bin/env python3
|
|
640
736
|
import json, os, pathlib, subprocess, sys
|
|
641
737
|
p = pathlib.Path(os.environ['FIXTURE_GH'])
|
|
642
|
-
d = json.loads(p.read_text()); a = sys.argv[1:]
|
|
643
|
-
def save(): p.write_text(json.dumps(d))
|
|
738
|
+
d = json.loads(p.read_text(encoding="utf-8")); a = sys.argv[1:]
|
|
739
|
+
def save(): p.write_text(json.dumps(d), encoding="utf-8")
|
|
644
740
|
def remote(ref):
|
|
645
|
-
r = subprocess.run([os.environ['REAL_GIT'], '--git-dir', d['bare'], 'rev-parse', '--verify', ref], capture_output=True, text=True)
|
|
741
|
+
r = subprocess.run([os.environ['REAL_GIT'], '--git-dir', d['bare'], 'rev-parse', '--verify', ref], capture_output=True, text=True, encoding="utf-8")
|
|
646
742
|
return r.stdout.strip() if r.returncode == 0 else None
|
|
647
743
|
if a[:2] == ['repo', 'view']:
|
|
648
744
|
assert a[2:] == ['github.com/test/project', '--json', 'nameWithOwner,url,defaultBranchRef,mergeCommitAllowed'], 'unsupported gh repo view arguments: '+str(a)
|
|
@@ -673,8 +769,8 @@ elif a[:2] == ['pr', 'merge']:
|
|
|
673
769
|
else:
|
|
674
770
|
g = [os.environ['REAL_GIT'],'--git-dir',d['bare']]
|
|
675
771
|
base = remote('refs/heads/main')
|
|
676
|
-
tree = subprocess.check_output(g+['rev-parse',sha+'^{tree}'],text=True).strip()
|
|
677
|
-
merge = subprocess.check_output(g+['commit-tree',tree,'-p',base,'-p',sha,'-m','merge fixture'],text=True).strip()
|
|
772
|
+
tree = subprocess.check_output(g+['rev-parse',sha+'^{tree}'],text=True, encoding="utf-8").strip()
|
|
773
|
+
merge = subprocess.check_output(g+['commit-tree',tree,'-p',base,'-p',sha,'-m','merge fixture'],text=True, encoding="utf-8").strip()
|
|
678
774
|
subprocess.check_call(g+['update-ref','refs/heads/main',merge,base])
|
|
679
775
|
d['pr'].update(state='MERGED',mergedAt='now',mergeCommit={'oid':merge})
|
|
680
776
|
save()
|
|
@@ -686,22 +782,22 @@ else:
|
|
|
686
782
|
GIT_WRAPPER = r'''#!/usr/bin/env python3
|
|
687
783
|
import json, os, pathlib, subprocess, sys
|
|
688
784
|
a = sys.argv[1:]
|
|
689
|
-
p = pathlib.Path(os.environ['FIXTURE_GH']); d = json.loads(p.read_text())
|
|
785
|
+
p = pathlib.Path(os.environ['FIXTURE_GH']); d = json.loads(p.read_text(encoding="utf-8"))
|
|
690
786
|
if 'push' in a and any(x.startswith('--force-with-lease=') for x in a) and d.pop('remote_delete_race',False):
|
|
691
787
|
subprocess.check_call([os.environ['REAL_GIT'],'--git-dir',d['bare'],'update-ref','refs/heads/'+d['pr']['headRefName'],d['race_sha']])
|
|
692
|
-
p.write_text(json.dumps(d))
|
|
788
|
+
p.write_text(json.dumps(d), encoding="utf-8")
|
|
693
789
|
for operation in ('push', 'fetch', 'ls-remote'):
|
|
694
790
|
if operation in a and 'origin' in a:
|
|
695
791
|
prefix = a[:a.index(operation)]
|
|
696
|
-
url = subprocess.check_output([os.environ['REAL_GIT'], *prefix, 'remote', 'get-url', *(['--push'] if operation == 'push' else []), '--all', 'origin'], text=True).strip()
|
|
792
|
+
url = subprocess.check_output([os.environ['REAL_GIT'], *prefix, 'remote', 'get-url', *(['--push'] if operation == 'push' else []), '--all', 'origin'], text=True, encoding="utf-8").strip()
|
|
697
793
|
if url in {'https://github.com/test/project.git', 'git@github.com:test/project.git', 'ssh://git@github.com/test/project.git'}:
|
|
698
794
|
a[a.index('origin')] = d['bare']
|
|
699
795
|
break
|
|
700
796
|
r = subprocess.run([os.environ['REAL_GIT'],*a])
|
|
701
797
|
event = 'delete' if 'push' in a and any(x.startswith(':refs/') for x in a) else 'push' if 'push' in a else 'remove' if 'worktree' in a and 'remove' in a else None
|
|
702
798
|
if r.returncode == 0 and event:
|
|
703
|
-
d = json.loads(p.read_text()); d[event+'s'] = d.get(event+'s',0)+1
|
|
704
|
-
fail = d.pop('interrupt_'+event,False); p.write_text(json.dumps(d))
|
|
799
|
+
d = json.loads(p.read_text(encoding="utf-8")); d[event+'s'] = d.get(event+'s',0)+1
|
|
800
|
+
fail = d.pop('interrupt_'+event,False); p.write_text(json.dumps(d), encoding="utf-8")
|
|
705
801
|
if fail: sys.exit(1)
|
|
706
802
|
sys.exit(r.returncode)
|
|
707
803
|
'''
|
|
@@ -717,7 +813,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
717
813
|
self.bin = self.root / "bin"
|
|
718
814
|
self.bin.mkdir()
|
|
719
815
|
self.data = self.root / "gh.json"
|
|
720
|
-
self.data.write_text(json.dumps({"bare": str(self.bare)}))
|
|
816
|
+
self.data.write_text(json.dumps({"bare": str(self.bare)}), encoding="utf-8")
|
|
721
817
|
self.env = {**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(self.root / "gitconfig"),
|
|
722
818
|
"GIT_AUTHOR_NAME": "Fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
|
|
723
819
|
"GIT_COMMITTER_NAME": "Fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid",
|
|
@@ -728,13 +824,13 @@ class CompletionTests(unittest.TestCase):
|
|
|
728
824
|
del self.env[key]
|
|
729
825
|
for name, body in (("gh", FAKE_GH), ("git", GIT_WRAPPER)):
|
|
730
826
|
path = self.bin / name
|
|
731
|
-
path.write_text(body)
|
|
827
|
+
path.write_text(body, encoding="utf-8")
|
|
732
828
|
path.chmod(0o755)
|
|
733
829
|
self.env["PATH"] = str(self.bin) + os.pathsep + os.environ["PATH"]
|
|
734
830
|
self.run_cmd(["git", "init", "--bare", "--initial-branch=main", str(self.bare)])
|
|
735
831
|
self.run_cmd(["git", "init", "--initial-branch=main", str(self.work)])
|
|
736
|
-
(self.work / ".gitignore").write_text(".devlyn/\nignored/\n")
|
|
737
|
-
(self.work / "product").write_text("baseline\n")
|
|
832
|
+
(self.work / ".gitignore").write_text(".devlyn/\nignored/\n", encoding="utf-8")
|
|
833
|
+
(self.work / "product").write_text("baseline\n", encoding="utf-8")
|
|
738
834
|
self.g("add", ".")
|
|
739
835
|
self.g("commit", "-m", "baseline")
|
|
740
836
|
self.g("remote", "add", "origin", "https://github.com/test/project.git")
|
|
@@ -742,7 +838,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
742
838
|
self.configure(pushs=0)
|
|
743
839
|
|
|
744
840
|
def run_cmd(self, args, *, cwd=None, success=True):
|
|
745
|
-
r = subprocess.run(args, cwd=cwd or self.root, env=self.env, capture_output=True, text=True)
|
|
841
|
+
r = subprocess.run(args, cwd=cwd or self.root, env=self.env, capture_output=True, text=True, encoding="utf-8")
|
|
746
842
|
if success:
|
|
747
843
|
self.assertEqual(r.returncode, 0, r.stderr + r.stdout)
|
|
748
844
|
return r
|
|
@@ -751,8 +847,8 @@ class CompletionTests(unittest.TestCase):
|
|
|
751
847
|
return self.run_cmd(["git", "-C", str(work or self.work), *args]).stdout.strip()
|
|
752
848
|
|
|
753
849
|
def configure(self, **values):
|
|
754
|
-
d = json.loads(self.data.read_text()); d.update(values)
|
|
755
|
-
self.data.write_text(json.dumps(d))
|
|
850
|
+
d = json.loads(self.data.read_text(encoding="utf-8")); d.update(values)
|
|
851
|
+
self.data.write_text(json.dumps(d), encoding="utf-8")
|
|
756
852
|
|
|
757
853
|
def cli(self, *args, success=True, cwd=None):
|
|
758
854
|
r = self.run_cmd([sys.executable, str(Path(__file__).resolve()), *map(str, args)], success=success, cwd=cwd)
|
|
@@ -767,49 +863,176 @@ class CompletionTests(unittest.TestCase):
|
|
|
767
863
|
self.task = Path(result["worktree"])
|
|
768
864
|
return result
|
|
769
865
|
|
|
770
|
-
|
|
866
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
867
|
+
def test_disposable_scratch_cleans_for_local_only_and_preserves_source(self):
|
|
868
|
+
result = self.allocate()
|
|
869
|
+
scratch = Path(result["scratch"])
|
|
870
|
+
(scratch / "target").mkdir()
|
|
871
|
+
(scratch / "target" / "object").write_bytes(b"rebuildable")
|
|
872
|
+
(self.task / "uncommitted-work").write_text("preserve")
|
|
873
|
+
result, _ = self.cli("complete", "--receipt", self.receipt, "--local-only", "--writers-stopped")
|
|
874
|
+
self.assertEqual(result["status"], "LOCAL_ONLY")
|
|
875
|
+
self.assertFalse(any(scratch.iterdir()))
|
|
876
|
+
self.assertEqual((self.task / "uncommitted-work").read_text(), "preserve")
|
|
877
|
+
self.assertEqual(result["scratch_cleanup"]["logical_bytes_removed"], 11)
|
|
878
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
879
|
+
self.assertEqual(result["status"], "SCRATCH_CLEAN")
|
|
880
|
+
|
|
881
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
882
|
+
def test_scratch_rejects_active_writer_and_requires_owner_assertion(self):
|
|
883
|
+
scratch = Path(self.allocate()["scratch"])
|
|
884
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, success=False)
|
|
885
|
+
self.assertIn("--writers-stopped", result["reason"])
|
|
886
|
+
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=scratch)
|
|
887
|
+
try:
|
|
888
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
|
|
889
|
+
self.assertIn("active process", result["reason"])
|
|
890
|
+
self.assertTrue(scratch.is_dir())
|
|
891
|
+
finally:
|
|
892
|
+
child.terminate()
|
|
893
|
+
child.wait(timeout=5)
|
|
894
|
+
self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
895
|
+
|
|
896
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
897
|
+
def test_scratch_rejects_redirect_and_git_data_but_does_not_follow_child_links(self):
|
|
898
|
+
scratch = Path(self.allocate()["scratch"])
|
|
899
|
+
outside = self.root / "outside"
|
|
900
|
+
outside.mkdir()
|
|
901
|
+
(outside / "keep").write_text("source")
|
|
902
|
+
retained = scratch.with_name("retained")
|
|
903
|
+
scratch.rename(retained)
|
|
904
|
+
scratch.symlink_to(outside, target_is_directory=True)
|
|
905
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
|
|
906
|
+
self.assertIn("redirected", result["reason"])
|
|
907
|
+
scratch.unlink()
|
|
908
|
+
retained.rename(scratch)
|
|
909
|
+
(scratch / ".GiT").write_text("gitdir: elsewhere")
|
|
910
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
|
|
911
|
+
self.assertIn("Git recovery data", result["reason"])
|
|
912
|
+
(scratch / ".GiT").unlink()
|
|
913
|
+
(scratch / "external-link").symlink_to(outside, target_is_directory=True)
|
|
914
|
+
self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
915
|
+
self.assertEqual((outside / "keep").read_text(), "source")
|
|
916
|
+
|
|
917
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
918
|
+
def test_scratch_reuse_and_partial_cleanup_keep_original_identity(self):
|
|
919
|
+
scratch = Path(self.allocate()["scratch"])
|
|
920
|
+
(scratch / "first-build").write_bytes(b"first")
|
|
921
|
+
self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
922
|
+
(scratch / "resumed-build").write_bytes(b"next")
|
|
923
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
924
|
+
self.assertEqual(result["scratch_cleanup"]["logical_bytes_removed"], 4)
|
|
925
|
+
self.assertFalse(any(scratch.iterdir()))
|
|
926
|
+
scratch.rename(scratch.with_name("original"))
|
|
927
|
+
scratch.mkdir()
|
|
928
|
+
(scratch / "foreign").write_text("preserve")
|
|
929
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
|
|
930
|
+
self.assertIn("replaced", result["reason"])
|
|
931
|
+
self.assertIn("--writers-stopped", result["resume"])
|
|
932
|
+
self.assertEqual((scratch / "foreign").read_text(), "preserve")
|
|
933
|
+
|
|
934
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
935
|
+
def test_scratch_refusal_does_not_block_local_only_delivery(self):
|
|
936
|
+
scratch = Path(self.allocate()["scratch"])
|
|
937
|
+
(scratch / ".git").mkdir()
|
|
938
|
+
result, _ = self.cli("complete", "--receipt", self.receipt, "--local-only", "--writers-stopped")
|
|
939
|
+
self.assertEqual(result["status"], "LOCAL_ONLY")
|
|
940
|
+
self.assertEqual(result["scratch_cleanup"]["status"], "RETAINED")
|
|
941
|
+
self.assertIn("Git recovery data", result["scratch_cleanup"]["reason"])
|
|
942
|
+
self.assertEqual(json.loads(self.receipt.read_text())["scratch_cleanup"]["status"], "RETAINED")
|
|
943
|
+
self.assertTrue((scratch / ".git").is_dir())
|
|
944
|
+
|
|
945
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
946
|
+
def test_scratch_refusal_reports_delivery_separately(self):
|
|
947
|
+
scratch = Path(self.allocate()["scratch"])
|
|
948
|
+
self.accept()
|
|
949
|
+
(scratch / ".git").mkdir()
|
|
950
|
+
result, _ = self.cli("complete", "--receipt", self.receipt, "--acceptance", self.acceptance, "--writers-stopped")
|
|
951
|
+
self.assertEqual(result["status"], "CLEANUP_PENDING")
|
|
952
|
+
self.assertEqual(result["delivery_status"], "COMPLETE")
|
|
953
|
+
self.assertEqual(result["scratch_cleanup"]["status"], "RETAINED")
|
|
954
|
+
self.assertIn("--writers-stopped", result["scratch_cleanup"]["resume"])
|
|
955
|
+
self.assertIn("--writers-stopped", result["resume"])
|
|
956
|
+
(scratch / ".git").rmdir()
|
|
957
|
+
result, _ = self.cli("complete", "--receipt", self.receipt, "--writers-stopped")
|
|
958
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
959
|
+
|
|
960
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
961
|
+
def test_scratch_mount_and_scan_failure_preserve_contents(self):
|
|
962
|
+
from unittest.mock import patch
|
|
963
|
+
scratch = Path(self.allocate()["scratch"])
|
|
964
|
+
(scratch / "keep").write_text("retained")
|
|
965
|
+
receipt = json.loads(self.receipt.read_text())
|
|
966
|
+
with patch.dict(clean_scratch.__globals__, {"scratch_mounts": lambda: [scratch / "same-device-bind"]}):
|
|
967
|
+
with self.assertRaisesRegex(CompletionError, "mounted filesystem"):
|
|
968
|
+
clean_scratch(receipt, self.receipt, True)
|
|
969
|
+
def failed_walk(*args, **kwargs):
|
|
970
|
+
kwargs["onerror"](PermissionError("unreadable subtree"))
|
|
971
|
+
with patch.object(os, "walk", failed_walk):
|
|
972
|
+
with self.assertRaisesRegex(PermissionError, "unreadable subtree"):
|
|
973
|
+
clean_scratch(receipt, self.receipt, True)
|
|
974
|
+
self.assertEqual((scratch / "keep").read_text(), "retained")
|
|
975
|
+
|
|
976
|
+
def test_scratch_mount_table_parsing_preserves_ambiguous_literal_paths(self):
|
|
977
|
+
from unittest.mock import patch
|
|
978
|
+
target = "/task/scratch/name on inside\\040literal"
|
|
979
|
+
with patch.object(sys, "platform", "darwin"), patch.dict(scratch_mounts.__globals__, {
|
|
980
|
+
"command": lambda args: "device on source on " + target + " (local, journaled)",
|
|
981
|
+
}):
|
|
982
|
+
self.assertIn(Path(target).resolve(), scratch_mounts())
|
|
983
|
+
alias = self.root / "scratch-alias"
|
|
984
|
+
alias.symlink_to(self.root, target_is_directory=True)
|
|
985
|
+
with patch.object(sys, "platform", "darwin"), patch.dict(scratch_mounts.__globals__, {
|
|
986
|
+
"command": lambda args: "device on " + str(alias / "mounted") + " (local)",
|
|
987
|
+
}):
|
|
988
|
+
self.assertEqual(scratch_mounts(), [(self.root / "mounted").resolve()])
|
|
989
|
+
with patch.object(sys, "platform", "linux"), patch.object(Path, "read_text", return_value="truncated row"):
|
|
990
|
+
with self.assertRaisesRegex(CompletionError, "cannot parse mount table"):
|
|
991
|
+
scratch_mounts()
|
|
992
|
+
|
|
993
|
+
def accept(self, pipeline=False, queue=False, spec_expected=None, spec_name="spec.md"):
|
|
771
994
|
if spec_expected is not None:
|
|
772
|
-
(self.task /
|
|
773
|
-
(self.task / "spec.expected.json").write_text(json.dumps(spec_expected))
|
|
774
|
-
self.g("add",
|
|
775
|
-
(self.task / "product").write_text("accepted\n")
|
|
995
|
+
(self.task / spec_name).write_text("# Fixture\nProduct contains accepted bytes.\n", encoding="utf-8")
|
|
996
|
+
(self.task / "spec.expected.json").write_text(json.dumps(spec_expected), encoding="utf-8")
|
|
997
|
+
self.g("add", spec_name, "spec.expected.json", work=self.task)
|
|
998
|
+
(self.task / "product").write_text("accepted\n", encoding="utf-8")
|
|
776
999
|
self.g("add", "product", work=self.task)
|
|
777
1000
|
self.g("commit", "-m", "scoped task", work=self.task)
|
|
778
1001
|
self.sha = self.g("rev-parse", "HEAD", work=self.task)
|
|
779
1002
|
evidence = self.task / ".devlyn"
|
|
780
1003
|
evidence.mkdir(exist_ok=True)
|
|
781
|
-
(evidence / "checks.txt").write_text("actual fixture check: accepted bytes\n")
|
|
1004
|
+
(evidence / "checks.txt").write_text("actual fixture check: accepted bytes\n", encoding="utf-8")
|
|
782
1005
|
a = {"kind": "direct", "task": "fixture", "source_sha": self.sha,
|
|
783
1006
|
"checks": [{"command": "fixture byte assertion", "evidence": ".devlyn/checks.txt"}]}
|
|
784
|
-
self.assertEqual((self.task / "product").read_text(), "accepted\n")
|
|
1007
|
+
self.assertEqual((self.task / "product").read_text(encoding="utf-8"), "accepted\n")
|
|
785
1008
|
if pipeline:
|
|
786
1009
|
a = {"kind": "pipeline", "task": "fixture", "source_sha": self.sha, "run_id": "fixture-run"}
|
|
787
1010
|
archive = evidence / "runs" / a["run_id"]
|
|
788
1011
|
archive.mkdir(parents=True)
|
|
789
1012
|
self.archive = archive
|
|
790
1013
|
report = "<!-- devlyn:final-report run_id=fixture-run -->\nFixture completed.\n"
|
|
791
|
-
(archive / "final-report.md").write_text(report)
|
|
1014
|
+
(archive / "final-report.md").write_text(report, encoding="utf-8")
|
|
792
1015
|
phases = {n: {"started_at": "2026-09-10T00:00:00Z", "completed_at": "2026-09-10T00:00:01Z", "verdict": "PASS"}
|
|
793
1016
|
for n in ("plan", "implement", "build_gate", "cleanup", "verify", "final_report")}
|
|
794
1017
|
phases["cleanup"]["post_sha"] = self.sha
|
|
795
1018
|
phases["final_report"].update(output_sha256=hashlib.sha256(report.encode()).hexdigest(), artifacts={"log_file": ".devlyn/final-report.md"})
|
|
796
1019
|
criteria = "# Fixture acceptance\nProduct contains accepted bytes.\n"
|
|
797
|
-
(archive / "criteria.generated.md").write_text(criteria)
|
|
1020
|
+
(archive / "criteria.generated.md").write_text(criteria, encoding="utf-8")
|
|
798
1021
|
source = {"type": "generated", "criteria_path": ".devlyn/criteria.generated.md", "criteria_sha256": hashlib.sha256(criteria.encode()).hexdigest()}
|
|
799
1022
|
self.state = {"run_id": a["run_id"], "mode": "free-form", "source": source, "phases": phases, "process_evidence": None}
|
|
800
1023
|
if spec_expected is not None:
|
|
801
1024
|
self.state["mode"] = "spec"
|
|
802
|
-
self.state["source"] = {"type": "spec", "spec_path":
|
|
803
|
-
(archive / "pipeline.state.json").write_text(json.dumps(self.state))
|
|
804
|
-
(archive / "finish-gate.summary.json").write_text(json.dumps({"mode": self.state["mode"], "exit": 0, "offenders": 0, "checked": 1}))
|
|
805
|
-
(archive / "verify-merge.summary.json").write_text(json.dumps({"verdict": "PASS"}))
|
|
1025
|
+
self.state["source"] = {"type": "spec", "spec_path": spec_name, "spec_sha256": hashlib.sha256((self.task / spec_name).read_bytes()).hexdigest()}
|
|
1026
|
+
(archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
|
|
1027
|
+
(archive / "finish-gate.summary.json").write_text(json.dumps({"mode": self.state["mode"], "exit": 0, "offenders": 0, "checked": 1}), encoding="utf-8")
|
|
1028
|
+
(archive / "verify-merge.summary.json").write_text(json.dumps({"verdict": "PASS"}), encoding="utf-8")
|
|
806
1029
|
if queue:
|
|
807
|
-
(self.task / "queue.md").write_text("- [x] fixture\n")
|
|
1030
|
+
(self.task / "queue.md").write_text("- [x] fixture\n", encoding="utf-8")
|
|
808
1031
|
self.g("add", "queue.md", work=self.task)
|
|
809
1032
|
self.g("commit", "-m", "terminal queue", work=self.task)
|
|
810
1033
|
a["queue"] = {"commit": self.g("rev-parse", "HEAD", work=self.task), "file": "queue.md"}
|
|
811
1034
|
self.acceptance = evidence / "acceptance.json"
|
|
812
|
-
self.acceptance.write_text(json.dumps(a))
|
|
1035
|
+
self.acceptance.write_text(json.dumps(a), encoding="utf-8")
|
|
813
1036
|
|
|
814
1037
|
def complete(self, *args, success=True, cwd=None, acceptance=True):
|
|
815
1038
|
command_args = ["complete", "--receipt", self.receipt]
|
|
@@ -843,11 +1066,11 @@ class CompletionTests(unittest.TestCase):
|
|
|
843
1066
|
alias = "git@github.com:test/project.git"
|
|
844
1067
|
self.g("config", "url." + alias + ".insteadOf", intended)
|
|
845
1068
|
self.allocate(); self.accept()
|
|
846
|
-
self.assertEqual(json.loads(self.receipt.read_text())["remote_url"], {"literal": intended, "fetch": alias, "push": alias})
|
|
1069
|
+
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["remote_url"], {"literal": intended, "fetch": alias, "push": alias})
|
|
847
1070
|
result, _ = self.complete("--mode", "pr")
|
|
848
1071
|
self.assertEqual(result["status"], "PR")
|
|
849
1072
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
850
|
-
self.assertEqual(json.loads(self.data.read_text())["pushs"], 1)
|
|
1073
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8"))["pushs"], 1)
|
|
851
1074
|
|
|
852
1075
|
def test_remote_rewrite_after_allocation_is_rejected(self):
|
|
853
1076
|
self.allocate(); self.accept()
|
|
@@ -868,9 +1091,9 @@ class CompletionTests(unittest.TestCase):
|
|
|
868
1091
|
destination = "https://github.com/test/other.git"
|
|
869
1092
|
self.g("config", "url." + destination + ".insteadOf", "https://github.com/test/project.git")
|
|
870
1093
|
# Reproduce a receipt the old literal-only allocation check accepted.
|
|
871
|
-
receipt = json.loads(self.receipt.read_text())
|
|
1094
|
+
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
|
872
1095
|
receipt["remote_url"].update(fetch=destination, push=destination)
|
|
873
|
-
self.receipt.write_text(json.dumps(receipt))
|
|
1096
|
+
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
|
|
874
1097
|
local_refs = self.g("show-ref")
|
|
875
1098
|
remote_refs = self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout
|
|
876
1099
|
server = self.data.read_bytes()
|
|
@@ -891,17 +1114,17 @@ class CompletionTests(unittest.TestCase):
|
|
|
891
1114
|
for _ in range(2):
|
|
892
1115
|
result, _ = self.complete("--mode", "auto")
|
|
893
1116
|
self.assertEqual(result["status"], "PENDING")
|
|
894
|
-
d = json.loads(self.data.read_text())
|
|
1117
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
895
1118
|
self.assertEqual((d["creates"], d["merges"], d["pushs"]), (1, 1, 1))
|
|
896
1119
|
|
|
897
1120
|
def test_in_place_retains_ignored_data(self):
|
|
898
1121
|
self.allocate(); self.accept()
|
|
899
1122
|
(self.task / "ignored").mkdir()
|
|
900
|
-
(self.task / "ignored" / "user.txt").write_text("retain me")
|
|
1123
|
+
(self.task / "ignored" / "user.txt").write_text("retain me", encoding="utf-8")
|
|
901
1124
|
result, _ = self.complete("--writers-stopped")
|
|
902
1125
|
self.assertEqual(result["status"], "COMPLETE")
|
|
903
1126
|
self.assertEqual(self.g("branch", "--show-current"), "main")
|
|
904
|
-
self.assertEqual((self.task / "ignored/user.txt").read_text(), "retain me")
|
|
1127
|
+
self.assertEqual((self.task / "ignored/user.txt").read_text(encoding="utf-8"), "retain me")
|
|
905
1128
|
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
906
1129
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
907
1130
|
again, _ = self.complete("--writers-stopped", acceptance=False)
|
|
@@ -915,7 +1138,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
915
1138
|
result, r = self.complete("--writers-stopped", success=False)
|
|
916
1139
|
self.assertNotEqual(r.returncode, 0)
|
|
917
1140
|
self.assertFalse(self.task.exists())
|
|
918
|
-
saved = json.loads(self.receipt.read_text())
|
|
1141
|
+
saved = json.loads(self.receipt.read_text(encoding="utf-8"))
|
|
919
1142
|
recovered = self.receipt.parent / "custody" / ".devlyn/runs/fixture-run/final-report.md"
|
|
920
1143
|
self.assertEqual(hashlib.sha256(recovered.read_bytes()).hexdigest(), saved["files"][".devlyn/runs/fixture-run/final-report.md"]["sha256"])
|
|
921
1144
|
result, _ = self.complete("--writers-stopped", acceptance=False)
|
|
@@ -933,30 +1156,30 @@ class CompletionTests(unittest.TestCase):
|
|
|
933
1156
|
if case == "unfinished": state["phases"]["verify"]["completed_at"] = None
|
|
934
1157
|
if case == "digest": state["phases"]["final_report"]["output_sha256"] = "0"*64
|
|
935
1158
|
if case == "source": state["phases"]["cleanup"]["post_sha"] = self.g("rev-parse", "main")
|
|
936
|
-
(self.archive / "pipeline.state.json").write_text(json.dumps(state))
|
|
1159
|
+
(self.archive / "pipeline.state.json").write_text(json.dumps(state), encoding="utf-8")
|
|
937
1160
|
with self.subTest(case=case):
|
|
938
1161
|
_, r = self.complete(success=False)
|
|
939
1162
|
self.assertNotEqual(r.returncode, 0)
|
|
940
1163
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
941
|
-
(self.archive / "pipeline.state.json").write_text(json.dumps(original))
|
|
1164
|
+
(self.archive / "pipeline.state.json").write_text(json.dumps(original), encoding="utf-8")
|
|
942
1165
|
for value in ("typo", "", " auto"):
|
|
943
1166
|
self.g("config", "--local", "devlyn.completionMode", value)
|
|
944
1167
|
_, r = self.complete("--mode", "pr", success=False)
|
|
945
1168
|
self.assertNotEqual(r.returncode, 0)
|
|
946
1169
|
result, _ = self.complete("--local-only")
|
|
947
1170
|
self.assertEqual(result["status"], "LOCAL_ONLY")
|
|
948
|
-
self.assertEqual(json.loads(self.data.read_text()).get("creates", 0), 0)
|
|
1171
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("creates", 0), 0)
|
|
949
1172
|
|
|
950
1173
|
def test_queue_only_and_unverified_descendant(self):
|
|
951
1174
|
self.allocate(); self.accept(pipeline=True, queue=True)
|
|
952
1175
|
result, _ = self.complete("--mode", "pr")
|
|
953
1176
|
self.assertEqual(result["status"], "PR")
|
|
954
|
-
(self.task / "product").write_text("unverified")
|
|
1177
|
+
(self.task / "product").write_text("unverified", encoding="utf-8")
|
|
955
1178
|
self.g("add", "product", work=self.task)
|
|
956
1179
|
self.g("commit", "-m", "unverified", work=self.task)
|
|
957
1180
|
_, r = self.complete(success=False)
|
|
958
1181
|
self.assertNotEqual(r.returncode, 0)
|
|
959
|
-
self.assertEqual(json.loads(self.data.read_text()).get("merges", 0), 0)
|
|
1182
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges", 0), 0)
|
|
960
1183
|
|
|
961
1184
|
def test_interrupted_external_effects(self):
|
|
962
1185
|
self.allocate(); self.accept()
|
|
@@ -966,7 +1189,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
966
1189
|
self.assertNotEqual(r.returncode, 0)
|
|
967
1190
|
result, _ = self.complete("--writers-stopped")
|
|
968
1191
|
self.assertEqual(result["status"], "COMPLETE")
|
|
969
|
-
d = json.loads(self.data.read_text())
|
|
1192
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
970
1193
|
self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1, 1, 1))
|
|
971
1194
|
|
|
972
1195
|
def test_retains_unknown_dirty_locked_and_active_tree(self):
|
|
@@ -974,7 +1197,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
974
1197
|
for case in ("ignored", "dirty", "untracked", "locked", "cwd", "no-yield"):
|
|
975
1198
|
path = self.task / ("ignored/unknown" if case == "ignored" else "product" if case == "dirty" else "unknown")
|
|
976
1199
|
if case in {"ignored", "dirty", "untracked"}:
|
|
977
|
-
path.parent.mkdir(exist_ok=True); path.write_text("do not delete")
|
|
1200
|
+
path.parent.mkdir(exist_ok=True); path.write_text("do not delete", encoding="utf-8")
|
|
978
1201
|
if case == "locked": self.g("worktree", "lock", str(self.task))
|
|
979
1202
|
with self.subTest(case=case):
|
|
980
1203
|
_, r = self.complete(*([] if case == "no-yield" else ["--writers-stopped"]), cwd=self.task if case == "cwd" else None, success=False)
|
|
@@ -982,7 +1205,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
982
1205
|
self.assertTrue(self.task.exists())
|
|
983
1206
|
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
984
1207
|
if case in {"ignored", "untracked"}: path.unlink()
|
|
985
|
-
if case == "dirty": path.write_text("accepted\n")
|
|
1208
|
+
if case == "dirty": path.write_text("accepted\n", encoding="utf-8")
|
|
986
1209
|
if case == "locked": self.g("worktree", "unlock", str(self.task))
|
|
987
1210
|
|
|
988
1211
|
def test_no_adoption_and_remote_head_race(self):
|
|
@@ -993,34 +1216,34 @@ class CompletionTests(unittest.TestCase):
|
|
|
993
1216
|
self.run_cmd(["git", "--git-dir", str(self.bare), "update-ref", "refs/heads/task/fixture", self.g("rev-parse", "main")])
|
|
994
1217
|
_, r = self.complete(success=False)
|
|
995
1218
|
self.assertNotEqual(r.returncode, 0)
|
|
996
|
-
self.assertEqual(json.loads(self.data.read_text()).get("merges",0), 0)
|
|
1219
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges",0), 0)
|
|
997
1220
|
|
|
998
1221
|
def test_concurrent_completions(self):
|
|
999
1222
|
self.allocate(); self.accept(); self.configure(pending=True)
|
|
1000
1223
|
args = [sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(self.receipt), "--acceptance", str(self.acceptance)]
|
|
1001
|
-
procs = [subprocess.Popen(args, cwd=self.root, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) for _ in range(2)]
|
|
1224
|
+
procs = [subprocess.Popen(args, cwd=self.root, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8") for _ in range(2)]
|
|
1002
1225
|
for proc in procs:
|
|
1003
1226
|
out, err = proc.communicate(timeout=30)
|
|
1004
1227
|
self.assertEqual(proc.returncode, 0, out+err)
|
|
1005
|
-
d = json.loads(self.data.read_text())
|
|
1228
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
1006
1229
|
self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1,1,1))
|
|
1007
1230
|
|
|
1008
1231
|
def test_custody_failure_and_evidence_tamper(self):
|
|
1009
1232
|
self.allocate(linked=True); self.accept()
|
|
1010
1233
|
bad = self.receipt.parent / "custody/.devlyn/checks.txt"
|
|
1011
1234
|
bad.parent.mkdir(parents=True)
|
|
1012
|
-
bad.write_text("partial interrupted copy")
|
|
1235
|
+
bad.write_text("partial interrupted copy", encoding="utf-8")
|
|
1013
1236
|
_, r = self.complete("--writers-stopped", success=False)
|
|
1014
1237
|
self.assertNotEqual(r.returncode, 0)
|
|
1015
1238
|
self.assertTrue(self.task.exists())
|
|
1016
|
-
self.assertEqual((self.task / ".devlyn/checks.txt").read_text(), "actual fixture check: accepted bytes\n")
|
|
1239
|
+
self.assertEqual((self.task / ".devlyn/checks.txt").read_text(encoding="utf-8"), "actual fixture check: accepted bytes\n")
|
|
1017
1240
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
1018
1241
|
bad.unlink() # Owner repairs only the identified corrupt fixture copy.
|
|
1019
1242
|
self.complete("--mode", "pr")
|
|
1020
|
-
(self.task / ".devlyn/checks.txt").write_text("altered after acceptance")
|
|
1243
|
+
(self.task / ".devlyn/checks.txt").write_text("altered after acceptance", encoding="utf-8")
|
|
1021
1244
|
_, r = self.complete("--mode", "auto", success=False)
|
|
1022
1245
|
self.assertNotEqual(r.returncode, 0)
|
|
1023
|
-
self.assertEqual(json.loads(self.data.read_text()).get("merges",0), 0)
|
|
1246
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges",0), 0)
|
|
1024
1247
|
|
|
1025
1248
|
def test_actual_foreign_writer_and_registration(self):
|
|
1026
1249
|
self.allocate(linked=True); self.accept()
|
|
@@ -1053,7 +1276,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1053
1276
|
self.configure(merge_allowed=False)
|
|
1054
1277
|
_, r = self.complete(success=False)
|
|
1055
1278
|
self.assertNotEqual(r.returncode, 0)
|
|
1056
|
-
self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
|
|
1279
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
|
|
1057
1280
|
self.complete("--mode", "pr")
|
|
1058
1281
|
result, _ = self.complete()
|
|
1059
1282
|
self.assertEqual(result["status"], "PR")
|
|
@@ -1068,15 +1291,15 @@ class CompletionTests(unittest.TestCase):
|
|
|
1068
1291
|
|
|
1069
1292
|
def test_queue_commit_cannot_hide_product_changes(self):
|
|
1070
1293
|
self.allocate(); self.accept(pipeline=True, queue=True)
|
|
1071
|
-
(self.task / "product").write_text("unverified extra\n")
|
|
1294
|
+
(self.task / "product").write_text("unverified extra\n", encoding="utf-8")
|
|
1072
1295
|
self.g("add", "product", work=self.task)
|
|
1073
1296
|
self.g("commit", "--amend", "--no-edit", work=self.task)
|
|
1074
|
-
a = json.loads(self.acceptance.read_text())
|
|
1297
|
+
a = json.loads(self.acceptance.read_text(encoding="utf-8"))
|
|
1075
1298
|
a["queue"]["commit"] = self.g("rev-parse", "HEAD", work=self.task)
|
|
1076
|
-
self.acceptance.write_text(json.dumps(a))
|
|
1299
|
+
self.acceptance.write_text(json.dumps(a), encoding="utf-8")
|
|
1077
1300
|
_, r = self.complete(success=False)
|
|
1078
1301
|
self.assertNotEqual(r.returncode, 0)
|
|
1079
|
-
self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
|
|
1302
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
|
|
1080
1303
|
|
|
1081
1304
|
def test_pipeline_sealed_process_evidence(self):
|
|
1082
1305
|
self.allocate(); self.accept(pipeline=True)
|
|
@@ -1088,15 +1311,15 @@ class CompletionTests(unittest.TestCase):
|
|
|
1088
1311
|
results = {"commands": evidence_module["bound_carrier_summary_commands"](self.task, carrier), "process_evidence": carrier}
|
|
1089
1312
|
self.state["phases"]["verify"]["round"] = 0
|
|
1090
1313
|
self.state["process_evidence"] = [carrier]
|
|
1091
|
-
(self.archive / "pipeline.state.json").write_text(json.dumps(self.state))
|
|
1092
|
-
(self.archive / "spec-verify.results.json").write_text(json.dumps(results))
|
|
1314
|
+
(self.archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
|
|
1315
|
+
(self.archive / "spec-verify.results.json").write_text(json.dumps(results), encoding="utf-8")
|
|
1093
1316
|
shutil.move(str(self.task / ".devlyn/process-evidence"), str(self.archive / "process-evidence"))
|
|
1094
1317
|
stdout = self.archive / carrier["streams"][0]["stdout"]["path"].removeprefix(".devlyn/")
|
|
1095
1318
|
before = stdout.read_bytes()
|
|
1096
|
-
stdout.write_text("tampered")
|
|
1319
|
+
stdout.write_text("tampered", encoding="utf-8")
|
|
1097
1320
|
_, r = self.complete(success=False)
|
|
1098
1321
|
self.assertNotEqual(r.returncode, 0)
|
|
1099
|
-
self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
|
|
1322
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
|
|
1100
1323
|
stdout.write_bytes(before)
|
|
1101
1324
|
result, _ = self.complete("--mode", "pr")
|
|
1102
1325
|
self.assertEqual(result["status"], "PR")
|
|
@@ -1105,7 +1328,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1105
1328
|
self.allocate(); self.accept()
|
|
1106
1329
|
user_file = self.task / "ignored/user.txt"
|
|
1107
1330
|
user_file.parent.mkdir()
|
|
1108
|
-
user_file.write_text("retained user data")
|
|
1331
|
+
user_file.write_text("retained user data", encoding="utf-8")
|
|
1109
1332
|
self.configure(interrupt_delete=True)
|
|
1110
1333
|
_, r = self.complete("--writers-stopped", success=False)
|
|
1111
1334
|
self.assertNotEqual(r.returncode, 0)
|
|
@@ -1113,13 +1336,13 @@ class CompletionTests(unittest.TestCase):
|
|
|
1113
1336
|
other = self.root / "advance"
|
|
1114
1337
|
self.run_cmd(["git", "clone", str(self.bare), str(other)])
|
|
1115
1338
|
(other / "ignored").mkdir()
|
|
1116
|
-
(other / "ignored/user.txt").write_text("new upstream tracked file")
|
|
1339
|
+
(other / "ignored/user.txt").write_text("new upstream tracked file", encoding="utf-8")
|
|
1117
1340
|
self.g("add", "-f", "ignored/user.txt", work=other)
|
|
1118
1341
|
self.g("commit", "-m", "upstream collision", work=other)
|
|
1119
1342
|
self.g("push", "origin", "main", work=other)
|
|
1120
1343
|
_, r = self.complete("--writers-stopped", acceptance=False, success=False)
|
|
1121
1344
|
self.assertNotEqual(r.returncode, 0)
|
|
1122
|
-
self.assertEqual(user_file.read_text(), "retained user data")
|
|
1345
|
+
self.assertEqual(user_file.read_text(encoding="utf-8"), "retained user data")
|
|
1123
1346
|
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1124
1347
|
|
|
1125
1348
|
def test_replaced_tree_and_unsafe_custody(self):
|
|
@@ -1138,21 +1361,21 @@ class CompletionTests(unittest.TestCase):
|
|
|
1138
1361
|
self.assertNotEqual(r.returncode, 0)
|
|
1139
1362
|
self.assertIn("replaced", json.loads(r.stdout)["reason"])
|
|
1140
1363
|
self.assertTrue(self.task.exists())
|
|
1141
|
-
self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
|
|
1364
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
|
|
1142
1365
|
|
|
1143
1366
|
def test_spec_source_and_report_binding(self):
|
|
1144
1367
|
self.allocate(); self.accept(pipeline=True, spec_expected={"verification_commands": []})
|
|
1145
1368
|
report = self.archive / "final-report.md"
|
|
1146
1369
|
original = report.read_bytes()
|
|
1147
|
-
report.write_text("<!-- devlyn:final-report run_id=another-run -->\nBody\n")
|
|
1370
|
+
report.write_text("<!-- devlyn:final-report run_id=another-run -->\nBody\n", encoding="utf-8")
|
|
1148
1371
|
self.state["phases"]["final_report"]["output_sha256"] = hashlib.sha256(report.read_bytes()).hexdigest()
|
|
1149
|
-
(self.archive / "pipeline.state.json").write_text(json.dumps(self.state))
|
|
1372
|
+
(self.archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
|
|
1150
1373
|
_, r = self.complete(success=False)
|
|
1151
1374
|
self.assertNotEqual(r.returncode, 0)
|
|
1152
|
-
self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
|
|
1375
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
|
|
1153
1376
|
report.write_bytes(original)
|
|
1154
1377
|
self.state["phases"]["final_report"]["output_sha256"] = hashlib.sha256(original).hexdigest()
|
|
1155
|
-
(self.archive / "pipeline.state.json").write_text(json.dumps(self.state))
|
|
1378
|
+
(self.archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
|
|
1156
1379
|
result, _ = self.complete("--mode", "pr")
|
|
1157
1380
|
self.assertEqual(result["status"], "PR")
|
|
1158
1381
|
|
|
@@ -1162,8 +1385,25 @@ class CompletionTests(unittest.TestCase):
|
|
|
1162
1385
|
_, r = self.complete(success=False)
|
|
1163
1386
|
self.assertNotEqual(r.returncode, 0)
|
|
1164
1387
|
self.assertIn("implement process evidence", json.loads(r.stdout)["reason"])
|
|
1165
|
-
self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
|
|
1388
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
|
|
1389
|
+
|
|
1390
|
+
def test_named_spec_custody(self):
|
|
1391
|
+
self.allocate(); self.accept(pipeline=True, spec_expected={"pure_design": True}, spec_name="X.md")
|
|
1392
|
+
result, _ = self.complete("--mode", "pr")
|
|
1393
|
+
self.assertEqual(result["status"], "PR")
|
|
1394
|
+
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
|
1395
|
+
self.assertIn("spec.expected.json", receipt["files"])
|
|
1396
|
+
self.assertEqual((self.receipt.parent / "custody/spec.expected.json").read_bytes(), (self.task / "spec.expected.json").read_bytes())
|
|
1397
|
+
|
|
1398
|
+
def test_named_spec_missing_required_process_evidence(self):
|
|
1399
|
+
expected = {"process_evidence": [{"id": "red-first", "phase": "implement", "argv": [sys.executable, "-c", "print('red')"], "exit_code": 0, "timeout_sec": 10}]}
|
|
1400
|
+
self.allocate(); self.accept(pipeline=True, spec_expected=expected, spec_name="X.md")
|
|
1401
|
+
_, r = self.complete(success=False)
|
|
1402
|
+
self.assertNotEqual(r.returncode, 0)
|
|
1403
|
+
self.assertIn("implement process evidence", json.loads(r.stdout)["reason"])
|
|
1404
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
|
|
1166
1405
|
|
|
1167
1406
|
|
|
1168
1407
|
if __name__ == "__main__":
|
|
1408
|
+
runpy.run_path(str(Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
|
|
1169
1409
|
sys.exit(main())
|