@christang/keel 5.3.7 → 5.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/bootstrap/AGENTS.md +2 -2
- package/assets/openspec/schemas/keel-spec-driven/schema.yaml +4 -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/scripts/pretooluse-guard.js +21 -12
- package/scripts/validate_plugin.py +543 -4
- package/src/core/gates.js +30 -7
- package/src/core/task-contract.js +15 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
<!-- keel:start version=5.3.
|
|
1
|
+
<!-- keel:start version=5.3.9 -->
|
|
2
2
|
## Keel Bootstrap
|
|
3
3
|
|
|
4
4
|
- Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
|
|
5
|
-
- Obey the selected task capsule: `keel gate task-start` before implementing, record its fingerprint
|
|
5
|
+
- Obey the selected task capsule: `keel gate task-start` before implementing, `--record` its fingerprint to Evidence `Contract`, which `task-complete` requires, before checking complete. Touch bounds product writes; the change's own dir is exempt. On Claude a passing `task-start` guards it by default (`--no-guard` opts out).
|
|
6
6
|
- One current agent owns writes; helpers return read-only report/evidence only. No commit, sync, or archive without explicit authorization.
|
|
7
7
|
- Native plugin projections (SessionStart context) are disposable views, never authority; without the plugin or hook, run the commands manually.
|
|
8
8
|
- Keel skills and hooks come from the `keel` native plugin (`codex plugin add` / `claude plugin install`); `keel --init` owns only the OpenSpec schema, overlays, and this bootstrap.
|
|
@@ -75,7 +75,10 @@ artifacts:
|
|
|
75
75
|
Every executable task MUST be a checkbox authored in the compact v4 form
|
|
76
76
|
from the template: Covers, Touch, Verify (Strategy plus ordered M<n>
|
|
77
77
|
public checks), and an Evidence anchor with its Contract line, per-check
|
|
78
|
-
results, semantic Review, and Blocker state.
|
|
78
|
+
results, semantic Review, and Blocker state. `task-start --record` writes
|
|
79
|
+
the compiled fingerprint into that Contract line, and task-complete
|
|
80
|
+
refuses a task that recorded none: without an anchor there is nothing for
|
|
81
|
+
completion to compare against, so the task has no drift detection. Omitted fields inherit
|
|
79
82
|
versioned keel-task-capsule/v1 defaults: Owner is the current Keel
|
|
80
83
|
agent, Mode is implementation, Read is the change artifacts plus
|
|
81
84
|
discovered repository context, Acceptance derives from the resolved
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.9",
|
|
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.
|
|
3
|
+
"version": "5.3.9",
|
|
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",
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// file edits while an explicit keel/guard.json manifest is active. Absence of
|
|
6
6
|
// the manifest allows everything silently; a present-but-untrusted manifest
|
|
7
7
|
// fails closed. The hook never writes state, never spawns the keel CLI, and
|
|
8
|
-
// always exits 0 — denial is expressed only through hook output.
|
|
9
|
-
//
|
|
8
|
+
// always exits 0 — denial is expressed only through hook output. The
|
|
9
|
+
// repository is the guard's scope: a path resolving outside it is not a product
|
|
10
|
+
// write and passes through, decided before the manifest is read so that no
|
|
11
|
+
// manifest state can reach it.
|
|
10
12
|
|
|
11
13
|
const crypto = require("crypto");
|
|
12
14
|
const fs = require("fs");
|
|
@@ -116,12 +118,27 @@ function main() {
|
|
|
116
118
|
}
|
|
117
119
|
const repo =
|
|
118
120
|
typeof event.cwd === "string" && event.cwd ? event.cwd : process.cwd();
|
|
119
|
-
const manifestPath = path.join(repo, "keel", "guard.json");
|
|
120
|
-
if (!fs.existsSync(manifestPath)) return 0;
|
|
121
121
|
|
|
122
122
|
const pathField = FILE_EDIT_TOOLS.get(event.tool_name);
|
|
123
123
|
if (!pathField) return 0;
|
|
124
124
|
|
|
125
|
+
// The repository is the guard's scope, so this boundary is settled before the
|
|
126
|
+
// manifest is read at all. It needs only the event's cwd and target, so no
|
|
127
|
+
// manifest state — absent, invalid, drifted, or completed — has anything to
|
|
128
|
+
// say about a path outside it. Deciding it here rather than further down is
|
|
129
|
+
// what stops a branch added to the manifest section from denying a file the
|
|
130
|
+
// guard never protected; that ordering has already failed twice.
|
|
131
|
+
const target = event.tool_input ? event.tool_input[pathField] : null;
|
|
132
|
+
if (typeof target !== "string" || !target) return 0;
|
|
133
|
+
const relative = path.relative(repo, path.resolve(repo, target));
|
|
134
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
const candidate = relative.replace(/\\/g, "/");
|
|
138
|
+
|
|
139
|
+
const manifestPath = path.join(repo, "keel", "guard.json");
|
|
140
|
+
if (!fs.existsSync(manifestPath)) return 0;
|
|
141
|
+
|
|
125
142
|
let manifest = null;
|
|
126
143
|
try {
|
|
127
144
|
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
@@ -144,14 +161,6 @@ function main() {
|
|
|
144
161
|
// stop the one thing the completion gate is waiting for. Derived from the
|
|
145
162
|
// manifest's existing `change` field, so no manifest shape changes.
|
|
146
163
|
const recordPrefix = `openspec/changes/${manifest.change}/`;
|
|
147
|
-
|
|
148
|
-
const target = event.tool_input ? event.tool_input[pathField] : null;
|
|
149
|
-
if (typeof target !== "string" || !target) return 0;
|
|
150
|
-
const relative = path.relative(repo, path.resolve(repo, target));
|
|
151
|
-
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
152
|
-
return 0;
|
|
153
|
-
}
|
|
154
|
-
const candidate = relative.replace(/\\/g, "/");
|
|
155
164
|
if (candidate.startsWith(recordPrefix)) return 0;
|
|
156
165
|
|
|
157
166
|
for (const entry of manifest.authority) {
|
|
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
|
|
|
37
37
|
"scripts/validate_plugin.py",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
-
PACKAGE_VERSION = "5.3.
|
|
41
|
-
PROTOCOL_VERSION = "5.3.
|
|
40
|
+
PACKAGE_VERSION = "5.3.9"
|
|
41
|
+
PROTOCOL_VERSION = "5.3.9"
|
|
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
|
|
@@ -3509,6 +3509,11 @@ def task_contract_fixture(
|
|
|
3509
3509
|
" - Stop Rules:\n"
|
|
3510
3510
|
" - Stop on failure.\n"
|
|
3511
3511
|
" - Evidence:\n"
|
|
3512
|
+
# Emitted unconditionally rather than through `evidence`, which callers
|
|
3513
|
+
# override to control the M-entries. Completion requires a recorded
|
|
3514
|
+
# fingerprint, and `--record` rewrites this line in place, so a caller
|
|
3515
|
+
# that customizes its evidence must not be able to drop the anchor.
|
|
3516
|
+
" - Contract: pending\n"
|
|
3512
3517
|
f"{evidence_lines}"
|
|
3513
3518
|
" - Review:\n"
|
|
3514
3519
|
" - Status: pending\n"
|
|
@@ -3545,7 +3550,7 @@ def invalidation_repo(root: Path, name: str, section: str | None) -> Path:
|
|
|
3545
3550
|
# A real Contract anchor, so the missing-section case can prove that a
|
|
3546
3551
|
# failing authoring gate leaves the anchor untouched rather than merely
|
|
3547
3552
|
# failing earlier for want of one.
|
|
3548
|
-
body = task_contract_fixture(
|
|
3553
|
+
body = task_contract_fixture()
|
|
3549
3554
|
body = body.replace(INVALIDATES_NONE, "" if section is None else section)
|
|
3550
3555
|
write_text(repo / "openspec/changes/demo/tasks.md", body)
|
|
3551
3556
|
return repo
|
|
@@ -3781,7 +3786,7 @@ def validate_anchor_reverification_bound_scenario() -> int:
|
|
|
3781
3786
|
repo = Path(raw_tmp) / "repo"
|
|
3782
3787
|
repo.mkdir()
|
|
3783
3788
|
live = repo / "openspec/changes/demo/tasks.md"
|
|
3784
|
-
write_text(live, task_contract_fixture(
|
|
3789
|
+
write_text(live, task_contract_fixture())
|
|
3785
3790
|
|
|
3786
3791
|
recorded = run_keel(
|
|
3787
3792
|
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
@@ -3993,6 +3998,7 @@ def validate_durable_owner_vocabulary_scenario() -> int:
|
|
|
3993
3998
|
)
|
|
3994
3999
|
)
|
|
3995
4000
|
write_text(tasks_path, fixture)
|
|
4001
|
+
record_contract_anchor(repo, "demo")
|
|
3996
4002
|
result = run_keel(
|
|
3997
4003
|
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
|
|
3998
4004
|
)
|
|
@@ -4138,6 +4144,7 @@ def validate_regression_check_tag_scenario() -> int:
|
|
|
4138
4144
|
|
|
4139
4145
|
def complete(fixture: str) -> dict:
|
|
4140
4146
|
write_text(tasks_path, fixture)
|
|
4147
|
+
record_contract_anchor(repo, "demo")
|
|
4141
4148
|
result = run_keel(
|
|
4142
4149
|
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
|
|
4143
4150
|
)
|
|
@@ -4654,12 +4661,39 @@ def task_capsule_compact_fixture() -> str:
|
|
|
4654
4661
|
" - Stop Rules:\n"
|
|
4655
4662
|
" - Stop on failure.\n"
|
|
4656
4663
|
" - Evidence:\n"
|
|
4664
|
+
# Completion requires a recorded fingerprint here, so the anchor line
|
|
4665
|
+
# must exist for `--record` to rewrite in place. See
|
|
4666
|
+
# record_contract_anchor.
|
|
4667
|
+
" - Contract: pending\n"
|
|
4657
4668
|
" - M1: pending\n"
|
|
4658
4669
|
" - Stop if:\n"
|
|
4659
4670
|
" - Requires files outside Touch.\n"
|
|
4660
4671
|
)
|
|
4661
4672
|
|
|
4662
4673
|
|
|
4674
|
+
def record_contract_anchor(repo: Path, change: str, task: str = "1.1") -> bool:
|
|
4675
|
+
"""Run `task-start --record` so a fixture can reach completion.
|
|
4676
|
+
|
|
4677
|
+
Completion refuses a task whose `Contract` anchor holds no compiled
|
|
4678
|
+
fingerprint (issue #30), so a scenario that only wanted to exercise
|
|
4679
|
+
task-complete still has to start the task first. That is the real loop, not
|
|
4680
|
+
a workaround: a task that was never started is not a task being completed.
|
|
4681
|
+
"""
|
|
4682
|
+
result = run_keel(
|
|
4683
|
+
repo,
|
|
4684
|
+
"gate",
|
|
4685
|
+
"task-start",
|
|
4686
|
+
"--change",
|
|
4687
|
+
change,
|
|
4688
|
+
"--task",
|
|
4689
|
+
task,
|
|
4690
|
+
"--record",
|
|
4691
|
+
"--no-guard",
|
|
4692
|
+
"--json",
|
|
4693
|
+
)
|
|
4694
|
+
return result.returncode == 0
|
|
4695
|
+
|
|
4696
|
+
|
|
4663
4697
|
def validate_non_concrete_verify_diagnostic_scenario() -> int:
|
|
4664
4698
|
"""A compact v4 task whose Verify carries an unfilled token must be told so.
|
|
4665
4699
|
|
|
@@ -5098,6 +5132,475 @@ def fill_template_slots(text: str, comments: str = "strip") -> str:
|
|
|
5098
5132
|
text = collapsed
|
|
5099
5133
|
|
|
5100
5134
|
|
|
5135
|
+
def validate_guard_scope_is_the_repository_scenario() -> int:
|
|
5136
|
+
"""Issue #31: a decision needing no manifest sat downstream of reading one.
|
|
5137
|
+
|
|
5138
|
+
Whether a target lies outside the repository is computable from the event's
|
|
5139
|
+
cwd and target path alone, but the invalid-manifest denial ran first, so a
|
|
5140
|
+
corrupt `keel/guard.json` denied writes to files the guard never protected.
|
|
5141
|
+
The precedence is what is asserted here, not the passthrough: a scenario
|
|
5142
|
+
checking only that an out-of-repo path passes under a valid manifest would
|
|
5143
|
+
have passed before this change too.
|
|
5144
|
+
"""
|
|
5145
|
+
hook = ROOT / "plugins/keel/scripts/pretooluse-guard.js"
|
|
5146
|
+
if not hook.is_file():
|
|
5147
|
+
report(f"guard-scope-is-the-repository: missing hook {hook}.")
|
|
5148
|
+
return 1
|
|
5149
|
+
|
|
5150
|
+
with tempfile.TemporaryDirectory(prefix="keel-guard-scope-") as raw:
|
|
5151
|
+
root = Path(raw)
|
|
5152
|
+
repo = root / "repo"
|
|
5153
|
+
outside = root / "scratch"
|
|
5154
|
+
outside.mkdir(parents=True)
|
|
5155
|
+
write_text(repo / "src/feature.js", "// product\n")
|
|
5156
|
+
# A live spec, so the Covers source lands outside the record layer and
|
|
5157
|
+
# authority drift can actually fire. Drifting the change's own tasks.md
|
|
5158
|
+
# produces no drift at all, because the record layer exempts it.
|
|
5159
|
+
live = repo / "openspec/specs/demo-cap/spec.md"
|
|
5160
|
+
write_text(
|
|
5161
|
+
live,
|
|
5162
|
+
"# demo-cap\n\n## Purpose\n\nFixture.\n\n"
|
|
5163
|
+
"### Requirement: The system emits a feed status\n"
|
|
5164
|
+
"The system SHALL emit the recorded feed status.\n\n"
|
|
5165
|
+
"#### Scenario: A status is emitted\n"
|
|
5166
|
+
"- **WHEN** the feed runs\n- **THEN** the status is recorded\n",
|
|
5167
|
+
)
|
|
5168
|
+
write_text(
|
|
5169
|
+
repo / "openspec/changes/demo/tasks.md",
|
|
5170
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5171
|
+
"- [ ] 1.1 Exercise the guard\n"
|
|
5172
|
+
" - Covers:\n - demo-cap / The system emits a feed status\n"
|
|
5173
|
+
" - Touch:\n - src/feature.js\n"
|
|
5174
|
+
" - Verify:\n - Strategy: evidence-first\n - M1: node test.js\n"
|
|
5175
|
+
" - Evidence:\n - Contract: pending\n - M1: pending\n"
|
|
5176
|
+
" - Review:\n - Status: pending\n"
|
|
5177
|
+
" - Acceptance check: pending\n - Scope check: pending\n"
|
|
5178
|
+
" - Findings: pending\n - Blocker: none\n",
|
|
5179
|
+
)
|
|
5180
|
+
started = run_keel(
|
|
5181
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
5182
|
+
"--record", "--json",
|
|
5183
|
+
)
|
|
5184
|
+
if started.returncode != 0:
|
|
5185
|
+
report("guard-scope-is-the-repository: the fixture did not start.")
|
|
5186
|
+
report((started.stdout or started.stderr).strip())
|
|
5187
|
+
return 1
|
|
5188
|
+
manifest_path = repo / "keel/guard.json"
|
|
5189
|
+
manifest_text = manifest_path.read_text(encoding="utf-8")
|
|
5190
|
+
hashed = [
|
|
5191
|
+
item["path"] for item in json.loads(manifest_text).get("authority", [])
|
|
5192
|
+
]
|
|
5193
|
+
if "openspec/specs/demo-cap/spec.md" not in hashed:
|
|
5194
|
+
report(
|
|
5195
|
+
"guard-scope-is-the-repository: the fixture hashed no authority "
|
|
5196
|
+
f"outside the change directory, so drift cannot fire: {hashed}."
|
|
5197
|
+
)
|
|
5198
|
+
return 1
|
|
5199
|
+
|
|
5200
|
+
def decide(target: Path) -> str:
|
|
5201
|
+
event = json.dumps({
|
|
5202
|
+
"cwd": str(repo),
|
|
5203
|
+
"tool_name": "Edit",
|
|
5204
|
+
"tool_input": {"file_path": str(target)},
|
|
5205
|
+
})
|
|
5206
|
+
result = subprocess.run(
|
|
5207
|
+
["node", str(hook)],
|
|
5208
|
+
input=event,
|
|
5209
|
+
capture_output=True,
|
|
5210
|
+
text=True,
|
|
5211
|
+
encoding="utf-8",
|
|
5212
|
+
errors="replace",
|
|
5213
|
+
check=False,
|
|
5214
|
+
)
|
|
5215
|
+
out = (result.stdout or "").strip()
|
|
5216
|
+
if not out:
|
|
5217
|
+
return "allow"
|
|
5218
|
+
payload = json.loads(out)["hookSpecificOutput"]
|
|
5219
|
+
return payload.get("permissionDecisionReason", "deny")
|
|
5220
|
+
|
|
5221
|
+
def expect(label: str, target: Path, allow: bool, needle: str = "") -> bool:
|
|
5222
|
+
verdict = decide(target)
|
|
5223
|
+
if allow:
|
|
5224
|
+
if verdict != "allow":
|
|
5225
|
+
report(f"guard-scope-is-the-repository: {label} was denied.")
|
|
5226
|
+
report(f" {verdict}")
|
|
5227
|
+
return False
|
|
5228
|
+
return True
|
|
5229
|
+
if verdict == "allow":
|
|
5230
|
+
report(f"guard-scope-is-the-repository: {label} was allowed.")
|
|
5231
|
+
return False
|
|
5232
|
+
if needle and needle not in verdict:
|
|
5233
|
+
report(
|
|
5234
|
+
f"guard-scope-is-the-repository: {label} was denied for the "
|
|
5235
|
+
f"wrong reason; expected {needle!r}."
|
|
5236
|
+
)
|
|
5237
|
+
report(f" {verdict}")
|
|
5238
|
+
return False
|
|
5239
|
+
return True
|
|
5240
|
+
|
|
5241
|
+
scratch = outside / "notes.md"
|
|
5242
|
+
# M2 — the in-repository denials must survive the reordering.
|
|
5243
|
+
checks = [
|
|
5244
|
+
expect("an in-Touch write", repo / "src/feature.js", True),
|
|
5245
|
+
expect(
|
|
5246
|
+
"an in-repository path outside Touch",
|
|
5247
|
+
repo / "other.js",
|
|
5248
|
+
False,
|
|
5249
|
+
"outside Touch",
|
|
5250
|
+
),
|
|
5251
|
+
expect(
|
|
5252
|
+
"the guarded change's own records",
|
|
5253
|
+
repo / "openspec/changes/demo/notes.md",
|
|
5254
|
+
True,
|
|
5255
|
+
),
|
|
5256
|
+
expect("an out-of-repository write", scratch, True),
|
|
5257
|
+
]
|
|
5258
|
+
|
|
5259
|
+
# M1, first half — genuine authority drift.
|
|
5260
|
+
write_text(live, live.read_text(encoding="utf-8") + "\nDRIFTED\n")
|
|
5261
|
+
checks.append(
|
|
5262
|
+
expect("an in-Touch write under drift", repo / "src/feature.js", False, "drift")
|
|
5263
|
+
)
|
|
5264
|
+
checks.append(
|
|
5265
|
+
expect("an out-of-repository write under drift", scratch, True)
|
|
5266
|
+
)
|
|
5267
|
+
|
|
5268
|
+
# M1, second half — the corrupt manifest, which is the reported defect.
|
|
5269
|
+
manifest_path.write_text("{ not json", encoding="utf-8")
|
|
5270
|
+
checks.append(
|
|
5271
|
+
expect("an out-of-repository write under a corrupt manifest", scratch, True)
|
|
5272
|
+
)
|
|
5273
|
+
checks.append(
|
|
5274
|
+
expect(
|
|
5275
|
+
"an in-repository write under a corrupt manifest",
|
|
5276
|
+
repo / "src/feature.js",
|
|
5277
|
+
False,
|
|
5278
|
+
"invalid",
|
|
5279
|
+
)
|
|
5280
|
+
)
|
|
5281
|
+
if not all(checks):
|
|
5282
|
+
return 1
|
|
5283
|
+
if "guard-scope-is-the-repository" not in {name for name, _ in SCENARIOS}:
|
|
5284
|
+
report(
|
|
5285
|
+
"guard-scope-is-the-repository: the scenario registry does not "
|
|
5286
|
+
"include it."
|
|
5287
|
+
)
|
|
5288
|
+
return 1
|
|
5289
|
+
report("guard-scope-is-the-repository scenario passed.")
|
|
5290
|
+
return 0
|
|
5291
|
+
|
|
5292
|
+
|
|
5293
|
+
def validate_completion_requires_a_recorded_anchor_scenario() -> int:
|
|
5294
|
+
"""Issue #30: an unrecorded anchor made the drift guarantee conditional.
|
|
5295
|
+
|
|
5296
|
+
`anchoredFingerprint` returns null for a non-digest value and completion
|
|
5297
|
+
then skipped the comparison entirely, so a task with `Contract: pending`
|
|
5298
|
+
passed with zero problems. It could be implemented against one contract,
|
|
5299
|
+
have its Touch or Verify rewritten mid-flight, and complete clean — purely
|
|
5300
|
+
by never running `task-start --record`. 5.3.7 closed the inference path;
|
|
5301
|
+
this closes the explicitly named one.
|
|
5302
|
+
"""
|
|
5303
|
+
header = "# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5304
|
+
|
|
5305
|
+
def task(contract: str) -> str:
|
|
5306
|
+
return (
|
|
5307
|
+
"- [ ] 1.1 Exercise task contract\n"
|
|
5308
|
+
" - Covers:\n"
|
|
5309
|
+
" - E1: Public behavior passes.\n"
|
|
5310
|
+
" - Touch:\n"
|
|
5311
|
+
" - src/feature.js\n"
|
|
5312
|
+
" - Verify:\n"
|
|
5313
|
+
" - Strategy: evidence-first\n"
|
|
5314
|
+
" - M1: node test.js asserts the recorded feed status\n"
|
|
5315
|
+
" - Evidence:\n"
|
|
5316
|
+
f" - Contract: {contract}\n"
|
|
5317
|
+
" - M1: the suite passed\n"
|
|
5318
|
+
" - Review:\n"
|
|
5319
|
+
" - Status: pass\n"
|
|
5320
|
+
" - Acceptance check: behavior asserted at the interface\n"
|
|
5321
|
+
" - Scope check: only Touch files changed\n"
|
|
5322
|
+
" - Findings: none\n"
|
|
5323
|
+
" - Blocker: none\n"
|
|
5324
|
+
)
|
|
5325
|
+
|
|
5326
|
+
with tempfile.TemporaryDirectory(prefix="keel-anchor-required-") as raw:
|
|
5327
|
+
repo = Path(raw)
|
|
5328
|
+
write_text(repo / "openspec/changes/unrecorded/tasks.md", header + task("pending"))
|
|
5329
|
+
|
|
5330
|
+
def gate(change: str, stage: str) -> dict:
|
|
5331
|
+
return json.loads(
|
|
5332
|
+
run_keel(
|
|
5333
|
+
repo,
|
|
5334
|
+
"gate",
|
|
5335
|
+
stage,
|
|
5336
|
+
"--change",
|
|
5337
|
+
change,
|
|
5338
|
+
"--task",
|
|
5339
|
+
"1.1",
|
|
5340
|
+
"--json",
|
|
5341
|
+
).stdout
|
|
5342
|
+
)
|
|
5343
|
+
|
|
5344
|
+
payload = gate("unrecorded", "task-complete")
|
|
5345
|
+
if payload.get("status") == "pass":
|
|
5346
|
+
report(
|
|
5347
|
+
"completion-requires-a-recorded-anchor: an explicitly named task "
|
|
5348
|
+
"with `Contract: pending` passed task-complete, so the "
|
|
5349
|
+
"fingerprint comparison still compares nothing."
|
|
5350
|
+
)
|
|
5351
|
+
return 1
|
|
5352
|
+
named = [
|
|
5353
|
+
problem.get("message", "")
|
|
5354
|
+
for problem in payload.get("problems", [])
|
|
5355
|
+
if problem.get("code") == "missing-contract-anchor"
|
|
5356
|
+
]
|
|
5357
|
+
if not named:
|
|
5358
|
+
report(
|
|
5359
|
+
"completion-requires-a-recorded-anchor: the task did not pass, "
|
|
5360
|
+
"but no missing-contract-anchor diagnostic explained why."
|
|
5361
|
+
)
|
|
5362
|
+
for problem in payload.get("problems", []):
|
|
5363
|
+
report(f" {problem.get('code')}: {problem.get('message')}")
|
|
5364
|
+
return 1
|
|
5365
|
+
for needle in ("Contract", "--record"):
|
|
5366
|
+
if needle not in named[0]:
|
|
5367
|
+
report(
|
|
5368
|
+
"completion-requires-a-recorded-anchor: the diagnostic did "
|
|
5369
|
+
f"not name {needle}."
|
|
5370
|
+
)
|
|
5371
|
+
report(named[0])
|
|
5372
|
+
return 1
|
|
5373
|
+
# Doing what the diagnostic asks must clear it. The fingerprint is the
|
|
5374
|
+
# one this task actually compiles to, so the anchor comparison that
|
|
5375
|
+
# follows is a real comparison rather than a shape check.
|
|
5376
|
+
started = gate("unrecorded", "task-start")
|
|
5377
|
+
fingerprint = started.get("contract", {}).get("fingerprint", {}).get("value")
|
|
5378
|
+
if not fingerprint:
|
|
5379
|
+
report(
|
|
5380
|
+
"completion-requires-a-recorded-anchor: task-start returned no "
|
|
5381
|
+
"fingerprint to record."
|
|
5382
|
+
)
|
|
5383
|
+
return 1
|
|
5384
|
+
if any(
|
|
5385
|
+
problem.get("code") == "missing-contract-anchor"
|
|
5386
|
+
for problem in started.get("problems", [])
|
|
5387
|
+
):
|
|
5388
|
+
report(
|
|
5389
|
+
"completion-requires-a-recorded-anchor: task-start reported the "
|
|
5390
|
+
"missing anchor, but it runs before one can exist."
|
|
5391
|
+
)
|
|
5392
|
+
return 1
|
|
5393
|
+
write_text(
|
|
5394
|
+
repo / "openspec/changes/recorded/tasks.md",
|
|
5395
|
+
header + task(f"keel-task-capsule/v1 sha256:{fingerprint}"),
|
|
5396
|
+
)
|
|
5397
|
+
recorded = gate("recorded", "task-complete")
|
|
5398
|
+
if recorded.get("status") != "pass":
|
|
5399
|
+
report(
|
|
5400
|
+
"completion-requires-a-recorded-anchor: recording the anchor did "
|
|
5401
|
+
"not clear the refusal."
|
|
5402
|
+
)
|
|
5403
|
+
for problem in recorded.get("problems", []):
|
|
5404
|
+
report(f" {problem.get('code')}: {problem.get('message')}")
|
|
5405
|
+
return 1
|
|
5406
|
+
if "completion-requires-a-recorded-anchor" not in {name for name, _ in SCENARIOS}:
|
|
5407
|
+
report(
|
|
5408
|
+
"completion-requires-a-recorded-anchor: the scenario registry does "
|
|
5409
|
+
"not include it."
|
|
5410
|
+
)
|
|
5411
|
+
return 1
|
|
5412
|
+
report("completion-requires-a-recorded-anchor scenario passed.")
|
|
5413
|
+
return 0
|
|
5414
|
+
|
|
5415
|
+
|
|
5416
|
+
def validate_task_body_ends_at_heading_scenario() -> int:
|
|
5417
|
+
"""Issue #29: a change-level section was read as the last task's Evidence.
|
|
5418
|
+
|
|
5419
|
+
parseTasks gave a task every line up to the next task or EOF, and a `##`
|
|
5420
|
+
heading did not stop it, so `## Invalidates` and `## Expectation Coverage`
|
|
5421
|
+
landed in whichever field was open last. That made the two checks look
|
|
5422
|
+
contradictory: `invalidation-phrase` requires the searchable wording in
|
|
5423
|
+
double quotes, while the concreteness test rejects an angle-bracket slot
|
|
5424
|
+
outside inline code, so an entry quoting wording that carries one could
|
|
5425
|
+
satisfy neither. They were never in conflict — the parser only made them
|
|
5426
|
+
appear so.
|
|
5427
|
+
"""
|
|
5428
|
+
slot = "<" + "n" + ">"
|
|
5429
|
+
header = "# Tasks\n\n"
|
|
5430
|
+
|
|
5431
|
+
def task(number: str, contract: str) -> str:
|
|
5432
|
+
return (
|
|
5433
|
+
f"- [ ] {number} Exercise task contract\n"
|
|
5434
|
+
" - Covers:\n"
|
|
5435
|
+
" - E1: Public behavior passes.\n"
|
|
5436
|
+
" - Touch:\n"
|
|
5437
|
+
" - src/feature.js\n"
|
|
5438
|
+
" - Verify:\n"
|
|
5439
|
+
" - Strategy: evidence-first\n"
|
|
5440
|
+
" - M1: node test.js asserts the recorded feed status\n"
|
|
5441
|
+
" - Evidence:\n"
|
|
5442
|
+
f" - Contract: {contract}\n"
|
|
5443
|
+
" - M1: the suite passed\n"
|
|
5444
|
+
" - Review:\n"
|
|
5445
|
+
" - Status: pass\n"
|
|
5446
|
+
" - Acceptance check: behavior asserted at the interface\n"
|
|
5447
|
+
" - Scope check: only Touch files changed\n"
|
|
5448
|
+
" - Findings: none\n"
|
|
5449
|
+
" - Blocker: none\n"
|
|
5450
|
+
)
|
|
5451
|
+
|
|
5452
|
+
# The entry quotes stale wording carrying an unfilled slot, which is exactly
|
|
5453
|
+
# what issue #16 asks an invalidation to quote. A stray Contract line sits
|
|
5454
|
+
# in the trailing section to prove the anchor search stops at the heading.
|
|
5455
|
+
sections = (
|
|
5456
|
+
"\n## Invalidates\n\n"
|
|
5457
|
+
f'- I1: "an unresolved Q{slot} without an authorized fallback blocks '
|
|
5458
|
+
'implementation" — the schema prose. Updated by: 1.1\n'
|
|
5459
|
+
" - Contract: keel-task-capsule/v1 sha256:" + "b" * 64 + "\n"
|
|
5460
|
+
"\n## Expectation Coverage\n\n"
|
|
5461
|
+
f"- E1: Every Q{slot} reference resolves Covered by: 1.1\n"
|
|
5462
|
+
)
|
|
5463
|
+
body = header + task("1.1", "pending") + "\n## 2. Second group\n\n" + task(
|
|
5464
|
+
"2.1", "pending"
|
|
5465
|
+
)
|
|
5466
|
+
|
|
5467
|
+
with tempfile.TemporaryDirectory(prefix="keel-task-extent-") as raw:
|
|
5468
|
+
repo = Path(raw)
|
|
5469
|
+
write_text(repo / "openspec/changes/demo/tasks.md", body + sections)
|
|
5470
|
+
started = run_keel(
|
|
5471
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "2.1", "--json"
|
|
5472
|
+
)
|
|
5473
|
+
payload = json.loads(started.stdout)
|
|
5474
|
+
problems = payload.get("problems", [])
|
|
5475
|
+
if payload.get("status") != "pass":
|
|
5476
|
+
report(
|
|
5477
|
+
"task-body-ends-at-heading: the last task did not pass task-start "
|
|
5478
|
+
"with a trailing section quoting an unfilled slot."
|
|
5479
|
+
)
|
|
5480
|
+
for problem in problems:
|
|
5481
|
+
report(f" {problem.get('code')}: {problem.get('message')}")
|
|
5482
|
+
return 1
|
|
5483
|
+
# The same run proves the phrase check is satisfied: an entry the phrase
|
|
5484
|
+
# check rejected would have produced invalidation-phrase above.
|
|
5485
|
+
malformed = body + (
|
|
5486
|
+
"\n## Invalidates\n\n"
|
|
5487
|
+
"- I1: the schema prose is stale. Updated by: 1.1\n"
|
|
5488
|
+
)
|
|
5489
|
+
write_text(repo / "openspec/changes/unquoted/tasks.md", malformed)
|
|
5490
|
+
unquoted = json.loads(
|
|
5491
|
+
run_keel(
|
|
5492
|
+
repo,
|
|
5493
|
+
"gate",
|
|
5494
|
+
"task-start",
|
|
5495
|
+
"--change",
|
|
5496
|
+
"unquoted",
|
|
5497
|
+
"--task",
|
|
5498
|
+
"2.1",
|
|
5499
|
+
"--json",
|
|
5500
|
+
).stdout
|
|
5501
|
+
)
|
|
5502
|
+
if not any(
|
|
5503
|
+
problem.get("code") == "invalidation-phrase"
|
|
5504
|
+
for problem in unquoted.get("problems", [])
|
|
5505
|
+
):
|
|
5506
|
+
report(
|
|
5507
|
+
"task-body-ends-at-heading: an unquoted invalidation entry was "
|
|
5508
|
+
"accepted, so the phrase check is no longer being satisfied by "
|
|
5509
|
+
"the quoted one."
|
|
5510
|
+
)
|
|
5511
|
+
return 1
|
|
5512
|
+
# A group heading must not be appended to the preceding task's field.
|
|
5513
|
+
first = json.loads(
|
|
5514
|
+
run_keel(
|
|
5515
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
5516
|
+
).stdout
|
|
5517
|
+
)
|
|
5518
|
+
evidence = json.dumps(first)
|
|
5519
|
+
if "Second group" in evidence:
|
|
5520
|
+
report(
|
|
5521
|
+
"task-body-ends-at-heading: the group heading leaked into the "
|
|
5522
|
+
"preceding task's fields."
|
|
5523
|
+
)
|
|
5524
|
+
return 1
|
|
5525
|
+
# --record must anchor the last task's own Contract line, not the stray
|
|
5526
|
+
# one planted in the trailing section.
|
|
5527
|
+
recorded = run_keel(
|
|
5528
|
+
repo,
|
|
5529
|
+
"gate",
|
|
5530
|
+
"task-start",
|
|
5531
|
+
"--change",
|
|
5532
|
+
"demo",
|
|
5533
|
+
"--task",
|
|
5534
|
+
"2.1",
|
|
5535
|
+
"--record",
|
|
5536
|
+
"--json",
|
|
5537
|
+
)
|
|
5538
|
+
if recorded.returncode != 0:
|
|
5539
|
+
report("task-body-ends-at-heading: --record failed on the last task.")
|
|
5540
|
+
report((recorded.stdout or recorded.stderr).strip())
|
|
5541
|
+
return 1
|
|
5542
|
+
written = (repo / "openspec/changes/demo/tasks.md").read_text(
|
|
5543
|
+
encoding="utf-8"
|
|
5544
|
+
)
|
|
5545
|
+
after_heading = written.split("## Invalidates", 1)[1]
|
|
5546
|
+
if "b" * 64 not in after_heading:
|
|
5547
|
+
report(
|
|
5548
|
+
"task-body-ends-at-heading: --record overwrote the Contract line "
|
|
5549
|
+
"planted inside the trailing section."
|
|
5550
|
+
)
|
|
5551
|
+
return 1
|
|
5552
|
+
# The extent change must move no fingerprint: an anchor that shifted
|
|
5553
|
+
# would drift every live change in every consumer repo at once. Pinned
|
|
5554
|
+
# rather than merely measured, so a future extent change cannot move one
|
|
5555
|
+
# silently. A deliberate capsule-shape change will fail here too — that
|
|
5556
|
+
# is the point; it should be looked at, not absorbed.
|
|
5557
|
+
pinned = {
|
|
5558
|
+
"1.1": "2f723a8778160a2d51cd91e34255bf19f2c654fa23cdcd7b013915727a541d17",
|
|
5559
|
+
"2.1": "5e0481362b06992d0317c91d34bbd5d6746fb9c1fee47566060f61fbba7cbf05",
|
|
5560
|
+
}
|
|
5561
|
+
plain = (
|
|
5562
|
+
"# Tasks\n\n"
|
|
5563
|
+
+ task("1.1", "pending")
|
|
5564
|
+
+ "\n## 2. Second group\n\n"
|
|
5565
|
+
+ task("2.1", "pending")
|
|
5566
|
+
+ '\n## Invalidates\n\n- I1: "the schema prose is stale here" — the '
|
|
5567
|
+
"schema. Updated by: 1.1\n\n## Expectation Coverage\n\n"
|
|
5568
|
+
"- E1: Covered by: 1.1\n"
|
|
5569
|
+
)
|
|
5570
|
+
write_text(repo / "openspec/changes/pinned/tasks.md", plain)
|
|
5571
|
+
for task_id, expected in pinned.items():
|
|
5572
|
+
payload = json.loads(
|
|
5573
|
+
run_keel(
|
|
5574
|
+
repo,
|
|
5575
|
+
"gate",
|
|
5576
|
+
"task-start",
|
|
5577
|
+
"--change",
|
|
5578
|
+
"pinned",
|
|
5579
|
+
"--task",
|
|
5580
|
+
task_id,
|
|
5581
|
+
"--json",
|
|
5582
|
+
).stdout
|
|
5583
|
+
)
|
|
5584
|
+
actual = (
|
|
5585
|
+
payload.get("contract", {}).get("fingerprint", {}).get("value")
|
|
5586
|
+
)
|
|
5587
|
+
if actual != expected:
|
|
5588
|
+
report(
|
|
5589
|
+
"task-body-ends-at-heading: the compiled fingerprint for "
|
|
5590
|
+
f"task {task_id} moved. Expected {expected}, got {actual}. "
|
|
5591
|
+
"An extent or capsule-shape change that moves an anchor "
|
|
5592
|
+
"drifts every live change in every consumer repo."
|
|
5593
|
+
)
|
|
5594
|
+
return 1
|
|
5595
|
+
if "task-body-ends-at-heading" not in {name for name, _ in SCENARIOS}:
|
|
5596
|
+
report(
|
|
5597
|
+
"task-body-ends-at-heading: the scenario registry does not include it."
|
|
5598
|
+
)
|
|
5599
|
+
return 1
|
|
5600
|
+
report("task-body-ends-at-heading scenario passed.")
|
|
5601
|
+
return 0
|
|
5602
|
+
|
|
5603
|
+
|
|
5101
5604
|
TASKS_TEMPLATE_RELATIVE = "schemas/keel-spec-driven/templates/tasks.md"
|
|
5102
5605
|
|
|
5103
5606
|
|
|
@@ -5876,6 +6379,7 @@ def tracker_owner_tasks(findings: str, closure: str) -> str:
|
|
|
5876
6379
|
" - Stop Rules:\n"
|
|
5877
6380
|
" - Stop on failure.\n"
|
|
5878
6381
|
" - Evidence:\n"
|
|
6382
|
+
" - Contract: pending\n"
|
|
5879
6383
|
" - M1: passed\n"
|
|
5880
6384
|
" - Review:\n"
|
|
5881
6385
|
" - Status: pass\n"
|
|
@@ -5910,6 +6414,7 @@ def validate_tracker_durable_owner_scenario() -> int:
|
|
|
5910
6414
|
|
|
5911
6415
|
def complete(findings: str, closure: str = "Covered by: 1.1"):
|
|
5912
6416
|
write_text(tasks, tracker_owner_tasks(findings, closure))
|
|
6417
|
+
record_contract_anchor(repo, "demo")
|
|
5913
6418
|
return run_keel(
|
|
5914
6419
|
repo, "gate", "task-complete",
|
|
5915
6420
|
"--change", "demo", "--task", "1.1", "--json",
|
|
@@ -6331,6 +6836,7 @@ def validate_task_capsule_scenario() -> int:
|
|
|
6331
6836
|
repo / "openspec/changes/demo/tasks.md",
|
|
6332
6837
|
completion_task,
|
|
6333
6838
|
)
|
|
6839
|
+
record_contract_anchor(repo, "demo")
|
|
6334
6840
|
completed = run_keel(
|
|
6335
6841
|
repo,
|
|
6336
6842
|
"gate",
|
|
@@ -7025,6 +7531,10 @@ def validate_core_gates_scenario() -> int:
|
|
|
7025
7531
|
" - Stop Rules:\n"
|
|
7026
7532
|
" - Stop on final assertion failure.\n"
|
|
7027
7533
|
" - Evidence:\n"
|
|
7534
|
+
# Read at call time, so every variant below carries the anchor
|
|
7535
|
+
# recorded once for this fixture. Evidence is not in the capsule,
|
|
7536
|
+
# so one fingerprint is correct for all of them.
|
|
7537
|
+
f" - Contract: {completion_anchor}\n"
|
|
7028
7538
|
f" - M1: {evidence}\n"
|
|
7029
7539
|
" - Review:\n"
|
|
7030
7540
|
f" - Status: {review_status}\n"
|
|
@@ -7038,6 +7548,19 @@ def validate_core_gates_scenario() -> int:
|
|
|
7038
7548
|
" - Summary\n"
|
|
7039
7549
|
)
|
|
7040
7550
|
|
|
7551
|
+
completion_anchor = "pending"
|
|
7552
|
+
write_text(completion_tasks, completion_task("pending", "pass"))
|
|
7553
|
+
if not record_contract_anchor(completion_repo, "demo"):
|
|
7554
|
+
report("core-gates scenario could not record the completion anchor.")
|
|
7555
|
+
return 1
|
|
7556
|
+
recorded_line = re.search(
|
|
7557
|
+
r"-\s*Contract:\s*(.+)", completion_tasks.read_text(encoding="utf-8")
|
|
7558
|
+
)
|
|
7559
|
+
if not recorded_line:
|
|
7560
|
+
report("core-gates scenario found no recorded Contract anchor.")
|
|
7561
|
+
return 1
|
|
7562
|
+
completion_anchor = recorded_line.group(1).strip()
|
|
7563
|
+
|
|
7041
7564
|
write_text(completion_tasks, completion_task("pending", "pass"))
|
|
7042
7565
|
missing_evidence = run_keel(
|
|
7043
7566
|
completion_repo,
|
|
@@ -7840,6 +8363,7 @@ def validate_scope_rename_attribution_scenario() -> int:
|
|
|
7840
8363
|
" - Stop Rules:\n"
|
|
7841
8364
|
" - Stop on final assertion failure.\n"
|
|
7842
8365
|
" - Evidence:\n"
|
|
8366
|
+
" - Contract: pending\n"
|
|
7843
8367
|
" - M1: passed\n"
|
|
7844
8368
|
" - Review:\n"
|
|
7845
8369
|
" - Status: pass\n"
|
|
@@ -7883,6 +8407,7 @@ def validate_scope_rename_attribution_scenario() -> int:
|
|
|
7883
8407
|
report("scope-rename scenario git mv failed:")
|
|
7884
8408
|
report((moved.stderr or moved.stdout).strip())
|
|
7885
8409
|
return 1
|
|
8410
|
+
record_contract_anchor(repo, "demo")
|
|
7886
8411
|
completed = run_keel(
|
|
7887
8412
|
repo,
|
|
7888
8413
|
"gate",
|
|
@@ -9244,6 +9769,7 @@ def validate_task_verification_strategies_scenario() -> int:
|
|
|
9244
9769
|
|
|
9245
9770
|
def run_completion(fixture: str) -> subprocess.CompletedProcess[str]:
|
|
9246
9771
|
write_text(tasks_path, fixture)
|
|
9772
|
+
record_contract_anchor(repo, "demo")
|
|
9247
9773
|
return run_keel(
|
|
9248
9774
|
repo,
|
|
9249
9775
|
"gate",
|
|
@@ -9983,6 +10509,7 @@ def _goal_task_block(
|
|
|
9983
10509
|
" - Stop Rules:",
|
|
9984
10510
|
" - stop on failure",
|
|
9985
10511
|
" - Evidence:",
|
|
10512
|
+
" - Contract: pending",
|
|
9986
10513
|
]
|
|
9987
10514
|
)
|
|
9988
10515
|
if filled:
|
|
@@ -10686,6 +11213,7 @@ def validate_native_goal_gate_order_scenario() -> int:
|
|
|
10686
11213
|
|
|
10687
11214
|
# 4. With evidence and a passing Review recorded, task-complete passes.
|
|
10688
11215
|
write_text(tasks_path, _goal_tasks_file([_goal_task_block(filled=True)]))
|
|
11216
|
+
record_contract_anchor(repo, "sample-change")
|
|
10689
11217
|
completed = run_keel(
|
|
10690
11218
|
repo, "gate", "task-complete",
|
|
10691
11219
|
"--change", "sample-change", "--task", "1.1", "--json",
|
|
@@ -11249,6 +11777,7 @@ def validate_single_task_goal_real_tasks_scenario() -> int:
|
|
|
11249
11777
|
[_goal_task_block(strategy=strategy, filled=True, redgreen=redgreen)]
|
|
11250
11778
|
),
|
|
11251
11779
|
)
|
|
11780
|
+
record_contract_anchor(repo, "sample-change")
|
|
11252
11781
|
done = run_keel(
|
|
11253
11782
|
repo, "gate", "task-complete",
|
|
11254
11783
|
"--change", "sample-change", "--task", "1.1", "--json", env=env,
|
|
@@ -11544,6 +12073,7 @@ def _single_task_matrix_target(target: str, root: Path) -> int:
|
|
|
11544
12073
|
]
|
|
11545
12074
|
),
|
|
11546
12075
|
)
|
|
12076
|
+
record_contract_anchor(repo, "sample-change")
|
|
11547
12077
|
done = run_keel(repo, "gate", "task-complete", "--change", "sample-change", "--task", "1.1", "--json", env=env)
|
|
11548
12078
|
if done.returncode != 0:
|
|
11549
12079
|
report("native-single-task-matrix %s could not complete with evidence." % target)
|
|
@@ -13768,6 +14298,15 @@ SCENARIOS: tuple = (
|
|
|
13768
14298
|
"task-complete-selection-requires-a-started-task",
|
|
13769
14299
|
validate_task_complete_selection_requires_a_started_task_scenario,
|
|
13770
14300
|
),
|
|
14301
|
+
("task-body-ends-at-heading", validate_task_body_ends_at_heading_scenario),
|
|
14302
|
+
(
|
|
14303
|
+
"completion-requires-a-recorded-anchor",
|
|
14304
|
+
validate_completion_requires_a_recorded_anchor_scenario,
|
|
14305
|
+
),
|
|
14306
|
+
(
|
|
14307
|
+
"guard-scope-is-the-repository",
|
|
14308
|
+
validate_guard_scope_is_the_repository_scenario,
|
|
14309
|
+
),
|
|
13771
14310
|
("spec-template-validates", validate_spec_template_validates_scenario),
|
|
13772
14311
|
(
|
|
13773
14312
|
"tasks-template-red-green-example",
|
package/src/core/gates.js
CHANGED
|
@@ -110,10 +110,29 @@ function loadSelection(repo, options, requireTask = true) {
|
|
|
110
110
|
// of the task they just finished. A task that has started records a fingerprint
|
|
111
111
|
// in its Evidence `Contract` anchor, so that anchor is what makes the inference
|
|
112
112
|
// safe — and without one there is nothing for completion to compare against.
|
|
113
|
+
function hasRecordedAnchor(selection, task) {
|
|
114
|
+
const plan = contractAnchorPlan(selection, task);
|
|
115
|
+
return Boolean(plan && anchoredFingerprint(plan.previous));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A task that recorded no anchor has no drift detection at all while presenting
|
|
119
|
+
// as fully gated: completion skipped the comparison rather than reporting that
|
|
120
|
+
// it had nothing to compare. Recording is already the documented step; this is
|
|
121
|
+
// what makes the guarantee unconditional instead of aspirational.
|
|
122
|
+
function missingAnchorProblem(selection, task) {
|
|
123
|
+
if (hasRecordedAnchor(selection, task)) return null;
|
|
124
|
+
return problem(
|
|
125
|
+
"missing-contract-anchor",
|
|
126
|
+
`${selection.change}#${task.id} records no compiled fingerprint in its `
|
|
127
|
+
+ "Evidence `Contract` anchor, so completion has nothing to compare and "
|
|
128
|
+
+ "the task has no drift detection. Run `keel gate task-start --record` "
|
|
129
|
+
+ "for this task, which rewrites the anchor in place, then complete it."
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
113
133
|
function unstartedInferenceProblem(selection) {
|
|
114
134
|
const task = selection.selected[0];
|
|
115
|
-
|
|
116
|
-
if (plan && anchoredFingerprint(plan.previous)) return null;
|
|
135
|
+
if (hasRecordedAnchor(selection, task)) return null;
|
|
117
136
|
const checked = [...selection.tasks].filter((item) => item.checked).pop();
|
|
118
137
|
return problem(
|
|
119
138
|
"ambiguous-completion-selection",
|
|
@@ -131,11 +150,9 @@ function unstartedInferenceProblem(selection) {
|
|
|
131
150
|
|
|
132
151
|
function contractAnchorPlan(selection, task) {
|
|
133
152
|
const lines = selection.content.split("\n");
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
? selection.tasks[index + 1].line
|
|
138
|
-
: lines.length;
|
|
153
|
+
// The task carries its own extent, so the anchor search cannot reach a
|
|
154
|
+
// `- Contract:` line sitting in a trailing change-level section.
|
|
155
|
+
const end = task.endLine !== undefined ? task.endLine : lines.length;
|
|
139
156
|
for (let cursor = task.line; cursor < end; cursor += 1) {
|
|
140
157
|
const match = lines[cursor].match(/^(\s*)-\s*Contract:\s*(.*?)(\r?)$/);
|
|
141
158
|
if (match) {
|
|
@@ -585,6 +602,10 @@ function completionChecks(repo, task, contract = null) {
|
|
|
585
602
|
function taskComplete(repo, options) {
|
|
586
603
|
const selection = loadSelection(repo, options);
|
|
587
604
|
const task = selection.selected[0];
|
|
605
|
+
// Selection ambiguity short-circuits, because the gate does not know which
|
|
606
|
+
// task the caller meant and evaluating the wrong one is the defect. A named
|
|
607
|
+
// task is not ambiguous: its missing anchor is one problem among however many
|
|
608
|
+
// else it has, so it joins the list rather than hiding the rest.
|
|
588
609
|
if (!options.task) {
|
|
589
610
|
const ambiguous = unstartedInferenceProblem(selection);
|
|
590
611
|
if (ambiguous) {
|
|
@@ -603,6 +624,8 @@ function taskComplete(repo, options) {
|
|
|
603
624
|
const usableContract = contract.diagnostics.length === 0 ? contract : null;
|
|
604
625
|
const checks = completionChecks(repo, task, usableContract);
|
|
605
626
|
checks.problems.push(...contract.diagnostics);
|
|
627
|
+
const missingAnchor = missingAnchorProblem(selection, task);
|
|
628
|
+
if (missingAnchor) checks.problems.push(missingAnchor);
|
|
606
629
|
const scope = scopeEvidence(
|
|
607
630
|
repo,
|
|
608
631
|
task,
|
|
@@ -68,7 +68,21 @@ function parseTasks(content) {
|
|
|
68
68
|
});
|
|
69
69
|
}
|
|
70
70
|
for (let index = 0; index < tasks.length; index += 1) {
|
|
71
|
-
|
|
71
|
+
// A task body ends at the next task or the next `##` heading, whichever
|
|
72
|
+
// comes first. Without the heading bound a change-level section such as
|
|
73
|
+
// `## Invalidates` was appended to whichever field was open last — the
|
|
74
|
+
// Evidence, in every shipped template — so a token quoted there made the
|
|
75
|
+
// Evidence non-concrete and the gate blamed a task that was fine.
|
|
76
|
+
const nextTask =
|
|
77
|
+
index + 1 < tasks.length ? tasks[index + 1].line : lines.length;
|
|
78
|
+
let end = nextTask;
|
|
79
|
+
for (let cursor = tasks[index].line + 1; cursor < nextTask; cursor += 1) {
|
|
80
|
+
if (/^\s*##\s/.test(lines[cursor])) {
|
|
81
|
+
end = cursor;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
tasks[index].endLine = end;
|
|
72
86
|
const bodyLines = lines.slice(tasks[index].line, end);
|
|
73
87
|
tasks[index].body = bodyLines.join("\n");
|
|
74
88
|
tasks[index].fields = new Map();
|