@christang/keel 5.16.0 → 5.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -9
- package/assets/bootstrap/AGENTS.md +1 -1
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +4 -1
- package/bin/keel.js +127 -22
- 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-align-expectations/SKILL.md +4 -18
- package/plugins/keel/skills/keel-review-checklist/SKILL.md +1 -1
- package/scripts/install_to_repo.py +56 -3
- package/scripts/validate_plugin.py +3782 -228
- package/src/core/config.js +192 -26
- package/src/core/context.js +9 -0
- package/src/core/gates.js +305 -54
- package/src/core/guard.js +77 -16
- package/src/core/task-contract.js +103 -7
package/src/core/gates.js
CHANGED
|
@@ -13,8 +13,9 @@ const {
|
|
|
13
13
|
isConcrete,
|
|
14
14
|
isPassingReviewStatus,
|
|
15
15
|
parseTasks,
|
|
16
|
+
unfilledToken,
|
|
16
17
|
} = require("./task-contract");
|
|
17
|
-
const { gitPaths, readManifest, startGuard } = require("./guard");
|
|
18
|
+
const { contentSignature, gitPaths, readManifest, startGuard } = require("./guard");
|
|
18
19
|
|
|
19
20
|
const GATE_STAGES = new Set(["task-start", "task-complete", "change-close"]);
|
|
20
21
|
|
|
@@ -349,11 +350,38 @@ function evidenceValue(task, label) {
|
|
|
349
350
|
return match ? match[1] : "";
|
|
350
351
|
}
|
|
351
352
|
|
|
353
|
+
// A Review entry is the text the author wrote under its label, not its first
|
|
354
|
+
// line. `parseTasks()` already gathers the whole Evidence body, so the
|
|
355
|
+
// continuation lines arrive here; a line-anchored `(.*)` used to drop them
|
|
356
|
+
// before any check saw them. That failed in both directions: a `Findings` whose
|
|
357
|
+
// `Durable owner:` sat on the fourth line was refused with the owner present
|
|
358
|
+
// and the path existing, and a `Findings` reading `none` above three lines of
|
|
359
|
+
// real findings passed, because the check tested the word and never saw them
|
|
360
|
+
// (issue #49).
|
|
361
|
+
//
|
|
362
|
+
// The entry ends at the next entry at the same or shallower indentation.
|
|
363
|
+
// Without that bound `Findings` — always the last of the four — would run to
|
|
364
|
+
// the end of Evidence and read `- Blocker:` as its own text, trading a
|
|
365
|
+
// fail-closed defect for a fail-open one. A deeper-indented `- ` line is a
|
|
366
|
+
// continuation, which is what makes a Findings written as a sub-list one entry.
|
|
367
|
+
const REVIEW_SIBLING = /^(\s*)-\s*[^\s:][^:\n]*:/;
|
|
368
|
+
|
|
352
369
|
function reviewValue(task, label) {
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
)
|
|
356
|
-
|
|
370
|
+
const lines = field(task, "Evidence").split(/\r?\n/);
|
|
371
|
+
const opener = new RegExp(`^(\\s*)-\\s*${label}:\\s*(.*)$`, "i");
|
|
372
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
373
|
+
const match = lines[index].match(opener);
|
|
374
|
+
if (!match) continue;
|
|
375
|
+
const indent = match[1].length;
|
|
376
|
+
const value = [match[2]];
|
|
377
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
378
|
+
const sibling = lines[cursor].match(REVIEW_SIBLING);
|
|
379
|
+
if (sibling && sibling[1].length <= indent) break;
|
|
380
|
+
value.push(lines[cursor]);
|
|
381
|
+
}
|
|
382
|
+
return value.join("\n").trim();
|
|
383
|
+
}
|
|
384
|
+
return "";
|
|
357
385
|
}
|
|
358
386
|
|
|
359
387
|
// The durable-owner forms that are pure shape checks, shared by the Review
|
|
@@ -372,6 +400,35 @@ const DURABLE_OWNER_FORMS =
|
|
|
372
400
|
+ "repository's own ledger; `keel/HANDOFF.md` is a pointer override rather "
|
|
373
401
|
+ "than an owner";
|
|
374
402
|
|
|
403
|
+
// Trailing punctuation a declared path can abut in prose. ASCII sentence marks
|
|
404
|
+
// and their CJK counterparts both belong here: once the extractor stops
|
|
405
|
+
// assuming ASCII, its terminators cannot assume ASCII either. A Chinese
|
|
406
|
+
// sentence ends in `。`, which is not whitespace, so a non-whitespace run
|
|
407
|
+
// swallows it and the gate looks for a file that cannot exist.
|
|
408
|
+
const DECLARED_PATH_TRAILING = /[.,;:!?)\]}"'\u2019\u201d\u3002\uff0c\u3001\uff1b\uff1a\uff01\uff1f\uff09\u3011\u300b\u300d\u300f]+$/;
|
|
409
|
+
|
|
410
|
+
// A declared path is a run of non-whitespace holding a separator. What ends a
|
|
411
|
+
// path is whitespace; what a path is *made of* is the filesystem's business,
|
|
412
|
+
// and answering the first question with the second is what refused
|
|
413
|
+
// `notes/note-006-转岗最难的不是流程/note.md` by reporting that
|
|
414
|
+
// `notes/note-006-` does not exist — a path nobody wrote (issue #60). It is the
|
|
415
|
+
// same class as #40 on the worktree-reading side, which survived because that
|
|
416
|
+
// fix repaired one reader rather than how paths are extracted; this is the one
|
|
417
|
+
// extractor every gate reader of a declared path now uses.
|
|
418
|
+
//
|
|
419
|
+
// The backtick form wins when present. It is the only way to write a path
|
|
420
|
+
// containing whitespace, and `touchEntries` already strips backticks from a
|
|
421
|
+
// Touch entry, so one authorship stops being spelled two ways depending on
|
|
422
|
+
// which reader will read it.
|
|
423
|
+
function declaredPath(value) {
|
|
424
|
+
const text = String(value || "");
|
|
425
|
+
const quoted = text.match(/`([^`\n]*\/[^`\n]*)`/);
|
|
426
|
+
if (quoted) return quoted[1].trim() || null;
|
|
427
|
+
const bare = text.match(/[^\s`]+\/[^\s`]+/);
|
|
428
|
+
if (!bare) return null;
|
|
429
|
+
return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
|
|
430
|
+
}
|
|
431
|
+
|
|
375
432
|
// Classify a declared `Durable owner:` value. A gate runs without network, so a
|
|
376
433
|
// URL is accepted on shape alone; a path is the one form it can actually check,
|
|
377
434
|
// and checking it is stricter than the prefix whitelist this replaced.
|
|
@@ -380,10 +437,10 @@ function durableOwnerVerdict(repo, value) {
|
|
|
380
437
|
if (!owner) return { ok: false, reason: "unrecognized" };
|
|
381
438
|
if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
|
|
382
439
|
if (TRACKER_REFERENCE.test(owner)) return { ok: true };
|
|
383
|
-
const candidate = owner
|
|
440
|
+
const candidate = declaredPath(owner);
|
|
384
441
|
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
385
|
-
if (fs.existsSync(path.join(repo, candidate
|
|
386
|
-
return { ok: false, reason: "missing", path: candidate
|
|
442
|
+
if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
|
|
443
|
+
return { ok: false, reason: "missing", path: candidate };
|
|
387
444
|
}
|
|
388
445
|
|
|
389
446
|
// A finding has three dispositions and the gate recognized two. One found and
|
|
@@ -395,13 +452,15 @@ function durableOwnerVerdict(repo, value) {
|
|
|
395
452
|
// same third slot since it shipped, where `Updated by:` names tasks of this
|
|
396
453
|
// change.
|
|
397
454
|
// The capture is the single token after the marker, not the rest of the line.
|
|
398
|
-
// Findings is
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
// Measured on this change's own task 1.3.
|
|
403
|
-
//
|
|
404
|
-
//
|
|
455
|
+
// Findings is free prose that normally holds several findings with different
|
|
456
|
+
// dispositions, so a capture reaching to the newline swallows every marker
|
|
457
|
+
// after it — a block recording one fix and one tracker-owned follow-up was
|
|
458
|
+
// refused because the *follow-up's* URL was read as the *fix's* evidence.
|
|
459
|
+
// Measured on this change's own task 1.3. That block used to be one line and
|
|
460
|
+
// may now wrap across several, which widens what a greedy capture would
|
|
461
|
+
// swallow and changes nothing about why this one is narrow. The match is
|
|
462
|
+
// global because each resolved claim owes its own evidence; checking only the
|
|
463
|
+
// first would let a second one assert itself for free.
|
|
405
464
|
const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
|
|
406
465
|
|
|
407
466
|
// Resolution evidence is deliberately narrower than a durable owner. An
|
|
@@ -423,10 +482,10 @@ function resolutionEvidenceVerdict(repo, value, commands) {
|
|
|
423
482
|
if (commands.includes(cited[0])) return { ok: true };
|
|
424
483
|
return { ok: false, reason: "unknown-check", label: cited[0] };
|
|
425
484
|
}
|
|
426
|
-
const candidate = evidence
|
|
485
|
+
const candidate = declaredPath(evidence);
|
|
427
486
|
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
428
|
-
if (fs.existsSync(path.join(repo, candidate
|
|
429
|
-
return { ok: false, reason: "missing", path: candidate
|
|
487
|
+
if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
|
|
488
|
+
return { ok: false, reason: "missing", path: candidate };
|
|
430
489
|
}
|
|
431
490
|
|
|
432
491
|
function resolutionEvidenceMessage(verdict) {
|
|
@@ -466,13 +525,12 @@ function findingOwnerIsDurable(repo, findings) {
|
|
|
466
525
|
/\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
|
|
467
526
|
);
|
|
468
527
|
if (artifact && fs.existsSync(path.join(repo, artifact[1]))) return true;
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
);
|
|
528
|
+
// Same extractor, scoped to the archive prefix: the segment after
|
|
529
|
+
// `keel/archive/` is a path like any other and was equally ASCII-bound.
|
|
530
|
+
const archive = findings.match(/keel\/archive\/[^\s`]*/i);
|
|
531
|
+
if (!archive) return false;
|
|
532
|
+
const archivePath = declaredPath(archive[0]);
|
|
533
|
+
return Boolean(archivePath) && fs.existsSync(path.join(repo, archivePath));
|
|
476
534
|
}
|
|
477
535
|
|
|
478
536
|
// Read in `-z` form, because every other form escapes. Git octal-escapes any
|
|
@@ -492,6 +550,9 @@ function findingOwnerIsDurable(repo, findings) {
|
|
|
492
550
|
// what a manifest written before this field, a cleared guard, or a
|
|
493
551
|
// `--no-guard` start all produce. Reading null as empty would attribute the
|
|
494
552
|
// whole worktree to the task and fail every completion in a dirty repository.
|
|
553
|
+
// Each entry is `{ path, sha256 }`, the content signature `contentSignature`
|
|
554
|
+
// read at that same moment — the record of *what* was dirty, not only that a
|
|
555
|
+
// path was.
|
|
495
556
|
function recordedBaseline(repo, change, task) {
|
|
496
557
|
const loaded = readManifest(repo);
|
|
497
558
|
if (loaded.state !== "ok") return null;
|
|
@@ -580,20 +641,27 @@ function scopeEvidence(
|
|
|
580
641
|
}
|
|
581
642
|
|
|
582
643
|
if (!base) {
|
|
583
|
-
// Dirty now and not dirty when the task started
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
644
|
+
// Dirty now and not dirty when the task started, or dirty now with
|
|
645
|
+
// content that no longer matches what was there at task start. Either
|
|
646
|
+
// answers "did this task write it", which is the question the boundary
|
|
647
|
+
// actually asks; it does not answer "which task wrote it", which is why
|
|
648
|
+
// the completed-sibling exclusion below still applies and still reports
|
|
649
|
+
// itself.
|
|
587
650
|
//
|
|
588
|
-
// A path already dirty at task start is
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
|
|
651
|
+
// A path already dirty at task start is exempt only while its content
|
|
652
|
+
// stays the one recorded then — recording a hash instead of only a name
|
|
653
|
+
// is what lets that hold without falling back to subtracting the whole
|
|
654
|
+
// path, which exempted every later write to it, not just the one that
|
|
655
|
+
// predated the task (#72).
|
|
656
|
+
const unchangedSinceStart = new Set(
|
|
657
|
+
baseline
|
|
658
|
+
.filter((entry) => contentSignature(repo, entry.path) === entry.sha256)
|
|
659
|
+
.map((entry) => entry.path)
|
|
660
|
+
);
|
|
593
661
|
return attributeChanged(
|
|
594
662
|
repo,
|
|
595
663
|
task,
|
|
596
|
-
dirtyPaths.filter((item) => !
|
|
664
|
+
dirtyPaths.filter((item) => !unchangedSinceStart.has(item)),
|
|
597
665
|
contract,
|
|
598
666
|
change,
|
|
599
667
|
tasks
|
|
@@ -673,12 +741,18 @@ function attributeChanged(repo, task, changedList, contract, change, tasks) {
|
|
|
673
741
|
};
|
|
674
742
|
}
|
|
675
743
|
|
|
676
|
-
function completionChecks(repo, task, contract = null) {
|
|
744
|
+
function completionChecks(repo, task, contract = null, changeVerify = null) {
|
|
677
745
|
const problems = [];
|
|
678
746
|
const commands = contract
|
|
679
747
|
? contract.capsule.verification.commands.map((item) => item.label)
|
|
680
748
|
: commandLabels(task);
|
|
681
|
-
|
|
749
|
+
// With no contract, the labels came from the expanded v3 `Commands` field,
|
|
750
|
+
// which a compact task never declares — so their absence is a fact about the
|
|
751
|
+
// fallback, not about the task. The compiler's own diagnostics are already in
|
|
752
|
+
// `problems` (the caller pushes them unconditionally, and an unusable
|
|
753
|
+
// contract has at least one), so this cannot turn a refusal into a pass. The
|
|
754
|
+
// per-label evidence checks below stay: a genuine v3 task yields real labels.
|
|
755
|
+
if (contract && commands.length === 0) {
|
|
682
756
|
problems.push(problem("missing-commands", "Commands must define at least one M<n>."));
|
|
683
757
|
}
|
|
684
758
|
for (const label of commands) {
|
|
@@ -716,11 +790,65 @@ function completionChecks(repo, task, contract = null) {
|
|
|
716
790
|
}
|
|
717
791
|
}
|
|
718
792
|
}
|
|
793
|
+
// A `(regression)` check's bare Evidence may defer to a change-level `C<n>`
|
|
794
|
+
// check instead of recording its own result (issue #95). The regression
|
|
795
|
+
// flag comes from the compiled contract, the same source the exemption
|
|
796
|
+
// above already trusts, so this cannot disagree with what `(regression)`
|
|
797
|
+
// itself decided. Resolution only — whether the reference is declared, not
|
|
798
|
+
// whether it has run yet — because at task-complete time it legitimately
|
|
799
|
+
// may not have; `changeVerifyProblems` requires it answered by close.
|
|
800
|
+
if (contract) {
|
|
801
|
+
const declaredLabels = new Set(
|
|
802
|
+
(changeVerify ? changeVerify.checks : []).map((entry) => entry.label)
|
|
803
|
+
);
|
|
804
|
+
for (const entry of contract.capsule.verification.commands) {
|
|
805
|
+
const deferred = deferredChangeCheck(evidenceValue(task, entry.label));
|
|
806
|
+
if (!deferred) continue;
|
|
807
|
+
if (!entry.regression) {
|
|
808
|
+
problems.push(
|
|
809
|
+
problem(
|
|
810
|
+
"deferred-evidence-not-regression",
|
|
811
|
+
`${entry.label} Evidence defers to ${deferred}, but ${entry.label} `
|
|
812
|
+
+ "is not tagged `(regression)`; only a `(regression)`-tagged "
|
|
813
|
+
+ "check may defer to a change-level check."
|
|
814
|
+
)
|
|
815
|
+
);
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (!declaredLabels.has(deferred)) {
|
|
819
|
+
problems.push(
|
|
820
|
+
problem(
|
|
821
|
+
"deferred-check-unresolved",
|
|
822
|
+
`${entry.label} defers to ${deferred}, but tasks.md's \`## `
|
|
823
|
+
+ `Change Verify\` does not declare it. Declare it there, or `
|
|
824
|
+
+ `record concrete Evidence for ${entry.label} directly.`
|
|
825
|
+
)
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
719
830
|
const blocker = evidenceValue(task, "Blocker");
|
|
720
831
|
if (isConcrete(blocker)) {
|
|
721
832
|
problems.push(problem("blocker", `Task records a blocker: ${blocker}`));
|
|
722
833
|
}
|
|
723
834
|
|
|
835
|
+
// Reauthorizations (#70) is a log, not a stop condition: absent, `none`, and
|
|
836
|
+
// concrete text all pass. Only an abandoned `<slot>` token — real content
|
|
837
|
+
// the author started and never finished — is refused, the same distinction
|
|
838
|
+
// `unfilledToken()` already draws for every other field that uses it.
|
|
839
|
+
const reauthorizationsToken = unfilledToken(reviewValue(task, "Reauthorizations"));
|
|
840
|
+
if (reauthorizationsToken) {
|
|
841
|
+
problems.push(
|
|
842
|
+
problem(
|
|
843
|
+
"reauthorizations-shape",
|
|
844
|
+
`Reauthorizations carries the unfilled slot \`${reauthorizationsToken}\`, `
|
|
845
|
+
+ "so it is not concrete. Replace that slot with the value it stands "
|
|
846
|
+
+ "for, or fence it in inline code when it is literal text rather "
|
|
847
|
+
+ "than a slot left to fill."
|
|
848
|
+
)
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
724
852
|
const reviewFields = {
|
|
725
853
|
Status: reviewValue(task, "Status"),
|
|
726
854
|
"Acceptance check": reviewValue(task, "Acceptance check"),
|
|
@@ -783,14 +911,14 @@ function completionChecks(repo, task, contract = null) {
|
|
|
783
911
|
problems.push(
|
|
784
912
|
problem(
|
|
785
913
|
"finding-owner",
|
|
786
|
-
"Review Findings must be `none` or carry a disposition
|
|
787
|
-
+ "
|
|
788
|
-
+ "
|
|
789
|
-
+ "
|
|
914
|
+
"Review Findings must be `none` or carry a disposition — name a "
|
|
915
|
+
+ "path after `Durable owner:` so it reads as the owner rather "
|
|
916
|
+
+ "than a file the finding mentions. A finding fixed in this "
|
|
917
|
+
+ "task is `Resolved here:` naming an `M<n>` check this task "
|
|
918
|
+
+ "declares or a repo-relative path that exists; one someone "
|
|
919
|
+
+ "must still do is `Durable owner:` naming "
|
|
790
920
|
+ `${DURABLE_OWNER_FORMS}; one deliberately not being done is a `
|
|
791
|
-
+ "`Discard reason:`/`Discard rationale:` prefix.
|
|
792
|
-
+ "`Durable owner:` so it reads as the owner rather than a file the "
|
|
793
|
-
+ "finding mentions."
|
|
921
|
+
+ "`Discard reason:`/`Discard rationale:` prefix."
|
|
794
922
|
)
|
|
795
923
|
);
|
|
796
924
|
}
|
|
@@ -821,7 +949,8 @@ function taskComplete(repo, options) {
|
|
|
821
949
|
}
|
|
822
950
|
const contract = compileTaskContract(repo, selection.change, task);
|
|
823
951
|
const usableContract = contract.diagnostics.length === 0 ? contract : null;
|
|
824
|
-
const
|
|
952
|
+
const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
|
|
953
|
+
const checks = completionChecks(repo, task, usableContract, changeVerify);
|
|
825
954
|
checks.problems.push(...contract.diagnostics);
|
|
826
955
|
const missingAnchor = missingAnchorProblem(selection, task);
|
|
827
956
|
if (missingAnchor) {
|
|
@@ -861,6 +990,131 @@ function taskComplete(repo, options) {
|
|
|
861
990
|
);
|
|
862
991
|
}
|
|
863
992
|
|
|
993
|
+
// The body of a change-level section — `## Invalidates`, `## Expectation
|
|
994
|
+
// Coverage` — ending at the next `##` heading or at the next task, whichever
|
|
995
|
+
// comes first. The heading half alone is the right bound for a document made of
|
|
996
|
+
// headings, and a tasks file is not one: its dominant structure is a list, so a
|
|
997
|
+
// section that is not the file's last one ran over the whole task list and read
|
|
998
|
+
// what the tasks had declared. An `E<n>` line under a task's `Covers` was
|
|
999
|
+
// judged as a coverage entry and reported unclosed; a `repo-action` task's
|
|
1000
|
+
// `Touch` of a bare `- none` was read as the section's `- None.` and closed a
|
|
1001
|
+
// declaration that closed nothing. The first failed loudly and named an entry
|
|
1002
|
+
// that was fine, the second failed silently, and which one an author met
|
|
1003
|
+
// depended only on where they had put the section — a position no template,
|
|
1004
|
+
// diagnostic, or document has ever stated.
|
|
1005
|
+
//
|
|
1006
|
+
// The task half is the task list already parsed for this file rather than a
|
|
1007
|
+
// second checkbox pattern, so it cannot drift from the boundary `parseTasks()`
|
|
1008
|
+
// applies to a task's own body. The heading half stays as it was: the two
|
|
1009
|
+
// spellings are not interchangeable, and unifying them truncates a tail-position
|
|
1010
|
+
// section at an indented `##` line inside its own body, which is this same
|
|
1011
|
+
// defect pointed the other way.
|
|
1012
|
+
function sectionBody(content, headingOffset, tasks) {
|
|
1013
|
+
const lines = content.split(/\r?\n/);
|
|
1014
|
+
const headingLine = content.slice(0, headingOffset).split(/\r?\n/).length - 1;
|
|
1015
|
+
let end = lines.length;
|
|
1016
|
+
for (const task of tasks) {
|
|
1017
|
+
if (task.line > headingLine && task.line < end) end = task.line;
|
|
1018
|
+
}
|
|
1019
|
+
for (let cursor = headingLine + 1; cursor < end; cursor += 1) {
|
|
1020
|
+
if (/^##\s+/.test(lines[cursor])) {
|
|
1021
|
+
end = cursor;
|
|
1022
|
+
break;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
return lines.slice(headingLine + 1, end).join("\n");
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// A `(regression)` check's bare Evidence may point at a change-level check
|
|
1029
|
+
// instead of recording its own result — issue #95. `deferred to C<n>` is
|
|
1030
|
+
// matched at the front of the value, the same way `Resolved here:`/`Durable
|
|
1031
|
+
// owner:` prefixes are read elsewhere in this file, so a real result that
|
|
1032
|
+
// happens to mention "deferred" mid-sentence is not misread as one.
|
|
1033
|
+
function deferredChangeCheck(value) {
|
|
1034
|
+
const match = String(value || "").trim().match(/^deferred to (C[1-9]\d*)\b/i);
|
|
1035
|
+
return match ? match[1] : null;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// `## Change Verify` is a change-level section a `(regression)` check's
|
|
1039
|
+
// Evidence can defer to — a check that only needs to run once for the whole
|
|
1040
|
+
// change instead of once per task. Parsed the same way as `## Invalidates`/
|
|
1041
|
+
// `## Expectation Coverage`: located by heading, bounded by `sectionBody()`.
|
|
1042
|
+
// Absent by default; a change that no task defers in never needs it.
|
|
1043
|
+
function changeVerifyChecks(content, tasks) {
|
|
1044
|
+
const heading = content.search(/^## Change Verify\s*$/m);
|
|
1045
|
+
if (heading < 0) return null;
|
|
1046
|
+
const section = sectionBody(content, heading, tasks);
|
|
1047
|
+
const strategyEntry = section.match(/^\s*-\s*Strategy:\s*(.*)$/im);
|
|
1048
|
+
const checks = [
|
|
1049
|
+
...section.matchAll(/^\s*-\s*(C[1-9]\d*):\s*(.*)$/gim),
|
|
1050
|
+
].map((match) => ({ label: match[1], check: match[2].trim() }));
|
|
1051
|
+
return {
|
|
1052
|
+
strategy: strategyEntry ? strategyEntry[1].trim() : "",
|
|
1053
|
+
checks,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// The `## Change Evidence` counterpart to `changeVerifyChecks` — one `C<n>:`
|
|
1058
|
+
// result per declared check, read the same way a task's own `M<n>` Evidence
|
|
1059
|
+
// already is.
|
|
1060
|
+
function changeEvidenceValue(content, tasks, label) {
|
|
1061
|
+
const heading = content.search(/^## Change Evidence\s*$/m);
|
|
1062
|
+
if (heading < 0) return "";
|
|
1063
|
+
const section = sectionBody(content, heading, tasks);
|
|
1064
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1065
|
+
const match = section.match(new RegExp(`^\\s*-\\s*${escaped}:\\s*(.*)$`, "im"));
|
|
1066
|
+
return match ? match[1].trim() : "";
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// `change-close`-only: `## Change Verify`'s own shape, and completeness of
|
|
1070
|
+
// `## Change Evidence` for every check it declares — whether or not any task
|
|
1071
|
+
// actually defers to it, because a declared check still owes its own result.
|
|
1072
|
+
// Per-task resolution (does a deferred reference resolve at all) runs inside
|
|
1073
|
+
// `completionChecks`, shared by `task-complete` and this loop's own call to
|
|
1074
|
+
// it, so it is not repeated here.
|
|
1075
|
+
function changeVerifyProblems(content, tasks) {
|
|
1076
|
+
const changeVerify = changeVerifyChecks(content, tasks);
|
|
1077
|
+
if (!changeVerify) return [];
|
|
1078
|
+
const problems = [];
|
|
1079
|
+
const labels = changeVerify.checks.map((entry) => entry.label);
|
|
1080
|
+
const expected = labels.map((_, index) => `C${index + 1}`);
|
|
1081
|
+
if (labels.length === 0 || !isConcrete(changeVerify.strategy)) {
|
|
1082
|
+
problems.push(
|
|
1083
|
+
problem(
|
|
1084
|
+
"change-verify-shape",
|
|
1085
|
+
"`## Change Verify` requires a concrete `Strategy:` line and at "
|
|
1086
|
+
+ "least one `C<n>:` check."
|
|
1087
|
+
)
|
|
1088
|
+
);
|
|
1089
|
+
} else if (labels.some((label, index) => label !== expected[index])) {
|
|
1090
|
+
problems.push(
|
|
1091
|
+
problem(
|
|
1092
|
+
"change-verify-shape",
|
|
1093
|
+
"`## Change Verify` labels must be contiguous and ordered: "
|
|
1094
|
+
+ `expected ${expected.join(", ")}; found ${labels.join(", ")}.`
|
|
1095
|
+
)
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
for (const entry of changeVerify.checks) {
|
|
1099
|
+
if (!isConcrete(entry.check)) {
|
|
1100
|
+
problems.push(
|
|
1101
|
+
problem("change-verify-shape", `${entry.label} must define a concrete check.`)
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
for (const entry of changeVerify.checks) {
|
|
1106
|
+
if (!isConcrete(changeEvidenceValue(content, tasks, entry.label))) {
|
|
1107
|
+
problems.push(
|
|
1108
|
+
problem(
|
|
1109
|
+
"change-evidence-missing",
|
|
1110
|
+
`Missing concrete \`## Change Evidence\` for ${entry.label}.`
|
|
1111
|
+
)
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return problems;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
864
1118
|
// Follow-up Ownership governs work a change left undone. This is the opposite
|
|
865
1119
|
// shape: statements left standing by work the change completed. It is asked at
|
|
866
1120
|
// task-start rather than change-close because the whole value is that the
|
|
@@ -885,10 +1139,7 @@ function invalidationProblems(repo, content, tasks) {
|
|
|
885
1139
|
),
|
|
886
1140
|
];
|
|
887
1141
|
}
|
|
888
|
-
const
|
|
889
|
-
const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
|
|
890
|
-
const nextHeading = remainder.search(/^##\s+/m);
|
|
891
|
-
const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
|
|
1142
|
+
const section = sectionBody(content, heading, tasks);
|
|
892
1143
|
if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
|
|
893
1144
|
const entries = [
|
|
894
1145
|
...section.matchAll(
|
|
@@ -988,10 +1239,7 @@ function expectationProblems(repo, content, tasks) {
|
|
|
988
1239
|
),
|
|
989
1240
|
];
|
|
990
1241
|
}
|
|
991
|
-
const
|
|
992
|
-
const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
|
|
993
|
-
const nextHeading = remainder.search(/^##\s+/m);
|
|
994
|
-
const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
|
|
1242
|
+
const section = sectionBody(content, heading, tasks);
|
|
995
1243
|
if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
|
|
996
1244
|
const entries = [
|
|
997
1245
|
...section.matchAll(
|
|
@@ -1083,6 +1331,7 @@ function changeClose(repo, options) {
|
|
|
1083
1331
|
const problems = [];
|
|
1084
1332
|
const reviewProblems = [];
|
|
1085
1333
|
const contracts = [];
|
|
1334
|
+
const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
|
|
1086
1335
|
if (selection.tasks.length === 0) {
|
|
1087
1336
|
problems.push(problem("missing-tasks", "Change has no executable tasks."));
|
|
1088
1337
|
}
|
|
@@ -1128,7 +1377,8 @@ function changeClose(repo, options) {
|
|
|
1128
1377
|
const checks = completionChecks(
|
|
1129
1378
|
repo,
|
|
1130
1379
|
task,
|
|
1131
|
-
contract.diagnostics.length === 0 ? contract : null
|
|
1380
|
+
contract.diagnostics.length === 0 ? contract : null,
|
|
1381
|
+
changeVerify
|
|
1132
1382
|
);
|
|
1133
1383
|
problems.push(
|
|
1134
1384
|
...checks.problems.map((item) =>
|
|
@@ -1142,6 +1392,7 @@ function changeClose(repo, options) {
|
|
|
1142
1392
|
);
|
|
1143
1393
|
}
|
|
1144
1394
|
problems.push(...expectationProblems(repo, selection.content, selection.tasks));
|
|
1395
|
+
problems.push(...changeVerifyProblems(selection.content, selection.tasks));
|
|
1145
1396
|
|
|
1146
1397
|
const changePath = path.dirname(selection.tasksPath);
|
|
1147
1398
|
if (!hasDeltaSpec(changePath)) {
|
package/src/core/guard.js
CHANGED
|
@@ -59,6 +59,20 @@ function sha256(buffer) {
|
|
|
59
59
|
return crypto.createHash("sha256").update(buffer).digest("hex");
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// The content a dirty path held at the moment it was read, or `null` when
|
|
63
|
+
// nothing could be read — a deleted path, mid-rename, or one that never
|
|
64
|
+
// existed. `null` is a signature like any other: it round-trips through the
|
|
65
|
+
// same equality check a real hash does, so a path that stays absent compares
|
|
66
|
+
// equal and one that gets created or deleted compares unequal, with no
|
|
67
|
+
// special case for either direction.
|
|
68
|
+
function contentSignature(repo, relative) {
|
|
69
|
+
try {
|
|
70
|
+
return sha256(fs.readFileSync(path.join(repo, relative)));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
62
76
|
function guardResult(subcommand, status, extra = {}) {
|
|
63
77
|
return {
|
|
64
78
|
schemaVersion: 1,
|
|
@@ -68,17 +82,17 @@ function guardResult(subcommand, status, extra = {}) {
|
|
|
68
82
|
manifestPath: "keel/guard.json",
|
|
69
83
|
problems: [],
|
|
70
84
|
warnings: [
|
|
71
|
-
"The guard manifest is a disposable enforcement pointer
|
|
72
|
-
+ "
|
|
85
|
+
"The guard manifest is a disposable enforcement pointer, not durable "
|
|
86
|
+
+ "authority — OpenSpec and Git are, and selection never derives "
|
|
73
87
|
+ "from it.",
|
|
74
88
|
// The status describes a file Keel wrote. Whether anything reads that
|
|
75
89
|
// file is a target-side fact: enforcement runs as a runtime hook the
|
|
76
90
|
// host loads, and a host that loaded different plugins keeps them for
|
|
77
91
|
// the life of its session. Reporting `started` as though it were a probe
|
|
78
92
|
// result is the same inference `--doctor` already refuses to make.
|
|
79
|
-
"This
|
|
80
|
-
+ "
|
|
81
|
-
+ "
|
|
93
|
+
"This describes the manifest only. Enforcement runs as a runtime hook "
|
|
94
|
+
+ "Keel cannot observe, so a written manifest proves no write was "
|
|
95
|
+
+ "checked.",
|
|
82
96
|
],
|
|
83
97
|
...extra,
|
|
84
98
|
};
|
|
@@ -161,13 +175,25 @@ function readManifest(repo) {
|
|
|
161
175
|
// written by a Keel that omits it, are both valid; what they are not is
|
|
162
176
|
// evidence that nothing was dirty. The consumer distinguishes absent from
|
|
163
177
|
// empty, so an empty list means "nothing was dirty" and an absent one means
|
|
164
|
-
// "nobody looked".
|
|
178
|
+
// "nobody looked". Each entry carries the path's content signature, not
|
|
179
|
+
// just its name, so completion can tell "still the content recorded at
|
|
180
|
+
// task start" from "dirty again for a different reason" — `sha256` is
|
|
181
|
+
// `null` for a path that had nothing to read.
|
|
165
182
|
if (
|
|
166
183
|
manifest.startedDirty !== undefined
|
|
167
184
|
&& (!Array.isArray(manifest.startedDirty)
|
|
168
|
-
|| manifest.startedDirty.some(
|
|
185
|
+
|| manifest.startedDirty.some(
|
|
186
|
+
(item) =>
|
|
187
|
+
!item
|
|
188
|
+
|| typeof item.path !== "string"
|
|
189
|
+
|| !item.path
|
|
190
|
+
|| (item.sha256 !== null
|
|
191
|
+
&& !/^[0-9a-f]{64}$/.test(String(item.sha256 || "")))
|
|
192
|
+
))
|
|
169
193
|
) {
|
|
170
|
-
shapeErrors.push(
|
|
194
|
+
shapeErrors.push(
|
|
195
|
+
"startedDirty must be a list of hashed dirty paths when present"
|
|
196
|
+
);
|
|
171
197
|
}
|
|
172
198
|
if (shapeErrors.length > 0) {
|
|
173
199
|
return {
|
|
@@ -231,8 +257,14 @@ function startGuard(repo, options) {
|
|
|
231
257
|
|
|
232
258
|
const paths = authorityPaths(repo, options.change, loaded.contract);
|
|
233
259
|
// 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
|
-
|
|
260
|
+
// record and cannot be attributed to the task it authorizes. Each dirty
|
|
261
|
+
// path is hashed at this same moment, so a later comparison can tell
|
|
262
|
+
// whether the task changed it again rather than only whether it stayed
|
|
263
|
+
// dirty.
|
|
264
|
+
const startedDirty = gitPaths(repo).map((relative) => ({
|
|
265
|
+
path: relative,
|
|
266
|
+
sha256: contentSignature(repo, relative),
|
|
267
|
+
}));
|
|
236
268
|
const manifest = {
|
|
237
269
|
schema: MANIFEST_SCHEMA,
|
|
238
270
|
change: options.change,
|
|
@@ -265,12 +297,40 @@ function guardStatus(repo) {
|
|
|
265
297
|
const problems = [];
|
|
266
298
|
const loaded = loadTaskContract(repo, manifest.change, manifest.task);
|
|
267
299
|
if (!loaded) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
300
|
+
// `loadTaskContract` returns null for two unrelated reasons — the tasks
|
|
301
|
+
// file is not there, or the task id is not in it — and only one of them
|
|
302
|
+
// has a reauthorization to perform. Telling the reader to reauthorize a
|
|
303
|
+
// change that has been archived sends them to `keel gate task-start`,
|
|
304
|
+
// which reports a missing tasks file, and to `keel guard start`, which
|
|
305
|
+
// reports that the task does not exist; neither names `keel guard clear`,
|
|
306
|
+
// which is the only action that resolves it.
|
|
307
|
+
//
|
|
308
|
+
// The change *directory* is the test, not the tasks file, and it is the
|
|
309
|
+
// same object `plugins/keel/scripts/pretooluse-guard.js` tests for the
|
|
310
|
+
// same question. Two surfaces deciding it by different means would
|
|
311
|
+
// eventually disagree about a state a reader is looking at from both. It
|
|
312
|
+
// also leaves a live change whose tasks.md is absent — mid-authoring — on
|
|
313
|
+
// the reauthorize path, where reauthorizing genuinely is the way out.
|
|
314
|
+
const changeDir = path.join(repo, "openspec", "changes", manifest.change);
|
|
315
|
+
problems.push(
|
|
316
|
+
fs.existsSync(changeDir)
|
|
317
|
+
? {
|
|
318
|
+
code: "authority-drift",
|
|
319
|
+
message:
|
|
320
|
+
`Guarded task ${manifest.change}#${manifest.task} no longer `
|
|
321
|
+
+ "resolves; reauthorize through `keel gate task-start` and "
|
|
322
|
+
+ "`keel guard start`.",
|
|
323
|
+
}
|
|
324
|
+
: {
|
|
325
|
+
code: "stale-manifest",
|
|
326
|
+
message:
|
|
327
|
+
`This manifest is stale: it guards ${manifest.change}`
|
|
328
|
+
+ `#${manifest.task}, but openspec/changes/${manifest.change} no `
|
|
329
|
+
+ "longer exists, so the task it names cannot be reauthorized and "
|
|
330
|
+
+ "its Touch list authorizes nothing. Run `keel guard clear`, then "
|
|
331
|
+
+ "start the task you are actually working on.",
|
|
332
|
+
}
|
|
333
|
+
);
|
|
274
334
|
const drifted = guardResult("status", "drifted", { manifest });
|
|
275
335
|
drifted.problems = problems;
|
|
276
336
|
return drifted;
|
|
@@ -354,6 +414,7 @@ module.exports = {
|
|
|
354
414
|
GuardInputError,
|
|
355
415
|
MANIFEST_SCHEMA,
|
|
356
416
|
clearGuard,
|
|
417
|
+
contentSignature,
|
|
357
418
|
gitPaths,
|
|
358
419
|
guardStatus,
|
|
359
420
|
readManifest,
|