@christang/keel 5.14.0 → 5.20.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/install_to_repo.py +12 -2
- package/scripts/validate_plugin.py +956 -2
- package/src/core/gates.js +111 -47
- package/src/core/guard.js +53 -0
- package/src/core/task-contract.js +18 -4
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
|
|
|
@@ -372,6 +372,35 @@ const DURABLE_OWNER_FORMS =
|
|
|
372
372
|
+ "repository's own ledger; `keel/HANDOFF.md` is a pointer override rather "
|
|
373
373
|
+ "than an owner";
|
|
374
374
|
|
|
375
|
+
// Trailing punctuation a declared path can abut in prose. ASCII sentence marks
|
|
376
|
+
// and their CJK counterparts both belong here: once the extractor stops
|
|
377
|
+
// assuming ASCII, its terminators cannot assume ASCII either. A Chinese
|
|
378
|
+
// sentence ends in `。`, which is not whitespace, so a non-whitespace run
|
|
379
|
+
// swallows it and the gate looks for a file that cannot exist.
|
|
380
|
+
const DECLARED_PATH_TRAILING = /[.,;:!?)\]}"'\u2019\u201d\u3002\uff0c\u3001\uff1b\uff1a\uff01\uff1f\uff09\u3011\u300b\u300d\u300f]+$/;
|
|
381
|
+
|
|
382
|
+
// A declared path is a run of non-whitespace holding a separator. What ends a
|
|
383
|
+
// path is whitespace; what a path is *made of* is the filesystem's business,
|
|
384
|
+
// and answering the first question with the second is what refused
|
|
385
|
+
// `notes/note-006-转岗最难的不是流程/note.md` by reporting that
|
|
386
|
+
// `notes/note-006-` does not exist — a path nobody wrote (issue #60). It is the
|
|
387
|
+
// same class as #40 on the worktree-reading side, which survived because that
|
|
388
|
+
// fix repaired one reader rather than how paths are extracted; this is the one
|
|
389
|
+
// extractor every gate reader of a declared path now uses.
|
|
390
|
+
//
|
|
391
|
+
// The backtick form wins when present. It is the only way to write a path
|
|
392
|
+
// containing whitespace, and `touchEntries` already strips backticks from a
|
|
393
|
+
// Touch entry, so one authorship stops being spelled two ways depending on
|
|
394
|
+
// which reader will read it.
|
|
395
|
+
function declaredPath(value) {
|
|
396
|
+
const text = String(value || "");
|
|
397
|
+
const quoted = text.match(/`([^`\n]*\/[^`\n]*)`/);
|
|
398
|
+
if (quoted) return quoted[1].trim() || null;
|
|
399
|
+
const bare = text.match(/[^\s`]+\/[^\s`]+/);
|
|
400
|
+
if (!bare) return null;
|
|
401
|
+
return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
|
|
402
|
+
}
|
|
403
|
+
|
|
375
404
|
// Classify a declared `Durable owner:` value. A gate runs without network, so a
|
|
376
405
|
// URL is accepted on shape alone; a path is the one form it can actually check,
|
|
377
406
|
// and checking it is stricter than the prefix whitelist this replaced.
|
|
@@ -380,10 +409,10 @@ function durableOwnerVerdict(repo, value) {
|
|
|
380
409
|
if (!owner) return { ok: false, reason: "unrecognized" };
|
|
381
410
|
if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
|
|
382
411
|
if (TRACKER_REFERENCE.test(owner)) return { ok: true };
|
|
383
|
-
const candidate = owner
|
|
412
|
+
const candidate = declaredPath(owner);
|
|
384
413
|
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
385
|
-
if (fs.existsSync(path.join(repo, candidate
|
|
386
|
-
return { ok: false, reason: "missing", path: candidate
|
|
414
|
+
if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
|
|
415
|
+
return { ok: false, reason: "missing", path: candidate };
|
|
387
416
|
}
|
|
388
417
|
|
|
389
418
|
// A finding has three dispositions and the gate recognized two. One found and
|
|
@@ -423,10 +452,10 @@ function resolutionEvidenceVerdict(repo, value, commands) {
|
|
|
423
452
|
if (commands.includes(cited[0])) return { ok: true };
|
|
424
453
|
return { ok: false, reason: "unknown-check", label: cited[0] };
|
|
425
454
|
}
|
|
426
|
-
const candidate = evidence
|
|
455
|
+
const candidate = declaredPath(evidence);
|
|
427
456
|
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
428
|
-
if (fs.existsSync(path.join(repo, candidate
|
|
429
|
-
return { ok: false, reason: "missing", path: candidate
|
|
457
|
+
if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
|
|
458
|
+
return { ok: false, reason: "missing", path: candidate };
|
|
430
459
|
}
|
|
431
460
|
|
|
432
461
|
function resolutionEvidenceMessage(verdict) {
|
|
@@ -466,13 +495,12 @@ function findingOwnerIsDurable(repo, findings) {
|
|
|
466
495
|
/\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
|
|
467
496
|
);
|
|
468
497
|
if (artifact && fs.existsSync(path.join(repo, artifact[1]))) return true;
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
);
|
|
498
|
+
// Same extractor, scoped to the archive prefix: the segment after
|
|
499
|
+
// `keel/archive/` is a path like any other and was equally ASCII-bound.
|
|
500
|
+
const archive = findings.match(/keel\/archive\/[^\s`]*/i);
|
|
501
|
+
if (!archive) return false;
|
|
502
|
+
const archivePath = declaredPath(archive[0]);
|
|
503
|
+
return Boolean(archivePath) && fs.existsSync(path.join(repo, archivePath));
|
|
476
504
|
}
|
|
477
505
|
|
|
478
506
|
// Read in `-z` form, because every other form escapes. Git octal-escapes any
|
|
@@ -483,33 +511,21 @@ function findingOwnerIsDurable(repo, findings) {
|
|
|
483
511
|
// decoder — and it is a flag rather than a repository setting, so the answer
|
|
484
512
|
// does not depend on how the repository happens to be configured.
|
|
485
513
|
//
|
|
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;
|
|
514
|
+
// `gitPaths` moved to guard.js, which owns the worktree reading now that the
|
|
515
|
+
// task-start record and this comparison must use the same one.
|
|
516
|
+
|
|
517
|
+
// The dirty set recorded when this task was authorized, or null when nobody
|
|
518
|
+
// recorded one. Null and empty are different answers: an empty list says
|
|
519
|
+
// nothing was dirty at task start, and null says no record exists, which is
|
|
520
|
+
// what a manifest written before this field, a cleared guard, or a
|
|
521
|
+
// `--no-guard` start all produce. Reading null as empty would attribute the
|
|
522
|
+
// whole worktree to the task and fail every completion in a dirty repository.
|
|
523
|
+
function recordedBaseline(repo, change, task) {
|
|
524
|
+
const loaded = readManifest(repo);
|
|
525
|
+
if (loaded.state !== "ok") return null;
|
|
526
|
+
const manifest = loaded.manifest;
|
|
527
|
+
if (manifest.change !== change || manifest.task !== task) return null;
|
|
528
|
+
return Array.isArray(manifest.startedDirty) ? manifest.startedDirty : null;
|
|
513
529
|
}
|
|
514
530
|
|
|
515
531
|
function touchEntries(task, contract = null) {
|
|
@@ -570,7 +586,15 @@ function scopeEvidence(
|
|
|
570
586
|
tasks = null
|
|
571
587
|
) {
|
|
572
588
|
const dirtyPaths = gitPaths(repo);
|
|
573
|
-
|
|
589
|
+
// An explicit base wins. It asks a broader question than the record does —
|
|
590
|
+
// everything since that commit, not only since this task started — and
|
|
591
|
+
// substituting the narrower answer would make `--base` mean something other
|
|
592
|
+
// than what it says.
|
|
593
|
+
const baseline = base ? null : recordedBaseline(repo, change, task.id);
|
|
594
|
+
if (!base && !baseline) {
|
|
595
|
+
// No base and no record: the original conservatism, and the reason for it
|
|
596
|
+
// is unchanged. Git alone cannot say which task of a half-finished change
|
|
597
|
+
// wrote a given path, so the dirty state stays semantic review evidence.
|
|
574
598
|
return {
|
|
575
599
|
problems: [],
|
|
576
600
|
warnings:
|
|
@@ -583,6 +607,27 @@ function scopeEvidence(
|
|
|
583
607
|
};
|
|
584
608
|
}
|
|
585
609
|
|
|
610
|
+
if (!base) {
|
|
611
|
+
// Dirty now and not dirty when the task started. This answers "did this
|
|
612
|
+
// task write it", which is the question the boundary actually asks; it
|
|
613
|
+
// does not answer "which task wrote it", which is why the completed-
|
|
614
|
+
// sibling exclusion below still applies and still reports itself.
|
|
615
|
+
//
|
|
616
|
+
// A path already dirty at task start is subtracted even if the task also
|
|
617
|
+
// modified it. That is the price of a baseline that is not a commit, and
|
|
618
|
+
// it buys the far larger class this exists to avoid: failing every
|
|
619
|
+
// completion in a worktree that was dirty before the task began.
|
|
620
|
+
const startedDirty = new Set(baseline);
|
|
621
|
+
return attributeChanged(
|
|
622
|
+
repo,
|
|
623
|
+
task,
|
|
624
|
+
dirtyPaths.filter((item) => !startedDirty.has(item)),
|
|
625
|
+
contract,
|
|
626
|
+
change,
|
|
627
|
+
tasks
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
586
631
|
const verified = spawnSync(
|
|
587
632
|
"git",
|
|
588
633
|
["rev-parse", "--verify", `${base}^{commit}`],
|
|
@@ -601,10 +646,23 @@ function scopeEvidence(
|
|
|
601
646
|
if (diff.error || diff.status !== 0) {
|
|
602
647
|
throw new GateInputError(`could not compare Git base: ${base}`);
|
|
603
648
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
649
|
+
return attributeChanged(
|
|
650
|
+
repo,
|
|
651
|
+
task,
|
|
652
|
+
[...diff.stdout.split("\0").filter(Boolean), ...dirtyPaths],
|
|
653
|
+
contract,
|
|
654
|
+
change,
|
|
655
|
+
tasks
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// The one place a candidate path becomes a problem, whichever comparison
|
|
660
|
+
// produced it. Both callers reach it: the recorded-baseline path and the
|
|
661
|
+
// explicit-base path differ only in how they decide which paths are
|
|
662
|
+
// candidates, and a second copy of this would be a second definition of what
|
|
663
|
+
// Touch means.
|
|
664
|
+
function attributeChanged(repo, task, changedList, contract, change, tasks) {
|
|
665
|
+
const changed = new Set(changedList);
|
|
608
666
|
const touch = touchEntries(task, contract);
|
|
609
667
|
// The disposable guard manifest is the one artifact the gate contract itself
|
|
610
668
|
// permits a gate to write, and the selected change's own authoring artifacts
|
|
@@ -648,7 +706,13 @@ function completionChecks(repo, task, contract = null) {
|
|
|
648
706
|
const commands = contract
|
|
649
707
|
? contract.capsule.verification.commands.map((item) => item.label)
|
|
650
708
|
: commandLabels(task);
|
|
651
|
-
|
|
709
|
+
// With no contract, the labels came from the expanded v3 `Commands` field,
|
|
710
|
+
// which a compact task never declares — so their absence is a fact about the
|
|
711
|
+
// fallback, not about the task. The compiler's own diagnostics are already in
|
|
712
|
+
// `problems` (the caller pushes them unconditionally, and an unusable
|
|
713
|
+
// contract has at least one), so this cannot turn a refusal into a pass. The
|
|
714
|
+
// per-label evidence checks below stay: a genuine v3 task yields real labels.
|
|
715
|
+
if (contract && commands.length === 0) {
|
|
652
716
|
problems.push(problem("missing-commands", "Commands must define at least one M<n>."));
|
|
653
717
|
}
|
|
654
718
|
for (const label of commands) {
|
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,
|
|
@@ -416,10 +416,24 @@ function requiredFieldProblems(task) {
|
|
|
416
416
|
function missingFieldProblems(task, names) {
|
|
417
417
|
return names
|
|
418
418
|
.filter((name) => !isConcrete(field(task, name)))
|
|
419
|
-
.map((name) =>
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
419
|
+
.map((name) => {
|
|
420
|
+
// Name the matched slot, the way the check and Verify diagnostics already
|
|
421
|
+
// do. The unqualified wording stated only the verdict, so an author whose
|
|
422
|
+
// field held a token in ordinary prose had nothing to search for — and
|
|
423
|
+
// the first problem they saw named a field of the other schema instead.
|
|
424
|
+
// The code is unchanged: the verdict is the same either way, and a new
|
|
425
|
+
// one would hide this case from every consumer keying on `missing-field`.
|
|
426
|
+
const token = unfilledToken(field(task, name));
|
|
427
|
+
return {
|
|
428
|
+
code: "missing-field",
|
|
429
|
+
message: token
|
|
430
|
+
? `${name} carries the unfilled slot \`${token}\`, so it is not `
|
|
431
|
+
+ "concrete. Replace that slot with the value it stands for, or "
|
|
432
|
+
+ "fence it in inline code when it is literal text — a numeric "
|
|
433
|
+
+ "range or test output — rather than a slot left to fill."
|
|
434
|
+
: `${name} must be concrete.`,
|
|
435
|
+
};
|
|
436
|
+
});
|
|
423
437
|
}
|
|
424
438
|
|
|
425
439
|
function canonical(value) {
|