@christang/keel 5.3.3 → 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
@@ -136,7 +136,7 @@ function taskStart(repo, options) {
136
136
  const compiled = compileTaskContract(repo, selection.change, task);
137
137
  const problems = [
138
138
  ...compiled.diagnostics,
139
- ...invalidationProblems(selection.content, selection.tasks),
139
+ ...invalidationProblems(repo, selection.content, selection.tasks),
140
140
  ];
141
141
  // Recording the current fingerprint is idempotent: --record replaces the
142
142
  // selected task's Contract anchor whatever it holds, so reauthorizing a task
@@ -255,19 +255,52 @@ function reviewValue(task, label) {
255
255
  // confirmed that an archive path resolves either, so an external tracker
256
256
  // reference is no less checkable than what was already accepted; whether the
257
257
  // owner is real stays a Review judgment.
258
- const SHARED_DURABLE_OWNER_FORMS =
259
- /(?:\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
+ }
260
281
 
261
282
  function findingOwnerIsDurable(repo, findings) {
262
283
  if (/keel\/HANDOFF\.md/i.test(findings)) return false;
263
284
  if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
264
285
  return true;
265
286
  }
266
- if (SHARED_DURABLE_OWNER_FORMS.test(findings)) return true;
267
- 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(
268
294
  /\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
269
295
  );
270
- 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
+ );
271
304
  }
272
305
 
273
306
  function gitPaths(repo) {
@@ -435,14 +468,24 @@ function completionChecks(repo, task, contract = null) {
435
468
  ? contract.capsule.verification.strategy.toLowerCase()
436
469
  : "";
437
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
+ );
438
479
  for (const label of commands) {
480
+ if (exempt.has(label)) continue;
439
481
  for (const phase of ["red", "green"]) {
440
482
  if (!isConcrete(evidenceValue(task, `${label}.${phase}`))) {
441
483
  problems.push(
442
484
  problem(
443
485
  "missing-strategy-evidence",
444
486
  `${strategy} requires concrete ${label}.${phase} Evidence for `
445
- + "the same behavior check."
487
+ + "the same behavior check. Tag the check `(regression)` if it "
488
+ + "asserts that something already green stays green."
446
489
  )
447
490
  );
448
491
  }
@@ -504,9 +547,9 @@ function completionChecks(repo, task, contract = null) {
504
547
  problem(
505
548
  "finding-owner",
506
549
  "Review Findings must be `none` or carry a durable owner — a "
507
- + "`Discard reason:`/`Discard rationale:` prefix, a `keel/archive/…` "
508
- + "path, an existing `openspec/changes/…` artifact, or an absolute "
509
- + "`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."
510
553
  )
511
554
  );
512
555
  }
@@ -556,7 +599,7 @@ function taskComplete(repo, options) {
556
599
  // the author was not already holding in mind, so a list of remembered files
557
600
  // reproduces the failure; a searchable phrase is what turns the declaration
558
601
  // into a grep. What the phrase says is the agent's judgment, not the gate's.
559
- function invalidationProblems(content, tasks) {
602
+ function invalidationProblems(repo, content, tasks) {
560
603
  const heading = content.search(/^## Invalidates\s*$/m);
561
604
  if (heading < 0) {
562
605
  return [
@@ -606,20 +649,38 @@ function invalidationProblems(content, tasks) {
606
649
  }
607
650
  const updated = body.match(/Updated by:\s*([0-9.,\s-]+)/i);
608
651
  const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
609
- const hasDurableOwner = Boolean(
610
- declaredOwner
611
- && (/^openspec\/changes\//i.test(declaredOwner[1].trim())
612
- || SHARED_DURABLE_OWNER_FORMS.test(declaredOwner[1]))
613
- );
652
+ const verdict = declaredOwner
653
+ ? durableOwnerVerdict(repo, declaredOwner[1])
654
+ : { ok: false, reason: "absent" };
614
655
  const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
615
- if (!updated && !hasDurableOwner && !discarded) {
616
- problems.push(
617
- problem(
618
- "invalidation-closure",
619
- `${id} lacks an updating task, a durable owner, or a discard `
620
- + "rationale."
621
- )
622
- );
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
+ }
623
684
  continue;
624
685
  }
625
686
  // Deliberately weaker than Expectation Coverage, which requires a checked
@@ -643,7 +704,7 @@ function invalidationProblems(content, tasks) {
643
704
  return problems;
644
705
  }
645
706
 
646
- function expectationProblems(content, tasks) {
707
+ function expectationProblems(repo, content, tasks) {
647
708
  const heading = content.search(/^## Expectation Coverage\s*$/m);
648
709
  if (heading < 0) {
649
710
  return [
@@ -681,19 +742,30 @@ function expectationProblems(content, tasks) {
681
742
  const [, id, body] = entry;
682
743
  const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
683
744
  const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
684
- const hasDurableOwner = Boolean(
685
- declaredOwner
686
- && (/^openspec\/changes\//i.test(declaredOwner[1].trim())
687
- || SHARED_DURABLE_OWNER_FORMS.test(declaredOwner[1]))
688
- );
745
+ const verdict = declaredOwner
746
+ ? durableOwnerVerdict(repo, declaredOwner[1])
747
+ : { ok: false, reason: "absent" };
689
748
  const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
690
- if (!covered && !hasDurableOwner && !discarded) {
691
- problems.push(
692
- problem(
693
- "expectation-closure",
694
- `${id} lacks behavior coverage, durable owner, or discard rationale.`
695
- )
696
- );
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
+ }
697
769
  continue;
698
770
  }
699
771
  if (covered) {
@@ -775,7 +847,7 @@ function changeClose(repo, options) {
775
847
  )
776
848
  );
777
849
  }
778
- problems.push(...expectationProblems(selection.content, selection.tasks));
850
+ problems.push(...expectationProblems(repo, selection.content, selection.tasks));
779
851
 
780
852
  const changePath = path.dirname(selection.tasksPath);
781
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,