@christang/keel 5.3.0 → 5.3.3

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(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
@@ -316,7 +319,30 @@ function pathAllowed(candidate, touch) {
316
319
  });
317
320
  }
318
321
 
319
- function scopeEvidence(repo, task, base, contract = null, change = null) {
322
+ // A base comparison shows that a path changed, never who changed it. When a
323
+ // task of the same change is already checked complete and declares the path in
324
+ // its own Touch, blaming the selected task is a guess — and the wrong one often
325
+ // enough that the per-task commit habit became an implicit requirement whose
326
+ // diagnostic named a file the author never touched. Only a checked sibling
327
+ // counts: an unchecked task's Touch is a plan, not a record.
328
+ function completedSiblingOwners(tasks, selected) {
329
+ const owners = [];
330
+ for (const item of tasks || []) {
331
+ if (item.id === selected.id || !item.checked) continue;
332
+ const declared = touchEntries(item);
333
+ if (declared.length > 0) owners.push({ id: item.id, touch: declared });
334
+ }
335
+ return owners;
336
+ }
337
+
338
+ function scopeEvidence(
339
+ repo,
340
+ task,
341
+ base,
342
+ contract = null,
343
+ change = null,
344
+ tasks = null
345
+ ) {
320
346
  const dirtyPaths = gitPaths(repo);
321
347
  if (!base) {
322
348
  return {
@@ -358,17 +384,35 @@ function scopeEvidence(repo, task, base, contract = null, change = null) {
358
384
  // attributed as outside Touch. Other changes' directories, the archive tree,
359
385
  // and the specs/schemas trees stay attributable.
360
386
  const authoringPrefix = change ? `openspec/changes/${change}/` : null;
361
- const outside = [...changed]
387
+ const owners = completedSiblingOwners(tasks, task);
388
+ const warnings = [];
389
+ const outside = [];
390
+ const candidates = [...changed]
362
391
  .map((item) => item.replace(/\\/g, "/"))
363
392
  .filter((item) => item !== "keel/guard.json")
364
393
  .filter((item) => !(authoringPrefix && item.startsWith(authoringPrefix)))
365
394
  .filter((item) => !pathAllowed(item, touch))
366
395
  .sort();
396
+ for (const item of candidates) {
397
+ const owner = owners.find((entry) => pathAllowed(item, entry.touch));
398
+ if (owner) {
399
+ // Reported, not silent: the comparison could not establish authorship,
400
+ // and resolving that in the selected task's favour is a judgment the
401
+ // author should see rather than a fact the gate discovered.
402
+ warnings.push(
403
+ `${item} is not attributed to this task: task ${owner.id} of the same `
404
+ + "change is checked complete and declares it in Touch. A base "
405
+ + "comparison cannot establish which task wrote it."
406
+ );
407
+ continue;
408
+ }
409
+ outside.push(item);
410
+ }
367
411
  return {
368
412
  problems: outside.map((item) =>
369
413
  problem("outside-touch", `Changed path is outside Touch: ${item}`)
370
414
  ),
371
- warnings: [],
415
+ warnings,
372
416
  };
373
417
  }
374
418
 
@@ -481,7 +525,8 @@ function taskComplete(repo, options) {
481
525
  task,
482
526
  options.base,
483
527
  usableContract,
484
- selection.change
528
+ selection.change,
529
+ selection.tasks
485
530
  );
486
531
  checks.problems.push(...scope.problems);
487
532
  const status =
@@ -501,6 +546,103 @@ function taskComplete(repo, options) {
501
546
  );
502
547
  }
503
548
 
549
+ // Follow-up Ownership governs work a change left undone. This is the opposite
550
+ // shape: statements left standing by work the change completed. It is asked at
551
+ // task-start rather than change-close because the whole value is that the
552
+ // affected paths enter Touch before implementation — asking at the close finds
553
+ // the same facts after the reauthorization it was meant to prevent.
554
+ //
555
+ // A location list is refused on purpose. The text that goes stale is the text
556
+ // the author was not already holding in mind, so a list of remembered files
557
+ // reproduces the failure; a searchable phrase is what turns the declaration
558
+ // into a grep. What the phrase says is the agent's judgment, not the gate's.
559
+ function invalidationProblems(content, tasks) {
560
+ const heading = content.search(/^## Invalidates\s*$/m);
561
+ if (heading < 0) {
562
+ return [
563
+ problem(
564
+ "invalidation-declaration",
565
+ "tasks.md requires a `## Invalidates` section before its tasks are "
566
+ + "executable: one `- I<n>: \"searchable phrase\" — where it lives. "
567
+ + "Updated by: <task ids>` line per statement this change makes "
568
+ + "stale, using `Durable owner:` or `Discard reason:` instead when "
569
+ + "no task of this change updates it, or `- None.`."
570
+ ),
571
+ ];
572
+ }
573
+ const bodyStart = content.indexOf("\n", heading);
574
+ const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
575
+ const nextHeading = remainder.search(/^##\s+/m);
576
+ const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
577
+ if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
578
+ const entries = [
579
+ ...section.matchAll(
580
+ /^\s*-\s+(I\d+)\s*:\s*([\s\S]*?)(?=^\s*-\s+I\d+\s*:|(?![\s\S]))/gm
581
+ ),
582
+ ];
583
+ if (entries.length === 0) {
584
+ return [
585
+ problem(
586
+ "invalidation-declaration",
587
+ "Invalidates must declare each `I<n>` entry — a quoted searchable "
588
+ + "phrase, where it lives, and a closure (`Updated by:`, "
589
+ + "`Durable owner:`, or `Discard reason:`) — or `- None.`."
590
+ ),
591
+ ];
592
+ }
593
+ const problems = [];
594
+ for (const entry of entries) {
595
+ const [, id, body] = entry;
596
+ if (!/"[^"\n]{3,}"/.test(body)) {
597
+ problems.push(
598
+ problem(
599
+ "invalidation-phrase",
600
+ `${id} names where to look but not what to look for. Quote the `
601
+ + "wording a reader would search for, so the entry is a search "
602
+ + "rather than a reminder."
603
+ )
604
+ );
605
+ continue;
606
+ }
607
+ const updated = body.match(/Updated by:\s*([0-9.,\s-]+)/i);
608
+ 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
+ );
614
+ 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
+ );
623
+ continue;
624
+ }
625
+ // Deliberately weaker than Expectation Coverage, which requires a checked
626
+ // task: at authoring time the updater has not run yet, so existence is the
627
+ // only honest assertion. Completion is then structural — the named task
628
+ // carries the paths in its Touch and passes its own gate.
629
+ if (updated) {
630
+ const ids = updated[1].match(/\d+(?:\.\d+)+/g) || [];
631
+ for (const taskId of ids) {
632
+ if (!tasks.some((task) => task.id === taskId)) {
633
+ problems.push(
634
+ problem(
635
+ "invalidation-owner",
636
+ `${id} names task ${taskId}, which this change does not define.`
637
+ )
638
+ );
639
+ }
640
+ }
641
+ }
642
+ }
643
+ return problems;
644
+ }
645
+
504
646
  function expectationProblems(content, tasks) {
505
647
  const heading = content.search(/^## Expectation Coverage\s*$/m);
506
648
  if (heading < 0) {