@christang/keel 5.59.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
|
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
|
|
|
@@ -27239,6 +27491,7 @@ SCENARIOS: tuple = (
|
|
|
27239
27491
|
("the-weakest-strategy-states-its-reason", validate_weakest_strategy_states_its_reason_scenario),
|
|
27240
27492
|
("a-quoted-marker-is-not-a-disposition", validate_quoted_marker_is_not_a_disposition_scenario),
|
|
27241
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),
|
|
27242
27495
|
("a-reference-outlives-its-declaration", validate_reference_outlives_its_declaration_scenario),
|
|
27243
27496
|
("the-obligation-is-stated-early", validate_obligation_is_stated_early_scenario),
|
|
27244
27497
|
("an-explanation-is-printed-once", validate_explanation_is_printed_once_scenario),
|
package/src/core/gates.js
CHANGED
|
@@ -1536,27 +1536,54 @@ function invalidationProblems(repo, content, tasks, change) {
|
|
|
1536
1536
|
return problems;
|
|
1537
1537
|
}
|
|
1538
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
|
+
|
|
1539
1566
|
function expectationProblems(repo, content, tasks, change) {
|
|
1540
1567
|
const heading = content.search(/^## Expectation Coverage\s*$/m);
|
|
1541
1568
|
if (heading < 0) {
|
|
1542
|
-
return [
|
|
1569
|
+
return { problems: [
|
|
1543
1570
|
problem(
|
|
1544
1571
|
"expectation-coverage",
|
|
1545
1572
|
"tasks.md requires a `## Expectation Coverage` section: one "
|
|
1546
1573
|
+ "`- E<n>: <expectation> Covered by: <task ids>` line per "
|
|
1547
1574
|
+ "expectation, or `- None.`."
|
|
1548
1575
|
),
|
|
1549
|
-
];
|
|
1576
|
+
], report: null };
|
|
1550
1577
|
}
|
|
1551
1578
|
const section = sectionBody(content, heading, tasks);
|
|
1552
|
-
if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
|
|
1579
|
+
if (/^\s*-\s+None\.?\s*$/im.test(section)) return { problems: [], report: null };
|
|
1553
1580
|
const entries = [
|
|
1554
1581
|
...section.matchAll(
|
|
1555
1582
|
/^\s*-\s+(E\d+)\s*:\s*([\s\S]*?)(?=^\s*-\s+E\d+\s*:|(?![\s\S]))/gm
|
|
1556
1583
|
),
|
|
1557
1584
|
];
|
|
1558
1585
|
if (entries.length === 0) {
|
|
1559
|
-
return [
|
|
1586
|
+
return { problems: [
|
|
1560
1587
|
problem(
|
|
1561
1588
|
"expectation-coverage",
|
|
1562
1589
|
"Expectation Coverage must declare each `E<n>` closure — "
|
|
@@ -1564,9 +1591,12 @@ function expectationProblems(repo, content, tasks, change) {
|
|
|
1564
1591
|
+ "path or `https://…` tracker reference, or a `Discard reason:` — "
|
|
1565
1592
|
+ "or `- None.`."
|
|
1566
1593
|
),
|
|
1567
|
-
];
|
|
1594
|
+
], report: null };
|
|
1568
1595
|
}
|
|
1569
1596
|
const problems = [];
|
|
1597
|
+
const coverage = coversByIdentifier(tasks);
|
|
1598
|
+
let compared = 0;
|
|
1599
|
+
let uncited = 0;
|
|
1570
1600
|
for (const entry of entries) {
|
|
1571
1601
|
const [, id, body] = entry;
|
|
1572
1602
|
const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
|
|
@@ -1617,9 +1647,52 @@ function expectationProblems(repo, content, tasks, change) {
|
|
|
1617
1647
|
);
|
|
1618
1648
|
}
|
|
1619
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
|
+
}
|
|
1620
1682
|
}
|
|
1621
1683
|
}
|
|
1622
|
-
|
|
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 };
|
|
1623
1696
|
}
|
|
1624
1697
|
|
|
1625
1698
|
function hasDeltaSpec(changePath) {
|
|
@@ -1708,9 +1781,10 @@ function changeClose(repo, options) {
|
|
|
1708
1781
|
)
|
|
1709
1782
|
);
|
|
1710
1783
|
}
|
|
1711
|
-
|
|
1712
|
-
|
|
1784
|
+
const expectations = expectationProblems(
|
|
1785
|
+
repo, selection.content, selection.tasks, selection.change
|
|
1713
1786
|
);
|
|
1787
|
+
problems.push(...expectations.problems);
|
|
1714
1788
|
problems.push(...changeVerifyProblems(selection.content, selection.tasks));
|
|
1715
1789
|
|
|
1716
1790
|
const changePath = path.dirname(selection.tasksPath);
|
|
@@ -1744,7 +1818,7 @@ function changeClose(repo, options) {
|
|
|
1744
1818
|
selection.change,
|
|
1745
1819
|
selection.tasks.map((task) => task.id),
|
|
1746
1820
|
[...problems, ...reviewProblems],
|
|
1747
|
-
[],
|
|
1821
|
+
expectations.report ? [expectations.report] : [],
|
|
1748
1822
|
null,
|
|
1749
1823
|
contracts
|
|
1750
1824
|
);
|