@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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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.
|
|
41
|
-
PROTOCOL_VERSION = "5.
|
|
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
|
|
5525
|
-
# would also remove `node`, and the
|
|
5526
|
-
# would
|
|
5527
|
-
without
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
if
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
);
|