@christang/keel 5.20.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 +45 -2
- package/scripts/validate_plugin.py +3120 -204
- package/src/core/config.js +192 -26
- package/src/core/context.js +9 -0
- package/src/core/gates.js +257 -40
- package/src/core/guard.js +77 -16
- package/src/core/task-contract.js +85 -3
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
|
|
@@ -424,13 +452,15 @@ function durableOwnerVerdict(repo, value) {
|
|
|
424
452
|
// same third slot since it shipped, where `Updated by:` names tasks of this
|
|
425
453
|
// change.
|
|
426
454
|
// The capture is the single token after the marker, not the rest of the line.
|
|
427
|
-
// Findings is
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
// Measured on this change's own task 1.3.
|
|
432
|
-
//
|
|
433
|
-
//
|
|
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.
|
|
434
464
|
const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
|
|
435
465
|
|
|
436
466
|
// Resolution evidence is deliberately narrower than a durable owner. An
|
|
@@ -520,6 +550,9 @@ function findingOwnerIsDurable(repo, findings) {
|
|
|
520
550
|
// what a manifest written before this field, a cleared guard, or a
|
|
521
551
|
// `--no-guard` start all produce. Reading null as empty would attribute the
|
|
522
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.
|
|
523
556
|
function recordedBaseline(repo, change, task) {
|
|
524
557
|
const loaded = readManifest(repo);
|
|
525
558
|
if (loaded.state !== "ok") return null;
|
|
@@ -608,20 +641,27 @@ function scopeEvidence(
|
|
|
608
641
|
}
|
|
609
642
|
|
|
610
643
|
if (!base) {
|
|
611
|
-
// Dirty now and not dirty when the task started
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
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.
|
|
615
650
|
//
|
|
616
|
-
// A path already dirty at task start is
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
|
|
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
|
+
);
|
|
621
661
|
return attributeChanged(
|
|
622
662
|
repo,
|
|
623
663
|
task,
|
|
624
|
-
dirtyPaths.filter((item) => !
|
|
664
|
+
dirtyPaths.filter((item) => !unchangedSinceStart.has(item)),
|
|
625
665
|
contract,
|
|
626
666
|
change,
|
|
627
667
|
tasks
|
|
@@ -701,7 +741,7 @@ function attributeChanged(repo, task, changedList, contract, change, tasks) {
|
|
|
701
741
|
};
|
|
702
742
|
}
|
|
703
743
|
|
|
704
|
-
function completionChecks(repo, task, contract = null) {
|
|
744
|
+
function completionChecks(repo, task, contract = null, changeVerify = null) {
|
|
705
745
|
const problems = [];
|
|
706
746
|
const commands = contract
|
|
707
747
|
? contract.capsule.verification.commands.map((item) => item.label)
|
|
@@ -750,11 +790,65 @@ function completionChecks(repo, task, contract = null) {
|
|
|
750
790
|
}
|
|
751
791
|
}
|
|
752
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
|
+
}
|
|
753
830
|
const blocker = evidenceValue(task, "Blocker");
|
|
754
831
|
if (isConcrete(blocker)) {
|
|
755
832
|
problems.push(problem("blocker", `Task records a blocker: ${blocker}`));
|
|
756
833
|
}
|
|
757
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
|
+
|
|
758
852
|
const reviewFields = {
|
|
759
853
|
Status: reviewValue(task, "Status"),
|
|
760
854
|
"Acceptance check": reviewValue(task, "Acceptance check"),
|
|
@@ -817,14 +911,14 @@ function completionChecks(repo, task, contract = null) {
|
|
|
817
911
|
problems.push(
|
|
818
912
|
problem(
|
|
819
913
|
"finding-owner",
|
|
820
|
-
"Review Findings must be `none` or carry a disposition
|
|
821
|
-
+ "
|
|
822
|
-
+ "
|
|
823
|
-
+ "
|
|
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 "
|
|
824
920
|
+ `${DURABLE_OWNER_FORMS}; one deliberately not being done is a `
|
|
825
|
-
+ "`Discard reason:`/`Discard rationale:` prefix.
|
|
826
|
-
+ "`Durable owner:` so it reads as the owner rather than a file the "
|
|
827
|
-
+ "finding mentions."
|
|
921
|
+
+ "`Discard reason:`/`Discard rationale:` prefix."
|
|
828
922
|
)
|
|
829
923
|
);
|
|
830
924
|
}
|
|
@@ -855,7 +949,8 @@ function taskComplete(repo, options) {
|
|
|
855
949
|
}
|
|
856
950
|
const contract = compileTaskContract(repo, selection.change, task);
|
|
857
951
|
const usableContract = contract.diagnostics.length === 0 ? contract : null;
|
|
858
|
-
const
|
|
952
|
+
const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
|
|
953
|
+
const checks = completionChecks(repo, task, usableContract, changeVerify);
|
|
859
954
|
checks.problems.push(...contract.diagnostics);
|
|
860
955
|
const missingAnchor = missingAnchorProblem(selection, task);
|
|
861
956
|
if (missingAnchor) {
|
|
@@ -895,6 +990,131 @@ function taskComplete(repo, options) {
|
|
|
895
990
|
);
|
|
896
991
|
}
|
|
897
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
|
+
|
|
898
1118
|
// Follow-up Ownership governs work a change left undone. This is the opposite
|
|
899
1119
|
// shape: statements left standing by work the change completed. It is asked at
|
|
900
1120
|
// task-start rather than change-close because the whole value is that the
|
|
@@ -919,10 +1139,7 @@ function invalidationProblems(repo, content, tasks) {
|
|
|
919
1139
|
),
|
|
920
1140
|
];
|
|
921
1141
|
}
|
|
922
|
-
const
|
|
923
|
-
const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
|
|
924
|
-
const nextHeading = remainder.search(/^##\s+/m);
|
|
925
|
-
const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
|
|
1142
|
+
const section = sectionBody(content, heading, tasks);
|
|
926
1143
|
if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
|
|
927
1144
|
const entries = [
|
|
928
1145
|
...section.matchAll(
|
|
@@ -1022,10 +1239,7 @@ function expectationProblems(repo, content, tasks) {
|
|
|
1022
1239
|
),
|
|
1023
1240
|
];
|
|
1024
1241
|
}
|
|
1025
|
-
const
|
|
1026
|
-
const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
|
|
1027
|
-
const nextHeading = remainder.search(/^##\s+/m);
|
|
1028
|
-
const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
|
|
1242
|
+
const section = sectionBody(content, heading, tasks);
|
|
1029
1243
|
if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
|
|
1030
1244
|
const entries = [
|
|
1031
1245
|
...section.matchAll(
|
|
@@ -1117,6 +1331,7 @@ function changeClose(repo, options) {
|
|
|
1117
1331
|
const problems = [];
|
|
1118
1332
|
const reviewProblems = [];
|
|
1119
1333
|
const contracts = [];
|
|
1334
|
+
const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
|
|
1120
1335
|
if (selection.tasks.length === 0) {
|
|
1121
1336
|
problems.push(problem("missing-tasks", "Change has no executable tasks."));
|
|
1122
1337
|
}
|
|
@@ -1162,7 +1377,8 @@ function changeClose(repo, options) {
|
|
|
1162
1377
|
const checks = completionChecks(
|
|
1163
1378
|
repo,
|
|
1164
1379
|
task,
|
|
1165
|
-
contract.diagnostics.length === 0 ? contract : null
|
|
1380
|
+
contract.diagnostics.length === 0 ? contract : null,
|
|
1381
|
+
changeVerify
|
|
1166
1382
|
);
|
|
1167
1383
|
problems.push(
|
|
1168
1384
|
...checks.problems.map((item) =>
|
|
@@ -1176,6 +1392,7 @@ function changeClose(repo, options) {
|
|
|
1176
1392
|
);
|
|
1177
1393
|
}
|
|
1178
1394
|
problems.push(...expectationProblems(repo, selection.content, selection.tasks));
|
|
1395
|
+
problems.push(...changeVerifyProblems(selection.content, selection.tasks));
|
|
1179
1396
|
|
|
1180
1397
|
const changePath = path.dirname(selection.tasksPath);
|
|
1181
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,
|