@christang/keel 5.16.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/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/scripts/install_to_repo.py +12 -2
- package/scripts/validate_plugin.py +640 -2
- package/src/core/gates.js +48 -14
- package/src/core/task-contract.js +18 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.20.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.20.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",
|
|
@@ -120,9 +120,19 @@ TASKS_COMMIT_STATUS_PATTERNS = (
|
|
|
120
120
|
"remove branch merge state from tasks.md; git log is the source of truth",
|
|
121
121
|
),
|
|
122
122
|
)
|
|
123
|
+
# A commit identifier is hexadecimal, and `a`-`f` are the only reason those
|
|
124
|
+
# letters appear in one. A run of decimal digits alone is a phone number, a
|
|
125
|
+
# timestamp, an order number, or a port — evidence prose, not state git owns.
|
|
126
|
+
# Matching one as a recorded identifier (#58) asks the author to reword
|
|
127
|
+
# something that was true, and a check that refuses correct work is one people
|
|
128
|
+
# learn to route around. The length and word-boundary conditions are kept
|
|
129
|
+
# verbatim inside the lookahead, so the token that matches is the token that
|
|
130
|
+
# always matched, minus the all-decimal ones.
|
|
131
|
+
_HASH_SHAPED_TOKEN = r"\b(?=[0-9a-f]{7,40}\b)[0-9a-f]*[a-f][0-9a-f]*\b"
|
|
132
|
+
_HASH_CONTEXT_WORD = r"(?:commit|提交|合入|master|main|HEAD|hash|哈希)"
|
|
123
133
|
TASKS_CONTEXTUAL_HASH_RE = re.compile(
|
|
124
|
-
|
|
125
|
-
|
|
134
|
+
rf"(?i){_HASH_CONTEXT_WORD}.*{_HASH_SHAPED_TOKEN}|"
|
|
135
|
+
rf"{_HASH_SHAPED_TOKEN}.*{_HASH_CONTEXT_WORD}"
|
|
126
136
|
)
|
|
127
137
|
|
|
128
138
|
|
|
@@ -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
|
|
@@ -7890,6 +7890,197 @@ def validate_non_concrete_check_names_token_scenario() -> int:
|
|
|
7890
7890
|
return 0
|
|
7891
7891
|
|
|
7892
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
|
+
|
|
7893
8084
|
def validate_covers_question_reference_scope_scenario() -> int:
|
|
7894
8085
|
"""Issue #28 item 9: citing a resolved question must not re-open it.
|
|
7895
8086
|
|
|
@@ -19344,6 +19535,437 @@ def validate_assertion_shape_count_scenario() -> int:
|
|
|
19344
19535
|
return 0
|
|
19345
19536
|
|
|
19346
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
|
+
|
|
19347
19969
|
def validate_validation_runner_scenario() -> int:
|
|
19348
19970
|
if "SCENARIOS" not in globals():
|
|
19349
19971
|
report("validation-runner: the scenario registry is missing.")
|
|
@@ -19565,6 +20187,10 @@ SCENARIOS: tuple = (
|
|
|
19565
20187
|
"non-concrete-check-names-token",
|
|
19566
20188
|
validate_non_concrete_check_names_token_scenario,
|
|
19567
20189
|
),
|
|
20190
|
+
(
|
|
20191
|
+
"unusable-contract-names-only-its-cause",
|
|
20192
|
+
validate_unusable_contract_names_only_its_cause_scenario,
|
|
20193
|
+
),
|
|
19568
20194
|
(
|
|
19569
20195
|
"absent-verification-form-is-one-problem",
|
|
19570
20196
|
validate_absent_verification_form_is_one_problem_scenario,
|
|
@@ -19694,6 +20320,18 @@ SCENARIOS: tuple = (
|
|
|
19694
20320
|
("domain-lens-doctor", validate_domain_lens_doctor_scenario),
|
|
19695
20321
|
("plan-funnel-guidance", validate_plan_funnel_guidance_scenario),
|
|
19696
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
|
+
),
|
|
19697
20335
|
("validation-runner", validate_validation_runner_scenario),
|
|
19698
20336
|
)
|
|
19699
20337
|
|
package/src/core/gates.js
CHANGED
|
@@ -372,6 +372,35 @@ const DURABLE_OWNER_FORMS =
|
|
|
372
372
|
+ "repository's own ledger; `keel/HANDOFF.md` is a pointer override rather "
|
|
373
373
|
+ "than an owner";
|
|
374
374
|
|
|
375
|
+
// Trailing punctuation a declared path can abut in prose. ASCII sentence marks
|
|
376
|
+
// and their CJK counterparts both belong here: once the extractor stops
|
|
377
|
+
// assuming ASCII, its terminators cannot assume ASCII either. A Chinese
|
|
378
|
+
// sentence ends in `。`, which is not whitespace, so a non-whitespace run
|
|
379
|
+
// swallows it and the gate looks for a file that cannot exist.
|
|
380
|
+
const DECLARED_PATH_TRAILING = /[.,;:!?)\]}"'\u2019\u201d\u3002\uff0c\u3001\uff1b\uff1a\uff01\uff1f\uff09\u3011\u300b\u300d\u300f]+$/;
|
|
381
|
+
|
|
382
|
+
// A declared path is a run of non-whitespace holding a separator. What ends a
|
|
383
|
+
// path is whitespace; what a path is *made of* is the filesystem's business,
|
|
384
|
+
// and answering the first question with the second is what refused
|
|
385
|
+
// `notes/note-006-转岗最难的不是流程/note.md` by reporting that
|
|
386
|
+
// `notes/note-006-` does not exist — a path nobody wrote (issue #60). It is the
|
|
387
|
+
// same class as #40 on the worktree-reading side, which survived because that
|
|
388
|
+
// fix repaired one reader rather than how paths are extracted; this is the one
|
|
389
|
+
// extractor every gate reader of a declared path now uses.
|
|
390
|
+
//
|
|
391
|
+
// The backtick form wins when present. It is the only way to write a path
|
|
392
|
+
// containing whitespace, and `touchEntries` already strips backticks from a
|
|
393
|
+
// Touch entry, so one authorship stops being spelled two ways depending on
|
|
394
|
+
// which reader will read it.
|
|
395
|
+
function declaredPath(value) {
|
|
396
|
+
const text = String(value || "");
|
|
397
|
+
const quoted = text.match(/`([^`\n]*\/[^`\n]*)`/);
|
|
398
|
+
if (quoted) return quoted[1].trim() || null;
|
|
399
|
+
const bare = text.match(/[^\s`]+\/[^\s`]+/);
|
|
400
|
+
if (!bare) return null;
|
|
401
|
+
return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
|
|
402
|
+
}
|
|
403
|
+
|
|
375
404
|
// Classify a declared `Durable owner:` value. A gate runs without network, so a
|
|
376
405
|
// URL is accepted on shape alone; a path is the one form it can actually check,
|
|
377
406
|
// and checking it is stricter than the prefix whitelist this replaced.
|
|
@@ -380,10 +409,10 @@ function durableOwnerVerdict(repo, value) {
|
|
|
380
409
|
if (!owner) return { ok: false, reason: "unrecognized" };
|
|
381
410
|
if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
|
|
382
411
|
if (TRACKER_REFERENCE.test(owner)) return { ok: true };
|
|
383
|
-
const candidate = owner
|
|
412
|
+
const candidate = declaredPath(owner);
|
|
384
413
|
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
385
|
-
if (fs.existsSync(path.join(repo, candidate
|
|
386
|
-
return { ok: false, reason: "missing", path: candidate
|
|
414
|
+
if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
|
|
415
|
+
return { ok: false, reason: "missing", path: candidate };
|
|
387
416
|
}
|
|
388
417
|
|
|
389
418
|
// A finding has three dispositions and the gate recognized two. One found and
|
|
@@ -423,10 +452,10 @@ function resolutionEvidenceVerdict(repo, value, commands) {
|
|
|
423
452
|
if (commands.includes(cited[0])) return { ok: true };
|
|
424
453
|
return { ok: false, reason: "unknown-check", label: cited[0] };
|
|
425
454
|
}
|
|
426
|
-
const candidate = evidence
|
|
455
|
+
const candidate = declaredPath(evidence);
|
|
427
456
|
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
428
|
-
if (fs.existsSync(path.join(repo, candidate
|
|
429
|
-
return { ok: false, reason: "missing", path: candidate
|
|
457
|
+
if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
|
|
458
|
+
return { ok: false, reason: "missing", path: candidate };
|
|
430
459
|
}
|
|
431
460
|
|
|
432
461
|
function resolutionEvidenceMessage(verdict) {
|
|
@@ -466,13 +495,12 @@ function findingOwnerIsDurable(repo, findings) {
|
|
|
466
495
|
/\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
|
|
467
496
|
);
|
|
468
497
|
if (artifact && fs.existsSync(path.join(repo, artifact[1]))) return true;
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
);
|
|
498
|
+
// Same extractor, scoped to the archive prefix: the segment after
|
|
499
|
+
// `keel/archive/` is a path like any other and was equally ASCII-bound.
|
|
500
|
+
const archive = findings.match(/keel\/archive\/[^\s`]*/i);
|
|
501
|
+
if (!archive) return false;
|
|
502
|
+
const archivePath = declaredPath(archive[0]);
|
|
503
|
+
return Boolean(archivePath) && fs.existsSync(path.join(repo, archivePath));
|
|
476
504
|
}
|
|
477
505
|
|
|
478
506
|
// Read in `-z` form, because every other form escapes. Git octal-escapes any
|
|
@@ -678,7 +706,13 @@ function completionChecks(repo, task, contract = null) {
|
|
|
678
706
|
const commands = contract
|
|
679
707
|
? contract.capsule.verification.commands.map((item) => item.label)
|
|
680
708
|
: commandLabels(task);
|
|
681
|
-
|
|
709
|
+
// With no contract, the labels came from the expanded v3 `Commands` field,
|
|
710
|
+
// which a compact task never declares — so their absence is a fact about the
|
|
711
|
+
// fallback, not about the task. The compiler's own diagnostics are already in
|
|
712
|
+
// `problems` (the caller pushes them unconditionally, and an unusable
|
|
713
|
+
// contract has at least one), so this cannot turn a refusal into a pass. The
|
|
714
|
+
// per-label evidence checks below stay: a genuine v3 task yields real labels.
|
|
715
|
+
if (contract && commands.length === 0) {
|
|
682
716
|
problems.push(problem("missing-commands", "Commands must define at least one M<n>."));
|
|
683
717
|
}
|
|
684
718
|
for (const label of commands) {
|
|
@@ -416,10 +416,24 @@ function requiredFieldProblems(task) {
|
|
|
416
416
|
function missingFieldProblems(task, names) {
|
|
417
417
|
return names
|
|
418
418
|
.filter((name) => !isConcrete(field(task, name)))
|
|
419
|
-
.map((name) =>
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
419
|
+
.map((name) => {
|
|
420
|
+
// Name the matched slot, the way the check and Verify diagnostics already
|
|
421
|
+
// do. The unqualified wording stated only the verdict, so an author whose
|
|
422
|
+
// field held a token in ordinary prose had nothing to search for — and
|
|
423
|
+
// the first problem they saw named a field of the other schema instead.
|
|
424
|
+
// The code is unchanged: the verdict is the same either way, and a new
|
|
425
|
+
// one would hide this case from every consumer keying on `missing-field`.
|
|
426
|
+
const token = unfilledToken(field(task, name));
|
|
427
|
+
return {
|
|
428
|
+
code: "missing-field",
|
|
429
|
+
message: token
|
|
430
|
+
? `${name} carries the unfilled slot \`${token}\`, so it is not `
|
|
431
|
+
+ "concrete. Replace that slot with the value it stands for, or "
|
|
432
|
+
+ "fence it in inline code when it is literal text — a numeric "
|
|
433
|
+
+ "range or test output — rather than a slot left to fill."
|
|
434
|
+
: `${name} must be concrete.`,
|
|
435
|
+
};
|
|
436
|
+
});
|
|
423
437
|
}
|
|
424
438
|
|
|
425
439
|
function canonical(value) {
|