@christang/keel 5.20.0 → 5.44.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 +40 -11
- 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/plugins/keel/skills/keel-run-single-task-goal/SKILL.md +1 -1
- package/scripts/install_to_repo.py +116 -3
- package/scripts/validate_plugin.py +5027 -973
- package/src/core/config.js +199 -27
- package/src/core/context.js +9 -0
- package/src/core/gates.js +357 -57
- package/src/core/guard.js +77 -16
- package/src/core/task-contract.js +106 -5
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
|
|
|
@@ -243,7 +244,7 @@ function taskStart(repo, options) {
|
|
|
243
244
|
const compiled = compileTaskContract(repo, selection.change, task);
|
|
244
245
|
const problems = [
|
|
245
246
|
...compiled.diagnostics,
|
|
246
|
-
...invalidationProblems(repo, selection.content, selection.tasks),
|
|
247
|
+
...invalidationProblems(repo, selection.content, selection.tasks, selection.change),
|
|
247
248
|
];
|
|
248
249
|
// Recording the current fingerprint is idempotent: --record replaces the
|
|
249
250
|
// selected task's Contract anchor whatever it holds, so reauthorizing a task
|
|
@@ -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
|
|
@@ -366,11 +394,21 @@ const TRACKER_REFERENCE = /\bhttps?:\/\/\S/i;
|
|
|
366
394
|
|
|
367
395
|
// The owner forms, stated once so every refusal that lists them agrees with
|
|
368
396
|
// every other and with what the checks below actually accept.
|
|
397
|
+
//
|
|
398
|
+
// What each form is worth is part of the sentence, because a list of accepted
|
|
399
|
+
// spellings reads as a list of verified guarantees. A path is checked for
|
|
400
|
+
// existence at the moment it is cited and never again; a tracker reference is
|
|
401
|
+
// accepted on its shape, because a gate that fetched one would stop being
|
|
402
|
+
// local and offline, which is the property its verdict rests on. Leaving that
|
|
403
|
+
// unsaid is how an author comes to believe a check ran that did not (#100).
|
|
369
404
|
const DURABLE_OWNER_FORMS =
|
|
370
405
|
"an absolute `https://…` tracker reference, or any repo-relative path that "
|
|
371
|
-
+ "exists —
|
|
372
|
-
+ "
|
|
373
|
-
+ "than an owner"
|
|
406
|
+
+ "exists and outlives this change — an archived `openspec/changes/archive/…` "
|
|
407
|
+
+ "artifact, `keel/archive/…`, or the repository's own ledger; "
|
|
408
|
+
+ "`keel/HANDOFF.md` is a pointer override rather than an owner. A path is "
|
|
409
|
+
+ "checked for existence when it is cited and is not re-checked afterwards, "
|
|
410
|
+
+ "and a tracker reference is accepted on its shape because a gate runs "
|
|
411
|
+
+ "offline and never fetches one";
|
|
374
412
|
|
|
375
413
|
// Trailing punctuation a declared path can abut in prose. ASCII sentence marks
|
|
376
414
|
// and their CJK counterparts both belong here: once the extractor stops
|
|
@@ -401,20 +439,55 @@ function declaredPath(value) {
|
|
|
401
439
|
return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
|
|
402
440
|
}
|
|
403
441
|
|
|
442
|
+
// A path inside the selected change's own directory exists now and cannot
|
|
443
|
+
// exist later: archiving moves `openspec/changes/<name>/` under
|
|
444
|
+
// `openspec/changes/archive/`, so the one guarantee the gate offers expires in
|
|
445
|
+
// the next step of the workflow that accepted it. Measured in this repository,
|
|
446
|
+
// 10 declarations name such a path and all 10 are dead; the field report
|
|
447
|
+
// measured 35 of 36 (issue #100). Existence is necessary and not sufficient —
|
|
448
|
+
// the same line `keel/HANDOFF.md` already sits on.
|
|
449
|
+
//
|
|
450
|
+
// The rule is the directory, not the file: every file under it moves together,
|
|
451
|
+
// and naming `design.md` would refuse one spelling of one instance. And it is
|
|
452
|
+
// *this* change's directory, not change directories in general — the protocol
|
|
453
|
+
// names a new OpenSpec change as a legitimate owner of deferred work, and no
|
|
454
|
+
// measured pointer has that shape.
|
|
455
|
+
function insideOwnChangeDirectory(candidate, change) {
|
|
456
|
+
if (!change || !candidate) return false;
|
|
457
|
+
const prefix = `openspec/changes/${change}/`;
|
|
458
|
+
return String(candidate).replace(/^\.\//, "").startsWith(prefix);
|
|
459
|
+
}
|
|
460
|
+
|
|
404
461
|
// Classify a declared `Durable owner:` value. A gate runs without network, so a
|
|
405
462
|
// URL is accepted on shape alone; a path is the one form it can actually check,
|
|
406
|
-
// and checking it is stricter than the prefix whitelist this replaced
|
|
407
|
-
|
|
463
|
+
// and checking it is stricter than the prefix whitelist this replaced — for as
|
|
464
|
+
// long as the path outlives the change, which `insideOwnChangeDirectory` is
|
|
465
|
+
// there to decide.
|
|
466
|
+
function durableOwnerVerdict(repo, value, change) {
|
|
408
467
|
const owner = String(value || "").trim();
|
|
409
468
|
if (!owner) return { ok: false, reason: "unrecognized" };
|
|
410
469
|
if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
|
|
411
470
|
if (TRACKER_REFERENCE.test(owner)) return { ok: true };
|
|
412
471
|
const candidate = declaredPath(owner);
|
|
413
472
|
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
473
|
+
if (insideOwnChangeDirectory(candidate, change)) {
|
|
474
|
+
return { ok: false, reason: "transient", path: candidate };
|
|
475
|
+
}
|
|
414
476
|
if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
|
|
415
477
|
return { ok: false, reason: "missing", path: candidate };
|
|
416
478
|
}
|
|
417
479
|
|
|
480
|
+
// The one sentence every transient refusal makes, so the three consumers say
|
|
481
|
+
// it the same way. It names the cause (the directory moves) rather than the
|
|
482
|
+
// symptom, because the author cannot repair a spelling problem they do not
|
|
483
|
+
// have.
|
|
484
|
+
function transientOwnerMessage(candidate) {
|
|
485
|
+
return `\`${candidate}\` is inside this change's own directory, which moves `
|
|
486
|
+
+ "to `openspec/changes/archive/` when the change is archived — the file "
|
|
487
|
+
+ "exists now and the pointer is guaranteed to break. Name something that "
|
|
488
|
+
+ `outlives the change: ${DURABLE_OWNER_FORMS}.`;
|
|
489
|
+
}
|
|
490
|
+
|
|
418
491
|
// A finding has three dispositions and the gate recognized two. One found and
|
|
419
492
|
// fixed inside the task recording it has no owner to name and nothing to
|
|
420
493
|
// discard, so the only text that passed was `Discard reason:` — filing a repair
|
|
@@ -424,13 +497,15 @@ function durableOwnerVerdict(repo, value) {
|
|
|
424
497
|
// same third slot since it shipped, where `Updated by:` names tasks of this
|
|
425
498
|
// change.
|
|
426
499
|
// 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
|
-
//
|
|
500
|
+
// Findings is free prose that normally holds several findings with different
|
|
501
|
+
// dispositions, so a capture reaching to the newline swallows every marker
|
|
502
|
+
// after it — a block recording one fix and one tracker-owned follow-up was
|
|
503
|
+
// refused because the *follow-up's* URL was read as the *fix's* evidence.
|
|
504
|
+
// Measured on this change's own task 1.3. That block used to be one line and
|
|
505
|
+
// may now wrap across several, which widens what a greedy capture would
|
|
506
|
+
// swallow and changes nothing about why this one is narrow. The match is
|
|
507
|
+
// global because each resolved claim owes its own evidence; checking only the
|
|
508
|
+
// first would let a second one assert itself for free.
|
|
434
509
|
const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
|
|
435
510
|
|
|
436
511
|
// Resolution evidence is deliberately narrower than a durable owner. An
|
|
@@ -439,7 +514,7 @@ const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
|
|
|
439
514
|
// it or the artifact that shows it. A bare marker is refused because a
|
|
440
515
|
// disposition that asserts its own conclusion would be a way out of the other
|
|
441
516
|
// two, and the third state would decay into the easiest exit.
|
|
442
|
-
function resolutionEvidenceVerdict(repo, value, commands) {
|
|
517
|
+
function resolutionEvidenceVerdict(repo, value, commands, change) {
|
|
443
518
|
const evidence = String(value || "").trim();
|
|
444
519
|
if (!evidence) return { ok: false, reason: "empty" };
|
|
445
520
|
// The tracker form is tested before the path form: a URL contains something
|
|
@@ -454,6 +529,12 @@ function resolutionEvidenceVerdict(repo, value, commands) {
|
|
|
454
529
|
}
|
|
455
530
|
const candidate = declaredPath(evidence);
|
|
456
531
|
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
532
|
+
// Resolution evidence is a file like any other and moves with the directory
|
|
533
|
+
// holding it, so it earns the same verdict rather than a second answer to
|
|
534
|
+
// the same question.
|
|
535
|
+
if (insideOwnChangeDirectory(candidate, change)) {
|
|
536
|
+
return { ok: false, reason: "transient", path: candidate };
|
|
537
|
+
}
|
|
457
538
|
if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
|
|
458
539
|
return { ok: false, reason: "missing", path: candidate };
|
|
459
540
|
}
|
|
@@ -474,13 +555,16 @@ function resolutionEvidenceMessage(verdict) {
|
|
|
474
555
|
if (verdict.reason === "unknown-check") {
|
|
475
556
|
return `${lead}${verdict.label} is not a check this task declares.${tail}`;
|
|
476
557
|
}
|
|
558
|
+
if (verdict.reason === "transient") {
|
|
559
|
+
return `${lead}${transientOwnerMessage(verdict.path)}${tail}`;
|
|
560
|
+
}
|
|
477
561
|
if (verdict.reason === "missing") {
|
|
478
562
|
return `${lead}\`${verdict.path}\` does not exist.${tail}`;
|
|
479
563
|
}
|
|
480
564
|
return `${lead}it names neither a check nor a path.${tail}`;
|
|
481
565
|
}
|
|
482
566
|
|
|
483
|
-
function findingOwnerIsDurable(repo, findings) {
|
|
567
|
+
function findingOwnerIsDurable(repo, findings, change) {
|
|
484
568
|
if (/keel\/HANDOFF\.md/i.test(findings)) return false;
|
|
485
569
|
if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
|
|
486
570
|
return true;
|
|
@@ -490,11 +574,17 @@ function findingOwnerIsDurable(repo, findings) {
|
|
|
490
574
|
// prose, and a finding that merely mentions the source file it concerns has
|
|
491
575
|
// not thereby given that finding an owner.
|
|
492
576
|
const declared = findings.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
493
|
-
if (declared) return durableOwnerVerdict(repo, declared[1]).ok;
|
|
577
|
+
if (declared) return durableOwnerVerdict(repo, declared[1], change).ok;
|
|
494
578
|
const artifact = findings.match(
|
|
495
579
|
/\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
|
|
496
580
|
);
|
|
497
|
-
if (
|
|
581
|
+
if (
|
|
582
|
+
artifact
|
|
583
|
+
&& !insideOwnChangeDirectory(artifact[1], change)
|
|
584
|
+
&& fs.existsSync(path.join(repo, artifact[1]))
|
|
585
|
+
) {
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
498
588
|
// Same extractor, scoped to the archive prefix: the segment after
|
|
499
589
|
// `keel/archive/` is a path like any other and was equally ASCII-bound.
|
|
500
590
|
const archive = findings.match(/keel\/archive\/[^\s`]*/i);
|
|
@@ -520,6 +610,9 @@ function findingOwnerIsDurable(repo, findings) {
|
|
|
520
610
|
// what a manifest written before this field, a cleared guard, or a
|
|
521
611
|
// `--no-guard` start all produce. Reading null as empty would attribute the
|
|
522
612
|
// whole worktree to the task and fail every completion in a dirty repository.
|
|
613
|
+
// Each entry is `{ path, sha256 }`, the content signature `contentSignature`
|
|
614
|
+
// read at that same moment — the record of *what* was dirty, not only that a
|
|
615
|
+
// path was.
|
|
523
616
|
function recordedBaseline(repo, change, task) {
|
|
524
617
|
const loaded = readManifest(repo);
|
|
525
618
|
if (loaded.state !== "ok") return null;
|
|
@@ -608,20 +701,27 @@ function scopeEvidence(
|
|
|
608
701
|
}
|
|
609
702
|
|
|
610
703
|
if (!base) {
|
|
611
|
-
// Dirty now and not dirty when the task started
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
704
|
+
// Dirty now and not dirty when the task started, or dirty now with
|
|
705
|
+
// content that no longer matches what was there at task start. Either
|
|
706
|
+
// answers "did this task write it", which is the question the boundary
|
|
707
|
+
// actually asks; it does not answer "which task wrote it", which is why
|
|
708
|
+
// the completed-sibling exclusion below still applies and still reports
|
|
709
|
+
// itself.
|
|
615
710
|
//
|
|
616
|
-
// A path already dirty at task start is
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
|
|
711
|
+
// A path already dirty at task start is exempt only while its content
|
|
712
|
+
// stays the one recorded then — recording a hash instead of only a name
|
|
713
|
+
// is what lets that hold without falling back to subtracting the whole
|
|
714
|
+
// path, which exempted every later write to it, not just the one that
|
|
715
|
+
// predated the task (#72).
|
|
716
|
+
const unchangedSinceStart = new Set(
|
|
717
|
+
baseline
|
|
718
|
+
.filter((entry) => contentSignature(repo, entry.path) === entry.sha256)
|
|
719
|
+
.map((entry) => entry.path)
|
|
720
|
+
);
|
|
621
721
|
return attributeChanged(
|
|
622
722
|
repo,
|
|
623
723
|
task,
|
|
624
|
-
dirtyPaths.filter((item) => !
|
|
724
|
+
dirtyPaths.filter((item) => !unchangedSinceStart.has(item)),
|
|
625
725
|
contract,
|
|
626
726
|
change,
|
|
627
727
|
tasks
|
|
@@ -701,7 +801,7 @@ function attributeChanged(repo, task, changedList, contract, change, tasks) {
|
|
|
701
801
|
};
|
|
702
802
|
}
|
|
703
803
|
|
|
704
|
-
function completionChecks(repo, task, contract = null) {
|
|
804
|
+
function completionChecks(repo, task, contract = null, changeVerify = null, change = null) {
|
|
705
805
|
const problems = [];
|
|
706
806
|
const commands = contract
|
|
707
807
|
? contract.capsule.verification.commands.map((item) => item.label)
|
|
@@ -750,11 +850,65 @@ function completionChecks(repo, task, contract = null) {
|
|
|
750
850
|
}
|
|
751
851
|
}
|
|
752
852
|
}
|
|
853
|
+
// A `(regression)` check's bare Evidence may defer to a change-level `C<n>`
|
|
854
|
+
// check instead of recording its own result (issue #95). The regression
|
|
855
|
+
// flag comes from the compiled contract, the same source the exemption
|
|
856
|
+
// above already trusts, so this cannot disagree with what `(regression)`
|
|
857
|
+
// itself decided. Resolution only — whether the reference is declared, not
|
|
858
|
+
// whether it has run yet — because at task-complete time it legitimately
|
|
859
|
+
// may not have; `changeVerifyProblems` requires it answered by close.
|
|
860
|
+
if (contract) {
|
|
861
|
+
const declaredLabels = new Set(
|
|
862
|
+
(changeVerify ? changeVerify.checks : []).map((entry) => entry.label)
|
|
863
|
+
);
|
|
864
|
+
for (const entry of contract.capsule.verification.commands) {
|
|
865
|
+
const deferred = deferredChangeCheck(evidenceValue(task, entry.label));
|
|
866
|
+
if (!deferred) continue;
|
|
867
|
+
if (!entry.regression) {
|
|
868
|
+
problems.push(
|
|
869
|
+
problem(
|
|
870
|
+
"deferred-evidence-not-regression",
|
|
871
|
+
`${entry.label} Evidence defers to ${deferred}, but ${entry.label} `
|
|
872
|
+
+ "is not tagged `(regression)`; only a `(regression)`-tagged "
|
|
873
|
+
+ "check may defer to a change-level check."
|
|
874
|
+
)
|
|
875
|
+
);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
if (!declaredLabels.has(deferred)) {
|
|
879
|
+
problems.push(
|
|
880
|
+
problem(
|
|
881
|
+
"deferred-check-unresolved",
|
|
882
|
+
`${entry.label} defers to ${deferred}, but tasks.md's \`## `
|
|
883
|
+
+ `Change Verify\` does not declare it. Declare it there, or `
|
|
884
|
+
+ `record concrete Evidence for ${entry.label} directly.`
|
|
885
|
+
)
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
753
890
|
const blocker = evidenceValue(task, "Blocker");
|
|
754
891
|
if (isConcrete(blocker)) {
|
|
755
892
|
problems.push(problem("blocker", `Task records a blocker: ${blocker}`));
|
|
756
893
|
}
|
|
757
894
|
|
|
895
|
+
// Reauthorizations (#70) is a log, not a stop condition: absent, `none`, and
|
|
896
|
+
// concrete text all pass. Only an abandoned `<slot>` token — real content
|
|
897
|
+
// the author started and never finished — is refused, the same distinction
|
|
898
|
+
// `unfilledToken()` already draws for every other field that uses it.
|
|
899
|
+
const reauthorizationsToken = unfilledToken(reviewValue(task, "Reauthorizations"));
|
|
900
|
+
if (reauthorizationsToken) {
|
|
901
|
+
problems.push(
|
|
902
|
+
problem(
|
|
903
|
+
"reauthorizations-shape",
|
|
904
|
+
`Reauthorizations carries the unfilled slot \`${reauthorizationsToken}\`, `
|
|
905
|
+
+ "so it is not concrete. Replace that slot with the value it stands "
|
|
906
|
+
+ "for, or fence it in inline code when it is literal text rather "
|
|
907
|
+
+ "than a slot left to fill."
|
|
908
|
+
)
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
758
912
|
const reviewFields = {
|
|
759
913
|
Status: reviewValue(task, "Status"),
|
|
760
914
|
"Acceptance check": reviewValue(task, "Acceptance check"),
|
|
@@ -806,25 +960,25 @@ function completionChecks(repo, task, contract = null) {
|
|
|
806
960
|
const resolved = [...reviewFields.Findings.matchAll(RESOLVED_HERE)];
|
|
807
961
|
if (resolved.length > 0) {
|
|
808
962
|
for (const claim of resolved) {
|
|
809
|
-
const verdict = resolutionEvidenceVerdict(repo, claim[1], commands);
|
|
963
|
+
const verdict = resolutionEvidenceVerdict(repo, claim[1], commands, change);
|
|
810
964
|
if (verdict.ok) continue;
|
|
811
965
|
problems.push(
|
|
812
966
|
problem("finding-resolution-evidence", resolutionEvidenceMessage(verdict))
|
|
813
967
|
);
|
|
814
968
|
break;
|
|
815
969
|
}
|
|
816
|
-
} else if (!findingOwnerIsDurable(repo, reviewFields.Findings)) {
|
|
970
|
+
} else if (!findingOwnerIsDurable(repo, reviewFields.Findings, change)) {
|
|
817
971
|
problems.push(
|
|
818
972
|
problem(
|
|
819
973
|
"finding-owner",
|
|
820
|
-
"Review Findings must be `none` or carry a disposition
|
|
821
|
-
+ "
|
|
822
|
-
+ "
|
|
823
|
-
+ "
|
|
974
|
+
"Review Findings must be `none` or carry a disposition — name a "
|
|
975
|
+
+ "path after `Durable owner:` so it reads as the owner rather "
|
|
976
|
+
+ "than a file the finding mentions. A finding fixed in this "
|
|
977
|
+
+ "task is `Resolved here:` naming an `M<n>` check this task "
|
|
978
|
+
+ "declares or a repo-relative path that exists; one someone "
|
|
979
|
+
+ "must still do is `Durable owner:` naming "
|
|
824
980
|
+ `${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."
|
|
981
|
+
+ "`Discard reason:`/`Discard rationale:` prefix."
|
|
828
982
|
)
|
|
829
983
|
);
|
|
830
984
|
}
|
|
@@ -855,7 +1009,14 @@ function taskComplete(repo, options) {
|
|
|
855
1009
|
}
|
|
856
1010
|
const contract = compileTaskContract(repo, selection.change, task);
|
|
857
1011
|
const usableContract = contract.diagnostics.length === 0 ? contract : null;
|
|
858
|
-
const
|
|
1012
|
+
const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
|
|
1013
|
+
const checks = completionChecks(
|
|
1014
|
+
repo,
|
|
1015
|
+
task,
|
|
1016
|
+
usableContract,
|
|
1017
|
+
changeVerify,
|
|
1018
|
+
selection.change
|
|
1019
|
+
);
|
|
859
1020
|
checks.problems.push(...contract.diagnostics);
|
|
860
1021
|
const missingAnchor = missingAnchorProblem(selection, task);
|
|
861
1022
|
if (missingAnchor) {
|
|
@@ -895,6 +1056,131 @@ function taskComplete(repo, options) {
|
|
|
895
1056
|
);
|
|
896
1057
|
}
|
|
897
1058
|
|
|
1059
|
+
// The body of a change-level section — `## Invalidates`, `## Expectation
|
|
1060
|
+
// Coverage` — ending at the next `##` heading or at the next task, whichever
|
|
1061
|
+
// comes first. The heading half alone is the right bound for a document made of
|
|
1062
|
+
// headings, and a tasks file is not one: its dominant structure is a list, so a
|
|
1063
|
+
// section that is not the file's last one ran over the whole task list and read
|
|
1064
|
+
// what the tasks had declared. An `E<n>` line under a task's `Covers` was
|
|
1065
|
+
// judged as a coverage entry and reported unclosed; a `repo-action` task's
|
|
1066
|
+
// `Touch` of a bare `- none` was read as the section's `- None.` and closed a
|
|
1067
|
+
// declaration that closed nothing. The first failed loudly and named an entry
|
|
1068
|
+
// that was fine, the second failed silently, and which one an author met
|
|
1069
|
+
// depended only on where they had put the section — a position no template,
|
|
1070
|
+
// diagnostic, or document has ever stated.
|
|
1071
|
+
//
|
|
1072
|
+
// The task half is the task list already parsed for this file rather than a
|
|
1073
|
+
// second checkbox pattern, so it cannot drift from the boundary `parseTasks()`
|
|
1074
|
+
// applies to a task's own body. The heading half stays as it was: the two
|
|
1075
|
+
// spellings are not interchangeable, and unifying them truncates a tail-position
|
|
1076
|
+
// section at an indented `##` line inside its own body, which is this same
|
|
1077
|
+
// defect pointed the other way.
|
|
1078
|
+
function sectionBody(content, headingOffset, tasks) {
|
|
1079
|
+
const lines = content.split(/\r?\n/);
|
|
1080
|
+
const headingLine = content.slice(0, headingOffset).split(/\r?\n/).length - 1;
|
|
1081
|
+
let end = lines.length;
|
|
1082
|
+
for (const task of tasks) {
|
|
1083
|
+
if (task.line > headingLine && task.line < end) end = task.line;
|
|
1084
|
+
}
|
|
1085
|
+
for (let cursor = headingLine + 1; cursor < end; cursor += 1) {
|
|
1086
|
+
if (/^##\s+/.test(lines[cursor])) {
|
|
1087
|
+
end = cursor;
|
|
1088
|
+
break;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return lines.slice(headingLine + 1, end).join("\n");
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// A `(regression)` check's bare Evidence may point at a change-level check
|
|
1095
|
+
// instead of recording its own result — issue #95. `deferred to C<n>` is
|
|
1096
|
+
// matched at the front of the value, the same way `Resolved here:`/`Durable
|
|
1097
|
+
// owner:` prefixes are read elsewhere in this file, so a real result that
|
|
1098
|
+
// happens to mention "deferred" mid-sentence is not misread as one.
|
|
1099
|
+
function deferredChangeCheck(value) {
|
|
1100
|
+
const match = String(value || "").trim().match(/^deferred to (C[1-9]\d*)\b/i);
|
|
1101
|
+
return match ? match[1] : null;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// `## Change Verify` is a change-level section a `(regression)` check's
|
|
1105
|
+
// Evidence can defer to — a check that only needs to run once for the whole
|
|
1106
|
+
// change instead of once per task. Parsed the same way as `## Invalidates`/
|
|
1107
|
+
// `## Expectation Coverage`: located by heading, bounded by `sectionBody()`.
|
|
1108
|
+
// Absent by default; a change that no task defers in never needs it.
|
|
1109
|
+
function changeVerifyChecks(content, tasks) {
|
|
1110
|
+
const heading = content.search(/^## Change Verify\s*$/m);
|
|
1111
|
+
if (heading < 0) return null;
|
|
1112
|
+
const section = sectionBody(content, heading, tasks);
|
|
1113
|
+
const strategyEntry = section.match(/^\s*-\s*Strategy:\s*(.*)$/im);
|
|
1114
|
+
const checks = [
|
|
1115
|
+
...section.matchAll(/^\s*-\s*(C[1-9]\d*):\s*(.*)$/gim),
|
|
1116
|
+
].map((match) => ({ label: match[1], check: match[2].trim() }));
|
|
1117
|
+
return {
|
|
1118
|
+
strategy: strategyEntry ? strategyEntry[1].trim() : "",
|
|
1119
|
+
checks,
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// The `## Change Evidence` counterpart to `changeVerifyChecks` — one `C<n>:`
|
|
1124
|
+
// result per declared check, read the same way a task's own `M<n>` Evidence
|
|
1125
|
+
// already is.
|
|
1126
|
+
function changeEvidenceValue(content, tasks, label) {
|
|
1127
|
+
const heading = content.search(/^## Change Evidence\s*$/m);
|
|
1128
|
+
if (heading < 0) return "";
|
|
1129
|
+
const section = sectionBody(content, heading, tasks);
|
|
1130
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1131
|
+
const match = section.match(new RegExp(`^\\s*-\\s*${escaped}:\\s*(.*)$`, "im"));
|
|
1132
|
+
return match ? match[1].trim() : "";
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// `change-close`-only: `## Change Verify`'s own shape, and completeness of
|
|
1136
|
+
// `## Change Evidence` for every check it declares — whether or not any task
|
|
1137
|
+
// actually defers to it, because a declared check still owes its own result.
|
|
1138
|
+
// Per-task resolution (does a deferred reference resolve at all) runs inside
|
|
1139
|
+
// `completionChecks`, shared by `task-complete` and this loop's own call to
|
|
1140
|
+
// it, so it is not repeated here.
|
|
1141
|
+
function changeVerifyProblems(content, tasks) {
|
|
1142
|
+
const changeVerify = changeVerifyChecks(content, tasks);
|
|
1143
|
+
if (!changeVerify) return [];
|
|
1144
|
+
const problems = [];
|
|
1145
|
+
const labels = changeVerify.checks.map((entry) => entry.label);
|
|
1146
|
+
const expected = labels.map((_, index) => `C${index + 1}`);
|
|
1147
|
+
if (labels.length === 0 || !isConcrete(changeVerify.strategy)) {
|
|
1148
|
+
problems.push(
|
|
1149
|
+
problem(
|
|
1150
|
+
"change-verify-shape",
|
|
1151
|
+
"`## Change Verify` requires a concrete `Strategy:` line and at "
|
|
1152
|
+
+ "least one `C<n>:` check."
|
|
1153
|
+
)
|
|
1154
|
+
);
|
|
1155
|
+
} else if (labels.some((label, index) => label !== expected[index])) {
|
|
1156
|
+
problems.push(
|
|
1157
|
+
problem(
|
|
1158
|
+
"change-verify-shape",
|
|
1159
|
+
"`## Change Verify` labels must be contiguous and ordered: "
|
|
1160
|
+
+ `expected ${expected.join(", ")}; found ${labels.join(", ")}.`
|
|
1161
|
+
)
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
for (const entry of changeVerify.checks) {
|
|
1165
|
+
if (!isConcrete(entry.check)) {
|
|
1166
|
+
problems.push(
|
|
1167
|
+
problem("change-verify-shape", `${entry.label} must define a concrete check.`)
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
for (const entry of changeVerify.checks) {
|
|
1172
|
+
if (!isConcrete(changeEvidenceValue(content, tasks, entry.label))) {
|
|
1173
|
+
problems.push(
|
|
1174
|
+
problem(
|
|
1175
|
+
"change-evidence-missing",
|
|
1176
|
+
`Missing concrete \`## Change Evidence\` for ${entry.label}.`
|
|
1177
|
+
)
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return problems;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
898
1184
|
// Follow-up Ownership governs work a change left undone. This is the opposite
|
|
899
1185
|
// shape: statements left standing by work the change completed. It is asked at
|
|
900
1186
|
// task-start rather than change-close because the whole value is that the
|
|
@@ -905,7 +1191,7 @@ function taskComplete(repo, options) {
|
|
|
905
1191
|
// the author was not already holding in mind, so a list of remembered files
|
|
906
1192
|
// reproduces the failure; a searchable phrase is what turns the declaration
|
|
907
1193
|
// into a grep. What the phrase says is the agent's judgment, not the gate's.
|
|
908
|
-
function invalidationProblems(repo, content, tasks) {
|
|
1194
|
+
function invalidationProblems(repo, content, tasks, change) {
|
|
909
1195
|
const heading = content.search(/^## Invalidates\s*$/m);
|
|
910
1196
|
if (heading < 0) {
|
|
911
1197
|
return [
|
|
@@ -919,10 +1205,7 @@ function invalidationProblems(repo, content, tasks) {
|
|
|
919
1205
|
),
|
|
920
1206
|
];
|
|
921
1207
|
}
|
|
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);
|
|
1208
|
+
const section = sectionBody(content, heading, tasks);
|
|
926
1209
|
if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
|
|
927
1210
|
const entries = [
|
|
928
1211
|
...section.matchAll(
|
|
@@ -956,7 +1239,7 @@ function invalidationProblems(repo, content, tasks) {
|
|
|
956
1239
|
const updated = body.match(/Updated by:\s*([0-9.,\s-]+)/i);
|
|
957
1240
|
const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
958
1241
|
const verdict = declaredOwner
|
|
959
|
-
? durableOwnerVerdict(repo, declaredOwner[1])
|
|
1242
|
+
? durableOwnerVerdict(repo, declaredOwner[1], change)
|
|
960
1243
|
: { ok: false, reason: "absent" };
|
|
961
1244
|
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
962
1245
|
if (!updated && !verdict.ok && !discarded) {
|
|
@@ -968,6 +1251,13 @@ function invalidationProblems(repo, content, tasks) {
|
|
|
968
1251
|
+ "file exists in this repository."
|
|
969
1252
|
)
|
|
970
1253
|
);
|
|
1254
|
+
} else if (verdict.reason === "transient") {
|
|
1255
|
+
problems.push(
|
|
1256
|
+
problem(
|
|
1257
|
+
"invalidation-owner-transient",
|
|
1258
|
+
`${id} names ${transientOwnerMessage(verdict.path)}`
|
|
1259
|
+
)
|
|
1260
|
+
);
|
|
971
1261
|
} else if (verdict.reason === "handoff") {
|
|
972
1262
|
problems.push(
|
|
973
1263
|
problem(
|
|
@@ -1010,7 +1300,7 @@ function invalidationProblems(repo, content, tasks) {
|
|
|
1010
1300
|
return problems;
|
|
1011
1301
|
}
|
|
1012
1302
|
|
|
1013
|
-
function expectationProblems(repo, content, tasks) {
|
|
1303
|
+
function expectationProblems(repo, content, tasks, change) {
|
|
1014
1304
|
const heading = content.search(/^## Expectation Coverage\s*$/m);
|
|
1015
1305
|
if (heading < 0) {
|
|
1016
1306
|
return [
|
|
@@ -1022,10 +1312,7 @@ function expectationProblems(repo, content, tasks) {
|
|
|
1022
1312
|
),
|
|
1023
1313
|
];
|
|
1024
1314
|
}
|
|
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);
|
|
1315
|
+
const section = sectionBody(content, heading, tasks);
|
|
1029
1316
|
if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
|
|
1030
1317
|
const entries = [
|
|
1031
1318
|
...section.matchAll(
|
|
@@ -1049,7 +1336,7 @@ function expectationProblems(repo, content, tasks) {
|
|
|
1049
1336
|
const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
|
|
1050
1337
|
const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
1051
1338
|
const verdict = declaredOwner
|
|
1052
|
-
? durableOwnerVerdict(repo, declaredOwner[1])
|
|
1339
|
+
? durableOwnerVerdict(repo, declaredOwner[1], change)
|
|
1053
1340
|
: { ok: false, reason: "absent" };
|
|
1054
1341
|
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
1055
1342
|
if (!covered && !verdict.ok && !discarded) {
|
|
@@ -1061,6 +1348,13 @@ function expectationProblems(repo, content, tasks) {
|
|
|
1061
1348
|
+ "file exists in this repository."
|
|
1062
1349
|
)
|
|
1063
1350
|
);
|
|
1351
|
+
} else if (verdict.reason === "transient") {
|
|
1352
|
+
problems.push(
|
|
1353
|
+
problem(
|
|
1354
|
+
"expectation-owner-transient",
|
|
1355
|
+
`${id} names ${transientOwnerMessage(verdict.path)}`
|
|
1356
|
+
)
|
|
1357
|
+
);
|
|
1064
1358
|
} else {
|
|
1065
1359
|
problems.push(
|
|
1066
1360
|
problem(
|
|
@@ -1117,6 +1411,7 @@ function changeClose(repo, options) {
|
|
|
1117
1411
|
const problems = [];
|
|
1118
1412
|
const reviewProblems = [];
|
|
1119
1413
|
const contracts = [];
|
|
1414
|
+
const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
|
|
1120
1415
|
if (selection.tasks.length === 0) {
|
|
1121
1416
|
problems.push(problem("missing-tasks", "Change has no executable tasks."));
|
|
1122
1417
|
}
|
|
@@ -1162,7 +1457,9 @@ function changeClose(repo, options) {
|
|
|
1162
1457
|
const checks = completionChecks(
|
|
1163
1458
|
repo,
|
|
1164
1459
|
task,
|
|
1165
|
-
contract.diagnostics.length === 0 ? contract : null
|
|
1460
|
+
contract.diagnostics.length === 0 ? contract : null,
|
|
1461
|
+
changeVerify,
|
|
1462
|
+
selection.change
|
|
1166
1463
|
);
|
|
1167
1464
|
problems.push(
|
|
1168
1465
|
...checks.problems.map((item) =>
|
|
@@ -1175,7 +1472,10 @@ function changeClose(repo, options) {
|
|
|
1175
1472
|
)
|
|
1176
1473
|
);
|
|
1177
1474
|
}
|
|
1178
|
-
problems.push(
|
|
1475
|
+
problems.push(
|
|
1476
|
+
...expectationProblems(repo, selection.content, selection.tasks, selection.change)
|
|
1477
|
+
);
|
|
1478
|
+
problems.push(...changeVerifyProblems(selection.content, selection.tasks));
|
|
1179
1479
|
|
|
1180
1480
|
const changePath = path.dirname(selection.tasksPath);
|
|
1181
1481
|
if (!hasDeltaSpec(changePath)) {
|