@christang/keel 5.46.0 → 5.48.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 +4 -2
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +74 -0
- 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 +329 -2
- package/src/core/gates.js +48 -15
package/README.md
CHANGED
|
@@ -280,8 +280,10 @@ command at the right moment. Three things make that happen.
|
|
|
280
280
|
- **The hooks.** A SessionStart hook runs the continuity projection the moment a session
|
|
281
281
|
opens; a PreToolUse hook enforces the write guard on every edit. Neither needs prompting.
|
|
282
282
|
|
|
283
|
-
So in day-to-day use you run two commands: `keel --
|
|
284
|
-
|
|
283
|
+
So in day-to-day use you run two commands: `keel --doctor` when you want to check the
|
|
284
|
+
wiring, and `keel --init` whenever it tells you the repository is behind its install — the
|
|
285
|
+
protocol version lives in your `AGENTS.md`, and updating the package does not move it.
|
|
286
|
+
Everything below is the vocabulary the agent uses on your behalf.
|
|
285
287
|
|
|
286
288
|
## Verification layering
|
|
287
289
|
|
package/bin/keel.js
CHANGED
|
@@ -983,6 +983,78 @@ function printDoctorLine(name, status, detail = "") {
|
|
|
983
983
|
process.stdout.write(`${name}: ${status}${detail ? ` - ${detail}` : ""}\n`);
|
|
984
984
|
}
|
|
985
985
|
|
|
986
|
+
// The `version=` attribute of the managed marker is written by every install
|
|
987
|
+
// and, until this line existed, read back by nothing that runs locally: both
|
|
988
|
+
// marker parsers match `keel:start(?:\s+[^>]*)?` and throw the attributes away.
|
|
989
|
+
// The one reader was the plugin's SessionStart hook, and doctor reports that
|
|
990
|
+
// plugin's activation as manual on every target — so the check might simply not
|
|
991
|
+
// be running, with nothing to distinguish that from agreement. Doctor is the
|
|
992
|
+
// model-free fallback: the declaration comes from the working tree and the
|
|
993
|
+
// running version from this process, so the comparison holds with no plugin at
|
|
994
|
+
// all.
|
|
995
|
+
function declaredProtocolVersion(repo) {
|
|
996
|
+
const agentsPath = path.join(repo, "AGENTS.md");
|
|
997
|
+
if (!fs.existsSync(agentsPath)) return null;
|
|
998
|
+
const match = fs
|
|
999
|
+
.readFileSync(agentsPath, "utf8")
|
|
1000
|
+
.match(/<!--\s*keel:start\s+version=(\d+\.\d+\.\d+)\s*-->/);
|
|
1001
|
+
return match ? match[1] : null;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Numeric, not lexical: "5.9.0" precedes "5.10.0" and string order says
|
|
1005
|
+
// otherwise. Returns a negative number when `a` is behind `b`.
|
|
1006
|
+
function compareVersions(a, b) {
|
|
1007
|
+
const left = a.split(".").map(Number);
|
|
1008
|
+
const right = b.split(".").map(Number);
|
|
1009
|
+
for (let i = 0; i < 3; i += 1) {
|
|
1010
|
+
if (left[i] !== right[i]) return left[i] - right[i];
|
|
1011
|
+
}
|
|
1012
|
+
return 0;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Printed on every run, agreeing or not. A check that is silent when it passes
|
|
1016
|
+
// cannot be told apart from a check that did not run, which is the failure this
|
|
1017
|
+
// line exists to remove — so silence is never the report.
|
|
1018
|
+
function printProtocolVersionDrift(repo, target) {
|
|
1019
|
+
const running = PACKAGE_JSON.version;
|
|
1020
|
+
const declared = declaredProtocolVersion(repo);
|
|
1021
|
+
if (!declared) {
|
|
1022
|
+
printDoctorLine(
|
|
1023
|
+
"protocol",
|
|
1024
|
+
"not comparable",
|
|
1025
|
+
`this CLI is ${running}; the repository declares no protocol version in `
|
|
1026
|
+
+ "an AGENTS.md `keel:start` marker — run "
|
|
1027
|
+
+ `keel --init --target ${target} to write one`
|
|
1028
|
+
);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const order = compareVersions(declared, running);
|
|
1032
|
+
if (order === 0) {
|
|
1033
|
+
printDoctorLine(
|
|
1034
|
+
"protocol",
|
|
1035
|
+
"ok",
|
|
1036
|
+
`repo declares ${declared}, this CLI is ${running}`
|
|
1037
|
+
);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
// The two directions have different repairs, and the wrong repair is a no-op
|
|
1041
|
+
// that reads as a failure, so the line names the direction rather than the
|
|
1042
|
+
// difference. Drift never reaches the exit code: an out-of-date install is
|
|
1043
|
+
// not a broken one, and a doctor that goes red on release day is a doctor
|
|
1044
|
+
// people switch off.
|
|
1045
|
+
printDoctorLine(
|
|
1046
|
+
"protocol",
|
|
1047
|
+
"warning",
|
|
1048
|
+
order < 0
|
|
1049
|
+
? `repo declares ${declared}, this CLI is ${running} — the repository is `
|
|
1050
|
+
+ `behind its install; run keel --init --target ${target} to bring the `
|
|
1051
|
+
+ "protocol forward"
|
|
1052
|
+
: `repo declares ${declared}, this CLI is ${running} — the install is `
|
|
1053
|
+
+ "behind the repository, which carries a protocol this CLI cannot "
|
|
1054
|
+
+ "enforce; update the Keel package"
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
986
1058
|
function codexHome() {
|
|
987
1059
|
const configured = (process.env.CODEX_HOME || "").trim();
|
|
988
1060
|
return path.resolve(configured || path.join(os.homedir(), ".codex"));
|
|
@@ -1553,6 +1625,8 @@ function runDoctor(options) {
|
|
|
1553
1625
|
);
|
|
1554
1626
|
}
|
|
1555
1627
|
|
|
1628
|
+
printProtocolVersionDrift(repo, options.target);
|
|
1629
|
+
|
|
1556
1630
|
process.stdout.write("\nProject status:\n");
|
|
1557
1631
|
const checkStatus = runPython(
|
|
1558
1632
|
INSTALL_SCRIPT,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.48.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.48.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",
|
|
@@ -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.48.0"
|
|
41
|
+
PROTOCOL_VERSION = "5.48.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
|
|
@@ -5580,6 +5580,205 @@ def validate_a_declared_dependency_is_resolved_scenario() -> int:
|
|
|
5580
5580
|
return 0
|
|
5581
5581
|
|
|
5582
5582
|
|
|
5583
|
+
def validate_a_path_is_what_the_value_declares_scenario() -> int:
|
|
5584
|
+
"""Issue #113: the extractor answered with a path the value did not declare.
|
|
5585
|
+
|
|
5586
|
+
Two shapes, measured across five consuming repositories — 624 path-shaped
|
|
5587
|
+
declarations, 34 of them extracting something else.
|
|
5588
|
+
|
|
5589
|
+
A sentence supplies no whitespace. `openspec/FOLLOWUP.md。②接着说别的`
|
|
5590
|
+
extracted whole, because the run is bounded by whitespace and Chinese prose
|
|
5591
|
+
puts none after a path. That half refuses loudly, naming a path nobody
|
|
5592
|
+
wrote.
|
|
5593
|
+
|
|
5594
|
+
A citation outranked the declaration. The backtick branch ran first and
|
|
5595
|
+
searched the whole value, so a `Durable owner:` that named its owner and
|
|
5596
|
+
then quoted another file resolved to the quotation. That half is worse: the
|
|
5597
|
+
quoted file usually exists, so the gate accepts and checks a file the
|
|
5598
|
+
author never declared.
|
|
5599
|
+
"""
|
|
5600
|
+
label = "a-path-is-what-the-value-declares"
|
|
5601
|
+
|
|
5602
|
+
with tempfile.TemporaryDirectory(prefix="keel-declared-path-") as raw:
|
|
5603
|
+
repo = Path(raw) / "repo"
|
|
5604
|
+
repo.mkdir()
|
|
5605
|
+
tasks_path = repo / "openspec/changes/demo/tasks.md"
|
|
5606
|
+
write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
|
|
5607
|
+
write_text(repo / "openspec/changes/demo/design.md", "## Context\n\nfixture\n")
|
|
5608
|
+
write_text(
|
|
5609
|
+
repo / "openspec/changes/demo/specs/demo/spec.md",
|
|
5610
|
+
"## ADDED Requirements\n",
|
|
5611
|
+
)
|
|
5612
|
+
for name in (
|
|
5613
|
+
"docs/owner.md",
|
|
5614
|
+
"docs/cited.md",
|
|
5615
|
+
"docs/\u7b2c\u4e00\u7ae0/\u603b\u8bba.md",
|
|
5616
|
+
"AGENTS.md",
|
|
5617
|
+
):
|
|
5618
|
+
write_text(repo / name, "x\n")
|
|
5619
|
+
write_text(repo / "docs/a b.md", "x\n")
|
|
5620
|
+
|
|
5621
|
+
def completion(findings: str) -> dict:
|
|
5622
|
+
write_text(
|
|
5623
|
+
tasks_path,
|
|
5624
|
+
task_contract_fixture(evidence=("M1: check exercised.",))
|
|
5625
|
+
.replace("- [ ] 1.1", "- [x] 1.1")
|
|
5626
|
+
.replace(" - Status: pending\n", " - Status: pass\n")
|
|
5627
|
+
.replace(
|
|
5628
|
+
" - Acceptance check: pending\n",
|
|
5629
|
+
" - Acceptance check: behavior proven through the public CLI.\n",
|
|
5630
|
+
)
|
|
5631
|
+
.replace(
|
|
5632
|
+
" - Scope check: pending\n",
|
|
5633
|
+
" - Scope check: writes stayed inside Touch.\n",
|
|
5634
|
+
)
|
|
5635
|
+
.replace(" - Findings: pending\n", f" - Findings: {findings}\n")
|
|
5636
|
+
)
|
|
5637
|
+
record_contract_anchor(repo, "demo")
|
|
5638
|
+
result = run_keel(
|
|
5639
|
+
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
|
|
5640
|
+
"--json",
|
|
5641
|
+
)
|
|
5642
|
+
return json.loads(result.stdout)
|
|
5643
|
+
|
|
5644
|
+
def messages(payload: dict) -> str:
|
|
5645
|
+
return " ".join(
|
|
5646
|
+
item.get("message", "") for item in payload.get("problems", [])
|
|
5647
|
+
)
|
|
5648
|
+
|
|
5649
|
+
# --- the citation must not outrank the declaration ---------------
|
|
5650
|
+
cited = "\u5224\u636e\u89c1 `docs/cited.md`"
|
|
5651
|
+
payload = completion(f"still open. Durable owner: docs/owner.md\uff0c{cited}")
|
|
5652
|
+
if payload.get("status") != "pass":
|
|
5653
|
+
report(
|
|
5654
|
+
f"{label}: a value naming an existing owner and then citing "
|
|
5655
|
+
"another existing file was refused."
|
|
5656
|
+
)
|
|
5657
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
5658
|
+
return 1
|
|
5659
|
+
|
|
5660
|
+
# Proven by removing the declared owner: the refusal must name it, not
|
|
5661
|
+
# the citation. Without this the assertion above passes either way,
|
|
5662
|
+
# because both files exist.
|
|
5663
|
+
(repo / "docs/owner.md").unlink()
|
|
5664
|
+
payload = completion(f"still open. Durable owner: docs/owner.md\uff0c{cited}")
|
|
5665
|
+
if payload.get("status") == "pass":
|
|
5666
|
+
report(
|
|
5667
|
+
f"{label}: with the declared owner deleted the value still "
|
|
5668
|
+
"passed, so the gate checked the cited file instead. A "
|
|
5669
|
+
"citation that outranks the declaration lets a finding be "
|
|
5670
|
+
"owned by a file the author never named."
|
|
5671
|
+
)
|
|
5672
|
+
return 1
|
|
5673
|
+
|
|
5674
|
+
# Which path the gate looked for, asserted through the reader that
|
|
5675
|
+
# names one. `Findings` reports a single owner refusal for every
|
|
5676
|
+
# unusable owner and never names the path, so the naming half is
|
|
5677
|
+
# asserted where it is actually emitted.
|
|
5678
|
+
def invalidates(closure: str) -> dict:
|
|
5679
|
+
write_text(
|
|
5680
|
+
tasks_path,
|
|
5681
|
+
task_contract_fixture().replace(
|
|
5682
|
+
"## Invalidates\n\n- None.\n\n",
|
|
5683
|
+
'## Invalidates\n\n- I1: "the wording that is now wrong" '
|
|
5684
|
+
f"\u2014 somewhere in the repo. {closure}\n\n",
|
|
5685
|
+
),
|
|
5686
|
+
)
|
|
5687
|
+
result = run_keel(
|
|
5688
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
5689
|
+
"--json",
|
|
5690
|
+
)
|
|
5691
|
+
return json.loads(result.stdout)
|
|
5692
|
+
|
|
5693
|
+
payload = invalidates(f"Durable owner: docs/owner.md\uff0c{cited}")
|
|
5694
|
+
text = messages(payload)
|
|
5695
|
+
if payload.get("status") == "pass":
|
|
5696
|
+
report(
|
|
5697
|
+
f"{label}: an invalidation entry whose declared owner is "
|
|
5698
|
+
"missing passed, so the gate resolved the citation."
|
|
5699
|
+
)
|
|
5700
|
+
return 1
|
|
5701
|
+
if "docs/cited.md" in text or "docs/owner.md" not in text:
|
|
5702
|
+
report(
|
|
5703
|
+
f"{label}: the refusal named the citation rather than the "
|
|
5704
|
+
f"declared path. Got: {text or '(none)'}"
|
|
5705
|
+
)
|
|
5706
|
+
return 1
|
|
5707
|
+
write_text(repo / "docs/owner.md", "x\n")
|
|
5708
|
+
|
|
5709
|
+
payload = invalidates(f"Durable owner: docs/owner.md\uff0c{cited}")
|
|
5710
|
+
if payload.get("status") != "pass":
|
|
5711
|
+
report(
|
|
5712
|
+
f"{label}: with the declared owner restored the invalidation "
|
|
5713
|
+
"entry was still refused."
|
|
5714
|
+
)
|
|
5715
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
5716
|
+
return 1
|
|
5717
|
+
|
|
5718
|
+
# --- a bare run ends where the sentence does ---------------------
|
|
5719
|
+
accepted = (
|
|
5720
|
+
(
|
|
5721
|
+
"a path followed by a CJK terminator and more prose",
|
|
5722
|
+
"still open. Durable owner: docs/owner.md\u3002\u2461\u63a5\u7740\u8bf4\u522b\u7684",
|
|
5723
|
+
),
|
|
5724
|
+
(
|
|
5725
|
+
"a path followed by a CJK comma",
|
|
5726
|
+
"still open. Durable owner: docs/owner.md\uff0c\u968f\u540e\u8bf4\u660e",
|
|
5727
|
+
),
|
|
5728
|
+
(
|
|
5729
|
+
"a path whose own segments are CJK words",
|
|
5730
|
+
"still open. Durable owner: docs/\u7b2c\u4e00\u7ae0/\u603b\u8bba.md\u3002\u540e\u6587",
|
|
5731
|
+
),
|
|
5732
|
+
(
|
|
5733
|
+
"a backticked path containing a space, opening the value",
|
|
5734
|
+
"still open. Durable owner: `docs/a b.md` \u8bf4\u660e",
|
|
5735
|
+
),
|
|
5736
|
+
(
|
|
5737
|
+
"a backticked path with nothing declared before it",
|
|
5738
|
+
"still open. Durable owner: \u89c1 `docs/cited.md`",
|
|
5739
|
+
),
|
|
5740
|
+
("a repository-root file", "still open. Durable owner: AGENTS.md"),
|
|
5741
|
+
("the same file with a leading dot-slash", "still open. Durable owner: ./AGENTS.md"),
|
|
5742
|
+
(
|
|
5743
|
+
"an ASCII path ending a sentence",
|
|
5744
|
+
"still open. Durable owner: docs/owner.md. Then more text",
|
|
5745
|
+
),
|
|
5746
|
+
)
|
|
5747
|
+
for description, findings in accepted:
|
|
5748
|
+
payload = completion(findings)
|
|
5749
|
+
if payload.get("status") != "pass":
|
|
5750
|
+
report(f"{label}: {description} was refused.")
|
|
5751
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
5752
|
+
return 1
|
|
5753
|
+
|
|
5754
|
+
# --- the boundary: a non-path stays unrecognized ------------------
|
|
5755
|
+
for description, findings in (
|
|
5756
|
+
("a bare word", "still open. Durable owner: pending"),
|
|
5757
|
+
("a version string", "still open. Durable owner: 5.44.0"),
|
|
5758
|
+
(
|
|
5759
|
+
"a separator between two adjacent inline code spans",
|
|
5760
|
+
"still open. Durable owner: \u9996\u7248\u53ea\u88c5 `networkx`/`PyYAML`",
|
|
5761
|
+
),
|
|
5762
|
+
):
|
|
5763
|
+
payload = completion(findings)
|
|
5764
|
+
text = messages(payload)
|
|
5765
|
+
if payload.get("status") == "pass":
|
|
5766
|
+
report(f"{label}: {description} was accepted as a durable owner.")
|
|
5767
|
+
return 1
|
|
5768
|
+
if "does not exist" in text:
|
|
5769
|
+
report(
|
|
5770
|
+
f"{label}: {description} was reported as a missing file. "
|
|
5771
|
+
"Got: " + text
|
|
5772
|
+
)
|
|
5773
|
+
return 1
|
|
5774
|
+
|
|
5775
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
5776
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
5777
|
+
return 1
|
|
5778
|
+
report(f"{label} scenario passed.")
|
|
5779
|
+
return 0
|
|
5780
|
+
|
|
5781
|
+
|
|
5583
5782
|
def validate_a_root_file_is_a_path_scenario() -> int:
|
|
5584
5783
|
"""Issue #107: a file at the repository root had no separator to find.
|
|
5585
5784
|
|
|
@@ -24398,6 +24597,129 @@ def validate_doctor_openspec_honesty_scenario() -> int:
|
|
|
24398
24597
|
return 0
|
|
24399
24598
|
|
|
24400
24599
|
|
|
24600
|
+
# The marker's `version=` attribute is written by the installer and, before this
|
|
24601
|
+
# scenario, read back by nothing that runs locally: both marker parsers match
|
|
24602
|
+
# `keel:start(?:\s+[^>]*)?` and discard the attributes. The only reader was the
|
|
24603
|
+
# plugin's SessionStart hook, whose activation doctor itself reports as manual.
|
|
24604
|
+
def validate_marker_version_is_read_scenario() -> int:
|
|
24605
|
+
def doctor_protocol_line(repo: Path) -> tuple[str, int]:
|
|
24606
|
+
result = run_keel(repo, "--doctor")
|
|
24607
|
+
line = next(
|
|
24608
|
+
(
|
|
24609
|
+
entry
|
|
24610
|
+
for entry in result.stdout.splitlines()
|
|
24611
|
+
if entry.startswith("protocol:")
|
|
24612
|
+
),
|
|
24613
|
+
"",
|
|
24614
|
+
)
|
|
24615
|
+
return line, result.returncode
|
|
24616
|
+
|
|
24617
|
+
def write_repo(root: Path, name: str, marker: str | None) -> Path:
|
|
24618
|
+
repo = root / name
|
|
24619
|
+
repo.mkdir()
|
|
24620
|
+
body = "# Agents\n\nSession Start.\n"
|
|
24621
|
+
(repo / "AGENTS.md").write_text(
|
|
24622
|
+
f"{marker}\n{body}" if marker is not None else body,
|
|
24623
|
+
encoding="utf-8",
|
|
24624
|
+
)
|
|
24625
|
+
return repo
|
|
24626
|
+
|
|
24627
|
+
with tempfile.TemporaryDirectory(prefix="keel-marker-version-") as raw:
|
|
24628
|
+
root = Path(raw)
|
|
24629
|
+
|
|
24630
|
+
behind = write_repo(root, "behind", "<!-- keel:start version=5.14.0 -->")
|
|
24631
|
+
line, behind_code = doctor_protocol_line(behind)
|
|
24632
|
+
if not line.startswith("protocol: warning"):
|
|
24633
|
+
report(
|
|
24634
|
+
"the-marker-version-is-read scenario: a repository declaring an "
|
|
24635
|
+
f"older protocol did not warn; got {line!r}."
|
|
24636
|
+
)
|
|
24637
|
+
return 1
|
|
24638
|
+
for needed in ("5.14.0", PACKAGE_VERSION, "keel --init"):
|
|
24639
|
+
if needed not in line:
|
|
24640
|
+
report(
|
|
24641
|
+
"the-marker-version-is-read scenario: the behind-repository "
|
|
24642
|
+
f"warning omits {needed!r}; got {line!r}."
|
|
24643
|
+
)
|
|
24644
|
+
return 1
|
|
24645
|
+
if "repository" not in line:
|
|
24646
|
+
report(
|
|
24647
|
+
"the-marker-version-is-read scenario: the warning does not name "
|
|
24648
|
+
f"the repository as the term that is behind; got {line!r}."
|
|
24649
|
+
)
|
|
24650
|
+
return 1
|
|
24651
|
+
|
|
24652
|
+
ahead = write_repo(root, "ahead", "<!-- keel:start version=9.99.0 -->")
|
|
24653
|
+
line, _ = doctor_protocol_line(ahead)
|
|
24654
|
+
if not line.startswith("protocol: warning"):
|
|
24655
|
+
report(
|
|
24656
|
+
"the-marker-version-is-read scenario: a repository declaring a "
|
|
24657
|
+
f"newer protocol did not warn; got {line!r}."
|
|
24658
|
+
)
|
|
24659
|
+
return 1
|
|
24660
|
+
for needed in ("9.99.0", PACKAGE_VERSION, "install"):
|
|
24661
|
+
if needed not in line:
|
|
24662
|
+
report(
|
|
24663
|
+
"the-marker-version-is-read scenario: the ahead-repository "
|
|
24664
|
+
f"warning omits {needed!r}; got {line!r}."
|
|
24665
|
+
)
|
|
24666
|
+
return 1
|
|
24667
|
+
if "keel --init" in line:
|
|
24668
|
+
report(
|
|
24669
|
+
"the-marker-version-is-read scenario: a repository ahead of its "
|
|
24670
|
+
f"install was told to re-run keel --init; got {line!r}."
|
|
24671
|
+
)
|
|
24672
|
+
return 1
|
|
24673
|
+
|
|
24674
|
+
agreed = write_repo(
|
|
24675
|
+
root, "agreed", f"<!-- keel:start version={PACKAGE_VERSION} -->"
|
|
24676
|
+
)
|
|
24677
|
+
line, agreed_code = doctor_protocol_line(agreed)
|
|
24678
|
+
if not line.startswith("protocol: ok"):
|
|
24679
|
+
report(
|
|
24680
|
+
"the-marker-version-is-read scenario: agreeing versions did not "
|
|
24681
|
+
f"report ok; got {line!r}."
|
|
24682
|
+
)
|
|
24683
|
+
return 1
|
|
24684
|
+
if line.count(PACKAGE_VERSION) < 2:
|
|
24685
|
+
report(
|
|
24686
|
+
"the-marker-version-is-read scenario: the agreeing line does not "
|
|
24687
|
+
f"print both versions; got {line!r}."
|
|
24688
|
+
)
|
|
24689
|
+
return 1
|
|
24690
|
+
|
|
24691
|
+
# D2: drift is a warning and nothing else. A line that could turn a
|
|
24692
|
+
# pipeline red on release day is a line users switch off.
|
|
24693
|
+
if behind_code != agreed_code:
|
|
24694
|
+
report(
|
|
24695
|
+
"the-marker-version-is-read scenario: drift changed doctor's "
|
|
24696
|
+
f"exit code ({behind_code} vs {agreed_code})."
|
|
24697
|
+
)
|
|
24698
|
+
return 1
|
|
24699
|
+
|
|
24700
|
+
for name, marker in (
|
|
24701
|
+
("undeclared", None),
|
|
24702
|
+
("attributeless", "<!-- keel:start -->"),
|
|
24703
|
+
):
|
|
24704
|
+
repo = write_repo(root, name, marker)
|
|
24705
|
+
line, _ = doctor_protocol_line(repo)
|
|
24706
|
+
if not line.startswith("protocol: not comparable"):
|
|
24707
|
+
report(
|
|
24708
|
+
f"the-marker-version-is-read scenario: the {name} repository "
|
|
24709
|
+
f"did not report `not comparable`; got {line!r}."
|
|
24710
|
+
)
|
|
24711
|
+
return 1
|
|
24712
|
+
if "declares" not in line:
|
|
24713
|
+
report(
|
|
24714
|
+
f"the-marker-version-is-read scenario: the {name} repository "
|
|
24715
|
+
f"was not told which term is missing; got {line!r}."
|
|
24716
|
+
)
|
|
24717
|
+
return 1
|
|
24718
|
+
|
|
24719
|
+
report("the-marker-version-is-read scenario passed.")
|
|
24720
|
+
return 0
|
|
24721
|
+
|
|
24722
|
+
|
|
24401
24723
|
# A scenario name, as the registry spells one. Two registered names carry no
|
|
24402
24724
|
# hyphen — `cli` and `uninstall` — so requiring one would leave exactly those
|
|
24403
24725
|
# two unchecked, and allowing single words was measured to add no false
|
|
@@ -24627,6 +24949,7 @@ SCENARIOS: tuple = (
|
|
|
24627
24949
|
),
|
|
24628
24950
|
("cli", validate_cli_scenario),
|
|
24629
24951
|
("doctor-openspec-honesty", validate_doctor_openspec_honesty_scenario),
|
|
24952
|
+
("the-marker-version-is-read", validate_marker_version_is_read_scenario),
|
|
24630
24953
|
(
|
|
24631
24954
|
"authored-scenario-names-are-registered",
|
|
24632
24955
|
validate_authored_scenario_names_scenario,
|
|
@@ -24870,6 +25193,10 @@ SCENARIOS: tuple = (
|
|
|
24870
25193
|
validate_an_owner_outlives_the_change_scenario,
|
|
24871
25194
|
),
|
|
24872
25195
|
("a-root-file-is-a-path", validate_a_root_file_is_a_path_scenario),
|
|
25196
|
+
(
|
|
25197
|
+
"a-path-is-what-the-value-declares",
|
|
25198
|
+
validate_a_path_is_what_the_value_declares_scenario,
|
|
25199
|
+
),
|
|
24873
25200
|
(
|
|
24874
25201
|
"a-declared-dependency-is-resolved",
|
|
24875
25202
|
validate_a_declared_dependency_is_resolved_scenario,
|
package/src/core/gates.js
CHANGED
|
@@ -431,25 +431,58 @@ const DECLARED_PATH_TRAILING = /[.,;:!?)\]}"'\u2019\u201d\u3002\uff0c\u3001\uff1
|
|
|
431
431
|
// and authors write versions in prose beside an owner.
|
|
432
432
|
const ROOT_FILE_NAME = /^[^\s`]+\.[A-Za-z][A-Za-z0-9]{0,7}$/;
|
|
433
433
|
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
// `
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
// extractor every gate reader of a declared path now uses.
|
|
434
|
+
// What ends a path is whitespace — in a script that separates words with it.
|
|
435
|
+
// Chinese prose does not: a path is followed immediately by its terminator, so
|
|
436
|
+
// a run bounded only by whitespace swallowed the rest of the sentence and
|
|
437
|
+
// `openspec/FOLLOWUP.md。②本波两次重录重验` came back as one path (issue #113,
|
|
438
|
+
// 14 instances across five consuming repositories). These characters are
|
|
439
|
+
// already in `DECLARED_PATH_TRAILING`, which strips what *trails* the run and
|
|
440
|
+
// therefore never reached a terminator sitting inside it.
|
|
442
441
|
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
442
|
+
// ASCII punctuation keeps the other treatment — permitted inside the run and
|
|
443
|
+
// trimmed from its end — because `a.b/c-d.e` and `f(1)/g` are paths and ASCII
|
|
444
|
+
// prose supplies the whitespace that ends them. The asymmetry follows from the
|
|
445
|
+
// writing system rather than from a preference.
|
|
446
|
+
const PATH_TERMINATORS = "。,、;:!?()【】《》「」〈〉“”‘’";
|
|
447
|
+
const BARE_DECLARED_PATH = new RegExp(
|
|
448
|
+
`[^\\s\`${PATH_TERMINATORS}]+/[^\\s\`${PATH_TERMINATORS}]+`
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
// A declared path is a run of non-whitespace. What a path is *made of* is the
|
|
452
|
+
// filesystem's business, and answering that question with the boundary is what
|
|
453
|
+
// refused `notes/note-006-转岗最难的不是流程/note.md` by reporting that
|
|
454
|
+
// `notes/note-006-` does not exist — a path nobody wrote (issue #60). Adding
|
|
455
|
+
// terminators does not narrow the alphabet: a path whose segments are CJK
|
|
456
|
+
// words still extracts in full, and only its punctuation ends it.
|
|
457
|
+
//
|
|
458
|
+
// The declaration is what the value opens with. A leading backticked path wins
|
|
459
|
+
// because that is the only way to write a path containing whitespace, and
|
|
460
|
+
// `touchEntries` already accepts that form. A backticked span *elsewhere* is a
|
|
461
|
+
// citation: `Findings` is free prose where naming the owner and then quoting a
|
|
462
|
+
// file is ordinary, and taking the quotation first answered with a file the
|
|
463
|
+
// author never declared — one that usually exists, so the gate accepted rather
|
|
464
|
+
// than refused (issue #113, 20 instances). It is still read when the value
|
|
465
|
+
// declares nothing else, which keeps `Durable owner: 见 \`docs/a b.md\`` working.
|
|
466
|
+
// A separator with nothing on either side of it is not a path. Two adjacent
|
|
467
|
+
// inline code spans put one there — `networkx`/`PyYAML` closes one span and
|
|
468
|
+
// opens the next, so a backtick-delimited capture spanning the gap is the
|
|
469
|
+
// single character `/`. Measured twice in the consumer corpus, each time
|
|
470
|
+
// reported as a file that does not exist.
|
|
471
|
+
function backtickedPath(match) {
|
|
472
|
+
if (!match) return null;
|
|
473
|
+
const candidate = match[1].trim();
|
|
474
|
+
if (!candidate.split("/").some(Boolean)) return null;
|
|
475
|
+
return candidate || null;
|
|
476
|
+
}
|
|
477
|
+
|
|
447
478
|
function declaredPath(value) {
|
|
448
479
|
const text = String(value || "");
|
|
449
|
-
const
|
|
450
|
-
if (
|
|
451
|
-
const bare = text.match(
|
|
480
|
+
const leading = backtickedPath(text.match(/^\s*`([^`\n]*\/[^`\n]*)`/));
|
|
481
|
+
if (leading) return leading;
|
|
482
|
+
const bare = text.match(BARE_DECLARED_PATH);
|
|
452
483
|
if (bare) return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
|
|
484
|
+
const quoted = backtickedPath(text.match(/`([^`\n]*\/[^`\n]*)`/));
|
|
485
|
+
if (quoted) return quoted;
|
|
453
486
|
// The separator form is tried first and is unchanged, so nothing that
|
|
454
487
|
// resolves today resolves differently. The trim runs before the shape is
|
|
455
488
|
// judged, so a root file ending a sentence is still a root file.
|