@christang/keel 5.2.2 → 5.2.4
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 +1 -1
- package/bin/keel.js +31 -2
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/scripts/install_to_repo.py +45 -1
- package/scripts/validate_plugin.py +1032 -22
- package/src/core/capabilities.js +25 -5
- package/src/core/gates.js +70 -24
- package/src/core/guard.js +8 -0
- package/src/core/task-contract.js +127 -14
|
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
|
|
|
37
37
|
"scripts/validate_plugin.py",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
-
PACKAGE_VERSION = "5.2.
|
|
41
|
-
PROTOCOL_VERSION = "5.2.
|
|
40
|
+
PACKAGE_VERSION = "5.2.4"
|
|
41
|
+
PROTOCOL_VERSION = "5.2.4"
|
|
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")
|
|
@@ -3713,6 +3713,924 @@ def task_capsule_compact_fixture() -> str:
|
|
|
3713
3713
|
)
|
|
3714
3714
|
|
|
3715
3715
|
|
|
3716
|
+
def validate_non_concrete_verify_diagnostic_scenario() -> int:
|
|
3717
|
+
"""A compact v4 task whose Verify carries an unfilled token must be told so.
|
|
3718
|
+
|
|
3719
|
+
Regression for issue #7 example 1: the compact/expanded decision reads
|
|
3720
|
+
isConcrete(Verify), so one unfilled token used to select the expanded v3
|
|
3721
|
+
required-field set and report fields the author never declared.
|
|
3722
|
+
"""
|
|
3723
|
+
v3_only_fields = (
|
|
3724
|
+
"Owner",
|
|
3725
|
+
"Read",
|
|
3726
|
+
"Commands",
|
|
3727
|
+
"Acceptance",
|
|
3728
|
+
"Candidate Boundary",
|
|
3729
|
+
"Report",
|
|
3730
|
+
)
|
|
3731
|
+
with tempfile.TemporaryDirectory(prefix="keel-non-concrete-verify-") as raw_tmp:
|
|
3732
|
+
repo = Path(raw_tmp)
|
|
3733
|
+
# A bare token in prose. The reporter's own case wrote it inside an
|
|
3734
|
+
# inline code span, which the inline-code-is-concrete scenario now
|
|
3735
|
+
# covers as legitimately filled; what must still be reported is a token
|
|
3736
|
+
# standing unfenced in the text.
|
|
3737
|
+
task = task_capsule_compact_fixture().replace(
|
|
3738
|
+
" - M1: node test.js\n",
|
|
3739
|
+
" - M1: node test.js writes ledger/scan-log/<date>.md\n",
|
|
3740
|
+
)
|
|
3741
|
+
write_text(repo / "openspec/changes/demo/tasks.md", task)
|
|
3742
|
+
started = run_keel(
|
|
3743
|
+
repo,
|
|
3744
|
+
"gate",
|
|
3745
|
+
"task-start",
|
|
3746
|
+
"--change",
|
|
3747
|
+
"demo",
|
|
3748
|
+
"--task",
|
|
3749
|
+
"1.1",
|
|
3750
|
+
"--json",
|
|
3751
|
+
)
|
|
3752
|
+
payload = json.loads(started.stdout)
|
|
3753
|
+
problems = payload.get("problems", [])
|
|
3754
|
+
codes = {problem.get("code") for problem in problems}
|
|
3755
|
+
if "non-concrete-verify" not in codes:
|
|
3756
|
+
report(
|
|
3757
|
+
"non-concrete-verify-diagnostic: an unfilled token in Verify did "
|
|
3758
|
+
"not produce the non-concrete-verify diagnostic."
|
|
3759
|
+
)
|
|
3760
|
+
report(started.stdout.strip())
|
|
3761
|
+
return 1
|
|
3762
|
+
named = [
|
|
3763
|
+
problem
|
|
3764
|
+
for problem in problems
|
|
3765
|
+
if problem.get("code") == "non-concrete-verify"
|
|
3766
|
+
and "<date>" in problem.get("message", "")
|
|
3767
|
+
]
|
|
3768
|
+
if not named:
|
|
3769
|
+
report(
|
|
3770
|
+
"non-concrete-verify-diagnostic: the diagnostic did not name the "
|
|
3771
|
+
"matched token."
|
|
3772
|
+
)
|
|
3773
|
+
report(started.stdout.strip())
|
|
3774
|
+
return 1
|
|
3775
|
+
leaked = sorted(
|
|
3776
|
+
field
|
|
3777
|
+
for problem in problems
|
|
3778
|
+
if problem.get("code") == "missing-field"
|
|
3779
|
+
for field in v3_only_fields
|
|
3780
|
+
if problem.get("message", "").startswith(f"{field} must be concrete")
|
|
3781
|
+
)
|
|
3782
|
+
if leaked:
|
|
3783
|
+
report(
|
|
3784
|
+
"non-concrete-verify-diagnostic: expanded v3 fields were still "
|
|
3785
|
+
f"reported as missing: {', '.join(leaked)}."
|
|
3786
|
+
)
|
|
3787
|
+
report(started.stdout.strip())
|
|
3788
|
+
return 1
|
|
3789
|
+
# A task with no Verify at all is a genuine expanded v3 task and must
|
|
3790
|
+
# keep its existing required-field diagnostics.
|
|
3791
|
+
bare = task_capsule_compact_fixture()
|
|
3792
|
+
for block in (
|
|
3793
|
+
" - Verify:\n - Strategy: evidence-first\n - M1: node test.js\n",
|
|
3794
|
+
):
|
|
3795
|
+
bare = bare.replace(block, "")
|
|
3796
|
+
write_text(repo / "openspec/changes/bare/tasks.md", bare)
|
|
3797
|
+
bare_started = run_keel(
|
|
3798
|
+
repo,
|
|
3799
|
+
"gate",
|
|
3800
|
+
"task-start",
|
|
3801
|
+
"--change",
|
|
3802
|
+
"bare",
|
|
3803
|
+
"--task",
|
|
3804
|
+
"1.1",
|
|
3805
|
+
"--json",
|
|
3806
|
+
)
|
|
3807
|
+
bare_payload = json.loads(bare_started.stdout)
|
|
3808
|
+
bare_codes = {
|
|
3809
|
+
problem.get("code") for problem in bare_payload.get("problems", [])
|
|
3810
|
+
}
|
|
3811
|
+
if "non-concrete-verify" in bare_codes:
|
|
3812
|
+
report(
|
|
3813
|
+
"non-concrete-verify-diagnostic: a task with no Verify was "
|
|
3814
|
+
"reported as carrying an unfilled token."
|
|
3815
|
+
)
|
|
3816
|
+
report(bare_started.stdout.strip())
|
|
3817
|
+
return 1
|
|
3818
|
+
if "non-concrete-verify-diagnostic" not in {name for name, _ in SCENARIOS}:
|
|
3819
|
+
report(
|
|
3820
|
+
"non-concrete-verify-diagnostic: the scenario registry does not "
|
|
3821
|
+
"include it."
|
|
3822
|
+
)
|
|
3823
|
+
return 1
|
|
3824
|
+
report("non-concrete-verify-diagnostic scenario passed.")
|
|
3825
|
+
return 0
|
|
3826
|
+
|
|
3827
|
+
|
|
3828
|
+
def validate_inline_code_is_concrete_scenario() -> int:
|
|
3829
|
+
"""Unfilled-token forms inside inline code spans are documented patterns.
|
|
3830
|
+
|
|
3831
|
+
Regression for issue #7 example 1: the reporter's Verify wrote a filename
|
|
3832
|
+
pattern inside backticks and it was still judged unfilled. The exemption
|
|
3833
|
+
covers every token form, not only angle brackets, because prose naming the
|
|
3834
|
+
keywords is equally common — see
|
|
3835
|
+
keel/archive/follow-ups/2026-07-27-unfilled-token-keywords.md.
|
|
3836
|
+
"""
|
|
3837
|
+
fenced_m1 = (
|
|
3838
|
+
" - M1: node test.js writes `ledger/scan-log/<date>.md`, skips a "
|
|
3839
|
+
"`TODO` marker, and leaves `TBD` rows alone\n"
|
|
3840
|
+
)
|
|
3841
|
+
bare_m1 = " - M1: node test.js writes ledger/scan-log/<date>.md\n"
|
|
3842
|
+
with tempfile.TemporaryDirectory(prefix="keel-inline-code-") as raw_tmp:
|
|
3843
|
+
repo = Path(raw_tmp)
|
|
3844
|
+
write_text(
|
|
3845
|
+
repo / "openspec/changes/fenced/tasks.md",
|
|
3846
|
+
task_capsule_compact_fixture().replace(
|
|
3847
|
+
" - M1: node test.js\n", fenced_m1
|
|
3848
|
+
),
|
|
3849
|
+
)
|
|
3850
|
+
fenced = run_keel(
|
|
3851
|
+
repo, "gate", "task-start", "--change", "fenced", "--task", "1.1", "--json"
|
|
3852
|
+
)
|
|
3853
|
+
if fenced.returncode != 0:
|
|
3854
|
+
report(
|
|
3855
|
+
"inline-code-is-concrete: token forms inside inline code spans "
|
|
3856
|
+
"were still judged unfilled."
|
|
3857
|
+
)
|
|
3858
|
+
report((fenced.stdout or fenced.stderr).strip())
|
|
3859
|
+
return 1
|
|
3860
|
+
# The same token outside inline code must still be caught, otherwise the
|
|
3861
|
+
# exemption has swallowed the check it is narrowing.
|
|
3862
|
+
write_text(
|
|
3863
|
+
repo / "openspec/changes/bare/tasks.md",
|
|
3864
|
+
task_capsule_compact_fixture().replace(
|
|
3865
|
+
" - M1: node test.js\n", bare_m1
|
|
3866
|
+
),
|
|
3867
|
+
)
|
|
3868
|
+
bare = run_keel(
|
|
3869
|
+
repo, "gate", "task-start", "--change", "bare", "--task", "1.1", "--json"
|
|
3870
|
+
)
|
|
3871
|
+
bare_codes = {
|
|
3872
|
+
problem.get("code")
|
|
3873
|
+
for problem in json.loads(bare.stdout).get("problems", [])
|
|
3874
|
+
}
|
|
3875
|
+
if "non-concrete-verify" not in bare_codes:
|
|
3876
|
+
report(
|
|
3877
|
+
"inline-code-is-concrete: a bare token outside inline code was "
|
|
3878
|
+
"no longer reported as unfilled."
|
|
3879
|
+
)
|
|
3880
|
+
report(bare.stdout.strip())
|
|
3881
|
+
return 1
|
|
3882
|
+
# Stripping runs after the emptiness test, so a field that is entirely
|
|
3883
|
+
# one code span must not read as empty.
|
|
3884
|
+
write_text(
|
|
3885
|
+
repo / "openspec/changes/whole/tasks.md",
|
|
3886
|
+
task_capsule_compact_fixture()
|
|
3887
|
+
.replace(" - M1: node test.js\n", " - M1: `node test.js`\n")
|
|
3888
|
+
.replace(" - src/feature.js\n", " - `src/feature.js`\n"),
|
|
3889
|
+
)
|
|
3890
|
+
whole = run_keel(
|
|
3891
|
+
repo, "gate", "task-start", "--change", "whole", "--task", "1.1", "--json"
|
|
3892
|
+
)
|
|
3893
|
+
if whole.returncode != 0:
|
|
3894
|
+
report(
|
|
3895
|
+
"inline-code-is-concrete: a field whose whole value is one "
|
|
3896
|
+
"inline code span was judged empty."
|
|
3897
|
+
)
|
|
3898
|
+
report((whole.stdout or whole.stderr).strip())
|
|
3899
|
+
return 1
|
|
3900
|
+
if "inline-code-is-concrete" not in {name for name, _ in SCENARIOS}:
|
|
3901
|
+
report("inline-code-is-concrete: the scenario registry does not include it.")
|
|
3902
|
+
return 1
|
|
3903
|
+
report("inline-code-is-concrete scenario passed.")
|
|
3904
|
+
return 0
|
|
3905
|
+
|
|
3906
|
+
|
|
3907
|
+
def validate_covers_separator_collision_scenario() -> int:
|
|
3908
|
+
"""Issue #7 example 2: a requirement name containing the hierarchy separator.
|
|
3909
|
+
|
|
3910
|
+
Both spellings the reporter tried must fail loudly and say why. Keeping the
|
|
3911
|
+
slash over-segments the reference, which used to compile to an unlinked
|
|
3912
|
+
legacy-task-reference and PASS; removing it resolves nothing and used to
|
|
3913
|
+
give a generic message.
|
|
3914
|
+
"""
|
|
3915
|
+
collide = "Continue or downgrade or switch/window criteria"
|
|
3916
|
+
|
|
3917
|
+
def spec(requirement: str) -> str:
|
|
3918
|
+
return (
|
|
3919
|
+
"# cap\n\n## Purpose\nDemo.\n\n"
|
|
3920
|
+
f"### Requirement: {requirement}\nText.\n\n"
|
|
3921
|
+
"#### Scenario: Criteria cover three outcomes\n"
|
|
3922
|
+
"- **WHEN** a thing\n- **THEN** another\n"
|
|
3923
|
+
)
|
|
3924
|
+
|
|
3925
|
+
def tasks(reference: str) -> str:
|
|
3926
|
+
return task_capsule_compact_fixture().replace(
|
|
3927
|
+
" - E1: Public behavior passes.\n", f" - {reference}\n"
|
|
3928
|
+
)
|
|
3929
|
+
|
|
3930
|
+
with tempfile.TemporaryDirectory(prefix="keel-covers-collision-") as raw_tmp:
|
|
3931
|
+
repo = Path(raw_tmp)
|
|
3932
|
+
write_text(repo / "openspec/specs/collide-cap/spec.md", spec(collide))
|
|
3933
|
+
write_text(repo / "openspec/specs/clean-cap/spec.md", spec("Plain name"))
|
|
3934
|
+
|
|
3935
|
+
def start(change: str, reference: str):
|
|
3936
|
+
write_text(repo / f"openspec/changes/{change}/tasks.md", tasks(reference))
|
|
3937
|
+
result = run_keel(
|
|
3938
|
+
repo, "gate", "task-start", "--change", change, "--task", "1.1",
|
|
3939
|
+
"--json",
|
|
3940
|
+
)
|
|
3941
|
+
return json.loads(result.stdout)
|
|
3942
|
+
|
|
3943
|
+
# The reporter's correct spelling: over-segmented, capability is real.
|
|
3944
|
+
kept = start(
|
|
3945
|
+
"kept",
|
|
3946
|
+
f"collide-cap / {collide} / Criteria cover three outcomes",
|
|
3947
|
+
)
|
|
3948
|
+
kept_messages = " ".join(
|
|
3949
|
+
problem.get("message", "") for problem in kept.get("problems", [])
|
|
3950
|
+
)
|
|
3951
|
+
kept_kinds = {
|
|
3952
|
+
entry.get("kind")
|
|
3953
|
+
for entry in kept.get("contract", {})
|
|
3954
|
+
.get("capsule", {})
|
|
3955
|
+
.get("authority", [])
|
|
3956
|
+
}
|
|
3957
|
+
if kept.get("status") != "fail" or "legacy-task-reference" in kept_kinds:
|
|
3958
|
+
report(
|
|
3959
|
+
"covers-separator-collision: an over-segmented reference to a "
|
|
3960
|
+
"real capability still degraded to a free-text reference."
|
|
3961
|
+
)
|
|
3962
|
+
report(json.dumps(kept.get("problems"), ensure_ascii=False))
|
|
3963
|
+
return 1
|
|
3964
|
+
if collide not in kept_messages or "separator" not in kept_messages:
|
|
3965
|
+
report(
|
|
3966
|
+
"covers-separator-collision: the over-segmented diagnostic did "
|
|
3967
|
+
"not name the colliding requirement."
|
|
3968
|
+
)
|
|
3969
|
+
report(kept_messages)
|
|
3970
|
+
return 1
|
|
3971
|
+
# The reporter's fallback spelling: resolves to nothing.
|
|
3972
|
+
trimmed = start(
|
|
3973
|
+
"trimmed",
|
|
3974
|
+
"collide-cap / Continue or downgrade or switchwindow criteria"
|
|
3975
|
+
" / Criteria cover three outcomes",
|
|
3976
|
+
)
|
|
3977
|
+
trimmed_messages = " ".join(
|
|
3978
|
+
problem.get("message", "") for problem in trimmed.get("problems", [])
|
|
3979
|
+
)
|
|
3980
|
+
if collide not in trimmed_messages:
|
|
3981
|
+
report(
|
|
3982
|
+
"covers-separator-collision: the unresolved diagnostic did not "
|
|
3983
|
+
"name the colliding requirement."
|
|
3984
|
+
)
|
|
3985
|
+
report(trimmed_messages)
|
|
3986
|
+
return 1
|
|
3987
|
+
# A capability with no collision keeps the plain wording.
|
|
3988
|
+
plain = start(
|
|
3989
|
+
"plain", "clean-cap / No such requirement / No such scenario"
|
|
3990
|
+
)
|
|
3991
|
+
plain_messages = " ".join(
|
|
3992
|
+
problem.get("message", "") for problem in plain.get("problems", [])
|
|
3993
|
+
)
|
|
3994
|
+
if "separator" in plain_messages:
|
|
3995
|
+
report(
|
|
3996
|
+
"covers-separator-collision: a capability with no colliding "
|
|
3997
|
+
"name still received the separator hint."
|
|
3998
|
+
)
|
|
3999
|
+
report(plain_messages)
|
|
4000
|
+
return 1
|
|
4001
|
+
# Free text that merely contains slashes is not a spec reference.
|
|
4002
|
+
free = start("free", "E1: writes a/b/c and passes")
|
|
4003
|
+
free_kinds = {
|
|
4004
|
+
entry.get("kind")
|
|
4005
|
+
for entry in free.get("contract", {})
|
|
4006
|
+
.get("capsule", {})
|
|
4007
|
+
.get("authority", [])
|
|
4008
|
+
}
|
|
4009
|
+
if free.get("status") != "pass" or free_kinds != {"legacy-task-reference"}:
|
|
4010
|
+
report(
|
|
4011
|
+
"covers-separator-collision: free text containing slashes was "
|
|
4012
|
+
"no longer accepted as a legacy reference."
|
|
4013
|
+
)
|
|
4014
|
+
report(json.dumps(free.get("problems"), ensure_ascii=False))
|
|
4015
|
+
return 1
|
|
4016
|
+
if "covers-separator-collision" not in {name for name, _ in SCENARIOS}:
|
|
4017
|
+
report(
|
|
4018
|
+
"covers-separator-collision: the scenario registry does not include it."
|
|
4019
|
+
)
|
|
4020
|
+
return 1
|
|
4021
|
+
report("covers-separator-collision scenario passed.")
|
|
4022
|
+
return 0
|
|
4023
|
+
|
|
4024
|
+
|
|
4025
|
+
def validate_unresolved_authority_names_field_scenario() -> int:
|
|
4026
|
+
"""Issue #7 example 3: the diagnostic must name what it actually reads.
|
|
4027
|
+
|
|
4028
|
+
The check reads only the task's `Pre-authorized fallback:` line. The old
|
|
4029
|
+
wording said "documented design authority", which sent authors to design.md
|
|
4030
|
+
where the answer usually already was.
|
|
4031
|
+
"""
|
|
4032
|
+
with tempfile.TemporaryDirectory(prefix="keel-unresolved-authority-") as raw:
|
|
4033
|
+
repo = Path(raw)
|
|
4034
|
+
# design.md documents Q1 and an authorized fallback in prose, which is
|
|
4035
|
+
# exactly the state the reporter was in when the message misdirected.
|
|
4036
|
+
write_text(
|
|
4037
|
+
repo / "openspec/changes/demo/design.md",
|
|
4038
|
+
"## Questions\n\nQ1 — Should the widget retry on timeout?\n\n"
|
|
4039
|
+
"Authorized fallback: retry twice with backoff, then stop.\n",
|
|
4040
|
+
)
|
|
4041
|
+
without = task_capsule_compact_fixture().replace(
|
|
4042
|
+
" - E1: Public behavior passes.\n", " - Q1\n"
|
|
4043
|
+
)
|
|
4044
|
+
write_text(repo / "openspec/changes/demo/tasks.md", without)
|
|
4045
|
+
result = run_keel(
|
|
4046
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
4047
|
+
)
|
|
4048
|
+
payload = json.loads(result.stdout)
|
|
4049
|
+
messages = [
|
|
4050
|
+
problem.get("message", "")
|
|
4051
|
+
for problem in payload.get("problems", [])
|
|
4052
|
+
if problem.get("code") == "unresolved-authority"
|
|
4053
|
+
]
|
|
4054
|
+
if not messages:
|
|
4055
|
+
report(
|
|
4056
|
+
"unresolved-authority-names-field: a Q reference with no "
|
|
4057
|
+
"authorized fallback produced no unresolved-authority diagnostic."
|
|
4058
|
+
)
|
|
4059
|
+
report(result.stdout.strip())
|
|
4060
|
+
return 1
|
|
4061
|
+
message = messages[0]
|
|
4062
|
+
required = ("Autonomy boundary:", "Pre-authorized fallback:", "design.md")
|
|
4063
|
+
missing = [needle for needle in required if needle not in message]
|
|
4064
|
+
if missing:
|
|
4065
|
+
report(
|
|
4066
|
+
"unresolved-authority-names-field: the diagnostic omitted "
|
|
4067
|
+
f"{', '.join(missing)}."
|
|
4068
|
+
)
|
|
4069
|
+
report(message)
|
|
4070
|
+
return 1
|
|
4071
|
+
if "documented design authority" in message:
|
|
4072
|
+
report(
|
|
4073
|
+
"unresolved-authority-names-field: the diagnostic still points "
|
|
4074
|
+
"at design.md as the thing to add."
|
|
4075
|
+
)
|
|
4076
|
+
report(message)
|
|
4077
|
+
return 1
|
|
4078
|
+
# Doing literally what the message asks must clear it.
|
|
4079
|
+
with_fallback = without.replace(
|
|
4080
|
+
" - Pre-authorized fallback: none\n",
|
|
4081
|
+
" - Pre-authorized fallback: retry twice with backoff then stop;"
|
|
4082
|
+
" evidence is the retry log\n",
|
|
4083
|
+
)
|
|
4084
|
+
write_text(repo / "openspec/changes/fixed/tasks.md", with_fallback)
|
|
4085
|
+
write_text(
|
|
4086
|
+
repo / "openspec/changes/fixed/design.md",
|
|
4087
|
+
"## Questions\n\nQ1 — Should the widget retry on timeout?\n",
|
|
4088
|
+
)
|
|
4089
|
+
fixed = run_keel(
|
|
4090
|
+
repo, "gate", "task-start", "--change", "fixed", "--task", "1.1", "--json"
|
|
4091
|
+
)
|
|
4092
|
+
if fixed.returncode != 0:
|
|
4093
|
+
report(
|
|
4094
|
+
"unresolved-authority-names-field: following the diagnostic did "
|
|
4095
|
+
"not clear it."
|
|
4096
|
+
)
|
|
4097
|
+
report((fixed.stdout or fixed.stderr).strip())
|
|
4098
|
+
return 1
|
|
4099
|
+
if "unresolved-authority-names-field" not in {name for name, _ in SCENARIOS}:
|
|
4100
|
+
report(
|
|
4101
|
+
"unresolved-authority-names-field: the scenario registry does not "
|
|
4102
|
+
"include it."
|
|
4103
|
+
)
|
|
4104
|
+
return 1
|
|
4105
|
+
report("unresolved-authority-names-field scenario passed.")
|
|
4106
|
+
return 0
|
|
4107
|
+
|
|
4108
|
+
|
|
4109
|
+
def validate_dev_only_plugin_source_scoping_scenario() -> int:
|
|
4110
|
+
"""Issue #6: plugins/keel/ exists only in Keel's own repository.
|
|
4111
|
+
|
|
4112
|
+
In a consuming project the source check is permanently `missing` and the
|
|
4113
|
+
next line told the author to install a plugin they had just installed.
|
|
4114
|
+
"""
|
|
4115
|
+
with tempfile.TemporaryDirectory(prefix="keel-dev-only-scope-") as raw:
|
|
4116
|
+
consumer = Path(raw)
|
|
4117
|
+
init = run_keel(consumer, "--init", "--target", "claude")
|
|
4118
|
+
if init.returncode != 0:
|
|
4119
|
+
report("dev-only-plugin-source-scoping: keel --init failed.")
|
|
4120
|
+
report((init.stderr or init.stdout).strip())
|
|
4121
|
+
return 1
|
|
4122
|
+
doctor = run_keel(consumer, "--doctor")
|
|
4123
|
+
out = doctor.stdout or ""
|
|
4124
|
+
if "native plugin source" in out:
|
|
4125
|
+
report(
|
|
4126
|
+
"dev-only-plugin-source-scoping: a consuming project was still "
|
|
4127
|
+
"shown the development-only plugin source check."
|
|
4128
|
+
)
|
|
4129
|
+
report(out)
|
|
4130
|
+
return 1
|
|
4131
|
+
if "install the plugin if it is missing" in out:
|
|
4132
|
+
report(
|
|
4133
|
+
"dev-only-plugin-source-scoping: a consuming project was still "
|
|
4134
|
+
"told to install an already-installed plugin."
|
|
4135
|
+
)
|
|
4136
|
+
report(out)
|
|
4137
|
+
return 1
|
|
4138
|
+
if "plugin source" in out:
|
|
4139
|
+
report(
|
|
4140
|
+
"dev-only-plugin-source-scoping: the plugin source clause "
|
|
4141
|
+
"leaked into a consuming project's capability lines."
|
|
4142
|
+
)
|
|
4143
|
+
report(out)
|
|
4144
|
+
return 1
|
|
4145
|
+
# Keel's own repository must keep the check, which is where it means
|
|
4146
|
+
# something: the manifest and the CLI have to agree before release.
|
|
4147
|
+
own = run_keel(ROOT, "--doctor")
|
|
4148
|
+
if "native plugin source" not in (own.stdout or ""):
|
|
4149
|
+
report(
|
|
4150
|
+
"dev-only-plugin-source-scoping: Keel's own repository lost the "
|
|
4151
|
+
"plugin source check."
|
|
4152
|
+
)
|
|
4153
|
+
report((own.stdout or own.stderr).strip())
|
|
4154
|
+
return 1
|
|
4155
|
+
if "dev-only-plugin-source-scoping" not in {name for name, _ in SCENARIOS}:
|
|
4156
|
+
report(
|
|
4157
|
+
"dev-only-plugin-source-scoping: the scenario registry does not "
|
|
4158
|
+
"include it."
|
|
4159
|
+
)
|
|
4160
|
+
return 1
|
|
4161
|
+
report("dev-only-plugin-source-scoping scenario passed.")
|
|
4162
|
+
return 0
|
|
4163
|
+
|
|
4164
|
+
|
|
4165
|
+
def validate_source_repo_bootstrap_skip_scenario() -> int:
|
|
4166
|
+
"""Issue #9: `keel --install` must not overwrite Keel's own AGENTS.md.
|
|
4167
|
+
|
|
4168
|
+
Keel's repository AGENTS.md carries the full protocol that four scenarios
|
|
4169
|
+
assert on; the packaged asset is the shorter consumer bootstrap. Writing it
|
|
4170
|
+
here drops those sections and turns the repository red.
|
|
4171
|
+
"""
|
|
4172
|
+
managed = re.compile(
|
|
4173
|
+
r"<!--\s*keel:start.*?<!--\s*keel:end\s*-->", re.DOTALL
|
|
4174
|
+
)
|
|
4175
|
+
|
|
4176
|
+
def block(path: Path) -> str:
|
|
4177
|
+
found = managed.search(path.read_text(encoding="utf-8"))
|
|
4178
|
+
return found.group(0) if found else ""
|
|
4179
|
+
|
|
4180
|
+
own_agents = ROOT / "AGENTS.md"
|
|
4181
|
+
before = block(own_agents)
|
|
4182
|
+
if not before:
|
|
4183
|
+
report("source-repo-bootstrap-skip: Keel's AGENTS.md has no managed block.")
|
|
4184
|
+
return 1
|
|
4185
|
+
result = run_keel(ROOT, "--install", "--target", "claude")
|
|
4186
|
+
if result.returncode != 0:
|
|
4187
|
+
report("source-repo-bootstrap-skip: keel --install failed in Keel's repo.")
|
|
4188
|
+
report((result.stderr or result.stdout).strip())
|
|
4189
|
+
return 1
|
|
4190
|
+
if block(own_agents) != before:
|
|
4191
|
+
report(
|
|
4192
|
+
"source-repo-bootstrap-skip: keel --install rewrote Keel's own "
|
|
4193
|
+
"AGENTS.md managed block."
|
|
4194
|
+
)
|
|
4195
|
+
return 1
|
|
4196
|
+
if "skip AGENTS.md" not in (result.stdout or ""):
|
|
4197
|
+
report(
|
|
4198
|
+
"source-repo-bootstrap-skip: the skip was silent; it must be "
|
|
4199
|
+
"reported explicitly."
|
|
4200
|
+
)
|
|
4201
|
+
report((result.stdout or "").strip())
|
|
4202
|
+
return 1
|
|
4203
|
+
# A consuming project must still receive the bootstrap.
|
|
4204
|
+
with tempfile.TemporaryDirectory(prefix="keel-bootstrap-consumer-") as raw:
|
|
4205
|
+
consumer = Path(raw)
|
|
4206
|
+
installed = run_keel(consumer, "--install", "--target", "claude")
|
|
4207
|
+
if installed.returncode != 0:
|
|
4208
|
+
report(
|
|
4209
|
+
"source-repo-bootstrap-skip: keel --install failed in a "
|
|
4210
|
+
"consuming project."
|
|
4211
|
+
)
|
|
4212
|
+
report((installed.stderr or installed.stdout).strip())
|
|
4213
|
+
return 1
|
|
4214
|
+
asset_block = block(ROOT / "assets/bootstrap/AGENTS.md")
|
|
4215
|
+
if block(consumer / "AGENTS.md") != asset_block:
|
|
4216
|
+
report(
|
|
4217
|
+
"source-repo-bootstrap-skip: a consuming project did not "
|
|
4218
|
+
"receive the packaged bootstrap block."
|
|
4219
|
+
)
|
|
4220
|
+
return 1
|
|
4221
|
+
if "source-repo-bootstrap-skip" not in {name for name, _ in SCENARIOS}:
|
|
4222
|
+
report(
|
|
4223
|
+
"source-repo-bootstrap-skip: the scenario registry does not include it."
|
|
4224
|
+
)
|
|
4225
|
+
return 1
|
|
4226
|
+
report("source-repo-bootstrap-skip scenario passed.")
|
|
4227
|
+
return 0
|
|
4228
|
+
|
|
4229
|
+
|
|
4230
|
+
TRACKER_OWNER = "https://github.com/TanglmChris/keel/issues/12"
|
|
4231
|
+
|
|
4232
|
+
|
|
4233
|
+
def tracker_owner_tasks(findings: str, closure: str) -> str:
|
|
4234
|
+
"""One complete, checked task plus one Expectation Coverage closure line."""
|
|
4235
|
+
return (
|
|
4236
|
+
"# Tasks\n\n"
|
|
4237
|
+
"## Expectation Coverage\n\n"
|
|
4238
|
+
"- E1:\n"
|
|
4239
|
+
f" - {closure}\n\n"
|
|
4240
|
+
"## 1. Work\n\n"
|
|
4241
|
+
"- [x] 1.1 Complete behavior\n"
|
|
4242
|
+
" - Owner: keel-agent\n"
|
|
4243
|
+
" - Mode: implementation\n"
|
|
4244
|
+
" - Covers:\n"
|
|
4245
|
+
" - E1: public behavior\n"
|
|
4246
|
+
" - Read:\n"
|
|
4247
|
+
" - README.md\n"
|
|
4248
|
+
" - Touch:\n"
|
|
4249
|
+
" - src/feature.js\n"
|
|
4250
|
+
" - Commands:\n"
|
|
4251
|
+
" - M1: node test.js\n"
|
|
4252
|
+
" - Acceptance:\n"
|
|
4253
|
+
" - Public behavior passes.\n"
|
|
4254
|
+
" - Autonomy boundary:\n"
|
|
4255
|
+
" - Default: hard-stop\n"
|
|
4256
|
+
" - Pre-authorized fallback: none\n"
|
|
4257
|
+
" - Coupling: none\n"
|
|
4258
|
+
" - Candidate Boundary:\n"
|
|
4259
|
+
" - One candidate.\n"
|
|
4260
|
+
" - Stop Rules:\n"
|
|
4261
|
+
" - Stop on failure.\n"
|
|
4262
|
+
" - Evidence:\n"
|
|
4263
|
+
" - M1: passed\n"
|
|
4264
|
+
" - Review:\n"
|
|
4265
|
+
" - Status: pass\n"
|
|
4266
|
+
" - Acceptance check: reviewed\n"
|
|
4267
|
+
" - Scope check: reviewed\n"
|
|
4268
|
+
f" - Findings: {findings}\n"
|
|
4269
|
+
" - Blocker: none\n"
|
|
4270
|
+
" - Stop if:\n"
|
|
4271
|
+
" - Scope expands.\n"
|
|
4272
|
+
" - Report:\n"
|
|
4273
|
+
" - Summary\n"
|
|
4274
|
+
)
|
|
4275
|
+
|
|
4276
|
+
|
|
4277
|
+
def validate_tracker_durable_owner_scenario() -> int:
|
|
4278
|
+
"""Issue #12: an issue tracker is a durable follow-up owner.
|
|
4279
|
+
|
|
4280
|
+
The gate enumerated three repository-local forms, so a project whose
|
|
4281
|
+
declared follow-up owner is an issue tracker had to write a local note per
|
|
4282
|
+
finding whose only job was to be a shape the gate recognized. The tracker
|
|
4283
|
+
form is now accepted in both places a durable owner is required, and every
|
|
4284
|
+
previously accepted form still passes.
|
|
4285
|
+
"""
|
|
4286
|
+
with tempfile.TemporaryDirectory(prefix="keel-tracker-owner-") as raw:
|
|
4287
|
+
repo = Path(raw)
|
|
4288
|
+
tasks = repo / "openspec/changes/demo/tasks.md"
|
|
4289
|
+
write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
|
|
4290
|
+
write_text(
|
|
4291
|
+
repo / "openspec/changes/demo/specs/demo/spec.md",
|
|
4292
|
+
"## ADDED Requirements\n",
|
|
4293
|
+
)
|
|
4294
|
+
|
|
4295
|
+
def complete(findings: str, closure: str = "Covered by: 1.1"):
|
|
4296
|
+
write_text(tasks, tracker_owner_tasks(findings, closure))
|
|
4297
|
+
return run_keel(
|
|
4298
|
+
repo, "gate", "task-complete",
|
|
4299
|
+
"--change", "demo", "--task", "1.1", "--json",
|
|
4300
|
+
)
|
|
4301
|
+
|
|
4302
|
+
def close(closure: str):
|
|
4303
|
+
write_text(tasks, tracker_owner_tasks("none", closure))
|
|
4304
|
+
return run_keel(
|
|
4305
|
+
repo, "gate", "change-close",
|
|
4306
|
+
"--change", "demo", "--action", "sync", "--json",
|
|
4307
|
+
)
|
|
4308
|
+
|
|
4309
|
+
tracker = complete(f"stale local note; owner {TRACKER_OWNER}")
|
|
4310
|
+
if tracker.returncode != 0:
|
|
4311
|
+
report(
|
|
4312
|
+
"tracker-durable-owner: a finding owned by an absolute tracker "
|
|
4313
|
+
"reference was still refused."
|
|
4314
|
+
)
|
|
4315
|
+
report((tracker.stderr or tracker.stdout).strip())
|
|
4316
|
+
return 1
|
|
4317
|
+
|
|
4318
|
+
unowned = complete("stale local note that names no owner at all")
|
|
4319
|
+
unowned_payload = json.loads(unowned.stdout) if unowned.stdout else {}
|
|
4320
|
+
message = " ".join(
|
|
4321
|
+
problem.get("message", "")
|
|
4322
|
+
for problem in unowned_payload.get("problems", [])
|
|
4323
|
+
if problem.get("code") == "finding-owner"
|
|
4324
|
+
)
|
|
4325
|
+
if unowned.returncode != 3 or "http" not in message:
|
|
4326
|
+
report(
|
|
4327
|
+
"tracker-durable-owner: an unowned finding must still fail, and "
|
|
4328
|
+
"the error must list the tracker form among the accepted ones."
|
|
4329
|
+
)
|
|
4330
|
+
report((unowned.stderr or unowned.stdout).strip())
|
|
4331
|
+
return 1
|
|
4332
|
+
|
|
4333
|
+
handoff = complete("stale local note; owner keel/HANDOFF.md")
|
|
4334
|
+
if handoff.returncode != 3:
|
|
4335
|
+
report(
|
|
4336
|
+
"tracker-durable-owner: keel/HANDOFF.md must still be refused "
|
|
4337
|
+
"as a finding owner."
|
|
4338
|
+
)
|
|
4339
|
+
report((handoff.stderr or handoff.stdout).strip())
|
|
4340
|
+
return 1
|
|
4341
|
+
|
|
4342
|
+
archived = complete("stale local note; owner keel/archive/follow-ups/x.md")
|
|
4343
|
+
if archived.returncode != 0:
|
|
4344
|
+
report(
|
|
4345
|
+
"tracker-durable-owner: the pre-existing keel/archive owner form "
|
|
4346
|
+
"regressed."
|
|
4347
|
+
)
|
|
4348
|
+
report((archived.stderr or archived.stdout).strip())
|
|
4349
|
+
return 1
|
|
4350
|
+
|
|
4351
|
+
closed_by_tracker = close(f"Durable owner: {TRACKER_OWNER}")
|
|
4352
|
+
if closed_by_tracker.returncode != 0:
|
|
4353
|
+
report(
|
|
4354
|
+
"tracker-durable-owner: change-close refused an Expectation "
|
|
4355
|
+
"Coverage durable owner naming a tracker reference."
|
|
4356
|
+
)
|
|
4357
|
+
report((closed_by_tracker.stderr or closed_by_tracker.stdout).strip())
|
|
4358
|
+
return 1
|
|
4359
|
+
|
|
4360
|
+
closed_by_task = close("Covered by: 1.1")
|
|
4361
|
+
if closed_by_task.returncode != 0:
|
|
4362
|
+
report(
|
|
4363
|
+
"tracker-durable-owner: the pre-existing Covered by closure "
|
|
4364
|
+
"regressed at change-close."
|
|
4365
|
+
)
|
|
4366
|
+
report((closed_by_task.stderr or closed_by_task.stdout).strip())
|
|
4367
|
+
return 1
|
|
4368
|
+
|
|
4369
|
+
closed_unowned = close("pending")
|
|
4370
|
+
if closed_unowned.returncode != 3:
|
|
4371
|
+
report(
|
|
4372
|
+
"tracker-durable-owner: change-close must still reject an E1 "
|
|
4373
|
+
"with no coverage, owner, or discard reason."
|
|
4374
|
+
)
|
|
4375
|
+
report((closed_unowned.stderr or closed_unowned.stdout).strip())
|
|
4376
|
+
return 1
|
|
4377
|
+
|
|
4378
|
+
if "tracker-durable-owner" not in {name for name, _ in SCENARIOS}:
|
|
4379
|
+
report("tracker-durable-owner: the scenario registry does not include it.")
|
|
4380
|
+
return 1
|
|
4381
|
+
report("tracker-durable-owner scenario passed.")
|
|
4382
|
+
return 0
|
|
4383
|
+
|
|
4384
|
+
|
|
4385
|
+
def validate_guard_manifest_ignored_scenario() -> int:
|
|
4386
|
+
"""Issue #11: the guard manifest was written but declared ignorable nowhere.
|
|
4387
|
+
|
|
4388
|
+
Every gate run left an untracked `keel/guard.json` in the project, and
|
|
4389
|
+
because completion attributes working-tree paths against Touch, that is a
|
|
4390
|
+
permanent exception the author re-adjudicates at every completion.
|
|
4391
|
+
"""
|
|
4392
|
+
with tempfile.TemporaryDirectory(prefix="keel-guard-ignore-") as raw:
|
|
4393
|
+
project = Path(raw)
|
|
4394
|
+
init = run_keel(project, "--install", "--target", "claude")
|
|
4395
|
+
if init.returncode != 0:
|
|
4396
|
+
report("guard-manifest-ignored: keel --install failed.")
|
|
4397
|
+
report((init.stderr or init.stdout).strip())
|
|
4398
|
+
return 1
|
|
4399
|
+
ignore_path = project / "keel/.gitignore"
|
|
4400
|
+
if not ignore_path.is_file():
|
|
4401
|
+
report(
|
|
4402
|
+
"guard-manifest-ignored: keel --install did not scaffold "
|
|
4403
|
+
"keel/.gitignore."
|
|
4404
|
+
)
|
|
4405
|
+
return 1
|
|
4406
|
+
declared = ignore_path.read_text(encoding="utf-8")
|
|
4407
|
+
if "guard.json" not in declared:
|
|
4408
|
+
report(
|
|
4409
|
+
"guard-manifest-ignored: the scaffolded keel/.gitignore does "
|
|
4410
|
+
"not declare the guard manifest."
|
|
4411
|
+
)
|
|
4412
|
+
report(declared)
|
|
4413
|
+
return 1
|
|
4414
|
+
|
|
4415
|
+
# git must actually honour the declaration: initialize a repository,
|
|
4416
|
+
# write a manifest through a passing task-start, and confirm the path
|
|
4417
|
+
# never appears in porcelain status.
|
|
4418
|
+
if subprocess.run(
|
|
4419
|
+
["git", "init", "--quiet"], cwd=project, capture_output=True
|
|
4420
|
+
).returncode != 0:
|
|
4421
|
+
report("guard-manifest-ignored: git init failed in the fixture.")
|
|
4422
|
+
return 1
|
|
4423
|
+
write_text(
|
|
4424
|
+
project / "openspec/changes/demo/tasks.md",
|
|
4425
|
+
tracker_owner_tasks("none", "Covered by: 1.1").replace(
|
|
4426
|
+
"- [x] 1.1", "- [ ] 1.1"
|
|
4427
|
+
),
|
|
4428
|
+
)
|
|
4429
|
+
started = run_keel(
|
|
4430
|
+
project, "gate", "task-start",
|
|
4431
|
+
"--change", "demo", "--task", "1.1", "--json",
|
|
4432
|
+
)
|
|
4433
|
+
if started.returncode != 0 or not (project / "keel/guard.json").is_file():
|
|
4434
|
+
report(
|
|
4435
|
+
"guard-manifest-ignored: the fixture did not produce a guard "
|
|
4436
|
+
"manifest to test the declaration against."
|
|
4437
|
+
)
|
|
4438
|
+
report((started.stderr or started.stdout).strip())
|
|
4439
|
+
return 1
|
|
4440
|
+
status = subprocess.run(
|
|
4441
|
+
["git", "status", "--short", "--untracked-files=all"],
|
|
4442
|
+
cwd=project, capture_output=True, encoding="utf-8",
|
|
4443
|
+
)
|
|
4444
|
+
if "guard.json" in (status.stdout or ""):
|
|
4445
|
+
report(
|
|
4446
|
+
"guard-manifest-ignored: git still reports the guard manifest "
|
|
4447
|
+
"after a gate run, so the declaration does not take effect."
|
|
4448
|
+
)
|
|
4449
|
+
report(status.stdout or "")
|
|
4450
|
+
return 1
|
|
4451
|
+
|
|
4452
|
+
# Scaffold once: a project's own file is never rewritten.
|
|
4453
|
+
own = "# mine\nguard.json\nscratch/\n"
|
|
4454
|
+
write_text(ignore_path, own)
|
|
4455
|
+
again = run_keel(project, "--install", "--target", "claude")
|
|
4456
|
+
if again.returncode != 0 or ignore_path.read_text(encoding="utf-8") != own:
|
|
4457
|
+
report(
|
|
4458
|
+
"guard-manifest-ignored: a second install overwrote the "
|
|
4459
|
+
"project's own keel/.gitignore."
|
|
4460
|
+
)
|
|
4461
|
+
report((again.stderr or again.stdout).strip())
|
|
4462
|
+
return 1
|
|
4463
|
+
|
|
4464
|
+
if not (ROOT / "keel/.gitignore").is_file():
|
|
4465
|
+
report(
|
|
4466
|
+
"guard-manifest-ignored: Keel's own repository does not declare "
|
|
4467
|
+
"the guard manifest ignorable."
|
|
4468
|
+
)
|
|
4469
|
+
return 1
|
|
4470
|
+
if "guard-manifest-ignored" not in {name for name, _ in SCENARIOS}:
|
|
4471
|
+
report("guard-manifest-ignored: the scenario registry does not include it.")
|
|
4472
|
+
return 1
|
|
4473
|
+
report("guard-manifest-ignored scenario passed.")
|
|
4474
|
+
return 0
|
|
4475
|
+
|
|
4476
|
+
|
|
4477
|
+
def validate_guard_status_is_not_enforcement_scenario() -> int:
|
|
4478
|
+
"""Issue #14: `Guard: started` means the manifest was written.
|
|
4479
|
+
|
|
4480
|
+
Authors read it as "writes are being checked now". The two came apart when a
|
|
4481
|
+
session kept plugin state that predated a marketplace switch: the manifest
|
|
4482
|
+
was valid and keel's PreToolUse hook never ran. Keel cannot observe the hook
|
|
4483
|
+
from the repository, so the result must say so rather than imply otherwise.
|
|
4484
|
+
"""
|
|
4485
|
+
needles = ("enforcement", "runtime hook", "cannot observe")
|
|
4486
|
+
# Assertive claims only: the honest sentence legitimately denies that a
|
|
4487
|
+
# written manifest proves anything, so a bare "writes are checked" substring
|
|
4488
|
+
# would match the denial itself.
|
|
4489
|
+
forbidden = (
|
|
4490
|
+
"enforcement is active",
|
|
4491
|
+
"enforcement is live",
|
|
4492
|
+
"writes are guarded",
|
|
4493
|
+
)
|
|
4494
|
+
with tempfile.TemporaryDirectory(prefix="keel-guard-honesty-") as raw:
|
|
4495
|
+
project = Path(raw)
|
|
4496
|
+
write_text(
|
|
4497
|
+
project / "openspec/changes/demo/tasks.md",
|
|
4498
|
+
tracker_owner_tasks("none", "Covered by: 1.1").replace(
|
|
4499
|
+
"- [x] 1.1", "- [ ] 1.1"
|
|
4500
|
+
),
|
|
4501
|
+
)
|
|
4502
|
+
started = run_keel(
|
|
4503
|
+
project, "guard", "start",
|
|
4504
|
+
"--change", "demo", "--task", "1.1", "--json",
|
|
4505
|
+
)
|
|
4506
|
+
if started.returncode != 0:
|
|
4507
|
+
report("guard-status-is-not-enforcement: keel guard start failed.")
|
|
4508
|
+
report((started.stderr or started.stdout).strip())
|
|
4509
|
+
return 1
|
|
4510
|
+
for label, result in (
|
|
4511
|
+
("start", started),
|
|
4512
|
+
(
|
|
4513
|
+
"status",
|
|
4514
|
+
run_keel(project, "guard", "status", "--json"),
|
|
4515
|
+
),
|
|
4516
|
+
):
|
|
4517
|
+
payload = json.loads(result.stdout) if result.stdout else {}
|
|
4518
|
+
warnings = " ".join(payload.get("warnings", []))
|
|
4519
|
+
missing = [needle for needle in needles if needle not in warnings]
|
|
4520
|
+
if missing:
|
|
4521
|
+
report(
|
|
4522
|
+
f"guard-status-is-not-enforcement: keel guard {label} does "
|
|
4523
|
+
"not state that the status describes the manifest and that "
|
|
4524
|
+
"enforcement depends on a runtime hook Keel cannot observe; "
|
|
4525
|
+
f"missing {missing}."
|
|
4526
|
+
)
|
|
4527
|
+
report(result.stdout or result.stderr or "")
|
|
4528
|
+
return 1
|
|
4529
|
+
if not payload.get("status"):
|
|
4530
|
+
report(
|
|
4531
|
+
f"guard-status-is-not-enforcement: keel guard {label} lost "
|
|
4532
|
+
"its status value."
|
|
4533
|
+
)
|
|
4534
|
+
return 1
|
|
4535
|
+
human = run_keel(
|
|
4536
|
+
project,
|
|
4537
|
+
"guard",
|
|
4538
|
+
*(("start", "--change", "demo", "--task", "1.1", "--force")
|
|
4539
|
+
if label == "start" else ("status",)),
|
|
4540
|
+
)
|
|
4541
|
+
if "runtime hook" not in (human.stdout or ""):
|
|
4542
|
+
report(
|
|
4543
|
+
f"guard-status-is-not-enforcement: the human-readable keel "
|
|
4544
|
+
f"guard {label} output omits the enforcement boundary."
|
|
4545
|
+
)
|
|
4546
|
+
report(human.stdout or human.stderr or "")
|
|
4547
|
+
return 1
|
|
4548
|
+
for phrase in forbidden:
|
|
4549
|
+
if phrase in warnings or phrase in (human.stdout or ""):
|
|
4550
|
+
report(
|
|
4551
|
+
f"guard-status-is-not-enforcement: keel guard {label} "
|
|
4552
|
+
f"asserts observed enforcement: {phrase!r}."
|
|
4553
|
+
)
|
|
4554
|
+
return 1
|
|
4555
|
+
# The pre-existing durability statement must survive alongside it.
|
|
4556
|
+
status_payload = json.loads(
|
|
4557
|
+
run_keel(project, "guard", "status", "--json").stdout
|
|
4558
|
+
)
|
|
4559
|
+
if not any(
|
|
4560
|
+
"durable authority" in warning
|
|
4561
|
+
for warning in status_payload.get("warnings", [])
|
|
4562
|
+
):
|
|
4563
|
+
report(
|
|
4564
|
+
"guard-status-is-not-enforcement: the existing durable-authority "
|
|
4565
|
+
"statement was dropped."
|
|
4566
|
+
)
|
|
4567
|
+
return 1
|
|
4568
|
+
if "guard-status-is-not-enforcement" not in {name for name, _ in SCENARIOS}:
|
|
4569
|
+
report(
|
|
4570
|
+
"guard-status-is-not-enforcement: the scenario registry does not "
|
|
4571
|
+
"include it."
|
|
4572
|
+
)
|
|
4573
|
+
return 1
|
|
4574
|
+
report("guard-status-is-not-enforcement scenario passed.")
|
|
4575
|
+
return 0
|
|
4576
|
+
|
|
4577
|
+
|
|
4578
|
+
def validate_source_repo_cli_resolution_scenario() -> int:
|
|
4579
|
+
"""Issue #13 item 3: a bare `keel` runs the installed package.
|
|
4580
|
+
|
|
4581
|
+
When the repository under change *is* Keel, gate commands verify the
|
|
4582
|
+
installed CLI rather than the working tree, and the failure mode is a
|
|
4583
|
+
silently stale result rather than an error. One dogfood round was lost to
|
|
4584
|
+
two spurious problems reported by a stale global CLI.
|
|
4585
|
+
"""
|
|
4586
|
+
own = run_keel(ROOT, "--doctor")
|
|
4587
|
+
out = own.stdout or ""
|
|
4588
|
+
if "node bin/keel.js" not in out:
|
|
4589
|
+
report(
|
|
4590
|
+
"source-repo-cli-resolution: Keel's own repository is not told to "
|
|
4591
|
+
"run gate commands through its own entry point."
|
|
4592
|
+
)
|
|
4593
|
+
report(out)
|
|
4594
|
+
return 1
|
|
4595
|
+
# The local entry point needs an explicit repository argument; an author
|
|
4596
|
+
# who copies the hint without it gets an unrelated failure.
|
|
4597
|
+
hint = next(
|
|
4598
|
+
(line for line in out.splitlines() if "node bin/keel.js" in line), ""
|
|
4599
|
+
)
|
|
4600
|
+
# The line must be advisory: it describes a hazard, not a repository
|
|
4601
|
+
# failure, so it must never push doctor toward a non-ok verdict.
|
|
4602
|
+
for needle in ("gate", ".", "advisory"):
|
|
4603
|
+
if needle not in hint:
|
|
4604
|
+
report(
|
|
4605
|
+
"source-repo-cli-resolution: the hint does not name a usable "
|
|
4606
|
+
f"invocation, or is not advisory; missing {needle!r} in: {hint}"
|
|
4607
|
+
)
|
|
4608
|
+
return 1
|
|
4609
|
+
with tempfile.TemporaryDirectory(prefix="keel-cli-resolution-") as raw:
|
|
4610
|
+
consumer = Path(raw)
|
|
4611
|
+
init = run_keel(consumer, "--init", "--target", "claude")
|
|
4612
|
+
if init.returncode != 0:
|
|
4613
|
+
report("source-repo-cli-resolution: keel --init failed.")
|
|
4614
|
+
report((init.stderr or init.stdout).strip())
|
|
4615
|
+
return 1
|
|
4616
|
+
consumer_doctor = run_keel(consumer, "--doctor")
|
|
4617
|
+
if "node bin/keel.js" in (consumer_doctor.stdout or ""):
|
|
4618
|
+
report(
|
|
4619
|
+
"source-repo-cli-resolution: a consuming project was shown a "
|
|
4620
|
+
"hazard that exists only in Keel's own repository."
|
|
4621
|
+
)
|
|
4622
|
+
report(consumer_doctor.stdout or "")
|
|
4623
|
+
return 1
|
|
4624
|
+
if "source-repo-cli-resolution" not in {name for name, _ in SCENARIOS}:
|
|
4625
|
+
report(
|
|
4626
|
+
"source-repo-cli-resolution: the scenario registry does not "
|
|
4627
|
+
"include it."
|
|
4628
|
+
)
|
|
4629
|
+
return 1
|
|
4630
|
+
report("source-repo-cli-resolution scenario passed.")
|
|
4631
|
+
return 0
|
|
4632
|
+
|
|
4633
|
+
|
|
3716
4634
|
def validate_task_capsule_scenario() -> int:
|
|
3717
4635
|
with tempfile.TemporaryDirectory(prefix="keel-task-capsule-") as raw_tmp:
|
|
3718
4636
|
repo = Path(raw_tmp)
|
|
@@ -4887,14 +5805,19 @@ def validate_core_gates_scenario() -> int:
|
|
|
4887
5805
|
(completion_repo / "openspec/changes/other/proposal.md").unlink()
|
|
4888
5806
|
(completion_repo / "openspec/specs/demo-spec/spec.md").unlink()
|
|
4889
5807
|
|
|
4890
|
-
# Explicit --record replaces
|
|
4891
|
-
#
|
|
4892
|
-
#
|
|
5808
|
+
# Explicit --record replaces the selected task's Contract anchor
|
|
5809
|
+
# whatever it currently holds, so reauthorizing a task whose authority
|
|
5810
|
+
# changed needs no manual edit; a no-op re-record writes nothing, only
|
|
5811
|
+
# a missing anchor refuses, and without the flag the gate stays
|
|
5812
|
+
# read-only.
|
|
4893
5813
|
record_repo = root / "record-anchor"
|
|
4894
5814
|
record_repo.mkdir()
|
|
4895
5815
|
record_tasks = record_repo / "openspec/changes/demo/tasks.md"
|
|
4896
5816
|
|
|
4897
|
-
def record_task(anchor: str) -> str:
|
|
5817
|
+
def record_task(anchor: str, extra_touch: bool = False) -> str:
|
|
5818
|
+
touch = " - src/feature.js\n"
|
|
5819
|
+
if extra_touch:
|
|
5820
|
+
touch += " - src/extra.js\n"
|
|
4898
5821
|
return (
|
|
4899
5822
|
"# Tasks\n\n"
|
|
4900
5823
|
"- [ ] 1.1 Record behavior\n"
|
|
@@ -4905,8 +5828,8 @@ def validate_core_gates_scenario() -> int:
|
|
|
4905
5828
|
" - Read:\n"
|
|
4906
5829
|
" - README.md\n"
|
|
4907
5830
|
" - Touch:\n"
|
|
4908
|
-
|
|
4909
|
-
" - openspec/changes/demo/tasks.md\n"
|
|
5831
|
+
+ touch
|
|
5832
|
+
+ " - openspec/changes/demo/tasks.md\n"
|
|
4910
5833
|
" - Commands:\n"
|
|
4911
5834
|
" - M1: node test.js\n"
|
|
4912
5835
|
" - Acceptance:\n"
|
|
@@ -4951,8 +5874,15 @@ def validate_core_gates_scenario() -> int:
|
|
|
4951
5874
|
report("core-gates scenario --record on a pending anchor failed.")
|
|
4952
5875
|
report((recorded.stderr or recorded.stdout).strip())
|
|
4953
5876
|
return 1
|
|
5877
|
+
recorded_result = json.loads(recorded.stdout)
|
|
5878
|
+
if recorded_result.get("record", {}).get("status") != "recorded":
|
|
5879
|
+
report(
|
|
5880
|
+
"core-gates scenario --record over a pending anchor must "
|
|
5881
|
+
"report the outcome as recorded."
|
|
5882
|
+
)
|
|
5883
|
+
return 1
|
|
4954
5884
|
fingerprint = (
|
|
4955
|
-
|
|
5885
|
+
recorded_result
|
|
4956
5886
|
.get("contract", {})
|
|
4957
5887
|
.get("fingerprint", {})
|
|
4958
5888
|
.get("value", "")
|
|
@@ -4993,23 +5923,67 @@ def validate_core_gates_scenario() -> int:
|
|
|
4993
5923
|
)
|
|
4994
5924
|
return 1
|
|
4995
5925
|
|
|
4996
|
-
|
|
5926
|
+
before_noop = record_tasks.read_bytes()
|
|
5927
|
+
noop = run_keel(
|
|
5928
|
+
record_repo, "gate", "task-start",
|
|
5929
|
+
"--change", "demo", "--task", "1.1", "--no-guard", "--record",
|
|
5930
|
+
"--json",
|
|
5931
|
+
)
|
|
5932
|
+
noop_result = json.loads(noop.stdout) if noop.stdout else {}
|
|
5933
|
+
if (
|
|
5934
|
+
noop.returncode != 0
|
|
5935
|
+
or noop_result.get("record", {}).get("status") != "unchanged"
|
|
5936
|
+
or noop_result.get("warnings")
|
|
5937
|
+
or record_tasks.read_bytes() != before_noop
|
|
5938
|
+
):
|
|
5939
|
+
report(
|
|
5940
|
+
"core-gates scenario --record over an anchor that already "
|
|
5941
|
+
"carries the compiled fingerprint must report unchanged, "
|
|
5942
|
+
"warn about nothing, and write nothing."
|
|
5943
|
+
)
|
|
5944
|
+
report((noop.stderr or noop.stdout).strip())
|
|
5945
|
+
return 1
|
|
5946
|
+
|
|
5947
|
+
# Reauthorization: the task authority changes, so the recorded anchor
|
|
5948
|
+
# is now stale and --record must replace it rather than refuse.
|
|
5949
|
+
anchor_line = f"keel-task-capsule/v1 sha256:{fingerprint}"
|
|
5950
|
+
write_text(record_tasks, record_task(anchor_line, extra_touch=True))
|
|
5951
|
+
before_rerecord = record_tasks.read_text(encoding="utf-8").splitlines()
|
|
4997
5952
|
rerecord = run_keel(
|
|
4998
5953
|
record_repo, "gate", "task-start",
|
|
4999
5954
|
"--change", "demo", "--task", "1.1", "--no-guard", "--record",
|
|
5000
5955
|
"--json",
|
|
5001
5956
|
)
|
|
5957
|
+
rerecord_result = json.loads(rerecord.stdout) if rerecord.stdout else {}
|
|
5958
|
+
new_fingerprint = (
|
|
5959
|
+
rerecord_result.get("contract", {}).get("fingerprint", {}).get("value", "")
|
|
5960
|
+
)
|
|
5961
|
+
after_rerecord = record_tasks.read_text(encoding="utf-8").splitlines()
|
|
5962
|
+
rerecord_changed = [
|
|
5963
|
+
(old, new)
|
|
5964
|
+
for old, new in zip(before_rerecord, after_rerecord)
|
|
5965
|
+
if old != new
|
|
5966
|
+
]
|
|
5002
5967
|
if (
|
|
5003
|
-
rerecord.returncode !=
|
|
5968
|
+
rerecord.returncode != 0
|
|
5969
|
+
or rerecord_result.get("record", {}).get("status") != "rerecorded"
|
|
5970
|
+
or fingerprint not in rerecord_result.get("record", {}).get("previous", "")
|
|
5971
|
+
or not new_fingerprint
|
|
5972
|
+
or new_fingerprint == fingerprint
|
|
5973
|
+
or len(before_rerecord) != len(after_rerecord)
|
|
5974
|
+
or len(rerecord_changed) != 1
|
|
5975
|
+
or rerecord_changed[0][1]
|
|
5976
|
+
!= f" - Contract: keel-task-capsule/v1 sha256:{new_fingerprint}"
|
|
5004
5977
|
or not any(
|
|
5005
|
-
|
|
5006
|
-
for
|
|
5978
|
+
fingerprint in warning
|
|
5979
|
+
for warning in rerecord_result.get("warnings", [])
|
|
5007
5980
|
)
|
|
5008
|
-
or record_tasks.read_bytes() != before_refusal
|
|
5009
5981
|
):
|
|
5010
5982
|
report(
|
|
5011
|
-
"core-gates scenario --record
|
|
5012
|
-
"must
|
|
5983
|
+
"core-gates scenario --record over a stale recorded anchor "
|
|
5984
|
+
"must replace exactly that line with the new fingerprint, "
|
|
5985
|
+
"report the outcome as rerecorded with the replaced value, "
|
|
5986
|
+
"and warn naming the fingerprint it replaced."
|
|
5013
5987
|
)
|
|
5014
5988
|
report((rerecord.stderr or rerecord.stdout).strip())
|
|
5015
5989
|
return 1
|
|
@@ -9110,12 +10084,16 @@ def validate_thin_native_install_scenario() -> int:
|
|
|
9110
10084
|
report(uncertain_text.strip())
|
|
9111
10085
|
return 1
|
|
9112
10086
|
|
|
9113
|
-
# --- Case E: doctor reports the
|
|
10087
|
+
# --- Case E: doctor reports the native plugin runtime with remediation ---
|
|
10088
|
+
# These repos consume Keel, so the runtime line and its install
|
|
10089
|
+
# remediation must appear, while the plugin *source* check must not:
|
|
10090
|
+
# plugins/keel/ exists only in Keel's own repository and `keel --init`
|
|
10091
|
+
# never creates it, so reporting it here is a permanent unactionable
|
|
10092
|
+
# `missing`. See the dev-only-plugin-source-scoping scenario.
|
|
9114
10093
|
doctor = run_keel(repo, "--doctor")
|
|
9115
10094
|
doctor_text = (doctor.stdout or "") + (doctor.stderr or "")
|
|
9116
10095
|
if (
|
|
9117
|
-
"native plugin
|
|
9118
|
-
or "native plugin runtime" not in doctor_text
|
|
10096
|
+
"native plugin runtime" not in doctor_text
|
|
9119
10097
|
or "keel@<marketplace>" not in doctor_text
|
|
9120
10098
|
):
|
|
9121
10099
|
report(
|
|
@@ -9124,14 +10102,21 @@ def validate_thin_native_install_scenario() -> int:
|
|
|
9124
10102
|
)
|
|
9125
10103
|
report(doctor_text.strip())
|
|
9126
10104
|
return 1
|
|
10105
|
+
if "native plugin source" in doctor_text:
|
|
10106
|
+
report(
|
|
10107
|
+
"thin-native-install doctor reported the development-only "
|
|
10108
|
+
"plugin source check in a consuming project."
|
|
10109
|
+
)
|
|
10110
|
+
report(doctor_text.strip())
|
|
10111
|
+
return 1
|
|
9127
10112
|
missing_repo = tmp / "no-plugin"
|
|
9128
10113
|
missing_repo.mkdir()
|
|
9129
10114
|
missing_doctor = run_keel(missing_repo, "--doctor")
|
|
9130
10115
|
missing_text = (missing_doctor.stdout or "") + (missing_doctor.stderr or "")
|
|
9131
|
-
if "plugin source
|
|
10116
|
+
if "plugin source" in missing_text:
|
|
9132
10117
|
report(
|
|
9133
|
-
"thin-native-install doctor
|
|
9134
|
-
"
|
|
10118
|
+
"thin-native-install doctor diagnosed a plugin source outside "
|
|
10119
|
+
"Keel's own repository."
|
|
9135
10120
|
)
|
|
9136
10121
|
report(missing_text.strip())
|
|
9137
10122
|
return 1
|
|
@@ -10400,6 +11385,31 @@ SCENARIOS: tuple = (
|
|
|
10400
11385
|
("fast-pre-push-hooks", validate_fast_pre_push_hooks_scenario),
|
|
10401
11386
|
("fast-pre-push-doctor", validate_fast_pre_push_doctor_scenario),
|
|
10402
11387
|
("verify-layer-tag", validate_verify_layer_tag_scenario),
|
|
11388
|
+
(
|
|
11389
|
+
"non-concrete-verify-diagnostic",
|
|
11390
|
+
validate_non_concrete_verify_diagnostic_scenario,
|
|
11391
|
+
),
|
|
11392
|
+
("inline-code-is-concrete", validate_inline_code_is_concrete_scenario),
|
|
11393
|
+
("covers-separator-collision", validate_covers_separator_collision_scenario),
|
|
11394
|
+
(
|
|
11395
|
+
"unresolved-authority-names-field",
|
|
11396
|
+
validate_unresolved_authority_names_field_scenario,
|
|
11397
|
+
),
|
|
11398
|
+
(
|
|
11399
|
+
"dev-only-plugin-source-scoping",
|
|
11400
|
+
validate_dev_only_plugin_source_scoping_scenario,
|
|
11401
|
+
),
|
|
11402
|
+
(
|
|
11403
|
+
"source-repo-bootstrap-skip",
|
|
11404
|
+
validate_source_repo_bootstrap_skip_scenario,
|
|
11405
|
+
),
|
|
11406
|
+
("tracker-durable-owner", validate_tracker_durable_owner_scenario),
|
|
11407
|
+
("guard-manifest-ignored", validate_guard_manifest_ignored_scenario),
|
|
11408
|
+
(
|
|
11409
|
+
"guard-status-is-not-enforcement",
|
|
11410
|
+
validate_guard_status_is_not_enforcement_scenario,
|
|
11411
|
+
),
|
|
11412
|
+
("source-repo-cli-resolution", validate_source_repo_cli_resolution_scenario),
|
|
10403
11413
|
("task-contract-core", validate_task_contract_core_scenario),
|
|
10404
11414
|
("task-capsule", validate_task_capsule_scenario),
|
|
10405
11415
|
("task-verification-strategies", validate_task_verification_strategies_scenario),
|