@christang/keel 5.59.0 → 5.61.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.59.0 -->
1
+ <!-- keel:start version=5.61.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.59.0",
5
+ "version": "5.61.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.59.0",
3
+ "version": "5.61.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.59.0",
3
+ "version": "5.61.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",
@@ -3,6 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import contextlib
6
7
  import json
7
8
  import hashlib
8
9
  import os
@@ -37,8 +38,8 @@ REQUIRED_SCRIPTS = [
37
38
  "scripts/validate_plugin.py",
38
39
  ]
39
40
 
40
- PACKAGE_VERSION = "5.59.0"
41
- PROTOCOL_VERSION = "5.59.0"
41
+ PACKAGE_VERSION = "5.61.0"
42
+ PROTOCOL_VERSION = "5.61.0"
42
43
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
43
44
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
44
45
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -5521,36 +5522,46 @@ def validate_a_declared_dependency_is_resolved_scenario() -> int:
5521
5522
  """
5522
5523
  label = "a-declared-dependency-is-resolved"
5523
5524
 
5524
- # `no openspec on PATH`, and nothing else removed. Emptying PATH outright
5525
- # would also remove `node`, and the openspec shim needs it — the scenario
5526
- # would then be asserting that a shell without an interpreter fails.
5527
- without = dict(os.environ)
5528
- without["PATH"] = os.pathsep.join(
5529
- entry
5530
- for entry in os.environ.get("PATH", "").split(os.pathsep)
5531
- if entry and not (Path(entry) / "openspec").exists()
5532
- )
5533
- if shutil.which("openspec", path=without["PATH"]) is not None:
5534
- report(
5535
- f"{label}: could not build a PATH without openspec on it, so the "
5536
- "reproduction cannot be set up."
5537
- )
5538
- return 1
5539
- resolved = run_openspec(ROOT, "--version", env=without)
5540
- if resolved is None:
5541
- report(
5542
- f"{label}: with no `openspec` on PATH the runner resolved nothing, "
5543
- "but this package declares it as a dependency and installs it at "
5544
- "node_modules/.bin. A tool the repository ships is not a tool the "
5545
- "host has to provide."
5546
- )
5547
- return 1
5548
- if resolved.returncode != 0 or not re.search(r"\d+\.\d+\.\d+", resolved.stdout):
5549
- report(
5550
- f"{label}: the resolved openspec did not report a version. "
5551
- f"exit={resolved.returncode} out={(resolved.stdout or '').strip()!r}"
5552
- )
5553
- return 1
5525
+ # `no openspec on PATH`, and nothing else removed — by file, not by
5526
+ # directory. Removing the directory would also remove `node`, and the
5527
+ # openspec shim needs it, so the scenario would be asserting that a shell
5528
+ # without an interpreter fails (issue #137).
5529
+ with path_without_openspec() as sanitized:
5530
+ without = dict(os.environ)
5531
+ without["PATH"] = sanitized
5532
+ if shutil.which("openspec", path=without["PATH"]) is not None:
5533
+ report(
5534
+ f"{label}: could not build a PATH without openspec on it, so the "
5535
+ "reproduction cannot be set up."
5536
+ )
5537
+ return 1
5538
+ # The scenario's own precondition, asserted before the behavior. A shim
5539
+ # with no interpreter exits 127, and calling that a fact about openspec
5540
+ # sends the reader to a tool with nothing wrong with it.
5541
+ if shutil.which("node", path=without["PATH"]) is None:
5542
+ return skip_scenario(
5543
+ label,
5544
+ "node does not resolve on the PATH this scenario built, so the "
5545
+ "openspec shim it resolves would have no interpreter. That is a "
5546
+ "broken fixture, not a fact about openspec.",
5547
+ )
5548
+ resolved = run_openspec(ROOT, "--version", env=without)
5549
+ if resolved is None:
5550
+ report(
5551
+ f"{label}: with no `openspec` on PATH the runner resolved "
5552
+ "nothing, but this package declares it as a dependency and "
5553
+ "installs it at node_modules/.bin. A tool the repository ships "
5554
+ "is not a tool the host has to provide."
5555
+ )
5556
+ return 1
5557
+ if resolved.returncode != 0 or not re.search(
5558
+ r"\d+\.\d+\.\d+", resolved.stdout
5559
+ ):
5560
+ report(
5561
+ f"{label}: the resolved openspec did not report a version. "
5562
+ f"exit={resolved.returncode} out={(resolved.stdout or '').strip()!r}"
5563
+ )
5564
+ return 1
5554
5565
 
5555
5566
  # And it is the declared one, not whatever a host happens to carry.
5556
5567
  declared = ROOT / "node_modules" / ".bin" / "openspec"
@@ -14043,6 +14054,60 @@ OPENSPEC_SEARCH_ORDER = (
14043
14054
  )
14044
14055
 
14045
14056
 
14057
+ @contextlib.contextmanager
14058
+ def path_without_openspec(path: str | None = None):
14059
+ """Yield `path` with every `openspec` executable on it removed, and nothing else.
14060
+
14061
+ Dropping a PATH *directory* because it holds an `openspec` also drops that
14062
+ directory's other contents. On the ordinary Homebrew plus `npm install -g`
14063
+ layout one directory holds both `openspec` and `node`, so the filter took the
14064
+ interpreter the openspec shim needs and the scenario built on it asserted that
14065
+ a shell without an interpreter fails — reported against `openspec`, which was
14066
+ installed and working (issue #137).
14067
+
14068
+ So a directory that holds the tool is replaced in place, at the same index, by
14069
+ a mirror symlinking every one of its entries except the `openspec` ones; a
14070
+ directory that does not hold it is passed through untouched. Exclusion is by
14071
+ stem, so `openspec.cmd` and its siblings cannot survive a filter whose whole
14072
+ purpose is that the name does not resolve.
14073
+
14074
+ The mirrors live for the duration of the context, so callers must hold it open
14075
+ across every child process they run against the PATH, not only across the call
14076
+ that built it.
14077
+ """
14078
+ original = os.environ.get("PATH", "") if path is None else path
14079
+ entries = [entry for entry in original.split(os.pathsep) if entry]
14080
+ with tempfile.TemporaryDirectory(prefix="keel-no-openspec-") as raw:
14081
+ mirrors = Path(raw)
14082
+ rebuilt: list[str] = []
14083
+ for index, entry in enumerate(entries):
14084
+ source = Path(entry)
14085
+ try:
14086
+ contents = sorted(source.iterdir())
14087
+ except OSError:
14088
+ # Unreadable or absent: it carried nothing resolvable either way.
14089
+ rebuilt.append(entry)
14090
+ continue
14091
+ if not any(item.stem == "openspec" for item in contents):
14092
+ rebuilt.append(entry)
14093
+ continue
14094
+ mirror = mirrors / str(index)
14095
+ try:
14096
+ mirror.mkdir()
14097
+ for item in contents:
14098
+ if item.stem == "openspec":
14099
+ continue
14100
+ (mirror / item.name).symlink_to(item)
14101
+ except OSError:
14102
+ # Windows can refuse a symlink without the privilege. Fall back to
14103
+ # dropping the directory, which is what this replaces; the caller's
14104
+ # precondition check then reports the consequence by name instead
14105
+ # of attributing it to the tool.
14106
+ continue
14107
+ rebuilt.append(str(mirror))
14108
+ yield os.pathsep.join(rebuilt)
14109
+
14110
+
14046
14111
  def resolve_openspec(env: dict[str, str] | None = None) -> str | None:
14047
14112
  local = ROOT / "node_modules" / ".bin" / "openspec"
14048
14113
  if local.is_file():
@@ -24659,14 +24724,13 @@ def validate_dependency_resolves_where_npm_put_it_scenario() -> int:
24659
24724
  shutil.copy2(ROOT / "package.json", package_root / "package.json")
24660
24725
 
24661
24726
  # PATH without any openspec, so what resolves came from the layout and not
24662
- # from the machine running the suite.
24727
+ # from the machine running the suite. Removed by file rather than by
24728
+ # directory: one directory holds `openspec` and `node` on the ordinary
24729
+ # Homebrew plus `npm install -g` layout, and the children below are run by
24730
+ # `node` (issue #137).
24663
24731
  def clean_env(extra_path: Path | None = None) -> dict[str, str]:
24664
24732
  env = dict(os.environ)
24665
- entries = [
24666
- entry
24667
- for entry in env.get("PATH", "").split(os.pathsep)
24668
- if entry and not (Path(entry) / "openspec").exists()
24669
- ]
24733
+ entries = [entry for entry in sanitized_path.split(os.pathsep) if entry]
24670
24734
  if extra_path is not None:
24671
24735
  entries.insert(0, str(extra_path))
24672
24736
  env["PATH"] = os.pathsep.join(entries)
@@ -24696,7 +24760,21 @@ def validate_dependency_resolves_where_npm_put_it_scenario() -> int:
24696
24760
  "",
24697
24761
  )
24698
24762
 
24699
- with tempfile.TemporaryDirectory(prefix="keel-hoisted-") as raw:
24763
+ # The mirrors live for the duration of the context, so it stays open across
24764
+ # every child process below rather than only across the call that built it.
24765
+ with path_without_openspec() as sanitized_path, tempfile.TemporaryDirectory(
24766
+ prefix="keel-hoisted-"
24767
+ ) as raw:
24768
+ # The scenario's own precondition, asserted before the behavior: every
24769
+ # assertion below runs `node`, so a PATH without one is a broken fixture
24770
+ # and not a fact about where the dependency resolves.
24771
+ if shutil.which("node", path=sanitized_path) is None:
24772
+ return skip_scenario(
24773
+ label,
24774
+ "node does not resolve on the PATH this scenario built, and "
24775
+ "every assertion below runs the installed Keel through it. That "
24776
+ "is a broken fixture, not a fact about openspec.",
24777
+ )
24700
24778
  root = Path(raw)
24701
24779
 
24702
24780
  # The layout npm actually produces: Keel unpacked under the consumer's
@@ -26016,6 +26094,258 @@ def validate_drift_names_where_to_look_scenario() -> int:
26016
26094
  # `M<n>` was reported as naming a check the task does not declare — first, and
26017
26095
  # as somebody else's fault. The reporter calls it the only diagnostic in 149
26018
26096
  # invocations that made them edit the wrong file.
26097
+
26098
+ # `## Expectation Coverage` carries the protocol's one global assertion — every
26099
+ # expectation has an owner — and 80% of its closures were the single form with
26100
+ # nothing behind them. Issue #133 ran the set difference by hand over 24
26101
+ # archived changes and found six wrong claims, all of them already past
26102
+ # change-close and a semantic Review. The operation that finds them is between
26103
+ # two identifier lists the author already declared, in one file.
26104
+ def validate_coverage_claim_is_compared_scenario() -> int:
26105
+ label = "a-coverage-claim-is-compared"
26106
+
26107
+ design = (
26108
+ "## Context\n\nfixture\n\n## Decisions\n\n"
26109
+ "- F1 — the first fact the fixture declares.\n"
26110
+ "- F2 — the second fact the fixture declares.\n"
26111
+ "- D1 — the decision no task of the fixture covers.\n"
26112
+ )
26113
+
26114
+ def task(tid: str, covers: str) -> str:
26115
+ return "\n".join((
26116
+ f"- [x] {tid} Coverage probe",
26117
+ " - Owner: claude",
26118
+ " - Mode: implementation",
26119
+ " - Covers:",
26120
+ f" - {covers}",
26121
+ " - Read:",
26122
+ " - README.md",
26123
+ " - Touch:",
26124
+ " - src/example.js",
26125
+ " - Verify:",
26126
+ " - Strategy: evidence-first",
26127
+ " - Reason: fixture; nothing here can fail first",
26128
+ " - M1: the check asserts the public behavior",
26129
+ " - Autonomy boundary:",
26130
+ " - Default: hard-stop",
26131
+ " - Pre-authorized fallback: none",
26132
+ " - Stop Rules:",
26133
+ " - Stop if the fixture needs a decision it cannot make.",
26134
+ " - Evidence:",
26135
+ " - Contract: pending",
26136
+ " - M1: pass. ran it.",
26137
+ " - Review:",
26138
+ " - Status: pass",
26139
+ " - Acceptance check: M1 asserts the behavior at its interface.",
26140
+ " - Scope check: only src/example.js changed.",
26141
+ " - Findings: none",
26142
+ " - Blocker: none",
26143
+ " - Reauthorizations: none",
26144
+ "",
26145
+ ))
26146
+
26147
+ def fixture(root: Path, name: str, coverage: str) -> Path:
26148
+ repo = root / name
26149
+ write_gate_fixture(repo, tasks="", design=design)
26150
+ write_text(
26151
+ repo / "openspec/changes/demo/tasks.md",
26152
+ "# Tasks\n\n"
26153
+ + "## Work\n\n"
26154
+ + task("1.1", "F1")
26155
+ + "\n"
26156
+ + task("2.1", "F2")
26157
+ + "\n## Invalidates\n\n- None.\n\n"
26158
+ + "## Expectation Coverage\n\n"
26159
+ + coverage
26160
+ + "\n",
26161
+ )
26162
+ # Both anchors, in order: `Contract: pending` is replaced once per task,
26163
+ # first occurrence first, and a task's fingerprint covers only its own
26164
+ # authority text, so recording one does not move the other.
26165
+ tasks_path = repo / "openspec/changes/demo/tasks.md"
26166
+ for tid in ("1.1", "2.1"):
26167
+ started = run_keel(
26168
+ repo, "gate", "task-start", "--change", "demo", "--task", tid,
26169
+ "--json", "--no-guard",
26170
+ )
26171
+ try:
26172
+ payload = json.loads(started.stdout)
26173
+ except json.JSONDecodeError:
26174
+ report(
26175
+ f"{label}: the fixture's task {tid} did not start, so no "
26176
+ f"anchor could be recorded; "
26177
+ f"{(started.stderr or started.stdout).strip()[:400]!r}."
26178
+ )
26179
+ raise SystemExit(1)
26180
+ value = (
26181
+ ((payload.get("contract") or {}).get("fingerprint") or {})
26182
+ .get("value") or "0" * 64
26183
+ )
26184
+ tasks_path.write_text(
26185
+ tasks_path.read_text(encoding="utf-8").replace(
26186
+ " - Contract: pending",
26187
+ f" - Contract: keel-task-capsule/v1 sha256:{value}",
26188
+ 1,
26189
+ ),
26190
+ encoding="utf-8",
26191
+ )
26192
+ return repo
26193
+
26194
+ def close(repo: Path) -> dict:
26195
+ result = run_keel(
26196
+ repo, "gate", "change-close", "--change", "demo", "--action",
26197
+ "archive", "--json",
26198
+ )
26199
+ try:
26200
+ return json.loads(result.stdout)
26201
+ except json.JSONDecodeError:
26202
+ return {"status": "unparsed", "problems": [
26203
+ {"code": "unparsed", "message": result.stdout[:300]}
26204
+ ]}
26205
+
26206
+ def said(payload: dict) -> str:
26207
+ return " ".join(str(x) for x in (payload.get("warnings") or []))
26208
+
26209
+ agree = "- E1: the first expectation (F1). Covered by: 1.1\n" \
26210
+ "- E2: the second expectation (F2). Covered by: 2.1\n"
26211
+ # The identifier is covered — by the other task.
26212
+ mismatch = "- E1: the first expectation (F2). Covered by: 1.1\n" \
26213
+ "- E2: the second expectation. Covered by: 2.1\n"
26214
+ # The identifier is covered by nothing.
26215
+ orphan = "- E1: the first expectation (D1). Covered by: 1.1\n" \
26216
+ "- E2: the second expectation. Covered by: 2.1\n"
26217
+ # Same entry as `mismatch` with the citation removed, and nothing else.
26218
+ prose = "- E1: the first expectation. Covered by: 1.1\n" \
26219
+ "- E2: the second expectation. Covered by: 2.1\n"
26220
+ # Issue #133's contradiction: one identifier claimed covered and deferred.
26221
+ contradiction = "- E1: the first expectation (F2). Covered by: 1.1\n" \
26222
+ "- E2: the same identifier, deferred (F2). " \
26223
+ "Durable owner: https://github.com/TanglmChris/keel/issues/133\n"
26224
+
26225
+ with tempfile.TemporaryDirectory(prefix="keel-coverage-claim-") as raw:
26226
+ root = Path(raw)
26227
+
26228
+ # The control comes first: without a fixture that closes, every later
26229
+ # refusal could be something else refusing.
26230
+ settled = close(fixture(root, "agree", agree))
26231
+ if settled.get("status") != "pass":
26232
+ report(
26233
+ f"{label}: the agreeing fixture does not close, so no later "
26234
+ f"refusal can be attributed; {problem_text(settled)!r}."
26235
+ )
26236
+ return 1
26237
+ wrong = close(fixture(root, "mismatch", mismatch))
26238
+ if wrong.get("status") != "fail":
26239
+ report(
26240
+ f"{label}: a claim that task 1.1 covers F2 was accepted while "
26241
+ f"1.1's Covers names only F1; change-close returned "
26242
+ f"Status: {wrong.get('status')}."
26243
+ )
26244
+ return 1
26245
+ text = problem_text(wrong)
26246
+ if "E1" not in text:
26247
+ report(f"{label}: the refusal does not name the entry; {text!r}.")
26248
+ return 1
26249
+ if "F2" not in text:
26250
+ report(
26251
+ f"{label}: the refusal does not name the identifier it could "
26252
+ f"not find; {text!r}."
26253
+ )
26254
+ return 1
26255
+ if "2.1" not in text:
26256
+ report(
26257
+ f"{label}: the refusal does not name the task whose Covers "
26258
+ f"does hold F2, so the author is told the claim is wrong and "
26259
+ f"must re-derive which of three shapes it is; {text!r}."
26260
+ )
26261
+ return 1
26262
+
26263
+ nowhere = close(fixture(root, "orphan", orphan))
26264
+ if nowhere.get("status") != "fail":
26265
+ report(
26266
+ f"{label}: a claim naming an identifier no task covers was "
26267
+ f"accepted; change-close returned Status: {nowhere.get('status')}."
26268
+ )
26269
+ return 1
26270
+ text = problem_text(nowhere)
26271
+ if "D1" not in text:
26272
+ report(
26273
+ f"{label}: the refusal does not name the uncovered identifier; "
26274
+ f"{text!r}."
26275
+ )
26276
+ return 1
26277
+ # The distinction is which of the two halves the refusal chose, not
26278
+ # whether a task id appears at all — the entry's own claim names one.
26279
+ if "is covered by task" in text:
26280
+ report(
26281
+ f"{label}: the refusal points at a task as the place D1 is "
26282
+ f"covered, which sends the author to a capsule with nothing "
26283
+ f"wrong in it; {text!r}."
26284
+ )
26285
+ return 1
26286
+ if "No task of this change covers" not in text:
26287
+ report(
26288
+ f"{label}: the refusal does not say that no task covers it, so "
26289
+ f"the shape issue #133 measured as the serious one is not told "
26290
+ f"apart from a wrong task id; {text!r}."
26291
+ )
26292
+ return 1
26293
+
26294
+ # Citing is optional, and the fixture proving it is the mismatch with
26295
+ # its one citation removed — so a rule that refused prose would have to
26296
+ # refuse this and nothing else about it changed.
26297
+ quiet = close(fixture(root, "prose", prose))
26298
+ if quiet.get("status") != "pass":
26299
+ report(
26300
+ f"{label}: an entry citing no identifier was refused; "
26301
+ f"{problem_text(quiet)!r}."
26302
+ )
26303
+ return 1
26304
+
26305
+ # D3: the report's second suggestion is unnecessary, because the
26306
+ # comparison already refuses the case it was aimed at.
26307
+ both = close(fixture(root, "contradiction", contradiction))
26308
+ if both.get("status") != "fail":
26309
+ report(
26310
+ f"{label}: the same identifier claimed covered by a task that "
26311
+ f"omits it and deferred to a tracker was accepted; "
26312
+ f"change-close returned Status: {both.get('status')}."
26313
+ )
26314
+ return 1
26315
+ text = problem_text(both)
26316
+ if "F2" not in text:
26317
+ report(
26318
+ f"{label}: the contradiction was refused for something other "
26319
+ f"than the identifier it is about; {text!r}."
26320
+ )
26321
+ return 1
26322
+
26323
+ # How far the comparison reached, read off the control run above.
26324
+ spoken = said(settled)
26325
+ if "Expectation Coverage" not in spoken:
26326
+ report(
26327
+ f"{label}: the close does not report how much of the section "
26328
+ f"it compared, so a pass reads as a warrant it does not carry; "
26329
+ f"{spoken!r}."
26330
+ )
26331
+ return 1
26332
+ if "2" not in spoken:
26333
+ report(
26334
+ f"{label}: the report does not carry the compared count; "
26335
+ f"{spoken!r}."
26336
+ )
26337
+ return 1
26338
+ if "0" not in spoken:
26339
+ report(
26340
+ f"{label}: the report is suppressed when every entry was "
26341
+ f"compared, which teaches a reader that its absence means full "
26342
+ f"coverage; {spoken!r}."
26343
+ )
26344
+ return 1
26345
+
26346
+ report(f"{label} scenario passed.")
26347
+ return 0
26348
+
26019
26349
  def validate_reference_outlives_its_declaration_scenario() -> int:
26020
26350
  label = "a-reference-outlives-its-declaration"
26021
26351
 
@@ -27193,6 +27523,109 @@ def validate_authored_scenario_names_scenario() -> int:
27193
27523
  return 0
27194
27524
 
27195
27525
 
27526
+ def validate_a_filter_drops_only_what_it_named_scenario() -> int:
27527
+ """Issue #137: a PATH built to exclude one tool removed a whole directory.
27528
+
27529
+ Both scenarios that need "no openspec on PATH" dropped every directory that
27530
+ held an `openspec`. On the ordinary Homebrew plus `npm install -g` layout one
27531
+ directory holds `openspec` and `node`, so the filter took the interpreter the
27532
+ openspec shim needs, and the diagnostic named `openspec` — a tool that was
27533
+ installed and working.
27534
+
27535
+ The defect is a layout, so the fixture builds the layout instead of relying on
27536
+ this host having it: a directory carrying both binaries, on a PATH between two
27537
+ that carry neither. Asserted from both sides — that the runtime survived is the
27538
+ regression guard, that the tool is gone is the positive control, because a
27539
+ mirror that produced an empty directory would satisfy the second alone.
27540
+ """
27541
+ label = "a-filter-drops-only-what-it-named"
27542
+
27543
+ def write_executable(path: Path, body: str) -> None:
27544
+ path.write_text(body, encoding="utf-8")
27545
+ path.chmod(0o755)
27546
+
27547
+ with tempfile.TemporaryDirectory(prefix="keel-shared-bin-") as raw:
27548
+ root = Path(raw)
27549
+ before = root / "before"
27550
+ shared = root / "shared"
27551
+ after = root / "after"
27552
+ for directory in (before, shared, after):
27553
+ directory.mkdir()
27554
+ write_executable(before / "tool-a", "#!/bin/sh\necho a\n")
27555
+ write_executable(after / "tool-b", "#!/bin/sh\necho b\n")
27556
+ # The layout under test: the interpreter and the tool in one directory,
27557
+ # plus a neighbour that belongs to neither and must survive with it.
27558
+ write_executable(shared / "node", "#!/bin/sh\necho node\n")
27559
+ write_executable(shared / "openspec", "#!/bin/sh\necho 1.12.0\n")
27560
+ write_executable(shared / "openspec.cmd", "#!/bin/sh\necho 1.12.0\n")
27561
+ write_executable(shared / "unrelated", "#!/bin/sh\necho unrelated\n")
27562
+
27563
+ source = os.pathsep.join(str(entry) for entry in (before, shared, after))
27564
+ with path_without_openspec(source) as sanitized:
27565
+ entries = sanitized.split(os.pathsep)
27566
+
27567
+ # Behavior before shape: what the defect destroys is the ability to
27568
+ # run the interpreter, and an entry count is only its symptom.
27569
+ node = shutil.which("node", path=sanitized)
27570
+ if node is None:
27571
+ report(
27572
+ f"{label}: node did not survive a filter that was removing "
27573
+ f"openspec. The interpreter shares a directory with the "
27574
+ f"tool, and the whole directory went. sanitized PATH "
27575
+ f"{sanitized!r}"
27576
+ )
27577
+ return 1
27578
+ if shutil.which("unrelated", path=sanitized) is None:
27579
+ report(
27580
+ f"{label}: an executable unrelated to openspec was removed "
27581
+ f"along with it."
27582
+ )
27583
+ return 1
27584
+
27585
+ # The positive control. Without it, an empty mirror passes.
27586
+ for name in ("openspec", "openspec.cmd"):
27587
+ found = shutil.which(name, path=sanitized)
27588
+ if found is not None:
27589
+ report(
27590
+ f"{label}: {name} still resolves on a PATH built to "
27591
+ f"exclude it, at {found!r}."
27592
+ )
27593
+ return 1
27594
+
27595
+ if len(entries) != 3:
27596
+ report(
27597
+ f"{label}: the sanitized PATH has {len(entries)} entries, "
27598
+ f"not the 3 it was given, so search order did not survive. "
27599
+ f"got {sanitized!r}"
27600
+ )
27601
+ return 1
27602
+ if Path(node).parent != Path(entries[1]):
27603
+ report(
27604
+ f"{label}: node survived but moved search position; it "
27605
+ f"resolved from {str(Path(node).parent)!r}, not from PATH "
27606
+ f"entry 1 {entries[1]!r}."
27607
+ )
27608
+ return 1
27609
+
27610
+ # M2: an entry that does not hold the tool is passed through, not
27611
+ # mirrored — the cost and the blast radius stay on the directories
27612
+ # that carry it.
27613
+ for index, original in ((0, before), (2, after)):
27614
+ if entries[index] != str(original):
27615
+ report(
27616
+ f"{label}: a PATH entry holding no openspec was "
27617
+ f"rewritten; expected {str(original)!r}, got "
27618
+ f"{entries[index]!r}."
27619
+ )
27620
+ return 1
27621
+
27622
+ if label not in {name for name, _ in SCENARIOS}:
27623
+ report(f"{label}: the scenario registry does not include it.")
27624
+ return 1
27625
+ report(f"{label} scenario passed.")
27626
+ return 0
27627
+
27628
+
27196
27629
  SCENARIOS: tuple = (
27197
27630
  ("stateless-continuity", validate_stateless_continuity_scenario),
27198
27631
  ("core-gates", validate_core_gates_scenario),
@@ -27239,6 +27672,7 @@ SCENARIOS: tuple = (
27239
27672
  ("the-weakest-strategy-states-its-reason", validate_weakest_strategy_states_its_reason_scenario),
27240
27673
  ("a-quoted-marker-is-not-a-disposition", validate_quoted_marker_is_not_a_disposition_scenario),
27241
27674
  ("drift-names-where-to-look", validate_drift_names_where_to_look_scenario),
27675
+ ("a-coverage-claim-is-compared", validate_coverage_claim_is_compared_scenario),
27242
27676
  ("a-reference-outlives-its-declaration", validate_reference_outlives_its_declaration_scenario),
27243
27677
  ("the-obligation-is-stated-early", validate_obligation_is_stated_early_scenario),
27244
27678
  ("an-explanation-is-printed-once", validate_explanation_is_printed_once_scenario),
@@ -27534,6 +27968,10 @@ SCENARIOS: tuple = (
27534
27968
  "change-verify-deferred-evidence",
27535
27969
  validate_change_verify_deferred_evidence_scenario,
27536
27970
  ),
27971
+ (
27972
+ "a-filter-drops-only-what-it-named",
27973
+ validate_a_filter_drops_only_what_it_named_scenario,
27974
+ ),
27537
27975
  )
27538
27976
 
27539
27977
 
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
- 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 };
1623
1696
  }
1624
1697
 
1625
1698
  function hasDeltaSpec(changePath) {
@@ -1708,9 +1781,10 @@ function changeClose(repo, options) {
1708
1781
  )
1709
1782
  );
1710
1783
  }
1711
- problems.push(
1712
- ...expectationProblems(repo, selection.content, selection.tasks, selection.change)
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
  );