@christang/keel 5.39.0 → 5.44.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 +12 -2
- 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/plugins/keel/skills/keel-run-single-task-goal/SKILL.md +1 -1
- package/scripts/install_to_repo.py +74 -4
- package/scripts/validate_plugin.py +1225 -87
- package/src/core/config.js +7 -1
- package/src/core/gates.js +103 -20
- package/src/core/task-contract.js +24 -5
|
@@ -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.44.0"
|
|
41
|
+
PROTOCOL_VERSION = "5.44.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
|
|
@@ -1656,10 +1656,12 @@ def validate_unparsed_covers_critical_statement_scenario() -> int:
|
|
|
1656
1656
|
|
|
1657
1657
|
with tempfile.TemporaryDirectory(prefix="keel-unparsed-covers-unparsed-") as raw:
|
|
1658
1658
|
repo = Path(raw)
|
|
1659
|
+
# A colon after the identifier stays outside the accepted shapes now
|
|
1660
|
+
# that bullet and bold wrapping resolve (issue #49, owner-decided).
|
|
1659
1661
|
write_text(
|
|
1660
1662
|
repo / "openspec/changes/demo/design.md",
|
|
1661
1663
|
"## Decisions\n\n"
|
|
1662
|
-
"
|
|
1664
|
+
"D2: Keep one shared parser. Basis: fixture authority.\n",
|
|
1663
1665
|
)
|
|
1664
1666
|
returncode, message = covers_message(repo)
|
|
1665
1667
|
if returncode == 0:
|
|
@@ -1723,6 +1725,250 @@ def validate_unparsed_covers_critical_statement_scenario() -> int:
|
|
|
1723
1725
|
return 0
|
|
1724
1726
|
|
|
1725
1727
|
|
|
1728
|
+
def validate_widened_critical_statement_shapes_scenario() -> int:
|
|
1729
|
+
"""Issue #49 Section 1, owner-decided 2026-08-17: criticalAuthority() must
|
|
1730
|
+
resolve a design.md critical-statement line in the shapes authors actually
|
|
1731
|
+
write — bulleted, bold-wrapped, or both — not only the bare line shape,
|
|
1732
|
+
while a reference matching more than one line still fails as duplicated and
|
|
1733
|
+
a still-unaccepted shape reports Unparsed naming the accepted shapes.
|
|
1734
|
+
"""
|
|
1735
|
+
task = task_capsule_compact_fixture().replace(
|
|
1736
|
+
" - E1: Public behavior passes.\n",
|
|
1737
|
+
" - D2\n",
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
def start(repo: Path) -> subprocess.CompletedProcess[str]:
|
|
1741
|
+
write_text(repo / "openspec/changes/demo/tasks.md", task)
|
|
1742
|
+
return run_keel(
|
|
1743
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
1744
|
+
)
|
|
1745
|
+
|
|
1746
|
+
accepted_shapes = [
|
|
1747
|
+
"- D2 — Keep one shared parser.",
|
|
1748
|
+
"**D2** — Keep one shared parser.",
|
|
1749
|
+
"- **D2** — Keep one shared parser.",
|
|
1750
|
+
"* D2 — Keep one shared parser.",
|
|
1751
|
+
"+ D2 — Keep one shared parser.",
|
|
1752
|
+
]
|
|
1753
|
+
for shape in accepted_shapes:
|
|
1754
|
+
with tempfile.TemporaryDirectory(prefix="keel-widened-shape-") as raw:
|
|
1755
|
+
repo = Path(raw)
|
|
1756
|
+
write_text(
|
|
1757
|
+
repo / "openspec/changes/demo/design.md",
|
|
1758
|
+
f"## Decisions\n\n{shape}\n",
|
|
1759
|
+
)
|
|
1760
|
+
result = start(repo)
|
|
1761
|
+
if result.returncode != 0:
|
|
1762
|
+
report(
|
|
1763
|
+
f"widened-critical-statement-shapes: the shape {shape!r} "
|
|
1764
|
+
f"must resolve, got exit {result.returncode}: "
|
|
1765
|
+
f"{(result.stderr or result.stdout).strip()}"
|
|
1766
|
+
)
|
|
1767
|
+
return 1
|
|
1768
|
+
authority = (
|
|
1769
|
+
json.loads(result.stdout)
|
|
1770
|
+
.get("contract", {})
|
|
1771
|
+
.get("capsule", {})
|
|
1772
|
+
.get("authority", [])
|
|
1773
|
+
)
|
|
1774
|
+
if not any(
|
|
1775
|
+
item.get("reference") == "D2"
|
|
1776
|
+
and item.get("kind") == "critical-statement"
|
|
1777
|
+
and item.get("text") == "Keep one shared parser."
|
|
1778
|
+
for item in authority
|
|
1779
|
+
):
|
|
1780
|
+
report(
|
|
1781
|
+
f"widened-critical-statement-shapes: the shape {shape!r} "
|
|
1782
|
+
"must resolve as critical-statement authority carrying the "
|
|
1783
|
+
"statement text."
|
|
1784
|
+
)
|
|
1785
|
+
return 1
|
|
1786
|
+
|
|
1787
|
+
with tempfile.TemporaryDirectory(prefix="keel-widened-duplicate-") as raw:
|
|
1788
|
+
repo = Path(raw)
|
|
1789
|
+
write_text(
|
|
1790
|
+
repo / "openspec/changes/demo/design.md",
|
|
1791
|
+
"## Decisions\n\nD2 — Keep one shared parser.\n\n"
|
|
1792
|
+
"- D2 — Keep one shared parser.\n",
|
|
1793
|
+
)
|
|
1794
|
+
result = start(repo)
|
|
1795
|
+
problems = json.loads(result.stdout).get("problems", [])
|
|
1796
|
+
if result.returncode == 0 or not any(
|
|
1797
|
+
item.get("code") == "ambiguous-covers"
|
|
1798
|
+
and item.get("message", "").startswith(
|
|
1799
|
+
"Duplicated Covers critical statement: D2."
|
|
1800
|
+
)
|
|
1801
|
+
for item in problems
|
|
1802
|
+
):
|
|
1803
|
+
report(
|
|
1804
|
+
"widened-critical-statement-shapes: the same identifier in "
|
|
1805
|
+
"two accepted shapes must fail as duplicated."
|
|
1806
|
+
)
|
|
1807
|
+
return 1
|
|
1808
|
+
|
|
1809
|
+
with tempfile.TemporaryDirectory(prefix="keel-widened-unparsed-") as raw:
|
|
1810
|
+
repo = Path(raw)
|
|
1811
|
+
write_text(
|
|
1812
|
+
repo / "openspec/changes/demo/design.md",
|
|
1813
|
+
"## Decisions\n\nD2: Keep one shared parser.\n",
|
|
1814
|
+
)
|
|
1815
|
+
result = start(repo)
|
|
1816
|
+
problems = json.loads(result.stdout).get("problems", [])
|
|
1817
|
+
message = next(
|
|
1818
|
+
(
|
|
1819
|
+
item.get("message", "")
|
|
1820
|
+
for item in problems
|
|
1821
|
+
if item.get("code") == "unresolved-covers"
|
|
1822
|
+
),
|
|
1823
|
+
"",
|
|
1824
|
+
)
|
|
1825
|
+
if result.returncode == 0 or not message.startswith(
|
|
1826
|
+
"Unparsed Covers critical statement: D2."
|
|
1827
|
+
):
|
|
1828
|
+
report(
|
|
1829
|
+
"widened-critical-statement-shapes: a colon line must still "
|
|
1830
|
+
f"report Unparsed, got: {message!r}"
|
|
1831
|
+
)
|
|
1832
|
+
return 1
|
|
1833
|
+
if "- D2 — " not in message or "**D2** — " not in message:
|
|
1834
|
+
report(
|
|
1835
|
+
"widened-critical-statement-shapes: the Unparsed message must "
|
|
1836
|
+
f"name the bulleted and bold shapes as accepted, got: {message!r}"
|
|
1837
|
+
)
|
|
1838
|
+
return 1
|
|
1839
|
+
|
|
1840
|
+
if "widened-critical-statement-shapes" not in {name for name, _ in SCENARIOS}:
|
|
1841
|
+
report(
|
|
1842
|
+
"widened-critical-statement-shapes: the scenario registry does not "
|
|
1843
|
+
"include it."
|
|
1844
|
+
)
|
|
1845
|
+
return 1
|
|
1846
|
+
report("widened-critical-statement-shapes scenario passed.")
|
|
1847
|
+
return 0
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
def validate_covers_annotation_entry_scenario() -> int:
|
|
1851
|
+
"""Issue #49 Section 1's fail-open half, owner-decided 2026-08-17: a Covers
|
|
1852
|
+
entry that opens with a critical-statement identifier and a dash
|
|
1853
|
+
(`D2 — note`) must resolve as that critical statement — failing loudly when
|
|
1854
|
+
the identifier is missing from design.md — instead of silently degrading to
|
|
1855
|
+
an unlinked legacy-task-reference, while colon-form entries and hyphenated
|
|
1856
|
+
free text stay free-text references.
|
|
1857
|
+
"""
|
|
1858
|
+
design_with_d2 = "## Decisions\n\nD2 — Keep one shared parser.\n"
|
|
1859
|
+
|
|
1860
|
+
def start_with_covers(
|
|
1861
|
+
repo: Path, covers_entry: str, design: str
|
|
1862
|
+
) -> subprocess.CompletedProcess[str]:
|
|
1863
|
+
write_text(repo / "openspec/changes/demo/design.md", design)
|
|
1864
|
+
write_text(
|
|
1865
|
+
repo / "openspec/changes/demo/tasks.md",
|
|
1866
|
+
task_capsule_compact_fixture().replace(
|
|
1867
|
+
" - E1: Public behavior passes.\n",
|
|
1868
|
+
f" - {covers_entry}\n",
|
|
1869
|
+
),
|
|
1870
|
+
)
|
|
1871
|
+
return run_keel(
|
|
1872
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
1873
|
+
)
|
|
1874
|
+
|
|
1875
|
+
def authority_of(result: subprocess.CompletedProcess[str]) -> list:
|
|
1876
|
+
return (
|
|
1877
|
+
json.loads(result.stdout)
|
|
1878
|
+
.get("contract", {})
|
|
1879
|
+
.get("capsule", {})
|
|
1880
|
+
.get("authority", [])
|
|
1881
|
+
)
|
|
1882
|
+
|
|
1883
|
+
with tempfile.TemporaryDirectory(prefix="keel-covers-annotation-") as raw:
|
|
1884
|
+
repo = Path(raw)
|
|
1885
|
+
result = start_with_covers(repo, "D2 — an annotation", design_with_d2)
|
|
1886
|
+
if result.returncode != 0:
|
|
1887
|
+
report(
|
|
1888
|
+
"covers-annotation-entry: `D2 — an annotation` with a resolvable "
|
|
1889
|
+
f"D2 must pass, got exit {result.returncode}: "
|
|
1890
|
+
f"{(result.stderr or result.stdout).strip()}"
|
|
1891
|
+
)
|
|
1892
|
+
return 1
|
|
1893
|
+
authority = authority_of(result)
|
|
1894
|
+
if not any(
|
|
1895
|
+
item.get("reference") == "D2"
|
|
1896
|
+
and item.get("kind") == "critical-statement"
|
|
1897
|
+
and item.get("text") == "Keep one shared parser."
|
|
1898
|
+
for item in authority
|
|
1899
|
+
):
|
|
1900
|
+
report(
|
|
1901
|
+
"covers-annotation-entry: `D2 — an annotation` must resolve as "
|
|
1902
|
+
"critical-statement authority carrying the design.md statement "
|
|
1903
|
+
f"text, got: {authority!r}"
|
|
1904
|
+
)
|
|
1905
|
+
return 1
|
|
1906
|
+
if any("an annotation" in str(item.get("text", "")) for item in authority):
|
|
1907
|
+
report(
|
|
1908
|
+
"covers-annotation-entry: the annotation must not become "
|
|
1909
|
+
"authority text; design.md owns the statement."
|
|
1910
|
+
)
|
|
1911
|
+
return 1
|
|
1912
|
+
|
|
1913
|
+
with tempfile.TemporaryDirectory(prefix="keel-covers-annotation-missing-") as raw:
|
|
1914
|
+
repo = Path(raw)
|
|
1915
|
+
result = start_with_covers(
|
|
1916
|
+
repo,
|
|
1917
|
+
"D2 — an annotation",
|
|
1918
|
+
"## Decisions\n\nD1 — Unrelated decision. Basis: fixture authority.\n",
|
|
1919
|
+
)
|
|
1920
|
+
problems = json.loads(result.stdout).get("problems", [])
|
|
1921
|
+
message = next(
|
|
1922
|
+
(
|
|
1923
|
+
item.get("message", "")
|
|
1924
|
+
for item in problems
|
|
1925
|
+
if item.get("code") == "unresolved-covers"
|
|
1926
|
+
),
|
|
1927
|
+
"",
|
|
1928
|
+
)
|
|
1929
|
+
if result.returncode == 0 or not message.startswith(
|
|
1930
|
+
"Missing Covers critical statement: D2."
|
|
1931
|
+
):
|
|
1932
|
+
report(
|
|
1933
|
+
"covers-annotation-entry: `D2 — an annotation` with no D2 in "
|
|
1934
|
+
"design.md must fail as Missing instead of passing as an "
|
|
1935
|
+
f"unlinked reference, got exit {result.returncode}: {message!r}"
|
|
1936
|
+
)
|
|
1937
|
+
return 1
|
|
1938
|
+
|
|
1939
|
+
for covers_entry in ("E1: Public behavior passes.", "D2-compatible fixture text"):
|
|
1940
|
+
with tempfile.TemporaryDirectory(prefix="keel-covers-annotation-free-") as raw:
|
|
1941
|
+
repo = Path(raw)
|
|
1942
|
+
result = start_with_covers(repo, covers_entry, design_with_d2)
|
|
1943
|
+
if result.returncode != 0:
|
|
1944
|
+
report(
|
|
1945
|
+
f"covers-annotation-entry: {covers_entry!r} must stay a "
|
|
1946
|
+
"passing free-text reference, got exit "
|
|
1947
|
+
f"{result.returncode}: "
|
|
1948
|
+
f"{(result.stderr or result.stdout).strip()}"
|
|
1949
|
+
)
|
|
1950
|
+
return 1
|
|
1951
|
+
authority = authority_of(result)
|
|
1952
|
+
if not any(
|
|
1953
|
+
item.get("kind") == "legacy-task-reference" for item in authority
|
|
1954
|
+
) or any(
|
|
1955
|
+
item.get("kind") == "critical-statement" for item in authority
|
|
1956
|
+
):
|
|
1957
|
+
report(
|
|
1958
|
+
f"covers-annotation-entry: {covers_entry!r} must remain a "
|
|
1959
|
+
f"legacy-task-reference, got: {authority!r}"
|
|
1960
|
+
)
|
|
1961
|
+
return 1
|
|
1962
|
+
|
|
1963
|
+
if "covers-annotation-entry" not in {name for name, _ in SCENARIOS}:
|
|
1964
|
+
report(
|
|
1965
|
+
"covers-annotation-entry: the scenario registry does not include it."
|
|
1966
|
+
)
|
|
1967
|
+
return 1
|
|
1968
|
+
report("covers-annotation-entry scenario passed.")
|
|
1969
|
+
return 0
|
|
1970
|
+
|
|
1971
|
+
|
|
1726
1972
|
def validate_expectation_completion_gates_scenario() -> int:
|
|
1727
1973
|
protocol_snippets = [
|
|
1728
1974
|
"Completion Gate",
|
|
@@ -4866,6 +5112,359 @@ def validate_authoring_surface_owner_and_tags_scenario() -> int:
|
|
|
4866
5112
|
return 0
|
|
4867
5113
|
|
|
4868
5114
|
|
|
5115
|
+
def validate_the_spec_names_the_managed_set_scenario() -> int:
|
|
5116
|
+
"""Issue #86: the spec's summary named two of the four actions it covers.
|
|
5117
|
+
|
|
5118
|
+
`propose` and `sync` joined the managed set as new requirements appended to
|
|
5119
|
+
the overlay spec, and the `## Purpose` and the requirement that says which
|
|
5120
|
+
actions carry an overlay were never rewritten — so the top of the file
|
|
5121
|
+
disagreed with the requirements below it.
|
|
5122
|
+
|
|
5123
|
+
The Purpose line survived issue #79's sweep for a structural reason:
|
|
5124
|
+
OpenSpec's delta operations are Requirement-scoped, so no change can carry
|
|
5125
|
+
a Purpose edit, and the line is written once when the capability is
|
|
5126
|
+
created. It is therefore the location most likely to drift and the one
|
|
5127
|
+
least likely to be noticed, which is why it is checked by name here.
|
|
5128
|
+
|
|
5129
|
+
Two named locations, not a scan. Three statements in the published specs
|
|
5130
|
+
name a proper subset of the managed set and are correct — the doctor's
|
|
5131
|
+
command-surface label, which excludes the authoring action on purpose;
|
|
5132
|
+
`sync/archive decisions`, which names what the current agent owns; and
|
|
5133
|
+
`authoring/apply/archive/sync`, which spells `propose` as authoring. A
|
|
5134
|
+
check that refused those would cost more than the drift it catches.
|
|
5135
|
+
"""
|
|
5136
|
+
label = "the-spec-names-the-managed-set"
|
|
5137
|
+
|
|
5138
|
+
source = (ROOT / "bin" / "keel.js").read_text(encoding="utf-8")
|
|
5139
|
+
declaration = re.search(
|
|
5140
|
+
r"const OPENSPEC_OVERLAY_ACTIONS = \[([^\]]*)\];", source
|
|
5141
|
+
)
|
|
5142
|
+
if declaration is None:
|
|
5143
|
+
report(
|
|
5144
|
+
f"{label}: bin/keel.js declares no OPENSPEC_OVERLAY_ACTIONS, so the "
|
|
5145
|
+
"check has no managed set to compare against. Restating the set "
|
|
5146
|
+
"here would be the same defect one layer out."
|
|
5147
|
+
)
|
|
5148
|
+
return 1
|
|
5149
|
+
managed = re.findall(r'"([a-z]+)"', declaration.group(1))
|
|
5150
|
+
if not managed:
|
|
5151
|
+
report(f"{label}: OPENSPEC_OVERLAY_ACTIONS parsed to an empty set.")
|
|
5152
|
+
return 1
|
|
5153
|
+
|
|
5154
|
+
spec_path = ROOT / "openspec/specs/keel-openspec-surface-overlay/spec.md"
|
|
5155
|
+
REQUIREMENT = "### Requirement: Keel overlays every action in the managed set"
|
|
5156
|
+
|
|
5157
|
+
def locations(text: str) -> tuple[str, str] | None:
|
|
5158
|
+
purpose = re.search(r"^## Purpose\s*\n+(.+?)\n", text, re.M)
|
|
5159
|
+
if purpose is None:
|
|
5160
|
+
return None
|
|
5161
|
+
start = text.find(REQUIREMENT)
|
|
5162
|
+
if start < 0:
|
|
5163
|
+
return None
|
|
5164
|
+
end = text.find("\n### Requirement:", start + len(REQUIREMENT))
|
|
5165
|
+
body = text[start : end if end > 0 else len(text)]
|
|
5166
|
+
return purpose.group(1), body
|
|
5167
|
+
|
|
5168
|
+
# Two distinct failures, kept apart on purpose: a location the check
|
|
5169
|
+
# cannot find is not a location that names the wrong thing, and reporting
|
|
5170
|
+
# the second when the first happened sends the reader to a paragraph with
|
|
5171
|
+
# nothing wrong in it.
|
|
5172
|
+
NOT_FOUND = "__not-found__"
|
|
5173
|
+
|
|
5174
|
+
def missing(text: str) -> list[tuple[str, list[str]]]:
|
|
5175
|
+
found = locations(text)
|
|
5176
|
+
if found is None:
|
|
5177
|
+
return [(NOT_FOUND, list(managed))]
|
|
5178
|
+
purpose, body = found
|
|
5179
|
+
gaps = []
|
|
5180
|
+
for name, region in (("## Purpose", purpose), (REQUIREMENT, body)):
|
|
5181
|
+
absent = [
|
|
5182
|
+
action for action in managed
|
|
5183
|
+
if not re.search(rf"\b{action}\b", region)
|
|
5184
|
+
]
|
|
5185
|
+
if absent:
|
|
5186
|
+
gaps.append((name, absent))
|
|
5187
|
+
return gaps
|
|
5188
|
+
|
|
5189
|
+
published = spec_path.read_text(encoding="utf-8")
|
|
5190
|
+
gaps = missing(published)
|
|
5191
|
+
if gaps:
|
|
5192
|
+
for name, absent in gaps:
|
|
5193
|
+
if name == NOT_FOUND:
|
|
5194
|
+
report(
|
|
5195
|
+
f"{label}: keel-openspec-surface-overlay has no `## Purpose` "
|
|
5196
|
+
f"line or no `{REQUIREMENT}` heading, so this check found "
|
|
5197
|
+
"nothing to compare against the managed set. Nothing about "
|
|
5198
|
+
"the action names is being reported here — the location is "
|
|
5199
|
+
"what is missing."
|
|
5200
|
+
)
|
|
5201
|
+
continue
|
|
5202
|
+
report(
|
|
5203
|
+
f"{label}: the `{name}` of keel-openspec-surface-overlay does "
|
|
5204
|
+
f"not name {', '.join(absent)}, which bin/keel.js manages an "
|
|
5205
|
+
"overlay for. The file's summary and the requirements under it "
|
|
5206
|
+
"disagree, and a reader takes the summary."
|
|
5207
|
+
)
|
|
5208
|
+
return 1
|
|
5209
|
+
|
|
5210
|
+
# The check fails on a drifted copy, and names the location that drifted.
|
|
5211
|
+
drifted = published.replace(
|
|
5212
|
+
REQUIREMENT, "### Requirement: Keel overlays apply and archive surfaces", 1
|
|
5213
|
+
)
|
|
5214
|
+
if missing(drifted) == []:
|
|
5215
|
+
report(
|
|
5216
|
+
f"{label}: a copy whose requirement heading is gone was reported "
|
|
5217
|
+
"clean. A check that cannot find what it looks for must fail, not "
|
|
5218
|
+
"pass — a vacuous pass is how the drift got here."
|
|
5219
|
+
)
|
|
5220
|
+
return 1
|
|
5221
|
+
|
|
5222
|
+
for action in managed:
|
|
5223
|
+
reduced = published
|
|
5224
|
+
found = locations(published)
|
|
5225
|
+
assert found is not None
|
|
5226
|
+
purpose_line = found[0]
|
|
5227
|
+
reduced = reduced.replace(
|
|
5228
|
+
purpose_line, re.sub(rf"\b{action}\b[,/ ]*", "", purpose_line), 1
|
|
5229
|
+
)
|
|
5230
|
+
gaps = missing(reduced)
|
|
5231
|
+
if not any(name == "## Purpose" and action in absent for name, absent in gaps):
|
|
5232
|
+
report(
|
|
5233
|
+
f"{label}: the Purpose line with `{action}` removed was not "
|
|
5234
|
+
"reported. Each managed action is checked on its own, so an "
|
|
5235
|
+
"action that joins the set later is not covered by the others."
|
|
5236
|
+
)
|
|
5237
|
+
return 1
|
|
5238
|
+
|
|
5239
|
+
# The three subset spellings that are correct stay correct: the check is
|
|
5240
|
+
# two named locations in one file, not a scan for action words.
|
|
5241
|
+
diagnostics = (
|
|
5242
|
+
ROOT / "openspec/specs/keel-target-surface-diagnostics/spec.md"
|
|
5243
|
+
).read_text(encoding="utf-8")
|
|
5244
|
+
for correct in (
|
|
5245
|
+
"apply/archive/sync overlay markers",
|
|
5246
|
+
"it names sync alongside apply and archive",
|
|
5247
|
+
):
|
|
5248
|
+
if correct not in diagnostics:
|
|
5249
|
+
report(
|
|
5250
|
+
f"{label}: the diagnostics spec no longer carries the correct "
|
|
5251
|
+
f"subset spelling `{correct}`. This scenario asserts the check "
|
|
5252
|
+
"leaves it alone; if the wording moved, the assertion has to "
|
|
5253
|
+
"move with it rather than be dropped."
|
|
5254
|
+
)
|
|
5255
|
+
return 1
|
|
5256
|
+
if "sync/archive decisions" not in published:
|
|
5257
|
+
report(
|
|
5258
|
+
f"{label}: the overlay spec no longer carries `sync/archive "
|
|
5259
|
+
"decisions`, the third correct subset spelling this check must "
|
|
5260
|
+
"not refuse."
|
|
5261
|
+
)
|
|
5262
|
+
return 1
|
|
5263
|
+
|
|
5264
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
5265
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
5266
|
+
return 1
|
|
5267
|
+
report(f"{label} scenario passed.")
|
|
5268
|
+
return 0
|
|
5269
|
+
|
|
5270
|
+
|
|
5271
|
+
def validate_an_owner_outlives_the_change_scenario() -> int:
|
|
5272
|
+
"""Issue #100: the existence check expires at archive.
|
|
5273
|
+
|
|
5274
|
+
`openspec archive` moves `openspec/changes/<name>/` under
|
|
5275
|
+
`openspec/changes/archive/`, so a `Durable owner:` naming the change's own
|
|
5276
|
+
`design.md` is true when the gate reads it and false one workflow step
|
|
5277
|
+
later. Measured in this repository: 10 declarations name a path inside a
|
|
5278
|
+
live change directory, all 10 are dead, and all 10 point at the change that
|
|
5279
|
+
wrote them. A pointer that must break is worse than none — the Review reads
|
|
5280
|
+
as closed while the trail ends halfway.
|
|
5281
|
+
|
|
5282
|
+
A path into a *different* live change stays accepted: the protocol names a
|
|
5283
|
+
new OpenSpec change as a legitimate owner of deferred work, and no measured
|
|
5284
|
+
pointer has that shape.
|
|
5285
|
+
"""
|
|
5286
|
+
label = "an-owner-outlives-the-change"
|
|
5287
|
+
|
|
5288
|
+
with tempfile.TemporaryDirectory(prefix="keel-owner-outlives-") as raw_tmp:
|
|
5289
|
+
repo = Path(raw_tmp) / "repo"
|
|
5290
|
+
repo.mkdir()
|
|
5291
|
+
tasks_path = repo / "openspec/changes/demo/tasks.md"
|
|
5292
|
+
write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
|
|
5293
|
+
write_text(repo / "openspec/changes/demo/design.md", "## Context\n\nfixture\n")
|
|
5294
|
+
write_text(
|
|
5295
|
+
repo / "openspec/changes/demo/specs/demo/spec.md",
|
|
5296
|
+
"## ADDED Requirements\n",
|
|
5297
|
+
)
|
|
5298
|
+
# The other live change, and an archived path. Both exist; only one of
|
|
5299
|
+
# them is the directory the selected change is about to move.
|
|
5300
|
+
write_text(repo / "openspec/changes/other/design.md", "## Context\n\nother\n")
|
|
5301
|
+
write_text(repo / "keel/archive/notes/2026-09-05-example.md", "note\n")
|
|
5302
|
+
|
|
5303
|
+
SELF = "openspec/changes/demo/design.md"
|
|
5304
|
+
OTHER = "openspec/changes/other/design.md"
|
|
5305
|
+
ARCHIVED = "keel/archive/notes/2026-09-05-example.md"
|
|
5306
|
+
TRACKER = "https://github.com/TanglmChris/keel/issues/100"
|
|
5307
|
+
|
|
5308
|
+
def start(section: str) -> dict:
|
|
5309
|
+
fixture = task_contract_fixture()
|
|
5310
|
+
if section.startswith("## Invalidates"):
|
|
5311
|
+
fixture = fixture.replace("## Invalidates\n\n- None.\n\n", section)
|
|
5312
|
+
else:
|
|
5313
|
+
fixture = fixture + "\n" + section
|
|
5314
|
+
write_text(tasks_path, fixture)
|
|
5315
|
+
result = run_keel(
|
|
5316
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
5317
|
+
"--json",
|
|
5318
|
+
)
|
|
5319
|
+
return json.loads(result.stdout)
|
|
5320
|
+
|
|
5321
|
+
def invalidates(closure: str) -> dict:
|
|
5322
|
+
return start(
|
|
5323
|
+
'## Invalidates\n\n- I1: "the wording that is now wrong" '
|
|
5324
|
+
f"— somewhere in the repo. {closure}\n\n"
|
|
5325
|
+
)
|
|
5326
|
+
|
|
5327
|
+
# `## Expectation Coverage` is read by change-close, not task-start, so
|
|
5328
|
+
# this fixture closes the change rather than starting a task. Mirrors
|
|
5329
|
+
# the `durable-owner-vocabulary` scenario's own closing fixture.
|
|
5330
|
+
def expectation(closure: str) -> dict:
|
|
5331
|
+
write_text(
|
|
5332
|
+
tasks_path,
|
|
5333
|
+
task_contract_fixture(evidence=("M1: check exercised.",))
|
|
5334
|
+
.replace("- [ ] 1.1", "- [x] 1.1")
|
|
5335
|
+
.replace(" - Status: pending\n", " - Status: pass\n")
|
|
5336
|
+
.replace(
|
|
5337
|
+
" - Acceptance check: pending\n",
|
|
5338
|
+
" - Acceptance check: proven.\n",
|
|
5339
|
+
)
|
|
5340
|
+
.replace(
|
|
5341
|
+
" - Scope check: pending\n",
|
|
5342
|
+
" - Scope check: inside Touch.\n",
|
|
5343
|
+
)
|
|
5344
|
+
.replace(" - Findings: pending\n", " - Findings: none\n")
|
|
5345
|
+
+ f"\n## Expectation Coverage\n\n- E1: the expectation. {closure}\n",
|
|
5346
|
+
)
|
|
5347
|
+
record_contract_anchor(repo, "demo")
|
|
5348
|
+
result = run_keel(
|
|
5349
|
+
repo, "gate", "change-close", "--change", "demo", "--action",
|
|
5350
|
+
"sync", "--json",
|
|
5351
|
+
)
|
|
5352
|
+
return json.loads(result.stdout)
|
|
5353
|
+
|
|
5354
|
+
def completion(findings: str) -> dict:
|
|
5355
|
+
fixture = (
|
|
5356
|
+
task_contract_fixture(evidence=("M1: check exercised.",))
|
|
5357
|
+
.replace("- [ ] 1.1", "- [x] 1.1")
|
|
5358
|
+
.replace(" - Status: pending\n", " - Status: pass\n")
|
|
5359
|
+
.replace(
|
|
5360
|
+
" - Acceptance check: pending\n",
|
|
5361
|
+
" - Acceptance check: behavior proven through the public CLI.\n",
|
|
5362
|
+
)
|
|
5363
|
+
.replace(
|
|
5364
|
+
" - Scope check: pending\n",
|
|
5365
|
+
" - Scope check: writes stayed inside Touch.\n",
|
|
5366
|
+
)
|
|
5367
|
+
.replace(" - Findings: pending\n", f" - Findings: {findings}\n")
|
|
5368
|
+
)
|
|
5369
|
+
write_text(tasks_path, fixture)
|
|
5370
|
+
record_contract_anchor(repo, "demo")
|
|
5371
|
+
result = run_keel(
|
|
5372
|
+
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
|
|
5373
|
+
"--json",
|
|
5374
|
+
)
|
|
5375
|
+
return json.loads(result.stdout)
|
|
5376
|
+
|
|
5377
|
+
def messages(payload: dict) -> str:
|
|
5378
|
+
return " ".join(
|
|
5379
|
+
item.get("message", "") for item in payload.get("problems", [])
|
|
5380
|
+
)
|
|
5381
|
+
|
|
5382
|
+
# The self-pointer is refused wherever an owner closes an entry, and
|
|
5383
|
+
# the refusal says why rather than reporting a spelling problem.
|
|
5384
|
+
refusals = (
|
|
5385
|
+
("an Invalidates entry", invalidates(f"Durable owner: {SELF}")),
|
|
5386
|
+
("an Expectation Coverage entry", expectation(f"Durable owner: {SELF}")),
|
|
5387
|
+
(
|
|
5388
|
+
"a Review finding",
|
|
5389
|
+
completion(f"the rule needs a second look. Durable owner: {SELF}"),
|
|
5390
|
+
),
|
|
5391
|
+
(
|
|
5392
|
+
"a Resolved here claim",
|
|
5393
|
+
completion(f"the rule was repaired. Resolved here: {SELF}"),
|
|
5394
|
+
),
|
|
5395
|
+
)
|
|
5396
|
+
for description, payload in refusals:
|
|
5397
|
+
if payload.get("status") == "pass":
|
|
5398
|
+
report(
|
|
5399
|
+
f"{label}: a path inside the selected change's own directory "
|
|
5400
|
+
f"closed {description}. That directory moves when the change "
|
|
5401
|
+
"is archived, so the check was true only until the next step "
|
|
5402
|
+
"of the workflow that accepted it."
|
|
5403
|
+
)
|
|
5404
|
+
return 1
|
|
5405
|
+
text = messages(payload)
|
|
5406
|
+
if "archiv" not in text.lower():
|
|
5407
|
+
report(
|
|
5408
|
+
f"{label}: the refusal for {description} does not say the "
|
|
5409
|
+
"directory moves when the change is archived, so it reads as "
|
|
5410
|
+
"a spelling problem the author cannot repair. Got: "
|
|
5411
|
+
f"{text or '(none)'}"
|
|
5412
|
+
)
|
|
5413
|
+
return 1
|
|
5414
|
+
|
|
5415
|
+
# A different live change is a legitimate owner and stays one, as do
|
|
5416
|
+
# the forms that already worked.
|
|
5417
|
+
accepted = (
|
|
5418
|
+
("another live change closing an Invalidates entry",
|
|
5419
|
+
invalidates(f"Durable owner: {OTHER}")),
|
|
5420
|
+
("another live change closing an Expectation Coverage entry",
|
|
5421
|
+
expectation(f"Durable owner: {OTHER}")),
|
|
5422
|
+
("another live change owning a finding",
|
|
5423
|
+
completion(f"still open. Durable owner: {OTHER}")),
|
|
5424
|
+
("resolution evidence in another live change",
|
|
5425
|
+
completion(f"repaired. Resolved here: {OTHER}")),
|
|
5426
|
+
("an archived path owning a finding",
|
|
5427
|
+
completion(f"still open. Durable owner: {ARCHIVED}")),
|
|
5428
|
+
("a tracker reference owning a finding",
|
|
5429
|
+
completion(f"still open. Durable owner: {TRACKER}")),
|
|
5430
|
+
)
|
|
5431
|
+
for description, payload in accepted:
|
|
5432
|
+
if payload.get("status") != "pass":
|
|
5433
|
+
report(
|
|
5434
|
+
f"{label}: {description} was refused. The rule is about the "
|
|
5435
|
+
"directory this change is about to move, not about change "
|
|
5436
|
+
"directories in general."
|
|
5437
|
+
)
|
|
5438
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
5439
|
+
return 1
|
|
5440
|
+
|
|
5441
|
+
# Every refusal that lists the forms says what checking each is worth.
|
|
5442
|
+
for description, payload in (
|
|
5443
|
+
("an Invalidates refusal", invalidates("no closure at all")),
|
|
5444
|
+
("an Expectation Coverage refusal", expectation("no closure at all")),
|
|
5445
|
+
("a Findings refusal", completion("a narrative finding with no marker")),
|
|
5446
|
+
):
|
|
5447
|
+
text = messages(payload)
|
|
5448
|
+
for expected in (
|
|
5449
|
+
"checked for existence when it is cited",
|
|
5450
|
+
"never fetches",
|
|
5451
|
+
):
|
|
5452
|
+
if expected not in text:
|
|
5453
|
+
report(
|
|
5454
|
+
f"{label}: {description} does not say what the accepted "
|
|
5455
|
+
f"forms are worth — missing: {expected}. An author who "
|
|
5456
|
+
"cannot see what was checked infers that it was."
|
|
5457
|
+
)
|
|
5458
|
+
report(text or "(none)")
|
|
5459
|
+
return 1
|
|
5460
|
+
|
|
5461
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
5462
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
5463
|
+
return 1
|
|
5464
|
+
report(f"{label} scenario passed.")
|
|
5465
|
+
return 0
|
|
5466
|
+
|
|
5467
|
+
|
|
4869
5468
|
def validate_durable_owner_vocabulary_scenario() -> int:
|
|
4870
5469
|
label = "durable-owner-vocabulary"
|
|
4871
5470
|
|
|
@@ -5003,14 +5602,18 @@ def validate_durable_owner_vocabulary_scenario() -> int:
|
|
|
5003
5602
|
report(messages)
|
|
5004
5603
|
return 1
|
|
5005
5604
|
|
|
5006
|
-
# M3 — every previously accepted form still closes.
|
|
5605
|
+
# M3 — every previously accepted form still closes. The change artifact
|
|
5606
|
+
# names another live change: a path inside `demo`'s own directory is
|
|
5607
|
+
# refused now, because that directory moves when `demo` is archived.
|
|
5007
5608
|
for closure in (
|
|
5008
|
-
"Durable owner: openspec/changes/
|
|
5609
|
+
"Durable owner: openspec/changes/other-change/proposal.md",
|
|
5009
5610
|
"Durable owner: keel/archive/notes/2026-07-28-example.md",
|
|
5010
5611
|
"Durable owner: https://github.com/TanglmChris/keel/issues/20",
|
|
5011
5612
|
"Discard reason: it stands as written.",
|
|
5012
5613
|
):
|
|
5013
|
-
write_text(
|
|
5614
|
+
write_text(
|
|
5615
|
+
repo / "openspec/changes/other-change/proposal.md", "# Proposal\n"
|
|
5616
|
+
)
|
|
5014
5617
|
payload = invalidation_start(closure)
|
|
5015
5618
|
if payload.get("status") != "pass":
|
|
5016
5619
|
report(f"{label} dropped a previously accepted form: {closure}")
|
|
@@ -10031,7 +10634,11 @@ def validate_review_entry_extent_scenario() -> int:
|
|
|
10031
10634
|
one entry cannot be satisfied by another's text.
|
|
10032
10635
|
"""
|
|
10033
10636
|
label = "review-entry-extent"
|
|
10034
|
-
|
|
10637
|
+
# Outside the selected change's own directory on purpose: a path under
|
|
10638
|
+
# `openspec/changes/demo/` is refused as a durable owner because that
|
|
10639
|
+
# directory moves at archive, and this scenario is about how far a wrapped
|
|
10640
|
+
# Findings entry extends, not about which owner forms are accepted.
|
|
10641
|
+
owner_path = "openspec/FOLLOWUP.md"
|
|
10035
10642
|
|
|
10036
10643
|
with tempfile.TemporaryDirectory(
|
|
10037
10644
|
prefix="keel-review-extent-", ignore_cleanup_errors=True
|
|
@@ -10039,6 +10646,7 @@ def validate_review_entry_extent_scenario() -> int:
|
|
|
10039
10646
|
repo = Path(raw)
|
|
10040
10647
|
tasks = repo / "openspec/changes/demo/tasks.md"
|
|
10041
10648
|
write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
|
|
10649
|
+
write_text(repo / owner_path, "# Follow-ups\n")
|
|
10042
10650
|
write_text(
|
|
10043
10651
|
repo / "openspec/changes/demo/specs/demo/spec.md",
|
|
10044
10652
|
"## ADDED Requirements\n",
|
|
@@ -15556,7 +16164,13 @@ def validate_verification_layering_docs_scenario() -> int:
|
|
|
15556
16164
|
return 0
|
|
15557
16165
|
|
|
15558
16166
|
|
|
15559
|
-
STANDING_AUTHORIZATION_ACTIONS = (
|
|
16167
|
+
STANDING_AUTHORIZATION_ACTIONS = (
|
|
16168
|
+
"commit",
|
|
16169
|
+
"push",
|
|
16170
|
+
"release",
|
|
16171
|
+
"archive",
|
|
16172
|
+
"continuation",
|
|
16173
|
+
)
|
|
15560
16174
|
|
|
15561
16175
|
|
|
15562
16176
|
def write_authorize_config(repo: Path, body: str) -> None:
|
|
@@ -15588,7 +16202,11 @@ def validate_standing_authorization_declaration_scenario() -> int:
|
|
|
15588
16202
|
report(f"standing-authorization: declared action not reported: {needle}")
|
|
15589
16203
|
report(out)
|
|
15590
16204
|
return 1
|
|
15591
|
-
for needle in (
|
|
16205
|
+
for needle in (
|
|
16206
|
+
"release: not authorized",
|
|
16207
|
+
"archive: not authorized",
|
|
16208
|
+
"continuation: not authorized",
|
|
16209
|
+
):
|
|
15592
16210
|
if needle not in out:
|
|
15593
16211
|
report(f"standing-authorization: undeclared action not reported: {needle}")
|
|
15594
16212
|
report(out)
|
|
@@ -18030,84 +18648,264 @@ def validate_standing_authorization_never_weakens_scenario() -> int:
|
|
|
18030
18648
|
),
|
|
18031
18649
|
}
|
|
18032
18650
|
|
|
18033
|
-
def pair(root: Path, name: str, tasks: str) -> tuple[Path, Path]:
|
|
18034
|
-
authorizing = root / f"{name}-authorizing"
|
|
18035
|
-
authorizing.mkdir()
|
|
18036
|
-
write_gate_fixture(authorizing, tasks)
|
|
18651
|
+
def pair(root: Path, name: str, tasks: str) -> tuple[Path, Path]:
|
|
18652
|
+
authorizing = root / f"{name}-authorizing"
|
|
18653
|
+
authorizing.mkdir()
|
|
18654
|
+
write_gate_fixture(authorizing, tasks)
|
|
18655
|
+
write_authorize_config(
|
|
18656
|
+
authorizing,
|
|
18657
|
+
"authorize:\n - commit\n - push\n - release\n - archive\n"
|
|
18658
|
+
" - continuation\n",
|
|
18659
|
+
)
|
|
18660
|
+
silent = root / f"{name}-silent"
|
|
18661
|
+
silent.mkdir()
|
|
18662
|
+
write_gate_fixture(silent, tasks)
|
|
18663
|
+
# Positive control. Every check below compares these two repositories
|
|
18664
|
+
# and passes when they agree, so a declaration that silently failed to
|
|
18665
|
+
# reach the capsule would make each comparison trivially true and prove
|
|
18666
|
+
# nothing. Assert the difference exists before asserting it is inert.
|
|
18667
|
+
live = standing_authorization_autonomy(authorizing) or []
|
|
18668
|
+
inert = standing_authorization_autonomy(silent) or []
|
|
18669
|
+
if not any("keel/config.yaml" in entry for entry in live):
|
|
18670
|
+
report(
|
|
18671
|
+
f"standing-authorization-inert: the {name} authorizing fixture "
|
|
18672
|
+
f"never actually authorized anything: {live}"
|
|
18673
|
+
)
|
|
18674
|
+
raise AssertionError("authorizing fixture is not authorizing")
|
|
18675
|
+
if any("keel/config.yaml" in entry for entry in inert):
|
|
18676
|
+
report(
|
|
18677
|
+
f"standing-authorization-inert: the {name} silent fixture "
|
|
18678
|
+
f"declared something: {inert}"
|
|
18679
|
+
)
|
|
18680
|
+
raise AssertionError("silent fixture is not silent")
|
|
18681
|
+
return authorizing, silent
|
|
18682
|
+
|
|
18683
|
+
with tempfile.TemporaryDirectory(prefix="keel-authinert-") as raw_tmp:
|
|
18684
|
+
root = Path(raw_tmp)
|
|
18685
|
+
|
|
18686
|
+
# M1 — completion returns the same status and problem set either way.
|
|
18687
|
+
authorizing, silent = pair(root, "complete", complete_task)
|
|
18688
|
+
for repo in (authorizing, silent):
|
|
18689
|
+
if gate_result(repo, "task-start") is None:
|
|
18690
|
+
report("standing-authorization-inert: task-start produced no JSON.")
|
|
18691
|
+
return 1
|
|
18692
|
+
authorized_result = gate_result(authorizing, "task-complete")
|
|
18693
|
+
silent_result = gate_result(silent, "task-complete")
|
|
18694
|
+
if authorized_result is None or silent_result is None:
|
|
18695
|
+
report("standing-authorization-inert: task-complete produced no JSON.")
|
|
18696
|
+
return 1
|
|
18697
|
+
if authorized_result != silent_result:
|
|
18698
|
+
report(
|
|
18699
|
+
"standing-authorization-inert: a declaration changed the "
|
|
18700
|
+
f"completion gate result: {authorized_result} != {silent_result}"
|
|
18701
|
+
)
|
|
18702
|
+
return 1
|
|
18703
|
+
|
|
18704
|
+
# M2 — a repo authorizing every action still fails for missing evidence,
|
|
18705
|
+
# with unchanged failure text.
|
|
18706
|
+
authorizing, silent = pair(root, "missing", missing_evidence_task)
|
|
18707
|
+
for repo in (authorizing, silent):
|
|
18708
|
+
if gate_result(repo, "task-start") is None:
|
|
18709
|
+
report("standing-authorization-inert: task-start produced no JSON.")
|
|
18710
|
+
return 1
|
|
18711
|
+
authorized_result = gate_result(authorizing, "task-complete")
|
|
18712
|
+
silent_result = gate_result(silent, "task-complete")
|
|
18713
|
+
if authorized_result is None or silent_result is None:
|
|
18714
|
+
report("standing-authorization-inert: task-complete produced no JSON.")
|
|
18715
|
+
return 1
|
|
18716
|
+
if authorized_result.get("status") == "pass":
|
|
18717
|
+
report(
|
|
18718
|
+
"standing-authorization-inert: authorizing every action let a "
|
|
18719
|
+
"task with missing evidence pass completion."
|
|
18720
|
+
)
|
|
18721
|
+
return 1
|
|
18722
|
+
if authorized_result != silent_result:
|
|
18723
|
+
report(
|
|
18724
|
+
"standing-authorization-inert: a declaration changed the failure "
|
|
18725
|
+
f"text: {authorized_result} != {silent_result}"
|
|
18726
|
+
)
|
|
18727
|
+
return 1
|
|
18728
|
+
|
|
18729
|
+
# M3 — a declaration selects nothing and starts nothing.
|
|
18730
|
+
def continuity(repo: Path) -> dict | None:
|
|
18731
|
+
result = run_keel(repo, "context", "--json")
|
|
18732
|
+
try:
|
|
18733
|
+
payload = json.loads(result.stdout)
|
|
18734
|
+
except json.JSONDecodeError:
|
|
18735
|
+
return None
|
|
18736
|
+
return {
|
|
18737
|
+
"status": payload.get("status"),
|
|
18738
|
+
"selection": payload.get("selection"),
|
|
18739
|
+
"nextAction": payload.get("nextAction"),
|
|
18740
|
+
}
|
|
18741
|
+
|
|
18742
|
+
authorizing, silent = pair(root, "context", complete_task)
|
|
18743
|
+
authorized_context = continuity(authorizing)
|
|
18744
|
+
silent_context = continuity(silent)
|
|
18745
|
+
if authorized_context is None or silent_context is None:
|
|
18746
|
+
report("standing-authorization-inert: keel context produced no JSON.")
|
|
18747
|
+
return 1
|
|
18748
|
+
if authorized_context != silent_context:
|
|
18749
|
+
report(
|
|
18750
|
+
"standing-authorization-inert: a declaration changed continuity "
|
|
18751
|
+
f"selection: {authorized_context} != {silent_context}"
|
|
18752
|
+
)
|
|
18753
|
+
return 1
|
|
18754
|
+
|
|
18755
|
+
report("standing-authorization-never-weakens scenario passed.")
|
|
18756
|
+
return 0
|
|
18757
|
+
|
|
18758
|
+
|
|
18759
|
+
def validate_continuation_authorization_scenario() -> int:
|
|
18760
|
+
"""`continuation` gives the between-task approval a durable home (#94).
|
|
18761
|
+
|
|
18762
|
+
The fifth vocabulary name has the same nature as the four before it:
|
|
18763
|
+
declared, it authorizes and an inheriting capsule names its source;
|
|
18764
|
+
undeclared, it authorizes nothing; and the declaration stays inert to
|
|
18765
|
+
every gate result and to continuity selection — it removes a
|
|
18766
|
+
confirmation, never a proof, and it never selects the next task itself.
|
|
18767
|
+
"""
|
|
18768
|
+
label = "continuation-authorization"
|
|
18769
|
+
|
|
18770
|
+
complete_task = (
|
|
18771
|
+
"- [ ] 1.1 Behavior\n"
|
|
18772
|
+
" - Covers:\n"
|
|
18773
|
+
" - E1: public behavior\n"
|
|
18774
|
+
" - Touch:\n"
|
|
18775
|
+
" - src/feature.js\n"
|
|
18776
|
+
" - Verify:\n"
|
|
18777
|
+
" - Strategy: evidence-first\n"
|
|
18778
|
+
" - M1: node test.js proves the public behavior\n"
|
|
18779
|
+
" - Evidence:\n"
|
|
18780
|
+
" - Contract: pending\n"
|
|
18781
|
+
" - M1: node test.js printed ok\n"
|
|
18782
|
+
" - Review:\n"
|
|
18783
|
+
" - Status: pass\n"
|
|
18784
|
+
" - Acceptance check: reviewed\n"
|
|
18785
|
+
" - Scope check: reviewed\n"
|
|
18786
|
+
" - Findings: none\n"
|
|
18787
|
+
" - Blocker: none\n"
|
|
18788
|
+
)
|
|
18789
|
+
|
|
18790
|
+
def gate_result(repo: Path, stage: str) -> dict | None:
|
|
18791
|
+
result = run_keel(
|
|
18792
|
+
repo, "gate", stage, "--change", "demo", "--task", "1.1", "--json"
|
|
18793
|
+
)
|
|
18794
|
+
try:
|
|
18795
|
+
payload = json.loads(result.stdout)
|
|
18796
|
+
except json.JSONDecodeError:
|
|
18797
|
+
return None
|
|
18798
|
+
return {
|
|
18799
|
+
"status": payload.get("status"),
|
|
18800
|
+
"problems": sorted(
|
|
18801
|
+
(problem.get("code", ""), problem.get("message", ""))
|
|
18802
|
+
for problem in payload.get("problems") or []
|
|
18803
|
+
),
|
|
18804
|
+
}
|
|
18805
|
+
|
|
18806
|
+
with tempfile.TemporaryDirectory(prefix="keel-continuation-") as raw_tmp:
|
|
18807
|
+
root = Path(raw_tmp)
|
|
18808
|
+
|
|
18809
|
+
# M1 — declared alone, `continuation` is authorized and no repository
|
|
18810
|
+
# action rides along with it: commit, push, release, and archive each
|
|
18811
|
+
# still require their own name.
|
|
18812
|
+
declared = root / "declared"
|
|
18813
|
+
declared.mkdir()
|
|
18814
|
+
write_authorize_config(declared, "authorize:\n - continuation\n")
|
|
18815
|
+
result = run_keel(declared, "--doctor")
|
|
18816
|
+
if result.returncode != 0:
|
|
18817
|
+
report(f"{label}: a continuation-only declaration was refused.")
|
|
18818
|
+
report(result.stdout + result.stderr)
|
|
18819
|
+
return 1
|
|
18820
|
+
if "continuation: authorized" not in result.stdout:
|
|
18821
|
+
report(f"{label}: a declared continuation was not reported authorized.")
|
|
18822
|
+
report(result.stdout)
|
|
18823
|
+
return 1
|
|
18824
|
+
for needle in (
|
|
18825
|
+
"commit: not authorized",
|
|
18826
|
+
"push: not authorized",
|
|
18827
|
+
"release: not authorized",
|
|
18828
|
+
"archive: not authorized",
|
|
18829
|
+
):
|
|
18830
|
+
if needle not in result.stdout:
|
|
18831
|
+
report(f"{label}: undeclared action not reported: {needle}")
|
|
18832
|
+
report(result.stdout)
|
|
18833
|
+
return 1
|
|
18834
|
+
|
|
18835
|
+
# M1 — undeclared, it is reported beside the four exactly as any
|
|
18836
|
+
# unlisted action is.
|
|
18837
|
+
four = root / "four"
|
|
18838
|
+
four.mkdir()
|
|
18037
18839
|
write_authorize_config(
|
|
18038
|
-
|
|
18039
|
-
"authorize:\n - commit\n - push\n - release\n - archive\n",
|
|
18840
|
+
four, "authorize:\n - commit\n - push\n - release\n - archive\n"
|
|
18040
18841
|
)
|
|
18041
|
-
|
|
18042
|
-
|
|
18043
|
-
|
|
18044
|
-
|
|
18045
|
-
|
|
18046
|
-
|
|
18047
|
-
#
|
|
18048
|
-
|
|
18049
|
-
|
|
18050
|
-
|
|
18842
|
+
out = run_keel(four, "--doctor").stdout
|
|
18843
|
+
if "continuation: not authorized" not in out:
|
|
18844
|
+
report(f"{label}: an undeclared continuation was not reported.")
|
|
18845
|
+
report(out)
|
|
18846
|
+
return 1
|
|
18847
|
+
|
|
18848
|
+
# M1 — a task that authored no boundary inherits it, the capsule names
|
|
18849
|
+
# the declaration as its source, and undeclared actions keep the
|
|
18850
|
+
# hard-stop default.
|
|
18851
|
+
write_gate_fixture(declared, standing_authorization_task())
|
|
18852
|
+
autonomy = standing_authorization_autonomy(declared)
|
|
18853
|
+
if autonomy is None:
|
|
18854
|
+
report(f"{label}: task-start returned no capsule autonomy.")
|
|
18855
|
+
return 1
|
|
18856
|
+
inherited = [entry for entry in autonomy if "continuation" in entry]
|
|
18857
|
+
if not inherited:
|
|
18051
18858
|
report(
|
|
18052
|
-
f"
|
|
18053
|
-
f"
|
|
18859
|
+
f"{label}: continuation did not reach the capsule autonomy: "
|
|
18860
|
+
f"{autonomy}"
|
|
18054
18861
|
)
|
|
18055
|
-
|
|
18056
|
-
if any("keel/config.yaml" in entry for entry in
|
|
18862
|
+
return 1
|
|
18863
|
+
if not any("keel/config.yaml" in entry for entry in inherited):
|
|
18057
18864
|
report(
|
|
18058
|
-
f"
|
|
18059
|
-
f"
|
|
18865
|
+
f"{label}: the inherited entry does not name its source: "
|
|
18866
|
+
f"{autonomy}"
|
|
18060
18867
|
)
|
|
18061
|
-
raise AssertionError("silent fixture is not silent")
|
|
18062
|
-
return authorizing, silent
|
|
18063
|
-
|
|
18064
|
-
with tempfile.TemporaryDirectory(prefix="keel-authinert-") as raw_tmp:
|
|
18065
|
-
root = Path(raw_tmp)
|
|
18066
|
-
|
|
18067
|
-
# M1 — completion returns the same status and problem set either way.
|
|
18068
|
-
authorizing, silent = pair(root, "complete", complete_task)
|
|
18069
|
-
for repo in (authorizing, silent):
|
|
18070
|
-
if gate_result(repo, "task-start") is None:
|
|
18071
|
-
report("standing-authorization-inert: task-start produced no JSON.")
|
|
18072
|
-
return 1
|
|
18073
|
-
authorized_result = gate_result(authorizing, "task-complete")
|
|
18074
|
-
silent_result = gate_result(silent, "task-complete")
|
|
18075
|
-
if authorized_result is None or silent_result is None:
|
|
18076
|
-
report("standing-authorization-inert: task-complete produced no JSON.")
|
|
18077
18868
|
return 1
|
|
18078
|
-
if
|
|
18869
|
+
if not any(entry.startswith("Default: hard-stop") for entry in autonomy):
|
|
18079
18870
|
report(
|
|
18080
|
-
"
|
|
18081
|
-
f"
|
|
18871
|
+
f"{label}: undeclared actions lost the hard-stop default: "
|
|
18872
|
+
f"{autonomy}"
|
|
18082
18873
|
)
|
|
18083
18874
|
return 1
|
|
18084
18875
|
|
|
18085
|
-
#
|
|
18086
|
-
#
|
|
18087
|
-
|
|
18088
|
-
|
|
18876
|
+
# M1 — inert to gates and selection: against an otherwise identical
|
|
18877
|
+
# silent repository, gate results and continuity are equal. The
|
|
18878
|
+
# capsule check above is the positive control — the declaration
|
|
18879
|
+
# demonstrably reached the capsule, so equality is not trivially true.
|
|
18880
|
+
inert_declared = root / "inert-declared"
|
|
18881
|
+
inert_declared.mkdir()
|
|
18882
|
+
write_gate_fixture(inert_declared, complete_task)
|
|
18883
|
+
write_authorize_config(inert_declared, "authorize:\n - continuation\n")
|
|
18884
|
+
inert_silent = root / "inert-silent"
|
|
18885
|
+
inert_silent.mkdir()
|
|
18886
|
+
write_gate_fixture(inert_silent, complete_task)
|
|
18887
|
+
if any(
|
|
18888
|
+
"keel/config.yaml" in entry
|
|
18889
|
+
for entry in standing_authorization_autonomy(inert_silent) or []
|
|
18890
|
+
):
|
|
18891
|
+
report(f"{label}: the silent fixture declared something.")
|
|
18892
|
+
return 1
|
|
18893
|
+
for repo in (inert_declared, inert_silent):
|
|
18089
18894
|
if gate_result(repo, "task-start") is None:
|
|
18090
|
-
report("
|
|
18895
|
+
report(f"{label}: task-start produced no JSON.")
|
|
18091
18896
|
return 1
|
|
18092
|
-
|
|
18093
|
-
silent_result = gate_result(
|
|
18094
|
-
if
|
|
18095
|
-
report("
|
|
18096
|
-
return 1
|
|
18097
|
-
if authorized_result.get("status") == "pass":
|
|
18098
|
-
report(
|
|
18099
|
-
"standing-authorization-inert: authorizing every action let a "
|
|
18100
|
-
"task with missing evidence pass completion."
|
|
18101
|
-
)
|
|
18897
|
+
declared_result = gate_result(inert_declared, "task-complete")
|
|
18898
|
+
silent_result = gate_result(inert_silent, "task-complete")
|
|
18899
|
+
if declared_result is None or silent_result is None:
|
|
18900
|
+
report(f"{label}: task-complete produced no JSON.")
|
|
18102
18901
|
return 1
|
|
18103
|
-
if
|
|
18902
|
+
if declared_result != silent_result:
|
|
18104
18903
|
report(
|
|
18105
|
-
"
|
|
18106
|
-
f"
|
|
18904
|
+
f"{label}: the declaration changed a gate result: "
|
|
18905
|
+
f"{declared_result} != {silent_result}"
|
|
18107
18906
|
)
|
|
18108
18907
|
return 1
|
|
18109
18908
|
|
|
18110
|
-
# M3 — a declaration selects nothing and starts nothing.
|
|
18111
18909
|
def continuity(repo: Path) -> dict | None:
|
|
18112
18910
|
result = run_keel(repo, "context", "--json")
|
|
18113
18911
|
try:
|
|
@@ -18120,20 +18918,95 @@ def validate_standing_authorization_never_weakens_scenario() -> int:
|
|
|
18120
18918
|
"nextAction": payload.get("nextAction"),
|
|
18121
18919
|
}
|
|
18122
18920
|
|
|
18123
|
-
|
|
18124
|
-
|
|
18125
|
-
silent_context
|
|
18126
|
-
|
|
18127
|
-
report("standing-authorization-inert: keel context produced no JSON.")
|
|
18921
|
+
declared_context = continuity(inert_declared)
|
|
18922
|
+
silent_context = continuity(inert_silent)
|
|
18923
|
+
if declared_context is None or silent_context is None:
|
|
18924
|
+
report(f"{label}: keel context produced no JSON.")
|
|
18128
18925
|
return 1
|
|
18129
|
-
if
|
|
18926
|
+
if declared_context != silent_context:
|
|
18130
18927
|
report(
|
|
18131
|
-
"
|
|
18132
|
-
f"
|
|
18928
|
+
f"{label}: the declaration changed continuity selection: "
|
|
18929
|
+
f"{declared_context} != {silent_context}"
|
|
18133
18930
|
)
|
|
18134
18931
|
return 1
|
|
18135
18932
|
|
|
18136
|
-
report("
|
|
18933
|
+
report("continuation-authorization scenario passed.")
|
|
18934
|
+
return 0
|
|
18935
|
+
|
|
18936
|
+
|
|
18937
|
+
def validate_continuation_docs_scenario() -> int:
|
|
18938
|
+
"""Every text an agent reads at the between-task boundary names the word (#94).
|
|
18939
|
+
|
|
18940
|
+
The declaration only works if the boundary's readers know it exists: the
|
|
18941
|
+
goal skill's stop rule is what an agent obeys at the boundary, the README
|
|
18942
|
+
and the config comment are where an owner learns the vocabulary, and the
|
|
18943
|
+
resident protocol is what the enforcing agent holds in context.
|
|
18944
|
+
"""
|
|
18945
|
+
label = "continuation-docs"
|
|
18946
|
+
|
|
18947
|
+
src_skill = (
|
|
18948
|
+
ROOT / "src/skills/keel-run-single-task-goal/SKILL.md"
|
|
18949
|
+
).read_text(encoding="utf-8")
|
|
18950
|
+
plugin_skill = (
|
|
18951
|
+
ROOT / "plugins/keel/skills/keel-run-single-task-goal/SKILL.md"
|
|
18952
|
+
).read_text(encoding="utf-8")
|
|
18953
|
+
if src_skill != plugin_skill:
|
|
18954
|
+
report(f"{label}: the src/ and plugins/ skill copies diverge.")
|
|
18955
|
+
return 1
|
|
18956
|
+
|
|
18957
|
+
step7 = next(
|
|
18958
|
+
(line for line in plugin_skill.splitlines() if line.startswith("7. ")), ""
|
|
18959
|
+
)
|
|
18960
|
+
if not step7.startswith("7. Stop."):
|
|
18961
|
+
report(f"{label}: the goal skill lost its stop step: {step7!r}")
|
|
18962
|
+
return 1
|
|
18963
|
+
for needle in (
|
|
18964
|
+
"standing `continuation` authorization",
|
|
18965
|
+
"keel/config.yaml",
|
|
18966
|
+
"next unchecked task of the same change",
|
|
18967
|
+
"its own recorded fingerprint",
|
|
18968
|
+
"no hidden scheduler",
|
|
18969
|
+
):
|
|
18970
|
+
if needle not in step7:
|
|
18971
|
+
report(f"{label}: the stop rule lacks: {needle}")
|
|
18972
|
+
report(step7)
|
|
18973
|
+
return 1
|
|
18974
|
+
|
|
18975
|
+
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
18976
|
+
for needle in (
|
|
18977
|
+
"accepted names: commit, push, release, archive, continuation",
|
|
18978
|
+
"next unchecked task of the same change",
|
|
18979
|
+
"the stop that re-asks for an approval already given",
|
|
18980
|
+
"The five names above are the whole vocabulary.",
|
|
18981
|
+
):
|
|
18982
|
+
if needle not in readme:
|
|
18983
|
+
report(f"{label}: README.md lacks: {needle}")
|
|
18984
|
+
return 1
|
|
18985
|
+
|
|
18986
|
+
config_text = (ROOT / "keel/config.yaml").read_text(encoding="utf-8")
|
|
18987
|
+
if "commit, push, release, archive,\n# continuation" not in config_text:
|
|
18988
|
+
report(
|
|
18989
|
+
f"{label}: keel/config.yaml's comment does not name the five-name "
|
|
18990
|
+
"vocabulary."
|
|
18991
|
+
)
|
|
18992
|
+
return 1
|
|
18993
|
+
|
|
18994
|
+
agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
|
|
18995
|
+
parts = agents.split("## Execution boundary", 1)
|
|
18996
|
+
if len(parts) != 2:
|
|
18997
|
+
report(f"{label}: AGENTS.md lost its Execution boundary section.")
|
|
18998
|
+
return 1
|
|
18999
|
+
section = parts[1].split("\n## ", 1)[0]
|
|
19000
|
+
for needle in (
|
|
19001
|
+
"standing `continuation` authorization",
|
|
19002
|
+
"next unchecked task of the same change",
|
|
19003
|
+
"its own recorded fingerprint",
|
|
19004
|
+
):
|
|
19005
|
+
if needle not in section:
|
|
19006
|
+
report(f"{label}: AGENTS.md Execution boundary lacks: {needle}")
|
|
19007
|
+
return 1
|
|
19008
|
+
|
|
19009
|
+
report("continuation-docs scenario passed.")
|
|
18137
19010
|
return 0
|
|
18138
19011
|
|
|
18139
19012
|
|
|
@@ -22270,8 +23143,11 @@ def validate_decimal_runs_are_not_hash_shaped_scenario() -> int:
|
|
|
22270
23143
|
)
|
|
22271
23144
|
# The other half of the requirement: what the rule exists to refuse.
|
|
22272
23145
|
refused_token = "- M4: pass —— 合入前的 commit a1b2c3d4e5f6 已验证。\n"
|
|
22273
|
-
# Wording alone, carrying no hash-shaped token at all.
|
|
22274
|
-
|
|
23146
|
+
# Wording alone, carrying no hash-shaped token at all. The line names 分支
|
|
23147
|
+
# because 提交 on its own is an ordinary verb and no longer refused (#103);
|
|
23148
|
+
# what this fixture is for is the absence of a token, not the absence of
|
|
23149
|
+
# context.
|
|
23150
|
+
refused_wording = "- M5: 该分支**未提交**,等待评审。\n"
|
|
22275
23151
|
|
|
22276
23152
|
def state_of(check) -> str:
|
|
22277
23153
|
if "keel state: ok" in check.stdout:
|
|
@@ -22494,12 +23370,17 @@ def validate_a_context_word_is_a_word_scenario() -> int:
|
|
|
22494
23370
|
|
|
22495
23371
|
# The Chinese words carry no boundary, because none is definable
|
|
22496
23372
|
# between two word characters. Asserted here rather than reasoned
|
|
22497
|
-
# about: `\b提交\b` matches none of these.
|
|
22498
|
-
|
|
23373
|
+
# about: `\b提交\b` matches none of these. What the absence buys is
|
|
23374
|
+
# the context supply below — each line holds a hash-shaped token and
|
|
23375
|
+
# no other context word, so the Chinese word is the only thing that
|
|
23376
|
+
# can make the token an identifier. The wording rule no longer rests
|
|
23377
|
+
# on this: 提交 alone is an ordinary verb and needs a git word of its
|
|
23378
|
+
# own, which `a-submission-is-not-a-commit` covers.
|
|
23379
|
+
for recorded in ("已提交", "未提交", "该任务尚未提交"):
|
|
22499
23380
|
state, _ = check(
|
|
22500
23381
|
"- [x] A1 implementation\n"
|
|
22501
23382
|
" - Evidence:\n"
|
|
22502
|
-
f" - M1: {recorded}。\n"
|
|
23383
|
+
f" - M1: {recorded} a1b2c3d4e5f6。\n"
|
|
22503
23384
|
)
|
|
22504
23385
|
if state == "unreported":
|
|
22505
23386
|
report(
|
|
@@ -22510,12 +23391,240 @@ def validate_a_context_word_is_a_word_scenario() -> int:
|
|
|
22510
23391
|
return 1
|
|
22511
23392
|
if state != "failed":
|
|
22512
23393
|
report(
|
|
22513
|
-
f"{label}:
|
|
22514
|
-
"
|
|
22515
|
-
"
|
|
23394
|
+
f"{label}: an identifier beside `{recorded}` was accepted. "
|
|
23395
|
+
"A word boundary around a Chinese context word disables it "
|
|
23396
|
+
"silently, which is why it does not carry one."
|
|
23397
|
+
)
|
|
23398
|
+
return 1
|
|
23399
|
+
|
|
23400
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
23401
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
23402
|
+
return 1
|
|
23403
|
+
report(f"{label} scenario passed.")
|
|
23404
|
+
return 0
|
|
23405
|
+
|
|
23406
|
+
|
|
23407
|
+
def validate_a_submission_is_not_a_commit_scenario() -> int:
|
|
23408
|
+
"""Issue #103: a general Chinese verb was read as a git word.
|
|
23409
|
+
|
|
23410
|
+
\u63d0\u4ea4 is an ordinary transitive verb — \u63d0\u4ea4\u8d44\u6599, \u63d0\u4ea4\u5ba1\u6838, \u63d0\u4ea4\u7533\u8bf7. Matched bare, it
|
|
23411
|
+
refused an Evidence line recording that a user had submitted paperwork to
|
|
23412
|
+
a third-party review queue, while the same fact written in English passed
|
|
23413
|
+
untouched. A check whose verdict depends on which language the author
|
|
23414
|
+
wrote in is not checking what it claims to.
|
|
23415
|
+
|
|
23416
|
+
\u5408\u5165 is the other half and stays bare: it names the git act and has no
|
|
23417
|
+
ordinary-prose reading, so requiring context for it would buy nothing.
|
|
23418
|
+
"""
|
|
23419
|
+
label = "a-submission-is-not-a-commit"
|
|
23420
|
+
|
|
23421
|
+
with tempfile.TemporaryDirectory(prefix="keel-submission-") as raw:
|
|
23422
|
+
check, failure = _tasks_semantics_probe(raw)
|
|
23423
|
+
if failure is not None:
|
|
23424
|
+
report(f"{label}: keel --install failed while building the fixture.")
|
|
23425
|
+
report(failure)
|
|
23426
|
+
return 1
|
|
23427
|
+
|
|
23428
|
+
def evidence(text: str) -> str:
|
|
23429
|
+
return (
|
|
23430
|
+
"- [x] A1 implementation\n"
|
|
23431
|
+
" - Evidence:\n"
|
|
23432
|
+
f" - M1: {text}\n"
|
|
23433
|
+
)
|
|
23434
|
+
|
|
23435
|
+
# The report's own line, and its English translation. Neither says
|
|
23436
|
+
# anything about this repository's git state, and the check must not
|
|
23437
|
+
# split them.
|
|
23438
|
+
accepted = (
|
|
23439
|
+
(
|
|
23440
|
+
"the reported line",
|
|
23441
|
+
"\u6536\u5de5\u72b6\u6001\u4e00\u5e76\u767b\u8bb0\uff1a\u7528\u6237\u5f53\u5929**\u5df2\u63d0\u4ea4**\u80fd\u586b\u7684\u5546\u6237\u4e0e\u7ed3\u7b97\u8d44\u6599\u3002",
|
|
23442
|
+
),
|
|
23443
|
+
(
|
|
23444
|
+
"its English translation",
|
|
23445
|
+
"the user has submitted the merchant paperwork for review.",
|
|
23446
|
+
),
|
|
23447
|
+
(
|
|
23448
|
+
"a submission to a review queue",
|
|
23449
|
+
"\u8868\u5355**\u5df2\u63d0\u4ea4**\uff0c\u5f00\u53d1\u8005\u8d26\u6237\u5904\u4e8e\u5ba1\u6838\u4e2d\u3002",
|
|
23450
|
+
),
|
|
23451
|
+
)
|
|
23452
|
+
for description, text in accepted:
|
|
23453
|
+
state, errors = check(evidence(text))
|
|
23454
|
+
if state == "unreported":
|
|
23455
|
+
report(
|
|
23456
|
+
f"{label}: keel --check reported no state at all on "
|
|
23457
|
+
f"{description}."
|
|
23458
|
+
)
|
|
23459
|
+
return 1
|
|
23460
|
+
if state != "ok":
|
|
23461
|
+
report(
|
|
23462
|
+
f"{label}: {description} was refused as recorded commit "
|
|
23463
|
+
"state. Nothing on that line names git, and the same "
|
|
23464
|
+
"sentence in English is accepted — so what the check "
|
|
23465
|
+
"measured was the language, not the claim."
|
|
23466
|
+
)
|
|
23467
|
+
for error in errors:
|
|
23468
|
+
report(f" {error}")
|
|
23469
|
+
return 1
|
|
23470
|
+
|
|
23471
|
+
# What the rule exists to catch, kept refused. Each of these carries a
|
|
23472
|
+
# git word, so the subject of the verb is not in doubt.
|
|
23473
|
+
refused = (
|
|
23474
|
+
("beside a branch name", "1.2 \u7684\u6539\u52a8**\u5df2\u63d0\u4ea4**\u5230 main\u3002"),
|
|
23475
|
+
("beside \u5206\u652f", "\u8be5\u5206\u652f\u7684\u6539\u52a8**\u5c1a\u672a\u63d0\u4ea4**\uff0c\u7b49\u5f85\u8bc4\u5ba1\u3002"),
|
|
23476
|
+
("beside \u4ee3\u7801", "\u4ee3\u7801**\u5df2\u63d0\u4ea4**\uff0c\u6587\u6863\u672a\u52a8\u3002"),
|
|
23477
|
+
("\u5408\u5165 alone", "\u8fd9\u4e00\u6ce2**\u672a\u5408\u5165**\uff0c\u4e0b\u5468\u518d\u8bf4\u3002"),
|
|
23478
|
+
("\u5df2\u5408\u5165 alone", "\u8be5\u6539\u52a8**\u5df2\u5408\u5165**\u3002"),
|
|
23479
|
+
)
|
|
23480
|
+
for description, text in refused:
|
|
23481
|
+
state, errors = check(evidence(text))
|
|
23482
|
+
if state == "unreported":
|
|
23483
|
+
report(
|
|
23484
|
+
f"{label}: keel --check reported no state at all on a "
|
|
23485
|
+
f"line {description}."
|
|
23486
|
+
)
|
|
23487
|
+
return 1
|
|
23488
|
+
if state != "failed":
|
|
23489
|
+
report(
|
|
23490
|
+
f"{label}: recorded state {description} was accepted. "
|
|
23491
|
+
"Requiring context must narrow which lines are read, not "
|
|
23492
|
+
"which words the rule knows."
|
|
23493
|
+
)
|
|
23494
|
+
return 1
|
|
23495
|
+
if not [error for error in errors if "tasks.md:" in error]:
|
|
23496
|
+
report(f"{label}: the refusal {description} named no line.")
|
|
23497
|
+
return 1
|
|
23498
|
+
|
|
23499
|
+
# The contextual-identifier rule is untouched: a Chinese state word
|
|
23500
|
+
# still supplies context to a hash-shaped token, with no other context
|
|
23501
|
+
# word on the line.
|
|
23502
|
+
state, _ = check(evidence("\u5df2\u63d0\u4ea4 a1b2c3d4e5f6 \u5f85\u9a8c\u3002"))
|
|
23503
|
+
if state != "failed":
|
|
23504
|
+
report(
|
|
23505
|
+
f"{label}: a hash-shaped token beside a Chinese state word "
|
|
23506
|
+
"was accepted. This change bounds the wording rule, not the "
|
|
23507
|
+
"contextual-identifier rule."
|
|
23508
|
+
)
|
|
23509
|
+
return 1
|
|
23510
|
+
|
|
23511
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
23512
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
23513
|
+
return 1
|
|
23514
|
+
report(f"{label} scenario passed.")
|
|
23515
|
+
return 0
|
|
23516
|
+
|
|
23517
|
+
|
|
23518
|
+
def validate_a_quoted_span_is_not_a_claim_scenario() -> int:
|
|
23519
|
+
"""Issue #65 §2 and §4: quoted material was read as an assertion.
|
|
23520
|
+
|
|
23521
|
+
Evidence prose quotes what it is evidence of — the command that ran, the
|
|
23522
|
+
output it printed, the branch base it ran against, the name of the
|
|
23523
|
+
requirement under change. `withoutInlineCode()` in
|
|
23524
|
+
`src/core/task-contract.js` already settled that shape for the field
|
|
23525
|
+
reader; this check never had it, so a Scope check recording that it ran
|
|
23526
|
+
`git status --short` was refused for the word inside the backticks.
|
|
23527
|
+
|
|
23528
|
+
The boundary matters as much as the exemption: quoting one token must not
|
|
23529
|
+
exempt the sentence around it, or the rule becomes optional.
|
|
23530
|
+
"""
|
|
23531
|
+
label = "a-quoted-span-is-not-a-claim"
|
|
23532
|
+
|
|
23533
|
+
with tempfile.TemporaryDirectory(prefix="keel-quoted-span-") as raw:
|
|
23534
|
+
check, failure = _tasks_semantics_probe(raw)
|
|
23535
|
+
if failure is not None:
|
|
23536
|
+
report(f"{label}: keel --install failed while building the fixture.")
|
|
23537
|
+
report(failure)
|
|
23538
|
+
return 1
|
|
23539
|
+
|
|
23540
|
+
# Each of these carries its whole match inside a quoted span. Nothing
|
|
23541
|
+
# outside the quotes on any of these lines is a claim about this
|
|
23542
|
+
# repository's git state.
|
|
23543
|
+
quoted = (
|
|
23544
|
+
(
|
|
23545
|
+
"inline code holding quoted output",
|
|
23546
|
+
"- [x] A1 implementation\n"
|
|
23547
|
+
" - Evidence:\n"
|
|
23548
|
+
" - M1.red: fail, as required. The runner printed "
|
|
23549
|
+
"`fatal: you have uncommitted changes`.\n",
|
|
23550
|
+
),
|
|
23551
|
+
(
|
|
23552
|
+
"inline code holding an identifier and its context word",
|
|
23553
|
+
"- [x] A1 implementation\n"
|
|
23554
|
+
" - Evidence:\n"
|
|
23555
|
+
" - M1: pass. The fixture repository reported "
|
|
23556
|
+
"`HEAD is at 3f2a9bc` before the run.\n",
|
|
23557
|
+
),
|
|
23558
|
+
(
|
|
23559
|
+
"a fenced block holding output",
|
|
23560
|
+
"- [x] A1 implementation\n"
|
|
23561
|
+
" - Evidence:\n"
|
|
23562
|
+
" - M1: pass. The runner printed:\n"
|
|
23563
|
+
" ```\n"
|
|
23564
|
+
" HEAD is at 3f2a9bc, working tree uncommitted\n"
|
|
23565
|
+
" ```\n",
|
|
23566
|
+
),
|
|
23567
|
+
(
|
|
23568
|
+
"a quotation span holding a requirement name",
|
|
23569
|
+
"- [x] A1 implementation\n"
|
|
23570
|
+
" - Evidence:\n"
|
|
23571
|
+
" - M1: pass. This task renames \u201cA recorded commit "
|
|
23572
|
+
"hash is recognized by what makes it one\u201d.\n",
|
|
23573
|
+
),
|
|
23574
|
+
)
|
|
23575
|
+
for description, body in quoted:
|
|
23576
|
+
state, errors = check(body)
|
|
23577
|
+
if state == "unreported":
|
|
23578
|
+
report(
|
|
23579
|
+
f"{label}: keel --check reported no state at all on "
|
|
23580
|
+
f"{description}. This is not a verdict about the fixture — "
|
|
23581
|
+
"the check did not reach the point of having one."
|
|
23582
|
+
)
|
|
23583
|
+
return 1
|
|
23584
|
+
if state != "ok":
|
|
23585
|
+
report(
|
|
23586
|
+
f"{label}: {description} was read as a claim. A quoted "
|
|
23587
|
+
"span is content the author cites, and the only repair "
|
|
23588
|
+
"open to them is to stop quoting it accurately."
|
|
22516
23589
|
)
|
|
23590
|
+
for error in errors:
|
|
23591
|
+
report(f" {error}")
|
|
22517
23592
|
return 1
|
|
22518
23593
|
|
|
23594
|
+
# The boundary. Quoting one token exempts that token and nothing else.
|
|
23595
|
+
state, errors = check(
|
|
23596
|
+
"- [x] A1 implementation\n"
|
|
23597
|
+
" - Evidence:\n"
|
|
23598
|
+
" - M1: verified against `3f2a9bc`, but the work is still "
|
|
23599
|
+
"uncommitted.\n"
|
|
23600
|
+
)
|
|
23601
|
+
if state != "failed":
|
|
23602
|
+
report(
|
|
23603
|
+
f"{label}: wording outside a quoted span was exempted. The "
|
|
23604
|
+
"span is what stops being read, not the line holding it."
|
|
23605
|
+
)
|
|
23606
|
+
return 1
|
|
23607
|
+
if not [error for error in errors if "tasks.md:" in error]:
|
|
23608
|
+
report(f"{label}: the refusal outside the span named no line.")
|
|
23609
|
+
return 1
|
|
23610
|
+
|
|
23611
|
+
# An apostrophe is not a quotation delimiter. Treating it as one would
|
|
23612
|
+
# silence the remainder of any line carrying a contraction, which is
|
|
23613
|
+
# the failure mode that is invisible rather than wrong.
|
|
23614
|
+
state, errors = check(
|
|
23615
|
+
"- [x] A1 implementation\n"
|
|
23616
|
+
" - Evidence:\n"
|
|
23617
|
+
" - M1: the guard doesn't fire here, and the worktree is "
|
|
23618
|
+
"uncommitted.\n"
|
|
23619
|
+
)
|
|
23620
|
+
if state != "failed":
|
|
23621
|
+
report(
|
|
23622
|
+
f"{label}: a contraction opened a quotation span and silenced "
|
|
23623
|
+
"the rest of the line. The ASCII single quote is an "
|
|
23624
|
+
"apostrophe far more often than it is a quotation mark."
|
|
23625
|
+
)
|
|
23626
|
+
return 1
|
|
23627
|
+
|
|
22519
23628
|
if label not in {name for name, _ in SCENARIOS}:
|
|
22520
23629
|
report(f"{label}: the scenario registry does not include it.")
|
|
22521
23630
|
return 1
|
|
@@ -22622,7 +23731,7 @@ def validate_a_covers_citation_is_not_a_record_scenario() -> int:
|
|
|
22622
23731
|
" - Covers:\n"
|
|
22623
23732
|
f" - {cited[0]}\n"
|
|
22624
23733
|
" - Verify:\n"
|
|
22625
|
-
" - M1: the change is
|
|
23734
|
+
" - M1: the change is 已合入 and needs no further work.\n"
|
|
22626
23735
|
)
|
|
22627
23736
|
if state == "unreported":
|
|
22628
23737
|
report(
|
|
@@ -22989,6 +24098,14 @@ SCENARIOS: tuple = (
|
|
|
22989
24098
|
"unparsed-covers-critical-statement",
|
|
22990
24099
|
validate_unparsed_covers_critical_statement_scenario,
|
|
22991
24100
|
),
|
|
24101
|
+
(
|
|
24102
|
+
"widened-critical-statement-shapes",
|
|
24103
|
+
validate_widened_critical_statement_shapes_scenario,
|
|
24104
|
+
),
|
|
24105
|
+
(
|
|
24106
|
+
"covers-annotation-entry",
|
|
24107
|
+
validate_covers_annotation_entry_scenario,
|
|
24108
|
+
),
|
|
22992
24109
|
("expectation-completion-gates", validate_expectation_completion_gates_scenario),
|
|
22993
24110
|
("authoring-continuity", validate_authoring_continuity_scenario),
|
|
22994
24111
|
("domain-lenses", validate_domain_lenses_scenario),
|
|
@@ -23026,6 +24143,11 @@ SCENARIOS: tuple = (
|
|
|
23026
24143
|
"standing-authorization-never-weakens",
|
|
23027
24144
|
validate_standing_authorization_never_weakens_scenario,
|
|
23028
24145
|
),
|
|
24146
|
+
(
|
|
24147
|
+
"continuation-authorization",
|
|
24148
|
+
validate_continuation_authorization_scenario,
|
|
24149
|
+
),
|
|
24150
|
+
("continuation-docs", validate_continuation_docs_scenario),
|
|
23029
24151
|
(
|
|
23030
24152
|
"precedent-store-declaration",
|
|
23031
24153
|
validate_precedent_store_declaration_scenario,
|
|
@@ -23236,7 +24358,23 @@ SCENARIOS: tuple = (
|
|
|
23236
24358
|
"decimal-runs-are-not-hash-shaped",
|
|
23237
24359
|
validate_decimal_runs_are_not_hash_shaped_scenario,
|
|
23238
24360
|
),
|
|
24361
|
+
(
|
|
24362
|
+
"an-owner-outlives-the-change",
|
|
24363
|
+
validate_an_owner_outlives_the_change_scenario,
|
|
24364
|
+
),
|
|
24365
|
+
(
|
|
24366
|
+
"the-spec-names-the-managed-set",
|
|
24367
|
+
validate_the_spec_names_the_managed_set_scenario,
|
|
24368
|
+
),
|
|
23239
24369
|
("a-context-word-is-a-word", validate_a_context_word_is_a_word_scenario),
|
|
24370
|
+
(
|
|
24371
|
+
"a-quoted-span-is-not-a-claim",
|
|
24372
|
+
validate_a_quoted_span_is_not_a_claim_scenario,
|
|
24373
|
+
),
|
|
24374
|
+
(
|
|
24375
|
+
"a-submission-is-not-a-commit",
|
|
24376
|
+
validate_a_submission_is_not_a_commit_scenario,
|
|
24377
|
+
),
|
|
23240
24378
|
(
|
|
23241
24379
|
"a-covers-citation-is-not-a-record",
|
|
23242
24380
|
validate_a_covers_citation_is_not_a_record_scenario,
|