@christang/keel 5.63.0 → 5.65.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 +39 -0
- package/assets/bootstrap/AGENTS.md +1 -1
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/scripts/validate_plugin.py +540 -2
- package/src/core/gates.js +118 -4
- package/src/core/task-contract.js +119 -22
package/README.md
CHANGED
|
@@ -375,6 +375,45 @@ set — fails `task-start` by name rather than sitting in the contract doing not
|
|
|
375
375
|
`Fails with:` marker that names no literal. To *write about* the marker in a check without declaring
|
|
376
376
|
one, put it in inline code.
|
|
377
377
|
|
|
378
|
+
#### A red can be honest and the check still immune
|
|
379
|
+
|
|
380
|
+
A signature predicts the red of an **absent** feature. It says nothing about the red of a **broken**
|
|
381
|
+
one, and the two can be unrelated. A consistency check asserting two tool outputs agree had a real
|
|
382
|
+
red, a correct signature, a real green — and stayed green through a 1000× unit error, the one thing
|
|
383
|
+
it existed to catch, because its tolerance carried a default absolute floor. The only way to find
|
|
384
|
+
that is to put the defect in and watch.
|
|
385
|
+
|
|
386
|
+
`Detects:` declares that injection — the mutation, and the failure it must produce:
|
|
387
|
+
|
|
388
|
+
```
|
|
389
|
+
- M1: `pytest tests/test_fmax.py` asserts synth fmax == sta fmax. Fails with: `AttributeError` Detects: `sed -i s/0.0005/0.5/ run_sta.py` -> `assert 5e-16 == 5e-13`
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Completion requires that second literal in the check's `.detects` Evidence. Clauses chain, so a check
|
|
393
|
+
may carry both — the example above is one check, one line.
|
|
394
|
+
|
|
395
|
+
**Keel does not run the mutation and does not judge it.** It records the claim and puts it where
|
|
396
|
+
review can see it, the same standing every other check result has. You can declare an injection any
|
|
397
|
+
check would catch; what the clause buys is that you decided it before the run, and that editing it
|
|
398
|
+
afterwards moves the fingerprint.
|
|
399
|
+
|
|
400
|
+
A `(regression)` check **may** declare one, and is the best place for it: such a check has no honest
|
|
401
|
+
red by construction, so an injection is the only thing that can show it is not vacuous.
|
|
402
|
+
|
|
403
|
+
#### A number can claim to be a measurement
|
|
404
|
+
|
|
405
|
+
`Basis:`, Evidence prose and `Findings` are free text, and a number in them reads the same whether it
|
|
406
|
+
was measured, estimated, or remembered. `Measured:` binds one to the output behind it:
|
|
407
|
+
|
|
408
|
+
```
|
|
409
|
+
- M1: `node bench.js` reports the fanout load. Measured: `1799.9`
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Completion requires that literal in the check's own `M<n>` Evidence — the entry holding the command
|
|
413
|
+
and its output. Opt-in, and deliberately not a rule over every number: measured against this
|
|
414
|
+
repository's archive, that rule would reach 847 inline-code spans, mostly version strings, counts,
|
|
415
|
+
and quoted references that appear in no command output, and each would be a false stop.
|
|
416
|
+
|
|
378
417
|
## Domain lenses
|
|
379
418
|
|
|
380
419
|
Keel's core is pure process; it ships no domain knowledge and no decisions of its own. Alongside
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.65.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.65.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",
|
|
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
|
|
|
38
38
|
"scripts/validate_plugin.py",
|
|
39
39
|
]
|
|
40
40
|
|
|
41
|
-
PACKAGE_VERSION = "5.
|
|
42
|
-
PROTOCOL_VERSION = "5.
|
|
41
|
+
PACKAGE_VERSION = "5.65.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.65.0"
|
|
43
43
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
44
44
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
45
45
|
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
@@ -28061,6 +28061,536 @@ def validate_the_routing_rule_reaches_the_decision_scenario() -> int:
|
|
|
28061
28061
|
return 0
|
|
28062
28062
|
|
|
28063
28063
|
|
|
28064
|
+
def validate_a_claim_names_what_would_falsify_it_scenario() -> int:
|
|
28065
|
+
"""Issue #132: an honest red does not mean the check can catch the defect.
|
|
28066
|
+
|
|
28067
|
+
A consistency check whose red was real, whose signature predicted it, and
|
|
28068
|
+
whose green was real stayed green through a 1000x unit error — the one thing
|
|
28069
|
+
it existed to catch — because `pytest.approx` carries a default absolute
|
|
28070
|
+
tolerance. `.red` proves the check failed before the implementation existed
|
|
28071
|
+
and `Fails with:` predicts the red of an *absent* feature; neither says
|
|
28072
|
+
anything about the red of a *broken* one. `Detects:` declares the injection
|
|
28073
|
+
that answers the third question, in the same shape: inside the check text, so
|
|
28074
|
+
it enters the fingerprint, and enforced by requiring its literal in Evidence.
|
|
28075
|
+
"""
|
|
28076
|
+
label = "a-claim-names-what-would-falsify-it"
|
|
28077
|
+
mutation = "sed -i s/0.0005/0.5/ run_sta.py"
|
|
28078
|
+
failure = "assert 5e-16 == 5e-13"
|
|
28079
|
+
|
|
28080
|
+
with tempfile.TemporaryDirectory(prefix="keel-detects-") as raw:
|
|
28081
|
+
root = Path(raw)
|
|
28082
|
+
|
|
28083
|
+
def start(name: str, **kwargs) -> dict:
|
|
28084
|
+
return strategy_probe_start(root, name, strategy_probe_task(**kwargs))
|
|
28085
|
+
|
|
28086
|
+
def commands_of(payload: dict) -> list[dict]:
|
|
28087
|
+
contract = payload.get("contract") or {}
|
|
28088
|
+
capsule = contract.get("capsule") or {}
|
|
28089
|
+
return ((capsule.get("verification") or {}).get("commands") or [])
|
|
28090
|
+
|
|
28091
|
+
# M1 — two clauses on one check, both parsed. The failure signature comes
|
|
28092
|
+
# first, so this is also the assertion that a signature no longer has to
|
|
28093
|
+
# be the final clause.
|
|
28094
|
+
chained = (
|
|
28095
|
+
"M1: node test.js asserts the public behavior. "
|
|
28096
|
+
f"Fails with: `boom` Detects: `{mutation}` -> `{failure}`",
|
|
28097
|
+
)
|
|
28098
|
+
payload = start("chained", strategy="vertical-tdd", commands=chained)
|
|
28099
|
+
if payload.get("status") != "pass":
|
|
28100
|
+
report(
|
|
28101
|
+
f"{label}: Detects: is not parsed — a check carrying a failure "
|
|
28102
|
+
"signature followed by an injection clause was refused; got "
|
|
28103
|
+
f"{payload.get('status')!r} {problem_text(payload)!r}."
|
|
28104
|
+
)
|
|
28105
|
+
return 1
|
|
28106
|
+
compiled = commands_of(payload)
|
|
28107
|
+
if len(compiled) != 1:
|
|
28108
|
+
report(f"{label}: the chained check did not compile to one check; {compiled!r}")
|
|
28109
|
+
return 1
|
|
28110
|
+
entry = compiled[0]
|
|
28111
|
+
if entry.get("failsWith") != "boom":
|
|
28112
|
+
report(
|
|
28113
|
+
f"{label}: Detects: is not parsed — the failure signature was "
|
|
28114
|
+
f"lost when a clause followed it; got {entry!r}."
|
|
28115
|
+
)
|
|
28116
|
+
return 1
|
|
28117
|
+
detects = entry.get("detects") or {}
|
|
28118
|
+
if detects.get("mutation") != mutation or detects.get("failure") != failure:
|
|
28119
|
+
report(
|
|
28120
|
+
f"{label}: Detects: is not parsed — the compiled check does not "
|
|
28121
|
+
f"carry the declared injection; got {entry!r}."
|
|
28122
|
+
)
|
|
28123
|
+
return 1
|
|
28124
|
+
# The clause is inside the check text, so it is inside the fingerprint.
|
|
28125
|
+
# Without this, an injection edited after the run would be invisible.
|
|
28126
|
+
other = start(
|
|
28127
|
+
"chained-edited",
|
|
28128
|
+
strategy="vertical-tdd",
|
|
28129
|
+
commands=(
|
|
28130
|
+
"M1: node test.js asserts the public behavior. "
|
|
28131
|
+
f"Fails with: `boom` Detects: `{mutation}` -> `assert 1 == 2`",
|
|
28132
|
+
),
|
|
28133
|
+
)
|
|
28134
|
+
first = str((payload.get("contract") or {}).get("fingerprint") or "")
|
|
28135
|
+
second = str((other.get("contract") or {}).get("fingerprint") or "")
|
|
28136
|
+
if not first or first == second:
|
|
28137
|
+
report(
|
|
28138
|
+
f"{label}: editing the declared injection did not move the "
|
|
28139
|
+
f"fingerprint; {first!r} vs {second!r}."
|
|
28140
|
+
)
|
|
28141
|
+
return 1
|
|
28142
|
+
|
|
28143
|
+
# M1, second half — the declaration is enforced at completion, against a
|
|
28144
|
+
# `.detects` Evidence entry for the same check. The clause is a claim Keel
|
|
28145
|
+
# records: it never runs the mutation, so what completion holds is that
|
|
28146
|
+
# the declared failure appears in what the author recorded.
|
|
28147
|
+
def complete(name: str, detects_evidence: str | None) -> dict:
|
|
28148
|
+
repo = root / name
|
|
28149
|
+
evidence = [
|
|
28150
|
+
" - Contract: pending",
|
|
28151
|
+
" - M1: pass. node test.js reported the behavior.",
|
|
28152
|
+
" - M1.red: fail. `boom` as predicted.",
|
|
28153
|
+
" - M1.green: pass.",
|
|
28154
|
+
]
|
|
28155
|
+
if detects_evidence is not None:
|
|
28156
|
+
evidence.append(f" - M1.detects: {detects_evidence}")
|
|
28157
|
+
task = "\n".join(
|
|
28158
|
+
[
|
|
28159
|
+
"- [x] 1.1 Injection probe",
|
|
28160
|
+
" - Owner: claude",
|
|
28161
|
+
" - Mode: implementation",
|
|
28162
|
+
" - Covers:",
|
|
28163
|
+
" - E1: the task proves its own behavior",
|
|
28164
|
+
" - Read:",
|
|
28165
|
+
" - README.md",
|
|
28166
|
+
" - Touch:",
|
|
28167
|
+
" - src/example.js",
|
|
28168
|
+
" - Verify:",
|
|
28169
|
+
" - Strategy: vertical-tdd",
|
|
28170
|
+
" - M1: node test.js asserts the public behavior. "
|
|
28171
|
+
f"Fails with: `boom` Detects: `{mutation}` -> `{failure}`",
|
|
28172
|
+
" - Evidence:",
|
|
28173
|
+
*evidence,
|
|
28174
|
+
" - Review:",
|
|
28175
|
+
" - Status: pass",
|
|
28176
|
+
" - Acceptance check: the behavior is proven.",
|
|
28177
|
+
" - Scope check: only Touch changed.",
|
|
28178
|
+
" - Findings: none.",
|
|
28179
|
+
" - Blocker: none",
|
|
28180
|
+
" - Reauthorizations: none",
|
|
28181
|
+
]
|
|
28182
|
+
)
|
|
28183
|
+
write_gate_fixture(repo, tasks=task)
|
|
28184
|
+
started = run_keel(
|
|
28185
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
28186
|
+
"--record", "--json",
|
|
28187
|
+
)
|
|
28188
|
+
try:
|
|
28189
|
+
anchor_value = str(
|
|
28190
|
+
(json.loads(started.stdout).get("contract") or {}).get(
|
|
28191
|
+
"fingerprint"
|
|
28192
|
+
)
|
|
28193
|
+
or ""
|
|
28194
|
+
)
|
|
28195
|
+
except json.JSONDecodeError:
|
|
28196
|
+
anchor_value = ""
|
|
28197
|
+
if not anchor_value:
|
|
28198
|
+
return {"status": "unstarted", "problems": [{"message": started.stdout[:300]}]}
|
|
28199
|
+
done = run_keel(
|
|
28200
|
+
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
|
|
28201
|
+
"--json",
|
|
28202
|
+
)
|
|
28203
|
+
try:
|
|
28204
|
+
return json.loads(done.stdout)
|
|
28205
|
+
except json.JSONDecodeError:
|
|
28206
|
+
return {"status": "unparsed", "problems": [{"message": done.stdout[:400]}]}
|
|
28207
|
+
|
|
28208
|
+
absent = complete("no-detects", None)
|
|
28209
|
+
if absent.get("status") == "pass":
|
|
28210
|
+
report(
|
|
28211
|
+
f"{label}: Detects: is not enforced — a declared injection with "
|
|
28212
|
+
"no `.detects` Evidence completed cleanly."
|
|
28213
|
+
)
|
|
28214
|
+
return 1
|
|
28215
|
+
if "missing-injection-evidence" not in problem_codes(absent):
|
|
28216
|
+
report(
|
|
28217
|
+
f"{label}: Detects: is not enforced — an absent `.detects` was "
|
|
28218
|
+
f"refused under another diagnostic; {problem_codes(absent)!r} "
|
|
28219
|
+
f"{problem_text(absent)!r}."
|
|
28220
|
+
)
|
|
28221
|
+
return 1
|
|
28222
|
+
wrong = complete("wrong-detects", "ran the mutation; it still passed.")
|
|
28223
|
+
if wrong.get("status") == "pass":
|
|
28224
|
+
report(
|
|
28225
|
+
f"{label}: Detects: is not enforced — a `.detects` recording "
|
|
28226
|
+
"something other than the declared failure completed cleanly."
|
|
28227
|
+
)
|
|
28228
|
+
return 1
|
|
28229
|
+
if "injection-missing-declared-failure" not in problem_codes(wrong):
|
|
28230
|
+
report(
|
|
28231
|
+
f"{label}: a `.detects` lacking the declared failure was "
|
|
28232
|
+
f"refused under another diagnostic; {problem_codes(wrong)!r} "
|
|
28233
|
+
f"{problem_text(wrong)!r}."
|
|
28234
|
+
)
|
|
28235
|
+
return 1
|
|
28236
|
+
right = complete("good-detects", f"fail, as declared: `{failure}`.")
|
|
28237
|
+
if right.get("status") != "pass":
|
|
28238
|
+
report(
|
|
28239
|
+
f"{label}: a `.detects` carrying the declared failure was still "
|
|
28240
|
+
f"refused; {problem_codes(right)!r} {problem_text(right)!r}."
|
|
28241
|
+
)
|
|
28242
|
+
return 1
|
|
28243
|
+
|
|
28244
|
+
# M2 — a (regression) check may declare one. It has no honest red by
|
|
28245
|
+
# construction and is exempt from .red/.green, so an injection is the
|
|
28246
|
+
# only mechanism that can show it is not vacuous: this is where the
|
|
28247
|
+
# clause is worth most, which is why the report's suggested refusal here
|
|
28248
|
+
# was narrowed rather than adopted.
|
|
28249
|
+
regression = start(
|
|
28250
|
+
"regression",
|
|
28251
|
+
strategy="vertical-tdd",
|
|
28252
|
+
commands=(
|
|
28253
|
+
"M1: node test.js asserts the new behavior. Fails with: `boom`",
|
|
28254
|
+
f"M2 (regression): node all.js stays green. Detects: `{mutation}` -> `{failure}`",
|
|
28255
|
+
),
|
|
28256
|
+
)
|
|
28257
|
+
if regression.get("status") != "pass":
|
|
28258
|
+
report(
|
|
28259
|
+
f"{label}: regression check may not declare an injection; got "
|
|
28260
|
+
f"{regression.get('status')!r} {problem_text(regression)!r}."
|
|
28261
|
+
)
|
|
28262
|
+
return 1
|
|
28263
|
+
second_entry = next(
|
|
28264
|
+
(e for e in commands_of(regression) if e.get("label") == "M2"), {}
|
|
28265
|
+
)
|
|
28266
|
+
if not (second_entry.get("detects") or {}).get("failure"):
|
|
28267
|
+
report(
|
|
28268
|
+
f"{label}: regression check may not declare an injection — the "
|
|
28269
|
+
f"clause was dropped; got {second_entry!r}."
|
|
28270
|
+
)
|
|
28271
|
+
return 1
|
|
28272
|
+
|
|
28273
|
+
# M3 — a malformed clause is named, not ignored. Both shapes: one literal
|
|
28274
|
+
# with no arrow, and an arrow with nothing after it.
|
|
28275
|
+
for name, clause in (
|
|
28276
|
+
("one-literal", f"Detects: `{mutation}`"),
|
|
28277
|
+
("no-target", f"Detects: `{mutation}` ->"),
|
|
28278
|
+
):
|
|
28279
|
+
bad = start(
|
|
28280
|
+
name,
|
|
28281
|
+
strategy="vertical-tdd",
|
|
28282
|
+
commands=(f"M1: node test.js asserts the behavior. {clause}",),
|
|
28283
|
+
)
|
|
28284
|
+
if bad.get("status") == "pass":
|
|
28285
|
+
report(
|
|
28286
|
+
f"{label}: a malformed injection clause ({name}) was "
|
|
28287
|
+
"silently ignored, which leaves the author believing an "
|
|
28288
|
+
"injection is enforced when none was parsed."
|
|
28289
|
+
)
|
|
28290
|
+
return 1
|
|
28291
|
+
if "malformed-injection" not in problem_codes(bad):
|
|
28292
|
+
report(
|
|
28293
|
+
f"{label}: a malformed injection clause ({name}) was "
|
|
28294
|
+
"silently ignored under another diagnostic; got "
|
|
28295
|
+
f"{problem_codes(bad)!r} {problem_text(bad)!r}."
|
|
28296
|
+
)
|
|
28297
|
+
return 1
|
|
28298
|
+
# A marker inside inline code is quoted material, not a declaration —
|
|
28299
|
+
# which is what lets this repository's own tasks write about the clause.
|
|
28300
|
+
quoted = start(
|
|
28301
|
+
"quoted",
|
|
28302
|
+
strategy="vertical-tdd",
|
|
28303
|
+
commands=(
|
|
28304
|
+
"M1: node test.js asserts that a check may close with "
|
|
28305
|
+
"`Detects:` and two literals. Fails with: `boom`",
|
|
28306
|
+
),
|
|
28307
|
+
)
|
|
28308
|
+
if quoted.get("status") != "pass":
|
|
28309
|
+
report(
|
|
28310
|
+
f"{label}: a clause named inside inline code was silently "
|
|
28311
|
+
f"ignored as a declaration; got {problem_text(quoted)!r}."
|
|
28312
|
+
)
|
|
28313
|
+
return 1
|
|
28314
|
+
|
|
28315
|
+
# 1.2 — `Measured:` binds a literal to the check's own recorded output.
|
|
28316
|
+
# The failure class it answers is a number that reads like a measurement
|
|
28317
|
+
# and is an estimate or a recollection; the one instance of it that was
|
|
28318
|
+
# caught in the reporting session was caught exactly this way, by sitting
|
|
28319
|
+
# in a clause the gate held against recorded output.
|
|
28320
|
+
measured_literal = "1799.9"
|
|
28321
|
+
|
|
28322
|
+
def complete_measured(name: str, m1_evidence: str, clause: str) -> dict:
|
|
28323
|
+
repo = root / name
|
|
28324
|
+
task = "\n".join(
|
|
28325
|
+
[
|
|
28326
|
+
"- [x] 1.1 Measurement probe",
|
|
28327
|
+
" - Owner: claude",
|
|
28328
|
+
" - Mode: implementation",
|
|
28329
|
+
" - Covers:",
|
|
28330
|
+
" - E1: the task proves its own behavior",
|
|
28331
|
+
" - Read:",
|
|
28332
|
+
" - README.md",
|
|
28333
|
+
" - Touch:",
|
|
28334
|
+
" - src/example.js",
|
|
28335
|
+
" - Verify:",
|
|
28336
|
+
" - Strategy: vertical-tdd",
|
|
28337
|
+
f" - M1: node test.js reports the fanout load.{clause}",
|
|
28338
|
+
" - Evidence:",
|
|
28339
|
+
" - Contract: pending",
|
|
28340
|
+
f" - M1: {m1_evidence}",
|
|
28341
|
+
" - M1.red: fail. the reader did not exist.",
|
|
28342
|
+
" - M1.green: pass.",
|
|
28343
|
+
" - Review:",
|
|
28344
|
+
" - Status: pass",
|
|
28345
|
+
" - Acceptance check: the behavior is proven.",
|
|
28346
|
+
" - Scope check: only Touch changed.",
|
|
28347
|
+
" - Findings: none.",
|
|
28348
|
+
" - Blocker: none",
|
|
28349
|
+
" - Reauthorizations: none",
|
|
28350
|
+
]
|
|
28351
|
+
)
|
|
28352
|
+
write_gate_fixture(repo, tasks=task)
|
|
28353
|
+
run_keel(
|
|
28354
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
28355
|
+
"--record", "--json",
|
|
28356
|
+
)
|
|
28357
|
+
done = run_keel(
|
|
28358
|
+
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
|
|
28359
|
+
"--json",
|
|
28360
|
+
)
|
|
28361
|
+
try:
|
|
28362
|
+
return json.loads(done.stdout)
|
|
28363
|
+
except json.JSONDecodeError:
|
|
28364
|
+
return {"status": "unparsed", "problems": [{"message": done.stdout[:400]}]}
|
|
28365
|
+
|
|
28366
|
+
estimated = complete_measured(
|
|
28367
|
+
"measured-absent",
|
|
28368
|
+
"pass. node test.js reported a fanout load of 1200 fF.",
|
|
28369
|
+
f" Measured: `{measured_literal}`",
|
|
28370
|
+
)
|
|
28371
|
+
if estimated.get("status") == "pass":
|
|
28372
|
+
report(
|
|
28373
|
+
f"{label}: Measured: is not enforced — a declared literal absent "
|
|
28374
|
+
"from the check's own recorded output completed cleanly, which is "
|
|
28375
|
+
"an estimate presented as a measurement."
|
|
28376
|
+
)
|
|
28377
|
+
return 1
|
|
28378
|
+
if "measurement-missing-from-evidence" not in problem_codes(estimated):
|
|
28379
|
+
report(
|
|
28380
|
+
f"{label}: a declared measurement absent from the output was "
|
|
28381
|
+
f"refused under another diagnostic; {problem_codes(estimated)!r} "
|
|
28382
|
+
f"{problem_text(estimated)!r}."
|
|
28383
|
+
)
|
|
28384
|
+
return 1
|
|
28385
|
+
real = complete_measured(
|
|
28386
|
+
"measured-present",
|
|
28387
|
+
f"pass. node test.js reported a fanout load of {measured_literal} fF.",
|
|
28388
|
+
f" Measured: `{measured_literal}`",
|
|
28389
|
+
)
|
|
28390
|
+
if real.get("status") != "pass":
|
|
28391
|
+
report(
|
|
28392
|
+
f"{label}: a declared measurement present in the output was "
|
|
28393
|
+
f"refused; {problem_codes(real)!r} {problem_text(real)!r}."
|
|
28394
|
+
)
|
|
28395
|
+
return 1
|
|
28396
|
+
# D4 — opt-in. A number in Evidence that no check declared is required
|
|
28397
|
+
# nowhere: the universal rule was declined on measurement, because in this
|
|
28398
|
+
# repository's own archive it would reach 847 inline-code spans, most of
|
|
28399
|
+
# them version strings, computed counts, and quoted references.
|
|
28400
|
+
undeclared = complete_measured(
|
|
28401
|
+
"measured-undeclared",
|
|
28402
|
+
"pass. node test.js reported `1200` fF across `2433` fanout pins.",
|
|
28403
|
+
"",
|
|
28404
|
+
)
|
|
28405
|
+
if undeclared.get("status") != "pass":
|
|
28406
|
+
report(
|
|
28407
|
+
f"{label}: numbers in Evidence with no `Measured:` clause were "
|
|
28408
|
+
f"refused, so the opt-in boundary did not hold; "
|
|
28409
|
+
f"{problem_codes(undeclared)!r} {problem_text(undeclared)!r}."
|
|
28410
|
+
)
|
|
28411
|
+
return 1
|
|
28412
|
+
|
|
28413
|
+
# 1.3 — the clauses are documented where the first one is documented. A
|
|
28414
|
+
# vocabulary an author cannot discover is a vocabulary nobody declares.
|
|
28415
|
+
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
28416
|
+
agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
|
|
28417
|
+
for needle, why in (
|
|
28418
|
+
("Detects:", "the injection clause must be named"),
|
|
28419
|
+
("Measured:", "the measurement clause must be named"),
|
|
28420
|
+
):
|
|
28421
|
+
if needle not in readme:
|
|
28422
|
+
report(
|
|
28423
|
+
f"{label}: README does not name {needle} — {why}, and a clause "
|
|
28424
|
+
"an author cannot find is a clause nobody declares."
|
|
28425
|
+
)
|
|
28426
|
+
return 1
|
|
28427
|
+
if needle not in agents:
|
|
28428
|
+
report(
|
|
28429
|
+
f"{label}: the protocol's verification discipline does not name "
|
|
28430
|
+
f"{needle} — {why}."
|
|
28431
|
+
)
|
|
28432
|
+
return 1
|
|
28433
|
+
flat_readme = re.sub(r"\s+", " ", readme)
|
|
28434
|
+
for needle, why in (
|
|
28435
|
+
("records the claim", "the README must say Keel does not judge the injection"),
|
|
28436
|
+
("does not run", "the README must say Keel does not run the mutation"),
|
|
28437
|
+
):
|
|
28438
|
+
if needle not in flat_readme:
|
|
28439
|
+
report(f"{label}: README does not name Detects: honestly — {why}.")
|
|
28440
|
+
return 1
|
|
28441
|
+
# The chaining is taught by the example rather than by prose about it: a
|
|
28442
|
+
# reader copies the example, and an example showing one clause teaches that
|
|
28443
|
+
# one clause is all there is.
|
|
28444
|
+
chained_example = re.search(
|
|
28445
|
+
r"Fails with: `[^`]+` Detects: `[^`]+` -> `[^`]+`", readme
|
|
28446
|
+
)
|
|
28447
|
+
if not chained_example:
|
|
28448
|
+
report(
|
|
28449
|
+
f"{label}: README does not name Detects: in a worked example beside "
|
|
28450
|
+
"a failure signature, so a reader learns the clauses are mutually "
|
|
28451
|
+
"exclusive from the only example they have."
|
|
28452
|
+
)
|
|
28453
|
+
return 1
|
|
28454
|
+
|
|
28455
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
28456
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
28457
|
+
return 1
|
|
28458
|
+
report(f"{label} scenario passed.")
|
|
28459
|
+
return 0
|
|
28460
|
+
|
|
28461
|
+
|
|
28462
|
+
def validate_a_negation_is_not_a_marker_scenario() -> int:
|
|
28463
|
+
"""Issue #144: `Not resolved here:` was read as `Resolved here:`.
|
|
28464
|
+
|
|
28465
|
+
The marker scan's `\\b` sits at the space inside the negation, so a finding
|
|
28466
|
+
that said in as many words it was *not* resolved here had the next word read
|
|
28467
|
+
as its resolution evidence — a dismissal read as a repair, then refused for
|
|
28468
|
+
lacking repair evidence. 5.42.0 established that a quoted marker is a
|
|
28469
|
+
quotation; a negated one is the same class and was not covered.
|
|
28470
|
+
"""
|
|
28471
|
+
label = "a-negation-is-not-a-marker"
|
|
28472
|
+
|
|
28473
|
+
def findings_task(findings: str) -> str:
|
|
28474
|
+
return "\n".join(
|
|
28475
|
+
[
|
|
28476
|
+
"- [x] 1.1 Findings probe",
|
|
28477
|
+
" - Owner: claude",
|
|
28478
|
+
" - Mode: implementation",
|
|
28479
|
+
" - Covers:",
|
|
28480
|
+
" - E1: the task proves its own behavior",
|
|
28481
|
+
" - Read:",
|
|
28482
|
+
" - README.md",
|
|
28483
|
+
" - Touch:",
|
|
28484
|
+
" - src/example.js",
|
|
28485
|
+
" - Verify:",
|
|
28486
|
+
" - Strategy: vertical-tdd",
|
|
28487
|
+
" - M1: node test.js asserts the public behavior",
|
|
28488
|
+
" - Evidence:",
|
|
28489
|
+
" - Contract: pending",
|
|
28490
|
+
" - M1: pass. node test.js reported the behavior.",
|
|
28491
|
+
" - M1.red: fail. the behavior did not exist.",
|
|
28492
|
+
" - M1.green: pass.",
|
|
28493
|
+
" - Review:",
|
|
28494
|
+
" - Status: pass",
|
|
28495
|
+
" - Acceptance check: the behavior is proven.",
|
|
28496
|
+
" - Scope check: only Touch changed.",
|
|
28497
|
+
f" - Findings: {findings}",
|
|
28498
|
+
" - Blocker: none",
|
|
28499
|
+
" - Reauthorizations: none",
|
|
28500
|
+
]
|
|
28501
|
+
)
|
|
28502
|
+
|
|
28503
|
+
with tempfile.TemporaryDirectory(prefix="keel-negation-") as raw:
|
|
28504
|
+
root = Path(raw)
|
|
28505
|
+
|
|
28506
|
+
def complete(name: str, findings: str) -> dict:
|
|
28507
|
+
repo = root / name
|
|
28508
|
+
write_gate_fixture(repo, tasks=findings_task(findings))
|
|
28509
|
+
run_keel(
|
|
28510
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
28511
|
+
"--record", "--json",
|
|
28512
|
+
)
|
|
28513
|
+
done = run_keel(
|
|
28514
|
+
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
|
|
28515
|
+
"--json",
|
|
28516
|
+
)
|
|
28517
|
+
try:
|
|
28518
|
+
return json.loads(done.stdout)
|
|
28519
|
+
except json.JSONDecodeError:
|
|
28520
|
+
return {"status": "unparsed", "problems": [{"message": done.stdout[:400]}]}
|
|
28521
|
+
|
|
28522
|
+
# M1 — the text that produced #144, verbatim in shape.
|
|
28523
|
+
negated = complete(
|
|
28524
|
+
"negated",
|
|
28525
|
+
"one, still open. The count is derivable from the declarations the "
|
|
28526
|
+
"module reads. Not resolved here: it is a different module and would "
|
|
28527
|
+
"be scope expansion. Durable owner: https://example.com/issues/1",
|
|
28528
|
+
)
|
|
28529
|
+
if negated.get("status") != "pass":
|
|
28530
|
+
report(
|
|
28531
|
+
f"{label}: names neither a check nor a path — a finding saying it "
|
|
28532
|
+
"was *not* resolved here, closing with a durable owner, was "
|
|
28533
|
+
f"refused; {problem_codes(negated)!r} {problem_text(negated)!r}."
|
|
28534
|
+
)
|
|
28535
|
+
return 1
|
|
28536
|
+
if "resolution evidence" in problem_text(negated).lower():
|
|
28537
|
+
report(
|
|
28538
|
+
f"{label}: the negated phrase still produced a resolution-evidence "
|
|
28539
|
+
f"diagnostic; {problem_text(negated)!r}."
|
|
28540
|
+
)
|
|
28541
|
+
return 1
|
|
28542
|
+
|
|
28543
|
+
# M2 — the positive control, in all three opening positions. Without it
|
|
28544
|
+
# the narrowing could pass by recognizing no marker anywhere.
|
|
28545
|
+
for name, findings in (
|
|
28546
|
+
("opens-value", "Resolved here: M9"),
|
|
28547
|
+
("after-break", "one, and it was fixed.\n Resolved here: M9"),
|
|
28548
|
+
("after-stop", "one, and it was fixed. Resolved here: M9"),
|
|
28549
|
+
):
|
|
28550
|
+
payload = complete(name, findings)
|
|
28551
|
+
if payload.get("status") == "pass":
|
|
28552
|
+
report(
|
|
28553
|
+
f"{label}: stopped recognizing a real marker ({name}) — a "
|
|
28554
|
+
"`Resolved here:` naming a check this task does not declare "
|
|
28555
|
+
"completed cleanly."
|
|
28556
|
+
)
|
|
28557
|
+
return 1
|
|
28558
|
+
if "M9" not in problem_text(payload):
|
|
28559
|
+
report(
|
|
28560
|
+
f"{label}: stopped recognizing a real marker ({name}) — the "
|
|
28561
|
+
f"refusal does not name the cited check; {problem_text(payload)!r}."
|
|
28562
|
+
)
|
|
28563
|
+
return 1
|
|
28564
|
+
|
|
28565
|
+
# M3 — a marker that opens no clause is refused, not ignored, and the
|
|
28566
|
+
# refusal names the rule so the author is not told to add what they wrote.
|
|
28567
|
+
swallowed = complete(
|
|
28568
|
+
"swallowed", "one, and the fix was Resolved here: M1 in passing"
|
|
28569
|
+
)
|
|
28570
|
+
if swallowed.get("status") == "pass":
|
|
28571
|
+
report(
|
|
28572
|
+
f"{label}: a finding whose only marker opens no clause completed "
|
|
28573
|
+
"cleanly, so it carried no disposition and nothing said so."
|
|
28574
|
+
)
|
|
28575
|
+
return 1
|
|
28576
|
+
# Asserted on the whole phrase, not the word: `opens` alone matches
|
|
28577
|
+
# inside `openspec`, which the same diagnostic already prints, and the
|
|
28578
|
+
# assertion was vacuous until this was tightened.
|
|
28579
|
+
if "opens its clause" not in problem_text(swallowed).lower():
|
|
28580
|
+
report(
|
|
28581
|
+
f"{label}: does not name the opening requirement — an author "
|
|
28582
|
+
"whose sentence visibly contains a marker is told to add a "
|
|
28583
|
+
f"disposition; {problem_text(swallowed)!r}."
|
|
28584
|
+
)
|
|
28585
|
+
return 1
|
|
28586
|
+
|
|
28587
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
28588
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
28589
|
+
return 1
|
|
28590
|
+
report(f"{label} scenario passed.")
|
|
28591
|
+
return 0
|
|
28592
|
+
|
|
28593
|
+
|
|
28064
28594
|
SCENARIOS: tuple = (
|
|
28065
28595
|
("stateless-continuity", validate_stateless_continuity_scenario),
|
|
28066
28596
|
("core-gates", validate_core_gates_scenario),
|
|
@@ -28415,6 +28945,14 @@ SCENARIOS: tuple = (
|
|
|
28415
28945
|
"the-routing-rule-reaches-the-decision",
|
|
28416
28946
|
validate_the_routing_rule_reaches_the_decision_scenario,
|
|
28417
28947
|
),
|
|
28948
|
+
(
|
|
28949
|
+
"a-claim-names-what-would-falsify-it",
|
|
28950
|
+
validate_a_claim_names_what_would_falsify_it_scenario,
|
|
28951
|
+
),
|
|
28952
|
+
(
|
|
28953
|
+
"a-negation-is-not-a-marker",
|
|
28954
|
+
validate_a_negation_is_not_a_marker_scenario,
|
|
28955
|
+
),
|
|
28418
28956
|
)
|
|
28419
28957
|
|
|
28420
28958
|
|
package/src/core/gates.js
CHANGED
|
@@ -672,7 +672,24 @@ function transientOwnerMessage(candidate) {
|
|
|
672
672
|
// swallow and changes nothing about why this one is narrow. The match is
|
|
673
673
|
// global because each resolved claim owes its own evidence; checking only the
|
|
674
674
|
// first would let a second one assert itself for free.
|
|
675
|
-
|
|
675
|
+
// A marker is a disposition only where it OPENS its clause: the start of the
|
|
676
|
+
// value, a line break, or sentence-ending punctuation may precede it, and a word
|
|
677
|
+
// may not. `Not resolved here:` contains the marker and means its opposite, and a
|
|
678
|
+
// `\b` boundary sat happily at the space inside it — the negation was read as a
|
|
679
|
+
// resolution claim and the next word, `it`, as its evidence, so a dismissal was
|
|
680
|
+
// read as a repair and then refused for lacking repair evidence (issue #144).
|
|
681
|
+
// Quoting was the first way a marker could appear without being one (5.42.0);
|
|
682
|
+
// negating it is the second.
|
|
683
|
+
//
|
|
684
|
+
// Written as a lookbehind so the captures keep reading from the same offsets —
|
|
685
|
+
// every rule here is positional, and a rule that shortened the text would move
|
|
686
|
+
// what they read.
|
|
687
|
+
const CLAUSE_OPENING = "(?<=^|[\\n.;:!?\u2014\u2013])[ \\t]*";
|
|
688
|
+
|
|
689
|
+
const RESOLVED_HERE = new RegExp(
|
|
690
|
+
`${CLAUSE_OPENING}resolved here\\s*:[ \\t]*(\\S*)`,
|
|
691
|
+
"gi"
|
|
692
|
+
);
|
|
676
693
|
|
|
677
694
|
// Resolution evidence is deliberately narrower than a durable owner. An
|
|
678
695
|
// `http`/`https` reference says someone else will do the work later, which is
|
|
@@ -714,8 +731,24 @@ function resolutionEvidenceVerdict(repo, value, commands, change) {
|
|
|
714
731
|
// next token, so a shortened text would move what they read. The original is
|
|
715
732
|
// kept for anything reported back, since a diagnostic quoting the blanked copy
|
|
716
733
|
// would show the author a sentence with holes in it.
|
|
717
|
-
|
|
718
|
-
|
|
734
|
+
// The same opening rule, for the same reason. Narrowing only the capture would
|
|
735
|
+
// leave a negated marker counted as a disposition being *present* while it
|
|
736
|
+
// supplied no evidence — a finding accepted as disposed with nothing behind it,
|
|
737
|
+
// which is worse than either half of the defect alone.
|
|
738
|
+
// The marker vocabulary wherever it appears, with no opening rule. Two different
|
|
739
|
+
// questions are asked of this text and they need different patterns. *Blanking* a
|
|
740
|
+
// marker inside a quoted span asks "is this marker text?" — and inside a span the
|
|
741
|
+
// character before it is a backtick, so an opening rule would refuse to blank it
|
|
742
|
+
// and the quotation would survive into the recognition scan. *Recognizing* a
|
|
743
|
+
// disposition asks "does this marker open a clause?", which is the narrower
|
|
744
|
+
// question `DISPOSITION_MARKER` below answers.
|
|
745
|
+
const MARKER_VOCABULARY =
|
|
746
|
+
/(?:resolved here|durable owner|discard (?:reason|rationale))\s*:/gi;
|
|
747
|
+
|
|
748
|
+
const DISPOSITION_MARKER = new RegExp(
|
|
749
|
+
`${CLAUSE_OPENING}(?:resolved here|durable owner|discard (?:reason|rationale))\\s*:`,
|
|
750
|
+
"gi"
|
|
751
|
+
);
|
|
719
752
|
|
|
720
753
|
function withoutQuotedMarkers(text) {
|
|
721
754
|
return String(text || "").replace(/`[^`\n]*`/g, (span) =>
|
|
@@ -725,7 +758,7 @@ function withoutQuotedMarkers(text) {
|
|
|
725
758
|
// span would destroy the very path the rule exists to read. Measured: the
|
|
726
759
|
// first draft of this function did exactly that, and both the backticked
|
|
727
760
|
// owner path and the backticked resolution path started failing.
|
|
728
|
-
span.replace(
|
|
761
|
+
span.replace(MARKER_VOCABULARY, (marker) => " ".repeat(marker.length))
|
|
729
762
|
);
|
|
730
763
|
}
|
|
731
764
|
|
|
@@ -1022,6 +1055,68 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
|
|
|
1022
1055
|
);
|
|
1023
1056
|
}
|
|
1024
1057
|
}
|
|
1058
|
+
// A declared measurement is held against the check's own bare `M<n>` Evidence
|
|
1059
|
+
// — the entry where the command and its output are recorded. A literal that
|
|
1060
|
+
// output does not contain is either unpasted or not measured, and free prose
|
|
1061
|
+
// gives a reader no way to tell those from a real reading (issue #132).
|
|
1062
|
+
for (const entry of contract ? contract.capsule.verification.commands : []) {
|
|
1063
|
+
if (!entry.measured || !entry.label) continue;
|
|
1064
|
+
const recorded = evidenceValue(task, entry.label);
|
|
1065
|
+
if (!isConcrete(recorded)) continue;
|
|
1066
|
+
if (!String(recorded).includes(entry.measured)) {
|
|
1067
|
+
problems.push(
|
|
1068
|
+
problem(
|
|
1069
|
+
"measurement-missing-from-evidence",
|
|
1070
|
+
`${entry.label} declares the measurement \`${entry.measured}\`, and `
|
|
1071
|
+
+ `its recorded ${entry.label} Evidence does not contain that `
|
|
1072
|
+
+ "string. Paste the output the number came from, or correct the "
|
|
1073
|
+
+ "declaration — which moves the contract fingerprint, because a "
|
|
1074
|
+
+ "number reconciled to the output after the run is a transcription "
|
|
1075
|
+
+ "of it.",
|
|
1076
|
+
)
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
// A declared injection is enforced whatever the strategy and whatever the
|
|
1081
|
+
// tags. It is not a red-green artifact: `.red` proves the check failed before
|
|
1082
|
+
// the implementation existed and a failure signature predicts the red of an
|
|
1083
|
+
// *absent* feature, while an injection answers what neither can — whether the
|
|
1084
|
+
// check still fails once the feature exists and is broken. A `(regression)`
|
|
1085
|
+
// check is where it matters most, because it has no honest red at all, so
|
|
1086
|
+
// exempting it here would remove the clause from its best use (issue #132).
|
|
1087
|
+
//
|
|
1088
|
+
// Keel does not run the mutation. What it holds is that the failure the author
|
|
1089
|
+
// declared before the run appears in what they recorded after it.
|
|
1090
|
+
for (const entry of contract ? contract.capsule.verification.commands : []) {
|
|
1091
|
+
if (!entry.detects || !entry.label) continue;
|
|
1092
|
+
const recorded = evidenceValue(task, `${entry.label}.detects`);
|
|
1093
|
+
if (!isConcrete(recorded)) {
|
|
1094
|
+
problems.push(
|
|
1095
|
+
problem(
|
|
1096
|
+
"missing-injection-evidence",
|
|
1097
|
+
`${entry.label} declares that \`${entry.detects.mutation}\` must make `
|
|
1098
|
+
+ `it fail with \`${entry.detects.failure}\`, and records no `
|
|
1099
|
+
+ `${entry.label}.detects Evidence. Run the mutation, record what it `
|
|
1100
|
+
+ "printed, and revert it — a green check that has never been made "
|
|
1101
|
+
+ "to fail on the defect it names is not evidence that it would.",
|
|
1102
|
+
)
|
|
1103
|
+
);
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
if (!String(recorded).includes(entry.detects.failure)) {
|
|
1107
|
+
problems.push(
|
|
1108
|
+
problem(
|
|
1109
|
+
"injection-missing-declared-failure",
|
|
1110
|
+
`${entry.label} declares that its injection fails with `
|
|
1111
|
+
+ `\`${entry.detects.failure}\`, and the recorded `
|
|
1112
|
+
+ `${entry.label}.detects Evidence does not contain that string. `
|
|
1113
|
+
+ "Record what the mutation actually printed, or correct the "
|
|
1114
|
+
+ "declaration — which moves the contract fingerprint, because an "
|
|
1115
|
+
+ "injection edited after the run is a transcription of it.",
|
|
1116
|
+
)
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1025
1120
|
const strategy = contract
|
|
1026
1121
|
? contract.capsule.verification.strategy.toLowerCase()
|
|
1027
1122
|
: "";
|
|
@@ -1207,6 +1302,16 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
|
|
|
1207
1302
|
+ "must still do is `Durable owner:` naming "
|
|
1208
1303
|
+ `${DURABLE_OWNER_FORMS}; one deliberately not being done is a `
|
|
1209
1304
|
+ "`Discard reason:`/`Discard rationale:` prefix."
|
|
1305
|
+
// Said only where a marker is visibly present and was not counted,
|
|
1306
|
+
// which is the one case where "carry a disposition" reads as a
|
|
1307
|
+
// contradiction of what the author can see they wrote.
|
|
1308
|
+
+ (mentionsUnopenedMarker(scannable)
|
|
1309
|
+
? " A marker counts only where it opens its clause — after the "
|
|
1310
|
+
+ "start of the value, a line break, or sentence-ending "
|
|
1311
|
+
+ "punctuation. This text mentions one mid-sentence, so it was "
|
|
1312
|
+
+ "read as prose; move it to the start of its own clause, or "
|
|
1313
|
+
+ "keep it as prose and add the disposition separately."
|
|
1314
|
+
: "")
|
|
1210
1315
|
)
|
|
1211
1316
|
);
|
|
1212
1317
|
}
|
|
@@ -1214,6 +1319,15 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
|
|
|
1214
1319
|
return { problems, reviewProblems };
|
|
1215
1320
|
}
|
|
1216
1321
|
|
|
1322
|
+
// Whether the text names a disposition marker somewhere that is not a clause
|
|
1323
|
+
// opening. Used only to add a sentence to a refusal, never to accept anything:
|
|
1324
|
+
// a marker mid-sentence stays prose, and this is what tells its author why.
|
|
1325
|
+
function mentionsUnopenedMarker(text) {
|
|
1326
|
+
const body = String(text || "");
|
|
1327
|
+
if (!new RegExp(MARKER_VOCABULARY.source, "i").test(body)) return false;
|
|
1328
|
+
return !new RegExp(DISPOSITION_MARKER.source, "i").test(body);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1217
1331
|
function taskComplete(repo, options) {
|
|
1218
1332
|
const selection = loadSelection(repo, options);
|
|
1219
1333
|
const task = selection.selected[0];
|
|
@@ -153,28 +153,88 @@ const RED_GREEN_VERIFICATION_STRATEGIES = new Set([
|
|
|
153
153
|
// Tags an M<n> check may carry after its label, as a comma-separated set.
|
|
154
154
|
const COMMAND_TAGS = new Set(["fast", "full", "regression"]);
|
|
155
155
|
|
|
156
|
-
// A check may end by declaring
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
156
|
+
// A check may end by declaring things about itself, so that each is written down
|
|
157
|
+
// before the run it describes. Every clause has the same shape: it closes the
|
|
158
|
+
// clause sequence with inline-code literals, it lives inside the check text and
|
|
159
|
+
// therefore inside the contract fingerprint, and it is enforced by requiring its
|
|
160
|
+
// literal in a named Evidence entry. Keel judges none of them.
|
|
161
|
+
//
|
|
162
|
+
// The clauses chain. A check is one line — `fieldValues` splits the field per
|
|
163
|
+
// line and treats each as its own entry — so a single end-anchored slot would
|
|
164
|
+
// make the clauses mutually exclusive, and the case that motivated `Detects:`
|
|
165
|
+
// declares an injection beside a failure signature on one check (issue #132).
|
|
166
|
+
//
|
|
167
|
+
// Each is still anchored at the end of what remains, on purpose: a check that
|
|
168
|
+
// describes this rule mentions a marker mid-sentence, and a mention is not a
|
|
169
|
+
// declaration.
|
|
170
|
+
//
|
|
171
|
+
// - `Fails with:` — the failure the check's red must show. Predicts the red of an
|
|
172
|
+
// *absent* feature.
|
|
173
|
+
// - `Detects:` — a mutation that puts a defect in, and the failure it must
|
|
174
|
+
// produce. Answers the question a red cannot: the red of a *broken* feature.
|
|
175
|
+
// The two can be entirely unrelated, which is the whole reason this exists.
|
|
176
|
+
const DECLARATION_CLAUSES = [
|
|
177
|
+
{
|
|
178
|
+
name: "failure",
|
|
179
|
+
pattern: /\bFails with:[ \t]*`([^`\n]+)`[ \t]*$/i,
|
|
180
|
+
marker: /\bFails with:/i,
|
|
181
|
+
build: (match) => match[1].trim(),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: "detects",
|
|
185
|
+
pattern: /\bDetects:[ \t]*`([^`\n]+)`[ \t]*->[ \t]*`([^`\n]+)`[ \t]*$/i,
|
|
186
|
+
marker: /\bDetects:/i,
|
|
187
|
+
build: (match) => ({
|
|
188
|
+
mutation: match[1].trim(),
|
|
189
|
+
failure: match[2].trim(),
|
|
190
|
+
}),
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
// `Measured:` — a literal the check's own recorded output must contain. The
|
|
194
|
+
// failure class is a number that reads like a measurement and is an estimate
|
|
195
|
+
// or a recollection; free prose cannot tell a reader which it is. Opt-in on
|
|
196
|
+
// purpose: a universal rule over every number in Evidence would reach 847
|
|
197
|
+
// inline-code spans in this repository's own archive, most of them version
|
|
198
|
+
// strings, counts the author computed, and quoted references that appear in
|
|
199
|
+
// no command output, and each would be a false stop.
|
|
200
|
+
name: "measured",
|
|
201
|
+
pattern: /\bMeasured:[ \t]*`([^`\n]+)`[ \t]*$/i,
|
|
202
|
+
marker: /\bMeasured:/i,
|
|
203
|
+
build: (match) => match[1].trim(),
|
|
204
|
+
},
|
|
205
|
+
];
|
|
163
206
|
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
// enforced
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
207
|
+
// Strip one matching trailing clause at a time until none matches, then report a
|
|
208
|
+
// marker surviving in the remaining prose as malformed. Malformed rather than
|
|
209
|
+
// ignored: a declaration that parsed as nothing reads to its author as a check
|
|
210
|
+
// being enforced. A marker inside inline code is quoted material, the meaning
|
|
211
|
+
// inline code already carries here, which is what lets this file's own tasks
|
|
212
|
+
// name the markers.
|
|
213
|
+
function declarationClauses(check) {
|
|
214
|
+
let text = String(check || "");
|
|
215
|
+
const declared = {};
|
|
216
|
+
for (let matched = true; matched; ) {
|
|
217
|
+
matched = false;
|
|
218
|
+
for (const clause of DECLARATION_CLAUSES) {
|
|
219
|
+
const match = text.match(clause.pattern);
|
|
220
|
+
if (!match) continue;
|
|
221
|
+
if (!(clause.name in declared)) declared[clause.name] = clause.build(match);
|
|
222
|
+
text = text.slice(0, match.index).replace(/[ \t]+$/, "");
|
|
223
|
+
matched = true;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
174
227
|
const remainder = withoutInlineCode(text);
|
|
228
|
+
const malformed = {};
|
|
229
|
+
for (const clause of DECLARATION_CLAUSES) {
|
|
230
|
+
malformed[clause.name] =
|
|
231
|
+
!(clause.name in declared) && clause.marker.test(remainder);
|
|
232
|
+
}
|
|
175
233
|
return {
|
|
176
|
-
signature: null,
|
|
177
|
-
|
|
234
|
+
signature: declared.failure == null ? null : declared.failure,
|
|
235
|
+
detects: declared.detects == null ? null : declared.detects,
|
|
236
|
+
measured: declared.measured == null ? null : declared.measured,
|
|
237
|
+
malformed,
|
|
178
238
|
};
|
|
179
239
|
}
|
|
180
240
|
|
|
@@ -226,6 +286,10 @@ function verification(task) {
|
|
|
226
286
|
check: entry,
|
|
227
287
|
failsWith: null,
|
|
228
288
|
malformedSignature: false,
|
|
289
|
+
detects: null,
|
|
290
|
+
malformedInjection: false,
|
|
291
|
+
measured: null,
|
|
292
|
+
malformedMeasurement: false,
|
|
229
293
|
};
|
|
230
294
|
}
|
|
231
295
|
const tags = (match[2] || "")
|
|
@@ -240,17 +304,25 @@ function verification(task) {
|
|
|
240
304
|
check: entry,
|
|
241
305
|
failsWith: null,
|
|
242
306
|
malformedSignature: false,
|
|
307
|
+
detects: null,
|
|
308
|
+
malformedInjection: false,
|
|
309
|
+
measured: null,
|
|
310
|
+
malformedMeasurement: false,
|
|
243
311
|
};
|
|
244
312
|
}
|
|
245
313
|
const check = normalizeText(match[3]);
|
|
246
|
-
const
|
|
314
|
+
const clauses = declarationClauses(check);
|
|
247
315
|
return {
|
|
248
316
|
label: match[1],
|
|
249
317
|
layer: tags.includes("fast") ? "fast" : "full",
|
|
250
318
|
regression: tags.includes("regression"),
|
|
251
319
|
check,
|
|
252
|
-
failsWith:
|
|
253
|
-
malformedSignature:
|
|
320
|
+
failsWith: clauses.signature,
|
|
321
|
+
malformedSignature: clauses.malformed.failure,
|
|
322
|
+
detects: clauses.detects,
|
|
323
|
+
malformedInjection: clauses.malformed.detects,
|
|
324
|
+
measured: clauses.measured,
|
|
325
|
+
malformedMeasurement: clauses.malformed.measured,
|
|
254
326
|
};
|
|
255
327
|
});
|
|
256
328
|
return {
|
|
@@ -443,6 +515,29 @@ function failureSignatureProblems(task) {
|
|
|
443
515
|
});
|
|
444
516
|
continue;
|
|
445
517
|
}
|
|
518
|
+
if (entry.malformedMeasurement) {
|
|
519
|
+
problems.push({
|
|
520
|
+
code: "malformed-measurement",
|
|
521
|
+
message:
|
|
522
|
+
`${entry.label} carries a \`Measured:\` marker that does not close `
|
|
523
|
+
+ "the check with a literal. Write it as `Measured: `<literal>`` at "
|
|
524
|
+
+ "the end of the check, or fence the marker in inline code when the "
|
|
525
|
+
+ "check is describing it rather than declaring one.",
|
|
526
|
+
});
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (entry.malformedInjection) {
|
|
530
|
+
problems.push({
|
|
531
|
+
code: "malformed-injection",
|
|
532
|
+
message:
|
|
533
|
+
`${entry.label} carries a \`Detects:\` marker that does not close `
|
|
534
|
+
+ "the check with a mutation and the failure it must produce. Write "
|
|
535
|
+
+ "it as `Detects: `<mutation>` -> `<failure>`` at the end of the "
|
|
536
|
+
+ "check, or fence the marker in inline code when the check is "
|
|
537
|
+
+ "describing it rather than declaring one.",
|
|
538
|
+
});
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
446
541
|
if (!entry.failsWith) continue;
|
|
447
542
|
if (!redGreen) {
|
|
448
543
|
problems.push({
|
|
@@ -1229,6 +1324,8 @@ function compileTaskContract(repo, change, task) {
|
|
|
1229
1324
|
// field is what the gate reads. Emitted only when declared, so every
|
|
1230
1325
|
// check without one keeps the capsule shape and fingerprint it had.
|
|
1231
1326
|
if (entry.failsWith) emitted.failsWith = entry.failsWith;
|
|
1327
|
+
if (entry.detects) emitted.detects = entry.detects;
|
|
1328
|
+
if (entry.measured) emitted.measured = entry.measured;
|
|
1232
1329
|
return emitted;
|
|
1233
1330
|
}),
|
|
1234
1331
|
},
|