@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 +21 -0
- package/assets/bootstrap/AGENTS.md +1 -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 +468 -2
- package/src/core/context.js +4 -1
- package/src/core/gates.js +92 -11
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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.
|
|
41
|
-
PROTOCOL_VERSION = "5.
|
|
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
|
(
|
package/src/core/context.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1705
|
-
|
|
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
|
);
|