@christang/keel 5.56.0 → 5.58.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 +31 -0
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +61 -12
- 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 +487 -2
- package/src/core/gates.js +44 -2
- package/src/core/task-contract.js +106 -4
package/README.md
CHANGED
|
@@ -313,6 +313,37 @@ keel --uninstall # reverts core.hooksPath when Keel set it
|
|
|
313
313
|
`--with-git-hooks` is opt-in: a plain `keel --install` never touches git config, and the override
|
|
314
314
|
is repo-local and reversible.
|
|
315
315
|
|
|
316
|
+
## Declaring what a red proves
|
|
317
|
+
|
|
318
|
+
Red-green discipline makes you write a check that fails before the implementation exists, and
|
|
319
|
+
`keel gate task-complete` refuses a task whose `M<n>.red` Evidence is missing. What it could not
|
|
320
|
+
check was *why* the red failed — a red that fails because a fixture is empty, or because `PATH` was
|
|
321
|
+
never cleared, is shape-perfect.
|
|
322
|
+
|
|
323
|
+
A check may close by declaring the failure its red must show:
|
|
324
|
+
|
|
325
|
+
```
|
|
326
|
+
- M1: `npm test -- --scenario widget` asserts the widget renders. Fails with: `widget is undefined`
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Completion then requires that literal in the check's `.red` Evidence:
|
|
330
|
+
|
|
331
|
+
```
|
|
332
|
+
- M1.red: fail, for the right reason. The scenario reported `widget is undefined`.
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The clause lives inside the check text, so it is inside the contract fingerprint. That is the point:
|
|
336
|
+
a signature cannot be added or rewritten after the red was observed without the anchor moving, which
|
|
337
|
+
is what separates a prediction from a transcription. Keel does not judge whether the signature is a
|
|
338
|
+
good one — you can declare a string that any failure prints — it holds only that you wrote it first
|
|
339
|
+
and that review can see it.
|
|
340
|
+
|
|
341
|
+
The clause is optional; a check without one behaves exactly as before. A declaration on a check that
|
|
342
|
+
can have no red — one tagged `(regression)`, or any check under a strategy outside the red-green
|
|
343
|
+
set — fails `task-start` by name rather than sitting in the contract doing nothing. So does a
|
|
344
|
+
`Fails with:` marker that names no literal. To *write about* the marker in a check without declaring
|
|
345
|
+
one, put it in inline code.
|
|
346
|
+
|
|
316
347
|
## Domain lenses
|
|
317
348
|
|
|
318
349
|
Keel's core is pure process; it ships no domain knowledge and no decisions of its own. Alongside
|
package/bin/keel.js
CHANGED
|
@@ -671,16 +671,33 @@ function npmCommand() {
|
|
|
671
671
|
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
672
672
|
}
|
|
673
673
|
|
|
674
|
+
// npm hoists: installed as a dependency, Keel's OpenSpec bin lands in the
|
|
675
|
+
// consumer project's `node_modules/.bin`, and Keel's own package root has no
|
|
676
|
+
// `node_modules` at all. Searching only the package root worked in exactly one
|
|
677
|
+
// layout — a checkout of this repository, where Keel is the direct consumer —
|
|
678
|
+
// which is why every scenario stayed green while `keel openspec` failed on a
|
|
679
|
+
// plain install (issue #129). Walk outward the way Node resolves a module:
|
|
680
|
+
// nearest wins, so a project pinning its own OpenSpec is honored over one
|
|
681
|
+
// further up. The `.bin` entry is the published contract between npm and a
|
|
682
|
+
// consumer; resolving through the dependency's internal bin path instead would
|
|
683
|
+
// bind Keel to a path that belongs to the dependency.
|
|
684
|
+
function openspecBinNames() {
|
|
685
|
+
return process.platform === "win32"
|
|
686
|
+
? ["openspec.cmd", "openspec.exe", "openspec"]
|
|
687
|
+
: ["openspec"];
|
|
688
|
+
}
|
|
689
|
+
|
|
674
690
|
function openspecCandidates() {
|
|
675
|
-
const localBin = path.join(
|
|
676
|
-
PACKAGE_ROOT,
|
|
677
|
-
"node_modules",
|
|
678
|
-
".bin",
|
|
679
|
-
process.platform === "win32" ? "openspec.cmd" : "openspec"
|
|
680
|
-
);
|
|
681
691
|
const candidates = [];
|
|
682
|
-
|
|
683
|
-
|
|
692
|
+
let directory = PACKAGE_ROOT;
|
|
693
|
+
for (;;) {
|
|
694
|
+
for (const name of openspecBinNames()) {
|
|
695
|
+
const candidate = path.join(directory, "node_modules", ".bin", name);
|
|
696
|
+
if (fs.existsSync(candidate)) candidates.push(candidate);
|
|
697
|
+
}
|
|
698
|
+
const parent = path.dirname(directory);
|
|
699
|
+
if (parent === directory) break;
|
|
700
|
+
directory = parent;
|
|
684
701
|
}
|
|
685
702
|
candidates.push(process.platform === "win32" ? "openspec.cmd" : "openspec");
|
|
686
703
|
candidates.push("openspec");
|
|
@@ -765,6 +782,39 @@ function declaredOpenSpecVersion(repo) {
|
|
|
765
782
|
return { state: "none", version: null };
|
|
766
783
|
}
|
|
767
784
|
|
|
785
|
+
// "Not installed" and "installed and Keel cannot reach it" need different
|
|
786
|
+
// advice: reinstalling is the only remedy for the first and cannot help with the
|
|
787
|
+
// second, and issue #129 measured a whole class of installs being sent to it.
|
|
788
|
+
// The package directory is the evidence — it is present whenever npm placed the
|
|
789
|
+
// dependency, whatever happened to its `.bin` entry.
|
|
790
|
+
function installedOpenSpecPackage() {
|
|
791
|
+
let directory = PACKAGE_ROOT;
|
|
792
|
+
for (;;) {
|
|
793
|
+
const candidate = path.join(
|
|
794
|
+
directory,
|
|
795
|
+
"node_modules",
|
|
796
|
+
"@fission-ai",
|
|
797
|
+
"openspec",
|
|
798
|
+
"package.json"
|
|
799
|
+
);
|
|
800
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
801
|
+
const parent = path.dirname(directory);
|
|
802
|
+
if (parent === directory) return null;
|
|
803
|
+
directory = parent;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// The remedy for an openspec Keel could not run, split by whether npm has
|
|
808
|
+
// already done the thing the other branch would ask for.
|
|
809
|
+
function unresolvedOpenSpecAdvice() {
|
|
810
|
+
const installed = installedOpenSpecPackage();
|
|
811
|
+
return installed
|
|
812
|
+
? `the OpenSpec dependency is installed at ${path.dirname(installed)} but `
|
|
813
|
+
+ "Keel could not run it — check that its node_modules/.bin entry exists "
|
|
814
|
+
+ "and is executable; reinstalling will not change this"
|
|
815
|
+
: "reinstall keel so npm installs its OpenSpec dependency";
|
|
816
|
+
}
|
|
817
|
+
|
|
768
818
|
function findOpenSpecCommand() {
|
|
769
819
|
for (const command of openspecCandidates()) {
|
|
770
820
|
const status = runCommand(command, ["--version"], {
|
|
@@ -1606,8 +1656,8 @@ function runDoctor(options) {
|
|
|
1606
1656
|
if (!openspec) {
|
|
1607
1657
|
printDoctorLine(
|
|
1608
1658
|
"openspec",
|
|
1609
|
-
"missing",
|
|
1610
|
-
|
|
1659
|
+
installedOpenSpecPackage() ? "problem" : "missing",
|
|
1660
|
+
unresolvedOpenSpecAdvice()
|
|
1611
1661
|
);
|
|
1612
1662
|
} else {
|
|
1613
1663
|
const bareOpenSpecOnPath =
|
|
@@ -1967,8 +2017,7 @@ function runAction(options) {
|
|
|
1967
2017
|
const openspec = findOpenSpecCommand();
|
|
1968
2018
|
if (!openspec) {
|
|
1969
2019
|
process.stderr.write(
|
|
1970
|
-
|
|
1971
|
-
+ "its OpenSpec dependency\n"
|
|
2020
|
+
`keel: openspec is not resolvable; ${unresolvedOpenSpecAdvice()}\n`
|
|
1972
2021
|
);
|
|
1973
2022
|
return 1;
|
|
1974
2023
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.58.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.58.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.58.0"
|
|
41
|
+
PROTOCOL_VERSION = "5.58.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
|
|
@@ -24623,6 +24623,186 @@ def validate_validation_runner_scenario() -> int:
|
|
|
24623
24623
|
|
|
24624
24624
|
# The one ordered scenario registry: --scenario dispatch, the --all runner,
|
|
24625
24625
|
# and registration assertions all read this list and nothing else.
|
|
24626
|
+
|
|
24627
|
+
# 170 scenarios and every release gate ran green while `keel openspec` did not
|
|
24628
|
+
# resolve on a plain `npm install` (issue #129). The cause is a layout, not a
|
|
24629
|
+
# behavior: npm hoists, so the OpenSpec bin lands in the consumer project's
|
|
24630
|
+
# `node_modules/.bin` while the lookup only searched Keel's own package root —
|
|
24631
|
+
# which is where it sits in a checkout of this repository, and nowhere else.
|
|
24632
|
+
# So the assertion has to build the other layout rather than test harder in this
|
|
24633
|
+
# one. Built from the package's own files instead of the registry: what is under
|
|
24634
|
+
# test is where a file sits, and a suite that reaches the network fails for
|
|
24635
|
+
# reasons that have nothing to do with this code.
|
|
24636
|
+
def validate_dependency_resolves_where_npm_put_it_scenario() -> int:
|
|
24637
|
+
label = "the-dependency-resolves-where-npm-put-it"
|
|
24638
|
+
|
|
24639
|
+
def write_openspec_stub(bin_dir: Path, version: str) -> None:
|
|
24640
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
24641
|
+
if os.name == "nt":
|
|
24642
|
+
(bin_dir / "openspec.cmd").write_text(
|
|
24643
|
+
f"@echo off\necho {version}\n", encoding="utf-8"
|
|
24644
|
+
)
|
|
24645
|
+
return
|
|
24646
|
+
stub = bin_dir / "openspec"
|
|
24647
|
+
stub.write_text(f"#!/bin/sh\necho {version}\n", encoding="utf-8")
|
|
24648
|
+
stub.chmod(0o755)
|
|
24649
|
+
|
|
24650
|
+
def install_keel(package_root: Path) -> None:
|
|
24651
|
+
# The published `files` set, copied rather than packed: the tarball's
|
|
24652
|
+
# contents are what npm unpacks, and `npm pack` would add a network-free
|
|
24653
|
+
# but slow round trip through tar for no additional assertion.
|
|
24654
|
+
package_root.mkdir(parents=True, exist_ok=True)
|
|
24655
|
+
for entry in ("bin", "scripts", "src", "assets", "plugins"):
|
|
24656
|
+
source = ROOT / entry
|
|
24657
|
+
if source.exists():
|
|
24658
|
+
shutil.copytree(source, package_root / entry, dirs_exist_ok=True)
|
|
24659
|
+
shutil.copy2(ROOT / "package.json", package_root / "package.json")
|
|
24660
|
+
|
|
24661
|
+
# PATH without any openspec, so what resolves came from the layout and not
|
|
24662
|
+
# from the machine running the suite.
|
|
24663
|
+
def clean_env(extra_path: Path | None = None) -> dict[str, str]:
|
|
24664
|
+
env = dict(os.environ)
|
|
24665
|
+
entries = [
|
|
24666
|
+
entry
|
|
24667
|
+
for entry in env.get("PATH", "").split(os.pathsep)
|
|
24668
|
+
if entry and not (Path(entry) / "openspec").exists()
|
|
24669
|
+
]
|
|
24670
|
+
if extra_path is not None:
|
|
24671
|
+
entries.insert(0, str(extra_path))
|
|
24672
|
+
env["PATH"] = os.pathsep.join(entries)
|
|
24673
|
+
return env
|
|
24674
|
+
|
|
24675
|
+
def run_installed(
|
|
24676
|
+
package_root: Path, cwd: Path, *args: str, env: dict[str, str] | None = None
|
|
24677
|
+
) -> subprocess.CompletedProcess[str]:
|
|
24678
|
+
return subprocess.run(
|
|
24679
|
+
["node", str(package_root / "bin" / "keel.js"), *args],
|
|
24680
|
+
cwd=cwd,
|
|
24681
|
+
env=env if env is not None else clean_env(),
|
|
24682
|
+
text=True,
|
|
24683
|
+
encoding="utf-8",
|
|
24684
|
+
errors="replace",
|
|
24685
|
+
capture_output=True,
|
|
24686
|
+
check=False,
|
|
24687
|
+
)
|
|
24688
|
+
|
|
24689
|
+
def doctor_openspec_line(result: subprocess.CompletedProcess[str]) -> str:
|
|
24690
|
+
return next(
|
|
24691
|
+
(
|
|
24692
|
+
line
|
|
24693
|
+
for line in result.stdout.splitlines()
|
|
24694
|
+
if line.startswith("openspec:")
|
|
24695
|
+
),
|
|
24696
|
+
"",
|
|
24697
|
+
)
|
|
24698
|
+
|
|
24699
|
+
with tempfile.TemporaryDirectory(prefix="keel-hoisted-") as raw:
|
|
24700
|
+
root = Path(raw)
|
|
24701
|
+
|
|
24702
|
+
# The layout npm actually produces: Keel unpacked under the consumer's
|
|
24703
|
+
# node_modules with no node_modules of its own, the dependency's bin
|
|
24704
|
+
# hoisted beside it.
|
|
24705
|
+
project = root / "project"
|
|
24706
|
+
package_root = project / "node_modules/@christang/keel"
|
|
24707
|
+
install_keel(package_root)
|
|
24708
|
+
write_openspec_stub(project / "node_modules/.bin", "1.12.0")
|
|
24709
|
+
if (package_root / "node_modules").exists():
|
|
24710
|
+
report(
|
|
24711
|
+
f"{label}: the fixture gave Keel a node_modules of its own, "
|
|
24712
|
+
"which is the one layout the defect does not appear in."
|
|
24713
|
+
)
|
|
24714
|
+
return 1
|
|
24715
|
+
|
|
24716
|
+
proxied = run_installed(package_root, project, "openspec", "--version")
|
|
24717
|
+
if proxied.returncode != 0:
|
|
24718
|
+
report(
|
|
24719
|
+
f"{label}: `keel openspec` failed against the hoisted "
|
|
24720
|
+
f"dependency; exit {proxied.returncode}, stderr "
|
|
24721
|
+
f"{proxied.stderr.strip()!r}."
|
|
24722
|
+
)
|
|
24723
|
+
return 1
|
|
24724
|
+
if "1.12.0" not in proxied.stdout:
|
|
24725
|
+
report(
|
|
24726
|
+
f"{label}: `keel openspec` exited 0 without running the "
|
|
24727
|
+
f"hoisted dependency; stdout {proxied.stdout.strip()!r}."
|
|
24728
|
+
)
|
|
24729
|
+
return 1
|
|
24730
|
+
|
|
24731
|
+
hoisted_doctor = doctor_openspec_line(
|
|
24732
|
+
run_installed(package_root, project, "--doctor")
|
|
24733
|
+
)
|
|
24734
|
+
if hoisted_doctor.startswith("openspec: missing"):
|
|
24735
|
+
report(
|
|
24736
|
+
f"{label}: doctor reported an installed dependency as missing; "
|
|
24737
|
+
f"got {hoisted_doctor!r}."
|
|
24738
|
+
)
|
|
24739
|
+
return 1
|
|
24740
|
+
|
|
24741
|
+
# Nearest wins, so a project pinning its own OpenSpec is honored over
|
|
24742
|
+
# one further up the tree.
|
|
24743
|
+
nested = root / "outer/project"
|
|
24744
|
+
nested_package = nested / "node_modules/@christang/keel"
|
|
24745
|
+
install_keel(nested_package)
|
|
24746
|
+
write_openspec_stub(root / "outer/node_modules/.bin", "9.9.9")
|
|
24747
|
+
write_openspec_stub(nested / "node_modules/.bin", "1.12.0")
|
|
24748
|
+
nearest = run_installed(nested_package, nested, "openspec", "--version")
|
|
24749
|
+
if "1.12.0" not in nearest.stdout:
|
|
24750
|
+
report(
|
|
24751
|
+
f"{label}: the farther OpenSpec won over the nearer one; got "
|
|
24752
|
+
f"{nearest.stdout.strip()!r}."
|
|
24753
|
+
)
|
|
24754
|
+
return 1
|
|
24755
|
+
|
|
24756
|
+
# The one case where advising a reinstall is correct.
|
|
24757
|
+
bare = root / "bare"
|
|
24758
|
+
bare_package = bare / "node_modules/@christang/keel"
|
|
24759
|
+
install_keel(bare_package)
|
|
24760
|
+
absent = doctor_openspec_line(
|
|
24761
|
+
run_installed(bare_package, bare, "--doctor")
|
|
24762
|
+
)
|
|
24763
|
+
if not absent.startswith("openspec: missing"):
|
|
24764
|
+
report(
|
|
24765
|
+
f"{label}: doctor did not report a genuinely absent dependency "
|
|
24766
|
+
f"as missing; got {absent!r}."
|
|
24767
|
+
)
|
|
24768
|
+
return 1
|
|
24769
|
+
if "reinstall" not in absent:
|
|
24770
|
+
report(
|
|
24771
|
+
f"{label}: doctor stopped advising a reinstall in the one case "
|
|
24772
|
+
f"where it is the right advice; got {absent!r}."
|
|
24773
|
+
)
|
|
24774
|
+
return 1
|
|
24775
|
+
|
|
24776
|
+
# Installed and unreachable: the package is where npm puts it, its bin
|
|
24777
|
+
# entry is not. Sending this reader to reinstall is what issue #129
|
|
24778
|
+
# measured, and it is the one action that cannot help.
|
|
24779
|
+
broken = root / "broken"
|
|
24780
|
+
broken_package = broken / "node_modules/@christang/keel"
|
|
24781
|
+
install_keel(broken_package)
|
|
24782
|
+
write_text(
|
|
24783
|
+
broken / "node_modules/@fission-ai/openspec/package.json",
|
|
24784
|
+
'{"name": "@fission-ai/openspec", "version": "1.12.0"}\n',
|
|
24785
|
+
)
|
|
24786
|
+
unreachable = doctor_openspec_line(
|
|
24787
|
+
run_installed(broken_package, broken, "--doctor")
|
|
24788
|
+
)
|
|
24789
|
+
if "reinstall" in unreachable and "will not change this" not in unreachable:
|
|
24790
|
+
report(
|
|
24791
|
+
f"{label}: doctor advised reinstalling for a dependency that "
|
|
24792
|
+
f"is already installed; got {unreachable!r}."
|
|
24793
|
+
)
|
|
24794
|
+
return 1
|
|
24795
|
+
if "installed" not in unreachable:
|
|
24796
|
+
report(
|
|
24797
|
+
f"{label}: doctor did not distinguish an installed but "
|
|
24798
|
+
f"unreachable dependency from an absent one; got "
|
|
24799
|
+
f"{unreachable!r}."
|
|
24800
|
+
)
|
|
24801
|
+
return 1
|
|
24802
|
+
|
|
24803
|
+
report(f"{label} scenario passed.")
|
|
24804
|
+
return 0
|
|
24805
|
+
|
|
24626
24806
|
def validate_doctor_openspec_honesty_scenario() -> int:
|
|
24627
24807
|
cli = (ROOT / "bin/keel.js").read_text(encoding="utf-8")
|
|
24628
24808
|
if "is not on PATH" not in cli:
|
|
@@ -25020,6 +25200,306 @@ def problem_codes(payload: dict) -> list[str]:
|
|
|
25020
25200
|
return [str(entry.get("code", "")) for entry in (payload.get("problems") or [])]
|
|
25021
25201
|
|
|
25022
25202
|
|
|
25203
|
+
|
|
25204
|
+
# Red-green forces the expensive half — a check that fails before the
|
|
25205
|
+
# implementation exists — and enforces that a `.red` line is present. Nothing
|
|
25206
|
+
# checked that the red failed *because of the thing under test*. Issue #116
|
|
25207
|
+
# measured two reds in one session that were shape-perfect and failed for an
|
|
25208
|
+
# unrelated reason: one where the fixture had not cleared PATH so the check
|
|
25209
|
+
# passed, one where the fixture contained no wording the checker would refuse.
|
|
25210
|
+
# The declaration is what a gate can hold: written into the check, so it is in
|
|
25211
|
+
# the fingerprint, and therefore written before the run rather than after it.
|
|
25212
|
+
def validate_red_declares_what_it_proves_scenario() -> int:
|
|
25213
|
+
label = "a-red-declares-what-it-proves"
|
|
25214
|
+
signature = "openspec: not found"
|
|
25215
|
+
declared = (
|
|
25216
|
+
f"M1: node test.js asserts the public behavior. Fails with: `{signature}`",
|
|
25217
|
+
)
|
|
25218
|
+
plain = ("M1: node test.js asserts the public behavior",)
|
|
25219
|
+
with tempfile.TemporaryDirectory(prefix="keel-failswith-") as raw:
|
|
25220
|
+
root = Path(raw)
|
|
25221
|
+
|
|
25222
|
+
def start(name: str, **kwargs) -> dict:
|
|
25223
|
+
return strategy_probe_start(
|
|
25224
|
+
root, name, strategy_probe_task(**kwargs)
|
|
25225
|
+
)
|
|
25226
|
+
|
|
25227
|
+
def commands_of(payload: dict) -> list[dict]:
|
|
25228
|
+
contract = payload.get("contract") or {}
|
|
25229
|
+
capsule = contract.get("capsule") or {}
|
|
25230
|
+
return ((capsule.get("verification") or {}).get("commands") or [])
|
|
25231
|
+
|
|
25232
|
+
def fingerprint_of(payload: dict) -> str:
|
|
25233
|
+
return str((payload.get("contract") or {}).get("fingerprint") or "")
|
|
25234
|
+
|
|
25235
|
+
# A declared signature compiles and is carried as its own field, so the
|
|
25236
|
+
# gate can read it without re-parsing the author's prose.
|
|
25237
|
+
with_signature = start(
|
|
25238
|
+
"declared", strategy="vertical-tdd", commands=declared
|
|
25239
|
+
)
|
|
25240
|
+
if with_signature.get("status") != "pass":
|
|
25241
|
+
report(
|
|
25242
|
+
f"{label}: a check declaring a failure signature was refused; "
|
|
25243
|
+
f"got {with_signature.get('status')!r} "
|
|
25244
|
+
f"{problem_text(with_signature)!r}."
|
|
25245
|
+
)
|
|
25246
|
+
return 1
|
|
25247
|
+
compiled = commands_of(with_signature)
|
|
25248
|
+
if len(compiled) != 1 or compiled[0].get("failsWith") != signature:
|
|
25249
|
+
report(
|
|
25250
|
+
f"{label}: the compiled check does not carry the declared "
|
|
25251
|
+
f"failure signature; got {compiled!r}."
|
|
25252
|
+
)
|
|
25253
|
+
return 1
|
|
25254
|
+
# The clause is part of the check the author wrote, so it stays in
|
|
25255
|
+
# `check` as well; the field is what the gate reads, not a replacement
|
|
25256
|
+
# for the text.
|
|
25257
|
+
if "Fails with:" not in str(compiled[0].get("check", "")):
|
|
25258
|
+
report(
|
|
25259
|
+
f"{label}: compiling the signature stripped it out of the "
|
|
25260
|
+
f"check text the author wrote; got {compiled[0]!r}."
|
|
25261
|
+
)
|
|
25262
|
+
return 1
|
|
25263
|
+
|
|
25264
|
+
# In the fingerprint for free, which is what makes the declaration a
|
|
25265
|
+
# prediction rather than a transcription: it cannot be retrofitted to
|
|
25266
|
+
# whatever the run happened to print without the anchor moving.
|
|
25267
|
+
edited = start(
|
|
25268
|
+
"edited",
|
|
25269
|
+
strategy="vertical-tdd",
|
|
25270
|
+
commands=(
|
|
25271
|
+
"M1: node test.js asserts the public behavior. "
|
|
25272
|
+
"Fails with: `a different failure entirely`",
|
|
25273
|
+
),
|
|
25274
|
+
)
|
|
25275
|
+
without = start("without", strategy="vertical-tdd", commands=plain)
|
|
25276
|
+
prints = {
|
|
25277
|
+
"declared": fingerprint_of(with_signature),
|
|
25278
|
+
"edited": fingerprint_of(edited),
|
|
25279
|
+
"without": fingerprint_of(without),
|
|
25280
|
+
}
|
|
25281
|
+
if not all(prints.values()):
|
|
25282
|
+
report(f"{label}: a probe reported no fingerprint; got {prints!r}.")
|
|
25283
|
+
return 1
|
|
25284
|
+
if prints["declared"] == prints["edited"]:
|
|
25285
|
+
report(
|
|
25286
|
+
f"{label}: editing only the declared failure signature left "
|
|
25287
|
+
f"the fingerprint unmoved at {prints['declared']!r}, so a "
|
|
25288
|
+
"signature could be rewritten after the red was recorded."
|
|
25289
|
+
)
|
|
25290
|
+
return 1
|
|
25291
|
+
if prints["declared"] == prints["without"]:
|
|
25292
|
+
report(
|
|
25293
|
+
f"{label}: declaring a signature left the fingerprint "
|
|
25294
|
+
f"unmoved at {prints['without']!r}."
|
|
25295
|
+
)
|
|
25296
|
+
return 1
|
|
25297
|
+
|
|
25298
|
+
# A declaration that can produce no red is refused rather than left to
|
|
25299
|
+
# sit in the contract doing nothing, which reads to its author as a
|
|
25300
|
+
# check being enforced.
|
|
25301
|
+
for name, kwargs in (
|
|
25302
|
+
(
|
|
25303
|
+
"regression",
|
|
25304
|
+
dict(
|
|
25305
|
+
strategy="vertical-tdd",
|
|
25306
|
+
commands=(
|
|
25307
|
+
"M1: node test.js asserts the public behavior",
|
|
25308
|
+
"M2 (regression): the suite stays green. "
|
|
25309
|
+
f"Fails with: `{signature}`",
|
|
25310
|
+
),
|
|
25311
|
+
),
|
|
25312
|
+
),
|
|
25313
|
+
(
|
|
25314
|
+
"no-red-strategy",
|
|
25315
|
+
dict(
|
|
25316
|
+
strategy="evidence-first",
|
|
25317
|
+
reason="the artifact is inspected after it is written.",
|
|
25318
|
+
commands=declared,
|
|
25319
|
+
),
|
|
25320
|
+
),
|
|
25321
|
+
):
|
|
25322
|
+
payload = start(name, **kwargs)
|
|
25323
|
+
if payload.get("status") != "fail":
|
|
25324
|
+
report(
|
|
25325
|
+
f"{label}: a failure signature on a check with no red "
|
|
25326
|
+
f"({name}) was accepted; got {payload.get('status')!r}."
|
|
25327
|
+
)
|
|
25328
|
+
return 1
|
|
25329
|
+
text = problem_text(payload)
|
|
25330
|
+
expected_label = "M2" if name == "regression" else "M1"
|
|
25331
|
+
if expected_label not in text or "red" not in text:
|
|
25332
|
+
report(
|
|
25333
|
+
f"{label}: the refusal for {name} does not name the check "
|
|
25334
|
+
f"and say it records no red; got {text!r}."
|
|
25335
|
+
)
|
|
25336
|
+
return 1
|
|
25337
|
+
if "signature-without-red" not in problem_codes(payload):
|
|
25338
|
+
report(
|
|
25339
|
+
f"{label}: the refusal for {name} carries no "
|
|
25340
|
+
f"signature-without-red code; got "
|
|
25341
|
+
f"{problem_codes(payload)!r}."
|
|
25342
|
+
)
|
|
25343
|
+
return 1
|
|
25344
|
+
|
|
25345
|
+
# A marker that names no literal is the shape of a typo. Ignoring it
|
|
25346
|
+
# leaves the author believing a signature is enforced when none parsed.
|
|
25347
|
+
for name, check in (
|
|
25348
|
+
("bare", "M1: node test.js asserts the behavior. Fails with:"),
|
|
25349
|
+
(
|
|
25350
|
+
"unquoted",
|
|
25351
|
+
"M1: node test.js asserts the behavior. Fails with: not found",
|
|
25352
|
+
),
|
|
25353
|
+
(
|
|
25354
|
+
"not-closing",
|
|
25355
|
+
"M1: node test.js asserts it. Fails with: `not found` "
|
|
25356
|
+
"and then keeps going.",
|
|
25357
|
+
),
|
|
25358
|
+
):
|
|
25359
|
+
payload = start(name, strategy="vertical-tdd", commands=(check,))
|
|
25360
|
+
if payload.get("status") != "fail":
|
|
25361
|
+
report(
|
|
25362
|
+
f"{label}: a malformed signature marker ({name}) compiled "
|
|
25363
|
+
f"as if nothing were declared; got "
|
|
25364
|
+
f"{payload.get('status')!r}."
|
|
25365
|
+
)
|
|
25366
|
+
return 1
|
|
25367
|
+
if "malformed-failure-signature" not in problem_codes(payload):
|
|
25368
|
+
report(
|
|
25369
|
+
f"{label}: the refusal for {name} carries no "
|
|
25370
|
+
f"malformed-failure-signature code; got "
|
|
25371
|
+
f"{problem_codes(payload)!r}."
|
|
25372
|
+
)
|
|
25373
|
+
return 1
|
|
25374
|
+
|
|
25375
|
+
# The escape is the one this repository already established for quoted
|
|
25376
|
+
# material: a check that describes this rule has to be able to name it.
|
|
25377
|
+
quoted = start(
|
|
25378
|
+
"quoted",
|
|
25379
|
+
strategy="vertical-tdd",
|
|
25380
|
+
commands=(
|
|
25381
|
+
"M1: a `Fails with:` marker with no literal is refused by name",
|
|
25382
|
+
),
|
|
25383
|
+
)
|
|
25384
|
+
if quoted.get("status") != "pass":
|
|
25385
|
+
report(
|
|
25386
|
+
f"{label}: a marker written inside inline code was read as a "
|
|
25387
|
+
f"declaration; got {quoted.get('status')!r} "
|
|
25388
|
+
f"{problem_text(quoted)!r}."
|
|
25389
|
+
)
|
|
25390
|
+
return 1
|
|
25391
|
+
if commands_of(quoted)[0].get("failsWith") is not None:
|
|
25392
|
+
report(
|
|
25393
|
+
f"{label}: a marker inside inline code compiled to a failure "
|
|
25394
|
+
f"signature; got {commands_of(quoted)!r}."
|
|
25395
|
+
)
|
|
25396
|
+
return 1
|
|
25397
|
+
|
|
25398
|
+
# The obligation is stated where the author is deciding — before the
|
|
25399
|
+
# failing check is written — rather than discovered once it has run.
|
|
25400
|
+
warnings = " ".join(str(w) for w in (with_signature.get("warnings") or []))
|
|
25401
|
+
if signature not in warnings or "M1" not in warnings:
|
|
25402
|
+
report(
|
|
25403
|
+
f"{label}: the task-start obligation does not name which "
|
|
25404
|
+
f"checks declared a failure signature; got {warnings!r}."
|
|
25405
|
+
)
|
|
25406
|
+
return 1
|
|
25407
|
+
undeclared = " ".join(str(w) for w in (without.get("warnings") or []))
|
|
25408
|
+
if "declared no failure signature" not in undeclared:
|
|
25409
|
+
report(
|
|
25410
|
+
f"{label}: the obligation does not say that a check owing a "
|
|
25411
|
+
f"red declared no signature; got {undeclared!r}."
|
|
25412
|
+
)
|
|
25413
|
+
return 1
|
|
25414
|
+
|
|
25415
|
+
# Completion is where the declaration becomes a criterion.
|
|
25416
|
+
repo = root / "completion"
|
|
25417
|
+
tasks_path = repo / "openspec/changes/demo/tasks.md"
|
|
25418
|
+
|
|
25419
|
+
def complete(check: str, red: str) -> dict:
|
|
25420
|
+
write_gate_fixture(
|
|
25421
|
+
repo,
|
|
25422
|
+
tasks=strategy_probe_task(
|
|
25423
|
+
strategy="vertical-tdd", commands=(check,)
|
|
25424
|
+
).replace(
|
|
25425
|
+
" - M1: pending\n",
|
|
25426
|
+
f" - M1: the check ran.\n"
|
|
25427
|
+
f" - M1.red: {red}\n"
|
|
25428
|
+
" - M1.green: it passed after the implementation.\n",
|
|
25429
|
+
),
|
|
25430
|
+
)
|
|
25431
|
+
record_contract_anchor(repo, "demo")
|
|
25432
|
+
result = run_keel(
|
|
25433
|
+
repo,
|
|
25434
|
+
"gate",
|
|
25435
|
+
"task-complete",
|
|
25436
|
+
"--change",
|
|
25437
|
+
"demo",
|
|
25438
|
+
"--task",
|
|
25439
|
+
"1.1",
|
|
25440
|
+
"--json",
|
|
25441
|
+
)
|
|
25442
|
+
try:
|
|
25443
|
+
return json.loads(result.stdout)
|
|
25444
|
+
except json.JSONDecodeError:
|
|
25445
|
+
return {
|
|
25446
|
+
"status": "unparsed",
|
|
25447
|
+
"problems": [{"message": result.stdout[:400]}],
|
|
25448
|
+
}
|
|
25449
|
+
|
|
25450
|
+
declared_check = declared[0]
|
|
25451
|
+
mismatched = complete(
|
|
25452
|
+
declared_check, "it failed before the implementation existed."
|
|
25453
|
+
)
|
|
25454
|
+
if mismatched.get("status") != "fail":
|
|
25455
|
+
report(
|
|
25456
|
+
f"{label}: task-complete accepted a red that does not show "
|
|
25457
|
+
f"the declared failure; got {mismatched.get('status')!r}."
|
|
25458
|
+
)
|
|
25459
|
+
return 1
|
|
25460
|
+
text = problem_text(mismatched)
|
|
25461
|
+
if "M1" not in text or signature not in text:
|
|
25462
|
+
report(
|
|
25463
|
+
f"{label}: the refusal names neither the check nor the string "
|
|
25464
|
+
f"it did not find; got {text!r}."
|
|
25465
|
+
)
|
|
25466
|
+
return 1
|
|
25467
|
+
if "red-missing-declared-failure" not in problem_codes(mismatched):
|
|
25468
|
+
report(
|
|
25469
|
+
f"{label}: the refusal carries no "
|
|
25470
|
+
f"red-missing-declared-failure code; got "
|
|
25471
|
+
f"{problem_codes(mismatched)!r}."
|
|
25472
|
+
)
|
|
25473
|
+
return 1
|
|
25474
|
+
|
|
25475
|
+
matched = complete(
|
|
25476
|
+
declared_check,
|
|
25477
|
+
f"it failed with `{signature}` before the implementation existed.",
|
|
25478
|
+
)
|
|
25479
|
+
if matched.get("status") != "pass":
|
|
25480
|
+
report(
|
|
25481
|
+
f"{label}: a red showing the declared failure was refused; "
|
|
25482
|
+
f"got {matched.get('status')!r} {problem_text(matched)!r}."
|
|
25483
|
+
)
|
|
25484
|
+
return 1
|
|
25485
|
+
|
|
25486
|
+
# D3: the undeclared case is unchanged, asserted against the same
|
|
25487
|
+
# fixture with the clause removed and the same weak red that the
|
|
25488
|
+
# declared check was refused for.
|
|
25489
|
+
undeclared_task = complete(
|
|
25490
|
+
plain[0], "it failed before the implementation existed."
|
|
25491
|
+
)
|
|
25492
|
+
if undeclared_task.get("status") != "pass":
|
|
25493
|
+
report(
|
|
25494
|
+
f"{label}: a red-green task declaring no signature stopped "
|
|
25495
|
+
f"completing; got {undeclared_task.get('status')!r} "
|
|
25496
|
+
f"{problem_text(undeclared_task)!r}."
|
|
25497
|
+
)
|
|
25498
|
+
return 1
|
|
25499
|
+
|
|
25500
|
+
report(f"{label} scenario passed.")
|
|
25501
|
+
return 0
|
|
25502
|
+
|
|
25023
25503
|
# The strategy was the one capsule field the compiler supplied from a value no
|
|
25024
25504
|
# spec documents as a default — and it supplied the weakest of the six, so
|
|
25025
25505
|
# omitting the line was how a task opted out of red-green.
|
|
@@ -26539,9 +27019,14 @@ SCENARIOS: tuple = (
|
|
|
26539
27019
|
),
|
|
26540
27020
|
("cli", validate_cli_scenario),
|
|
26541
27021
|
("doctor-openspec-honesty", validate_doctor_openspec_honesty_scenario),
|
|
27022
|
+
(
|
|
27023
|
+
"the-dependency-resolves-where-npm-put-it",
|
|
27024
|
+
validate_dependency_resolves_where_npm_put_it_scenario,
|
|
27025
|
+
),
|
|
26542
27026
|
("the-marker-version-is-read", validate_marker_version_is_read_scenario),
|
|
26543
27027
|
("output-survives-the-pipe", validate_output_survives_the_pipe_scenario),
|
|
26544
27028
|
("a-strategy-is-declared", validate_strategy_is_declared_scenario),
|
|
27029
|
+
("a-red-declares-what-it-proves", validate_red_declares_what_it_proves_scenario),
|
|
26545
27030
|
("the-weakest-strategy-states-its-reason", validate_weakest_strategy_states_its_reason_scenario),
|
|
26546
27031
|
("a-quoted-marker-is-not-a-disposition", validate_quoted_marker_is_not_a_disposition_scenario),
|
|
26547
27032
|
("drift-names-where-to-look", validate_drift_names_where_to_look_scenario),
|
package/src/core/gates.js
CHANGED
|
@@ -232,9 +232,24 @@ function redGreenObligation(compiled) {
|
|
|
232
232
|
const strategy = compiled.capsule.verification.strategy.toLowerCase();
|
|
233
233
|
if (!RED_GREEN_VERIFICATION_STRATEGIES.has(strategy)) return [];
|
|
234
234
|
const commands = compiled.capsule.verification.commands;
|
|
235
|
-
const
|
|
235
|
+
const owingChecks = commands.filter((item) => !item.regression);
|
|
236
|
+
const owing = owingChecks.map((item) => item.label);
|
|
236
237
|
const exempt = commands.filter((item) => item.regression).map((item) => item.label);
|
|
237
238
|
if (owing.length === 0) return [];
|
|
239
|
+
// What a red proves is only checkable if it was written down before the red
|
|
240
|
+
// was run, so the moment to say so is here — the author is about to write the
|
|
241
|
+
// failing check — and not at completion, where any signature is a
|
|
242
|
+
// transcription of whatever already failed (issue #116).
|
|
243
|
+
const signed = owingChecks.filter((item) => item.failsWith);
|
|
244
|
+
const unsigned = owingChecks.filter((item) => !item.failsWith).map((item) => item.label);
|
|
245
|
+
const signatures = signed.length > 0
|
|
246
|
+
? ` ${signed.map((item) => `${item.label} must fail with \`${item.failsWith}\``).join("; ")}.`
|
|
247
|
+
+ (unsigned.length > 0
|
|
248
|
+
? ` ${unsigned.join(", ")} declared no failure signature.`
|
|
249
|
+
: "")
|
|
250
|
+
: ` ${owing.join(", ")} declared no failure signature — close the check with `
|
|
251
|
+
+ "`Fails with:` and the literal its red should print, and the red that "
|
|
252
|
+
+ "gets recorded has to be the one you predicted.";
|
|
238
253
|
return [
|
|
239
254
|
`${strategy} will require concrete .red and .green Evidence at completion `
|
|
240
255
|
+ `for ${owing.join(", ")}`
|
|
@@ -244,7 +259,8 @@ function redGreenObligation(compiled) {
|
|
|
244
259
|
: "")
|
|
245
260
|
+ ". Tag a check `(regression)` now if it asserts that something already "
|
|
246
261
|
+ "green stays green, rather than discovering the obligation once the "
|
|
247
|
-
+ "checks have been run."
|
|
262
|
+
+ "checks have been run."
|
|
263
|
+
+ signatures,
|
|
248
264
|
];
|
|
249
265
|
}
|
|
250
266
|
|
|
@@ -1011,8 +1027,34 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
|
|
|
1011
1027
|
.filter((entry) => entry.regression)
|
|
1012
1028
|
.map((entry) => entry.label)
|
|
1013
1029
|
);
|
|
1030
|
+
// A check may declare the failure its red must show. Enforced against the
|
|
1031
|
+
// recorded `.red`, which is where the shape check stopped: a red that
|
|
1032
|
+
// failed for an unrelated reason satisfied the presence check exactly as
|
|
1033
|
+
// well as one that failed for the right reason (issue #116). Keel does not
|
|
1034
|
+
// judge whether the declared string is a good one — what it holds is that
|
|
1035
|
+
// the string is in the contract, so it was written before the run.
|
|
1036
|
+
const declaredFailure = new Map(
|
|
1037
|
+
(contract ? contract.capsule.verification.commands : [])
|
|
1038
|
+
.filter((entry) => entry.failsWith)
|
|
1039
|
+
.map((entry) => [entry.label, entry.failsWith])
|
|
1040
|
+
);
|
|
1014
1041
|
for (const label of commands) {
|
|
1015
1042
|
if (exempt.has(label)) continue;
|
|
1043
|
+
const expected = declaredFailure.get(label);
|
|
1044
|
+
const recorded = evidenceValue(task, `${label}.red`);
|
|
1045
|
+
if (expected && isConcrete(recorded) && !String(recorded).includes(expected)) {
|
|
1046
|
+
problems.push(
|
|
1047
|
+
problem(
|
|
1048
|
+
"red-missing-declared-failure",
|
|
1049
|
+
`${label} declares that its red fails with \`${expected}\`, and `
|
|
1050
|
+
+ `the recorded ${label}.red Evidence does not contain that `
|
|
1051
|
+
+ "string. Record the failure output the red actually printed, "
|
|
1052
|
+
+ "or correct the declaration — which moves the contract "
|
|
1053
|
+
+ "fingerprint, because a signature edited after the red was "
|
|
1054
|
+
+ "observed is a transcription of it.",
|
|
1055
|
+
)
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1016
1058
|
for (const phase of ["red", "green"]) {
|
|
1017
1059
|
if (!isConcrete(evidenceValue(task, `${label}.${phase}`))) {
|
|
1018
1060
|
problems.push(
|
|
@@ -153,6 +153,31 @@ const RED_GREEN_VERIFICATION_STRATEGIES = new Set([
|
|
|
153
153
|
// Tags an M<n> check may carry after its label, as a comma-separated set.
|
|
154
154
|
const COMMAND_TAGS = new Set(["fast", "full", "regression"]);
|
|
155
155
|
|
|
156
|
+
// A check may end by declaring the failure its red is expected to show, so that
|
|
157
|
+
// what the red proves is written down before the red is run. The clause closes
|
|
158
|
+
// the check: `Fails with:` followed by one inline-code literal and nothing more.
|
|
159
|
+
// End-anchored on purpose — a check that describes this rule mentions the marker
|
|
160
|
+
// mid-sentence, and a mention is not a declaration.
|
|
161
|
+
const FAILURE_SIGNATURE = /\bFails with:[ \t]*`([^`\n]+)`[ \t]*$/i;
|
|
162
|
+
const FAILURE_MARKER = /\bFails with:/i;
|
|
163
|
+
|
|
164
|
+
// Classify a check's `Fails with:` marker. `signature` is the declared literal;
|
|
165
|
+
// `malformed` marks a marker that is present and is not a closing clause — a
|
|
166
|
+
// typo shape, and ignoring it would leave the author believing a signature is
|
|
167
|
+
// enforced when none was parsed. A marker written inside inline code is quoted
|
|
168
|
+
// material rather than a declaration, the meaning inline code already carries
|
|
169
|
+
// here, which is what lets this file's own tasks name the marker.
|
|
170
|
+
function failureSignature(check) {
|
|
171
|
+
const text = String(check || "");
|
|
172
|
+
const match = text.match(FAILURE_SIGNATURE);
|
|
173
|
+
if (match) return { signature: match[1].trim(), malformed: false };
|
|
174
|
+
const remainder = withoutInlineCode(text);
|
|
175
|
+
return {
|
|
176
|
+
signature: null,
|
|
177
|
+
malformed: FAILURE_MARKER.test(remainder),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
156
181
|
// Single source of truth for the accepted completion Review `Status`
|
|
157
182
|
// vocabulary. Consumed by both the completion gate (src/core/gates.js) and the
|
|
158
183
|
// context "already reviewed" probe (src/core/context.js) so the two never
|
|
@@ -193,19 +218,39 @@ function verification(task) {
|
|
|
193
218
|
// therefore exempt from the red-green evidence requirement. A check may
|
|
194
219
|
// carry both, so the tag is a comma-separated set rather than one word.
|
|
195
220
|
const match = entry.match(/^(M[1-9]\d*)(?:\s*\(([^)\n]*)\))?:\s*(.*)$/);
|
|
196
|
-
if (!match)
|
|
221
|
+
if (!match) {
|
|
222
|
+
return {
|
|
223
|
+
label: null,
|
|
224
|
+
layer: "full",
|
|
225
|
+
regression: false,
|
|
226
|
+
check: entry,
|
|
227
|
+
failsWith: null,
|
|
228
|
+
malformedSignature: false,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
197
231
|
const tags = (match[2] || "")
|
|
198
232
|
.split(",")
|
|
199
233
|
.map((tag) => tag.trim().toLowerCase())
|
|
200
234
|
.filter(Boolean);
|
|
201
235
|
if (tags.some((tag) => !COMMAND_TAGS.has(tag))) {
|
|
202
|
-
return {
|
|
236
|
+
return {
|
|
237
|
+
label: null,
|
|
238
|
+
layer: "full",
|
|
239
|
+
regression: false,
|
|
240
|
+
check: entry,
|
|
241
|
+
failsWith: null,
|
|
242
|
+
malformedSignature: false,
|
|
243
|
+
};
|
|
203
244
|
}
|
|
245
|
+
const check = normalizeText(match[3]);
|
|
246
|
+
const failure = failureSignature(check);
|
|
204
247
|
return {
|
|
205
248
|
label: match[1],
|
|
206
249
|
layer: tags.includes("fast") ? "fast" : "full",
|
|
207
250
|
regression: tags.includes("regression"),
|
|
208
|
-
check
|
|
251
|
+
check,
|
|
252
|
+
failsWith: failure.signature,
|
|
253
|
+
malformedSignature: failure.malformed,
|
|
209
254
|
};
|
|
210
255
|
});
|
|
211
256
|
return {
|
|
@@ -367,7 +412,60 @@ function taskStartContractProblems(task) {
|
|
|
367
412
|
...regressionOnlyProblems(task),
|
|
368
413
|
];
|
|
369
414
|
}
|
|
370
|
-
return [
|
|
415
|
+
return [
|
|
416
|
+
...commandLabelProblems(task),
|
|
417
|
+
...regressionOnlyProblems(task),
|
|
418
|
+
...failureSignatureProblems(task),
|
|
419
|
+
];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// A declared failure signature describes the check's red. A `(regression)` check
|
|
423
|
+
// is exempt from red-green and a strategy outside the red-green set records no
|
|
424
|
+
// `.red` at all, so in both cases the declaration describes evidence that will
|
|
425
|
+
// never exist. Refused rather than left sitting in the contract: a declaration
|
|
426
|
+
// doing nothing reads to its author as a check being enforced.
|
|
427
|
+
function failureSignatureProblems(task) {
|
|
428
|
+
const parsed = verification(task);
|
|
429
|
+
const strategy = parsed.strategy.toLowerCase();
|
|
430
|
+
const redGreen = RED_GREEN_VERIFICATION_STRATEGIES.has(strategy);
|
|
431
|
+
const problems = [];
|
|
432
|
+
for (const entry of parsed.commands) {
|
|
433
|
+
if (!entry.label) continue;
|
|
434
|
+
if (entry.malformedSignature) {
|
|
435
|
+
problems.push({
|
|
436
|
+
code: "malformed-failure-signature",
|
|
437
|
+
message:
|
|
438
|
+
`${entry.label} carries a \`Fails with:\` marker that does not close `
|
|
439
|
+
+ "the check with a literal. Write the failure signature as one "
|
|
440
|
+
+ "inline-code literal at the end of the check, or fence the marker "
|
|
441
|
+
+ "in inline code when the check is describing it rather than "
|
|
442
|
+
+ "declaring one.",
|
|
443
|
+
});
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (!entry.failsWith) continue;
|
|
447
|
+
if (!redGreen) {
|
|
448
|
+
problems.push({
|
|
449
|
+
code: "signature-without-red",
|
|
450
|
+
message:
|
|
451
|
+
`${entry.label} declares the failure its red must show, but `
|
|
452
|
+
+ `\`${parsed.strategy}\` records no red for the signature to `
|
|
453
|
+
+ "describe. Name a red-green strategy, or drop the clause.",
|
|
454
|
+
});
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (entry.regression) {
|
|
458
|
+
problems.push({
|
|
459
|
+
code: "signature-without-red",
|
|
460
|
+
message:
|
|
461
|
+
`${entry.label} declares the failure its red must show, but it is `
|
|
462
|
+
+ "tagged `(regression)` and so records no red for the signature to "
|
|
463
|
+
+ "describe. Untag the check if it proves new behavior, or drop the "
|
|
464
|
+
+ "clause.",
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return problems;
|
|
371
469
|
}
|
|
372
470
|
|
|
373
471
|
// A red-green strategy whose every check is exempt from red-green is that
|
|
@@ -1127,6 +1225,10 @@ function compileTaskContract(repo, change, task) {
|
|
|
1127
1225
|
const emitted = { label: entry.label, check: entry.check };
|
|
1128
1226
|
if (entry.layer && entry.layer !== "full") emitted.layer = entry.layer;
|
|
1129
1227
|
if (entry.regression) emitted.regression = true;
|
|
1228
|
+
// The clause stays in `check` — it is text the author wrote — and the
|
|
1229
|
+
// field is what the gate reads. Emitted only when declared, so every
|
|
1230
|
+
// check without one keeps the capsule shape and fingerprint it had.
|
|
1231
|
+
if (entry.failsWith) emitted.failsWith = entry.failsWith;
|
|
1130
1232
|
return emitted;
|
|
1131
1233
|
}),
|
|
1132
1234
|
},
|