@christang/keel 5.58.0 → 5.60.0

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 CHANGED
@@ -364,6 +364,27 @@ keel lenses add web # copy the web template into keel/lenses/web.md, the
364
364
  keel lenses add web --force # overwrite an existing lens
365
365
  ```
366
366
 
367
+ ## What a coverage claim is checked against
368
+
369
+ `## Expectation Coverage` closes each expectation one of three ways, and each is checked. A
370
+ `Durable owner:` must name a path that exists or an `https://…` reference that already carries
371
+ its content. A `Discard reason:` must give a reason. And a `Covered by:` **that cites
372
+ expectation identifiers** is compared against the `Covers:` of the task it names:
373
+
374
+ ```
375
+ - E3: the records land in different flow generations (F4, D3). Covered by: 1.1
376
+ ```
377
+
378
+ `keel gate change-close` checks that task 1.1's `Covers:` actually names `F4` and `D3`. When it
379
+ does not, the refusal says so and says where the identifier *is* — the task of this change whose
380
+ `Covers:` holds it, or that none does.
381
+
382
+ **Citing identifiers is optional.** An entry that names none is not refused and not reported as
383
+ deficient; plenty of expectations are prose ("documentation and skills follow the behavior
384
+ changes above") and numbering them to satisfy a parser is worse than leaving them. What the check
385
+ holds you to is the claim you chose to make. So that a pass is not read as more than it is, the
386
+ close reports how many entries it compared and how many it did not.
387
+
367
388
  ## Re-recording a contract
368
389
 
369
390
  Changing a task's contract after work has started moves its fingerprint, and Keel reports that the
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.58.0 -->
1
+ <!-- keel:start version=5.60.0 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.58.0",
5
+ "version": "5.60.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.58.0",
3
+ "version": "5.60.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.58.0",
3
+ "version": "5.60.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.58.0"
41
- PROTOCOL_VERSION = "5.58.0"
40
+ PACKAGE_VERSION = "5.60.0"
41
+ PROTOCOL_VERSION = "5.60.0"
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
@@ -26016,6 +26016,258 @@ def validate_drift_names_where_to_look_scenario() -> int:
26016
26016
  # `M<n>` was reported as naming a check the task does not declare — first, and
26017
26017
  # as somebody else's fault. The reporter calls it the only diagnostic in 149
26018
26018
  # invocations that made them edit the wrong file.
26019
+
26020
+ # `## Expectation Coverage` carries the protocol's one global assertion — every
26021
+ # expectation has an owner — and 80% of its closures were the single form with
26022
+ # nothing behind them. Issue #133 ran the set difference by hand over 24
26023
+ # archived changes and found six wrong claims, all of them already past
26024
+ # change-close and a semantic Review. The operation that finds them is between
26025
+ # two identifier lists the author already declared, in one file.
26026
+ def validate_coverage_claim_is_compared_scenario() -> int:
26027
+ label = "a-coverage-claim-is-compared"
26028
+
26029
+ design = (
26030
+ "## Context\n\nfixture\n\n## Decisions\n\n"
26031
+ "- F1 — the first fact the fixture declares.\n"
26032
+ "- F2 — the second fact the fixture declares.\n"
26033
+ "- D1 — the decision no task of the fixture covers.\n"
26034
+ )
26035
+
26036
+ def task(tid: str, covers: str) -> str:
26037
+ return "\n".join((
26038
+ f"- [x] {tid} Coverage probe",
26039
+ " - Owner: claude",
26040
+ " - Mode: implementation",
26041
+ " - Covers:",
26042
+ f" - {covers}",
26043
+ " - Read:",
26044
+ " - README.md",
26045
+ " - Touch:",
26046
+ " - src/example.js",
26047
+ " - Verify:",
26048
+ " - Strategy: evidence-first",
26049
+ " - Reason: fixture; nothing here can fail first",
26050
+ " - M1: the check asserts the public behavior",
26051
+ " - Autonomy boundary:",
26052
+ " - Default: hard-stop",
26053
+ " - Pre-authorized fallback: none",
26054
+ " - Stop Rules:",
26055
+ " - Stop if the fixture needs a decision it cannot make.",
26056
+ " - Evidence:",
26057
+ " - Contract: pending",
26058
+ " - M1: pass. ran it.",
26059
+ " - Review:",
26060
+ " - Status: pass",
26061
+ " - Acceptance check: M1 asserts the behavior at its interface.",
26062
+ " - Scope check: only src/example.js changed.",
26063
+ " - Findings: none",
26064
+ " - Blocker: none",
26065
+ " - Reauthorizations: none",
26066
+ "",
26067
+ ))
26068
+
26069
+ def fixture(root: Path, name: str, coverage: str) -> Path:
26070
+ repo = root / name
26071
+ write_gate_fixture(repo, tasks="", design=design)
26072
+ write_text(
26073
+ repo / "openspec/changes/demo/tasks.md",
26074
+ "# Tasks\n\n"
26075
+ + "## Work\n\n"
26076
+ + task("1.1", "F1")
26077
+ + "\n"
26078
+ + task("2.1", "F2")
26079
+ + "\n## Invalidates\n\n- None.\n\n"
26080
+ + "## Expectation Coverage\n\n"
26081
+ + coverage
26082
+ + "\n",
26083
+ )
26084
+ # Both anchors, in order: `Contract: pending` is replaced once per task,
26085
+ # first occurrence first, and a task's fingerprint covers only its own
26086
+ # authority text, so recording one does not move the other.
26087
+ tasks_path = repo / "openspec/changes/demo/tasks.md"
26088
+ for tid in ("1.1", "2.1"):
26089
+ started = run_keel(
26090
+ repo, "gate", "task-start", "--change", "demo", "--task", tid,
26091
+ "--json", "--no-guard",
26092
+ )
26093
+ try:
26094
+ payload = json.loads(started.stdout)
26095
+ except json.JSONDecodeError:
26096
+ report(
26097
+ f"{label}: the fixture's task {tid} did not start, so no "
26098
+ f"anchor could be recorded; "
26099
+ f"{(started.stderr or started.stdout).strip()[:400]!r}."
26100
+ )
26101
+ raise SystemExit(1)
26102
+ value = (
26103
+ ((payload.get("contract") or {}).get("fingerprint") or {})
26104
+ .get("value") or "0" * 64
26105
+ )
26106
+ tasks_path.write_text(
26107
+ tasks_path.read_text(encoding="utf-8").replace(
26108
+ " - Contract: pending",
26109
+ f" - Contract: keel-task-capsule/v1 sha256:{value}",
26110
+ 1,
26111
+ ),
26112
+ encoding="utf-8",
26113
+ )
26114
+ return repo
26115
+
26116
+ def close(repo: Path) -> dict:
26117
+ result = run_keel(
26118
+ repo, "gate", "change-close", "--change", "demo", "--action",
26119
+ "archive", "--json",
26120
+ )
26121
+ try:
26122
+ return json.loads(result.stdout)
26123
+ except json.JSONDecodeError:
26124
+ return {"status": "unparsed", "problems": [
26125
+ {"code": "unparsed", "message": result.stdout[:300]}
26126
+ ]}
26127
+
26128
+ def said(payload: dict) -> str:
26129
+ return " ".join(str(x) for x in (payload.get("warnings") or []))
26130
+
26131
+ agree = "- E1: the first expectation (F1). Covered by: 1.1\n" \
26132
+ "- E2: the second expectation (F2). Covered by: 2.1\n"
26133
+ # The identifier is covered — by the other task.
26134
+ mismatch = "- E1: the first expectation (F2). Covered by: 1.1\n" \
26135
+ "- E2: the second expectation. Covered by: 2.1\n"
26136
+ # The identifier is covered by nothing.
26137
+ orphan = "- E1: the first expectation (D1). Covered by: 1.1\n" \
26138
+ "- E2: the second expectation. Covered by: 2.1\n"
26139
+ # Same entry as `mismatch` with the citation removed, and nothing else.
26140
+ prose = "- E1: the first expectation. Covered by: 1.1\n" \
26141
+ "- E2: the second expectation. Covered by: 2.1\n"
26142
+ # Issue #133's contradiction: one identifier claimed covered and deferred.
26143
+ contradiction = "- E1: the first expectation (F2). Covered by: 1.1\n" \
26144
+ "- E2: the same identifier, deferred (F2). " \
26145
+ "Durable owner: https://github.com/TanglmChris/keel/issues/133\n"
26146
+
26147
+ with tempfile.TemporaryDirectory(prefix="keel-coverage-claim-") as raw:
26148
+ root = Path(raw)
26149
+
26150
+ # The control comes first: without a fixture that closes, every later
26151
+ # refusal could be something else refusing.
26152
+ settled = close(fixture(root, "agree", agree))
26153
+ if settled.get("status") != "pass":
26154
+ report(
26155
+ f"{label}: the agreeing fixture does not close, so no later "
26156
+ f"refusal can be attributed; {problem_text(settled)!r}."
26157
+ )
26158
+ return 1
26159
+ wrong = close(fixture(root, "mismatch", mismatch))
26160
+ if wrong.get("status") != "fail":
26161
+ report(
26162
+ f"{label}: a claim that task 1.1 covers F2 was accepted while "
26163
+ f"1.1's Covers names only F1; change-close returned "
26164
+ f"Status: {wrong.get('status')}."
26165
+ )
26166
+ return 1
26167
+ text = problem_text(wrong)
26168
+ if "E1" not in text:
26169
+ report(f"{label}: the refusal does not name the entry; {text!r}.")
26170
+ return 1
26171
+ if "F2" not in text:
26172
+ report(
26173
+ f"{label}: the refusal does not name the identifier it could "
26174
+ f"not find; {text!r}."
26175
+ )
26176
+ return 1
26177
+ if "2.1" not in text:
26178
+ report(
26179
+ f"{label}: the refusal does not name the task whose Covers "
26180
+ f"does hold F2, so the author is told the claim is wrong and "
26181
+ f"must re-derive which of three shapes it is; {text!r}."
26182
+ )
26183
+ return 1
26184
+
26185
+ nowhere = close(fixture(root, "orphan", orphan))
26186
+ if nowhere.get("status") != "fail":
26187
+ report(
26188
+ f"{label}: a claim naming an identifier no task covers was "
26189
+ f"accepted; change-close returned Status: {nowhere.get('status')}."
26190
+ )
26191
+ return 1
26192
+ text = problem_text(nowhere)
26193
+ if "D1" not in text:
26194
+ report(
26195
+ f"{label}: the refusal does not name the uncovered identifier; "
26196
+ f"{text!r}."
26197
+ )
26198
+ return 1
26199
+ # The distinction is which of the two halves the refusal chose, not
26200
+ # whether a task id appears at all — the entry's own claim names one.
26201
+ if "is covered by task" in text:
26202
+ report(
26203
+ f"{label}: the refusal points at a task as the place D1 is "
26204
+ f"covered, which sends the author to a capsule with nothing "
26205
+ f"wrong in it; {text!r}."
26206
+ )
26207
+ return 1
26208
+ if "No task of this change covers" not in text:
26209
+ report(
26210
+ f"{label}: the refusal does not say that no task covers it, so "
26211
+ f"the shape issue #133 measured as the serious one is not told "
26212
+ f"apart from a wrong task id; {text!r}."
26213
+ )
26214
+ return 1
26215
+
26216
+ # Citing is optional, and the fixture proving it is the mismatch with
26217
+ # its one citation removed — so a rule that refused prose would have to
26218
+ # refuse this and nothing else about it changed.
26219
+ quiet = close(fixture(root, "prose", prose))
26220
+ if quiet.get("status") != "pass":
26221
+ report(
26222
+ f"{label}: an entry citing no identifier was refused; "
26223
+ f"{problem_text(quiet)!r}."
26224
+ )
26225
+ return 1
26226
+
26227
+ # D3: the report's second suggestion is unnecessary, because the
26228
+ # comparison already refuses the case it was aimed at.
26229
+ both = close(fixture(root, "contradiction", contradiction))
26230
+ if both.get("status") != "fail":
26231
+ report(
26232
+ f"{label}: the same identifier claimed covered by a task that "
26233
+ f"omits it and deferred to a tracker was accepted; "
26234
+ f"change-close returned Status: {both.get('status')}."
26235
+ )
26236
+ return 1
26237
+ text = problem_text(both)
26238
+ if "F2" not in text:
26239
+ report(
26240
+ f"{label}: the contradiction was refused for something other "
26241
+ f"than the identifier it is about; {text!r}."
26242
+ )
26243
+ return 1
26244
+
26245
+ # How far the comparison reached, read off the control run above.
26246
+ spoken = said(settled)
26247
+ if "Expectation Coverage" not in spoken:
26248
+ report(
26249
+ f"{label}: the close does not report how much of the section "
26250
+ f"it compared, so a pass reads as a warrant it does not carry; "
26251
+ f"{spoken!r}."
26252
+ )
26253
+ return 1
26254
+ if "2" not in spoken:
26255
+ report(
26256
+ f"{label}: the report does not carry the compared count; "
26257
+ f"{spoken!r}."
26258
+ )
26259
+ return 1
26260
+ if "0" not in spoken:
26261
+ report(
26262
+ f"{label}: the report is suppressed when every entry was "
26263
+ f"compared, which teaches a reader that its absence means full "
26264
+ f"coverage; {spoken!r}."
26265
+ )
26266
+ return 1
26267
+
26268
+ report(f"{label} scenario passed.")
26269
+ return 0
26270
+
26019
26271
  def validate_reference_outlives_its_declaration_scenario() -> int:
26020
26272
  label = "a-reference-outlives-its-declaration"
26021
26273
 
@@ -26606,6 +26858,215 @@ def validate_evidence_survives_what_did_not_change_scenario() -> int:
26606
26858
  return 0
26607
26859
 
26608
26860
 
26861
+ # 5.55.0 built the way out of a blanket "all of it is stale" and signed it
26862
+ # nowhere a reader would look: issue #134 grepped the installed package and
26863
+ # found `--keep-evidence` only in its own two refusals, so the population told
26864
+ # it exists is exactly the population already using it. One use in 67 task
26865
+ # capsules, against a median re-verification of 256 s. Every exit that tells an
26866
+ # author their evidence is stale now names it — and none of them names which
26867
+ # checks it would apply to, because the gate keeps only the previous
26868
+ # fingerprint and cannot know.
26869
+ def validate_staleness_report_names_its_exception_scenario() -> int:
26870
+ label = "a-staleness-report-names-its-exception"
26871
+ flag = "--keep-evidence"
26872
+
26873
+ def fixture(root: Path, name: str) -> Path:
26874
+ repo = root / name
26875
+ task = strategy_probe_task(
26876
+ strategy="evidence-first",
26877
+ reason="fixture; nothing here can fail first",
26878
+ commands=(
26879
+ "M1: the first check asserts the public behavior",
26880
+ "M2: the second check asserts the public behavior",
26881
+ "M3: the third check asserts the public behavior",
26882
+ ),
26883
+ )
26884
+ # A recorded anchor that is not the compiled one, so every exit fires.
26885
+ task = task.replace(
26886
+ " - Contract: pending",
26887
+ " - Contract: keel-task-capsule/v1 sha256:" + "0" * 64,
26888
+ )
26889
+ write_gate_fixture(repo, tasks=task)
26890
+ return repo
26891
+
26892
+ def keel_json(repo: Path, *args) -> dict:
26893
+ result = run_keel(repo, *args)
26894
+ try:
26895
+ return json.loads(result.stdout)
26896
+ except json.JSONDecodeError:
26897
+ return {"status": "unparsed", "problems": [
26898
+ {"code": "unparsed", "message": result.stdout[:300]}
26899
+ ]}
26900
+
26901
+ def message_for(payload: dict, code: str) -> str:
26902
+ for entry in payload.get("problems") or []:
26903
+ if str(entry.get("code", "")) == code:
26904
+ return str(entry.get("message", ""))
26905
+ return ""
26906
+
26907
+ # The suggestion has to carry the flag's own contract, not just its name:
26908
+ # when it applies, and that the claim is the author's to defend.
26909
+ def states_the_condition(text: str) -> bool:
26910
+ return "assertion" in text.lower()
26911
+
26912
+ def sends_the_reason_home(text: str) -> bool:
26913
+ return "Reauthorizations" in text
26914
+
26915
+ def names_a_check(text: str) -> str:
26916
+ return next((m for m in ("M1", "M2", "M3") if m in text), "")
26917
+
26918
+ with tempfile.TemporaryDirectory(prefix="keel-names-exception-") as raw:
26919
+ root = Path(raw)
26920
+
26921
+ # Exit 1 of 3 — `task-start --record` with no declaration. This is the
26922
+ # branch issue #134 names, and the only one whose reader is mid-record.
26923
+ blanket = keel_json(
26924
+ fixture(root, "blanket"), "gate", "task-start", "--change", "demo",
26925
+ "--task", "1.1", "--json", "--no-guard", "--record",
26926
+ )
26927
+ if blanket.get("status") != "pass":
26928
+ report(
26929
+ f"{label}: the re-record fixture did not pass, so no "
26930
+ f"stale-evidence report was produced; {problem_text(blanket)!r}."
26931
+ )
26932
+ return 1
26933
+ warned = " ".join(str(x) for x in (blanket.get("warnings") or []))
26934
+ if flag not in warned:
26935
+ report(
26936
+ f"{label}: the blanket stale-evidence report does not name "
26937
+ f"{flag}, so an author who has not already used it re-verifies "
26938
+ f"everything; {warned!r}."
26939
+ )
26940
+ return 1
26941
+ if not states_the_condition(warned):
26942
+ report(
26943
+ f"{label}: the blanket report names {flag} without saying when "
26944
+ f"it applies — that the check's assertion did not move; "
26945
+ f"{warned!r}."
26946
+ )
26947
+ return 1
26948
+ if not sends_the_reason_home(warned):
26949
+ report(
26950
+ f"{label}: the blanket report names {flag} without saying the "
26951
+ f"reason belongs in Reauthorizations, where it can be "
26952
+ f"disagreed with; {warned!r}."
26953
+ )
26954
+ return 1
26955
+ named = names_a_check(warned)
26956
+ if named:
26957
+ report(
26958
+ f"{label}: the blanket report names {named} while suggesting "
26959
+ f"{flag}. The gate keeps only the previous fingerprint and "
26960
+ f"cannot know which checks are unaffected; {warned!r}."
26961
+ )
26962
+ return 1
26963
+
26964
+ # Exit 2 of 3 — `task-complete`'s contract-drift refusal, which already
26965
+ # prints the command the flag belongs to.
26966
+ completing = fixture(root, "completing")
26967
+ done = keel_json(
26968
+ completing, "gate", "task-complete", "--change", "demo",
26969
+ "--task", "1.1", "--json",
26970
+ )
26971
+ drift = message_for(done, "contract-drift")
26972
+ if not drift:
26973
+ report(
26974
+ f"{label}: the drifted fixture did not produce a "
26975
+ f"contract-drift refusal; {problem_codes(done)!r}."
26976
+ )
26977
+ return 1
26978
+ if "keel gate task-start --record" not in drift:
26979
+ report(
26980
+ f"{label}: the contract-drift refusal stopped naming the "
26981
+ f"reauthorization command, which is what makes the flag "
26982
+ f"belong in it; {drift!r}."
26983
+ )
26984
+ return 1
26985
+ if flag not in drift:
26986
+ report(
26987
+ f"{label}: the contract-drift refusal does not name {flag} "
26988
+ f"even though it names the command that accepts it; {drift!r}."
26989
+ )
26990
+ return 1
26991
+ if not states_the_condition(drift):
26992
+ report(
26993
+ f"{label}: the contract-drift refusal names {flag} without "
26994
+ f"saying when it applies; {drift!r}."
26995
+ )
26996
+ return 1
26997
+ named = names_a_check(drift)
26998
+ if named:
26999
+ report(
27000
+ f"{label}: the contract-drift refusal names {named} while "
27001
+ f"suggesting {flag}; {drift!r}."
27002
+ )
27003
+ return 1
27004
+
27005
+ # Exit 3 of 3 — the context drift hard-stop, which already says to
27006
+ # re-run task-start and record the new anchor.
27007
+ blocked = keel_json(fixture(root, "blocked"), "context", "--json")
27008
+ reasons = " ".join(str(x) for x in (blocked.get("reasons") or []))
27009
+ if blocked.get("status") != "blocked":
27010
+ report(
27011
+ f"{label}: the drifted fixture did not block context, so the "
27012
+ f"drift hard-stop did not fire; {blocked.get('status')!r}."
27013
+ )
27014
+ return 1
27015
+ if "drift" not in reasons.lower():
27016
+ report(
27017
+ f"{label}: context blocked for something other than the "
27018
+ f"contract drift this fixture builds; {reasons!r}."
27019
+ )
27020
+ return 1
27021
+ if flag not in reasons:
27022
+ report(
27023
+ f"{label}: the context drift hard-stop does not name {flag} "
27024
+ f"even though it tells the reader to re-record the anchor; "
27025
+ f"{reasons!r}."
27026
+ )
27027
+ return 1
27028
+ if not states_the_condition(reasons):
27029
+ report(
27030
+ f"{label}: the context drift hard-stop names {flag} without "
27031
+ f"saying when it applies; {reasons!r}."
27032
+ )
27033
+ return 1
27034
+ named = names_a_check(reasons)
27035
+ if named:
27036
+ report(
27037
+ f"{label}: the context drift hard-stop names {named} while "
27038
+ f"suggesting {flag}; {reasons!r}."
27039
+ )
27040
+ return 1
27041
+
27042
+ # The already-narrowed report does not repeat the suggestion. Absence
27043
+ # is also what a broken narrowing produces, so the positive control
27044
+ # runs first: the branch has to be alive before its silence means
27045
+ # anything.
27046
+ narrowed = keel_json(
27047
+ fixture(root, "narrowed"), "gate", "task-start", "--change", "demo",
27048
+ "--task", "1.1", "--json", "--no-guard", "--record",
27049
+ "--keep-evidence", "M1,M3",
27050
+ )
27051
+ narrowed_said = " ".join(str(x) for x in (narrowed.get("warnings") or []))
27052
+ if "declared unaffected" not in narrowed_said:
27053
+ report(
27054
+ f"{label}: the narrowed branch did not report a declaration, "
27055
+ f"so its silence about {flag} proves nothing; "
27056
+ f"{narrowed_said!r}."
27057
+ )
27058
+ return 1
27059
+ if flag in narrowed_said:
27060
+ report(
27061
+ f"{label}: the narrowed report suggests {flag} to a reader who "
27062
+ f"just used it; {narrowed_said!r}."
27063
+ )
27064
+ return 1
27065
+
27066
+ report(f"{label} scenario passed.")
27067
+ return 0
27068
+
27069
+
26609
27070
  # `keel context` exists to answer "what now" and answered with a noun. Issue
26610
27071
  # #112: `Next action: change-close` followed by `keel gate change-close
26611
27072
  # --change x` failing on the argument that stage requires — a wrong attempt
@@ -27030,11 +27491,16 @@ SCENARIOS: tuple = (
27030
27491
  ("the-weakest-strategy-states-its-reason", validate_weakest_strategy_states_its_reason_scenario),
27031
27492
  ("a-quoted-marker-is-not-a-disposition", validate_quoted_marker_is_not_a_disposition_scenario),
27032
27493
  ("drift-names-where-to-look", validate_drift_names_where_to_look_scenario),
27494
+ ("a-coverage-claim-is-compared", validate_coverage_claim_is_compared_scenario),
27033
27495
  ("a-reference-outlives-its-declaration", validate_reference_outlives_its_declaration_scenario),
27034
27496
  ("the-obligation-is-stated-early", validate_obligation_is_stated_early_scenario),
27035
27497
  ("an-explanation-is-printed-once", validate_explanation_is_printed_once_scenario),
27036
27498
  ("a-paused-change-is-not-the-next-action", validate_paused_change_is_not_the_next_action_scenario),
27037
27499
  ("evidence-survives-what-did-not-change", validate_evidence_survives_what_did_not_change_scenario),
27500
+ (
27501
+ "a-staleness-report-names-its-exception",
27502
+ validate_staleness_report_names_its_exception_scenario,
27503
+ ),
27038
27504
  ("the-next-action-is-a-command", validate_next_action_is_a_command_scenario),
27039
27505
  ("the-overlay-names-the-invocation", validate_overlay_names_the_invocation_scenario),
27040
27506
  (
@@ -129,7 +129,10 @@ function driftSearchSet(contract) {
129
129
  `${where} Evidence, Review, and the task checkbox are not covered — `
130
130
  + "editing them does not move it. Reauthorize by re-running "
131
131
  + "`keel gate task-start` and recording the new anchor, after confirming "
132
- + "the change to the authority above was intended."
132
+ + "the change to the authority above was intended. If a check's assertion "
133
+ + "did not move, add `--keep-evidence <check>` to that re-record; Keel "
134
+ + "records that claim and does not verify it, so state the reason in "
135
+ + "Reauthorizations."
133
136
  );
134
137
  }
135
138
 
package/src/core/gates.js CHANGED
@@ -147,7 +147,10 @@ function contractDriftProblem(recorded, contract) {
147
147
  + "implemented under is not the authority it is being judged against. "
148
148
  + "Reauthorize with `keel gate task-start --record`, which rewrites the "
149
149
  + "anchor in place; execution evidence produced under the previous "
150
- + "contract is stale and has to be cleared or re-verified first."
150
+ + "contract is stale and has to be cleared or re-verified first. If a "
151
+ + "check's assertion did not move, add `--keep-evidence <check>` to that "
152
+ + "re-record; Keel records that claim and does not verify it, so state "
153
+ + "the reason in Reauthorizations."
151
154
  );
152
155
  }
153
156
 
@@ -434,7 +437,11 @@ function taskStart(repo, options) {
434
437
  + "previous fingerprint and cannot compare a check's former text "
435
438
  + "to its current one. State the reason in Reauthorizations."
436
439
  : "Execution evidence produced under the previous contract is "
437
- + "stale; clear or re-verify it before completing this task.")
440
+ + "stale; clear or re-verify it before completing this task. "
441
+ + "If a check's assertion did not move, re-record with "
442
+ + "`--keep-evidence <check>` to say so; Keel records that claim "
443
+ + "and does not verify it, so state the reason in "
444
+ + "Reauthorizations.")
438
445
  );
439
446
  }
440
447
  }
@@ -1529,27 +1536,54 @@ function invalidationProblems(repo, content, tasks, change) {
1529
1536
  return problems;
1530
1537
  }
1531
1538
 
1539
+ // `Covered by:` is the closure form with nothing behind it. `Durable owner:`
1540
+ // checks that the path or reference exists; `Discard reason:` requires a
1541
+ // reason; a coverage claim was checked only for whether the task it named was
1542
+ // checked, never for whether that task claimed the same thing back. Issue #133
1543
+ // ran the set difference by hand over 24 archived changes: 6 of the 42 citing
1544
+ // entries were wrong, all of them already past this gate and a semantic Review.
1545
+ // The two lists are declared, structured, and in one file — a reader will not
1546
+ // diff them by eye, which is the whole reason it is worth a gate.
1547
+ //
1548
+ // `I<n>` is deliberately not compared: an `## Invalidates` entry has its own
1549
+ // closure check that already requires `Updated by:` to name tasks of this
1550
+ // change, so an E entry citing one is a second and weaker claim about
1551
+ // something already owned.
1552
+ const CRITICAL_ID = /\b([FDAQ]\d+)\b/g;
1553
+
1554
+ function coversByIdentifier(tasks) {
1555
+ const index = new Map();
1556
+ for (const task of tasks) {
1557
+ for (const match of field(task, "Covers").matchAll(CRITICAL_ID)) {
1558
+ const list = index.get(match[1]) || [];
1559
+ if (!list.includes(task.id)) list.push(task.id);
1560
+ index.set(match[1], list);
1561
+ }
1562
+ }
1563
+ return index;
1564
+ }
1565
+
1532
1566
  function expectationProblems(repo, content, tasks, change) {
1533
1567
  const heading = content.search(/^## Expectation Coverage\s*$/m);
1534
1568
  if (heading < 0) {
1535
- return [
1569
+ return { problems: [
1536
1570
  problem(
1537
1571
  "expectation-coverage",
1538
1572
  "tasks.md requires a `## Expectation Coverage` section: one "
1539
1573
  + "`- E<n>: <expectation> Covered by: <task ids>` line per "
1540
1574
  + "expectation, or `- None.`."
1541
1575
  ),
1542
- ];
1576
+ ], report: null };
1543
1577
  }
1544
1578
  const section = sectionBody(content, heading, tasks);
1545
- if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
1579
+ if (/^\s*-\s+None\.?\s*$/im.test(section)) return { problems: [], report: null };
1546
1580
  const entries = [
1547
1581
  ...section.matchAll(
1548
1582
  /^\s*-\s+(E\d+)\s*:\s*([\s\S]*?)(?=^\s*-\s+E\d+\s*:|(?![\s\S]))/gm
1549
1583
  ),
1550
1584
  ];
1551
1585
  if (entries.length === 0) {
1552
- return [
1586
+ return { problems: [
1553
1587
  problem(
1554
1588
  "expectation-coverage",
1555
1589
  "Expectation Coverage must declare each `E<n>` closure — "
@@ -1557,9 +1591,12 @@ function expectationProblems(repo, content, tasks, change) {
1557
1591
  + "path or `https://…` tracker reference, or a `Discard reason:` — "
1558
1592
  + "or `- None.`."
1559
1593
  ),
1560
- ];
1594
+ ], report: null };
1561
1595
  }
1562
1596
  const problems = [];
1597
+ const coverage = coversByIdentifier(tasks);
1598
+ let compared = 0;
1599
+ let uncited = 0;
1563
1600
  for (const entry of entries) {
1564
1601
  const [, id, body] = entry;
1565
1602
  const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
@@ -1610,9 +1647,52 @@ function expectationProblems(repo, content, tasks, change) {
1610
1647
  );
1611
1648
  }
1612
1649
  }
1650
+ // Read from the entry with its closure clause removed, so a task id in
1651
+ // `Covered by:` can never be mistaken for a statement identifier.
1652
+ const claim = body.replace(/Covered by:[\s\S]*/i, "");
1653
+ const cited = [
1654
+ ...new Set([...claim.matchAll(CRITICAL_ID)].map((match) => match[1])),
1655
+ ];
1656
+ if (cited.length === 0) uncited += 1;
1657
+ else compared += 1;
1658
+ for (const reference of cited) {
1659
+ if (ids.some((taskId) => (coverage.get(reference) || []).includes(taskId))) {
1660
+ continue;
1661
+ }
1662
+ const elsewhere = coverage.get(reference) || [];
1663
+ // Naming where it actually is turns the three shapes issue #133
1664
+ // measured — wrong task, deferred and claimed at once, covered by
1665
+ // nothing — into one edit instead of an investigation. The information
1666
+ // is free: the same parse already holds every task's Covers.
1667
+ problems.push(
1668
+ problem(
1669
+ "expectation-coverage-mismatch",
1670
+ `${id} says ${ids.join(", ")} covers ${reference}, and `
1671
+ + `${ids.length > 1 ? "none of those tasks names" : `task ${ids[0]} does not name`} `
1672
+ + `it in Covers. ${
1673
+ elsewhere.length > 0
1674
+ ? `${reference} is covered by task ${elsewhere.join(", ")}.`
1675
+ : `No task of this change covers ${reference}.`
1676
+ } Name the task that covers it, add ${reference} to the Covers `
1677
+ + "of the task named, or drop the citation if the mention is not "
1678
+ + "a coverage claim."
1679
+ )
1680
+ );
1681
+ }
1613
1682
  }
1614
1683
  }
1615
- return problems;
1684
+ // Stated whatever the numbers are: a line that appears only when something
1685
+ // was skipped teaches the reader that its absence means full coverage, and
1686
+ // in the repository that filed #133 the uncompared share is 58%.
1687
+ const report =
1688
+ compared + uncited > 0
1689
+ ? `Expectation Coverage: compared ${compared} of ${compared + uncited} `
1690
+ + `\`Covered by:\` ${compared + uncited === 1 ? "entry" : "entries"} `
1691
+ + `against the Covers of the task named; ${uncited} cited no `
1692
+ + `expectation identifier and ${uncited === 1 ? "was" : "were"} not `
1693
+ + "compared."
1694
+ : null;
1695
+ return { problems, report };
1616
1696
  }
1617
1697
 
1618
1698
  function hasDeltaSpec(changePath) {
@@ -1701,9 +1781,10 @@ function changeClose(repo, options) {
1701
1781
  )
1702
1782
  );
1703
1783
  }
1704
- problems.push(
1705
- ...expectationProblems(repo, selection.content, selection.tasks, selection.change)
1784
+ const expectations = expectationProblems(
1785
+ repo, selection.content, selection.tasks, selection.change
1706
1786
  );
1787
+ problems.push(...expectations.problems);
1707
1788
  problems.push(...changeVerifyProblems(selection.content, selection.tasks));
1708
1789
 
1709
1790
  const changePath = path.dirname(selection.tasksPath);
@@ -1737,7 +1818,7 @@ function changeClose(repo, options) {
1737
1818
  selection.change,
1738
1819
  selection.tasks.map((task) => task.id),
1739
1820
  [...problems, ...reviewProblems],
1740
- [],
1821
+ expectations.report ? [expectations.report] : [],
1741
1822
  null,
1742
1823
  contracts
1743
1824
  );