@christang/keel 5.39.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.
@@ -7,7 +7,13 @@ const path = require("path");
7
7
  // closed so an entry outside it can be reported by name: a free-form grant
8
8
  // cannot tell a typo from a decision, and silently dropping one leaves the
9
9
  // author believing they authorized something they did not.
10
- const STANDING_AUTHORIZATION_ACTIONS = ["commit", "push", "release", "archive"];
10
+ const STANDING_AUTHORIZATION_ACTIONS = [
11
+ "commit",
12
+ "push",
13
+ "release",
14
+ "archive",
15
+ "continuation",
16
+ ];
11
17
 
12
18
  // The closed vocabulary of capability tiers a repository may declare for a
13
19
  // delegated task. The names describe the capability the work requires, never
package/src/core/gates.js CHANGED
@@ -244,7 +244,7 @@ function taskStart(repo, options) {
244
244
  const compiled = compileTaskContract(repo, selection.change, task);
245
245
  const problems = [
246
246
  ...compiled.diagnostics,
247
- ...invalidationProblems(repo, selection.content, selection.tasks),
247
+ ...invalidationProblems(repo, selection.content, selection.tasks, selection.change),
248
248
  ];
249
249
  // Recording the current fingerprint is idempotent: --record replaces the
250
250
  // selected task's Contract anchor whatever it holds, so reauthorizing a task
@@ -394,11 +394,21 @@ const TRACKER_REFERENCE = /\bhttps?:\/\/\S/i;
394
394
 
395
395
  // The owner forms, stated once so every refusal that lists them agrees with
396
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).
397
404
  const DURABLE_OWNER_FORMS =
398
405
  "an absolute `https://…` tracker reference, or any repo-relative path that "
399
- + "exists — `keel/archive/…`, an `openspec/changes/…` artifact, or the "
400
- + "repository's own ledger; `keel/HANDOFF.md` is a pointer override rather "
401
- + "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";
402
412
 
403
413
  // Trailing punctuation a declared path can abut in prose. ASCII sentence marks
404
414
  // and their CJK counterparts both belong here: once the extractor stops
@@ -429,20 +439,55 @@ function declaredPath(value) {
429
439
  return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
430
440
  }
431
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
+
432
461
  // Classify a declared `Durable owner:` value. A gate runs without network, so a
433
462
  // URL is accepted on shape alone; a path is the one form it can actually check,
434
- // and checking it is stricter than the prefix whitelist this replaced.
435
- function durableOwnerVerdict(repo, value) {
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) {
436
467
  const owner = String(value || "").trim();
437
468
  if (!owner) return { ok: false, reason: "unrecognized" };
438
469
  if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
439
470
  if (TRACKER_REFERENCE.test(owner)) return { ok: true };
440
471
  const candidate = declaredPath(owner);
441
472
  if (!candidate) return { ok: false, reason: "unrecognized" };
473
+ if (insideOwnChangeDirectory(candidate, change)) {
474
+ return { ok: false, reason: "transient", path: candidate };
475
+ }
442
476
  if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
443
477
  return { ok: false, reason: "missing", path: candidate };
444
478
  }
445
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
+
446
491
  // A finding has three dispositions and the gate recognized two. One found and
447
492
  // fixed inside the task recording it has no owner to name and nothing to
448
493
  // discard, so the only text that passed was `Discard reason:` — filing a repair
@@ -469,7 +514,7 @@ const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
469
514
  // it or the artifact that shows it. A bare marker is refused because a
470
515
  // disposition that asserts its own conclusion would be a way out of the other
471
516
  // two, and the third state would decay into the easiest exit.
472
- function resolutionEvidenceVerdict(repo, value, commands) {
517
+ function resolutionEvidenceVerdict(repo, value, commands, change) {
473
518
  const evidence = String(value || "").trim();
474
519
  if (!evidence) return { ok: false, reason: "empty" };
475
520
  // The tracker form is tested before the path form: a URL contains something
@@ -484,6 +529,12 @@ function resolutionEvidenceVerdict(repo, value, commands) {
484
529
  }
485
530
  const candidate = declaredPath(evidence);
486
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
+ }
487
538
  if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
488
539
  return { ok: false, reason: "missing", path: candidate };
489
540
  }
@@ -504,13 +555,16 @@ function resolutionEvidenceMessage(verdict) {
504
555
  if (verdict.reason === "unknown-check") {
505
556
  return `${lead}${verdict.label} is not a check this task declares.${tail}`;
506
557
  }
558
+ if (verdict.reason === "transient") {
559
+ return `${lead}${transientOwnerMessage(verdict.path)}${tail}`;
560
+ }
507
561
  if (verdict.reason === "missing") {
508
562
  return `${lead}\`${verdict.path}\` does not exist.${tail}`;
509
563
  }
510
564
  return `${lead}it names neither a check nor a path.${tail}`;
511
565
  }
512
566
 
513
- function findingOwnerIsDurable(repo, findings) {
567
+ function findingOwnerIsDurable(repo, findings, change) {
514
568
  if (/keel\/HANDOFF\.md/i.test(findings)) return false;
515
569
  if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
516
570
  return true;
@@ -520,11 +574,17 @@ function findingOwnerIsDurable(repo, findings) {
520
574
  // prose, and a finding that merely mentions the source file it concerns has
521
575
  // not thereby given that finding an owner.
522
576
  const declared = findings.match(/Durable owner:\s*(\S[^\n]*)/i);
523
- if (declared) return durableOwnerVerdict(repo, declared[1]).ok;
577
+ if (declared) return durableOwnerVerdict(repo, declared[1], change).ok;
524
578
  const artifact = findings.match(
525
579
  /\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
526
580
  );
527
- if (artifact && fs.existsSync(path.join(repo, artifact[1]))) return true;
581
+ if (
582
+ artifact
583
+ && !insideOwnChangeDirectory(artifact[1], change)
584
+ && fs.existsSync(path.join(repo, artifact[1]))
585
+ ) {
586
+ return true;
587
+ }
528
588
  // Same extractor, scoped to the archive prefix: the segment after
529
589
  // `keel/archive/` is a path like any other and was equally ASCII-bound.
530
590
  const archive = findings.match(/keel\/archive\/[^\s`]*/i);
@@ -741,7 +801,7 @@ function attributeChanged(repo, task, changedList, contract, change, tasks) {
741
801
  };
742
802
  }
743
803
 
744
- function completionChecks(repo, task, contract = null, changeVerify = null) {
804
+ function completionChecks(repo, task, contract = null, changeVerify = null, change = null) {
745
805
  const problems = [];
746
806
  const commands = contract
747
807
  ? contract.capsule.verification.commands.map((item) => item.label)
@@ -900,14 +960,14 @@ function completionChecks(repo, task, contract = null, changeVerify = null) {
900
960
  const resolved = [...reviewFields.Findings.matchAll(RESOLVED_HERE)];
901
961
  if (resolved.length > 0) {
902
962
  for (const claim of resolved) {
903
- const verdict = resolutionEvidenceVerdict(repo, claim[1], commands);
963
+ const verdict = resolutionEvidenceVerdict(repo, claim[1], commands, change);
904
964
  if (verdict.ok) continue;
905
965
  problems.push(
906
966
  problem("finding-resolution-evidence", resolutionEvidenceMessage(verdict))
907
967
  );
908
968
  break;
909
969
  }
910
- } else if (!findingOwnerIsDurable(repo, reviewFields.Findings)) {
970
+ } else if (!findingOwnerIsDurable(repo, reviewFields.Findings, change)) {
911
971
  problems.push(
912
972
  problem(
913
973
  "finding-owner",
@@ -950,7 +1010,13 @@ function taskComplete(repo, options) {
950
1010
  const contract = compileTaskContract(repo, selection.change, task);
951
1011
  const usableContract = contract.diagnostics.length === 0 ? contract : null;
952
1012
  const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
953
- const checks = completionChecks(repo, task, usableContract, changeVerify);
1013
+ const checks = completionChecks(
1014
+ repo,
1015
+ task,
1016
+ usableContract,
1017
+ changeVerify,
1018
+ selection.change
1019
+ );
954
1020
  checks.problems.push(...contract.diagnostics);
955
1021
  const missingAnchor = missingAnchorProblem(selection, task);
956
1022
  if (missingAnchor) {
@@ -1125,7 +1191,7 @@ function changeVerifyProblems(content, tasks) {
1125
1191
  // the author was not already holding in mind, so a list of remembered files
1126
1192
  // reproduces the failure; a searchable phrase is what turns the declaration
1127
1193
  // into a grep. What the phrase says is the agent's judgment, not the gate's.
1128
- function invalidationProblems(repo, content, tasks) {
1194
+ function invalidationProblems(repo, content, tasks, change) {
1129
1195
  const heading = content.search(/^## Invalidates\s*$/m);
1130
1196
  if (heading < 0) {
1131
1197
  return [
@@ -1173,7 +1239,7 @@ function invalidationProblems(repo, content, tasks) {
1173
1239
  const updated = body.match(/Updated by:\s*([0-9.,\s-]+)/i);
1174
1240
  const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
1175
1241
  const verdict = declaredOwner
1176
- ? durableOwnerVerdict(repo, declaredOwner[1])
1242
+ ? durableOwnerVerdict(repo, declaredOwner[1], change)
1177
1243
  : { ok: false, reason: "absent" };
1178
1244
  const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
1179
1245
  if (!updated && !verdict.ok && !discarded) {
@@ -1185,6 +1251,13 @@ function invalidationProblems(repo, content, tasks) {
1185
1251
  + "file exists in this repository."
1186
1252
  )
1187
1253
  );
1254
+ } else if (verdict.reason === "transient") {
1255
+ problems.push(
1256
+ problem(
1257
+ "invalidation-owner-transient",
1258
+ `${id} names ${transientOwnerMessage(verdict.path)}`
1259
+ )
1260
+ );
1188
1261
  } else if (verdict.reason === "handoff") {
1189
1262
  problems.push(
1190
1263
  problem(
@@ -1227,7 +1300,7 @@ function invalidationProblems(repo, content, tasks) {
1227
1300
  return problems;
1228
1301
  }
1229
1302
 
1230
- function expectationProblems(repo, content, tasks) {
1303
+ function expectationProblems(repo, content, tasks, change) {
1231
1304
  const heading = content.search(/^## Expectation Coverage\s*$/m);
1232
1305
  if (heading < 0) {
1233
1306
  return [
@@ -1263,7 +1336,7 @@ function expectationProblems(repo, content, tasks) {
1263
1336
  const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
1264
1337
  const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
1265
1338
  const verdict = declaredOwner
1266
- ? durableOwnerVerdict(repo, declaredOwner[1])
1339
+ ? durableOwnerVerdict(repo, declaredOwner[1], change)
1267
1340
  : { ok: false, reason: "absent" };
1268
1341
  const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
1269
1342
  if (!covered && !verdict.ok && !discarded) {
@@ -1275,6 +1348,13 @@ function expectationProblems(repo, content, tasks) {
1275
1348
  + "file exists in this repository."
1276
1349
  )
1277
1350
  );
1351
+ } else if (verdict.reason === "transient") {
1352
+ problems.push(
1353
+ problem(
1354
+ "expectation-owner-transient",
1355
+ `${id} names ${transientOwnerMessage(verdict.path)}`
1356
+ )
1357
+ );
1278
1358
  } else {
1279
1359
  problems.push(
1280
1360
  problem(
@@ -1378,7 +1458,8 @@ function changeClose(repo, options) {
1378
1458
  repo,
1379
1459
  task,
1380
1460
  contract.diagnostics.length === 0 ? contract : null,
1381
- changeVerify
1461
+ changeVerify,
1462
+ selection.change
1382
1463
  );
1383
1464
  problems.push(
1384
1465
  ...checks.problems.map((item) =>
@@ -1391,7 +1472,9 @@ function changeClose(repo, options) {
1391
1472
  )
1392
1473
  );
1393
1474
  }
1394
- problems.push(...expectationProblems(repo, selection.content, selection.tasks));
1475
+ problems.push(
1476
+ ...expectationProblems(repo, selection.content, selection.tasks, selection.change)
1477
+ );
1395
1478
  problems.push(...changeVerifyProblems(selection.content, selection.tasks));
1396
1479
 
1397
1480
  const changePath = path.dirname(selection.tasksPath);
@@ -687,9 +687,16 @@ function criticalAuthority(repo, change, reference) {
687
687
  };
688
688
  }
689
689
  const content = fs.readFileSync(designPath, "utf8");
690
+ // Accepted line shapes: an optional CommonMark list bullet, the identifier
691
+ // bare or wrapped in balanced `**`, then the dash and statement. Authors
692
+ // overwhelmingly write the bulleted and bold shapes (issue #49).
690
693
  const matches = [
691
694
  ...content.matchAll(
692
- new RegExp(`^\\s*${reference}\\s*[—-]\\s*(.+?)\\s*$`, "gmi")
695
+ new RegExp(
696
+ `^\\s*(?:[-*+]\\s+)?(?:\\*\\*${reference}\\*\\*|${reference})`
697
+ + `\\s*[—-]\\s*(.+?)\\s*$`,
698
+ "gmi"
699
+ )
693
700
  ),
694
701
  ];
695
702
  if (matches.length !== 1) {
@@ -707,9 +714,11 @@ function criticalAuthority(repo, change, reference) {
707
714
  code: "unresolved-covers",
708
715
  message:
709
716
  `Unparsed Covers critical statement: ${reference}. It appears in `
710
- + "design.md but not in the required shape — write it starting "
711
- + `the line as \`${reference} — one-line statement\` (no leading `
712
- + "`-`, `**`, or other decoration) so it can be resolved.",
717
+ + "design.md but not in an accepted line shape — write it as a "
718
+ + "line opening with the identifier and a dash, "
719
+ + `\`${reference} — one-line statement\`, optionally as a list `
720
+ + `bullet (\`- ${reference} — …\`) and/or with the identifier `
721
+ + `bold (\`**${reference}** — …\`).`,
713
722
  },
714
723
  };
715
724
  }
@@ -758,7 +767,17 @@ function resolveAuthority(repo, change, task) {
758
767
  }
759
768
  const entries = [...seen].sort();
760
769
  for (const entry of entries) {
761
- const critical = criticalAuthority(repo, change, entry);
770
+ // A critical-statement reference may open its entry with a trailing
771
+ // annotation after a dash (`D2 — note`); the identifier resolves and the
772
+ // annotation stays annotation — design.md owns the statement text. The
773
+ // boundary after the identifier is whitespace or an em dash so that free
774
+ // text like `D2-compatible` does not become a reference.
775
+ const annotated = entry.match(/^([DFAQ]\d+)(?=\s|—)\s*[—-]\s*.+$/);
776
+ const critical = criticalAuthority(
777
+ repo,
778
+ change,
779
+ annotated ? annotated[1] : entry
780
+ );
762
781
  if (critical) {
763
782
  if (critical.diagnostic) diagnostics.push(critical.diagnostic);
764
783
  if (critical.authority) authority.push(critical.authority);