@christang/keel 5.3.1 → 5.3.4

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/src/core/gates.js CHANGED
@@ -134,7 +134,10 @@ function taskStart(repo, options) {
134
134
  const selection = loadSelection(repo, options);
135
135
  const task = selection.selected[0];
136
136
  const compiled = compileTaskContract(repo, selection.change, task);
137
- const problems = [...compiled.diagnostics];
137
+ const problems = [
138
+ ...compiled.diagnostics,
139
+ ...invalidationProblems(repo, selection.content, selection.tasks),
140
+ ];
138
141
  // Recording the current fingerprint is idempotent: --record replaces the
139
142
  // selected task's Contract anchor whatever it holds, so reauthorizing a task
140
143
  // whose authority changed — the path the guard's own drift messages direct
@@ -252,19 +255,52 @@ function reviewValue(task, label) {
252
255
  // confirmed that an archive path resolves either, so an external tracker
253
256
  // reference is no less checkable than what was already accepted; whether the
254
257
  // owner is real stays a Review judgment.
255
- const SHARED_DURABLE_OWNER_FORMS =
256
- /(?:\bkeel\/archive\/[A-Za-z0-9._/-]+|\bhttps?:\/\/\S)/i;
258
+ const TRACKER_REFERENCE = /\bhttps?:\/\/\S/i;
259
+
260
+ // The owner forms, stated once so every refusal that lists them agrees with
261
+ // every other and with what the checks below actually accept.
262
+ const DURABLE_OWNER_FORMS =
263
+ "an absolute `https://…` tracker reference, or any repo-relative path that "
264
+ + "exists — `keel/archive/…`, an `openspec/changes/…` artifact, or the "
265
+ + "repository's own ledger; `keel/HANDOFF.md` is a pointer override rather "
266
+ + "than an owner";
267
+
268
+ // Classify a declared `Durable owner:` value. A gate runs without network, so a
269
+ // URL is accepted on shape alone; a path is the one form it can actually check,
270
+ // and checking it is stricter than the prefix whitelist this replaced.
271
+ function durableOwnerVerdict(repo, value) {
272
+ const owner = String(value || "").trim();
273
+ if (!owner) return { ok: false, reason: "unrecognized" };
274
+ if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
275
+ if (TRACKER_REFERENCE.test(owner)) return { ok: true };
276
+ const candidate = owner.match(/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+/);
277
+ if (!candidate) return { ok: false, reason: "unrecognized" };
278
+ if (fs.existsSync(path.join(repo, candidate[0]))) return { ok: true };
279
+ return { ok: false, reason: "missing", path: candidate[0] };
280
+ }
257
281
 
258
282
  function findingOwnerIsDurable(repo, findings) {
259
283
  if (/keel\/HANDOFF\.md/i.test(findings)) return false;
260
284
  if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
261
285
  return true;
262
286
  }
263
- if (SHARED_DURABLE_OWNER_FORMS.test(findings)) return true;
264
- const owner = findings.match(
287
+ if (TRACKER_REFERENCE.test(findings)) return true;
288
+ // A path counts here only when it is named as the owner. Findings is free
289
+ // prose, and a finding that merely mentions the source file it concerns has
290
+ // not thereby given that finding an owner.
291
+ const declared = findings.match(/Durable owner:\s*(\S[^\n]*)/i);
292
+ if (declared) return durableOwnerVerdict(repo, declared[1]).ok;
293
+ const artifact = findings.match(
265
294
  /\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
266
295
  );
267
- return Boolean(owner && fs.existsSync(path.join(repo, owner[1])));
296
+ if (artifact && fs.existsSync(path.join(repo, artifact[1]))) return true;
297
+ return /\bkeel\/archive\/[A-Za-z0-9._/-]+/i.test(findings)
298
+ && fs.existsSync(
299
+ path.join(
300
+ repo,
301
+ findings.match(/\bkeel\/archive\/[A-Za-z0-9._/-]+/i)[0]
302
+ )
303
+ );
268
304
  }
269
305
 
270
306
  function gitPaths(repo) {
@@ -432,14 +468,24 @@ function completionChecks(repo, task, contract = null) {
432
468
  ? contract.capsule.verification.strategy.toLowerCase()
433
469
  : "";
434
470
  if (RED_GREEN_VERIFICATION_STRATEGIES.has(strategy)) {
471
+ // A `(regression)` check asserts that something already green is still
472
+ // green, so it has no honest red. It is exempt from red-green but not from
473
+ // evidence: the bare-label check above still applies to it.
474
+ const exempt = new Set(
475
+ (contract ? contract.capsule.verification.commands : [])
476
+ .filter((entry) => entry.regression)
477
+ .map((entry) => entry.label)
478
+ );
435
479
  for (const label of commands) {
480
+ if (exempt.has(label)) continue;
436
481
  for (const phase of ["red", "green"]) {
437
482
  if (!isConcrete(evidenceValue(task, `${label}.${phase}`))) {
438
483
  problems.push(
439
484
  problem(
440
485
  "missing-strategy-evidence",
441
486
  `${strategy} requires concrete ${label}.${phase} Evidence for `
442
- + "the same behavior check."
487
+ + "the same behavior check. Tag the check `(regression)` if it "
488
+ + "asserts that something already green stays green."
443
489
  )
444
490
  );
445
491
  }
@@ -501,9 +547,9 @@ function completionChecks(repo, task, contract = null) {
501
547
  problem(
502
548
  "finding-owner",
503
549
  "Review Findings must be `none` or carry a durable owner — a "
504
- + "`Discard reason:`/`Discard rationale:` prefix, a `keel/archive/…` "
505
- + "path, an existing `openspec/changes/…` artifact, or an absolute "
506
- + "`https://…` tracker reference; `keel/HANDOFF.md` is not an owner."
550
+ + "`Discard reason:`/`Discard rationale:` prefix, or "
551
+ + `${DURABLE_OWNER_FORMS}. Name a path after \`Durable owner:\` so `
552
+ + "it reads as the owner rather than a file the finding mentions."
507
553
  )
508
554
  );
509
555
  }
@@ -543,7 +589,122 @@ function taskComplete(repo, options) {
543
589
  );
544
590
  }
545
591
 
546
- function expectationProblems(content, tasks) {
592
+ // Follow-up Ownership governs work a change left undone. This is the opposite
593
+ // shape: statements left standing by work the change completed. It is asked at
594
+ // task-start rather than change-close because the whole value is that the
595
+ // affected paths enter Touch before implementation — asking at the close finds
596
+ // the same facts after the reauthorization it was meant to prevent.
597
+ //
598
+ // A location list is refused on purpose. The text that goes stale is the text
599
+ // the author was not already holding in mind, so a list of remembered files
600
+ // reproduces the failure; a searchable phrase is what turns the declaration
601
+ // into a grep. What the phrase says is the agent's judgment, not the gate's.
602
+ function invalidationProblems(repo, content, tasks) {
603
+ const heading = content.search(/^## Invalidates\s*$/m);
604
+ if (heading < 0) {
605
+ return [
606
+ problem(
607
+ "invalidation-declaration",
608
+ "tasks.md requires a `## Invalidates` section before its tasks are "
609
+ + "executable: one `- I<n>: \"searchable phrase\" — where it lives. "
610
+ + "Updated by: <task ids>` line per statement this change makes "
611
+ + "stale, using `Durable owner:` or `Discard reason:` instead when "
612
+ + "no task of this change updates it, or `- None.`."
613
+ ),
614
+ ];
615
+ }
616
+ const bodyStart = content.indexOf("\n", heading);
617
+ const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
618
+ const nextHeading = remainder.search(/^##\s+/m);
619
+ const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
620
+ if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
621
+ const entries = [
622
+ ...section.matchAll(
623
+ /^\s*-\s+(I\d+)\s*:\s*([\s\S]*?)(?=^\s*-\s+I\d+\s*:|(?![\s\S]))/gm
624
+ ),
625
+ ];
626
+ if (entries.length === 0) {
627
+ return [
628
+ problem(
629
+ "invalidation-declaration",
630
+ "Invalidates must declare each `I<n>` entry — a quoted searchable "
631
+ + "phrase, where it lives, and a closure (`Updated by:`, "
632
+ + "`Durable owner:`, or `Discard reason:`) — or `- None.`."
633
+ ),
634
+ ];
635
+ }
636
+ const problems = [];
637
+ for (const entry of entries) {
638
+ const [, id, body] = entry;
639
+ if (!/"[^"\n]{3,}"/.test(body)) {
640
+ problems.push(
641
+ problem(
642
+ "invalidation-phrase",
643
+ `${id} names where to look but not what to look for. Quote the `
644
+ + "wording a reader would search for, so the entry is a search "
645
+ + "rather than a reminder."
646
+ )
647
+ );
648
+ continue;
649
+ }
650
+ const updated = body.match(/Updated by:\s*([0-9.,\s-]+)/i);
651
+ const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
652
+ const verdict = declaredOwner
653
+ ? durableOwnerVerdict(repo, declaredOwner[1])
654
+ : { ok: false, reason: "absent" };
655
+ const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
656
+ if (!updated && !verdict.ok && !discarded) {
657
+ if (verdict.reason === "missing") {
658
+ problems.push(
659
+ problem(
660
+ "invalidation-owner-missing",
661
+ `${id} names \`${verdict.path}\` as its durable owner, but no such `
662
+ + "file exists in this repository."
663
+ )
664
+ );
665
+ } else if (verdict.reason === "handoff") {
666
+ problems.push(
667
+ problem(
668
+ "invalidation-closure",
669
+ `${id} names keel/HANDOFF.md, which is a pointer override rather `
670
+ + `than a durable owner. Accepted forms: ${DURABLE_OWNER_FORMS}.`
671
+ )
672
+ );
673
+ } else {
674
+ problems.push(
675
+ problem(
676
+ "invalidation-closure",
677
+ `${id} lacks an updating task, a durable owner, or a discard `
678
+ + "rationale. Close it with `Updated by: <task ids>` naming tasks "
679
+ + "of this change, a `Discard reason:`, or a `Durable owner:` "
680
+ + `naming ${DURABLE_OWNER_FORMS}.`
681
+ )
682
+ );
683
+ }
684
+ continue;
685
+ }
686
+ // Deliberately weaker than Expectation Coverage, which requires a checked
687
+ // task: at authoring time the updater has not run yet, so existence is the
688
+ // only honest assertion. Completion is then structural — the named task
689
+ // carries the paths in its Touch and passes its own gate.
690
+ if (updated) {
691
+ const ids = updated[1].match(/\d+(?:\.\d+)+/g) || [];
692
+ for (const taskId of ids) {
693
+ if (!tasks.some((task) => task.id === taskId)) {
694
+ problems.push(
695
+ problem(
696
+ "invalidation-owner",
697
+ `${id} names task ${taskId}, which this change does not define.`
698
+ )
699
+ );
700
+ }
701
+ }
702
+ }
703
+ }
704
+ return problems;
705
+ }
706
+
707
+ function expectationProblems(repo, content, tasks) {
547
708
  const heading = content.search(/^## Expectation Coverage\s*$/m);
548
709
  if (heading < 0) {
549
710
  return [
@@ -581,19 +742,30 @@ function expectationProblems(content, tasks) {
581
742
  const [, id, body] = entry;
582
743
  const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
583
744
  const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
584
- const hasDurableOwner = Boolean(
585
- declaredOwner
586
- && (/^openspec\/changes\//i.test(declaredOwner[1].trim())
587
- || SHARED_DURABLE_OWNER_FORMS.test(declaredOwner[1]))
588
- );
745
+ const verdict = declaredOwner
746
+ ? durableOwnerVerdict(repo, declaredOwner[1])
747
+ : { ok: false, reason: "absent" };
589
748
  const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
590
- if (!covered && !hasDurableOwner && !discarded) {
591
- problems.push(
592
- problem(
593
- "expectation-closure",
594
- `${id} lacks behavior coverage, durable owner, or discard rationale.`
595
- )
596
- );
749
+ if (!covered && !verdict.ok && !discarded) {
750
+ if (verdict.reason === "missing") {
751
+ problems.push(
752
+ problem(
753
+ "expectation-owner-missing",
754
+ `${id} names \`${verdict.path}\` as its durable owner, but no such `
755
+ + "file exists in this repository."
756
+ )
757
+ );
758
+ } else {
759
+ problems.push(
760
+ problem(
761
+ "expectation-closure",
762
+ `${id} lacks behavior coverage, durable owner, or discard `
763
+ + "rationale. Close it with `Covered by: <task ids>`, a "
764
+ + "`Discard reason:`, or a `Durable owner:` naming "
765
+ + `${DURABLE_OWNER_FORMS}.`
766
+ )
767
+ );
768
+ }
597
769
  continue;
598
770
  }
599
771
  if (covered) {
@@ -675,7 +847,7 @@ function changeClose(repo, options) {
675
847
  )
676
848
  );
677
849
  }
678
- problems.push(...expectationProblems(selection.content, selection.tasks));
850
+ problems.push(...expectationProblems(repo, selection.content, selection.tasks));
679
851
 
680
852
  const changePath = path.dirname(selection.tasksPath);
681
853
  if (!hasDeltaSpec(changePath)) {
@@ -130,6 +130,9 @@ const RED_GREEN_VERIFICATION_STRATEGIES = new Set([
130
130
  "regression-first",
131
131
  ]);
132
132
 
133
+ // Tags an M<n> check may carry after its label, as a comma-separated set.
134
+ const COMMAND_TAGS = new Set(["fast", "full", "regression"]);
135
+
133
136
  // Single source of truth for the accepted completion Review `Status`
134
137
  // vocabulary. Consumed by both the completion gate (src/core/gates.js) and the
135
138
  // context "already reviewed" probe (src/core/context.js) so the two never
@@ -156,12 +159,26 @@ function verification(task) {
156
159
  ? compact.filter((entry) => !/^Strategy:\s*/i.test(entry))
157
160
  : fieldValues(task, "Commands");
158
161
  const commands = commandSource.map((entry) => {
159
- // An optional (fast)/(full) layer tag after the M<n> label marks which
160
- // checks the fast inner loop runs; an untagged check is full.
161
- const match = entry.match(/^(M[1-9]\d*)(?:\s*\((fast|full)\))?:\s*(.*)$/);
162
- return match
163
- ? { label: match[1], layer: match[2] || "full", check: normalizeText(match[3]) }
164
- : { label: null, layer: "full", check: entry };
162
+ // An optional tag set after the M<n> label. `fast`/`full` marks which checks
163
+ // the fast inner loop runs; `regression` marks a check that asserts
164
+ // something already green is still green, which has no honest red and is
165
+ // therefore exempt from the red-green evidence requirement. A check may
166
+ // carry both, so the tag is a comma-separated set rather than one word.
167
+ const match = entry.match(/^(M[1-9]\d*)(?:\s*\(([^)\n]*)\))?:\s*(.*)$/);
168
+ if (!match) return { label: null, layer: "full", regression: false, check: entry };
169
+ const tags = (match[2] || "")
170
+ .split(",")
171
+ .map((tag) => tag.trim().toLowerCase())
172
+ .filter(Boolean);
173
+ if (tags.some((tag) => !COMMAND_TAGS.has(tag))) {
174
+ return { label: null, layer: "full", regression: false, check: entry };
175
+ }
176
+ return {
177
+ label: match[1],
178
+ layer: tags.includes("fast") ? "fast" : "full",
179
+ regression: tags.includes("regression"),
180
+ check: normalizeText(match[3]),
181
+ };
165
182
  });
166
183
  return {
167
184
  compact: compact.length > 0,
@@ -189,7 +206,9 @@ function commandLabelProblems(task) {
189
206
  malformed = true;
190
207
  problems.push({
191
208
  code: "invalid-command-label",
192
- message: `Command entry must use an M<n> label: ${entry}`,
209
+ message: `Command entry must use an M<n> label, optionally followed by `
210
+ + `a tag set drawn from ${[...COMMAND_TAGS].join(", ")} — `
211
+ + `for example \`M2 (regression): …\`: ${entry}`,
193
212
  });
194
213
  continue;
195
214
  }
@@ -264,7 +283,7 @@ function taskStartContractProblems(task) {
264
283
  },
265
284
  ];
266
285
  }
267
- return commandLabelProblems(task);
286
+ return [...commandLabelProblems(task), ...regressionOnlyProblems(task)];
268
287
  }
269
288
  if (!touch.some((entry) => isConcrete(entry))) {
270
289
  return [
@@ -273,9 +292,34 @@ function taskStartContractProblems(task) {
273
292
  message: "implementation and plan-first require a concrete Touch path.",
274
293
  },
275
294
  ...commandLabelProblems(task),
295
+ ...regressionOnlyProblems(task),
276
296
  ];
277
297
  }
278
- return commandLabelProblems(task);
298
+ return [...commandLabelProblems(task), ...regressionOnlyProblems(task)];
299
+ }
300
+
301
+ // A red-green strategy whose every check is exempt from red-green is that
302
+ // strategy in name only, and the tag would become the escape hatch rather than
303
+ // the declaration it is meant to be.
304
+ function regressionOnlyProblems(task) {
305
+ const parsed = verification(task);
306
+ if (!RED_GREEN_VERIFICATION_STRATEGIES.has(parsed.strategy.toLowerCase())) {
307
+ return [];
308
+ }
309
+ const labelled = parsed.commands.filter((entry) => entry.label);
310
+ if (labelled.length === 0 || labelled.some((entry) => !entry.regression)) {
311
+ return [];
312
+ }
313
+ return [
314
+ {
315
+ code: "regression-only-strategy",
316
+ message:
317
+ `\`${parsed.strategy}\` requires at least one check that is not tagged `
318
+ + "`(regression)`, because a regression check has no red to record. "
319
+ + "Untag the check that proves the new behavior, or name a strategy "
320
+ + "that is not red-green.",
321
+ },
322
+ ];
279
323
  }
280
324
 
281
325
  function requiredFieldProblems(task) {
@@ -803,15 +847,17 @@ function compileTaskContract(repo, change, task) {
803
847
  acceptance: [...new Set([...derivedAcceptance, ...explicitAcceptance])],
804
848
  verification: {
805
849
  strategy: taskVerification.strategy,
806
- // Emit the layer only when a check opts into the fast inner loop, so
807
- // untagged (full) checks keep their existing capsule shape and fingerprint.
850
+ // Emit a tag only when the check opts out of a default, so an untagged
851
+ // check keeps the capsule shape and fingerprint it had before either tag
852
+ // existed. `layer` appears only for `fast`, `regression` only when true.
808
853
  commands: taskVerification.commands
809
854
  .filter((entry) => entry.label)
810
- .map((entry) =>
811
- entry.layer && entry.layer !== "full"
812
- ? { label: entry.label, check: entry.check, layer: entry.layer }
813
- : { label: entry.label, check: entry.check }
814
- ),
855
+ .map((entry) => {
856
+ const emitted = { label: entry.label, check: entry.check };
857
+ if (entry.layer && entry.layer !== "full") emitted.layer = entry.layer;
858
+ if (entry.regression) emitted.regression = true;
859
+ return emitted;
860
+ }),
815
861
  },
816
862
  boundaries: {
817
863
  autonomy,