@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/src/core/guard.js CHANGED
@@ -59,6 +59,20 @@ function sha256(buffer) {
59
59
  return crypto.createHash("sha256").update(buffer).digest("hex");
60
60
  }
61
61
 
62
+ // The content a dirty path held at the moment it was read, or `null` when
63
+ // nothing could be read — a deleted path, mid-rename, or one that never
64
+ // existed. `null` is a signature like any other: it round-trips through the
65
+ // same equality check a real hash does, so a path that stays absent compares
66
+ // equal and one that gets created or deleted compares unequal, with no
67
+ // special case for either direction.
68
+ function contentSignature(repo, relative) {
69
+ try {
70
+ return sha256(fs.readFileSync(path.join(repo, relative)));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
62
76
  function guardResult(subcommand, status, extra = {}) {
63
77
  return {
64
78
  schemaVersion: 1,
@@ -68,17 +82,17 @@ function guardResult(subcommand, status, extra = {}) {
68
82
  manifestPath: "keel/guard.json",
69
83
  problems: [],
70
84
  warnings: [
71
- "The guard manifest is a disposable enforcement pointer; OpenSpec and "
72
- + "Git remain the only durable authority and selection never derives "
85
+ "The guard manifest is a disposable enforcement pointer, not durable "
86
+ + "authority OpenSpec and Git are, and selection never derives "
73
87
  + "from it.",
74
88
  // The status describes a file Keel wrote. Whether anything reads that
75
89
  // file is a target-side fact: enforcement runs as a runtime hook the
76
90
  // host loads, and a host that loaded different plugins keeps them for
77
91
  // the life of its session. Reporting `started` as though it were a probe
78
92
  // result is the same inference `--doctor` already refuses to make.
79
- "This status describes the manifest only. Enforcement runs as a runtime "
80
- + "hook in the host, which Keel cannot observe from the repository, so "
81
- + "a written manifest is not evidence that any write was checked.",
93
+ "This describes the manifest only. Enforcement runs as a runtime hook "
94
+ + "Keel cannot observe, so a written manifest proves no write was "
95
+ + "checked.",
82
96
  ],
83
97
  ...extra,
84
98
  };
@@ -161,13 +175,25 @@ function readManifest(repo) {
161
175
  // written by a Keel that omits it, are both valid; what they are not is
162
176
  // evidence that nothing was dirty. The consumer distinguishes absent from
163
177
  // empty, so an empty list means "nothing was dirty" and an absent one means
164
- // "nobody looked".
178
+ // "nobody looked". Each entry carries the path's content signature, not
179
+ // just its name, so completion can tell "still the content recorded at
180
+ // task start" from "dirty again for a different reason" — `sha256` is
181
+ // `null` for a path that had nothing to read.
165
182
  if (
166
183
  manifest.startedDirty !== undefined
167
184
  && (!Array.isArray(manifest.startedDirty)
168
- || manifest.startedDirty.some((item) => typeof item !== "string"))
185
+ || manifest.startedDirty.some(
186
+ (item) =>
187
+ !item
188
+ || typeof item.path !== "string"
189
+ || !item.path
190
+ || (item.sha256 !== null
191
+ && !/^[0-9a-f]{64}$/.test(String(item.sha256 || "")))
192
+ ))
169
193
  ) {
170
- shapeErrors.push("startedDirty must be a string list when present");
194
+ shapeErrors.push(
195
+ "startedDirty must be a list of hashed dirty paths when present"
196
+ );
171
197
  }
172
198
  if (shapeErrors.length > 0) {
173
199
  return {
@@ -231,8 +257,14 @@ function startGuard(repo, options) {
231
257
 
232
258
  const paths = authorityPaths(repo, options.change, loaded.contract);
233
259
  // Read before the manifest is written, so the manifest is never in its own
234
- // record and cannot be attributed to the task it authorizes.
235
- const startedDirty = gitPaths(repo);
260
+ // record and cannot be attributed to the task it authorizes. Each dirty
261
+ // path is hashed at this same moment, so a later comparison can tell
262
+ // whether the task changed it again rather than only whether it stayed
263
+ // dirty.
264
+ const startedDirty = gitPaths(repo).map((relative) => ({
265
+ path: relative,
266
+ sha256: contentSignature(repo, relative),
267
+ }));
236
268
  const manifest = {
237
269
  schema: MANIFEST_SCHEMA,
238
270
  change: options.change,
@@ -265,12 +297,40 @@ function guardStatus(repo) {
265
297
  const problems = [];
266
298
  const loaded = loadTaskContract(repo, manifest.change, manifest.task);
267
299
  if (!loaded) {
268
- problems.push({
269
- code: "authority-drift",
270
- message:
271
- `Guarded task ${manifest.change}#${manifest.task} no longer resolves; `
272
- + "reauthorize through `keel gate task-start` and `keel guard start`.",
273
- });
300
+ // `loadTaskContract` returns null for two unrelated reasons — the tasks
301
+ // file is not there, or the task id is not in it — and only one of them
302
+ // has a reauthorization to perform. Telling the reader to reauthorize a
303
+ // change that has been archived sends them to `keel gate task-start`,
304
+ // which reports a missing tasks file, and to `keel guard start`, which
305
+ // reports that the task does not exist; neither names `keel guard clear`,
306
+ // which is the only action that resolves it.
307
+ //
308
+ // The change *directory* is the test, not the tasks file, and it is the
309
+ // same object `plugins/keel/scripts/pretooluse-guard.js` tests for the
310
+ // same question. Two surfaces deciding it by different means would
311
+ // eventually disagree about a state a reader is looking at from both. It
312
+ // also leaves a live change whose tasks.md is absent — mid-authoring — on
313
+ // the reauthorize path, where reauthorizing genuinely is the way out.
314
+ const changeDir = path.join(repo, "openspec", "changes", manifest.change);
315
+ problems.push(
316
+ fs.existsSync(changeDir)
317
+ ? {
318
+ code: "authority-drift",
319
+ message:
320
+ `Guarded task ${manifest.change}#${manifest.task} no longer `
321
+ + "resolves; reauthorize through `keel gate task-start` and "
322
+ + "`keel guard start`.",
323
+ }
324
+ : {
325
+ code: "stale-manifest",
326
+ message:
327
+ `This manifest is stale: it guards ${manifest.change}`
328
+ + `#${manifest.task}, but openspec/changes/${manifest.change} no `
329
+ + "longer exists, so the task it names cannot be reauthorized and "
330
+ + "its Touch list authorizes nothing. Run `keel guard clear`, then "
331
+ + "start the task you are actually working on.",
332
+ }
333
+ );
274
334
  const drifted = guardResult("status", "drifted", { manifest });
275
335
  drifted.problems = problems;
276
336
  return drifted;
@@ -354,6 +414,7 @@ module.exports = {
354
414
  GuardInputError,
355
415
  MANIFEST_SCHEMA,
356
416
  clearGuard,
417
+ contentSignature,
357
418
  gitPaths,
358
419
  guardStatus,
359
420
  readManifest,
@@ -518,6 +518,67 @@ function collisionHint(repo, change, capability) {
518
518
  );
519
519
  }
520
520
 
521
+ // One phrasing, so an author who has read the over-segmented refusal recognizes
522
+ // the unresolved one. It used to be reachable only by writing too many
523
+ // segments, which withheld it from the reference people actually write wrong.
524
+ const COVERS_HIERARCHY =
525
+ "the hierarchy is capability / requirement, or capability / requirement "
526
+ + "/ scenario";
527
+
528
+ // Say which segment failed. The candidate specs were opened by the caller and
529
+ // the name the author typed is very often a heading one level below where they
530
+ // put it — the shipped task template taught exactly that reference — so the
531
+ // refusal can name the requirement it belongs to instead of handing the
532
+ // reference back. Reporting what a spec contains is not heuristic matching: the
533
+ // reference still fails, and no near miss is resolved on the author's behalf.
534
+ function unresolvedDetail(repo, change, capability, name) {
535
+ const candidates = specCandidatePaths(repo, change, capability);
536
+ const existing = candidates.filter((specPath) => fs.existsSync(specPath));
537
+ if (existing.length === 0) {
538
+ const looked = candidates
539
+ .map((specPath) => path.relative(repo, specPath).replace(/\\/g, "/"))
540
+ .join(" and ");
541
+ return ` No spec declares capability ${capability}; ${looked} do not exist.`;
542
+ }
543
+ const parents = [];
544
+ for (const specPath of existing) {
545
+ const content = fs.readFileSync(specPath, "utf8");
546
+ for (const requirement of headingSections(
547
+ content,
548
+ /^### Requirement:\s*(.+?)\s*$/
549
+ )) {
550
+ const holdsName = headingSections(
551
+ requirement.content,
552
+ /^#### Scenario:\s*(.+?)\s*$/
553
+ ).some((item) => item.title === name);
554
+ if (holdsName && !parents.includes(requirement.title)) {
555
+ parents.push(requirement.title);
556
+ }
557
+ }
558
+ }
559
+ if (parents.length === 1) {
560
+ return (
561
+ ` "${name}" is a Scenario of Requirement "${parents[0]}", not a `
562
+ + `Requirement; ${COVERS_HIERARCHY}. Write it as: `
563
+ + `${capability} / ${parents[0]} / ${name}.`
564
+ );
565
+ }
566
+ if (parents.length > 1) {
567
+ // No single reference corrects this one, so none is offered: sending the
568
+ // author to a reference that fails as ambiguous would cost them the round
569
+ // this diagnostic exists to save.
570
+ const named = parents.map((title) => `"${title}"`).join(", ");
571
+ return (
572
+ ` "${name}" is a Scenario of more than one Requirement — ${named} — so `
573
+ + `no single reference corrects it; ${COVERS_HIERARCHY}.`
574
+ );
575
+ }
576
+ return (
577
+ ` Capability ${capability} declares no Requirement or Scenario named `
578
+ + `"${name}"; ${COVERS_HIERARCHY}.`
579
+ );
580
+ }
581
+
521
582
  function specAuthority(repo, change, reference) {
522
583
  const parts = reference.split("/").map((part) => part.trim());
523
584
  const [capability, requirementName, scenarioName] = parts;
@@ -534,9 +595,8 @@ function specAuthority(repo, change, reference) {
534
595
  diagnostic: {
535
596
  code: "unresolved-covers",
536
597
  message:
537
- `Covers reference has ${parts.length} segments; the hierarchy is `
538
- + "capability / requirement, or capability / requirement / "
539
- + `scenario: ${reference}.`
598
+ `Covers reference has ${parts.length} segments; `
599
+ + `${COVERS_HIERARCHY}: ${reference}.`
540
600
  + collisionHint(repo, change, capability),
541
601
  },
542
602
  };
@@ -603,6 +663,7 @@ function specAuthority(repo, change, reference) {
603
663
  code: "unresolved-covers",
604
664
  message:
605
665
  `Covers reference could not be resolved: ${reference}.`
666
+ + unresolvedDetail(repo, change, capability, requirementName)
606
667
  + collisionHint(repo, change, capability),
607
668
  },
608
669
  };
@@ -626,12 +687,41 @@ function criticalAuthority(repo, change, reference) {
626
687
  };
627
688
  }
628
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).
629
693
  const matches = [
630
694
  ...content.matchAll(
631
- new RegExp(`^\\s*${reference}\\s*[—-]\\s*(.+?)\\s*$`, "gmi")
695
+ new RegExp(
696
+ `^\\s*(?:[-*+]\\s+)?(?:\\*\\*${reference}\\*\\*|${reference})`
697
+ + `\\s*[—-]\\s*(.+?)\\s*$`,
698
+ "gmi"
699
+ )
632
700
  ),
633
701
  ];
634
702
  if (matches.length !== 1) {
703
+ // Zero matches is ambiguous: the identifier may never appear in design.md,
704
+ // or it may appear in some other shape (bulleted, bold) that the strict
705
+ // regex above does not accept. A whole-word scan tells those apart so the
706
+ // message sends the author to the actual defect — a shape fix, not a
707
+ // statement that already exists — instead of collapsing both into "Missing".
708
+ if (
709
+ matches.length === 0
710
+ && new RegExp(`\\b${reference}\\b`).test(content)
711
+ ) {
712
+ return {
713
+ diagnostic: {
714
+ code: "unresolved-covers",
715
+ message:
716
+ `Unparsed Covers critical statement: ${reference}. It appears in `
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}** — …\`).`,
722
+ },
723
+ };
724
+ }
635
725
  return {
636
726
  diagnostic: {
637
727
  code: matches.length > 1 ? "ambiguous-covers" : "unresolved-covers",
@@ -677,7 +767,17 @@ function resolveAuthority(repo, change, task) {
677
767
  }
678
768
  const entries = [...seen].sort();
679
769
  for (const entry of entries) {
680
- 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
+ );
681
781
  if (critical) {
682
782
  if (critical.diagnostic) diagnostics.push(critical.diagnostic);
683
783
  if (critical.authority) authority.push(critical.authority);
@@ -1049,4 +1149,5 @@ module.exports = {
1049
1149
  loadTaskContract,
1050
1150
  parseTasks,
1051
1151
  taskStartContractProblems,
1152
+ unfilledToken,
1052
1153
  };