@christang/keel 5.14.0 → 5.20.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/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +39 -17
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/plugins/keel/skills/keel-review-checklist/SKILL.md +1 -1
- package/scripts/install_to_repo.py +12 -2
- package/scripts/validate_plugin.py +956 -2
- package/src/core/gates.js +111 -47
- package/src/core/guard.js +53 -0
- package/src/core/task-contract.js +18 -4
|
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
|
|
|
37
37
|
"scripts/validate_plugin.py",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
-
PACKAGE_VERSION = "5.
|
|
41
|
-
PROTOCOL_VERSION = "5.
|
|
40
|
+
PACKAGE_VERSION = "5.20.0"
|
|
41
|
+
PROTOCOL_VERSION = "5.20.0"
|
|
42
42
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
43
43
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
44
44
|
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
@@ -5594,6 +5594,146 @@ def validate_runtime_versions_are_checked_scenario() -> int:
|
|
|
5594
5594
|
return 0
|
|
5595
5595
|
|
|
5596
5596
|
|
|
5597
|
+
def validate_doctor_reads_the_diagnosed_repository_scenario() -> int:
|
|
5598
|
+
"""Issue #57: doctor named one repository and answered about another.
|
|
5599
|
+
|
|
5600
|
+
The OpenSpec declaration was read from `PACKAGE_ROOT/package-lock.json` —
|
|
5601
|
+
the directory Keel is installed in — while the line above it says
|
|
5602
|
+
`keel doctor for <repo>`. The two coincide only when `node bin/keel.js`
|
|
5603
|
+
runs from Keel's own checkout, which is the one arrangement every existing
|
|
5604
|
+
scenario uses: `run_keel` always spawns `node <ROOT>/bin/keel.js`, so
|
|
5605
|
+
`PACKAGE_ROOT` is Keel's checkout no matter what `cwd` is. Driving the
|
|
5606
|
+
diagnosis at a repository that declares its own version is what separates
|
|
5607
|
+
the two roots without needing a second installation.
|
|
5608
|
+
"""
|
|
5609
|
+
label = "doctor-reads-the-diagnosed-repository"
|
|
5610
|
+
|
|
5611
|
+
def openspec_line(stdout: str) -> str:
|
|
5612
|
+
return next(
|
|
5613
|
+
(line for line in stdout.splitlines() if line.startswith("openspec:")),
|
|
5614
|
+
"",
|
|
5615
|
+
)
|
|
5616
|
+
|
|
5617
|
+
# M1 first half — a repository that declares a version Keel's own tree does
|
|
5618
|
+
# not. The sentinel is deliberately one no published OpenSpec reports, so a
|
|
5619
|
+
# line carrying it cannot have come from the resolved binary either.
|
|
5620
|
+
declared = "0.0.1"
|
|
5621
|
+
with tempfile.TemporaryDirectory(prefix="keel-doctor-declares-") as raw:
|
|
5622
|
+
declares = Path(raw).resolve()
|
|
5623
|
+
write_text(
|
|
5624
|
+
declares / "package-lock.json",
|
|
5625
|
+
json.dumps(
|
|
5626
|
+
{
|
|
5627
|
+
"name": "consumer",
|
|
5628
|
+
"lockfileVersion": 3,
|
|
5629
|
+
"packages": {
|
|
5630
|
+
"node_modules/@fission-ai/openspec": {"version": declared}
|
|
5631
|
+
},
|
|
5632
|
+
},
|
|
5633
|
+
indent=2,
|
|
5634
|
+
)
|
|
5635
|
+
+ "\n",
|
|
5636
|
+
)
|
|
5637
|
+
doctor = run_keel(declares, "--doctor")
|
|
5638
|
+
line = openspec_line(doctor.stdout or "")
|
|
5639
|
+
if not line:
|
|
5640
|
+
report(f"{label} M1 `keel --doctor` emitted no openspec line.")
|
|
5641
|
+
report((doctor.stdout or doctor.stderr or "").strip())
|
|
5642
|
+
return 1
|
|
5643
|
+
own_lock = json.loads((ROOT / "package-lock.json").read_text(encoding="utf-8"))
|
|
5644
|
+
own_declared = None
|
|
5645
|
+
for name, entry in own_lock.get("packages", {}).items():
|
|
5646
|
+
if name.endswith("@fission-ai/openspec"):
|
|
5647
|
+
own_declared = entry.get("version")
|
|
5648
|
+
if not own_declared:
|
|
5649
|
+
report(f"{label} could not read Keel's own declared OpenSpec version.")
|
|
5650
|
+
return 1
|
|
5651
|
+
# The reader sees two versions on this line: the one the resolved binary
|
|
5652
|
+
# reports and the one the repository declares. Assert on the declared
|
|
5653
|
+
# POSITION, not on bare presence — the resolved binary's version may
|
|
5654
|
+
# legitimately equal Keel's own declared version, and a test that reads
|
|
5655
|
+
# any occurrence cannot tell the two apart.
|
|
5656
|
+
# Two distinct failures, two conditions. A line carrying the right
|
|
5657
|
+
# version with no attribution and a line attributing the wrong version
|
|
5658
|
+
# need different fixes, and one message covering both would send half
|
|
5659
|
+
# its readers to a place with no problem in it.
|
|
5660
|
+
if "repo pins" not in line:
|
|
5661
|
+
report(
|
|
5662
|
+
f"{label} M1 the openspec line does not attribute any declared "
|
|
5663
|
+
"version to the repository, so a reader seeing two versions "
|
|
5664
|
+
"cannot tell which one is theirs."
|
|
5665
|
+
)
|
|
5666
|
+
report(f" {line}")
|
|
5667
|
+
return 1
|
|
5668
|
+
if f"repo pins {declared}" not in line:
|
|
5669
|
+
report(
|
|
5670
|
+
f"{label} M1 the openspec line attributes a declared version "
|
|
5671
|
+
f"to the repository, but not {declared}, which is what this "
|
|
5672
|
+
"repository declares. It is answering about some other "
|
|
5673
|
+
"repository."
|
|
5674
|
+
)
|
|
5675
|
+
report(f" {line}")
|
|
5676
|
+
return 1
|
|
5677
|
+
if own_declared != declared and f"repo pins {own_declared}" in line:
|
|
5678
|
+
report(
|
|
5679
|
+
f"{label} M1 the openspec line reports {own_declared} as the "
|
|
5680
|
+
"declared version. That is what is declared where Keel is "
|
|
5681
|
+
"installed, not where it was pointed."
|
|
5682
|
+
)
|
|
5683
|
+
report(f" {line}")
|
|
5684
|
+
return 1
|
|
5685
|
+
if not line.startswith("openspec: warning"):
|
|
5686
|
+
report(
|
|
5687
|
+
f"{label} M1 the resolved binary and the declared version "
|
|
5688
|
+
f"disagree ({declared} is declared and no release reports it), "
|
|
5689
|
+
"and the line does not state the disagreement."
|
|
5690
|
+
)
|
|
5691
|
+
report(f" {line}")
|
|
5692
|
+
return 1
|
|
5693
|
+
|
|
5694
|
+
# M1 second half — absence is a statement about the repository, not a read
|
|
5695
|
+
# failure, and not a disagreement. Most repositories do not depend on
|
|
5696
|
+
# OpenSpec at all, so warning here would train readers to ignore the line.
|
|
5697
|
+
with tempfile.TemporaryDirectory(prefix="keel-doctor-silent-") as raw:
|
|
5698
|
+
silent = Path(raw).resolve()
|
|
5699
|
+
write_text(silent / "README.md", "# consumer\n")
|
|
5700
|
+
doctor = run_keel(silent, "--doctor")
|
|
5701
|
+
line = openspec_line(doctor.stdout or "")
|
|
5702
|
+
if not line:
|
|
5703
|
+
report(f"{label} M1 `keel --doctor` emitted no openspec line.")
|
|
5704
|
+
report((doctor.stdout or doctor.stderr or "").strip())
|
|
5705
|
+
return 1
|
|
5706
|
+
if "declares no OpenSpec" not in line:
|
|
5707
|
+
report(
|
|
5708
|
+
f"{label} M1 a repository declaring no OpenSpec version is not "
|
|
5709
|
+
"reported as declaring none."
|
|
5710
|
+
)
|
|
5711
|
+
report(f" {line}")
|
|
5712
|
+
return 1
|
|
5713
|
+
if "unreadable" in line:
|
|
5714
|
+
report(
|
|
5715
|
+
f"{label} M1 the absence of a declaration is reported as a "
|
|
5716
|
+
"failure to read one. The two are different facts and only one "
|
|
5717
|
+
"of them is true here."
|
|
5718
|
+
)
|
|
5719
|
+
report(f" {line}")
|
|
5720
|
+
return 1
|
|
5721
|
+
if "answering from a different" in line:
|
|
5722
|
+
report(
|
|
5723
|
+
f"{label} M1 a repository declaring nothing is reported as "
|
|
5724
|
+
"disagreeing with the resolved binary. There is nothing for it "
|
|
5725
|
+
"to disagree with."
|
|
5726
|
+
)
|
|
5727
|
+
report(f" {line}")
|
|
5728
|
+
return 1
|
|
5729
|
+
|
|
5730
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
5731
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
5732
|
+
return 1
|
|
5733
|
+
report(f"{label} scenario passed.")
|
|
5734
|
+
return 0
|
|
5735
|
+
|
|
5736
|
+
|
|
5597
5737
|
TASK_SHAPE_TEMPLATE = """## 1. Work
|
|
5598
5738
|
|
|
5599
5739
|
- [ ] 1.1 First half
|
|
@@ -6054,6 +6194,174 @@ def validate_interpreter_surfaces_agree_scenario() -> int:
|
|
|
6054
6194
|
return 0
|
|
6055
6195
|
|
|
6056
6196
|
|
|
6197
|
+
def validate_default_completion_attributes_writes_scenario() -> int:
|
|
6198
|
+
"""A write outside Touch has to fail the gate the author actually runs.
|
|
6199
|
+
|
|
6200
|
+
`attributionResult` refuses an out-of-Touch path in full — diff, union with
|
|
6201
|
+
the dirty set, `pathAllowed` filter, `outside-touch` problem — behind
|
|
6202
|
+
`if (!base) return { problems: [] }`. Without `--base` the whole comparison
|
|
6203
|
+
is skipped and the paths are printed as a warning instead. Measured at
|
|
6204
|
+
5.15.0 on one tree and one task: `pass` with the offending path in a
|
|
6205
|
+
warning, `fail` once `--base HEAD` was added.
|
|
6206
|
+
|
|
6207
|
+
It was found by it happening. During the 5.15.0 release task a file was
|
|
6208
|
+
written that the task had not declared, through a `python3` heredoc in
|
|
6209
|
+
Bash — the write guard binds the host's file-writing tools and cannot bind
|
|
6210
|
+
a shell — and `task-complete` returned `pass`. A human reading `git status`
|
|
6211
|
+
caught it. Nobody reads `git status` in an unattended run, and this
|
|
6212
|
+
repository admits work into unattended runs by declaration.
|
|
6213
|
+
|
|
6214
|
+
The base does not have to come from the caller. `task-start` already writes
|
|
6215
|
+
the manifest at the instant the task is authorized; recording what was
|
|
6216
|
+
dirty then answers "did this task write it" without asking Git to answer
|
|
6217
|
+
"which task wrote it", which is the question it cannot answer in a
|
|
6218
|
+
half-finished change.
|
|
6219
|
+
"""
|
|
6220
|
+
label = "default-completion-attributes-writes"
|
|
6221
|
+
|
|
6222
|
+
def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
6223
|
+
return subprocess.run(
|
|
6224
|
+
["git", "-C", str(repo), *args], capture_output=True, text=True
|
|
6225
|
+
)
|
|
6226
|
+
|
|
6227
|
+
def tasks_doc() -> str:
|
|
6228
|
+
return (
|
|
6229
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
6230
|
+
"- [ ] 1.1 Exercise task contract\n"
|
|
6231
|
+
" - Covers:\n - E1: Public behavior passes.\n"
|
|
6232
|
+
" - Touch:\n - src/declared.js\n"
|
|
6233
|
+
" - Verify:\n - Strategy: evidence-first\n"
|
|
6234
|
+
" - M1: node test.js asserts the recorded feed status\n"
|
|
6235
|
+
" - Evidence:\n - Contract: pending\n - M1: the suite passed\n"
|
|
6236
|
+
" - Review:\n - Status: pass\n"
|
|
6237
|
+
" - Acceptance check: behavior asserted at the interface\n"
|
|
6238
|
+
" - Scope check: only Touch files changed\n"
|
|
6239
|
+
" - Findings: none\n"
|
|
6240
|
+
" - Blocker: none\n"
|
|
6241
|
+
)
|
|
6242
|
+
|
|
6243
|
+
with tempfile.TemporaryDirectory(prefix="keel-default-attribution-") as raw:
|
|
6244
|
+
repo = (Path(raw) / "repo").resolve()
|
|
6245
|
+
repo.mkdir()
|
|
6246
|
+
git(repo, "init", "-q")
|
|
6247
|
+
git(repo, "config", "user.email", "t@example.com")
|
|
6248
|
+
git(repo, "config", "user.name", "keel-test")
|
|
6249
|
+
write_text(repo / "src/declared.js", "// product\n")
|
|
6250
|
+
write_text(repo / "src/undeclared.js", "// product\n")
|
|
6251
|
+
write_text(repo / "src/already-dirty.js", "// product\n")
|
|
6252
|
+
write_text(repo / "openspec/changes/demo/tasks.md", tasks_doc())
|
|
6253
|
+
git(repo, "add", "-A")
|
|
6254
|
+
git(repo, "-c", "commit.gpgsign=false", "commit", "-q", "-m", "base")
|
|
6255
|
+
|
|
6256
|
+
def start(*extra: str) -> subprocess.CompletedProcess[str]:
|
|
6257
|
+
return run_keel(
|
|
6258
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
6259
|
+
"--record", *extra, "--json",
|
|
6260
|
+
)
|
|
6261
|
+
|
|
6262
|
+
def gate(*extra: str) -> dict:
|
|
6263
|
+
return json.loads(
|
|
6264
|
+
run_keel(
|
|
6265
|
+
repo, "gate", "task-complete", "--change", "demo",
|
|
6266
|
+
"--task", "1.1", *extra, "--json",
|
|
6267
|
+
).stdout
|
|
6268
|
+
)
|
|
6269
|
+
|
|
6270
|
+
def outside(payload: dict) -> list[str]:
|
|
6271
|
+
return [
|
|
6272
|
+
problem.get("message", "")
|
|
6273
|
+
for problem in payload.get("problems", [])
|
|
6274
|
+
if problem.get("code") == "outside-touch"
|
|
6275
|
+
]
|
|
6276
|
+
|
|
6277
|
+
# A path already dirty before the task is authorized. It must stay
|
|
6278
|
+
# unattributed afterwards: subtracting the start set is what removes
|
|
6279
|
+
# the false-positive class that made automatic attribution unsafe.
|
|
6280
|
+
write_text(repo / "src/already-dirty.js", "// touched before the task\n")
|
|
6281
|
+
|
|
6282
|
+
if start().returncode != 0:
|
|
6283
|
+
report(f"{label} could not authorize the fixture task.")
|
|
6284
|
+
return 1
|
|
6285
|
+
|
|
6286
|
+
# M1 — the reported defect. A write the guard never saw, and the gate
|
|
6287
|
+
# invoked the way an author actually invokes it.
|
|
6288
|
+
write_text(repo / "src/undeclared.js", "// written outside Touch\n")
|
|
6289
|
+
payload = gate()
|
|
6290
|
+
problems = outside(payload)
|
|
6291
|
+
if not any("src/undeclared.js" in message for message in problems):
|
|
6292
|
+
report(
|
|
6293
|
+
f"{label} M1 a path written outside Touch after task start was "
|
|
6294
|
+
"not refused by the default completion gate. The boundary "
|
|
6295
|
+
"holds only when the caller asks for it."
|
|
6296
|
+
)
|
|
6297
|
+
report(f" status={payload.get('status')!r}")
|
|
6298
|
+
for warning in payload.get("warnings", []):
|
|
6299
|
+
report(f" warning: {warning}")
|
|
6300
|
+
return 1
|
|
6301
|
+
if payload.get("status") != "fail":
|
|
6302
|
+
report(
|
|
6303
|
+
f"{label} M1 the gate named the out-of-Touch path but did not "
|
|
6304
|
+
f"fail; got status {payload.get('status')!r}. A boundary that "
|
|
6305
|
+
"reports without refusing is not a boundary."
|
|
6306
|
+
)
|
|
6307
|
+
return 1
|
|
6308
|
+
if any("src/already-dirty.js" in message for message in problems):
|
|
6309
|
+
report(
|
|
6310
|
+
f"{label} M1 a path that was already dirty when the task "
|
|
6311
|
+
"started was attributed to the task. The recorded set is not "
|
|
6312
|
+
"being subtracted, so an unrelated dirty worktree fails the "
|
|
6313
|
+
"gate."
|
|
6314
|
+
)
|
|
6315
|
+
return 1
|
|
6316
|
+
|
|
6317
|
+
# M1 — an explicit base answers the question the caller asked, which is
|
|
6318
|
+
# the broader one: everything since that commit, including what was
|
|
6319
|
+
# already dirty when the task started.
|
|
6320
|
+
base_problems = outside(gate("--base", "HEAD"))
|
|
6321
|
+
if not any("src/already-dirty.js" in message for message in base_problems):
|
|
6322
|
+
report(
|
|
6323
|
+
f"{label} M1 an explicit --base did not attribute a path that "
|
|
6324
|
+
"changed since that base, so the recorded set is overriding "
|
|
6325
|
+
"the base the caller supplied instead of yielding to it."
|
|
6326
|
+
)
|
|
6327
|
+
for message in base_problems:
|
|
6328
|
+
report(f" {message}")
|
|
6329
|
+
return 1
|
|
6330
|
+
|
|
6331
|
+
# M1 — no record means no attribution. A manifest written before this
|
|
6332
|
+
# existed, or a cleared one, must not be read as a clean start: absence
|
|
6333
|
+
# of a record is not a record of absence.
|
|
6334
|
+
run_keel(repo, "guard", "clear")
|
|
6335
|
+
if start("--no-guard").returncode != 0:
|
|
6336
|
+
report(f"{label} could not re-authorize without a manifest.")
|
|
6337
|
+
return 1
|
|
6338
|
+
payload = gate()
|
|
6339
|
+
if outside(payload):
|
|
6340
|
+
report(
|
|
6341
|
+
f"{label} M1 the gate attributed paths with no recorded "
|
|
6342
|
+
"task-start set. A missing record is being read as a clean "
|
|
6343
|
+
"start, which fails every completion in a dirty repository."
|
|
6344
|
+
)
|
|
6345
|
+
for message in outside(payload):
|
|
6346
|
+
report(f" {message}")
|
|
6347
|
+
return 1
|
|
6348
|
+
if not any(
|
|
6349
|
+
"not attributed" in warning for warning in payload.get("warnings", [])
|
|
6350
|
+
):
|
|
6351
|
+
report(
|
|
6352
|
+
f"{label} M1 with no record the gate neither attributed nor "
|
|
6353
|
+
"reported the dirty paths, so the fallback lost the semantic "
|
|
6354
|
+
"review evidence it is supposed to preserve."
|
|
6355
|
+
)
|
|
6356
|
+
return 1
|
|
6357
|
+
|
|
6358
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
6359
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
6360
|
+
return 1
|
|
6361
|
+
report(f"{label} scenario passed.")
|
|
6362
|
+
return 0
|
|
6363
|
+
|
|
6364
|
+
|
|
6057
6365
|
CJK_PATH = "src/摄影光影规划工具.js"
|
|
6058
6366
|
SPACE_PATH = "src/has space.js"
|
|
6059
6367
|
QUOTE_PATH = 'src/has"quote.js'
|
|
@@ -7582,6 +7890,197 @@ def validate_non_concrete_check_names_token_scenario() -> int:
|
|
|
7582
7890
|
return 0
|
|
7583
7891
|
|
|
7584
7892
|
|
|
7893
|
+
def validate_unusable_contract_names_only_its_cause_scenario() -> int:
|
|
7894
|
+
"""Issue #52: the first problem named `Commands`, which compact tasks lack.
|
|
7895
|
+
|
|
7896
|
+
Two defects compound. `missingFieldProblems` emitted a bare "must be
|
|
7897
|
+
concrete" while `unfilledToken` sat eight lines above it, and a contract
|
|
7898
|
+
carrying any diagnostic is discarded, after which `completionChecks` falls
|
|
7899
|
+
back to reading `Commands` — a field a compact task never declares. The
|
|
7900
|
+
derived problem sorts first and is the only one naming a field, so it is the
|
|
7901
|
+
one an author acts on, and it is about a schema they did not choose.
|
|
7902
|
+
|
|
7903
|
+
What is deliberately *not* changed is which fields the gate accepts. A bare
|
|
7904
|
+
token in prose stays non-concrete; see the M3 regression on
|
|
7905
|
+
`inline-code-is-concrete` and the decision recorded in
|
|
7906
|
+
keel/archive/follow-ups/2026-07-27-unfilled-token-keywords.md.
|
|
7907
|
+
"""
|
|
7908
|
+
less, greater = "<", ">"
|
|
7909
|
+
# The reporter's own shape: two bare brackets separated by half a sentence,
|
|
7910
|
+
# so the unbounded `<[^>]+>` swallows the span between them.
|
|
7911
|
+
prose = (
|
|
7912
|
+
f"pass — max ratio 0.001916 (bound {less}0.02), "
|
|
7913
|
+
f"min ratio 0.998107 (bound {greater}0.98)."
|
|
7914
|
+
)
|
|
7915
|
+
captured = f"{less}0.02), min ratio 0.998107 (bound {greater}"
|
|
7916
|
+
review = (
|
|
7917
|
+
" - Review:\n"
|
|
7918
|
+
" - Status: pass\n"
|
|
7919
|
+
" - Acceptance check: reviewed\n"
|
|
7920
|
+
" - Scope check: reviewed\n"
|
|
7921
|
+
" - Findings: none\n"
|
|
7922
|
+
)
|
|
7923
|
+
with tempfile.TemporaryDirectory(prefix="keel-unusable-contract-") as raw:
|
|
7924
|
+
repo = Path(raw)
|
|
7925
|
+
clean = task_capsule_compact_fixture()
|
|
7926
|
+
write_text(repo / "openspec/changes/prose/tasks.md", clean)
|
|
7927
|
+
# The anchor has to be recorded while the Evidence is still concrete —
|
|
7928
|
+
# which is the real sequence, not a workaround. The token arrives when
|
|
7929
|
+
# the author writes up the result, after the task started.
|
|
7930
|
+
if not record_contract_anchor(repo, "prose"):
|
|
7931
|
+
report(
|
|
7932
|
+
"unusable-contract-names-only-its-cause: the clean fixture "
|
|
7933
|
+
"could not record a contract anchor."
|
|
7934
|
+
)
|
|
7935
|
+
return 1
|
|
7936
|
+
started = (repo / "openspec/changes/prose/tasks.md").read_text(
|
|
7937
|
+
encoding="utf-8"
|
|
7938
|
+
)
|
|
7939
|
+
write_text(
|
|
7940
|
+
repo / "openspec/changes/prose/tasks.md",
|
|
7941
|
+
started.replace(
|
|
7942
|
+
" - M1: pending\n", f" - M1: {prose}\n{review}"
|
|
7943
|
+
),
|
|
7944
|
+
)
|
|
7945
|
+
completed = run_keel(
|
|
7946
|
+
repo,
|
|
7947
|
+
"gate",
|
|
7948
|
+
"task-complete",
|
|
7949
|
+
"--change",
|
|
7950
|
+
"prose",
|
|
7951
|
+
"--task",
|
|
7952
|
+
"1.1",
|
|
7953
|
+
"--json",
|
|
7954
|
+
)
|
|
7955
|
+
payload = json.loads(completed.stdout)
|
|
7956
|
+
problems = payload.get("problems", [])
|
|
7957
|
+
messages = [problem.get("message", "") for problem in problems]
|
|
7958
|
+
|
|
7959
|
+
# 1. The derived problem is gone. It named a field compact tasks lack.
|
|
7960
|
+
derived = [text for text in messages if "must define at least one" in text]
|
|
7961
|
+
if derived:
|
|
7962
|
+
report(
|
|
7963
|
+
"unusable-contract-names-only-its-cause: an unusable contract "
|
|
7964
|
+
"still derived a verification-form problem from the other "
|
|
7965
|
+
"schema's field."
|
|
7966
|
+
)
|
|
7967
|
+
for text in derived:
|
|
7968
|
+
report(f" {text}")
|
|
7969
|
+
return 1
|
|
7970
|
+
|
|
7971
|
+
# 2. The remaining problem names the span that caused it, and the escape.
|
|
7972
|
+
named = [
|
|
7973
|
+
text
|
|
7974
|
+
for text in messages
|
|
7975
|
+
if captured in text and "inline code" in text
|
|
7976
|
+
]
|
|
7977
|
+
if not named:
|
|
7978
|
+
report(
|
|
7979
|
+
"unusable-contract-names-only-its-cause: the Evidence "
|
|
7980
|
+
"diagnostic did not name the matched span and the inline-code "
|
|
7981
|
+
"escape."
|
|
7982
|
+
)
|
|
7983
|
+
for text in messages:
|
|
7984
|
+
report(f" {text}")
|
|
7985
|
+
return 1
|
|
7986
|
+
|
|
7987
|
+
# 3. Naming the cause did not stop the gate refusing. This is the
|
|
7988
|
+
# assertion that must never be dropped: suppressing a problem is the
|
|
7989
|
+
# direction that can wrongly make a gate pass.
|
|
7990
|
+
if payload.get("status") != "fail":
|
|
7991
|
+
report(
|
|
7992
|
+
"unusable-contract-names-only-its-cause: the gate returned "
|
|
7993
|
+
f"{payload.get('status')!r} for a task whose Evidence is not "
|
|
7994
|
+
"concrete."
|
|
7995
|
+
)
|
|
7996
|
+
return 1
|
|
7997
|
+
|
|
7998
|
+
# 4. Fencing exactly what the message names clears it, so the offered
|
|
7999
|
+
# repair is the one that works.
|
|
8000
|
+
fenced = started.replace(
|
|
8001
|
+
" - M1: pending\n",
|
|
8002
|
+
f" - M1: `{prose}`\n{review}",
|
|
8003
|
+
)
|
|
8004
|
+
write_text(repo / "openspec/changes/fenced/tasks.md", fenced)
|
|
8005
|
+
if not record_contract_anchor(repo, "fenced"):
|
|
8006
|
+
report(
|
|
8007
|
+
"unusable-contract-names-only-its-cause: fencing the named "
|
|
8008
|
+
"span did not make the Evidence concrete."
|
|
8009
|
+
)
|
|
8010
|
+
return 1
|
|
8011
|
+
|
|
8012
|
+
# 5. An empty field has no token to name, so it keeps the plain wording.
|
|
8013
|
+
empty = clean.replace(
|
|
8014
|
+
" - Covers:\n - E1: Public behavior passes.\n", " - Covers:\n"
|
|
8015
|
+
)
|
|
8016
|
+
write_text(repo / "openspec/changes/empty/tasks.md", empty)
|
|
8017
|
+
bare = run_keel(
|
|
8018
|
+
repo, "gate", "task-start", "--change", "empty", "--task", "1.1",
|
|
8019
|
+
"--json",
|
|
8020
|
+
)
|
|
8021
|
+
bare_messages = [
|
|
8022
|
+
problem.get("message", "")
|
|
8023
|
+
for problem in json.loads(bare.stdout).get("problems", [])
|
|
8024
|
+
]
|
|
8025
|
+
if not any(
|
|
8026
|
+
text.startswith("Covers must be concrete") for text in bare_messages
|
|
8027
|
+
):
|
|
8028
|
+
report(
|
|
8029
|
+
"unusable-contract-names-only-its-cause: an empty required "
|
|
8030
|
+
"field lost the unqualified wording."
|
|
8031
|
+
)
|
|
8032
|
+
for text in bare_messages:
|
|
8033
|
+
report(f" {text}")
|
|
8034
|
+
return 1
|
|
8035
|
+
|
|
8036
|
+
# 6. Suppression must not hide a task that genuinely declares no
|
|
8037
|
+
# verification form. The refusal names the compact field to add.
|
|
8038
|
+
noform = clean.replace(
|
|
8039
|
+
" - Verify:\n - Strategy: evidence-first\n - M1: node test.js\n",
|
|
8040
|
+
"",
|
|
8041
|
+
)
|
|
8042
|
+
write_text(repo / "openspec/changes/noform/tasks.md", noform)
|
|
8043
|
+
absent = run_keel(
|
|
8044
|
+
repo, "gate", "task-complete", "--change", "noform", "--task", "1.1",
|
|
8045
|
+
"--json",
|
|
8046
|
+
)
|
|
8047
|
+
absent_payload = json.loads(absent.stdout)
|
|
8048
|
+
absent_messages = [
|
|
8049
|
+
problem.get("message", "")
|
|
8050
|
+
for problem in absent_payload.get("problems", [])
|
|
8051
|
+
]
|
|
8052
|
+
if absent_payload.get("status") != "fail":
|
|
8053
|
+
report(
|
|
8054
|
+
"unusable-contract-names-only-its-cause: a task declaring no "
|
|
8055
|
+
"verification form was not refused."
|
|
8056
|
+
)
|
|
8057
|
+
return 1
|
|
8058
|
+
if not any("`Verify`" in text for text in absent_messages):
|
|
8059
|
+
report(
|
|
8060
|
+
"unusable-contract-names-only-its-cause: the refusal did not "
|
|
8061
|
+
"name `Verify` as the field to add."
|
|
8062
|
+
)
|
|
8063
|
+
for text in absent_messages:
|
|
8064
|
+
report(f" {text}")
|
|
8065
|
+
return 1
|
|
8066
|
+
if any("must define at least one" in text for text in absent_messages):
|
|
8067
|
+
report(
|
|
8068
|
+
"unusable-contract-names-only-its-cause: a task declaring no "
|
|
8069
|
+
"verification form was told about `Commands`."
|
|
8070
|
+
)
|
|
8071
|
+
return 1
|
|
8072
|
+
if "unusable-contract-names-only-its-cause" not in {
|
|
8073
|
+
name for name, _ in SCENARIOS
|
|
8074
|
+
}:
|
|
8075
|
+
report(
|
|
8076
|
+
"unusable-contract-names-only-its-cause: the scenario registry "
|
|
8077
|
+
"does not include it."
|
|
8078
|
+
)
|
|
8079
|
+
return 1
|
|
8080
|
+
report("unusable-contract-names-only-its-cause scenario passed.")
|
|
8081
|
+
return 0
|
|
8082
|
+
|
|
8083
|
+
|
|
7585
8084
|
def validate_covers_question_reference_scope_scenario() -> int:
|
|
7586
8085
|
"""Issue #28 item 9: citing a resolved question must not re-open it.
|
|
7587
8086
|
|
|
@@ -19036,6 +19535,437 @@ def validate_assertion_shape_count_scenario() -> int:
|
|
|
19036
19535
|
return 0
|
|
19037
19536
|
|
|
19038
19537
|
|
|
19538
|
+
def validate_declared_paths_are_read_whole_scenario() -> int:
|
|
19539
|
+
"""Issue #60: a durable owner under a Chinese directory was refused.
|
|
19540
|
+
|
|
19541
|
+
`notes/note-006-转岗最难的不是流程/note.md` exists and `git ls-files` finds
|
|
19542
|
+
it, and `change-close` reported that `notes/note-006-` does not exist — a
|
|
19543
|
+
path nobody wrote. The extractor was `[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)+`,
|
|
19544
|
+
which answers "where does the path end" by assuming what a path is made of.
|
|
19545
|
+
|
|
19546
|
+
Two more shapes fell out while reproducing it: a path whose *first* segment
|
|
19547
|
+
is not ASCII does not match at all, and a path containing a space truncates
|
|
19548
|
+
at the space. The last is why the backtick form matters — `Touch` already
|
|
19549
|
+
accepts it, so one authorship was spelled two ways depending on which
|
|
19550
|
+
reader would read it.
|
|
19551
|
+
|
|
19552
|
+
Same class as #40, which fixed it in `gitPaths` for the worktree side. That
|
|
19553
|
+
fix did not generalize because it repaired one reader rather than how the
|
|
19554
|
+
repository extracts paths.
|
|
19555
|
+
"""
|
|
19556
|
+
label = "declared-paths-are-read-whole"
|
|
19557
|
+
cjk_owner = "notes/note-006-转岗最难的不是流程/note.md"
|
|
19558
|
+
cjk_first = "文档/风格.md"
|
|
19559
|
+
spaced = "docs/has space.md"
|
|
19560
|
+
|
|
19561
|
+
def git(repo, *args):
|
|
19562
|
+
return subprocess.run(
|
|
19563
|
+
["git", "-C", str(repo), *args], capture_output=True, text=True
|
|
19564
|
+
)
|
|
19565
|
+
|
|
19566
|
+
def tasks_doc(findings: str, coverage: str) -> str:
|
|
19567
|
+
return (
|
|
19568
|
+
"# Tasks\n\n"
|
|
19569
|
+
"- [x] 1.1 Exercise the declared-path readers\n"
|
|
19570
|
+
" - Covers:\n - E1: Public behavior passes.\n"
|
|
19571
|
+
" - Touch:\n - src/declared.js\n"
|
|
19572
|
+
" - Verify:\n - Strategy: evidence-first\n"
|
|
19573
|
+
" - M1: node test.js asserts the recorded feed status\n"
|
|
19574
|
+
" - Evidence:\n"
|
|
19575
|
+
" - Contract: keel-task-capsule/v1 sha256:"
|
|
19576
|
+
+ ("0" * 64) + "\n"
|
|
19577
|
+
" - M1: the suite passed\n"
|
|
19578
|
+
" - Review:\n - Status: pass\n"
|
|
19579
|
+
" - Acceptance check: behavior asserted at the interface\n"
|
|
19580
|
+
" - Scope check: only Touch files changed\n"
|
|
19581
|
+
f" - Findings: {findings}\n"
|
|
19582
|
+
" - Blocker: none\n\n"
|
|
19583
|
+
"## Invalidates\n\n- None.\n\n"
|
|
19584
|
+
"## Expectation Coverage\n\n"
|
|
19585
|
+
f"- E1: the behavior {coverage}\n"
|
|
19586
|
+
)
|
|
19587
|
+
|
|
19588
|
+
with tempfile.TemporaryDirectory(prefix="keel-declared-paths-") as raw:
|
|
19589
|
+
repo = (Path(raw) / "repo").resolve()
|
|
19590
|
+
repo.mkdir()
|
|
19591
|
+
git(repo, "init", "-q")
|
|
19592
|
+
git(repo, "config", "user.email", "t@example.com")
|
|
19593
|
+
git(repo, "config", "user.name", "keel-test")
|
|
19594
|
+
for item in (cjk_owner, cjk_first, spaced):
|
|
19595
|
+
write_text(repo / item, "# owner\n")
|
|
19596
|
+
write_text(repo / "src/declared.js", "// product\n")
|
|
19597
|
+
tasks_path = repo / "openspec/changes/demo/tasks.md"
|
|
19598
|
+
|
|
19599
|
+
def close(findings: str, coverage: str) -> dict:
|
|
19600
|
+
write_text(tasks_path, tasks_doc(findings, coverage))
|
|
19601
|
+
return json.loads(
|
|
19602
|
+
run_keel(
|
|
19603
|
+
repo, "gate", "change-close", "--change", "demo",
|
|
19604
|
+
"--action", "sync", "--json",
|
|
19605
|
+
).stdout
|
|
19606
|
+
)
|
|
19607
|
+
|
|
19608
|
+
def problems(payload: dict) -> list[str]:
|
|
19609
|
+
return [p.get("message", "") for p in payload.get("problems", [])]
|
|
19610
|
+
|
|
19611
|
+
# M1 — the reported shape, on both readers that take a declared path.
|
|
19612
|
+
for where, findings, coverage in (
|
|
19613
|
+
("Findings", f"one open. Durable owner: {cjk_owner}", "Covered by: 1.1"),
|
|
19614
|
+
("Expectation Coverage", "none", f"Durable owner: {cjk_owner}"),
|
|
19615
|
+
):
|
|
19616
|
+
payload = close(findings, coverage)
|
|
19617
|
+
truncated = [m for m in problems(payload) if "note-006-" in m]
|
|
19618
|
+
if truncated:
|
|
19619
|
+
report(
|
|
19620
|
+
f"{label} M1 a {where} durable owner naming an existing "
|
|
19621
|
+
"file under a non-ASCII directory was refused, and the "
|
|
19622
|
+
"refusal names a path nobody wrote."
|
|
19623
|
+
)
|
|
19624
|
+
for message in truncated:
|
|
19625
|
+
report(f" {message}")
|
|
19626
|
+
return 1
|
|
19627
|
+
|
|
19628
|
+
# M1 — a path whose first segment is not ASCII matched nothing at all,
|
|
19629
|
+
# which is a different failure from truncation and needs its own case.
|
|
19630
|
+
payload = close("none", f"Durable owner: {cjk_first}")
|
|
19631
|
+
rejected = [m for m in problems(payload) if "E1" in m and "owner" in m.lower()]
|
|
19632
|
+
if rejected:
|
|
19633
|
+
report(
|
|
19634
|
+
f"{label} M1 a durable owner whose first segment is not ASCII "
|
|
19635
|
+
"was not recognized as a path at all."
|
|
19636
|
+
)
|
|
19637
|
+
for message in rejected:
|
|
19638
|
+
report(f" {message}")
|
|
19639
|
+
return 1
|
|
19640
|
+
|
|
19641
|
+
# M1 — whitespace arrives in backticks, the form Touch already accepts.
|
|
19642
|
+
payload = close("none", f"Durable owner: `{spaced}`")
|
|
19643
|
+
rejected = [m for m in problems(payload) if "E1" in m and "owner" in m.lower()]
|
|
19644
|
+
if rejected:
|
|
19645
|
+
report(
|
|
19646
|
+
f"{label} M1 a backtick-wrapped path containing a space was "
|
|
19647
|
+
"not read whole, so Touch and Durable owner still disagree "
|
|
19648
|
+
"about how one path is written."
|
|
19649
|
+
)
|
|
19650
|
+
for message in rejected:
|
|
19651
|
+
report(f" {message}")
|
|
19652
|
+
return 1
|
|
19653
|
+
|
|
19654
|
+
# M1 — a path ending a sentence, in both punctuation families.
|
|
19655
|
+
for mark in ("。", ","):
|
|
19656
|
+
payload = close("none", f"Durable owner: {cjk_owner}{mark} 说明文字")
|
|
19657
|
+
rejected = [
|
|
19658
|
+
m for m in problems(payload) if "E1" in m and "owner" in m.lower()
|
|
19659
|
+
]
|
|
19660
|
+
if rejected:
|
|
19661
|
+
report(
|
|
19662
|
+
f"{label} M1 a path followed by {mark!r} was extended by "
|
|
19663
|
+
"its punctuation, so the gate looked for a file that "
|
|
19664
|
+
"cannot exist."
|
|
19665
|
+
)
|
|
19666
|
+
for message in rejected:
|
|
19667
|
+
report(f" {message}")
|
|
19668
|
+
return 1
|
|
19669
|
+
|
|
19670
|
+
# M1 — the check itself must survive the widening. A path that does not
|
|
19671
|
+
# exist is still refused, and the refusal names the whole path.
|
|
19672
|
+
missing = "notes/不存在的目录/note.md"
|
|
19673
|
+
payload = close("none", f"Durable owner: {missing}")
|
|
19674
|
+
named = [m for m in problems(payload) if missing in m]
|
|
19675
|
+
if not named:
|
|
19676
|
+
report(
|
|
19677
|
+
f"{label} M1 a durable owner naming a file that does not exist "
|
|
19678
|
+
"was accepted, or was refused without naming the whole path. "
|
|
19679
|
+
"Widening the extractor must not weaken the existence check."
|
|
19680
|
+
)
|
|
19681
|
+
for message in problems(payload):
|
|
19682
|
+
report(f" {message}")
|
|
19683
|
+
return 1
|
|
19684
|
+
|
|
19685
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
19686
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
19687
|
+
return 1
|
|
19688
|
+
report(f"{label} scenario passed.")
|
|
19689
|
+
return 0
|
|
19690
|
+
|
|
19691
|
+
|
|
19692
|
+
def validate_published_specs_validate_strictly_scenario() -> int:
|
|
19693
|
+
"""Issue #46: the store Keel publishes was validated by nothing.
|
|
19694
|
+
|
|
19695
|
+
Every change's closing task runs `openspec validate <change> --strict`,
|
|
19696
|
+
which reads the change directory and stays green. The published store under
|
|
19697
|
+
`openspec/specs/` was read by no check at all — measured at 5.16.0,
|
|
19698
|
+
`--specs` appeared zero times in this file. Issue #46 found 8 of 21 specs
|
|
19699
|
+
failing strict validation, every one on `Requirement must contain SHALL or
|
|
19700
|
+
MUST keyword`, because the requirement opened with a context paragraph and
|
|
19701
|
+
the strict validator reads only the block under the heading. Those failures
|
|
19702
|
+
had never appeared in front of anyone.
|
|
19703
|
+
|
|
19704
|
+
They are gone now, removed by spec rewrites made for other reasons. That is
|
|
19705
|
+
what makes this the moment to assert it absolutely rather than as the
|
|
19706
|
+
ratchet #46 proposed: at a count of 8 a ratchet was the honest shape, and at
|
|
19707
|
+
0 the same mechanism becomes a budget for failures to hide in.
|
|
19708
|
+
"""
|
|
19709
|
+
label = "published-specs-validate-strictly"
|
|
19710
|
+
|
|
19711
|
+
# The pinned binary, not whatever `openspec` PATH happens to offer. This is
|
|
19712
|
+
# not fussiness: measured here, PATH answers 1.4.1 and reports 8 failures
|
|
19713
|
+
# while the version this repository resolves answers 1.6.0 and reports
|
|
19714
|
+
# none. Issue #46 recorded those 8 failures as an openspec 1.6.0 result;
|
|
19715
|
+
# they are 1.4.1's, and the store has always passed under the version the
|
|
19716
|
+
# repository pins. A check that reads PATH would re-record the same
|
|
19717
|
+
# mistake, which is exactly what `keel-target-surface-diagnostics` means by
|
|
19718
|
+
# a suite that silently changes which program it runs reporting facts about
|
|
19719
|
+
# a different program.
|
|
19720
|
+
pinned = ROOT / "node_modules" / ".bin" / (
|
|
19721
|
+
"openspec.cmd" if os.name == "nt" else "openspec"
|
|
19722
|
+
)
|
|
19723
|
+
if not pinned.exists():
|
|
19724
|
+
report(
|
|
19725
|
+
f"{label}: the pinned openspec is not installed "
|
|
19726
|
+
"(node_modules/.bin/openspec); reporting the skip rather than "
|
|
19727
|
+
"falling back to PATH, which would answer for a different program."
|
|
19728
|
+
)
|
|
19729
|
+
return 0
|
|
19730
|
+
|
|
19731
|
+
def pinned_openspec(*args: str) -> subprocess.CompletedProcess[str]:
|
|
19732
|
+
return subprocess.run(
|
|
19733
|
+
[str(pinned), *args],
|
|
19734
|
+
cwd=ROOT,
|
|
19735
|
+
text=True,
|
|
19736
|
+
encoding="utf-8",
|
|
19737
|
+
errors="replace",
|
|
19738
|
+
capture_output=True,
|
|
19739
|
+
check=False,
|
|
19740
|
+
)
|
|
19741
|
+
|
|
19742
|
+
version = pinned_openspec("--version")
|
|
19743
|
+
exercised = re.search(r"\d+\.\d+\.\d+", version.stdout or "")
|
|
19744
|
+
result = pinned_openspec("validate", "--specs", "--strict")
|
|
19745
|
+
|
|
19746
|
+
output = f"{result.stdout or ''}{result.stderr or ''}"
|
|
19747
|
+
# Two independent readings. The exit status alone would pass if the command
|
|
19748
|
+
# stopped validating; the per-spec lines alone would pass if it started
|
|
19749
|
+
# exiting non-zero for an unrelated reason. Neither is trusted on its own.
|
|
19750
|
+
failed = [
|
|
19751
|
+
line.strip()
|
|
19752
|
+
for line in output.splitlines()
|
|
19753
|
+
if line.strip().startswith("✗")
|
|
19754
|
+
]
|
|
19755
|
+
totals = re.search(r"Totals:\s*(\d+) passed,\s*(\d+) failed", output)
|
|
19756
|
+
if not totals:
|
|
19757
|
+
report(
|
|
19758
|
+
f"{label} the validator produced no totals line, so the result "
|
|
19759
|
+
"cannot be read. The output shape it reports may have moved."
|
|
19760
|
+
)
|
|
19761
|
+
report(output.strip()[:600])
|
|
19762
|
+
return 1
|
|
19763
|
+
passed_count, failed_count = int(totals.group(1)), int(totals.group(2))
|
|
19764
|
+
|
|
19765
|
+
if failed:
|
|
19766
|
+
report(
|
|
19767
|
+
f"{label} {len(failed)} published spec(s) fail strict validation "
|
|
19768
|
+
f"against openspec {exercised.group(0) if exercised else 'unknown'}. "
|
|
19769
|
+
"A published spec that the validator Keel ships refuses is one "
|
|
19770
|
+
"every consumer sees refused. The usual cause is a requirement "
|
|
19771
|
+
"whose modal verb sits below its first paragraph — the strict "
|
|
19772
|
+
"validator reads only the block directly under the heading."
|
|
19773
|
+
)
|
|
19774
|
+
for line in failed:
|
|
19775
|
+
report(f" {line}")
|
|
19776
|
+
return 1
|
|
19777
|
+
if failed_count != 0:
|
|
19778
|
+
report(
|
|
19779
|
+
f"{label} the totals line reports {failed_count} failing spec(s) "
|
|
19780
|
+
"while no per-spec failure line was emitted. The two readings "
|
|
19781
|
+
"disagree, so the result is not trustworthy either way."
|
|
19782
|
+
)
|
|
19783
|
+
report(output.strip()[:600])
|
|
19784
|
+
return 1
|
|
19785
|
+
if result.returncode != 0:
|
|
19786
|
+
report(
|
|
19787
|
+
f"{label} the validator reported {passed_count} passed and 0 "
|
|
19788
|
+
f"failed but exited {result.returncode}. The verdict and the exit "
|
|
19789
|
+
"status disagree."
|
|
19790
|
+
)
|
|
19791
|
+
report(output.strip()[:600])
|
|
19792
|
+
return 1
|
|
19793
|
+
if passed_count == 0:
|
|
19794
|
+
report(
|
|
19795
|
+
f"{label} the validator reports zero specs. An empty store passes "
|
|
19796
|
+
"every assertion about it, which is the shape "
|
|
19797
|
+
"`A derived assertion set that collapses to empty fails instead of "
|
|
19798
|
+
"passing` exists to refuse."
|
|
19799
|
+
)
|
|
19800
|
+
return 1
|
|
19801
|
+
|
|
19802
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
19803
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
19804
|
+
return 1
|
|
19805
|
+
report(
|
|
19806
|
+
f"{label} scenario passed: {passed_count} published specs validate "
|
|
19807
|
+
f"strictly against openspec "
|
|
19808
|
+
f"{exercised.group(0) if exercised else 'unknown'}."
|
|
19809
|
+
)
|
|
19810
|
+
return 0
|
|
19811
|
+
|
|
19812
|
+
|
|
19813
|
+
def validate_decimal_runs_are_not_hash_shaped_scenario() -> int:
|
|
19814
|
+
"""Issue #58: an eleven-digit fake phone number failed `keel state`.
|
|
19815
|
+
|
|
19816
|
+
The criterion was a context word beside `[0-9a-f]{7,40}`, and eleven
|
|
19817
|
+
decimal digits are eleven characters of that class. So was a timestamp,
|
|
19818
|
+
an order number, a port, and any numeric fixture that happened to sit on a
|
|
19819
|
+
line with the word `commit` or `提交` — which in evidence prose is most of
|
|
19820
|
+
them.
|
|
19821
|
+
|
|
19822
|
+
The cost is not the refusal, it is what the refusal asks for. Nothing is
|
|
19823
|
+
wrong with the line, so the only way past it is to write the evidence
|
|
19824
|
+
differently; the reporter changed the number to `138****0000`. Evidence
|
|
19825
|
+
reworded to satisfy a pattern is weaker than the evidence that was true,
|
|
19826
|
+
and a check that fails on correct work is one people learn to route
|
|
19827
|
+
around.
|
|
19828
|
+
|
|
19829
|
+
Same class as #60, repaired one release earlier in the other direction:
|
|
19830
|
+
there the character class was too narrow. The rule was right both times.
|
|
19831
|
+
"""
|
|
19832
|
+
label = "decimal-runs-are-not-hash-shaped"
|
|
19833
|
+
|
|
19834
|
+
def tasks_doc(body: str) -> str:
|
|
19835
|
+
return (
|
|
19836
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
19837
|
+
"## Tasks\n\n"
|
|
19838
|
+
"- [x] A1 implementation\n"
|
|
19839
|
+
f"{body}\n"
|
|
19840
|
+
"## Workflow Notes\n\n- None.\n"
|
|
19841
|
+
)
|
|
19842
|
+
|
|
19843
|
+
# F2, measured at 5.18.0: each of these is refused by the pre-change
|
|
19844
|
+
# criterion, and none of them records anything git owns.
|
|
19845
|
+
accepted = (
|
|
19846
|
+
"- M1: pass —— 提交表单时手机号 13800138000 通过校验。\n",
|
|
19847
|
+
"- M2: pass —— 时间戳 1700000000 与 commit 记录对齐。\n",
|
|
19848
|
+
"- M3: pass —— 提交订单号 20260802123 落库。\n",
|
|
19849
|
+
)
|
|
19850
|
+
# The other half of the requirement: what the rule exists to refuse.
|
|
19851
|
+
refused_token = "- M4: pass —— 合入前的 commit a1b2c3d4e5f6 已验证。\n"
|
|
19852
|
+
# Wording alone, carrying no hash-shaped token at all.
|
|
19853
|
+
refused_wording = "- M5: 该任务**未提交**,等待评审。\n"
|
|
19854
|
+
|
|
19855
|
+
def state_of(check) -> str:
|
|
19856
|
+
if "keel state: ok" in check.stdout:
|
|
19857
|
+
return "ok"
|
|
19858
|
+
if "keel state: failed" in check.stdout:
|
|
19859
|
+
return "failed"
|
|
19860
|
+
return "unreported"
|
|
19861
|
+
|
|
19862
|
+
with tempfile.TemporaryDirectory(prefix="keel-decimal-runs-") as raw:
|
|
19863
|
+
repo = (Path(raw) / "repo").resolve()
|
|
19864
|
+
repo.mkdir()
|
|
19865
|
+
install = run_keel(repo, "--install")
|
|
19866
|
+
if install.returncode != 0:
|
|
19867
|
+
report(f"{label}: keel --install failed while building the fixture.")
|
|
19868
|
+
report((install.stderr or install.stdout).strip())
|
|
19869
|
+
return 1
|
|
19870
|
+
|
|
19871
|
+
tasks_path = repo / "openspec/changes/numbers-in-evidence/tasks.md"
|
|
19872
|
+
|
|
19873
|
+
def check(body: str):
|
|
19874
|
+
write_text(tasks_path, tasks_doc(body))
|
|
19875
|
+
return run_keel(repo, "--check")
|
|
19876
|
+
|
|
19877
|
+
# `keel state` reporting nothing at all is a different failure from it
|
|
19878
|
+
# reporting a refusal, and one condition covering both would send the
|
|
19879
|
+
# reader to a line that has no problem in it.
|
|
19880
|
+
def unreported(result, where: str) -> bool:
|
|
19881
|
+
if state_of(result) != "unreported":
|
|
19882
|
+
return False
|
|
19883
|
+
report(
|
|
19884
|
+
f"{label}: keel --check reported no state at all while {where}. "
|
|
19885
|
+
"This is not a verdict about the fixture — the check did not "
|
|
19886
|
+
"reach the point of having one."
|
|
19887
|
+
)
|
|
19888
|
+
report((result.stderr or result.stdout).strip()[:600])
|
|
19889
|
+
return True
|
|
19890
|
+
|
|
19891
|
+
# M1 — the reported shape and its two siblings, each on its own line so
|
|
19892
|
+
# a failure names which one.
|
|
19893
|
+
for line in accepted:
|
|
19894
|
+
result = check(line)
|
|
19895
|
+
if unreported(result, "reading one ordinary number"):
|
|
19896
|
+
return 1
|
|
19897
|
+
if state_of(result) != "ok":
|
|
19898
|
+
report(
|
|
19899
|
+
f"{label}: an ordinary number in evidence prose was refused "
|
|
19900
|
+
"as a recorded identifier. Nothing on this line records "
|
|
19901
|
+
"anything git owns, so the only way past the refusal is to "
|
|
19902
|
+
"reword evidence that was true."
|
|
19903
|
+
)
|
|
19904
|
+
report(f" line: {line.strip()}")
|
|
19905
|
+
for state_error in [
|
|
19906
|
+
out for out in result.stdout.splitlines()
|
|
19907
|
+
if out.startswith("state-error")
|
|
19908
|
+
]:
|
|
19909
|
+
report(f" {state_error}")
|
|
19910
|
+
return 1
|
|
19911
|
+
|
|
19912
|
+
# All three together, because the check reports per line and a
|
|
19913
|
+
# per-line pass says nothing about a file holding several.
|
|
19914
|
+
together = check("".join(accepted))
|
|
19915
|
+
if unreported(together, "reading three ordinary numbers in one file"):
|
|
19916
|
+
return 1
|
|
19917
|
+
if state_of(together) != "ok":
|
|
19918
|
+
report(f"{label}: three ordinary numbers in one file were refused.")
|
|
19919
|
+
report(together.stdout.strip()[:600])
|
|
19920
|
+
return 1
|
|
19921
|
+
|
|
19922
|
+
# M1 negative — the narrowing must not have narrowed the rule.
|
|
19923
|
+
with_token = check("".join(accepted) + refused_token)
|
|
19924
|
+
if unreported(with_token, "reading a hexadecimal identifier"):
|
|
19925
|
+
return 1
|
|
19926
|
+
if state_of(with_token) != "failed":
|
|
19927
|
+
report(
|
|
19928
|
+
f"{label}: a hexadecimal identifier of that length beside a "
|
|
19929
|
+
"context word was accepted. Narrowing what counts as an "
|
|
19930
|
+
"identifier must not stop the check refusing one."
|
|
19931
|
+
)
|
|
19932
|
+
report(with_token.stdout.strip()[:600])
|
|
19933
|
+
return 1
|
|
19934
|
+
if with_token.returncode == 0:
|
|
19935
|
+
report(f"{label}: the refusal did not fail the check's exit status.")
|
|
19936
|
+
return 1
|
|
19937
|
+
named = [
|
|
19938
|
+
out for out in with_token.stdout.splitlines()
|
|
19939
|
+
if out.startswith("state-error") and "tasks.md:" in out
|
|
19940
|
+
]
|
|
19941
|
+
if not named:
|
|
19942
|
+
report(
|
|
19943
|
+
f"{label}: the refusal named no line. An author cannot act on "
|
|
19944
|
+
"a refusal that does not say where."
|
|
19945
|
+
)
|
|
19946
|
+
report(with_token.stdout.strip()[:600])
|
|
19947
|
+
return 1
|
|
19948
|
+
|
|
19949
|
+
# And the wording patterns, which never depended on a digit run.
|
|
19950
|
+
wording = check(refused_wording)
|
|
19951
|
+
if unreported(wording, "reading recorded work state written in words"):
|
|
19952
|
+
return 1
|
|
19953
|
+
if state_of(wording) != "failed":
|
|
19954
|
+
report(
|
|
19955
|
+
f"{label}: recorded work state written in words was accepted. "
|
|
19956
|
+
"That rule reads the wording and is untouched by any change to "
|
|
19957
|
+
"what counts as an identifier."
|
|
19958
|
+
)
|
|
19959
|
+
report(wording.stdout.strip()[:600])
|
|
19960
|
+
return 1
|
|
19961
|
+
|
|
19962
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
19963
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
19964
|
+
return 1
|
|
19965
|
+
report(f"{label} scenario passed.")
|
|
19966
|
+
return 0
|
|
19967
|
+
|
|
19968
|
+
|
|
19039
19969
|
def validate_validation_runner_scenario() -> int:
|
|
19040
19970
|
if "SCENARIOS" not in globals():
|
|
19041
19971
|
report("validation-runner: the scenario registry is missing.")
|
|
@@ -19257,6 +20187,10 @@ SCENARIOS: tuple = (
|
|
|
19257
20187
|
"non-concrete-check-names-token",
|
|
19258
20188
|
validate_non_concrete_check_names_token_scenario,
|
|
19259
20189
|
),
|
|
20190
|
+
(
|
|
20191
|
+
"unusable-contract-names-only-its-cause",
|
|
20192
|
+
validate_unusable_contract_names_only_its_cause_scenario,
|
|
20193
|
+
),
|
|
19260
20194
|
(
|
|
19261
20195
|
"absent-verification-form-is-one-problem",
|
|
19262
20196
|
validate_absent_verification_form_is_one_problem_scenario,
|
|
@@ -19286,10 +20220,18 @@ SCENARIOS: tuple = (
|
|
|
19286
20220
|
"git-paths-carry-no-escaping",
|
|
19287
20221
|
validate_git_paths_carry_no_escaping_scenario,
|
|
19288
20222
|
),
|
|
20223
|
+
(
|
|
20224
|
+
"default-completion-attributes-writes",
|
|
20225
|
+
validate_default_completion_attributes_writes_scenario,
|
|
20226
|
+
),
|
|
19289
20227
|
(
|
|
19290
20228
|
"runtime-versions-are-checked",
|
|
19291
20229
|
validate_runtime_versions_are_checked_scenario,
|
|
19292
20230
|
),
|
|
20231
|
+
(
|
|
20232
|
+
"doctor-reads-the-diagnosed-repository",
|
|
20233
|
+
validate_doctor_reads_the_diagnosed_repository_scenario,
|
|
20234
|
+
),
|
|
19293
20235
|
("task-shape-warning", validate_task_shape_warning_scenario),
|
|
19294
20236
|
("context-names-its-keel", validate_context_names_its_keel_scenario),
|
|
19295
20237
|
("interpreter-surfaces-agree", validate_interpreter_surfaces_agree_scenario),
|
|
@@ -19378,6 +20320,18 @@ SCENARIOS: tuple = (
|
|
|
19378
20320
|
("domain-lens-doctor", validate_domain_lens_doctor_scenario),
|
|
19379
20321
|
("plan-funnel-guidance", validate_plan_funnel_guidance_scenario),
|
|
19380
20322
|
("native-tasks-view", validate_native_tasks_view_scenario),
|
|
20323
|
+
(
|
|
20324
|
+
"declared-paths-are-read-whole",
|
|
20325
|
+
validate_declared_paths_are_read_whole_scenario,
|
|
20326
|
+
),
|
|
20327
|
+
(
|
|
20328
|
+
"published-specs-validate-strictly",
|
|
20329
|
+
validate_published_specs_validate_strictly_scenario,
|
|
20330
|
+
),
|
|
20331
|
+
(
|
|
20332
|
+
"decimal-runs-are-not-hash-shaped",
|
|
20333
|
+
validate_decimal_runs_are_not_hash_shaped_scenario,
|
|
20334
|
+
),
|
|
19381
20335
|
("validation-runner", validate_validation_runner_scenario),
|
|
19382
20336
|
)
|
|
19383
20337
|
|