@christang/keel 5.14.0 → 5.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +39 -17
- 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-review-checklist/SKILL.md +1 -1
- package/scripts/validate_plugin.py +318 -2
- package/src/core/gates.js +63 -33
- package/src/core/guard.js +53 -0
package/bin/keel.js
CHANGED
|
@@ -699,21 +699,35 @@ function openspecReportedVersion(command) {
|
|
|
699
699
|
return match ? match[0] : null;
|
|
700
700
|
}
|
|
701
701
|
|
|
702
|
-
|
|
702
|
+
// The root is the repository under diagnosis, never PACKAGE_ROOT. Rooting this
|
|
703
|
+
// at Keel's own install location made the line a statement about a repository
|
|
704
|
+
// the reader was never shown: a consumer pinning 9.9.9 was told the version
|
|
705
|
+
// Keel's checkout pins, and a global install — which ships no lockfile — was
|
|
706
|
+
// told `unreadable` forever, which is exactly the drift this check was added
|
|
707
|
+
// to expose. There is no fallback to PACKAGE_ROOT on purpose; falling back
|
|
708
|
+
// would reinstate the misattribution and leave the reader unable to tell which
|
|
709
|
+
// case they were in.
|
|
710
|
+
//
|
|
711
|
+
// Three outcomes, not two. A repository with no lockfile and one whose lockfile
|
|
712
|
+
// names no OpenSpec both *declare* nothing, which is the ordinary case for any
|
|
713
|
+
// project that does not depend on OpenSpec directly. A lockfile that exists and
|
|
714
|
+
// cannot be parsed is a read failure. Collapsing them would either warn at
|
|
715
|
+
// everyone or hide a real failure.
|
|
716
|
+
function declaredOpenSpecVersion(repo) {
|
|
717
|
+
const lockPath = path.join(repo, "package-lock.json");
|
|
718
|
+
if (!fs.existsSync(lockPath)) return { state: "none", version: null };
|
|
719
|
+
let lock;
|
|
703
720
|
try {
|
|
704
|
-
|
|
705
|
-
fs.readFileSync(path.join(PACKAGE_ROOT, "package-lock.json"), "utf8")
|
|
706
|
-
);
|
|
707
|
-
for (const [name, entry] of Object.entries(lock.packages || {})) {
|
|
708
|
-
if (name.endsWith("@fission-ai/openspec") && entry && entry.version) {
|
|
709
|
-
return entry.version;
|
|
710
|
-
}
|
|
711
|
-
}
|
|
721
|
+
lock = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
712
722
|
} catch {
|
|
713
|
-
|
|
714
|
-
// with, which is not the same as agreement and is reported as such.
|
|
723
|
+
return { state: "unreadable", version: null };
|
|
715
724
|
}
|
|
716
|
-
|
|
725
|
+
for (const [name, entry] of Object.entries(lock.packages || {})) {
|
|
726
|
+
if (name.endsWith("@fission-ai/openspec") && entry && entry.version) {
|
|
727
|
+
return { state: "declared", version: entry.version };
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return { state: "none", version: null };
|
|
717
731
|
}
|
|
718
732
|
|
|
719
733
|
function findOpenSpecCommand() {
|
|
@@ -1426,16 +1440,24 @@ function runDoctor(options) {
|
|
|
1426
1440
|
silentNotFound: true,
|
|
1427
1441
|
}) === 0;
|
|
1428
1442
|
const resolvedVersion = openspecReportedVersion(openspec);
|
|
1429
|
-
const
|
|
1443
|
+
const declared = declaredOpenSpecVersion(repo);
|
|
1430
1444
|
const mismatched = Boolean(
|
|
1431
|
-
resolvedVersion
|
|
1445
|
+
resolvedVersion
|
|
1446
|
+
&& declared.state === "declared"
|
|
1447
|
+
&& resolvedVersion !== declared.version
|
|
1432
1448
|
);
|
|
1433
1449
|
const where = bareOpenSpecOnPath
|
|
1434
1450
|
? openspec
|
|
1435
1451
|
: `${openspec} is keel-resolvable but bare \`openspec\` is not on PATH — use \`keel openspec\``;
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1452
|
+
// Two versions on one line need two owners. `repo` is the repository named
|
|
1453
|
+
// on doctor's first line; the answering build is attributed by the path
|
|
1454
|
+
// already printed beside it.
|
|
1455
|
+
const declaredText = {
|
|
1456
|
+
declared: `repo pins ${declared.version}`,
|
|
1457
|
+
none: "repo declares no OpenSpec version",
|
|
1458
|
+
unreadable: "repo package-lock.json unreadable",
|
|
1459
|
+
}[declared.state];
|
|
1460
|
+
const versions = `${resolvedVersion || "version unreadable"}, ${declaredText}`;
|
|
1439
1461
|
printDoctorLine(
|
|
1440
1462
|
"openspec",
|
|
1441
1463
|
mismatched || !bareOpenSpecOnPath ? "warning" : "ok",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.16.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.16.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",
|
|
@@ -23,7 +23,7 @@ Read the selected OpenSpec proposal, design, specs, tasks, diff, and command evi
|
|
|
23
23
|
- For a red-green strategy (`vertical-tdd`, `regression-first`), confirm concrete per-label `.red` and `.green` Evidence exists for the same check; evidence-first tasks instead name their observable proof.
|
|
24
24
|
- A failure message must **name the actual cause** of what it reports. Watch for one condition guarding **two distinct failures** — `if result is None or result["status"] != expected` reports the first failure's message when the second one happened, sending the reader to a place with no problem in it. Split the condition. No gate can judge this: deciding whether a sentence misleads needs a model, so it stays here.
|
|
25
25
|
- When two tasks in the change declared the same Touch set under a red-green strategy — `keel gate task-start` warns about this — ask whether they turned out to be **one behavior** split in half. The tell is that the first task's minimal implementation was wrong in the field, or that the second had no honest red left because the first already made its checks pass. The gate can only see the shape; by completion you can see the outcome, which is the only point at which this is answerable.
|
|
26
|
-
-
|
|
26
|
+
- Scope attribution is now the gate's job by default: `keel gate task-complete` compares the worktree against the dirty set `task-start` recorded and refuses a path outside Touch, so a `pass` is evidence about scope rather than silence about it. Two cases still need you. When the gate reports paths as *unattributed* — no recorded set, because the manifest predates the field or the guard was cleared — nothing was checked and the review is the only scope evidence. And a path already dirty when the task started is never attributed even if the task changed it again, so read the gate's silence about such a path as absence of a check rather than absence of a write.
|
|
27
27
|
- For `Coupling: required`, confirm one complete candidate reached its completion gate and generated artifacts are aligned.
|
|
28
28
|
|
|
29
29
|
## Semantic Review
|
|
@@ -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.16.0"
|
|
41
|
+
PROTOCOL_VERSION = "5.16.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
|
|
@@ -5594,6 +5594,146 @@ def validate_runtime_versions_are_checked_scenario() -> int:
|
|
|
5594
5594
|
return 0
|
|
5595
5595
|
|
|
5596
5596
|
|
|
5597
|
+
def validate_doctor_reads_the_diagnosed_repository_scenario() -> int:
|
|
5598
|
+
"""Issue #57: doctor named one repository and answered about another.
|
|
5599
|
+
|
|
5600
|
+
The OpenSpec declaration was read from `PACKAGE_ROOT/package-lock.json` —
|
|
5601
|
+
the directory Keel is installed in — while the line above it says
|
|
5602
|
+
`keel doctor for <repo>`. The two coincide only when `node bin/keel.js`
|
|
5603
|
+
runs from Keel's own checkout, which is the one arrangement every existing
|
|
5604
|
+
scenario uses: `run_keel` always spawns `node <ROOT>/bin/keel.js`, so
|
|
5605
|
+
`PACKAGE_ROOT` is Keel's checkout no matter what `cwd` is. Driving the
|
|
5606
|
+
diagnosis at a repository that declares its own version is what separates
|
|
5607
|
+
the two roots without needing a second installation.
|
|
5608
|
+
"""
|
|
5609
|
+
label = "doctor-reads-the-diagnosed-repository"
|
|
5610
|
+
|
|
5611
|
+
def openspec_line(stdout: str) -> str:
|
|
5612
|
+
return next(
|
|
5613
|
+
(line for line in stdout.splitlines() if line.startswith("openspec:")),
|
|
5614
|
+
"",
|
|
5615
|
+
)
|
|
5616
|
+
|
|
5617
|
+
# M1 first half — a repository that declares a version Keel's own tree does
|
|
5618
|
+
# not. The sentinel is deliberately one no published OpenSpec reports, so a
|
|
5619
|
+
# line carrying it cannot have come from the resolved binary either.
|
|
5620
|
+
declared = "0.0.1"
|
|
5621
|
+
with tempfile.TemporaryDirectory(prefix="keel-doctor-declares-") as raw:
|
|
5622
|
+
declares = Path(raw).resolve()
|
|
5623
|
+
write_text(
|
|
5624
|
+
declares / "package-lock.json",
|
|
5625
|
+
json.dumps(
|
|
5626
|
+
{
|
|
5627
|
+
"name": "consumer",
|
|
5628
|
+
"lockfileVersion": 3,
|
|
5629
|
+
"packages": {
|
|
5630
|
+
"node_modules/@fission-ai/openspec": {"version": declared}
|
|
5631
|
+
},
|
|
5632
|
+
},
|
|
5633
|
+
indent=2,
|
|
5634
|
+
)
|
|
5635
|
+
+ "\n",
|
|
5636
|
+
)
|
|
5637
|
+
doctor = run_keel(declares, "--doctor")
|
|
5638
|
+
line = openspec_line(doctor.stdout or "")
|
|
5639
|
+
if not line:
|
|
5640
|
+
report(f"{label} M1 `keel --doctor` emitted no openspec line.")
|
|
5641
|
+
report((doctor.stdout or doctor.stderr or "").strip())
|
|
5642
|
+
return 1
|
|
5643
|
+
own_lock = json.loads((ROOT / "package-lock.json").read_text(encoding="utf-8"))
|
|
5644
|
+
own_declared = None
|
|
5645
|
+
for name, entry in own_lock.get("packages", {}).items():
|
|
5646
|
+
if name.endswith("@fission-ai/openspec"):
|
|
5647
|
+
own_declared = entry.get("version")
|
|
5648
|
+
if not own_declared:
|
|
5649
|
+
report(f"{label} could not read Keel's own declared OpenSpec version.")
|
|
5650
|
+
return 1
|
|
5651
|
+
# The reader sees two versions on this line: the one the resolved binary
|
|
5652
|
+
# reports and the one the repository declares. Assert on the declared
|
|
5653
|
+
# POSITION, not on bare presence — the resolved binary's version may
|
|
5654
|
+
# legitimately equal Keel's own declared version, and a test that reads
|
|
5655
|
+
# any occurrence cannot tell the two apart.
|
|
5656
|
+
# Two distinct failures, two conditions. A line carrying the right
|
|
5657
|
+
# version with no attribution and a line attributing the wrong version
|
|
5658
|
+
# need different fixes, and one message covering both would send half
|
|
5659
|
+
# its readers to a place with no problem in it.
|
|
5660
|
+
if "repo pins" not in line:
|
|
5661
|
+
report(
|
|
5662
|
+
f"{label} M1 the openspec line does not attribute any declared "
|
|
5663
|
+
"version to the repository, so a reader seeing two versions "
|
|
5664
|
+
"cannot tell which one is theirs."
|
|
5665
|
+
)
|
|
5666
|
+
report(f" {line}")
|
|
5667
|
+
return 1
|
|
5668
|
+
if f"repo pins {declared}" not in line:
|
|
5669
|
+
report(
|
|
5670
|
+
f"{label} M1 the openspec line attributes a declared version "
|
|
5671
|
+
f"to the repository, but not {declared}, which is what this "
|
|
5672
|
+
"repository declares. It is answering about some other "
|
|
5673
|
+
"repository."
|
|
5674
|
+
)
|
|
5675
|
+
report(f" {line}")
|
|
5676
|
+
return 1
|
|
5677
|
+
if own_declared != declared and f"repo pins {own_declared}" in line:
|
|
5678
|
+
report(
|
|
5679
|
+
f"{label} M1 the openspec line reports {own_declared} as the "
|
|
5680
|
+
"declared version. That is what is declared where Keel is "
|
|
5681
|
+
"installed, not where it was pointed."
|
|
5682
|
+
)
|
|
5683
|
+
report(f" {line}")
|
|
5684
|
+
return 1
|
|
5685
|
+
if not line.startswith("openspec: warning"):
|
|
5686
|
+
report(
|
|
5687
|
+
f"{label} M1 the resolved binary and the declared version "
|
|
5688
|
+
f"disagree ({declared} is declared and no release reports it), "
|
|
5689
|
+
"and the line does not state the disagreement."
|
|
5690
|
+
)
|
|
5691
|
+
report(f" {line}")
|
|
5692
|
+
return 1
|
|
5693
|
+
|
|
5694
|
+
# M1 second half — absence is a statement about the repository, not a read
|
|
5695
|
+
# failure, and not a disagreement. Most repositories do not depend on
|
|
5696
|
+
# OpenSpec at all, so warning here would train readers to ignore the line.
|
|
5697
|
+
with tempfile.TemporaryDirectory(prefix="keel-doctor-silent-") as raw:
|
|
5698
|
+
silent = Path(raw).resolve()
|
|
5699
|
+
write_text(silent / "README.md", "# consumer\n")
|
|
5700
|
+
doctor = run_keel(silent, "--doctor")
|
|
5701
|
+
line = openspec_line(doctor.stdout or "")
|
|
5702
|
+
if not line:
|
|
5703
|
+
report(f"{label} M1 `keel --doctor` emitted no openspec line.")
|
|
5704
|
+
report((doctor.stdout or doctor.stderr or "").strip())
|
|
5705
|
+
return 1
|
|
5706
|
+
if "declares no OpenSpec" not in line:
|
|
5707
|
+
report(
|
|
5708
|
+
f"{label} M1 a repository declaring no OpenSpec version is not "
|
|
5709
|
+
"reported as declaring none."
|
|
5710
|
+
)
|
|
5711
|
+
report(f" {line}")
|
|
5712
|
+
return 1
|
|
5713
|
+
if "unreadable" in line:
|
|
5714
|
+
report(
|
|
5715
|
+
f"{label} M1 the absence of a declaration is reported as a "
|
|
5716
|
+
"failure to read one. The two are different facts and only one "
|
|
5717
|
+
"of them is true here."
|
|
5718
|
+
)
|
|
5719
|
+
report(f" {line}")
|
|
5720
|
+
return 1
|
|
5721
|
+
if "answering from a different" in line:
|
|
5722
|
+
report(
|
|
5723
|
+
f"{label} M1 a repository declaring nothing is reported as "
|
|
5724
|
+
"disagreeing with the resolved binary. There is nothing for it "
|
|
5725
|
+
"to disagree with."
|
|
5726
|
+
)
|
|
5727
|
+
report(f" {line}")
|
|
5728
|
+
return 1
|
|
5729
|
+
|
|
5730
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
5731
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
5732
|
+
return 1
|
|
5733
|
+
report(f"{label} scenario passed.")
|
|
5734
|
+
return 0
|
|
5735
|
+
|
|
5736
|
+
|
|
5597
5737
|
TASK_SHAPE_TEMPLATE = """## 1. Work
|
|
5598
5738
|
|
|
5599
5739
|
- [ ] 1.1 First half
|
|
@@ -6054,6 +6194,174 @@ def validate_interpreter_surfaces_agree_scenario() -> int:
|
|
|
6054
6194
|
return 0
|
|
6055
6195
|
|
|
6056
6196
|
|
|
6197
|
+
def validate_default_completion_attributes_writes_scenario() -> int:
|
|
6198
|
+
"""A write outside Touch has to fail the gate the author actually runs.
|
|
6199
|
+
|
|
6200
|
+
`attributionResult` refuses an out-of-Touch path in full — diff, union with
|
|
6201
|
+
the dirty set, `pathAllowed` filter, `outside-touch` problem — behind
|
|
6202
|
+
`if (!base) return { problems: [] }`. Without `--base` the whole comparison
|
|
6203
|
+
is skipped and the paths are printed as a warning instead. Measured at
|
|
6204
|
+
5.15.0 on one tree and one task: `pass` with the offending path in a
|
|
6205
|
+
warning, `fail` once `--base HEAD` was added.
|
|
6206
|
+
|
|
6207
|
+
It was found by it happening. During the 5.15.0 release task a file was
|
|
6208
|
+
written that the task had not declared, through a `python3` heredoc in
|
|
6209
|
+
Bash — the write guard binds the host's file-writing tools and cannot bind
|
|
6210
|
+
a shell — and `task-complete` returned `pass`. A human reading `git status`
|
|
6211
|
+
caught it. Nobody reads `git status` in an unattended run, and this
|
|
6212
|
+
repository admits work into unattended runs by declaration.
|
|
6213
|
+
|
|
6214
|
+
The base does not have to come from the caller. `task-start` already writes
|
|
6215
|
+
the manifest at the instant the task is authorized; recording what was
|
|
6216
|
+
dirty then answers "did this task write it" without asking Git to answer
|
|
6217
|
+
"which task wrote it", which is the question it cannot answer in a
|
|
6218
|
+
half-finished change.
|
|
6219
|
+
"""
|
|
6220
|
+
label = "default-completion-attributes-writes"
|
|
6221
|
+
|
|
6222
|
+
def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
6223
|
+
return subprocess.run(
|
|
6224
|
+
["git", "-C", str(repo), *args], capture_output=True, text=True
|
|
6225
|
+
)
|
|
6226
|
+
|
|
6227
|
+
def tasks_doc() -> str:
|
|
6228
|
+
return (
|
|
6229
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
6230
|
+
"- [ ] 1.1 Exercise task contract\n"
|
|
6231
|
+
" - Covers:\n - E1: Public behavior passes.\n"
|
|
6232
|
+
" - Touch:\n - src/declared.js\n"
|
|
6233
|
+
" - Verify:\n - Strategy: evidence-first\n"
|
|
6234
|
+
" - M1: node test.js asserts the recorded feed status\n"
|
|
6235
|
+
" - Evidence:\n - Contract: pending\n - M1: the suite passed\n"
|
|
6236
|
+
" - Review:\n - Status: pass\n"
|
|
6237
|
+
" - Acceptance check: behavior asserted at the interface\n"
|
|
6238
|
+
" - Scope check: only Touch files changed\n"
|
|
6239
|
+
" - Findings: none\n"
|
|
6240
|
+
" - Blocker: none\n"
|
|
6241
|
+
)
|
|
6242
|
+
|
|
6243
|
+
with tempfile.TemporaryDirectory(prefix="keel-default-attribution-") as raw:
|
|
6244
|
+
repo = (Path(raw) / "repo").resolve()
|
|
6245
|
+
repo.mkdir()
|
|
6246
|
+
git(repo, "init", "-q")
|
|
6247
|
+
git(repo, "config", "user.email", "t@example.com")
|
|
6248
|
+
git(repo, "config", "user.name", "keel-test")
|
|
6249
|
+
write_text(repo / "src/declared.js", "// product\n")
|
|
6250
|
+
write_text(repo / "src/undeclared.js", "// product\n")
|
|
6251
|
+
write_text(repo / "src/already-dirty.js", "// product\n")
|
|
6252
|
+
write_text(repo / "openspec/changes/demo/tasks.md", tasks_doc())
|
|
6253
|
+
git(repo, "add", "-A")
|
|
6254
|
+
git(repo, "-c", "commit.gpgsign=false", "commit", "-q", "-m", "base")
|
|
6255
|
+
|
|
6256
|
+
def start(*extra: str) -> subprocess.CompletedProcess[str]:
|
|
6257
|
+
return run_keel(
|
|
6258
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
6259
|
+
"--record", *extra, "--json",
|
|
6260
|
+
)
|
|
6261
|
+
|
|
6262
|
+
def gate(*extra: str) -> dict:
|
|
6263
|
+
return json.loads(
|
|
6264
|
+
run_keel(
|
|
6265
|
+
repo, "gate", "task-complete", "--change", "demo",
|
|
6266
|
+
"--task", "1.1", *extra, "--json",
|
|
6267
|
+
).stdout
|
|
6268
|
+
)
|
|
6269
|
+
|
|
6270
|
+
def outside(payload: dict) -> list[str]:
|
|
6271
|
+
return [
|
|
6272
|
+
problem.get("message", "")
|
|
6273
|
+
for problem in payload.get("problems", [])
|
|
6274
|
+
if problem.get("code") == "outside-touch"
|
|
6275
|
+
]
|
|
6276
|
+
|
|
6277
|
+
# A path already dirty before the task is authorized. It must stay
|
|
6278
|
+
# unattributed afterwards: subtracting the start set is what removes
|
|
6279
|
+
# the false-positive class that made automatic attribution unsafe.
|
|
6280
|
+
write_text(repo / "src/already-dirty.js", "// touched before the task\n")
|
|
6281
|
+
|
|
6282
|
+
if start().returncode != 0:
|
|
6283
|
+
report(f"{label} could not authorize the fixture task.")
|
|
6284
|
+
return 1
|
|
6285
|
+
|
|
6286
|
+
# M1 — the reported defect. A write the guard never saw, and the gate
|
|
6287
|
+
# invoked the way an author actually invokes it.
|
|
6288
|
+
write_text(repo / "src/undeclared.js", "// written outside Touch\n")
|
|
6289
|
+
payload = gate()
|
|
6290
|
+
problems = outside(payload)
|
|
6291
|
+
if not any("src/undeclared.js" in message for message in problems):
|
|
6292
|
+
report(
|
|
6293
|
+
f"{label} M1 a path written outside Touch after task start was "
|
|
6294
|
+
"not refused by the default completion gate. The boundary "
|
|
6295
|
+
"holds only when the caller asks for it."
|
|
6296
|
+
)
|
|
6297
|
+
report(f" status={payload.get('status')!r}")
|
|
6298
|
+
for warning in payload.get("warnings", []):
|
|
6299
|
+
report(f" warning: {warning}")
|
|
6300
|
+
return 1
|
|
6301
|
+
if payload.get("status") != "fail":
|
|
6302
|
+
report(
|
|
6303
|
+
f"{label} M1 the gate named the out-of-Touch path but did not "
|
|
6304
|
+
f"fail; got status {payload.get('status')!r}. A boundary that "
|
|
6305
|
+
"reports without refusing is not a boundary."
|
|
6306
|
+
)
|
|
6307
|
+
return 1
|
|
6308
|
+
if any("src/already-dirty.js" in message for message in problems):
|
|
6309
|
+
report(
|
|
6310
|
+
f"{label} M1 a path that was already dirty when the task "
|
|
6311
|
+
"started was attributed to the task. The recorded set is not "
|
|
6312
|
+
"being subtracted, so an unrelated dirty worktree fails the "
|
|
6313
|
+
"gate."
|
|
6314
|
+
)
|
|
6315
|
+
return 1
|
|
6316
|
+
|
|
6317
|
+
# M1 — an explicit base answers the question the caller asked, which is
|
|
6318
|
+
# the broader one: everything since that commit, including what was
|
|
6319
|
+
# already dirty when the task started.
|
|
6320
|
+
base_problems = outside(gate("--base", "HEAD"))
|
|
6321
|
+
if not any("src/already-dirty.js" in message for message in base_problems):
|
|
6322
|
+
report(
|
|
6323
|
+
f"{label} M1 an explicit --base did not attribute a path that "
|
|
6324
|
+
"changed since that base, so the recorded set is overriding "
|
|
6325
|
+
"the base the caller supplied instead of yielding to it."
|
|
6326
|
+
)
|
|
6327
|
+
for message in base_problems:
|
|
6328
|
+
report(f" {message}")
|
|
6329
|
+
return 1
|
|
6330
|
+
|
|
6331
|
+
# M1 — no record means no attribution. A manifest written before this
|
|
6332
|
+
# existed, or a cleared one, must not be read as a clean start: absence
|
|
6333
|
+
# of a record is not a record of absence.
|
|
6334
|
+
run_keel(repo, "guard", "clear")
|
|
6335
|
+
if start("--no-guard").returncode != 0:
|
|
6336
|
+
report(f"{label} could not re-authorize without a manifest.")
|
|
6337
|
+
return 1
|
|
6338
|
+
payload = gate()
|
|
6339
|
+
if outside(payload):
|
|
6340
|
+
report(
|
|
6341
|
+
f"{label} M1 the gate attributed paths with no recorded "
|
|
6342
|
+
"task-start set. A missing record is being read as a clean "
|
|
6343
|
+
"start, which fails every completion in a dirty repository."
|
|
6344
|
+
)
|
|
6345
|
+
for message in outside(payload):
|
|
6346
|
+
report(f" {message}")
|
|
6347
|
+
return 1
|
|
6348
|
+
if not any(
|
|
6349
|
+
"not attributed" in warning for warning in payload.get("warnings", [])
|
|
6350
|
+
):
|
|
6351
|
+
report(
|
|
6352
|
+
f"{label} M1 with no record the gate neither attributed nor "
|
|
6353
|
+
"reported the dirty paths, so the fallback lost the semantic "
|
|
6354
|
+
"review evidence it is supposed to preserve."
|
|
6355
|
+
)
|
|
6356
|
+
return 1
|
|
6357
|
+
|
|
6358
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
6359
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
6360
|
+
return 1
|
|
6361
|
+
report(f"{label} scenario passed.")
|
|
6362
|
+
return 0
|
|
6363
|
+
|
|
6364
|
+
|
|
6057
6365
|
CJK_PATH = "src/摄影光影规划工具.js"
|
|
6058
6366
|
SPACE_PATH = "src/has space.js"
|
|
6059
6367
|
QUOTE_PATH = 'src/has"quote.js'
|
|
@@ -19286,10 +19594,18 @@ SCENARIOS: tuple = (
|
|
|
19286
19594
|
"git-paths-carry-no-escaping",
|
|
19287
19595
|
validate_git_paths_carry_no_escaping_scenario,
|
|
19288
19596
|
),
|
|
19597
|
+
(
|
|
19598
|
+
"default-completion-attributes-writes",
|
|
19599
|
+
validate_default_completion_attributes_writes_scenario,
|
|
19600
|
+
),
|
|
19289
19601
|
(
|
|
19290
19602
|
"runtime-versions-are-checked",
|
|
19291
19603
|
validate_runtime_versions_are_checked_scenario,
|
|
19292
19604
|
),
|
|
19605
|
+
(
|
|
19606
|
+
"doctor-reads-the-diagnosed-repository",
|
|
19607
|
+
validate_doctor_reads_the_diagnosed_repository_scenario,
|
|
19608
|
+
),
|
|
19293
19609
|
("task-shape-warning", validate_task_shape_warning_scenario),
|
|
19294
19610
|
("context-names-its-keel", validate_context_names_its_keel_scenario),
|
|
19295
19611
|
("interpreter-surfaces-agree", validate_interpreter_surfaces_agree_scenario),
|
package/src/core/gates.js
CHANGED
|
@@ -14,7 +14,7 @@ const {
|
|
|
14
14
|
isPassingReviewStatus,
|
|
15
15
|
parseTasks,
|
|
16
16
|
} = require("./task-contract");
|
|
17
|
-
const { startGuard } = require("./guard");
|
|
17
|
+
const { gitPaths, readManifest, startGuard } = require("./guard");
|
|
18
18
|
|
|
19
19
|
const GATE_STAGES = new Set(["task-start", "task-complete", "change-close"]);
|
|
20
20
|
|
|
@@ -483,33 +483,21 @@ function findingOwnerIsDurable(repo, findings) {
|
|
|
483
483
|
// decoder — and it is a flag rather than a repository setting, so the answer
|
|
484
484
|
// does not depend on how the repository happens to be configured.
|
|
485
485
|
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
// is not a false outside-Touch failure.
|
|
502
|
-
const fields = status.stdout.split("\0").filter(Boolean);
|
|
503
|
-
const paths = [];
|
|
504
|
-
for (let index = 0; index < fields.length; index += 1) {
|
|
505
|
-
const record = fields[index];
|
|
506
|
-
paths.push(record.slice(3));
|
|
507
|
-
if (record[0] === "R" || record[0] === "C") {
|
|
508
|
-
index += 1;
|
|
509
|
-
if (index < fields.length) paths.push(fields[index]);
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
return paths;
|
|
486
|
+
// `gitPaths` moved to guard.js, which owns the worktree reading now that the
|
|
487
|
+
// task-start record and this comparison must use the same one.
|
|
488
|
+
|
|
489
|
+
// The dirty set recorded when this task was authorized, or null when nobody
|
|
490
|
+
// recorded one. Null and empty are different answers: an empty list says
|
|
491
|
+
// nothing was dirty at task start, and null says no record exists, which is
|
|
492
|
+
// what a manifest written before this field, a cleared guard, or a
|
|
493
|
+
// `--no-guard` start all produce. Reading null as empty would attribute the
|
|
494
|
+
// whole worktree to the task and fail every completion in a dirty repository.
|
|
495
|
+
function recordedBaseline(repo, change, task) {
|
|
496
|
+
const loaded = readManifest(repo);
|
|
497
|
+
if (loaded.state !== "ok") return null;
|
|
498
|
+
const manifest = loaded.manifest;
|
|
499
|
+
if (manifest.change !== change || manifest.task !== task) return null;
|
|
500
|
+
return Array.isArray(manifest.startedDirty) ? manifest.startedDirty : null;
|
|
513
501
|
}
|
|
514
502
|
|
|
515
503
|
function touchEntries(task, contract = null) {
|
|
@@ -570,7 +558,15 @@ function scopeEvidence(
|
|
|
570
558
|
tasks = null
|
|
571
559
|
) {
|
|
572
560
|
const dirtyPaths = gitPaths(repo);
|
|
573
|
-
|
|
561
|
+
// An explicit base wins. It asks a broader question than the record does —
|
|
562
|
+
// everything since that commit, not only since this task started — and
|
|
563
|
+
// substituting the narrower answer would make `--base` mean something other
|
|
564
|
+
// than what it says.
|
|
565
|
+
const baseline = base ? null : recordedBaseline(repo, change, task.id);
|
|
566
|
+
if (!base && !baseline) {
|
|
567
|
+
// No base and no record: the original conservatism, and the reason for it
|
|
568
|
+
// is unchanged. Git alone cannot say which task of a half-finished change
|
|
569
|
+
// wrote a given path, so the dirty state stays semantic review evidence.
|
|
574
570
|
return {
|
|
575
571
|
problems: [],
|
|
576
572
|
warnings:
|
|
@@ -583,6 +579,27 @@ function scopeEvidence(
|
|
|
583
579
|
};
|
|
584
580
|
}
|
|
585
581
|
|
|
582
|
+
if (!base) {
|
|
583
|
+
// Dirty now and not dirty when the task started. This answers "did this
|
|
584
|
+
// task write it", which is the question the boundary actually asks; it
|
|
585
|
+
// does not answer "which task wrote it", which is why the completed-
|
|
586
|
+
// sibling exclusion below still applies and still reports itself.
|
|
587
|
+
//
|
|
588
|
+
// A path already dirty at task start is subtracted even if the task also
|
|
589
|
+
// modified it. That is the price of a baseline that is not a commit, and
|
|
590
|
+
// it buys the far larger class this exists to avoid: failing every
|
|
591
|
+
// completion in a worktree that was dirty before the task began.
|
|
592
|
+
const startedDirty = new Set(baseline);
|
|
593
|
+
return attributeChanged(
|
|
594
|
+
repo,
|
|
595
|
+
task,
|
|
596
|
+
dirtyPaths.filter((item) => !startedDirty.has(item)),
|
|
597
|
+
contract,
|
|
598
|
+
change,
|
|
599
|
+
tasks
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
586
603
|
const verified = spawnSync(
|
|
587
604
|
"git",
|
|
588
605
|
["rev-parse", "--verify", `${base}^{commit}`],
|
|
@@ -601,10 +618,23 @@ function scopeEvidence(
|
|
|
601
618
|
if (diff.error || diff.status !== 0) {
|
|
602
619
|
throw new GateInputError(`could not compare Git base: ${base}`);
|
|
603
620
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
621
|
+
return attributeChanged(
|
|
622
|
+
repo,
|
|
623
|
+
task,
|
|
624
|
+
[...diff.stdout.split("\0").filter(Boolean), ...dirtyPaths],
|
|
625
|
+
contract,
|
|
626
|
+
change,
|
|
627
|
+
tasks
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// The one place a candidate path becomes a problem, whichever comparison
|
|
632
|
+
// produced it. Both callers reach it: the recorded-baseline path and the
|
|
633
|
+
// explicit-base path differ only in how they decide which paths are
|
|
634
|
+
// candidates, and a second copy of this would be a second definition of what
|
|
635
|
+
// Touch means.
|
|
636
|
+
function attributeChanged(repo, task, changedList, contract, change, tasks) {
|
|
637
|
+
const changed = new Set(changedList);
|
|
608
638
|
const touch = touchEntries(task, contract);
|
|
609
639
|
// The disposable guard manifest is the one artifact the gate contract itself
|
|
610
640
|
// permits a gate to write, and the selected change's own authoring artifacts
|
package/src/core/guard.js
CHANGED
|
@@ -9,8 +9,44 @@
|
|
|
9
9
|
const crypto = require("crypto");
|
|
10
10
|
const fs = require("fs");
|
|
11
11
|
const path = require("path");
|
|
12
|
+
const { spawnSync } = require("child_process");
|
|
12
13
|
const { loadTaskContract } = require("./task-contract");
|
|
13
14
|
|
|
15
|
+
// Nothing rewrites backslashes here. Git emits forward slashes on every
|
|
16
|
+
// platform, so the rewrite normalized a separator that never arrives while
|
|
17
|
+
// turning `\346` into `/346`, which is how a path declared on the first line
|
|
18
|
+
// of Touch was reported as outside Touch (issue #40).
|
|
19
|
+
//
|
|
20
|
+
// This lives here rather than in gates.js because both the task-start record
|
|
21
|
+
// and the completion comparison read it, and gates.js already requires this
|
|
22
|
+
// module. One implementation is the point: a baseline and a comparison that
|
|
23
|
+
// disagreed about what "dirty" means, or about how a rename is represented,
|
|
24
|
+
// would attribute a path nobody wrote.
|
|
25
|
+
function gitPaths(repo) {
|
|
26
|
+
const status = spawnSync(
|
|
27
|
+
"git",
|
|
28
|
+
["status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
29
|
+
{ cwd: repo, encoding: "utf8" }
|
|
30
|
+
);
|
|
31
|
+
if (status.error || status.status !== 0) return [];
|
|
32
|
+
// Each record is `XY <path>`, NUL-terminated. A rename or copy is followed
|
|
33
|
+
// by a second bare field holding its other endpoint — the new path first in
|
|
34
|
+
// `-z`, the reverse of the ` -> ` line format. The order is immaterial:
|
|
35
|
+
// both endpoints are attributed, so a rename whose paths are both in Touch
|
|
36
|
+
// is not a false outside-Touch failure.
|
|
37
|
+
const fields = status.stdout.split("\0").filter(Boolean);
|
|
38
|
+
const paths = [];
|
|
39
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
40
|
+
const record = fields[index];
|
|
41
|
+
paths.push(record.slice(3));
|
|
42
|
+
if (record[0] === "R" || record[0] === "C") {
|
|
43
|
+
index += 1;
|
|
44
|
+
if (index < fields.length) paths.push(fields[index]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return paths;
|
|
48
|
+
}
|
|
49
|
+
|
|
14
50
|
const MANIFEST_SCHEMA = "keel-write-guard/v1";
|
|
15
51
|
|
|
16
52
|
class GuardInputError extends Error {}
|
|
@@ -121,6 +157,18 @@ function readManifest(repo) {
|
|
|
121
157
|
) {
|
|
122
158
|
shapeErrors.push("authority must list hashed source files");
|
|
123
159
|
}
|
|
160
|
+
// Optional on purpose. A manifest written before this field existed, and one
|
|
161
|
+
// written by a Keel that omits it, are both valid; what they are not is
|
|
162
|
+
// evidence that nothing was dirty. The consumer distinguishes absent from
|
|
163
|
+
// empty, so an empty list means "nothing was dirty" and an absent one means
|
|
164
|
+
// "nobody looked".
|
|
165
|
+
if (
|
|
166
|
+
manifest.startedDirty !== undefined
|
|
167
|
+
&& (!Array.isArray(manifest.startedDirty)
|
|
168
|
+
|| manifest.startedDirty.some((item) => typeof item !== "string"))
|
|
169
|
+
) {
|
|
170
|
+
shapeErrors.push("startedDirty must be a string list when present");
|
|
171
|
+
}
|
|
124
172
|
if (shapeErrors.length > 0) {
|
|
125
173
|
return {
|
|
126
174
|
state: "invalid",
|
|
@@ -182,6 +230,9 @@ function startGuard(repo, options) {
|
|
|
182
230
|
}
|
|
183
231
|
|
|
184
232
|
const paths = authorityPaths(repo, options.change, loaded.contract);
|
|
233
|
+
// Read before the manifest is written, so the manifest is never in its own
|
|
234
|
+
// record and cannot be attributed to the task it authorizes.
|
|
235
|
+
const startedDirty = gitPaths(repo);
|
|
185
236
|
const manifest = {
|
|
186
237
|
schema: MANIFEST_SCHEMA,
|
|
187
238
|
change: options.change,
|
|
@@ -189,6 +240,7 @@ function startGuard(repo, options) {
|
|
|
189
240
|
fingerprint: loaded.contract.fingerprint,
|
|
190
241
|
touch: loaded.contract.capsule.touch,
|
|
191
242
|
authority: hashAuthority(repo, paths),
|
|
243
|
+
startedDirty,
|
|
192
244
|
};
|
|
193
245
|
fs.mkdirSync(path.join(repo, "keel"), { recursive: true });
|
|
194
246
|
fs.writeFileSync(
|
|
@@ -302,6 +354,7 @@ module.exports = {
|
|
|
302
354
|
GuardInputError,
|
|
303
355
|
MANIFEST_SCHEMA,
|
|
304
356
|
clearGuard,
|
|
357
|
+
gitPaths,
|
|
305
358
|
guardStatus,
|
|
306
359
|
readManifest,
|
|
307
360
|
renderGuard,
|