@christang/keel 5.3.6 → 5.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/bootstrap/AGENTS.md +2 -2
- package/assets/openspec/schemas/keel-spec-driven/schema.yaml +9 -3
- package/assets/openspec/schemas/keel-spec-driven/templates/spec.md +3 -1
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +31 -1
- 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/validate_plugin.py +1051 -7
- package/src/core/gates.js +68 -5
- package/src/core/task-contract.js +78 -29
|
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
|
|
|
37
37
|
"scripts/validate_plugin.py",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
-
PACKAGE_VERSION = "5.3.
|
|
41
|
-
PROTOCOL_VERSION = "5.3.
|
|
40
|
+
PACKAGE_VERSION = "5.3.8"
|
|
41
|
+
PROTOCOL_VERSION = "5.3.8"
|
|
42
42
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
43
43
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
44
44
|
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
@@ -1307,7 +1307,11 @@ def validate_expectation_slice_gates_scenario() -> int:
|
|
|
1307
1307
|
"Covers:",
|
|
1308
1308
|
"source expectation",
|
|
1309
1309
|
"D<n>/F<n>/A<n>/Q<n>",
|
|
1310
|
-
"unresolved Q<n>"
|
|
1310
|
+
# The rule is no longer "an unresolved Q<n> requires a fallback"
|
|
1311
|
+
# unconditionally: the identifier blocks only where it opens the entry,
|
|
1312
|
+
# so a resolved question can be cited beside the fact that closed it.
|
|
1313
|
+
"OPENS an entry",
|
|
1314
|
+
"requires an authorized fallback",
|
|
1311
1315
|
]
|
|
1312
1316
|
design_template_snippets = [
|
|
1313
1317
|
"D<n>",
|
|
@@ -3505,6 +3509,11 @@ def task_contract_fixture(
|
|
|
3505
3509
|
" - Stop Rules:\n"
|
|
3506
3510
|
" - Stop on failure.\n"
|
|
3507
3511
|
" - Evidence:\n"
|
|
3512
|
+
# Emitted unconditionally rather than through `evidence`, which callers
|
|
3513
|
+
# override to control the M-entries. Completion requires a recorded
|
|
3514
|
+
# fingerprint, and `--record` rewrites this line in place, so a caller
|
|
3515
|
+
# that customizes its evidence must not be able to drop the anchor.
|
|
3516
|
+
" - Contract: pending\n"
|
|
3508
3517
|
f"{evidence_lines}"
|
|
3509
3518
|
" - Review:\n"
|
|
3510
3519
|
" - Status: pending\n"
|
|
@@ -3541,7 +3550,7 @@ def invalidation_repo(root: Path, name: str, section: str | None) -> Path:
|
|
|
3541
3550
|
# A real Contract anchor, so the missing-section case can prove that a
|
|
3542
3551
|
# failing authoring gate leaves the anchor untouched rather than merely
|
|
3543
3552
|
# failing earlier for want of one.
|
|
3544
|
-
body = task_contract_fixture(
|
|
3553
|
+
body = task_contract_fixture()
|
|
3545
3554
|
body = body.replace(INVALIDATES_NONE, "" if section is None else section)
|
|
3546
3555
|
write_text(repo / "openspec/changes/demo/tasks.md", body)
|
|
3547
3556
|
return repo
|
|
@@ -3777,7 +3786,7 @@ def validate_anchor_reverification_bound_scenario() -> int:
|
|
|
3777
3786
|
repo = Path(raw_tmp) / "repo"
|
|
3778
3787
|
repo.mkdir()
|
|
3779
3788
|
live = repo / "openspec/changes/demo/tasks.md"
|
|
3780
|
-
write_text(live, task_contract_fixture(
|
|
3789
|
+
write_text(live, task_contract_fixture())
|
|
3781
3790
|
|
|
3782
3791
|
recorded = run_keel(
|
|
3783
3792
|
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
@@ -3989,6 +3998,7 @@ def validate_durable_owner_vocabulary_scenario() -> int:
|
|
|
3989
3998
|
)
|
|
3990
3999
|
)
|
|
3991
4000
|
write_text(tasks_path, fixture)
|
|
4001
|
+
record_contract_anchor(repo, "demo")
|
|
3992
4002
|
result = run_keel(
|
|
3993
4003
|
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
|
|
3994
4004
|
)
|
|
@@ -4134,6 +4144,7 @@ def validate_regression_check_tag_scenario() -> int:
|
|
|
4134
4144
|
|
|
4135
4145
|
def complete(fixture: str) -> dict:
|
|
4136
4146
|
write_text(tasks_path, fixture)
|
|
4147
|
+
record_contract_anchor(repo, "demo")
|
|
4137
4148
|
result = run_keel(
|
|
4138
4149
|
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
|
|
4139
4150
|
)
|
|
@@ -4650,12 +4661,39 @@ def task_capsule_compact_fixture() -> str:
|
|
|
4650
4661
|
" - Stop Rules:\n"
|
|
4651
4662
|
" - Stop on failure.\n"
|
|
4652
4663
|
" - Evidence:\n"
|
|
4664
|
+
# Completion requires a recorded fingerprint here, so the anchor line
|
|
4665
|
+
# must exist for `--record` to rewrite in place. See
|
|
4666
|
+
# record_contract_anchor.
|
|
4667
|
+
" - Contract: pending\n"
|
|
4653
4668
|
" - M1: pending\n"
|
|
4654
4669
|
" - Stop if:\n"
|
|
4655
4670
|
" - Requires files outside Touch.\n"
|
|
4656
4671
|
)
|
|
4657
4672
|
|
|
4658
4673
|
|
|
4674
|
+
def record_contract_anchor(repo: Path, change: str, task: str = "1.1") -> bool:
|
|
4675
|
+
"""Run `task-start --record` so a fixture can reach completion.
|
|
4676
|
+
|
|
4677
|
+
Completion refuses a task whose `Contract` anchor holds no compiled
|
|
4678
|
+
fingerprint (issue #30), so a scenario that only wanted to exercise
|
|
4679
|
+
task-complete still has to start the task first. That is the real loop, not
|
|
4680
|
+
a workaround: a task that was never started is not a task being completed.
|
|
4681
|
+
"""
|
|
4682
|
+
result = run_keel(
|
|
4683
|
+
repo,
|
|
4684
|
+
"gate",
|
|
4685
|
+
"task-start",
|
|
4686
|
+
"--change",
|
|
4687
|
+
change,
|
|
4688
|
+
"--task",
|
|
4689
|
+
task,
|
|
4690
|
+
"--record",
|
|
4691
|
+
"--no-guard",
|
|
4692
|
+
"--json",
|
|
4693
|
+
)
|
|
4694
|
+
return result.returncode == 0
|
|
4695
|
+
|
|
4696
|
+
|
|
4659
4697
|
def validate_non_concrete_verify_diagnostic_scenario() -> int:
|
|
4660
4698
|
"""A compact v4 task whose Verify carries an unfilled token must be told so.
|
|
4661
4699
|
|
|
@@ -4729,8 +4767,10 @@ def validate_non_concrete_verify_diagnostic_scenario() -> int:
|
|
|
4729
4767
|
)
|
|
4730
4768
|
report(started.stdout.strip())
|
|
4731
4769
|
return 1
|
|
4732
|
-
# A task with no Verify at all
|
|
4733
|
-
#
|
|
4770
|
+
# A task with no Verify at all must not be reported as carrying a token
|
|
4771
|
+
# it never wrote. What it is reported as instead — one missing
|
|
4772
|
+
# verification form rather than the expanded v3 set — belongs to the
|
|
4773
|
+
# absent-verification-form-is-one-problem scenario.
|
|
4734
4774
|
bare = task_capsule_compact_fixture()
|
|
4735
4775
|
for block in (
|
|
4736
4776
|
" - Verify:\n - Strategy: evidence-first\n - M1: node test.js\n",
|
|
@@ -5049,6 +5089,957 @@ def validate_unresolved_authority_names_field_scenario() -> int:
|
|
|
5049
5089
|
return 0
|
|
5050
5090
|
|
|
5051
5091
|
|
|
5092
|
+
SPEC_TEMPLATE_RELATIVE = "schemas/keel-spec-driven/templates/spec.md"
|
|
5093
|
+
|
|
5094
|
+
|
|
5095
|
+
SLOT_FILLER = "the recorded feed status"
|
|
5096
|
+
SLOT_VOCABULARY = {"<strategy>": "evidence-first"}
|
|
5097
|
+
|
|
5098
|
+
|
|
5099
|
+
def fill_template_slots(text: str, comments: str = "strip") -> str:
|
|
5100
|
+
"""Fill a shipped template's author-facing slots the way an author would.
|
|
5101
|
+
|
|
5102
|
+
Deliberately mechanical, so a slot added to the template later is handled
|
|
5103
|
+
without touching the scenario: what is asserted is the template's structure
|
|
5104
|
+
rather than a hand-maintained copy of it.
|
|
5105
|
+
|
|
5106
|
+
The two templates use HTML comments for different jobs, so the caller says
|
|
5107
|
+
which. In the spec template a comment *is* the slot — the requirement name
|
|
5108
|
+
and body are both comments — so it is replaced. In the tasks template
|
|
5109
|
+
comments are instructions to the author sitting on their own lines, and the
|
|
5110
|
+
slots are angle-bracket runs, so the comments are stripped.
|
|
5111
|
+
"""
|
|
5112
|
+
if comments == "replace":
|
|
5113
|
+
text = re.sub(r"<!--[\s\S]*?-->", SLOT_FILLER, text)
|
|
5114
|
+
else:
|
|
5115
|
+
# An own-line comment is an instruction to the author, so it goes. A
|
|
5116
|
+
# comment with text before it on the line is that line's slot — a task
|
|
5117
|
+
# title, a group name — and stripping it leaves a task line the parser
|
|
5118
|
+
# cannot read, so it is filled like any other slot.
|
|
5119
|
+
text = re.sub(r"^[ \t]*<!--[\s\S]*?-->[ \t]*\r?\n", "", text, flags=re.M)
|
|
5120
|
+
text = re.sub(r"<!--[\s\S]*?-->", SLOT_FILLER, text)
|
|
5121
|
+
# A slot whose value comes from a fixed vocabulary needs a member of it, so
|
|
5122
|
+
# those are named. Everything else takes the generic filler.
|
|
5123
|
+
for slot, value in SLOT_VOCABULARY.items():
|
|
5124
|
+
text = text.replace(slot, value)
|
|
5125
|
+
# Innermost-first to a fixed point: a slot may quote an identifier shape such
|
|
5126
|
+
# as `Q<n>`, and the inner brackets would otherwise block the outer match and
|
|
5127
|
+
# leave the whole slot unfilled.
|
|
5128
|
+
while True:
|
|
5129
|
+
collapsed = re.sub(r"<[^<>\n]*>", SLOT_FILLER, text)
|
|
5130
|
+
if collapsed == text:
|
|
5131
|
+
return text
|
|
5132
|
+
text = collapsed
|
|
5133
|
+
|
|
5134
|
+
|
|
5135
|
+
def validate_completion_requires_a_recorded_anchor_scenario() -> int:
|
|
5136
|
+
"""Issue #30: an unrecorded anchor made the drift guarantee conditional.
|
|
5137
|
+
|
|
5138
|
+
`anchoredFingerprint` returns null for a non-digest value and completion
|
|
5139
|
+
then skipped the comparison entirely, so a task with `Contract: pending`
|
|
5140
|
+
passed with zero problems. It could be implemented against one contract,
|
|
5141
|
+
have its Touch or Verify rewritten mid-flight, and complete clean — purely
|
|
5142
|
+
by never running `task-start --record`. 5.3.7 closed the inference path;
|
|
5143
|
+
this closes the explicitly named one.
|
|
5144
|
+
"""
|
|
5145
|
+
header = "# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5146
|
+
|
|
5147
|
+
def task(contract: str) -> str:
|
|
5148
|
+
return (
|
|
5149
|
+
"- [ ] 1.1 Exercise task contract\n"
|
|
5150
|
+
" - Covers:\n"
|
|
5151
|
+
" - E1: Public behavior passes.\n"
|
|
5152
|
+
" - Touch:\n"
|
|
5153
|
+
" - src/feature.js\n"
|
|
5154
|
+
" - Verify:\n"
|
|
5155
|
+
" - Strategy: evidence-first\n"
|
|
5156
|
+
" - M1: node test.js asserts the recorded feed status\n"
|
|
5157
|
+
" - Evidence:\n"
|
|
5158
|
+
f" - Contract: {contract}\n"
|
|
5159
|
+
" - M1: the suite passed\n"
|
|
5160
|
+
" - Review:\n"
|
|
5161
|
+
" - Status: pass\n"
|
|
5162
|
+
" - Acceptance check: behavior asserted at the interface\n"
|
|
5163
|
+
" - Scope check: only Touch files changed\n"
|
|
5164
|
+
" - Findings: none\n"
|
|
5165
|
+
" - Blocker: none\n"
|
|
5166
|
+
)
|
|
5167
|
+
|
|
5168
|
+
with tempfile.TemporaryDirectory(prefix="keel-anchor-required-") as raw:
|
|
5169
|
+
repo = Path(raw)
|
|
5170
|
+
write_text(repo / "openspec/changes/unrecorded/tasks.md", header + task("pending"))
|
|
5171
|
+
|
|
5172
|
+
def gate(change: str, stage: str) -> dict:
|
|
5173
|
+
return json.loads(
|
|
5174
|
+
run_keel(
|
|
5175
|
+
repo,
|
|
5176
|
+
"gate",
|
|
5177
|
+
stage,
|
|
5178
|
+
"--change",
|
|
5179
|
+
change,
|
|
5180
|
+
"--task",
|
|
5181
|
+
"1.1",
|
|
5182
|
+
"--json",
|
|
5183
|
+
).stdout
|
|
5184
|
+
)
|
|
5185
|
+
|
|
5186
|
+
payload = gate("unrecorded", "task-complete")
|
|
5187
|
+
if payload.get("status") == "pass":
|
|
5188
|
+
report(
|
|
5189
|
+
"completion-requires-a-recorded-anchor: an explicitly named task "
|
|
5190
|
+
"with `Contract: pending` passed task-complete, so the "
|
|
5191
|
+
"fingerprint comparison still compares nothing."
|
|
5192
|
+
)
|
|
5193
|
+
return 1
|
|
5194
|
+
named = [
|
|
5195
|
+
problem.get("message", "")
|
|
5196
|
+
for problem in payload.get("problems", [])
|
|
5197
|
+
if problem.get("code") == "missing-contract-anchor"
|
|
5198
|
+
]
|
|
5199
|
+
if not named:
|
|
5200
|
+
report(
|
|
5201
|
+
"completion-requires-a-recorded-anchor: the task did not pass, "
|
|
5202
|
+
"but no missing-contract-anchor diagnostic explained why."
|
|
5203
|
+
)
|
|
5204
|
+
for problem in payload.get("problems", []):
|
|
5205
|
+
report(f" {problem.get('code')}: {problem.get('message')}")
|
|
5206
|
+
return 1
|
|
5207
|
+
for needle in ("Contract", "--record"):
|
|
5208
|
+
if needle not in named[0]:
|
|
5209
|
+
report(
|
|
5210
|
+
"completion-requires-a-recorded-anchor: the diagnostic did "
|
|
5211
|
+
f"not name {needle}."
|
|
5212
|
+
)
|
|
5213
|
+
report(named[0])
|
|
5214
|
+
return 1
|
|
5215
|
+
# Doing what the diagnostic asks must clear it. The fingerprint is the
|
|
5216
|
+
# one this task actually compiles to, so the anchor comparison that
|
|
5217
|
+
# follows is a real comparison rather than a shape check.
|
|
5218
|
+
started = gate("unrecorded", "task-start")
|
|
5219
|
+
fingerprint = started.get("contract", {}).get("fingerprint", {}).get("value")
|
|
5220
|
+
if not fingerprint:
|
|
5221
|
+
report(
|
|
5222
|
+
"completion-requires-a-recorded-anchor: task-start returned no "
|
|
5223
|
+
"fingerprint to record."
|
|
5224
|
+
)
|
|
5225
|
+
return 1
|
|
5226
|
+
if any(
|
|
5227
|
+
problem.get("code") == "missing-contract-anchor"
|
|
5228
|
+
for problem in started.get("problems", [])
|
|
5229
|
+
):
|
|
5230
|
+
report(
|
|
5231
|
+
"completion-requires-a-recorded-anchor: task-start reported the "
|
|
5232
|
+
"missing anchor, but it runs before one can exist."
|
|
5233
|
+
)
|
|
5234
|
+
return 1
|
|
5235
|
+
write_text(
|
|
5236
|
+
repo / "openspec/changes/recorded/tasks.md",
|
|
5237
|
+
header + task(f"keel-task-capsule/v1 sha256:{fingerprint}"),
|
|
5238
|
+
)
|
|
5239
|
+
recorded = gate("recorded", "task-complete")
|
|
5240
|
+
if recorded.get("status") != "pass":
|
|
5241
|
+
report(
|
|
5242
|
+
"completion-requires-a-recorded-anchor: recording the anchor did "
|
|
5243
|
+
"not clear the refusal."
|
|
5244
|
+
)
|
|
5245
|
+
for problem in recorded.get("problems", []):
|
|
5246
|
+
report(f" {problem.get('code')}: {problem.get('message')}")
|
|
5247
|
+
return 1
|
|
5248
|
+
if "completion-requires-a-recorded-anchor" not in {name for name, _ in SCENARIOS}:
|
|
5249
|
+
report(
|
|
5250
|
+
"completion-requires-a-recorded-anchor: the scenario registry does "
|
|
5251
|
+
"not include it."
|
|
5252
|
+
)
|
|
5253
|
+
return 1
|
|
5254
|
+
report("completion-requires-a-recorded-anchor scenario passed.")
|
|
5255
|
+
return 0
|
|
5256
|
+
|
|
5257
|
+
|
|
5258
|
+
def validate_task_body_ends_at_heading_scenario() -> int:
|
|
5259
|
+
"""Issue #29: a change-level section was read as the last task's Evidence.
|
|
5260
|
+
|
|
5261
|
+
parseTasks gave a task every line up to the next task or EOF, and a `##`
|
|
5262
|
+
heading did not stop it, so `## Invalidates` and `## Expectation Coverage`
|
|
5263
|
+
landed in whichever field was open last. That made the two checks look
|
|
5264
|
+
contradictory: `invalidation-phrase` requires the searchable wording in
|
|
5265
|
+
double quotes, while the concreteness test rejects an angle-bracket slot
|
|
5266
|
+
outside inline code, so an entry quoting wording that carries one could
|
|
5267
|
+
satisfy neither. They were never in conflict — the parser only made them
|
|
5268
|
+
appear so.
|
|
5269
|
+
"""
|
|
5270
|
+
slot = "<" + "n" + ">"
|
|
5271
|
+
header = "# Tasks\n\n"
|
|
5272
|
+
|
|
5273
|
+
def task(number: str, contract: str) -> str:
|
|
5274
|
+
return (
|
|
5275
|
+
f"- [ ] {number} Exercise task contract\n"
|
|
5276
|
+
" - Covers:\n"
|
|
5277
|
+
" - E1: Public behavior passes.\n"
|
|
5278
|
+
" - Touch:\n"
|
|
5279
|
+
" - src/feature.js\n"
|
|
5280
|
+
" - Verify:\n"
|
|
5281
|
+
" - Strategy: evidence-first\n"
|
|
5282
|
+
" - M1: node test.js asserts the recorded feed status\n"
|
|
5283
|
+
" - Evidence:\n"
|
|
5284
|
+
f" - Contract: {contract}\n"
|
|
5285
|
+
" - M1: the suite passed\n"
|
|
5286
|
+
" - Review:\n"
|
|
5287
|
+
" - Status: pass\n"
|
|
5288
|
+
" - Acceptance check: behavior asserted at the interface\n"
|
|
5289
|
+
" - Scope check: only Touch files changed\n"
|
|
5290
|
+
" - Findings: none\n"
|
|
5291
|
+
" - Blocker: none\n"
|
|
5292
|
+
)
|
|
5293
|
+
|
|
5294
|
+
# The entry quotes stale wording carrying an unfilled slot, which is exactly
|
|
5295
|
+
# what issue #16 asks an invalidation to quote. A stray Contract line sits
|
|
5296
|
+
# in the trailing section to prove the anchor search stops at the heading.
|
|
5297
|
+
sections = (
|
|
5298
|
+
"\n## Invalidates\n\n"
|
|
5299
|
+
f'- I1: "an unresolved Q{slot} without an authorized fallback blocks '
|
|
5300
|
+
'implementation" — the schema prose. Updated by: 1.1\n'
|
|
5301
|
+
" - Contract: keel-task-capsule/v1 sha256:" + "b" * 64 + "\n"
|
|
5302
|
+
"\n## Expectation Coverage\n\n"
|
|
5303
|
+
f"- E1: Every Q{slot} reference resolves Covered by: 1.1\n"
|
|
5304
|
+
)
|
|
5305
|
+
body = header + task("1.1", "pending") + "\n## 2. Second group\n\n" + task(
|
|
5306
|
+
"2.1", "pending"
|
|
5307
|
+
)
|
|
5308
|
+
|
|
5309
|
+
with tempfile.TemporaryDirectory(prefix="keel-task-extent-") as raw:
|
|
5310
|
+
repo = Path(raw)
|
|
5311
|
+
write_text(repo / "openspec/changes/demo/tasks.md", body + sections)
|
|
5312
|
+
started = run_keel(
|
|
5313
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "2.1", "--json"
|
|
5314
|
+
)
|
|
5315
|
+
payload = json.loads(started.stdout)
|
|
5316
|
+
problems = payload.get("problems", [])
|
|
5317
|
+
if payload.get("status") != "pass":
|
|
5318
|
+
report(
|
|
5319
|
+
"task-body-ends-at-heading: the last task did not pass task-start "
|
|
5320
|
+
"with a trailing section quoting an unfilled slot."
|
|
5321
|
+
)
|
|
5322
|
+
for problem in problems:
|
|
5323
|
+
report(f" {problem.get('code')}: {problem.get('message')}")
|
|
5324
|
+
return 1
|
|
5325
|
+
# The same run proves the phrase check is satisfied: an entry the phrase
|
|
5326
|
+
# check rejected would have produced invalidation-phrase above.
|
|
5327
|
+
malformed = body + (
|
|
5328
|
+
"\n## Invalidates\n\n"
|
|
5329
|
+
"- I1: the schema prose is stale. Updated by: 1.1\n"
|
|
5330
|
+
)
|
|
5331
|
+
write_text(repo / "openspec/changes/unquoted/tasks.md", malformed)
|
|
5332
|
+
unquoted = json.loads(
|
|
5333
|
+
run_keel(
|
|
5334
|
+
repo,
|
|
5335
|
+
"gate",
|
|
5336
|
+
"task-start",
|
|
5337
|
+
"--change",
|
|
5338
|
+
"unquoted",
|
|
5339
|
+
"--task",
|
|
5340
|
+
"2.1",
|
|
5341
|
+
"--json",
|
|
5342
|
+
).stdout
|
|
5343
|
+
)
|
|
5344
|
+
if not any(
|
|
5345
|
+
problem.get("code") == "invalidation-phrase"
|
|
5346
|
+
for problem in unquoted.get("problems", [])
|
|
5347
|
+
):
|
|
5348
|
+
report(
|
|
5349
|
+
"task-body-ends-at-heading: an unquoted invalidation entry was "
|
|
5350
|
+
"accepted, so the phrase check is no longer being satisfied by "
|
|
5351
|
+
"the quoted one."
|
|
5352
|
+
)
|
|
5353
|
+
return 1
|
|
5354
|
+
# A group heading must not be appended to the preceding task's field.
|
|
5355
|
+
first = json.loads(
|
|
5356
|
+
run_keel(
|
|
5357
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
5358
|
+
).stdout
|
|
5359
|
+
)
|
|
5360
|
+
evidence = json.dumps(first)
|
|
5361
|
+
if "Second group" in evidence:
|
|
5362
|
+
report(
|
|
5363
|
+
"task-body-ends-at-heading: the group heading leaked into the "
|
|
5364
|
+
"preceding task's fields."
|
|
5365
|
+
)
|
|
5366
|
+
return 1
|
|
5367
|
+
# --record must anchor the last task's own Contract line, not the stray
|
|
5368
|
+
# one planted in the trailing section.
|
|
5369
|
+
recorded = run_keel(
|
|
5370
|
+
repo,
|
|
5371
|
+
"gate",
|
|
5372
|
+
"task-start",
|
|
5373
|
+
"--change",
|
|
5374
|
+
"demo",
|
|
5375
|
+
"--task",
|
|
5376
|
+
"2.1",
|
|
5377
|
+
"--record",
|
|
5378
|
+
"--json",
|
|
5379
|
+
)
|
|
5380
|
+
if recorded.returncode != 0:
|
|
5381
|
+
report("task-body-ends-at-heading: --record failed on the last task.")
|
|
5382
|
+
report((recorded.stdout or recorded.stderr).strip())
|
|
5383
|
+
return 1
|
|
5384
|
+
written = (repo / "openspec/changes/demo/tasks.md").read_text(
|
|
5385
|
+
encoding="utf-8"
|
|
5386
|
+
)
|
|
5387
|
+
after_heading = written.split("## Invalidates", 1)[1]
|
|
5388
|
+
if "b" * 64 not in after_heading:
|
|
5389
|
+
report(
|
|
5390
|
+
"task-body-ends-at-heading: --record overwrote the Contract line "
|
|
5391
|
+
"planted inside the trailing section."
|
|
5392
|
+
)
|
|
5393
|
+
return 1
|
|
5394
|
+
# The extent change must move no fingerprint: an anchor that shifted
|
|
5395
|
+
# would drift every live change in every consumer repo at once. Pinned
|
|
5396
|
+
# rather than merely measured, so a future extent change cannot move one
|
|
5397
|
+
# silently. A deliberate capsule-shape change will fail here too — that
|
|
5398
|
+
# is the point; it should be looked at, not absorbed.
|
|
5399
|
+
pinned = {
|
|
5400
|
+
"1.1": "2f723a8778160a2d51cd91e34255bf19f2c654fa23cdcd7b013915727a541d17",
|
|
5401
|
+
"2.1": "5e0481362b06992d0317c91d34bbd5d6746fb9c1fee47566060f61fbba7cbf05",
|
|
5402
|
+
}
|
|
5403
|
+
plain = (
|
|
5404
|
+
"# Tasks\n\n"
|
|
5405
|
+
+ task("1.1", "pending")
|
|
5406
|
+
+ "\n## 2. Second group\n\n"
|
|
5407
|
+
+ task("2.1", "pending")
|
|
5408
|
+
+ '\n## Invalidates\n\n- I1: "the schema prose is stale here" — the '
|
|
5409
|
+
"schema. Updated by: 1.1\n\n## Expectation Coverage\n\n"
|
|
5410
|
+
"- E1: Covered by: 1.1\n"
|
|
5411
|
+
)
|
|
5412
|
+
write_text(repo / "openspec/changes/pinned/tasks.md", plain)
|
|
5413
|
+
for task_id, expected in pinned.items():
|
|
5414
|
+
payload = json.loads(
|
|
5415
|
+
run_keel(
|
|
5416
|
+
repo,
|
|
5417
|
+
"gate",
|
|
5418
|
+
"task-start",
|
|
5419
|
+
"--change",
|
|
5420
|
+
"pinned",
|
|
5421
|
+
"--task",
|
|
5422
|
+
task_id,
|
|
5423
|
+
"--json",
|
|
5424
|
+
).stdout
|
|
5425
|
+
)
|
|
5426
|
+
actual = (
|
|
5427
|
+
payload.get("contract", {}).get("fingerprint", {}).get("value")
|
|
5428
|
+
)
|
|
5429
|
+
if actual != expected:
|
|
5430
|
+
report(
|
|
5431
|
+
"task-body-ends-at-heading: the compiled fingerprint for "
|
|
5432
|
+
f"task {task_id} moved. Expected {expected}, got {actual}. "
|
|
5433
|
+
"An extent or capsule-shape change that moves an anchor "
|
|
5434
|
+
"drifts every live change in every consumer repo."
|
|
5435
|
+
)
|
|
5436
|
+
return 1
|
|
5437
|
+
if "task-body-ends-at-heading" not in {name for name, _ in SCENARIOS}:
|
|
5438
|
+
report(
|
|
5439
|
+
"task-body-ends-at-heading: the scenario registry does not include it."
|
|
5440
|
+
)
|
|
5441
|
+
return 1
|
|
5442
|
+
report("task-body-ends-at-heading scenario passed.")
|
|
5443
|
+
return 0
|
|
5444
|
+
|
|
5445
|
+
|
|
5446
|
+
TASKS_TEMPLATE_RELATIVE = "schemas/keel-spec-driven/templates/tasks.md"
|
|
5447
|
+
|
|
5448
|
+
|
|
5449
|
+
def validate_tasks_template_red_green_example_scenario() -> int:
|
|
5450
|
+
"""Issue #28 items 2 and 3: the red-green shape was described, never shown.
|
|
5451
|
+
|
|
5452
|
+
The template's prose has said since 5.3.4 that the `.red`/`.green` entries
|
|
5453
|
+
come in addition to the bare `M<n>` entry, but it showed only the flat form.
|
|
5454
|
+
The reporter tried annotated labels, was refused, landed on the flat form,
|
|
5455
|
+
and was refused again for the missing bare entry. One worked example closes
|
|
5456
|
+
both attempts — and it is asserted by gating it, so it cannot drift from the
|
|
5457
|
+
rule it illustrates.
|
|
5458
|
+
"""
|
|
5459
|
+
shipped = ROOT / "openspec" / TASKS_TEMPLATE_RELATIVE
|
|
5460
|
+
packaged = ROOT / "assets" / "openspec" / TASKS_TEMPLATE_RELATIVE
|
|
5461
|
+
for path in (shipped, packaged):
|
|
5462
|
+
if not path.is_file():
|
|
5463
|
+
report(f"tasks-template-red-green-example: missing template {path}.")
|
|
5464
|
+
return 1
|
|
5465
|
+
if shipped.read_bytes() != packaged.read_bytes():
|
|
5466
|
+
report(
|
|
5467
|
+
"tasks-template-red-green-example: the two shipped copies of the "
|
|
5468
|
+
"tasks template have diverged."
|
|
5469
|
+
)
|
|
5470
|
+
return 1
|
|
5471
|
+
|
|
5472
|
+
source = shipped.read_text(encoding="utf-8")
|
|
5473
|
+
red_green = [
|
|
5474
|
+
block
|
|
5475
|
+
for block in re.split(r"^## ", source, flags=re.MULTILINE)
|
|
5476
|
+
if re.search(r"^\s*-\s*Strategy:\s*(vertical-tdd|regression-first)", block, re.M)
|
|
5477
|
+
]
|
|
5478
|
+
if not red_green:
|
|
5479
|
+
report(
|
|
5480
|
+
"tasks-template-red-green-example: the template defines no task "
|
|
5481
|
+
"group with a red-green strategy, so it still only describes one."
|
|
5482
|
+
)
|
|
5483
|
+
return 1
|
|
5484
|
+
group = red_green[0]
|
|
5485
|
+
untagged = re.findall(r"^\s*-\s*(M[1-9]\d*):\s*(?!pending)", group, re.M)
|
|
5486
|
+
tagged = re.findall(r"^\s*-\s*(M[1-9]\d*)\s*\([^)]*regression[^)]*\):", group, re.M)
|
|
5487
|
+
if not untagged:
|
|
5488
|
+
report(
|
|
5489
|
+
"tasks-template-red-green-example: the red-green group has no "
|
|
5490
|
+
"untagged check, which task-start refuses as regression-only."
|
|
5491
|
+
)
|
|
5492
|
+
return 1
|
|
5493
|
+
if not tagged:
|
|
5494
|
+
report(
|
|
5495
|
+
"tasks-template-red-green-example: the red-green group shows no "
|
|
5496
|
+
"(regression)-tagged check, so the exemption is still unillustrated."
|
|
5497
|
+
)
|
|
5498
|
+
return 1
|
|
5499
|
+
label = untagged[0]
|
|
5500
|
+
for suffix in ("", ".red", ".green"):
|
|
5501
|
+
if not re.search(rf"^\s*-\s*{label}{re.escape(suffix)}:", group, re.M):
|
|
5502
|
+
report(
|
|
5503
|
+
"tasks-template-red-green-example: the untagged check is missing "
|
|
5504
|
+
f"its `{label}{suffix}` Evidence entry."
|
|
5505
|
+
)
|
|
5506
|
+
return 1
|
|
5507
|
+
for suffix in (".red", ".green"):
|
|
5508
|
+
if re.search(rf"^\s*-\s*{tagged[0]}{re.escape(suffix)}:", group, re.M):
|
|
5509
|
+
report(
|
|
5510
|
+
"tasks-template-red-green-example: the (regression)-tagged check "
|
|
5511
|
+
f"carries a `{suffix}` entry it is exempt from."
|
|
5512
|
+
)
|
|
5513
|
+
return 1
|
|
5514
|
+
|
|
5515
|
+
# Gating the filled template is what keeps the example from drifting from
|
|
5516
|
+
# the rule it illustrates.
|
|
5517
|
+
filled = fill_template_slots(source)
|
|
5518
|
+
with tempfile.TemporaryDirectory(prefix="keel-tasks-template-") as raw:
|
|
5519
|
+
repo = Path(raw)
|
|
5520
|
+
write_text(repo / "openspec/changes/from-template/tasks.md", filled)
|
|
5521
|
+
ids = re.findall(r"^\s*-\s*\[[ xX]\]\s+(\d+(?:\.\d+)+)\s", filled, re.M)
|
|
5522
|
+
if not ids:
|
|
5523
|
+
report(
|
|
5524
|
+
"tasks-template-red-green-example: the filled template defines "
|
|
5525
|
+
"no task the gate can read."
|
|
5526
|
+
)
|
|
5527
|
+
return 1
|
|
5528
|
+
for task_id in ids:
|
|
5529
|
+
result = run_keel(
|
|
5530
|
+
repo,
|
|
5531
|
+
"gate",
|
|
5532
|
+
"task-start",
|
|
5533
|
+
"--change",
|
|
5534
|
+
"from-template",
|
|
5535
|
+
"--task",
|
|
5536
|
+
task_id,
|
|
5537
|
+
"--json",
|
|
5538
|
+
)
|
|
5539
|
+
payload = json.loads(result.stdout)
|
|
5540
|
+
if payload.get("status") != "pass":
|
|
5541
|
+
report(
|
|
5542
|
+
"tasks-template-red-green-example: task "
|
|
5543
|
+
f"{task_id} written from the shipped template did not pass "
|
|
5544
|
+
"task-start."
|
|
5545
|
+
)
|
|
5546
|
+
for problem in payload.get("problems", []):
|
|
5547
|
+
report(f" {problem.get('code')}: {problem.get('message')}")
|
|
5548
|
+
return 1
|
|
5549
|
+
if "tasks-template-red-green-example" not in {name for name, _ in SCENARIOS}:
|
|
5550
|
+
report(
|
|
5551
|
+
"tasks-template-red-green-example: the scenario registry does not "
|
|
5552
|
+
"include it."
|
|
5553
|
+
)
|
|
5554
|
+
return 1
|
|
5555
|
+
report("tasks-template-red-green-example scenario passed.")
|
|
5556
|
+
return 0
|
|
5557
|
+
|
|
5558
|
+
|
|
5559
|
+
def validate_spec_template_validates_scenario() -> int:
|
|
5560
|
+
"""Issue #28 item 7: following the spec template guaranteed a first failure.
|
|
5561
|
+
|
|
5562
|
+
The template's requirement body is a comment with no modal verb, and
|
|
5563
|
+
`openspec validate` requires SHALL or MUST, so the reporter's 16 requirements
|
|
5564
|
+
produced 16 errors. Asserted by running the filled template through the
|
|
5565
|
+
validator rather than by matching the template's prose, because a template
|
|
5566
|
+
that only mentions the requirement in a comment would satisfy the latter.
|
|
5567
|
+
"""
|
|
5568
|
+
shipped = ROOT / "openspec" / SPEC_TEMPLATE_RELATIVE
|
|
5569
|
+
packaged = ROOT / "assets" / "openspec" / SPEC_TEMPLATE_RELATIVE
|
|
5570
|
+
for path in (shipped, packaged):
|
|
5571
|
+
if not path.is_file():
|
|
5572
|
+
report(f"spec-template-validates: missing shipped template {path}.")
|
|
5573
|
+
return 1
|
|
5574
|
+
if shipped.read_bytes() != packaged.read_bytes():
|
|
5575
|
+
report(
|
|
5576
|
+
"spec-template-validates: the two shipped copies of the spec "
|
|
5577
|
+
"template have diverged."
|
|
5578
|
+
)
|
|
5579
|
+
return 1
|
|
5580
|
+
if run_openspec(ROOT, "--version") is None:
|
|
5581
|
+
report("spec-template-validates skipped: the openspec CLI is not on PATH.")
|
|
5582
|
+
return 0
|
|
5583
|
+
|
|
5584
|
+
filled = fill_template_slots(
|
|
5585
|
+
shipped.read_text(encoding="utf-8"), comments="replace"
|
|
5586
|
+
)
|
|
5587
|
+
with tempfile.TemporaryDirectory(prefix="keel-spec-template-") as raw:
|
|
5588
|
+
repo = Path(raw)
|
|
5589
|
+
write_text(repo / "openspec/project.md", "# Project\n\nA fixture.\n")
|
|
5590
|
+
change = repo / "openspec/changes/from-template"
|
|
5591
|
+
write_text(
|
|
5592
|
+
change / "proposal.md",
|
|
5593
|
+
"# from-template\n\n## Why\n\nExercise the shipped spec template.\n"
|
|
5594
|
+
"\n## What Changes\n\n- One requirement written from the template.\n",
|
|
5595
|
+
)
|
|
5596
|
+
write_text(change / "specs/demo-capability/spec.md", filled)
|
|
5597
|
+
result = run_openspec(repo, "validate", "from-template")
|
|
5598
|
+
if result is None:
|
|
5599
|
+
report(
|
|
5600
|
+
"spec-template-validates skipped: the openspec CLI vanished "
|
|
5601
|
+
"mid-scenario."
|
|
5602
|
+
)
|
|
5603
|
+
return 0
|
|
5604
|
+
if result.returncode != 0:
|
|
5605
|
+
report(
|
|
5606
|
+
"spec-template-validates: a requirement written from the "
|
|
5607
|
+
"shipped template did not validate."
|
|
5608
|
+
)
|
|
5609
|
+
report((result.stdout or result.stderr).strip())
|
|
5610
|
+
return 1
|
|
5611
|
+
if "spec-template-validates" not in {name for name, _ in SCENARIOS}:
|
|
5612
|
+
report("spec-template-validates: the scenario registry does not include it.")
|
|
5613
|
+
return 1
|
|
5614
|
+
report("spec-template-validates scenario passed.")
|
|
5615
|
+
return 0
|
|
5616
|
+
|
|
5617
|
+
|
|
5618
|
+
def validate_task_complete_selection_requires_a_started_task_scenario() -> int:
|
|
5619
|
+
"""Issue #28 item 6: the no-arg default reported another task's problems.
|
|
5620
|
+
|
|
5621
|
+
The documented order is gate-then-checkbox, so first-unchecked is the right
|
|
5622
|
+
inference and stays. The hazard is narrower: inferring a task that never
|
|
5623
|
+
started, then printing its readiness problems under a selection heading the
|
|
5624
|
+
author reads as their own task's failure. A task that has started records a
|
|
5625
|
+
fingerprint in its Evidence `Contract` anchor, so that anchor is what makes
|
|
5626
|
+
the inference safe.
|
|
5627
|
+
"""
|
|
5628
|
+
header = "# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5629
|
+
|
|
5630
|
+
def task(number: str, checked: bool, contract: str) -> str:
|
|
5631
|
+
box = "x" if checked else " "
|
|
5632
|
+
return (
|
|
5633
|
+
f"- [{box}] {number} Exercise task contract\n"
|
|
5634
|
+
" - Covers:\n"
|
|
5635
|
+
" - E1: Public behavior passes.\n"
|
|
5636
|
+
" - Touch:\n"
|
|
5637
|
+
" - src/feature.js\n"
|
|
5638
|
+
" - Verify:\n"
|
|
5639
|
+
" - Strategy: evidence-first\n"
|
|
5640
|
+
" - M1: node test.js\n"
|
|
5641
|
+
" - Evidence:\n"
|
|
5642
|
+
f" - Contract: {contract}\n"
|
|
5643
|
+
" - M1: the suite passed\n"
|
|
5644
|
+
" - Review:\n"
|
|
5645
|
+
" - Status: pass\n"
|
|
5646
|
+
" - Acceptance check: behavior asserted at the interface\n"
|
|
5647
|
+
" - Scope check: only Touch files changed\n"
|
|
5648
|
+
" - Findings: none\n"
|
|
5649
|
+
" - Blocker: none\n"
|
|
5650
|
+
)
|
|
5651
|
+
|
|
5652
|
+
digest = "a" * 64
|
|
5653
|
+
anchored = f"keel-task-capsule/v1 sha256:{digest}"
|
|
5654
|
+
with tempfile.TemporaryDirectory(prefix="keel-complete-selection-") as raw:
|
|
5655
|
+
repo = Path(raw)
|
|
5656
|
+
|
|
5657
|
+
def gate(change: str, stage: str) -> dict:
|
|
5658
|
+
result = run_keel(
|
|
5659
|
+
repo, "gate", stage, "--change", change, "--json"
|
|
5660
|
+
)
|
|
5661
|
+
return json.loads(result.stdout)
|
|
5662
|
+
|
|
5663
|
+
# 1.1 is finished; 1.2 has never started, so its anchor is still pending.
|
|
5664
|
+
write_text(
|
|
5665
|
+
repo / "openspec/changes/unstarted/tasks.md",
|
|
5666
|
+
header
|
|
5667
|
+
+ task("1.1", True, anchored)
|
|
5668
|
+
+ "\n"
|
|
5669
|
+
+ task("1.2", False, "pending"),
|
|
5670
|
+
)
|
|
5671
|
+
payload = gate("unstarted", "task-complete")
|
|
5672
|
+
codes = {problem.get("code") for problem in payload.get("problems", [])}
|
|
5673
|
+
if "ambiguous-completion-selection" not in codes:
|
|
5674
|
+
report(
|
|
5675
|
+
"task-complete-selection-requires-a-started-task: no-arg "
|
|
5676
|
+
"task-complete did not refuse on selection for a task that "
|
|
5677
|
+
"records no start fingerprint."
|
|
5678
|
+
)
|
|
5679
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
5680
|
+
return 1
|
|
5681
|
+
message = next(
|
|
5682
|
+
problem.get("message", "")
|
|
5683
|
+
for problem in payload.get("problems", [])
|
|
5684
|
+
if problem.get("code") == "ambiguous-completion-selection"
|
|
5685
|
+
)
|
|
5686
|
+
for needle in ("1.2", "1.1", "--task"):
|
|
5687
|
+
if needle not in message:
|
|
5688
|
+
report(
|
|
5689
|
+
"task-complete-selection-requires-a-started-task: the "
|
|
5690
|
+
f"refusal did not name {needle}."
|
|
5691
|
+
)
|
|
5692
|
+
report(message)
|
|
5693
|
+
return 1
|
|
5694
|
+
# The same shape, once 1.2 has recorded its start fingerprint.
|
|
5695
|
+
write_text(
|
|
5696
|
+
repo / "openspec/changes/started/tasks.md",
|
|
5697
|
+
header
|
|
5698
|
+
+ task("1.1", True, anchored)
|
|
5699
|
+
+ "\n"
|
|
5700
|
+
+ task("1.2", False, anchored),
|
|
5701
|
+
)
|
|
5702
|
+
started = gate("started", "task-complete")
|
|
5703
|
+
started_codes = {
|
|
5704
|
+
problem.get("code") for problem in started.get("problems", [])
|
|
5705
|
+
}
|
|
5706
|
+
if "ambiguous-completion-selection" in started_codes:
|
|
5707
|
+
report(
|
|
5708
|
+
"task-complete-selection-requires-a-started-task: a task that "
|
|
5709
|
+
"recorded its start fingerprint was still refused on selection."
|
|
5710
|
+
)
|
|
5711
|
+
return 1
|
|
5712
|
+
if started.get("selection", {}).get("tasks") != ["1.2"]:
|
|
5713
|
+
report(
|
|
5714
|
+
"task-complete-selection-requires-a-started-task: the started "
|
|
5715
|
+
"task was not the inferred selection."
|
|
5716
|
+
)
|
|
5717
|
+
report(json.dumps(started.get("selection", {}), indent=2))
|
|
5718
|
+
return 1
|
|
5719
|
+
# task-start keeps the plain first-unchecked default: selecting a task
|
|
5720
|
+
# that has not started is exactly its job.
|
|
5721
|
+
start = gate("unstarted", "task-start")
|
|
5722
|
+
if start.get("selection", {}).get("tasks") != ["1.2"]:
|
|
5723
|
+
report(
|
|
5724
|
+
"task-complete-selection-requires-a-started-task: no-arg "
|
|
5725
|
+
"task-start no longer selects the first unchecked task."
|
|
5726
|
+
)
|
|
5727
|
+
report(json.dumps(start.get("selection", {}), indent=2))
|
|
5728
|
+
return 1
|
|
5729
|
+
start_codes = {problem.get("code") for problem in start.get("problems", [])}
|
|
5730
|
+
if "ambiguous-completion-selection" in start_codes:
|
|
5731
|
+
report(
|
|
5732
|
+
"task-complete-selection-requires-a-started-task: the selection "
|
|
5733
|
+
"refusal leaked into task-start."
|
|
5734
|
+
)
|
|
5735
|
+
return 1
|
|
5736
|
+
if "task-complete-selection-requires-a-started-task" not in {
|
|
5737
|
+
name for name, _ in SCENARIOS
|
|
5738
|
+
}:
|
|
5739
|
+
report(
|
|
5740
|
+
"task-complete-selection-requires-a-started-task: the scenario "
|
|
5741
|
+
"registry does not include it."
|
|
5742
|
+
)
|
|
5743
|
+
return 1
|
|
5744
|
+
report("task-complete-selection-requires-a-started-task scenario passed.")
|
|
5745
|
+
return 0
|
|
5746
|
+
|
|
5747
|
+
|
|
5748
|
+
def validate_absent_verification_form_is_one_problem_scenario() -> int:
|
|
5749
|
+
"""Issue #28 item 4: the cascade reported a schema the author never chose.
|
|
5750
|
+
|
|
5751
|
+
Compact detection read only `isConcrete(Verify)`, so a task that simply had
|
|
5752
|
+
no verification field was reported as an expanded v3 task missing nine
|
|
5753
|
+
fields. Seven of them either resolve to a documented default, derive from
|
|
5754
|
+
other authority, are consumed nowhere, or belong to the coupling contract —
|
|
5755
|
+
leaving the one actionable line last.
|
|
5756
|
+
"""
|
|
5757
|
+
defaulted = (
|
|
5758
|
+
"Owner",
|
|
5759
|
+
"Mode",
|
|
5760
|
+
"Read",
|
|
5761
|
+
"Acceptance",
|
|
5762
|
+
"Report",
|
|
5763
|
+
"Candidate Boundary",
|
|
5764
|
+
"Stop Rules",
|
|
5765
|
+
)
|
|
5766
|
+
header = "# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5767
|
+
body = (
|
|
5768
|
+
"- [ ] 1.1 Exercise task contract\n"
|
|
5769
|
+
" - Covers:\n"
|
|
5770
|
+
" - E1: Public behavior passes.\n"
|
|
5771
|
+
" - Touch:\n"
|
|
5772
|
+
" - src/feature.js\n"
|
|
5773
|
+
)
|
|
5774
|
+
evidence = " - Evidence:\n - M1: pending\n"
|
|
5775
|
+
commands = " - Commands:\n - M1: node test.js\n"
|
|
5776
|
+
|
|
5777
|
+
with tempfile.TemporaryDirectory(prefix="keel-absent-verification-") as raw:
|
|
5778
|
+
repo = Path(raw)
|
|
5779
|
+
|
|
5780
|
+
def problems_for(change: str, content: str) -> list:
|
|
5781
|
+
write_text(repo / f"openspec/changes/{change}/tasks.md", content)
|
|
5782
|
+
result = run_keel(
|
|
5783
|
+
repo,
|
|
5784
|
+
"gate",
|
|
5785
|
+
"task-start",
|
|
5786
|
+
"--change",
|
|
5787
|
+
change,
|
|
5788
|
+
"--task",
|
|
5789
|
+
"1.1",
|
|
5790
|
+
"--json",
|
|
5791
|
+
)
|
|
5792
|
+
return json.loads(result.stdout).get("problems", [])
|
|
5793
|
+
|
|
5794
|
+
# M1 — neither verification form declared.
|
|
5795
|
+
none_declared = problems_for("noform", header + body + evidence)
|
|
5796
|
+
naming = [
|
|
5797
|
+
problem
|
|
5798
|
+
for problem in none_declared
|
|
5799
|
+
if "Verify" in problem.get("message", "")
|
|
5800
|
+
]
|
|
5801
|
+
if len(naming) != 1:
|
|
5802
|
+
report(
|
|
5803
|
+
"absent-verification-form-is-one-problem: expected exactly one "
|
|
5804
|
+
f"diagnostic naming Verify, found {len(naming)}."
|
|
5805
|
+
)
|
|
5806
|
+
for problem in none_declared:
|
|
5807
|
+
report(f" {problem.get('code')}: {problem.get('message')}")
|
|
5808
|
+
return 1
|
|
5809
|
+
leaked = sorted(
|
|
5810
|
+
name
|
|
5811
|
+
for problem in none_declared
|
|
5812
|
+
if problem.get("code") == "missing-field"
|
|
5813
|
+
for name in defaulted
|
|
5814
|
+
if problem.get("message", "").startswith(f"{name} must be concrete")
|
|
5815
|
+
)
|
|
5816
|
+
if leaked:
|
|
5817
|
+
report(
|
|
5818
|
+
"absent-verification-form-is-one-problem: fields with documented "
|
|
5819
|
+
f"defaults were still required: {', '.join(leaked)}."
|
|
5820
|
+
)
|
|
5821
|
+
return 1
|
|
5822
|
+
|
|
5823
|
+
# M2 — a genuine expanded v3 task that omits every defaulted field.
|
|
5824
|
+
expanded = problems_for("expanded", header + body + commands + evidence)
|
|
5825
|
+
if expanded:
|
|
5826
|
+
report(
|
|
5827
|
+
"absent-verification-form-is-one-problem: an expanded task "
|
|
5828
|
+
"declaring Commands, Covers, Touch and Evidence did not pass."
|
|
5829
|
+
)
|
|
5830
|
+
for problem in expanded:
|
|
5831
|
+
report(f" {problem.get('code')}: {problem.get('message')}")
|
|
5832
|
+
return 1
|
|
5833
|
+
# Removing Commands from that same task must still fail.
|
|
5834
|
+
if not problems_for("expanded-no-commands", header + body + evidence):
|
|
5835
|
+
report(
|
|
5836
|
+
"absent-verification-form-is-one-problem: removing Commands "
|
|
5837
|
+
"from the expanded task left it passing."
|
|
5838
|
+
)
|
|
5839
|
+
return 1
|
|
5840
|
+
# Candidate Boundary is the coupling contract's to require.
|
|
5841
|
+
coupled = problems_for(
|
|
5842
|
+
"coupled",
|
|
5843
|
+
header
|
|
5844
|
+
+ body
|
|
5845
|
+
+ " - Coupling: required\n"
|
|
5846
|
+
+ commands
|
|
5847
|
+
+ evidence,
|
|
5848
|
+
)
|
|
5849
|
+
if not any(
|
|
5850
|
+
"Candidate Boundary" in problem.get("message", "") for problem in coupled
|
|
5851
|
+
):
|
|
5852
|
+
report(
|
|
5853
|
+
"absent-verification-form-is-one-problem: Coupling required did "
|
|
5854
|
+
"not require a Candidate Boundary."
|
|
5855
|
+
)
|
|
5856
|
+
for problem in coupled:
|
|
5857
|
+
report(f" {problem.get('code')}: {problem.get('message')}")
|
|
5858
|
+
return 1
|
|
5859
|
+
if "absent-verification-form-is-one-problem" not in {
|
|
5860
|
+
name for name, _ in SCENARIOS
|
|
5861
|
+
}:
|
|
5862
|
+
report(
|
|
5863
|
+
"absent-verification-form-is-one-problem: the scenario registry does "
|
|
5864
|
+
"not include it."
|
|
5865
|
+
)
|
|
5866
|
+
return 1
|
|
5867
|
+
report("absent-verification-form-is-one-problem scenario passed.")
|
|
5868
|
+
return 0
|
|
5869
|
+
|
|
5870
|
+
|
|
5871
|
+
def validate_non_concrete_check_names_token_scenario() -> int:
|
|
5872
|
+
"""Issue #28 item 5: the check diagnostic must name the slot it matched.
|
|
5873
|
+
|
|
5874
|
+
`unfilledToken` already identifies the matched token and `Verify` already
|
|
5875
|
+
reports it. The per-check message said only that the check "must define a
|
|
5876
|
+
concrete public check", which describes the consequence rather than the
|
|
5877
|
+
cause, so the reporter had to guess which of several inline slots was the
|
|
5878
|
+
problem.
|
|
5879
|
+
"""
|
|
5880
|
+
slot = "<" + "url" + ">"
|
|
5881
|
+
with tempfile.TemporaryDirectory(prefix="keel-check-token-") as raw:
|
|
5882
|
+
repo = Path(raw)
|
|
5883
|
+
base = task_capsule_compact_fixture().replace(
|
|
5884
|
+
" - M1: node test.js\n",
|
|
5885
|
+
" - M1: node test.js\n"
|
|
5886
|
+
f" - M2: run the fetch script against {slot} and assert the "
|
|
5887
|
+
"recorded status\n",
|
|
5888
|
+
).replace(" - M1: pending\n", " - M1: pending\n - M2: pending\n")
|
|
5889
|
+
write_text(repo / "openspec/changes/tokened/tasks.md", base)
|
|
5890
|
+
|
|
5891
|
+
def check_messages(change: str) -> list:
|
|
5892
|
+
result = run_keel(
|
|
5893
|
+
repo,
|
|
5894
|
+
"gate",
|
|
5895
|
+
"task-start",
|
|
5896
|
+
"--change",
|
|
5897
|
+
change,
|
|
5898
|
+
"--task",
|
|
5899
|
+
"1.1",
|
|
5900
|
+
"--json",
|
|
5901
|
+
)
|
|
5902
|
+
payload = json.loads(result.stdout)
|
|
5903
|
+
return [
|
|
5904
|
+
problem.get("message", "")
|
|
5905
|
+
for problem in payload.get("problems", [])
|
|
5906
|
+
if problem.get("code") == "missing-command-check"
|
|
5907
|
+
]
|
|
5908
|
+
|
|
5909
|
+
tokened = check_messages("tokened")
|
|
5910
|
+
if not tokened:
|
|
5911
|
+
report(
|
|
5912
|
+
"non-concrete-check-names-token: a check carrying a bare "
|
|
5913
|
+
"unfilled slot produced no missing-command-check diagnostic."
|
|
5914
|
+
)
|
|
5915
|
+
return 1
|
|
5916
|
+
if slot not in tokened[0]:
|
|
5917
|
+
report(
|
|
5918
|
+
"non-concrete-check-names-token: the diagnostic did not name "
|
|
5919
|
+
f"the {slot} slot it matched."
|
|
5920
|
+
)
|
|
5921
|
+
report(tokened[0])
|
|
5922
|
+
return 1
|
|
5923
|
+
# Replacing exactly what the diagnostic names must clear it.
|
|
5924
|
+
fixed = base.replace(slot, "`https://example.test/feed`")
|
|
5925
|
+
write_text(repo / "openspec/changes/fixed/tasks.md", fixed)
|
|
5926
|
+
if check_messages("fixed"):
|
|
5927
|
+
report(
|
|
5928
|
+
"non-concrete-check-names-token: replacing the named slot did "
|
|
5929
|
+
"not clear the diagnostic."
|
|
5930
|
+
)
|
|
5931
|
+
return 1
|
|
5932
|
+
# An empty check has no token to name, so the unqualified wording is
|
|
5933
|
+
# still the honest one there.
|
|
5934
|
+
empty = base.replace(
|
|
5935
|
+
f" - M2: run the fetch script against {slot} and assert the "
|
|
5936
|
+
"recorded status\n",
|
|
5937
|
+
" - M2: pending\n",
|
|
5938
|
+
)
|
|
5939
|
+
write_text(repo / "openspec/changes/empty/tasks.md", empty)
|
|
5940
|
+
bare = check_messages("empty")
|
|
5941
|
+
if not bare:
|
|
5942
|
+
report(
|
|
5943
|
+
"non-concrete-check-names-token: a pending check produced no "
|
|
5944
|
+
"missing-command-check diagnostic."
|
|
5945
|
+
)
|
|
5946
|
+
return 1
|
|
5947
|
+
if "must define a concrete public check" not in bare[0]:
|
|
5948
|
+
report(
|
|
5949
|
+
"non-concrete-check-names-token: a pending check lost the "
|
|
5950
|
+
"unqualified wording."
|
|
5951
|
+
)
|
|
5952
|
+
report(bare[0])
|
|
5953
|
+
return 1
|
|
5954
|
+
if "non-concrete-check-names-token" not in {name for name, _ in SCENARIOS}:
|
|
5955
|
+
report(
|
|
5956
|
+
"non-concrete-check-names-token: the scenario registry does not "
|
|
5957
|
+
"include it."
|
|
5958
|
+
)
|
|
5959
|
+
return 1
|
|
5960
|
+
report("non-concrete-check-names-token scenario passed.")
|
|
5961
|
+
return 0
|
|
5962
|
+
|
|
5963
|
+
|
|
5964
|
+
def validate_covers_question_reference_scope_scenario() -> int:
|
|
5965
|
+
"""Issue #28 item 9: citing a resolved question must not re-open it.
|
|
5966
|
+
|
|
5967
|
+
The question scan used to run over the whole Covers field, so a task that
|
|
5968
|
+
named `Q1` beside the fact that closed it was told to declare a fallback for
|
|
5969
|
+
a question it does not carry. The reporter's only available fix was to
|
|
5970
|
+
delete the reference, which makes traceability worse.
|
|
5971
|
+
|
|
5972
|
+
Both sides are asserted. A scenario that only checked the newly passing
|
|
5973
|
+
shape would also be satisfied by deleting the check outright.
|
|
5974
|
+
"""
|
|
5975
|
+
with tempfile.TemporaryDirectory(prefix="keel-covers-question-") as raw:
|
|
5976
|
+
repo = Path(raw)
|
|
5977
|
+
base = task_capsule_compact_fixture()
|
|
5978
|
+
# Still in scope: the question is the subject of its entry.
|
|
5979
|
+
subject = base.replace(
|
|
5980
|
+
" - E1: Public behavior passes.\n",
|
|
5981
|
+
" - Q1: Should the widget retry on timeout?\n",
|
|
5982
|
+
)
|
|
5983
|
+
write_text(repo / "openspec/changes/subject/tasks.md", subject)
|
|
5984
|
+
# Out of scope: the entry's subject is the fact, and the resolved
|
|
5985
|
+
# question is named as the supporting detail that points at it.
|
|
5986
|
+
detail = base.replace(
|
|
5987
|
+
" - E1: Public behavior passes.\n",
|
|
5988
|
+
" - F13 (Q1 resolved: the widget retries twice, then stops)\n",
|
|
5989
|
+
)
|
|
5990
|
+
write_text(repo / "openspec/changes/detail/tasks.md", detail)
|
|
5991
|
+
|
|
5992
|
+
def authority_messages(change: str) -> list:
|
|
5993
|
+
result = run_keel(
|
|
5994
|
+
repo,
|
|
5995
|
+
"gate",
|
|
5996
|
+
"task-start",
|
|
5997
|
+
"--change",
|
|
5998
|
+
change,
|
|
5999
|
+
"--task",
|
|
6000
|
+
"1.1",
|
|
6001
|
+
"--json",
|
|
6002
|
+
)
|
|
6003
|
+
payload = json.loads(result.stdout)
|
|
6004
|
+
return [
|
|
6005
|
+
problem.get("message", "")
|
|
6006
|
+
for problem in payload.get("problems", [])
|
|
6007
|
+
if problem.get("code") == "unresolved-authority"
|
|
6008
|
+
]
|
|
6009
|
+
|
|
6010
|
+
blocking = authority_messages("subject")
|
|
6011
|
+
if not blocking:
|
|
6012
|
+
report(
|
|
6013
|
+
"covers-question-reference-scope: a question that opens its "
|
|
6014
|
+
"Covers entry produced no unresolved-authority diagnostic, so "
|
|
6015
|
+
"the check no longer refuses anything."
|
|
6016
|
+
)
|
|
6017
|
+
return 1
|
|
6018
|
+
if "Q1" not in blocking[0]:
|
|
6019
|
+
report(
|
|
6020
|
+
"covers-question-reference-scope: the diagnostic did not name "
|
|
6021
|
+
"the question it read."
|
|
6022
|
+
)
|
|
6023
|
+
report(blocking[0])
|
|
6024
|
+
return 1
|
|
6025
|
+
citing = authority_messages("detail")
|
|
6026
|
+
if citing:
|
|
6027
|
+
report(
|
|
6028
|
+
"covers-question-reference-scope: naming a resolved question as "
|
|
6029
|
+
"supporting detail still demands a fallback for it."
|
|
6030
|
+
)
|
|
6031
|
+
report(citing[0])
|
|
6032
|
+
return 1
|
|
6033
|
+
if "covers-question-reference-scope" not in {name for name, _ in SCENARIOS}:
|
|
6034
|
+
report(
|
|
6035
|
+
"covers-question-reference-scope: the scenario registry does not "
|
|
6036
|
+
"include it."
|
|
6037
|
+
)
|
|
6038
|
+
return 1
|
|
6039
|
+
report("covers-question-reference-scope scenario passed.")
|
|
6040
|
+
return 0
|
|
6041
|
+
|
|
6042
|
+
|
|
5052
6043
|
def validate_dev_only_plugin_source_scoping_scenario() -> int:
|
|
5053
6044
|
"""Issue #6: plugins/keel/ exists only in Keel's own repository.
|
|
5054
6045
|
|
|
@@ -5230,6 +6221,7 @@ def tracker_owner_tasks(findings: str, closure: str) -> str:
|
|
|
5230
6221
|
" - Stop Rules:\n"
|
|
5231
6222
|
" - Stop on failure.\n"
|
|
5232
6223
|
" - Evidence:\n"
|
|
6224
|
+
" - Contract: pending\n"
|
|
5233
6225
|
" - M1: passed\n"
|
|
5234
6226
|
" - Review:\n"
|
|
5235
6227
|
" - Status: pass\n"
|
|
@@ -5264,6 +6256,7 @@ def validate_tracker_durable_owner_scenario() -> int:
|
|
|
5264
6256
|
|
|
5265
6257
|
def complete(findings: str, closure: str = "Covered by: 1.1"):
|
|
5266
6258
|
write_text(tasks, tracker_owner_tasks(findings, closure))
|
|
6259
|
+
record_contract_anchor(repo, "demo")
|
|
5267
6260
|
return run_keel(
|
|
5268
6261
|
repo, "gate", "task-complete",
|
|
5269
6262
|
"--change", "demo", "--task", "1.1", "--json",
|
|
@@ -5685,6 +6678,7 @@ def validate_task_capsule_scenario() -> int:
|
|
|
5685
6678
|
repo / "openspec/changes/demo/tasks.md",
|
|
5686
6679
|
completion_task,
|
|
5687
6680
|
)
|
|
6681
|
+
record_contract_anchor(repo, "demo")
|
|
5688
6682
|
completed = run_keel(
|
|
5689
6683
|
repo,
|
|
5690
6684
|
"gate",
|
|
@@ -6379,6 +7373,10 @@ def validate_core_gates_scenario() -> int:
|
|
|
6379
7373
|
" - Stop Rules:\n"
|
|
6380
7374
|
" - Stop on final assertion failure.\n"
|
|
6381
7375
|
" - Evidence:\n"
|
|
7376
|
+
# Read at call time, so every variant below carries the anchor
|
|
7377
|
+
# recorded once for this fixture. Evidence is not in the capsule,
|
|
7378
|
+
# so one fingerprint is correct for all of them.
|
|
7379
|
+
f" - Contract: {completion_anchor}\n"
|
|
6382
7380
|
f" - M1: {evidence}\n"
|
|
6383
7381
|
" - Review:\n"
|
|
6384
7382
|
f" - Status: {review_status}\n"
|
|
@@ -6392,6 +7390,19 @@ def validate_core_gates_scenario() -> int:
|
|
|
6392
7390
|
" - Summary\n"
|
|
6393
7391
|
)
|
|
6394
7392
|
|
|
7393
|
+
completion_anchor = "pending"
|
|
7394
|
+
write_text(completion_tasks, completion_task("pending", "pass"))
|
|
7395
|
+
if not record_contract_anchor(completion_repo, "demo"):
|
|
7396
|
+
report("core-gates scenario could not record the completion anchor.")
|
|
7397
|
+
return 1
|
|
7398
|
+
recorded_line = re.search(
|
|
7399
|
+
r"-\s*Contract:\s*(.+)", completion_tasks.read_text(encoding="utf-8")
|
|
7400
|
+
)
|
|
7401
|
+
if not recorded_line:
|
|
7402
|
+
report("core-gates scenario found no recorded Contract anchor.")
|
|
7403
|
+
return 1
|
|
7404
|
+
completion_anchor = recorded_line.group(1).strip()
|
|
7405
|
+
|
|
6395
7406
|
write_text(completion_tasks, completion_task("pending", "pass"))
|
|
6396
7407
|
missing_evidence = run_keel(
|
|
6397
7408
|
completion_repo,
|
|
@@ -7194,6 +8205,7 @@ def validate_scope_rename_attribution_scenario() -> int:
|
|
|
7194
8205
|
" - Stop Rules:\n"
|
|
7195
8206
|
" - Stop on final assertion failure.\n"
|
|
7196
8207
|
" - Evidence:\n"
|
|
8208
|
+
" - Contract: pending\n"
|
|
7197
8209
|
" - M1: passed\n"
|
|
7198
8210
|
" - Review:\n"
|
|
7199
8211
|
" - Status: pass\n"
|
|
@@ -7237,6 +8249,7 @@ def validate_scope_rename_attribution_scenario() -> int:
|
|
|
7237
8249
|
report("scope-rename scenario git mv failed:")
|
|
7238
8250
|
report((moved.stderr or moved.stdout).strip())
|
|
7239
8251
|
return 1
|
|
8252
|
+
record_contract_anchor(repo, "demo")
|
|
7240
8253
|
completed = run_keel(
|
|
7241
8254
|
repo,
|
|
7242
8255
|
"gate",
|
|
@@ -8598,6 +9611,7 @@ def validate_task_verification_strategies_scenario() -> int:
|
|
|
8598
9611
|
|
|
8599
9612
|
def run_completion(fixture: str) -> subprocess.CompletedProcess[str]:
|
|
8600
9613
|
write_text(tasks_path, fixture)
|
|
9614
|
+
record_contract_anchor(repo, "demo")
|
|
8601
9615
|
return run_keel(
|
|
8602
9616
|
repo,
|
|
8603
9617
|
"gate",
|
|
@@ -9337,6 +10351,7 @@ def _goal_task_block(
|
|
|
9337
10351
|
" - Stop Rules:",
|
|
9338
10352
|
" - stop on failure",
|
|
9339
10353
|
" - Evidence:",
|
|
10354
|
+
" - Contract: pending",
|
|
9340
10355
|
]
|
|
9341
10356
|
)
|
|
9342
10357
|
if filled:
|
|
@@ -10040,6 +11055,7 @@ def validate_native_goal_gate_order_scenario() -> int:
|
|
|
10040
11055
|
|
|
10041
11056
|
# 4. With evidence and a passing Review recorded, task-complete passes.
|
|
10042
11057
|
write_text(tasks_path, _goal_tasks_file([_goal_task_block(filled=True)]))
|
|
11058
|
+
record_contract_anchor(repo, "sample-change")
|
|
10043
11059
|
completed = run_keel(
|
|
10044
11060
|
repo, "gate", "task-complete",
|
|
10045
11061
|
"--change", "sample-change", "--task", "1.1", "--json",
|
|
@@ -10603,6 +11619,7 @@ def validate_single_task_goal_real_tasks_scenario() -> int:
|
|
|
10603
11619
|
[_goal_task_block(strategy=strategy, filled=True, redgreen=redgreen)]
|
|
10604
11620
|
),
|
|
10605
11621
|
)
|
|
11622
|
+
record_contract_anchor(repo, "sample-change")
|
|
10606
11623
|
done = run_keel(
|
|
10607
11624
|
repo, "gate", "task-complete",
|
|
10608
11625
|
"--change", "sample-change", "--task", "1.1", "--json", env=env,
|
|
@@ -10898,6 +11915,7 @@ def _single_task_matrix_target(target: str, root: Path) -> int:
|
|
|
10898
11915
|
]
|
|
10899
11916
|
),
|
|
10900
11917
|
)
|
|
11918
|
+
record_contract_anchor(repo, "sample-change")
|
|
10901
11919
|
done = run_keel(repo, "gate", "task-complete", "--change", "sample-change", "--task", "1.1", "--json", env=env)
|
|
10902
11920
|
if done.returncode != 0:
|
|
10903
11921
|
report("native-single-task-matrix %s could not complete with evidence." % target)
|
|
@@ -13106,6 +14124,32 @@ SCENARIOS: tuple = (
|
|
|
13106
14124
|
"unresolved-authority-names-field",
|
|
13107
14125
|
validate_unresolved_authority_names_field_scenario,
|
|
13108
14126
|
),
|
|
14127
|
+
(
|
|
14128
|
+
"covers-question-reference-scope",
|
|
14129
|
+
validate_covers_question_reference_scope_scenario,
|
|
14130
|
+
),
|
|
14131
|
+
(
|
|
14132
|
+
"non-concrete-check-names-token",
|
|
14133
|
+
validate_non_concrete_check_names_token_scenario,
|
|
14134
|
+
),
|
|
14135
|
+
(
|
|
14136
|
+
"absent-verification-form-is-one-problem",
|
|
14137
|
+
validate_absent_verification_form_is_one_problem_scenario,
|
|
14138
|
+
),
|
|
14139
|
+
(
|
|
14140
|
+
"task-complete-selection-requires-a-started-task",
|
|
14141
|
+
validate_task_complete_selection_requires_a_started_task_scenario,
|
|
14142
|
+
),
|
|
14143
|
+
("task-body-ends-at-heading", validate_task_body_ends_at_heading_scenario),
|
|
14144
|
+
(
|
|
14145
|
+
"completion-requires-a-recorded-anchor",
|
|
14146
|
+
validate_completion_requires_a_recorded_anchor_scenario,
|
|
14147
|
+
),
|
|
14148
|
+
("spec-template-validates", validate_spec_template_validates_scenario),
|
|
14149
|
+
(
|
|
14150
|
+
"tasks-template-red-green-example",
|
|
14151
|
+
validate_tasks_template_red_green_example_scenario,
|
|
14152
|
+
),
|
|
13109
14153
|
(
|
|
13110
14154
|
"dev-only-plugin-source-scoping",
|
|
13111
14155
|
validate_dev_only_plugin_source_scoping_scenario,
|