@christang/keel 5.2.1 → 5.2.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/README.md +27 -0
- package/README.zh-CN.md +25 -0
- package/assets/bootstrap/AGENTS.md +1 -1
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +5 -1
- package/bin/keel.js +76 -3
- 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 +177 -1
- package/scripts/validate_plugin.py +917 -22
- package/src/core/capabilities.js +25 -5
- package/src/core/gates.js +50 -18
- package/src/core/task-contract.js +141 -18
|
@@ -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.3"
|
|
41
|
+
PROTOCOL_VERSION = "5.2.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")
|
|
@@ -3713,6 +3713,520 @@ 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
|
+
|
|
3716
4230
|
def validate_task_capsule_scenario() -> int:
|
|
3717
4231
|
with tempfile.TemporaryDirectory(prefix="keel-task-capsule-") as raw_tmp:
|
|
3718
4232
|
repo = Path(raw_tmp)
|
|
@@ -4887,14 +5401,19 @@ def validate_core_gates_scenario() -> int:
|
|
|
4887
5401
|
(completion_repo / "openspec/changes/other/proposal.md").unlink()
|
|
4888
5402
|
(completion_repo / "openspec/specs/demo-spec/spec.md").unlink()
|
|
4889
5403
|
|
|
4890
|
-
# Explicit --record replaces
|
|
4891
|
-
#
|
|
4892
|
-
#
|
|
5404
|
+
# Explicit --record replaces the selected task's Contract anchor
|
|
5405
|
+
# whatever it currently holds, so reauthorizing a task whose authority
|
|
5406
|
+
# changed needs no manual edit; a no-op re-record writes nothing, only
|
|
5407
|
+
# a missing anchor refuses, and without the flag the gate stays
|
|
5408
|
+
# read-only.
|
|
4893
5409
|
record_repo = root / "record-anchor"
|
|
4894
5410
|
record_repo.mkdir()
|
|
4895
5411
|
record_tasks = record_repo / "openspec/changes/demo/tasks.md"
|
|
4896
5412
|
|
|
4897
|
-
def record_task(anchor: str) -> str:
|
|
5413
|
+
def record_task(anchor: str, extra_touch: bool = False) -> str:
|
|
5414
|
+
touch = " - src/feature.js\n"
|
|
5415
|
+
if extra_touch:
|
|
5416
|
+
touch += " - src/extra.js\n"
|
|
4898
5417
|
return (
|
|
4899
5418
|
"# Tasks\n\n"
|
|
4900
5419
|
"- [ ] 1.1 Record behavior\n"
|
|
@@ -4905,8 +5424,8 @@ def validate_core_gates_scenario() -> int:
|
|
|
4905
5424
|
" - Read:\n"
|
|
4906
5425
|
" - README.md\n"
|
|
4907
5426
|
" - Touch:\n"
|
|
4908
|
-
|
|
4909
|
-
" - openspec/changes/demo/tasks.md\n"
|
|
5427
|
+
+ touch
|
|
5428
|
+
+ " - openspec/changes/demo/tasks.md\n"
|
|
4910
5429
|
" - Commands:\n"
|
|
4911
5430
|
" - M1: node test.js\n"
|
|
4912
5431
|
" - Acceptance:\n"
|
|
@@ -4951,8 +5470,15 @@ def validate_core_gates_scenario() -> int:
|
|
|
4951
5470
|
report("core-gates scenario --record on a pending anchor failed.")
|
|
4952
5471
|
report((recorded.stderr or recorded.stdout).strip())
|
|
4953
5472
|
return 1
|
|
5473
|
+
recorded_result = json.loads(recorded.stdout)
|
|
5474
|
+
if recorded_result.get("record", {}).get("status") != "recorded":
|
|
5475
|
+
report(
|
|
5476
|
+
"core-gates scenario --record over a pending anchor must "
|
|
5477
|
+
"report the outcome as recorded."
|
|
5478
|
+
)
|
|
5479
|
+
return 1
|
|
4954
5480
|
fingerprint = (
|
|
4955
|
-
|
|
5481
|
+
recorded_result
|
|
4956
5482
|
.get("contract", {})
|
|
4957
5483
|
.get("fingerprint", {})
|
|
4958
5484
|
.get("value", "")
|
|
@@ -4993,23 +5519,67 @@ def validate_core_gates_scenario() -> int:
|
|
|
4993
5519
|
)
|
|
4994
5520
|
return 1
|
|
4995
5521
|
|
|
4996
|
-
|
|
5522
|
+
before_noop = record_tasks.read_bytes()
|
|
5523
|
+
noop = run_keel(
|
|
5524
|
+
record_repo, "gate", "task-start",
|
|
5525
|
+
"--change", "demo", "--task", "1.1", "--no-guard", "--record",
|
|
5526
|
+
"--json",
|
|
5527
|
+
)
|
|
5528
|
+
noop_result = json.loads(noop.stdout) if noop.stdout else {}
|
|
5529
|
+
if (
|
|
5530
|
+
noop.returncode != 0
|
|
5531
|
+
or noop_result.get("record", {}).get("status") != "unchanged"
|
|
5532
|
+
or noop_result.get("warnings")
|
|
5533
|
+
or record_tasks.read_bytes() != before_noop
|
|
5534
|
+
):
|
|
5535
|
+
report(
|
|
5536
|
+
"core-gates scenario --record over an anchor that already "
|
|
5537
|
+
"carries the compiled fingerprint must report unchanged, "
|
|
5538
|
+
"warn about nothing, and write nothing."
|
|
5539
|
+
)
|
|
5540
|
+
report((noop.stderr or noop.stdout).strip())
|
|
5541
|
+
return 1
|
|
5542
|
+
|
|
5543
|
+
# Reauthorization: the task authority changes, so the recorded anchor
|
|
5544
|
+
# is now stale and --record must replace it rather than refuse.
|
|
5545
|
+
anchor_line = f"keel-task-capsule/v1 sha256:{fingerprint}"
|
|
5546
|
+
write_text(record_tasks, record_task(anchor_line, extra_touch=True))
|
|
5547
|
+
before_rerecord = record_tasks.read_text(encoding="utf-8").splitlines()
|
|
4997
5548
|
rerecord = run_keel(
|
|
4998
5549
|
record_repo, "gate", "task-start",
|
|
4999
5550
|
"--change", "demo", "--task", "1.1", "--no-guard", "--record",
|
|
5000
5551
|
"--json",
|
|
5001
5552
|
)
|
|
5553
|
+
rerecord_result = json.loads(rerecord.stdout) if rerecord.stdout else {}
|
|
5554
|
+
new_fingerprint = (
|
|
5555
|
+
rerecord_result.get("contract", {}).get("fingerprint", {}).get("value", "")
|
|
5556
|
+
)
|
|
5557
|
+
after_rerecord = record_tasks.read_text(encoding="utf-8").splitlines()
|
|
5558
|
+
rerecord_changed = [
|
|
5559
|
+
(old, new)
|
|
5560
|
+
for old, new in zip(before_rerecord, after_rerecord)
|
|
5561
|
+
if old != new
|
|
5562
|
+
]
|
|
5002
5563
|
if (
|
|
5003
|
-
rerecord.returncode !=
|
|
5564
|
+
rerecord.returncode != 0
|
|
5565
|
+
or rerecord_result.get("record", {}).get("status") != "rerecorded"
|
|
5566
|
+
or fingerprint not in rerecord_result.get("record", {}).get("previous", "")
|
|
5567
|
+
or not new_fingerprint
|
|
5568
|
+
or new_fingerprint == fingerprint
|
|
5569
|
+
or len(before_rerecord) != len(after_rerecord)
|
|
5570
|
+
or len(rerecord_changed) != 1
|
|
5571
|
+
or rerecord_changed[0][1]
|
|
5572
|
+
!= f" - Contract: keel-task-capsule/v1 sha256:{new_fingerprint}"
|
|
5004
5573
|
or not any(
|
|
5005
|
-
|
|
5006
|
-
for
|
|
5574
|
+
fingerprint in warning
|
|
5575
|
+
for warning in rerecord_result.get("warnings", [])
|
|
5007
5576
|
)
|
|
5008
|
-
or record_tasks.read_bytes() != before_refusal
|
|
5009
5577
|
):
|
|
5010
5578
|
report(
|
|
5011
|
-
"core-gates scenario --record
|
|
5012
|
-
"must
|
|
5579
|
+
"core-gates scenario --record over a stale recorded anchor "
|
|
5580
|
+
"must replace exactly that line with the new fingerprint, "
|
|
5581
|
+
"report the outcome as rerecorded with the replaced value, "
|
|
5582
|
+
"and warn naming the fingerprint it replaced."
|
|
5013
5583
|
)
|
|
5014
5584
|
report((rerecord.stderr or rerecord.stdout).strip())
|
|
5015
5585
|
return 1
|
|
@@ -7687,6 +8257,297 @@ def validate_native_tasks_view_scenario() -> int:
|
|
|
7687
8257
|
return 0
|
|
7688
8258
|
|
|
7689
8259
|
|
|
8260
|
+
def validate_verification_layering_docs_scenario() -> int:
|
|
8261
|
+
en = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
8262
|
+
for needle in (
|
|
8263
|
+
"## Verification layering",
|
|
8264
|
+
"inner-loop",
|
|
8265
|
+
"Full gate",
|
|
8266
|
+
"pre-push",
|
|
8267
|
+
"change-close",
|
|
8268
|
+
):
|
|
8269
|
+
if needle not in en:
|
|
8270
|
+
report(
|
|
8271
|
+
"verification-layering-docs: README.md lacks the fast/full "
|
|
8272
|
+
f"verification split marker: {needle}"
|
|
8273
|
+
)
|
|
8274
|
+
return 1
|
|
8275
|
+
|
|
8276
|
+
zh = (ROOT / "README.zh-CN.md").read_text(encoding="utf-8")
|
|
8277
|
+
for needle in ("## 验证分层", "快速内环", "全量门禁", "pre-push", "change-close"):
|
|
8278
|
+
if needle not in zh:
|
|
8279
|
+
report(
|
|
8280
|
+
"verification-layering-docs: README.zh-CN.md lacks the fast/full "
|
|
8281
|
+
f"verification split marker: {needle}"
|
|
8282
|
+
)
|
|
8283
|
+
return 1
|
|
8284
|
+
|
|
8285
|
+
if "verification-layering-docs" not in {name for name, _ in SCENARIOS}:
|
|
8286
|
+
report("verification-layering-docs: the scenario registry does not include it.")
|
|
8287
|
+
return 1
|
|
8288
|
+
|
|
8289
|
+
report("verification-layering-docs scenario passed.")
|
|
8290
|
+
return 0
|
|
8291
|
+
|
|
8292
|
+
|
|
8293
|
+
def validate_fast_check_config_scaffold_scenario() -> int:
|
|
8294
|
+
with tempfile.TemporaryDirectory(prefix="keel-fastcfg-") as raw_tmp:
|
|
8295
|
+
repo = Path(raw_tmp)
|
|
8296
|
+
first = run_keel(repo, "--install")
|
|
8297
|
+
if first.returncode != 0:
|
|
8298
|
+
report("fast-check-config-scaffold: keel --install failed.")
|
|
8299
|
+
report((first.stderr or first.stdout).strip())
|
|
8300
|
+
return 1
|
|
8301
|
+
|
|
8302
|
+
config_path = repo / "keel" / "config.yaml"
|
|
8303
|
+
if not config_path.is_file():
|
|
8304
|
+
report("fast-check-config-scaffold: install did not scaffold keel/config.yaml.")
|
|
8305
|
+
return 1
|
|
8306
|
+
scaffolded = config_path.read_text(encoding="utf-8")
|
|
8307
|
+
for needle in ("fast_check", "keel gate change-close", "--with-git-hooks"):
|
|
8308
|
+
if needle not in scaffolded:
|
|
8309
|
+
report(
|
|
8310
|
+
"fast-check-config-scaffold: scaffolded keel/config.yaml lacks the "
|
|
8311
|
+
f"fast_check guidance marker: {needle}"
|
|
8312
|
+
)
|
|
8313
|
+
return 1
|
|
8314
|
+
|
|
8315
|
+
# A project's own edits to keel/config.yaml must survive re-install.
|
|
8316
|
+
edited = "fast_check: pytest -m 'not slow' -q\n"
|
|
8317
|
+
config_path.write_text(edited, encoding="utf-8")
|
|
8318
|
+
second = run_keel(repo, "--install")
|
|
8319
|
+
if second.returncode != 0:
|
|
8320
|
+
report("fast-check-config-scaffold: second keel --install failed.")
|
|
8321
|
+
report((second.stderr or second.stdout).strip())
|
|
8322
|
+
return 1
|
|
8323
|
+
if config_path.read_text(encoding="utf-8") != edited:
|
|
8324
|
+
report(
|
|
8325
|
+
"fast-check-config-scaffold: re-install overwrote an existing "
|
|
8326
|
+
"keel/config.yaml."
|
|
8327
|
+
)
|
|
8328
|
+
return 1
|
|
8329
|
+
|
|
8330
|
+
report("fast-check-config-scaffold scenario passed.")
|
|
8331
|
+
return 0
|
|
8332
|
+
|
|
8333
|
+
|
|
8334
|
+
def validate_fast_pre_push_hooks_scenario() -> int:
|
|
8335
|
+
def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
8336
|
+
return subprocess.run(
|
|
8337
|
+
["git", "-C", str(repo), *args], capture_output=True, text=True
|
|
8338
|
+
)
|
|
8339
|
+
|
|
8340
|
+
def init_repo(root: Path, name: str) -> Path:
|
|
8341
|
+
repo = root / name
|
|
8342
|
+
repo.mkdir()
|
|
8343
|
+
git(repo, "init", "-q")
|
|
8344
|
+
git(repo, "config", "user.email", "t@example.com")
|
|
8345
|
+
git(repo, "config", "user.name", "keel-test")
|
|
8346
|
+
return repo
|
|
8347
|
+
|
|
8348
|
+
def declare_fast_check(repo: Path, command: str) -> None:
|
|
8349
|
+
(repo / "keel").mkdir(exist_ok=True)
|
|
8350
|
+
(repo / "keel" / "config.yaml").write_text(
|
|
8351
|
+
f"fast_check: {command}\n", encoding="utf-8"
|
|
8352
|
+
)
|
|
8353
|
+
|
|
8354
|
+
def hooks_path(repo: Path) -> str | None:
|
|
8355
|
+
got = git(repo, "config", "--local", "--get", "core.hooksPath")
|
|
8356
|
+
return got.stdout.strip() if got.returncode == 0 else None
|
|
8357
|
+
|
|
8358
|
+
with tempfile.TemporaryDirectory(prefix="keel-prepush-") as raw_tmp:
|
|
8359
|
+
root = Path(raw_tmp)
|
|
8360
|
+
|
|
8361
|
+
# 1. A declared fast_check generates the hook and sets hooksPath.
|
|
8362
|
+
declared = init_repo(root, "declared")
|
|
8363
|
+
declare_fast_check(declared, "echo fast-check-ran")
|
|
8364
|
+
res = run_keel(declared, "--install", "--with-git-hooks")
|
|
8365
|
+
if res.returncode != 0:
|
|
8366
|
+
report("fast-pre-push-hooks: --with-git-hooks failed with a declared fast_check.")
|
|
8367
|
+
report((res.stderr or res.stdout).strip())
|
|
8368
|
+
return 1
|
|
8369
|
+
hook = declared / ".githooks" / "pre-push"
|
|
8370
|
+
if not hook.is_file():
|
|
8371
|
+
report("fast-pre-push-hooks: --with-git-hooks did not write .githooks/pre-push.")
|
|
8372
|
+
return 1
|
|
8373
|
+
hook_text = hook.read_text(encoding="utf-8")
|
|
8374
|
+
if not hook_text.startswith("#!/bin/sh") or "echo fast-check-ran" not in hook_text:
|
|
8375
|
+
report("fast-pre-push-hooks: pre-push does not run the declared fast_check under sh.")
|
|
8376
|
+
report(hook_text)
|
|
8377
|
+
return 1
|
|
8378
|
+
if hooks_path(declared) != ".githooks":
|
|
8379
|
+
report("fast-pre-push-hooks: --with-git-hooks did not set core.hooksPath to .githooks.")
|
|
8380
|
+
return 1
|
|
8381
|
+
|
|
8382
|
+
# 2. A plain install touches neither the hook nor git config.
|
|
8383
|
+
plain = init_repo(root, "plain")
|
|
8384
|
+
declare_fast_check(plain, "echo plain")
|
|
8385
|
+
if run_keel(plain, "--install").returncode != 0:
|
|
8386
|
+
report("fast-pre-push-hooks: plain install failed.")
|
|
8387
|
+
return 1
|
|
8388
|
+
if (plain / ".githooks" / "pre-push").exists():
|
|
8389
|
+
report("fast-pre-push-hooks: plain install wrote a pre-push hook.")
|
|
8390
|
+
return 1
|
|
8391
|
+
if hooks_path(plain) is not None:
|
|
8392
|
+
report("fast-pre-push-hooks: plain install set core.hooksPath.")
|
|
8393
|
+
return 1
|
|
8394
|
+
|
|
8395
|
+
# 3. Without a declared fast_check the flag refuses and writes nothing.
|
|
8396
|
+
undeclared = init_repo(root, "undeclared")
|
|
8397
|
+
res = run_keel(undeclared, "--install", "--with-git-hooks")
|
|
8398
|
+
if res.returncode == 0:
|
|
8399
|
+
report("fast-pre-push-hooks: --with-git-hooks did not refuse without a fast_check.")
|
|
8400
|
+
return 1
|
|
8401
|
+
if (undeclared / ".githooks" / "pre-push").exists():
|
|
8402
|
+
report("fast-pre-push-hooks: a refusal still wrote a pre-push hook.")
|
|
8403
|
+
return 1
|
|
8404
|
+
if hooks_path(undeclared) is not None:
|
|
8405
|
+
report("fast-pre-push-hooks: a refusal still set core.hooksPath.")
|
|
8406
|
+
return 1
|
|
8407
|
+
|
|
8408
|
+
# 4a. Uninstall reverts a keel-set core.hooksPath.
|
|
8409
|
+
if run_keel(declared, "--uninstall").returncode != 0:
|
|
8410
|
+
report("fast-pre-push-hooks: uninstall failed.")
|
|
8411
|
+
return 1
|
|
8412
|
+
if hooks_path(declared) is not None:
|
|
8413
|
+
report("fast-pre-push-hooks: uninstall did not unset a keel-set core.hooksPath.")
|
|
8414
|
+
return 1
|
|
8415
|
+
|
|
8416
|
+
# 4b. Uninstall leaves a non-.githooks core.hooksPath untouched.
|
|
8417
|
+
custom = init_repo(root, "custom")
|
|
8418
|
+
git(custom, "config", "--local", "core.hooksPath", ".customhooks")
|
|
8419
|
+
if run_keel(custom, "--uninstall").returncode != 0:
|
|
8420
|
+
report("fast-pre-push-hooks: uninstall failed on a custom hooksPath repo.")
|
|
8421
|
+
return 1
|
|
8422
|
+
if hooks_path(custom) != ".customhooks":
|
|
8423
|
+
report("fast-pre-push-hooks: uninstall clobbered a non-keel core.hooksPath.")
|
|
8424
|
+
return 1
|
|
8425
|
+
|
|
8426
|
+
report("fast-pre-push-hooks scenario passed.")
|
|
8427
|
+
return 0
|
|
8428
|
+
|
|
8429
|
+
|
|
8430
|
+
def validate_fast_pre_push_doctor_scenario() -> int:
|
|
8431
|
+
def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
8432
|
+
return subprocess.run(
|
|
8433
|
+
["git", "-C", str(repo), *args], capture_output=True, text=True
|
|
8434
|
+
)
|
|
8435
|
+
|
|
8436
|
+
def init_repo(root: Path, name: str) -> Path:
|
|
8437
|
+
repo = root / name
|
|
8438
|
+
repo.mkdir()
|
|
8439
|
+
git(repo, "init", "-q")
|
|
8440
|
+
git(repo, "config", "user.email", "t@example.com")
|
|
8441
|
+
git(repo, "config", "user.name", "keel-test")
|
|
8442
|
+
return repo
|
|
8443
|
+
|
|
8444
|
+
with tempfile.TemporaryDirectory(prefix="keel-prepush-doc-") as raw_tmp:
|
|
8445
|
+
root = Path(raw_tmp)
|
|
8446
|
+
|
|
8447
|
+
# Surface active: fast_check declared and --with-git-hooks applied.
|
|
8448
|
+
active = init_repo(root, "active")
|
|
8449
|
+
(active / "keel").mkdir()
|
|
8450
|
+
(active / "keel" / "config.yaml").write_text(
|
|
8451
|
+
"fast_check: echo doc-check\n", encoding="utf-8"
|
|
8452
|
+
)
|
|
8453
|
+
if run_keel(active, "--install", "--with-git-hooks").returncode != 0:
|
|
8454
|
+
report("fast-pre-push-doctor: install --with-git-hooks failed.")
|
|
8455
|
+
return 1
|
|
8456
|
+
before = git(active, "config", "--local", "--get", "core.hooksPath").stdout.strip()
|
|
8457
|
+
out = run_keel(active, "--doctor").stdout
|
|
8458
|
+
for needle in (
|
|
8459
|
+
"Fast pre-push surface:",
|
|
8460
|
+
"fast_check: ok",
|
|
8461
|
+
"echo doc-check",
|
|
8462
|
+
"pre-push hook: ok",
|
|
8463
|
+
"core.hooksPath: ok",
|
|
8464
|
+
):
|
|
8465
|
+
if needle not in out:
|
|
8466
|
+
report(f"fast-pre-push-doctor: active-surface doctor output lacks: {needle}")
|
|
8467
|
+
report(out)
|
|
8468
|
+
return 1
|
|
8469
|
+
after = git(active, "config", "--local", "--get", "core.hooksPath").stdout.strip()
|
|
8470
|
+
if before != after:
|
|
8471
|
+
report("fast-pre-push-doctor: doctor mutated core.hooksPath.")
|
|
8472
|
+
return 1
|
|
8473
|
+
|
|
8474
|
+
# Surface absent: plain install, no fast_check, no hook.
|
|
8475
|
+
absent = init_repo(root, "absent")
|
|
8476
|
+
if run_keel(absent, "--install").returncode != 0:
|
|
8477
|
+
report("fast-pre-push-doctor: plain install failed.")
|
|
8478
|
+
return 1
|
|
8479
|
+
out = run_keel(absent, "--doctor").stdout
|
|
8480
|
+
for needle in ("fast_check: none", "pre-push hook: none", "core.hooksPath: unset"):
|
|
8481
|
+
if needle not in out:
|
|
8482
|
+
report(f"fast-pre-push-doctor: absent-surface doctor output lacks: {needle}")
|
|
8483
|
+
report(out)
|
|
8484
|
+
return 1
|
|
8485
|
+
|
|
8486
|
+
report("fast-pre-push-doctor scenario passed.")
|
|
8487
|
+
return 0
|
|
8488
|
+
|
|
8489
|
+
|
|
8490
|
+
def validate_verify_layer_tag_scenario() -> int:
|
|
8491
|
+
fixture = (
|
|
8492
|
+
"# Tasks\n\n"
|
|
8493
|
+
"- [ ] 1.1 Exercise the verification-layer tag\n"
|
|
8494
|
+
" - Covers:\n"
|
|
8495
|
+
" - E1: Public behavior passes.\n"
|
|
8496
|
+
" - Read:\n"
|
|
8497
|
+
" - README.md\n"
|
|
8498
|
+
" - Touch:\n"
|
|
8499
|
+
" - src/feature.js\n"
|
|
8500
|
+
" - Verify:\n"
|
|
8501
|
+
" - Strategy: evidence-first\n"
|
|
8502
|
+
" - M1 (fast): node fast.js\n"
|
|
8503
|
+
" - M2: node full.js\n"
|
|
8504
|
+
" - Autonomy boundary:\n"
|
|
8505
|
+
" - Default: hard-stop\n"
|
|
8506
|
+
" - Pre-authorized fallback: none\n"
|
|
8507
|
+
" - Stop Rules:\n"
|
|
8508
|
+
" - Stop on failure.\n"
|
|
8509
|
+
" - Evidence:\n"
|
|
8510
|
+
" - M1: pending\n"
|
|
8511
|
+
" - M2: pending\n"
|
|
8512
|
+
" - Stop if:\n"
|
|
8513
|
+
" - Requires files outside Touch.\n"
|
|
8514
|
+
)
|
|
8515
|
+
with tempfile.TemporaryDirectory(prefix="keel-verify-layer-") as raw_tmp:
|
|
8516
|
+
repo = Path(raw_tmp)
|
|
8517
|
+
write_text(repo / "openspec/changes/demo/tasks.md", fixture)
|
|
8518
|
+
started = run_keel(
|
|
8519
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
8520
|
+
)
|
|
8521
|
+
if started.returncode != 0:
|
|
8522
|
+
report("verify-layer-tag: task-start rejected the tagged fixture.")
|
|
8523
|
+
report((started.stderr or started.stdout).strip())
|
|
8524
|
+
return 1
|
|
8525
|
+
capsule = json.loads(started.stdout).get("contract", {}).get("capsule", {})
|
|
8526
|
+
commands = capsule.get("verification", {}).get("commands", [])
|
|
8527
|
+
by_label = {c.get("label"): c for c in commands}
|
|
8528
|
+
if by_label.get("M1", {}).get("layer") != "fast":
|
|
8529
|
+
report("verify-layer-tag: the (fast)-tagged check did not compile with layer fast.")
|
|
8530
|
+
report(json.dumps(commands))
|
|
8531
|
+
return 1
|
|
8532
|
+
if "layer" in by_label.get("M2", {}):
|
|
8533
|
+
report(
|
|
8534
|
+
"verify-layer-tag: an untagged check emitted a layer field; full is "
|
|
8535
|
+
"the implicit default and must stay off the capsule."
|
|
8536
|
+
)
|
|
8537
|
+
report(json.dumps(commands))
|
|
8538
|
+
return 1
|
|
8539
|
+
if (
|
|
8540
|
+
by_label.get("M1", {}).get("check") != "node fast.js"
|
|
8541
|
+
or by_label.get("M2", {}).get("check") != "node full.js"
|
|
8542
|
+
):
|
|
8543
|
+
report("verify-layer-tag: the layer tag altered the check text.")
|
|
8544
|
+
report(json.dumps(commands))
|
|
8545
|
+
return 1
|
|
8546
|
+
|
|
8547
|
+
report("verify-layer-tag scenario passed.")
|
|
8548
|
+
return 0
|
|
8549
|
+
|
|
8550
|
+
|
|
7690
8551
|
def validate_native_goal_gate_order_scenario() -> int:
|
|
7691
8552
|
with tempfile.TemporaryDirectory(prefix="keel-goal-order-") as raw_tmp:
|
|
7692
8553
|
root = Path(raw_tmp)
|
|
@@ -8819,12 +9680,16 @@ def validate_thin_native_install_scenario() -> int:
|
|
|
8819
9680
|
report(uncertain_text.strip())
|
|
8820
9681
|
return 1
|
|
8821
9682
|
|
|
8822
|
-
# --- Case E: doctor reports the
|
|
9683
|
+
# --- Case E: doctor reports the native plugin runtime with remediation ---
|
|
9684
|
+
# These repos consume Keel, so the runtime line and its install
|
|
9685
|
+
# remediation must appear, while the plugin *source* check must not:
|
|
9686
|
+
# plugins/keel/ exists only in Keel's own repository and `keel --init`
|
|
9687
|
+
# never creates it, so reporting it here is a permanent unactionable
|
|
9688
|
+
# `missing`. See the dev-only-plugin-source-scoping scenario.
|
|
8823
9689
|
doctor = run_keel(repo, "--doctor")
|
|
8824
9690
|
doctor_text = (doctor.stdout or "") + (doctor.stderr or "")
|
|
8825
9691
|
if (
|
|
8826
|
-
"native plugin
|
|
8827
|
-
or "native plugin runtime" not in doctor_text
|
|
9692
|
+
"native plugin runtime" not in doctor_text
|
|
8828
9693
|
or "keel@<marketplace>" not in doctor_text
|
|
8829
9694
|
):
|
|
8830
9695
|
report(
|
|
@@ -8833,14 +9698,21 @@ def validate_thin_native_install_scenario() -> int:
|
|
|
8833
9698
|
)
|
|
8834
9699
|
report(doctor_text.strip())
|
|
8835
9700
|
return 1
|
|
9701
|
+
if "native plugin source" in doctor_text:
|
|
9702
|
+
report(
|
|
9703
|
+
"thin-native-install doctor reported the development-only "
|
|
9704
|
+
"plugin source check in a consuming project."
|
|
9705
|
+
)
|
|
9706
|
+
report(doctor_text.strip())
|
|
9707
|
+
return 1
|
|
8836
9708
|
missing_repo = tmp / "no-plugin"
|
|
8837
9709
|
missing_repo.mkdir()
|
|
8838
9710
|
missing_doctor = run_keel(missing_repo, "--doctor")
|
|
8839
9711
|
missing_text = (missing_doctor.stdout or "") + (missing_doctor.stderr or "")
|
|
8840
|
-
if "plugin source
|
|
9712
|
+
if "plugin source" in missing_text:
|
|
8841
9713
|
report(
|
|
8842
|
-
"thin-native-install doctor
|
|
8843
|
-
"
|
|
9714
|
+
"thin-native-install doctor diagnosed a plugin source outside "
|
|
9715
|
+
"Keel's own repository."
|
|
8844
9716
|
)
|
|
8845
9717
|
report(missing_text.strip())
|
|
8846
9718
|
return 1
|
|
@@ -10104,6 +10976,29 @@ SCENARIOS: tuple = (
|
|
|
10104
10976
|
("doctor-openspec-honesty", validate_doctor_openspec_honesty_scenario),
|
|
10105
10977
|
("update-pack-install", validate_update_pack_install_scenario),
|
|
10106
10978
|
("update-default-registry", validate_update_default_registry_scenario),
|
|
10979
|
+
("verification-layering-docs", validate_verification_layering_docs_scenario),
|
|
10980
|
+
("fast-check-config-scaffold", validate_fast_check_config_scaffold_scenario),
|
|
10981
|
+
("fast-pre-push-hooks", validate_fast_pre_push_hooks_scenario),
|
|
10982
|
+
("fast-pre-push-doctor", validate_fast_pre_push_doctor_scenario),
|
|
10983
|
+
("verify-layer-tag", validate_verify_layer_tag_scenario),
|
|
10984
|
+
(
|
|
10985
|
+
"non-concrete-verify-diagnostic",
|
|
10986
|
+
validate_non_concrete_verify_diagnostic_scenario,
|
|
10987
|
+
),
|
|
10988
|
+
("inline-code-is-concrete", validate_inline_code_is_concrete_scenario),
|
|
10989
|
+
("covers-separator-collision", validate_covers_separator_collision_scenario),
|
|
10990
|
+
(
|
|
10991
|
+
"unresolved-authority-names-field",
|
|
10992
|
+
validate_unresolved_authority_names_field_scenario,
|
|
10993
|
+
),
|
|
10994
|
+
(
|
|
10995
|
+
"dev-only-plugin-source-scoping",
|
|
10996
|
+
validate_dev_only_plugin_source_scoping_scenario,
|
|
10997
|
+
),
|
|
10998
|
+
(
|
|
10999
|
+
"source-repo-bootstrap-skip",
|
|
11000
|
+
validate_source_repo_bootstrap_skip_scenario,
|
|
11001
|
+
),
|
|
10107
11002
|
("task-contract-core", validate_task_contract_core_scenario),
|
|
10108
11003
|
("task-capsule", validate_task_capsule_scenario),
|
|
10109
11004
|
("task-verification-strategies", validate_task_verification_strategies_scenario),
|