@christang/keel 5.3.0 → 5.3.3
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/assets/bootstrap/AGENTS.md +2 -2
- package/assets/openspec/schemas/keel-spec-driven/schema.yaml +12 -2
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +16 -0
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/plugins/keel/scripts/session-start.js +8 -1
- package/scripts/validate_plugin.py +780 -49
- package/src/core/gates.js +147 -5
|
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
|
|
|
37
37
|
"scripts/validate_plugin.py",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
-
PACKAGE_VERSION = "5.3.
|
|
41
|
-
PROTOCOL_VERSION = "5.3.
|
|
40
|
+
PACKAGE_VERSION = "5.3.3"
|
|
41
|
+
PROTOCOL_VERSION = "5.3.3"
|
|
42
42
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
43
43
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
44
44
|
OPENSPEC_CONFIG_PATH = Path("openspec/config.yaml")
|
|
@@ -99,7 +99,11 @@ RESIDENT_BLOCKS = [
|
|
|
99
99
|
"keel gate task-start",
|
|
100
100
|
"task capsule",
|
|
101
101
|
"fingerprint",
|
|
102
|
-
|
|
102
|
+
# Prose, not a command: this sentence is the one that has actually
|
|
103
|
+
# been reworded under byte pressure, so it is matched as a topic —
|
|
104
|
+
# Touch and a `bound…` word in one statement. The other prose
|
|
105
|
+
# entries stay literal until one of them needs the same freedom.
|
|
106
|
+
re.compile(r"Touch\b[^\n]*\bbound", re.IGNORECASE),
|
|
103
107
|
"read-only report/evidence",
|
|
104
108
|
"native plugin",
|
|
105
109
|
"keel --init",
|
|
@@ -491,9 +495,9 @@ def strip_template_checksum(content: str) -> tuple[str, str | None]:
|
|
|
491
495
|
return "".join(kept), checksum
|
|
492
496
|
|
|
493
497
|
|
|
494
|
-
def validate_resident_blocks(errors: list[str]) -> None:
|
|
498
|
+
def validate_resident_blocks(errors: list[str], root: Path = ROOT) -> None:
|
|
495
499
|
for block in RESIDENT_BLOCKS:
|
|
496
|
-
path =
|
|
500
|
+
path = root / block["path"]
|
|
497
501
|
if not path.is_file():
|
|
498
502
|
errors.append(f"{block['name']} is missing: {block['path']}")
|
|
499
503
|
continue
|
|
@@ -519,9 +523,24 @@ def validate_resident_blocks(errors: list[str]) -> None:
|
|
|
519
523
|
f"budget is {max_lines}"
|
|
520
524
|
)
|
|
521
525
|
|
|
526
|
+
# A required entry is one of two kinds, and they mean different things.
|
|
527
|
+
# A literal names a command, marker, or identifier: if the block no
|
|
528
|
+
# longer contains it exactly, it is telling a reader to run something
|
|
529
|
+
# that does not exist, so the check must fail. A pattern states a topic
|
|
530
|
+
# in prose: the concepts must remain, the wording may move — which it
|
|
531
|
+
# must be free to, because this block is under a line and byte budget
|
|
532
|
+
# and gets rewritten to fit.
|
|
522
533
|
for required in block["required"]:
|
|
523
|
-
if required
|
|
524
|
-
|
|
534
|
+
if isinstance(required, str):
|
|
535
|
+
if required not in managed_block:
|
|
536
|
+
errors.append(
|
|
537
|
+
f"{block['name']} missing required literal: {required}"
|
|
538
|
+
)
|
|
539
|
+
elif not required.search(managed_block):
|
|
540
|
+
errors.append(
|
|
541
|
+
f"{block['name']} missing required topic: "
|
|
542
|
+
f"{required.pattern}"
|
|
543
|
+
)
|
|
525
544
|
|
|
526
545
|
lowered = managed_block.lower()
|
|
527
546
|
for forbidden in RESIDENT_FORBIDDEN_SNIPPETS:
|
|
@@ -1082,6 +1101,17 @@ def validate_skill_portability_policy_scenario() -> int:
|
|
|
1082
1101
|
return 0
|
|
1083
1102
|
|
|
1084
1103
|
|
|
1104
|
+
def posix_paths(text: str) -> str:
|
|
1105
|
+
"""Fold path separators so an assertion does not encode the host's spelling.
|
|
1106
|
+
|
|
1107
|
+
Keel prints these paths through the host's path joiner, so the same doctor
|
|
1108
|
+
line reads `.claude\\commands\\opsx` on Windows and `.claude/commands/opsx`
|
|
1109
|
+
on a POSIX runner. Assertions state the forward-slash form and normalize the
|
|
1110
|
+
captured output, rather than branching on the platform or accepting both.
|
|
1111
|
+
"""
|
|
1112
|
+
return (text or "").replace("\\", "/")
|
|
1113
|
+
|
|
1114
|
+
|
|
1085
1115
|
def validate_target_surface_scenario() -> int:
|
|
1086
1116
|
with tempfile.TemporaryDirectory(prefix="keel-surface-") as raw_tmp:
|
|
1087
1117
|
tmp = Path(raw_tmp)
|
|
@@ -1098,9 +1128,9 @@ def validate_target_surface_scenario() -> int:
|
|
|
1098
1128
|
claude_doctor.returncode != 0
|
|
1099
1129
|
or "Target surface:" not in claude_doctor.stdout
|
|
1100
1130
|
or "OpenSpec commands: ok" not in claude_doctor.stdout
|
|
1101
|
-
or ".claude
|
|
1131
|
+
or ".claude/commands/opsx" not in posix_paths(claude_doctor.stdout)
|
|
1102
1132
|
or "OpenSpec action skills: ok" not in claude_doctor.stdout
|
|
1103
|
-
or ".claude
|
|
1133
|
+
or ".claude/skills" not in posix_paths(claude_doctor.stdout)
|
|
1104
1134
|
or "bootstrap: ok" not in claude_doctor.stdout
|
|
1105
1135
|
or "CLAUDE import: ok" not in claude_doctor.stdout
|
|
1106
1136
|
or "native plugin runtime: manual" not in claude_doctor.stdout
|
|
@@ -1130,9 +1160,9 @@ def validate_target_surface_scenario() -> int:
|
|
|
1130
1160
|
if (
|
|
1131
1161
|
codex_doctor.returncode != 0
|
|
1132
1162
|
or "OpenSpec commands: ok" not in codex_doctor.stdout
|
|
1133
|
-
or codex_prompt_dir not in codex_doctor.stdout
|
|
1163
|
+
or posix_paths(codex_prompt_dir) not in posix_paths(codex_doctor.stdout)
|
|
1134
1164
|
or "OpenSpec action skills: ok" not in codex_doctor.stdout
|
|
1135
|
-
or ".codex
|
|
1165
|
+
or ".codex/skills" not in posix_paths(codex_doctor.stdout)
|
|
1136
1166
|
or "bootstrap: ok" not in codex_doctor.stdout
|
|
1137
1167
|
or "native plugin runtime: manual" not in codex_doctor.stdout
|
|
1138
1168
|
or "Target capabilities (codex):" not in codex_doctor.stdout
|
|
@@ -1173,9 +1203,9 @@ def validate_target_surface_scenario() -> int:
|
|
|
1173
1203
|
if (
|
|
1174
1204
|
opencode_doctor.returncode != 0
|
|
1175
1205
|
or "OpenSpec commands: ok" not in opencode_doctor.stdout
|
|
1176
|
-
or ".opencode
|
|
1206
|
+
or ".opencode/commands" not in posix_paths(opencode_doctor.stdout)
|
|
1177
1207
|
or "OpenSpec action skills: ok" not in opencode_doctor.stdout
|
|
1178
|
-
or ".opencode
|
|
1208
|
+
or ".opencode/skills" not in posix_paths(opencode_doctor.stdout)
|
|
1179
1209
|
or "bootstrap: ok" not in opencode_doctor.stdout
|
|
1180
1210
|
or "native plugin: manual" not in opencode_doctor.stdout
|
|
1181
1211
|
or "Target capabilities (opencode):" not in opencode_doctor.stdout
|
|
@@ -1728,7 +1758,7 @@ def validate_authoring_continuity_scenario() -> int:
|
|
|
1728
1758
|
change_root / "specs/demo/spec.md",
|
|
1729
1759
|
"## ADDED Requirements\n",
|
|
1730
1760
|
)
|
|
1731
|
-
write_text(change_root / "tasks.md", "# Tasks\n\n## Tasks\n")
|
|
1761
|
+
write_text(change_root / "tasks.md", "# Tasks\n\n## Invalidates\n\n- None.\n\n## Tasks\n")
|
|
1732
1762
|
invalid = run_keel(invalid_repo, "context", "--json")
|
|
1733
1763
|
invalid_payload = json.loads(invalid.stdout)
|
|
1734
1764
|
if (
|
|
@@ -2497,7 +2527,9 @@ def write_gate_fixture(repo: Path, tasks: str, design: str = "## Context\n\nfixt
|
|
|
2497
2527
|
change = repo / "openspec/changes/demo"
|
|
2498
2528
|
write_text(
|
|
2499
2529
|
change / "tasks.md",
|
|
2500
|
-
"# Tasks\n\n"
|
|
2530
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
2531
|
+
"## Invalidates\n\n"
|
|
2532
|
+
"- None.\n\n"
|
|
2501
2533
|
"## Expectation Coverage\n\n"
|
|
2502
2534
|
"- E1:\n"
|
|
2503
2535
|
" - Covered by: 1.1\n\n"
|
|
@@ -2595,7 +2627,7 @@ def validate_cli_scenario() -> int:
|
|
|
2595
2627
|
|
|
2596
2628
|
write_text(
|
|
2597
2629
|
repo / "openspec/changes/status-drift/tasks.md",
|
|
2598
|
-
"# Tasks\n\n"
|
|
2630
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
2599
2631
|
"## Tasks\n\n"
|
|
2600
2632
|
"- [x] A1 implementation **未提交**\n\n"
|
|
2601
2633
|
"## Execution Status\n\n"
|
|
@@ -2618,7 +2650,7 @@ def validate_cli_scenario() -> int:
|
|
|
2618
2650
|
|
|
2619
2651
|
write_text(
|
|
2620
2652
|
repo / "openspec/changes/status-drift/tasks.md",
|
|
2621
|
-
"# Tasks\n\n"
|
|
2653
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
2622
2654
|
"> Boundary: tasks.md is the source for the current change's slice checklist and logical completion only. Completion state is only `[x]` / `[ ]`; progress and the default next slice are derived from the checklist. Do not record commit hashes, branch/merge state, dirty/uncommitted state, or manually computed completion counts. Durable work state belongs in OpenSpec; HANDOFF is only an explicit pointer override.\n\n"
|
|
2623
2655
|
"## Tasks\n\n"
|
|
2624
2656
|
"- [x] A1 implementation\n\n"
|
|
@@ -2636,7 +2668,7 @@ def validate_cli_scenario() -> int:
|
|
|
2636
2668
|
|
|
2637
2669
|
write_text(
|
|
2638
2670
|
repo / "openspec/changes/archive/2026-07-09-finished/tasks.md",
|
|
2639
|
-
"# Tasks\n\n"
|
|
2671
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
2640
2672
|
"- [x] A1 implementation\n\n"
|
|
2641
2673
|
"## Evidence\n\n"
|
|
2642
2674
|
"- Scope check: pre-existing dirty paths were not attributed to this task.\n",
|
|
@@ -2832,7 +2864,7 @@ def validate_stateless_continuity_scenario() -> int:
|
|
|
2832
2864
|
|
|
2833
2865
|
write_text(
|
|
2834
2866
|
repo / "openspec/changes/other/tasks.md",
|
|
2835
|
-
"# Tasks\n\n- [ ] 1.1 Other task\n",
|
|
2867
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Other task\n",
|
|
2836
2868
|
)
|
|
2837
2869
|
write_text(
|
|
2838
2870
|
repo / "keel/HANDOFF.md",
|
|
@@ -2892,7 +2924,7 @@ def validate_stateless_continuity_scenario() -> int:
|
|
|
2892
2924
|
|
|
2893
2925
|
write_text(
|
|
2894
2926
|
tasks_path,
|
|
2895
|
-
"# Tasks\n\n"
|
|
2927
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
2896
2928
|
"- [x] 1.1 First slice\n"
|
|
2897
2929
|
"- [x] 1.2 Second slice\n",
|
|
2898
2930
|
)
|
|
@@ -3012,7 +3044,7 @@ def validate_stateless_continuity_scenario() -> int:
|
|
|
3012
3044
|
stale_repo.mkdir()
|
|
3013
3045
|
write_text(
|
|
3014
3046
|
stale_repo / "openspec/changes/current/tasks.md",
|
|
3015
|
-
"# Tasks\n\n- [ ] 1.1 Current task\n",
|
|
3047
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Current task\n",
|
|
3016
3048
|
)
|
|
3017
3049
|
write_text(
|
|
3018
3050
|
stale_repo / "keel/HANDOFF.md",
|
|
@@ -3037,7 +3069,7 @@ def validate_stateless_continuity_scenario() -> int:
|
|
|
3037
3069
|
|
|
3038
3070
|
write_text(
|
|
3039
3071
|
stale_repo / "openspec/changes/current/tasks.md",
|
|
3040
|
-
"# Tasks\n\n- [x] 1.1 Current task\n",
|
|
3072
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n- [x] 1.1 Current task\n",
|
|
3041
3073
|
)
|
|
3042
3074
|
write_text(
|
|
3043
3075
|
stale_repo / "keel/HANDOFF.md",
|
|
@@ -3064,7 +3096,7 @@ def validate_stateless_continuity_scenario() -> int:
|
|
|
3064
3096
|
|
|
3065
3097
|
write_text(
|
|
3066
3098
|
stale_repo / "openspec/changes/current/tasks.md",
|
|
3067
|
-
"# Tasks\n\n- [ ] 1.1 Current task\n",
|
|
3099
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Current task\n",
|
|
3068
3100
|
)
|
|
3069
3101
|
write_text(
|
|
3070
3102
|
stale_repo / "keel/HANDOFF.md",
|
|
@@ -3409,7 +3441,7 @@ def task_contract_fixture(
|
|
|
3409
3441
|
command_lines = "".join(f" - {item}\n" for item in commands)
|
|
3410
3442
|
evidence_lines = "".join(f" - {item}\n" for item in evidence)
|
|
3411
3443
|
return (
|
|
3412
|
-
"# Tasks\n\n"
|
|
3444
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
3413
3445
|
"- [ ] 1.1 Exercise task contract\n"
|
|
3414
3446
|
" - Owner: keel-agent\n"
|
|
3415
3447
|
f" - Mode: {mode}\n"
|
|
@@ -3446,6 +3478,246 @@ def task_contract_fixture(
|
|
|
3446
3478
|
)
|
|
3447
3479
|
|
|
3448
3480
|
|
|
3481
|
+
# task-start requires a change to declare what it invalidates, so every fixture
|
|
3482
|
+
# that expects to start a task carries the cheapest legitimate answer. Scenarios
|
|
3483
|
+
# exercising the declaration itself replace this block.
|
|
3484
|
+
INVALIDATES_NONE = "## Invalidates\n\n- None.\n\n"
|
|
3485
|
+
|
|
3486
|
+
|
|
3487
|
+
INVALIDATION_ENTRIES = (
|
|
3488
|
+
'- I1: "Touch is the write boundary" — assets/bootstrap/AGENTS.md.'
|
|
3489
|
+
" Updated by: 1.1\n"
|
|
3490
|
+
'- I2: "does not accept a GitHub issue URL" —'
|
|
3491
|
+
" keel/archive/follow-ups/note.md."
|
|
3492
|
+
" Discard reason: archive notes are historical evidence.\n"
|
|
3493
|
+
'- I3: "the suite is not portable" — README.md.'
|
|
3494
|
+
" Durable owner: https://example.invalid/issues/1\n"
|
|
3495
|
+
)
|
|
3496
|
+
|
|
3497
|
+
|
|
3498
|
+
def invalidation_repo(root: Path, name: str, section: str | None) -> Path:
|
|
3499
|
+
repo = root / name
|
|
3500
|
+
# A real Contract anchor, so the missing-section case can prove that a
|
|
3501
|
+
# failing authoring gate leaves the anchor untouched rather than merely
|
|
3502
|
+
# failing earlier for want of one.
|
|
3503
|
+
body = task_contract_fixture(evidence=("Contract: pending", "M1: pending"))
|
|
3504
|
+
body = body.replace(INVALIDATES_NONE, "" if section is None else section)
|
|
3505
|
+
write_text(repo / "openspec/changes/demo/tasks.md", body)
|
|
3506
|
+
return repo
|
|
3507
|
+
|
|
3508
|
+
|
|
3509
|
+
def validate_task_start_invalidation_scenario() -> int:
|
|
3510
|
+
label = "task-start-invalidation"
|
|
3511
|
+
|
|
3512
|
+
def gate(repo: Path, *extra: str) -> tuple[subprocess.CompletedProcess[str], dict]:
|
|
3513
|
+
result = run_keel(
|
|
3514
|
+
repo, "gate", "task-start", ".",
|
|
3515
|
+
"--change", "demo", "--task", "1.1", "--json", *extra,
|
|
3516
|
+
)
|
|
3517
|
+
payload = json.loads(result.stdout) if result.stdout.strip() else {}
|
|
3518
|
+
return result, payload
|
|
3519
|
+
|
|
3520
|
+
def codes(payload: dict) -> set[str]:
|
|
3521
|
+
return {item.get("code") for item in payload.get("problems", [])}
|
|
3522
|
+
|
|
3523
|
+
def messages(payload: dict) -> str:
|
|
3524
|
+
return " ".join(item.get("message", "") for item in payload.get("problems", []))
|
|
3525
|
+
|
|
3526
|
+
with tempfile.TemporaryDirectory(
|
|
3527
|
+
prefix="keel-invalidation-", ignore_cleanup_errors=True
|
|
3528
|
+
) as raw:
|
|
3529
|
+
tmp = Path(raw)
|
|
3530
|
+
|
|
3531
|
+
# A change that never answered the question cannot start, and the
|
|
3532
|
+
# refusal writes nothing — the guard manifest and the Contract anchor
|
|
3533
|
+
# are both withheld, so a failed authoring gate leaves no state behind.
|
|
3534
|
+
missing = invalidation_repo(tmp, "missing", None)
|
|
3535
|
+
tasks_before = (missing / "openspec/changes/demo/tasks.md").read_text(
|
|
3536
|
+
encoding="utf-8"
|
|
3537
|
+
)
|
|
3538
|
+
result, payload = gate(missing, "--record")
|
|
3539
|
+
if (
|
|
3540
|
+
payload.get("status") != "fail"
|
|
3541
|
+
or "invalidation-declaration" not in codes(payload)
|
|
3542
|
+
):
|
|
3543
|
+
report(f"{label} accepted a change with no invalidation section.")
|
|
3544
|
+
report(repr(payload.get("problems")))
|
|
3545
|
+
return 1
|
|
3546
|
+
if (missing / "keel/guard.json").exists():
|
|
3547
|
+
report(f"{label} wrote a guard manifest for a failing authoring gate.")
|
|
3548
|
+
return 1
|
|
3549
|
+
if (missing / "openspec/changes/demo/tasks.md").read_text(
|
|
3550
|
+
encoding="utf-8"
|
|
3551
|
+
) != tasks_before:
|
|
3552
|
+
report(f"{label} recorded a Contract anchor for a failing gate.")
|
|
3553
|
+
return 1
|
|
3554
|
+
|
|
3555
|
+
none_repo = invalidation_repo(tmp, "none", INVALIDATES_NONE)
|
|
3556
|
+
_, none_payload = gate(none_repo, "--no-guard")
|
|
3557
|
+
if none_payload.get("status") != "pass":
|
|
3558
|
+
report(f"{label} refused a legitimate declaration of nothing.")
|
|
3559
|
+
report(repr(none_payload.get("problems")))
|
|
3560
|
+
return 1
|
|
3561
|
+
|
|
3562
|
+
full_repo = invalidation_repo(
|
|
3563
|
+
tmp, "full", "## Invalidates\n\n" + INVALIDATION_ENTRIES + "\n"
|
|
3564
|
+
)
|
|
3565
|
+
_, full_payload = gate(full_repo, "--no-guard")
|
|
3566
|
+
if full_payload.get("status") != "pass":
|
|
3567
|
+
report(f"{label} refused well-formed entries covering all three closures.")
|
|
3568
|
+
report(repr(full_payload.get("problems")))
|
|
3569
|
+
return 1
|
|
3570
|
+
|
|
3571
|
+
# The declaration is change-level bookkeeping, not task authority: two
|
|
3572
|
+
# changes whose tasks are byte-identical must compile the same capsule
|
|
3573
|
+
# however differently they answered this question.
|
|
3574
|
+
none_print = none_payload.get("contract", {}).get("fingerprint", {}).get("value")
|
|
3575
|
+
full_print = full_payload.get("contract", {}).get("fingerprint", {}).get("value")
|
|
3576
|
+
if not none_print or none_print != full_print:
|
|
3577
|
+
report(
|
|
3578
|
+
f"{label} let the invalidation section move the capsule "
|
|
3579
|
+
f"fingerprint: {none_print} vs {full_print}"
|
|
3580
|
+
)
|
|
3581
|
+
return 1
|
|
3582
|
+
|
|
3583
|
+
# A location list only ever names files the author already recalled,
|
|
3584
|
+
# which is the failure this section exists to prevent, so an entry
|
|
3585
|
+
# without the searchable wording is refused.
|
|
3586
|
+
no_phrase = invalidation_repo(
|
|
3587
|
+
tmp,
|
|
3588
|
+
"no-phrase",
|
|
3589
|
+
"## Invalidates\n\n- I1: AGENTS.md and README.md. Updated by: 1.1\n\n",
|
|
3590
|
+
)
|
|
3591
|
+
_, no_phrase_payload = gate(no_phrase, "--no-guard")
|
|
3592
|
+
if (
|
|
3593
|
+
no_phrase_payload.get("status") != "fail"
|
|
3594
|
+
or "I1" not in messages(no_phrase_payload)
|
|
3595
|
+
):
|
|
3596
|
+
report(f"{label} accepted an entry with no searchable phrase.")
|
|
3597
|
+
report(repr(no_phrase_payload.get("problems")))
|
|
3598
|
+
return 1
|
|
3599
|
+
|
|
3600
|
+
unclosed = invalidation_repo(
|
|
3601
|
+
tmp,
|
|
3602
|
+
"unclosed",
|
|
3603
|
+
'## Invalidates\n\n- I1: "Touch is the write boundary" — AGENTS.md.\n\n',
|
|
3604
|
+
)
|
|
3605
|
+
_, unclosed_payload = gate(unclosed, "--no-guard")
|
|
3606
|
+
if (
|
|
3607
|
+
unclosed_payload.get("status") != "fail"
|
|
3608
|
+
or "I1" not in messages(unclosed_payload)
|
|
3609
|
+
):
|
|
3610
|
+
report(f"{label} accepted an entry that never closed.")
|
|
3611
|
+
report(repr(unclosed_payload.get("problems")))
|
|
3612
|
+
return 1
|
|
3613
|
+
|
|
3614
|
+
unknown_owner = invalidation_repo(
|
|
3615
|
+
tmp,
|
|
3616
|
+
"unknown-task",
|
|
3617
|
+
'## Invalidates\n\n- I1: "Touch is the write boundary" — AGENTS.md.'
|
|
3618
|
+
" Updated by: 9.9\n\n",
|
|
3619
|
+
)
|
|
3620
|
+
_, unknown_payload = gate(unknown_owner, "--no-guard")
|
|
3621
|
+
if unknown_payload.get("status") != "fail":
|
|
3622
|
+
report(f"{label} accepted an updater task that does not exist.")
|
|
3623
|
+
return 1
|
|
3624
|
+
|
|
3625
|
+
if not codes(payload):
|
|
3626
|
+
report(f"{label} reported a failure with no problem code.")
|
|
3627
|
+
return 1
|
|
3628
|
+
|
|
3629
|
+
report(f"{label} scenario passed.")
|
|
3630
|
+
return 0
|
|
3631
|
+
|
|
3632
|
+
|
|
3633
|
+
SCHEMA_COPY_PAIRS = (
|
|
3634
|
+
(
|
|
3635
|
+
"openspec/schemas/keel-spec-driven/templates/tasks.md",
|
|
3636
|
+
"assets/openspec/schemas/keel-spec-driven/templates/tasks.md",
|
|
3637
|
+
),
|
|
3638
|
+
(
|
|
3639
|
+
"openspec/schemas/keel-spec-driven/schema.yaml",
|
|
3640
|
+
"assets/openspec/schemas/keel-spec-driven/schema.yaml",
|
|
3641
|
+
),
|
|
3642
|
+
)
|
|
3643
|
+
|
|
3644
|
+
|
|
3645
|
+
def validate_invalidation_authoring_surface_scenario() -> int:
|
|
3646
|
+
label = "invalidation-authoring-surface"
|
|
3647
|
+
|
|
3648
|
+
# The two schema copies are the repo-local one OpenSpec resolves and the
|
|
3649
|
+
# packaged one `keel --init` writes. compact-task-authoring already means to
|
|
3650
|
+
# assert they agree, but its canonical root does not exist in this layout, so
|
|
3651
|
+
# its rglob compares nothing; this states the pair explicitly.
|
|
3652
|
+
for local, packaged in SCHEMA_COPY_PAIRS:
|
|
3653
|
+
local_text = (ROOT / local).read_text(encoding="utf-8")
|
|
3654
|
+
packaged_text = (ROOT / packaged).read_text(encoding="utf-8")
|
|
3655
|
+
if local_text != packaged_text:
|
|
3656
|
+
report(f"{label} schema copies diverge: {local} vs {packaged}")
|
|
3657
|
+
return 1
|
|
3658
|
+
|
|
3659
|
+
template = (
|
|
3660
|
+
ROOT / "openspec/schemas/keel-spec-driven/templates/tasks.md"
|
|
3661
|
+
).read_text(encoding="utf-8")
|
|
3662
|
+
for marker in ("## Invalidates", "- None.", "- I1:"):
|
|
3663
|
+
if marker not in template:
|
|
3664
|
+
report(f"{label} tasks template lacks the invalidation section: {marker}")
|
|
3665
|
+
return 1
|
|
3666
|
+
|
|
3667
|
+
schema = (
|
|
3668
|
+
ROOT / "openspec/schemas/keel-spec-driven/schema.yaml"
|
|
3669
|
+
).read_text(encoding="utf-8")
|
|
3670
|
+
for marker in ("## Invalidates", "Updated by:", "Discard reason:"):
|
|
3671
|
+
if marker not in schema:
|
|
3672
|
+
report(
|
|
3673
|
+
f"{label} authoring instruction does not describe the "
|
|
3674
|
+
f"invalidation section: {marker}"
|
|
3675
|
+
)
|
|
3676
|
+
return 1
|
|
3677
|
+
|
|
3678
|
+
resident = resident_session_start_section(ROOT / "AGENTS.md")
|
|
3679
|
+
agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
|
|
3680
|
+
if resident is None:
|
|
3681
|
+
report(f"{label} resident AGENTS.md has no Session Start section.")
|
|
3682
|
+
return 1
|
|
3683
|
+
for marker in ("## Invalidates", "## Expectation Coverage"):
|
|
3684
|
+
if marker not in agents:
|
|
3685
|
+
report(f"{label} resident protocol does not name {marker}.")
|
|
3686
|
+
return 1
|
|
3687
|
+
|
|
3688
|
+
# An author who scaffolds and fills in the tasks must not additionally have
|
|
3689
|
+
# to discover this section, so the template's own answer has to satisfy the
|
|
3690
|
+
# gate. Placeholders are filled generically; the assertion is narrow on
|
|
3691
|
+
# purpose — no invalidation problem may survive.
|
|
3692
|
+
filled = re.sub(r"<!--[\s\S]*?-->", "", template)
|
|
3693
|
+
filled = filled.replace("<strategy>", "evidence-first")
|
|
3694
|
+
filled = re.sub(r"<[^<>\n]+>", "concrete authored value", filled)
|
|
3695
|
+
with tempfile.TemporaryDirectory(
|
|
3696
|
+
prefix="keel-invalidation-surface-", ignore_cleanup_errors=True
|
|
3697
|
+
) as raw:
|
|
3698
|
+
repo = Path(raw) / "scaffold"
|
|
3699
|
+
write_text(repo / "openspec/changes/demo/tasks.md", filled)
|
|
3700
|
+
started = run_keel(
|
|
3701
|
+
repo, "gate", "task-start", ".",
|
|
3702
|
+
"--change", "demo", "--task", "1.1", "--json", "--no-guard",
|
|
3703
|
+
)
|
|
3704
|
+
payload = json.loads(started.stdout) if started.stdout.strip() else {}
|
|
3705
|
+
offenders = [
|
|
3706
|
+
item for item in payload.get("problems", [])
|
|
3707
|
+
if str(item.get("code", "")).startswith("invalidation-")
|
|
3708
|
+
]
|
|
3709
|
+
if offenders:
|
|
3710
|
+
report(
|
|
3711
|
+
f"{label} a filled-in scaffold still fails the invalidation "
|
|
3712
|
+
"gate, so the template's own answer is not usable."
|
|
3713
|
+
)
|
|
3714
|
+
report(repr(offenders))
|
|
3715
|
+
return 1
|
|
3716
|
+
|
|
3717
|
+
report(f"{label} scenario passed.")
|
|
3718
|
+
return 0
|
|
3719
|
+
|
|
3720
|
+
|
|
3449
3721
|
def validate_task_contract_core_scenario() -> int:
|
|
3450
3722
|
with tempfile.TemporaryDirectory(prefix="keel-task-contract-") as raw_tmp:
|
|
3451
3723
|
repo = Path(raw_tmp)
|
|
@@ -3690,7 +3962,7 @@ def task_capsule_expanded_fixture() -> str:
|
|
|
3690
3962
|
|
|
3691
3963
|
def task_capsule_compact_fixture() -> str:
|
|
3692
3964
|
return (
|
|
3693
|
-
"# Tasks\n\n"
|
|
3965
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
3694
3966
|
"- [ ] 1.1 Exercise task contract\n"
|
|
3695
3967
|
" - Covers:\n"
|
|
3696
3968
|
" - E1: Public behavior passes.\n"
|
|
@@ -4233,7 +4505,7 @@ TRACKER_OWNER = "https://github.com/TanglmChris/keel/issues/12"
|
|
|
4233
4505
|
def tracker_owner_tasks(findings: str, closure: str) -> str:
|
|
4234
4506
|
"""One complete, checked task plus one Expectation Coverage closure line."""
|
|
4235
4507
|
return (
|
|
4236
|
-
"# Tasks\n\n"
|
|
4508
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
4237
4509
|
"## Expectation Coverage\n\n"
|
|
4238
4510
|
"- E1:\n"
|
|
4239
4511
|
f" - {closure}\n\n"
|
|
@@ -5137,7 +5409,7 @@ def validate_task_capsule_scenario() -> int:
|
|
|
5137
5409
|
|
|
5138
5410
|
close_task = (
|
|
5139
5411
|
completion_task
|
|
5140
|
-
.replace("# Tasks\n\n", "# Tasks\n\n## Expectation Coverage\n\n"
|
|
5412
|
+
.replace("# Tasks\n\n## Invalidates\n\n- None.\n\n", "# Tasks\n\n## Invalidates\n\n- None.\n\n## Expectation Coverage\n\n"
|
|
5141
5413
|
"- E1:\n - Covered by: 1.1\n\n## 1. Work\n\n")
|
|
5142
5414
|
.replace("- [ ] 1.1", "- [x] 1.1")
|
|
5143
5415
|
)
|
|
@@ -5197,7 +5469,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
5197
5469
|
tasks_path = repo / "openspec/changes/demo/tasks.md"
|
|
5198
5470
|
write_text(
|
|
5199
5471
|
tasks_path,
|
|
5200
|
-
"# Tasks\n\n"
|
|
5472
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5201
5473
|
"- [ ] 1.1 Incomplete task\n"
|
|
5202
5474
|
" - Owner: keel-agent\n",
|
|
5203
5475
|
)
|
|
@@ -5232,7 +5504,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
5232
5504
|
|
|
5233
5505
|
write_text(
|
|
5234
5506
|
tasks_path,
|
|
5235
|
-
"# Tasks\n\n"
|
|
5507
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5236
5508
|
"- [ ] 1.1 Complete executable task\n"
|
|
5237
5509
|
" - Owner: keel-agent\n"
|
|
5238
5510
|
" - Mode: implementation\n"
|
|
@@ -5382,7 +5654,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
5382
5654
|
findings: str = "none",
|
|
5383
5655
|
) -> str:
|
|
5384
5656
|
return (
|
|
5385
|
-
"# Tasks\n\n"
|
|
5657
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5386
5658
|
"- [ ] 1.1 Complete behavior\n"
|
|
5387
5659
|
" - Owner: keel-agent\n"
|
|
5388
5660
|
" - Mode: implementation\n"
|
|
@@ -5501,7 +5773,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
5501
5773
|
)
|
|
5502
5774
|
write_text(
|
|
5503
5775
|
completion_repo / "openspec/changes/follow-up/tasks.md",
|
|
5504
|
-
"# Tasks\n\n- [ ] 1.1 Own the finding\n",
|
|
5776
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Own the finding\n",
|
|
5505
5777
|
)
|
|
5506
5778
|
owned_finding = run_keel(
|
|
5507
5779
|
completion_repo,
|
|
@@ -5819,7 +6091,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
5819
6091
|
if extra_touch:
|
|
5820
6092
|
touch += " - src/extra.js\n"
|
|
5821
6093
|
return (
|
|
5822
|
-
"# Tasks\n\n"
|
|
6094
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5823
6095
|
"- [ ] 1.1 Record behavior\n"
|
|
5824
6096
|
" - Owner: keel-agent\n"
|
|
5825
6097
|
" - Mode: implementation\n"
|
|
@@ -6037,7 +6309,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
6037
6309
|
def close_task(checked: bool, review_status: str = "pass") -> str:
|
|
6038
6310
|
mark = "x" if checked else " "
|
|
6039
6311
|
return (
|
|
6040
|
-
"# Tasks\n\n"
|
|
6312
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
6041
6313
|
"## Expectation Coverage\n\n"
|
|
6042
6314
|
"- E1:\n"
|
|
6043
6315
|
" - Covered by: 1.1\n\n"
|
|
@@ -6196,7 +6468,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
6196
6468
|
|
|
6197
6469
|
def validate_scope_rename_attribution_scenario() -> int:
|
|
6198
6470
|
rename_task = (
|
|
6199
|
-
"# Tasks\n\n"
|
|
6471
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
6200
6472
|
"- [ ] 1.1 Complete behavior\n"
|
|
6201
6473
|
" - Owner: keel-agent\n"
|
|
6202
6474
|
" - Mode: implementation\n"
|
|
@@ -6399,7 +6671,7 @@ def validate_target_capability_adapters_scenario() -> int:
|
|
|
6399
6671
|
|
|
6400
6672
|
write_text(
|
|
6401
6673
|
repo / "openspec/changes/demo/tasks.md",
|
|
6402
|
-
"# Tasks\n\n- [ ] 1.1 Incomplete\n - Owner: keel-agent\n",
|
|
6674
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Incomplete\n - Owner: keel-agent\n",
|
|
6403
6675
|
)
|
|
6404
6676
|
gate = run_keel(
|
|
6405
6677
|
repo,
|
|
@@ -6825,6 +7097,31 @@ def session_start_context(result: subprocess.CompletedProcess[str]) -> str | Non
|
|
|
6825
7097
|
return output.get("additionalContext")
|
|
6826
7098
|
|
|
6827
7099
|
|
|
7100
|
+
# The projection is delivered through additionalContext, which the host injects
|
|
7101
|
+
# into the agent and never renders for the human. Every branch must therefore
|
|
7102
|
+
# carry the instruction to relay it, including — especially — the degraded ones,
|
|
7103
|
+
# because a projection nobody sees is a projection nobody checks.
|
|
7104
|
+
SESSION_START_DISCLOSURE = "to the user in your first reply"
|
|
7105
|
+
|
|
7106
|
+
# A host loads its plugins once per session, so the projection can be absent for
|
|
7107
|
+
# reasons no repository check can see. The resident protocol is the carrier of
|
|
7108
|
+
# last resort and must state the same obligation without trading away the
|
|
7109
|
+
# continuity rules it already carried.
|
|
7110
|
+
RESIDENT_SESSION_START_REQUIRED = (
|
|
7111
|
+
SESSION_START_DISCLOSURE,
|
|
7112
|
+
"keel context",
|
|
7113
|
+
"never infer continuity from native memory",
|
|
7114
|
+
)
|
|
7115
|
+
|
|
7116
|
+
|
|
7117
|
+
def resident_session_start_section(path: Path) -> str | None:
|
|
7118
|
+
text = path.read_text(encoding="utf-8")
|
|
7119
|
+
match = re.search(
|
|
7120
|
+
r"^## Session Start$(.*?)^## ", text, re.MULTILINE | re.DOTALL
|
|
7121
|
+
)
|
|
7122
|
+
return match.group(1) if match else None
|
|
7123
|
+
|
|
7124
|
+
|
|
6828
7125
|
def validate_native_plugin_session_start_scenario() -> int:
|
|
6829
7126
|
real_cli = f'node "{ROOT / "bin/keel.js"}"'
|
|
6830
7127
|
codex_event = {"hook_event_name": "SessionStart", "source": "startup"}
|
|
@@ -6872,6 +7169,7 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
6872
7169
|
or "demo#1.1" not in codex_context
|
|
6873
7170
|
or "task-start" not in codex_context
|
|
6874
7171
|
or "disposable" not in codex_context
|
|
7172
|
+
or SESSION_START_DISCLOSURE not in codex_context
|
|
6875
7173
|
):
|
|
6876
7174
|
report(
|
|
6877
7175
|
"native-plugin-session-start ready projection lacks concise "
|
|
@@ -6883,6 +7181,24 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
6883
7181
|
report("native-plugin-session-start projection overstepped.")
|
|
6884
7182
|
return 1
|
|
6885
7183
|
|
|
7184
|
+
idle_repo = tmp / "idle"
|
|
7185
|
+
(idle_repo / "openspec/changes").mkdir(parents=True)
|
|
7186
|
+
idle_result = run_session_start_hook(
|
|
7187
|
+
idle_repo, codex_event, keel_cli=real_cli
|
|
7188
|
+
)
|
|
7189
|
+
idle_context = session_start_context(idle_result)
|
|
7190
|
+
if (
|
|
7191
|
+
idle_result.returncode != 0
|
|
7192
|
+
or not idle_context
|
|
7193
|
+
or "idle" not in idle_context
|
|
7194
|
+
or SESSION_START_DISCLOSURE not in idle_context
|
|
7195
|
+
):
|
|
7196
|
+
report(
|
|
7197
|
+
"native-plugin-session-start idle projection did not disclose "
|
|
7198
|
+
"its status to the user: " + repr(idle_context)
|
|
7199
|
+
)
|
|
7200
|
+
return 1
|
|
7201
|
+
|
|
6886
7202
|
ambiguous_repo = tmp / "ambiguous"
|
|
6887
7203
|
ambiguous_repo.mkdir()
|
|
6888
7204
|
for change in ("alpha", "beta"):
|
|
@@ -6899,6 +7215,7 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
6899
7215
|
or not ambiguous_context
|
|
6900
7216
|
or "ambiguous" not in ambiguous_context
|
|
6901
7217
|
or "keel context" not in ambiguous_context
|
|
7218
|
+
or SESSION_START_DISCLOSURE not in ambiguous_context
|
|
6902
7219
|
or "alpha#1.1" in ambiguous_context
|
|
6903
7220
|
):
|
|
6904
7221
|
report(
|
|
@@ -6928,6 +7245,7 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
6928
7245
|
or not missing_context
|
|
6929
7246
|
or "missing or incompatible" not in missing_context
|
|
6930
7247
|
or "keel context" not in missing_context
|
|
7248
|
+
or SESSION_START_DISCLOSURE not in missing_context
|
|
6931
7249
|
):
|
|
6932
7250
|
report("native-plugin-session-start missing-CLI fallback failed.")
|
|
6933
7251
|
report(repr(missing_context))
|
|
@@ -6950,6 +7268,7 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
6950
7268
|
malformed_result.returncode != 0
|
|
6951
7269
|
or not malformed_context
|
|
6952
7270
|
or "malformed" not in malformed_context
|
|
7271
|
+
or SESSION_START_DISCLOSURE not in malformed_context
|
|
6953
7272
|
):
|
|
6954
7273
|
report("native-plugin-session-start malformed-output fallback failed.")
|
|
6955
7274
|
report(repr(malformed_context))
|
|
@@ -6975,6 +7294,7 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
6975
7294
|
hang_result.returncode != 0
|
|
6976
7295
|
or not hang_context
|
|
6977
7296
|
or "failed or timed out" not in hang_context
|
|
7297
|
+
or SESSION_START_DISCLOSURE not in hang_context
|
|
6978
7298
|
):
|
|
6979
7299
|
report("native-plugin-session-start timeout fallback failed.")
|
|
6980
7300
|
report(repr(hang_context))
|
|
@@ -6995,6 +7315,21 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
6995
7315
|
)
|
|
6996
7316
|
return 1
|
|
6997
7317
|
|
|
7318
|
+
resident = resident_session_start_section(ROOT / "AGENTS.md")
|
|
7319
|
+
if resident is None:
|
|
7320
|
+
report(
|
|
7321
|
+
"native-plugin-session-start resident AGENTS.md has no Session "
|
|
7322
|
+
"Start section."
|
|
7323
|
+
)
|
|
7324
|
+
return 1
|
|
7325
|
+
for needle in RESIDENT_SESSION_START_REQUIRED:
|
|
7326
|
+
if needle not in resident:
|
|
7327
|
+
report(
|
|
7328
|
+
"native-plugin-session-start resident Session Start section is "
|
|
7329
|
+
f"missing: {needle}"
|
|
7330
|
+
)
|
|
7331
|
+
return 1
|
|
7332
|
+
|
|
6998
7333
|
report("native-plugin-session-start scenario passed.")
|
|
6999
7334
|
return 0
|
|
7000
7335
|
|
|
@@ -7024,8 +7359,11 @@ def validate_native_plugin_marketplaces_scenario() -> int:
|
|
|
7024
7359
|
codex = shutil.which("codex")
|
|
7025
7360
|
claude = claude_cli()
|
|
7026
7361
|
if codex is None or claude is None:
|
|
7027
|
-
|
|
7028
|
-
|
|
7362
|
+
return skip_scenario(
|
|
7363
|
+
"native-plugin-marketplaces",
|
|
7364
|
+
"requires the codex and claude CLIs, which are not installed; it "
|
|
7365
|
+
"probes native marketplace behavior no CI runner provides",
|
|
7366
|
+
)
|
|
7029
7367
|
|
|
7030
7368
|
with tempfile.TemporaryDirectory(prefix="keel-native-market-") as raw_tmp:
|
|
7031
7369
|
tmp = Path(raw_tmp)
|
|
@@ -7680,7 +8018,7 @@ def validate_native_runtime_projection_scenario() -> int:
|
|
|
7680
8018
|
|
|
7681
8019
|
def projection_task(acceptance: str = "observable result") -> str:
|
|
7682
8020
|
return (
|
|
7683
|
-
"# Tasks\n\n"
|
|
8021
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
7684
8022
|
"- [ ] 1.1 Project selected behavior\n"
|
|
7685
8023
|
" - Owner: keel-agent\n"
|
|
7686
8024
|
" - Mode: implementation\n"
|
|
@@ -8274,7 +8612,7 @@ NATIVE_GOAL_VERSION = "keel-native-goal/v1"
|
|
|
8274
8612
|
|
|
8275
8613
|
|
|
8276
8614
|
def _goal_tasks_file(blocks: list[str]) -> str:
|
|
8277
|
-
return "# Tasks\n\n" + "\n\n".join(blocks) + "\n"
|
|
8615
|
+
return "# Tasks\n\n## Invalidates\n\n- None.\n\n" + "\n\n".join(blocks) + "\n"
|
|
8278
8616
|
|
|
8279
8617
|
|
|
8280
8618
|
def _goal_task_block(
|
|
@@ -8893,7 +9231,7 @@ def validate_fast_pre_push_doctor_scenario() -> int:
|
|
|
8893
9231
|
|
|
8894
9232
|
def validate_verify_layer_tag_scenario() -> int:
|
|
8895
9233
|
fixture = (
|
|
8896
|
-
"# Tasks\n\n"
|
|
9234
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
8897
9235
|
"- [ ] 1.1 Exercise the verification-layer tag\n"
|
|
8898
9236
|
" - Covers:\n"
|
|
8899
9237
|
" - E1: Public behavior passes.\n"
|
|
@@ -10129,8 +10467,11 @@ def validate_native_plugin_install_matrix_scenario() -> int:
|
|
|
10129
10467
|
codex = shutil.which("codex")
|
|
10130
10468
|
claude = claude_cli()
|
|
10131
10469
|
if codex is None or claude is None:
|
|
10132
|
-
|
|
10133
|
-
|
|
10470
|
+
return skip_scenario(
|
|
10471
|
+
"native-plugin-install-matrix",
|
|
10472
|
+
"requires the codex and claude CLIs, which are not installed; it "
|
|
10473
|
+
"probes native install behavior no CI runner provides",
|
|
10474
|
+
)
|
|
10134
10475
|
|
|
10135
10476
|
expected_version = json.loads(
|
|
10136
10477
|
(ROOT / "package.json").read_text(encoding="utf-8")
|
|
@@ -10283,7 +10624,7 @@ def validate_native_plugin_install_matrix_scenario() -> int:
|
|
|
10283
10624
|
def guard_task_fixture(checked: bool = False) -> str:
|
|
10284
10625
|
box = "x" if checked else " "
|
|
10285
10626
|
return (
|
|
10286
|
-
"# Tasks\n\n"
|
|
10627
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
10287
10628
|
f"- [{box}] 1.1 Exercise guarded feature\n"
|
|
10288
10629
|
" - Covers:\n"
|
|
10289
10630
|
" - E1: Guarded public behavior passes.\n"
|
|
@@ -10383,7 +10724,7 @@ RECORD_LAYER_SPEC = (
|
|
|
10383
10724
|
def record_layer_tasks(checked: bool = False, touch: str = "src/feature.js") -> str:
|
|
10384
10725
|
box = "x" if checked else " "
|
|
10385
10726
|
return (
|
|
10386
|
-
"# Tasks\n\n"
|
|
10727
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
10387
10728
|
f"- [{box}] 1.1 Exercise guarded feature\n"
|
|
10388
10729
|
" - Covers:\n"
|
|
10389
10730
|
" - demo-cap / Guarded behavior holds / Guarded public behavior passes\n"
|
|
@@ -10399,7 +10740,7 @@ def record_layer_tasks(checked: bool = False, touch: str = "src/feature.js") ->
|
|
|
10399
10740
|
|
|
10400
10741
|
def mode_fixture_tasks(mode: str, touch: str) -> str:
|
|
10401
10742
|
return (
|
|
10402
|
-
"# Tasks\n\n"
|
|
10743
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
10403
10744
|
"- [ ] 1.1 Establish the version baseline\n"
|
|
10404
10745
|
f" - Mode: {mode}\n"
|
|
10405
10746
|
" - Covers:\n"
|
|
@@ -10415,6 +10756,335 @@ def mode_fixture_tasks(mode: str, touch: str) -> str:
|
|
|
10415
10756
|
)
|
|
10416
10757
|
|
|
10417
10758
|
|
|
10759
|
+
def validate_runner_skip_accounting_scenario() -> int:
|
|
10760
|
+
"""Issue #10: the suite could not pass anywhere the native CLIs are absent.
|
|
10761
|
+
|
|
10762
|
+
Two of seventy scenarios probe native runtimes and used to `return 1` when
|
|
10763
|
+
the CLI was missing, so no CI runner could ever go green. A skip must be
|
|
10764
|
+
reported and counted, never conflated with a pass or a failure.
|
|
10765
|
+
"""
|
|
10766
|
+
label = "runner-skip-accounting"
|
|
10767
|
+
runner = str(ROOT / "scripts/validate_plugin.py")
|
|
10768
|
+
|
|
10769
|
+
def run_registry(results: str) -> subprocess.CompletedProcess[str]:
|
|
10770
|
+
"""Drive run_all over synthetic scenario results in a child process.
|
|
10771
|
+
|
|
10772
|
+
run_all dispatches each scenario as its own subprocess, which reads the
|
|
10773
|
+
real registry from disk, so a substituted registry would be ignored.
|
|
10774
|
+
The accounting is the behavior under test, so the process fan-out is
|
|
10775
|
+
replaced with fixed (name, code, output) triples instead.
|
|
10776
|
+
"""
|
|
10777
|
+
program = (
|
|
10778
|
+
"import sys\n"
|
|
10779
|
+
f"src = open({runner!r}, encoding='utf-8').read()\n"
|
|
10780
|
+
"ns = {'__name__': 'v', '__file__': %r}\n" % runner
|
|
10781
|
+
+ "exec(compile(src, %r, 'exec'), ns)\n" % runner
|
|
10782
|
+
+ f"results = {results}\n"
|
|
10783
|
+
"ns['SCENARIOS'] = tuple((n, None) for n, _, _ in results)\n"
|
|
10784
|
+
"ns['run_baseline'] = lambda: 0\n"
|
|
10785
|
+
"ns['run_scenario_processes'] = lambda names, jobs: results\n"
|
|
10786
|
+
"sys.exit(ns['run_all'](2))\n"
|
|
10787
|
+
)
|
|
10788
|
+
return subprocess.run(
|
|
10789
|
+
[sys.executable, "-c", program],
|
|
10790
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
10791
|
+
cwd=str(ROOT),
|
|
10792
|
+
)
|
|
10793
|
+
|
|
10794
|
+
# A skipping scenario must not fail the run, must be named, and must be
|
|
10795
|
+
# excluded from the verified count; a failing one must still fail it.
|
|
10796
|
+
both = run_registry(
|
|
10797
|
+
"[('fake-skip', 3, 'fake-skip scenario skipped: the frob CLI\\n'),"
|
|
10798
|
+
" ('fake-pass', 0, 'fake-pass scenario passed.\\n')]"
|
|
10799
|
+
)
|
|
10800
|
+
out = (both.stdout or "") + (both.stderr or "")
|
|
10801
|
+
if both.returncode != 0:
|
|
10802
|
+
report(f"{label}: a skipping scenario must not fail the run.")
|
|
10803
|
+
report(out.strip())
|
|
10804
|
+
return 1
|
|
10805
|
+
for needle in ("fake-skip", "skipped", "the frob CLI", "plus 1 scenario"):
|
|
10806
|
+
if needle not in out:
|
|
10807
|
+
report(f"{label}: the summary must report {needle!r}; got:\n{out.strip()}")
|
|
10808
|
+
return 1
|
|
10809
|
+
if "fake-pass" in out.split("passed:")[-1]:
|
|
10810
|
+
report(f"{label}: a passing scenario must not be listed as skipped.")
|
|
10811
|
+
report(out.strip())
|
|
10812
|
+
return 1
|
|
10813
|
+
|
|
10814
|
+
mixed = run_registry(
|
|
10815
|
+
"[('fake-skip', 3, 'fake-skip scenario skipped: the frob CLI\\n'),"
|
|
10816
|
+
" ('fake-fail', 1, 'fake-fail scenario failed.\\n')]"
|
|
10817
|
+
)
|
|
10818
|
+
mixed_out = (mixed.stdout or "") + (mixed.stderr or "")
|
|
10819
|
+
if mixed.returncode == 0 or "failed for: fake-fail" not in mixed_out:
|
|
10820
|
+
report(
|
|
10821
|
+
f"{label}: a skip beside a failure must still fail the run and name "
|
|
10822
|
+
"the failure."
|
|
10823
|
+
)
|
|
10824
|
+
report(mixed_out.strip())
|
|
10825
|
+
return 1
|
|
10826
|
+
if "fake-skip" in mixed_out.split("failed for:")[-1]:
|
|
10827
|
+
report(f"{label}: a skipped scenario must not be named as a failure.")
|
|
10828
|
+
report(mixed_out.strip())
|
|
10829
|
+
return 1
|
|
10830
|
+
|
|
10831
|
+
# The two real native-runtime scenarios must take the skip path, not fail,
|
|
10832
|
+
# when their CLI cannot be resolved.
|
|
10833
|
+
for name in ("native-plugin-marketplaces", "native-plugin-install-matrix"):
|
|
10834
|
+
blinded = subprocess.run(
|
|
10835
|
+
[sys.executable, runner, "--scenario", name],
|
|
10836
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
10837
|
+
cwd=str(ROOT),
|
|
10838
|
+
env={**os.environ, "PATH": str(ROOT), "PATHEXT": ""},
|
|
10839
|
+
)
|
|
10840
|
+
blinded_out = (blinded.stdout or "") + (blinded.stderr or "")
|
|
10841
|
+
if blinded.returncode != 3 or "skipped" not in blinded_out:
|
|
10842
|
+
report(
|
|
10843
|
+
f"{label}: {name} must exit 3 with a reported skip when its CLI "
|
|
10844
|
+
f"cannot be resolved; got {blinded.returncode}."
|
|
10845
|
+
)
|
|
10846
|
+
report(blinded_out.strip())
|
|
10847
|
+
return 1
|
|
10848
|
+
if "codex" not in blinded_out:
|
|
10849
|
+
report(f"{label}: {name}'s skip does not name the runtime it needed.")
|
|
10850
|
+
report(blinded_out.strip())
|
|
10851
|
+
return 1
|
|
10852
|
+
|
|
10853
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
10854
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
10855
|
+
return 1
|
|
10856
|
+
report(f"{label} scenario passed.")
|
|
10857
|
+
return 0
|
|
10858
|
+
|
|
10859
|
+
|
|
10860
|
+
def sibling_scope_tasks(sibling_checked: bool, sibling_touch: str) -> str:
|
|
10861
|
+
"""Two tasks: 1.1 owns `shared.js`, 1.2 is the one being completed."""
|
|
10862
|
+
|
|
10863
|
+
def task(task_id: str, title: str, checked: bool, touch: str) -> str:
|
|
10864
|
+
mark = "x" if checked else " "
|
|
10865
|
+
return (
|
|
10866
|
+
f"- [{mark}] {task_id} {title}\n"
|
|
10867
|
+
" - Covers:\n"
|
|
10868
|
+
" - E1: public behavior\n"
|
|
10869
|
+
" - Touch:\n"
|
|
10870
|
+
+ "".join(f" - {entry}\n" for entry in touch.split(","))
|
|
10871
|
+
+ " - Verify:\n"
|
|
10872
|
+
" - Strategy: evidence-first\n"
|
|
10873
|
+
" - M1: node test.js\n"
|
|
10874
|
+
" - Evidence:\n"
|
|
10875
|
+
" - M1: verified\n"
|
|
10876
|
+
" - Review:\n"
|
|
10877
|
+
" - Status: pass\n"
|
|
10878
|
+
" - Acceptance check: reviewed\n"
|
|
10879
|
+
" - Scope check: reviewed\n"
|
|
10880
|
+
" - Findings: none\n"
|
|
10881
|
+
" - Blocker: none\n"
|
|
10882
|
+
)
|
|
10883
|
+
|
|
10884
|
+
return (
|
|
10885
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
10886
|
+
"## 1. Work\n\n"
|
|
10887
|
+
+ task("1.1", "Own the shared file", sibling_checked, sibling_touch)
|
|
10888
|
+
+ "\n"
|
|
10889
|
+
+ task("1.2", "Own its own file", False, "src/mine.js")
|
|
10890
|
+
+ "\n## Expectation Coverage\n\n- None.\n"
|
|
10891
|
+
)
|
|
10892
|
+
|
|
10893
|
+
|
|
10894
|
+
def validate_completed_sibling_attribution_scenario() -> int:
|
|
10895
|
+
"""Issue #13 item 2: a finished task's uncommitted work blamed the next one.
|
|
10896
|
+
|
|
10897
|
+
`--base HEAD` cannot tell who wrote a path, so a sibling that already passed
|
|
10898
|
+
its own completion gate had its files attributed to whoever ran next. The
|
|
10899
|
+
workaround — commit per task — was correct but implicit, and the diagnostic
|
|
10900
|
+
named a file the author never touched.
|
|
10901
|
+
"""
|
|
10902
|
+
label = "completed-sibling-attribution"
|
|
10903
|
+
|
|
10904
|
+
def complete(sibling_checked: bool, sibling_touch: str = "src/shared.js"):
|
|
10905
|
+
with tempfile.TemporaryDirectory(prefix="keel-sibling-scope-") as raw:
|
|
10906
|
+
repo = Path(raw)
|
|
10907
|
+
for name in ("src/shared.js", "src/mine.js", "src/stray.js"):
|
|
10908
|
+
write_text(repo / name, "// base\n")
|
|
10909
|
+
write_text(
|
|
10910
|
+
repo / "openspec/changes/demo/tasks.md",
|
|
10911
|
+
sibling_scope_tasks(sibling_checked, sibling_touch),
|
|
10912
|
+
)
|
|
10913
|
+
for args in (
|
|
10914
|
+
["init", "--quiet"],
|
|
10915
|
+
["-c", "user.email=t@e", "-c", "user.name=t", "add", "-A"],
|
|
10916
|
+
[
|
|
10917
|
+
"-c", "user.email=t@e", "-c", "user.name=t",
|
|
10918
|
+
"commit", "--quiet", "-m", "base",
|
|
10919
|
+
],
|
|
10920
|
+
):
|
|
10921
|
+
done = subprocess.run(
|
|
10922
|
+
["git", *args], cwd=repo, capture_output=True, text=True
|
|
10923
|
+
)
|
|
10924
|
+
if done.returncode != 0:
|
|
10925
|
+
report(f"{label}: git {args[0]} failed: {done.stderr}")
|
|
10926
|
+
return None
|
|
10927
|
+
# The sibling's work and an undeclared stray, both uncommitted.
|
|
10928
|
+
write_text(repo / "src/shared.js", "// sibling's uncommitted work\n")
|
|
10929
|
+
write_text(repo / "src/stray.js", "// nobody declared this\n")
|
|
10930
|
+
result = run_keel(
|
|
10931
|
+
repo, "gate", "task-complete",
|
|
10932
|
+
"--change", "demo", "--task", "1.2", "--base", "HEAD", "--json",
|
|
10933
|
+
)
|
|
10934
|
+
return json.loads(result.stdout) if result.stdout else {}
|
|
10935
|
+
|
|
10936
|
+
owned = complete(sibling_checked=True)
|
|
10937
|
+
if owned is None:
|
|
10938
|
+
return 1
|
|
10939
|
+
outside = [
|
|
10940
|
+
item.get("message", "")
|
|
10941
|
+
for item in owned.get("problems", [])
|
|
10942
|
+
if item.get("code") == "outside-touch"
|
|
10943
|
+
]
|
|
10944
|
+
if any("src/shared.js" in message for message in outside):
|
|
10945
|
+
report(
|
|
10946
|
+
f"{label}: a completed sibling's declared file was still attributed "
|
|
10947
|
+
f"to the selected task: {outside}"
|
|
10948
|
+
)
|
|
10949
|
+
return 1
|
|
10950
|
+
if not any("src/stray.js" in message for message in outside):
|
|
10951
|
+
report(
|
|
10952
|
+
f"{label}: a path no task declares must still fail: {outside}"
|
|
10953
|
+
)
|
|
10954
|
+
return 1
|
|
10955
|
+
warnings = " ".join(owned.get("warnings", []))
|
|
10956
|
+
if "src/shared.js" not in warnings or "1.1" not in warnings:
|
|
10957
|
+
report(
|
|
10958
|
+
f"{label}: the exclusion must be reported, naming the path and the "
|
|
10959
|
+
f"completed task that declares it; got {owned.get('warnings')}"
|
|
10960
|
+
)
|
|
10961
|
+
return 1
|
|
10962
|
+
|
|
10963
|
+
unchecked = complete(sibling_checked=False)
|
|
10964
|
+
if unchecked is None:
|
|
10965
|
+
return 1
|
|
10966
|
+
unchecked_outside = [
|
|
10967
|
+
item.get("message", "")
|
|
10968
|
+
for item in unchecked.get("problems", [])
|
|
10969
|
+
if item.get("code") == "outside-touch"
|
|
10970
|
+
]
|
|
10971
|
+
if not any("src/shared.js" in message for message in unchecked_outside):
|
|
10972
|
+
report(
|
|
10973
|
+
f"{label}: an unchecked sibling's Touch must grant nothing: "
|
|
10974
|
+
f"{unchecked_outside}"
|
|
10975
|
+
)
|
|
10976
|
+
return 1
|
|
10977
|
+
|
|
10978
|
+
no_touch = complete(sibling_checked=True, sibling_touch="none")
|
|
10979
|
+
if no_touch is None:
|
|
10980
|
+
return 1
|
|
10981
|
+
none_outside = [
|
|
10982
|
+
item.get("message", "")
|
|
10983
|
+
for item in no_touch.get("problems", [])
|
|
10984
|
+
if item.get("code") == "outside-touch"
|
|
10985
|
+
]
|
|
10986
|
+
if not any("src/shared.js" in message for message in none_outside):
|
|
10987
|
+
report(
|
|
10988
|
+
f"{label}: a sibling whose Touch is none must contribute no claim: "
|
|
10989
|
+
f"{none_outside}"
|
|
10990
|
+
)
|
|
10991
|
+
return 1
|
|
10992
|
+
|
|
10993
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
10994
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
10995
|
+
return 1
|
|
10996
|
+
report(f"{label} scenario passed.")
|
|
10997
|
+
return 0
|
|
10998
|
+
|
|
10999
|
+
|
|
11000
|
+
def validate_resident_topic_matching_scenario() -> int:
|
|
11001
|
+
"""Issue #15 item 1: required entries were named topics, matched as prose.
|
|
11002
|
+
|
|
11003
|
+
The bootstrap is under a line and byte budget, so its wording gets rewritten
|
|
11004
|
+
to fit — and every rewrite of a pinned sentence failed the check that was
|
|
11005
|
+
supposed to prove only that the topic was still covered.
|
|
11006
|
+
"""
|
|
11007
|
+
label = "resident-topic-matching"
|
|
11008
|
+
source = (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8")
|
|
11009
|
+
original = "Touch is the write boundary for product files;"
|
|
11010
|
+
if original not in source:
|
|
11011
|
+
report(
|
|
11012
|
+
f"{label}: the fixture's anchor sentence is not in the bootstrap; "
|
|
11013
|
+
"update this scenario alongside the wording."
|
|
11014
|
+
)
|
|
11015
|
+
return 1
|
|
11016
|
+
|
|
11017
|
+
def errors_for(text: str) -> list[str]:
|
|
11018
|
+
with tempfile.TemporaryDirectory(prefix="keel-resident-topic-") as raw:
|
|
11019
|
+
root = Path(raw)
|
|
11020
|
+
write_text(root / "assets/bootstrap/AGENTS.md", text)
|
|
11021
|
+
found: list[str] = []
|
|
11022
|
+
validate_resident_blocks(found, root)
|
|
11023
|
+
return found
|
|
11024
|
+
|
|
11025
|
+
def touch_errors(text: str) -> list[str]:
|
|
11026
|
+
return [item for item in errors_for(text) if "Touch" in item or "bound" in item]
|
|
11027
|
+
|
|
11028
|
+
baseline = errors_for(source)
|
|
11029
|
+
if baseline:
|
|
11030
|
+
report(f"{label}: the unmodified bootstrap must pass: {baseline}")
|
|
11031
|
+
return 1
|
|
11032
|
+
|
|
11033
|
+
# A rewording that keeps both concepts in one statement must pass.
|
|
11034
|
+
reworded = source.replace(
|
|
11035
|
+
original, "Touch bounds product writes, not the task's own records;"
|
|
11036
|
+
)
|
|
11037
|
+
if touch_errors(reworded):
|
|
11038
|
+
report(
|
|
11039
|
+
f"{label}: a rewording that keeps the topic was rejected: "
|
|
11040
|
+
f"{touch_errors(reworded)}"
|
|
11041
|
+
)
|
|
11042
|
+
return 1
|
|
11043
|
+
|
|
11044
|
+
# Deleting the statement must still fail.
|
|
11045
|
+
deleted = source.replace(original, "")
|
|
11046
|
+
if not touch_errors(deleted):
|
|
11047
|
+
report(f"{label}: deleting the boundary statement did not fail the check.")
|
|
11048
|
+
return 1
|
|
11049
|
+
|
|
11050
|
+
# Mentioning only one of the topic's words must not satisfy it.
|
|
11051
|
+
partial = source.replace(original, "Touch the files you declared;")
|
|
11052
|
+
if not touch_errors(partial):
|
|
11053
|
+
report(
|
|
11054
|
+
f"{label}: a statement mentioning only Touch, with no boundary "
|
|
11055
|
+
"concept, satisfied the topic."
|
|
11056
|
+
)
|
|
11057
|
+
return 1
|
|
11058
|
+
|
|
11059
|
+
# A renamed command must still fail, and be reported as a literal.
|
|
11060
|
+
renamed = source.replace("keel context", "keel status")
|
|
11061
|
+
literal_errors = [item for item in errors_for(renamed) if "keel context" in item]
|
|
11062
|
+
if not literal_errors:
|
|
11063
|
+
report(f"{label}: renaming a required command did not fail the check.")
|
|
11064
|
+
return 1
|
|
11065
|
+
if not any("literal" in item for item in literal_errors):
|
|
11066
|
+
report(
|
|
11067
|
+
f"{label}: a missing command must be reported as a missing literal, "
|
|
11068
|
+
f"distinguishably from a missing topic: {literal_errors}"
|
|
11069
|
+
)
|
|
11070
|
+
return 1
|
|
11071
|
+
if any("literal" in item for item in touch_errors(deleted)):
|
|
11072
|
+
report(
|
|
11073
|
+
f"{label}: a missing topic must not be reported as a missing "
|
|
11074
|
+
f"literal: {touch_errors(deleted)}"
|
|
11075
|
+
)
|
|
11076
|
+
return 1
|
|
11077
|
+
|
|
11078
|
+
if (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8") != source:
|
|
11079
|
+
report(f"{label}: the shipped bootstrap was left modified.")
|
|
11080
|
+
return 1
|
|
11081
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
11082
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
11083
|
+
return 1
|
|
11084
|
+
report(f"{label} scenario passed.")
|
|
11085
|
+
return 0
|
|
11086
|
+
|
|
11087
|
+
|
|
10418
11088
|
def validate_repo_action_mode_scenario() -> int:
|
|
10419
11089
|
"""Issue #8 example 2: a repository action had no legal contract.
|
|
10420
11090
|
|
|
@@ -10893,7 +11563,7 @@ def validate_touch_write_guard_scenario() -> int:
|
|
|
10893
11563
|
|
|
10894
11564
|
def compaction_task_fixture() -> str:
|
|
10895
11565
|
return (
|
|
10896
|
-
"# Tasks\n\n"
|
|
11566
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
10897
11567
|
"- [ ] 1.1 Exercise compaction continuity\n"
|
|
10898
11568
|
" - Covers:\n"
|
|
10899
11569
|
" - E1: Continuity survives compaction.\n"
|
|
@@ -11603,6 +12273,33 @@ def validate_validation_runner_scenario() -> int:
|
|
|
11603
12273
|
report("validation-runner: README does not document the parallel runner.")
|
|
11604
12274
|
return 1
|
|
11605
12275
|
|
|
12276
|
+
# The full gate must actually run somewhere other than one author's machine:
|
|
12277
|
+
# a workflow drives the same single entry point on push and pull request,
|
|
12278
|
+
# and the release workflow keeps its own tag guard rather than becoming the
|
|
12279
|
+
# suite's only runner.
|
|
12280
|
+
workflow_path = ROOT / ".github/workflows/test.yml"
|
|
12281
|
+
if not workflow_path.is_file():
|
|
12282
|
+
report(
|
|
12283
|
+
"validation-runner: no .github/workflows/test.yml, so the full gate "
|
|
12284
|
+
"runs only in the local pre-push hook."
|
|
12285
|
+
)
|
|
12286
|
+
return 1
|
|
12287
|
+
workflow = workflow_path.read_text(encoding="utf-8")
|
|
12288
|
+
for needle in ("npm test", "npm ci", "pull_request", "push:", "ubuntu-latest"):
|
|
12289
|
+
if needle not in workflow:
|
|
12290
|
+
report(
|
|
12291
|
+
"validation-runner: the full-gate workflow does not declare "
|
|
12292
|
+
f"{needle!r}."
|
|
12293
|
+
)
|
|
12294
|
+
report(workflow)
|
|
12295
|
+
return 1
|
|
12296
|
+
publish = (ROOT / ".github/workflows/publish.yml").read_text(encoding="utf-8")
|
|
12297
|
+
if "does not match package.json version" not in publish:
|
|
12298
|
+
report(
|
|
12299
|
+
"validation-runner: the release workflow lost its tag/version guard."
|
|
12300
|
+
)
|
|
12301
|
+
return 1
|
|
12302
|
+
|
|
11606
12303
|
# Behavioral: the parallel machinery preserves registry order, keeps a
|
|
11607
12304
|
# passing scenario's buffered output, and fails loudly on a bad entry.
|
|
11608
12305
|
ordered = run_scenario_processes(
|
|
@@ -11752,6 +12449,17 @@ SCENARIOS: tuple = (
|
|
|
11752
12449
|
("touch-write-guard", validate_touch_write_guard_scenario),
|
|
11753
12450
|
("touch-guard-record-layer", validate_touch_guard_record_layer_scenario),
|
|
11754
12451
|
("repo-action-mode", validate_repo_action_mode_scenario),
|
|
12452
|
+
("runner-skip-accounting", validate_runner_skip_accounting_scenario),
|
|
12453
|
+
("resident-topic-matching", validate_resident_topic_matching_scenario),
|
|
12454
|
+
("task-start-invalidation", validate_task_start_invalidation_scenario),
|
|
12455
|
+
(
|
|
12456
|
+
"invalidation-authoring-surface",
|
|
12457
|
+
validate_invalidation_authoring_surface_scenario,
|
|
12458
|
+
),
|
|
12459
|
+
(
|
|
12460
|
+
"completed-sibling-attribution",
|
|
12461
|
+
validate_completed_sibling_attribution_scenario,
|
|
12462
|
+
),
|
|
11755
12463
|
("touch-guard-drift", validate_touch_guard_drift_scenario),
|
|
11756
12464
|
("touch-guard-surface", validate_touch_guard_surface_scenario),
|
|
11757
12465
|
("plugin-compaction-continuity", validate_plugin_compaction_continuity_scenario),
|
|
@@ -11868,6 +12576,19 @@ def validate_archive_overlay_hygiene(errors: list[str]) -> None:
|
|
|
11868
12576
|
)
|
|
11869
12577
|
|
|
11870
12578
|
|
|
12579
|
+
# Exit code 3 means "this scenario did not run because an external runtime it
|
|
12580
|
+
# probes is absent". 0 is pass, 1 is fail, 2 is an unknown scenario or a usage
|
|
12581
|
+
# error, so conflating an unavailable runtime with either would hide both. The
|
|
12582
|
+
# reason is narrow on purpose: an inconvenient assertion, a hard fixture, or a
|
|
12583
|
+
# platform difference is a failure, never a skip.
|
|
12584
|
+
SKIPPED = 3
|
|
12585
|
+
|
|
12586
|
+
|
|
12587
|
+
def skip_scenario(label: str, reason: str) -> int:
|
|
12588
|
+
report(f"{label} scenario skipped: {reason}")
|
|
12589
|
+
return SKIPPED
|
|
12590
|
+
|
|
12591
|
+
|
|
11871
12592
|
def run_baseline() -> int:
|
|
11872
12593
|
errors: list[str] = []
|
|
11873
12594
|
validate_manifest(errors)
|
|
@@ -11899,19 +12620,29 @@ def run_all(jobs: int) -> int:
|
|
|
11899
12620
|
# completion, buffered output is replayed in registry order, and every
|
|
11900
12621
|
# failure is named in one summary.
|
|
11901
12622
|
failures = []
|
|
12623
|
+
skipped = []
|
|
11902
12624
|
if run_baseline() != 0:
|
|
11903
12625
|
failures.append("baseline")
|
|
11904
12626
|
ordered = run_scenario_processes([name for name, _ in SCENARIOS], jobs)
|
|
11905
12627
|
for name, code, output in ordered:
|
|
11906
12628
|
sys.stdout.write(output)
|
|
11907
|
-
if code
|
|
12629
|
+
if code == SKIPPED:
|
|
12630
|
+
skipped.append(name)
|
|
12631
|
+
elif code != 0:
|
|
11908
12632
|
failures.append(name)
|
|
11909
12633
|
if failures:
|
|
11910
12634
|
report(f"validation --all failed for: {', '.join(failures)}")
|
|
11911
12635
|
return 1
|
|
11912
|
-
|
|
11913
|
-
|
|
12636
|
+
# The verified count excludes skips, so the number that lands in evidence is
|
|
12637
|
+
# the number actually run, and every skip is named with the run.
|
|
12638
|
+
verified = len(SCENARIOS) - len(skipped)
|
|
12639
|
+
summary = (
|
|
12640
|
+
f"validation --all passed: baseline plus {verified} "
|
|
12641
|
+
f"scenario{'' if verified == 1 else 's'}"
|
|
11914
12642
|
)
|
|
12643
|
+
if skipped:
|
|
12644
|
+
summary += f", {len(skipped)} skipped: {', '.join(skipped)}"
|
|
12645
|
+
report(f"{summary}.")
|
|
11915
12646
|
return 0
|
|
11916
12647
|
|
|
11917
12648
|
|