@christang/keel 5.3.6 → 5.3.8

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
@@ -103,13 +103,56 @@ function loadSelection(repo, options, requireTask = true) {
103
103
  return { change, tasksPath, content, tasks, selected: [selected] };
104
104
  }
105
105
 
106
+ // The documented order is gate-then-checkbox, so first-unchecked is the right
107
+ // inference for task-start and stays. The hazard is narrower: completing without
108
+ // an explicit task infers a task that has not started, then reports its
109
+ // readiness problems under a selection heading the author reads as the failure
110
+ // of the task they just finished. A task that has started records a fingerprint
111
+ // in its Evidence `Contract` anchor, so that anchor is what makes the inference
112
+ // safe — and without one there is nothing for completion to compare against.
113
+ function hasRecordedAnchor(selection, task) {
114
+ const plan = contractAnchorPlan(selection, task);
115
+ return Boolean(plan && anchoredFingerprint(plan.previous));
116
+ }
117
+
118
+ // A task that recorded no anchor has no drift detection at all while presenting
119
+ // as fully gated: completion skipped the comparison rather than reporting that
120
+ // it had nothing to compare. Recording is already the documented step; this is
121
+ // what makes the guarantee unconditional instead of aspirational.
122
+ function missingAnchorProblem(selection, task) {
123
+ if (hasRecordedAnchor(selection, task)) return null;
124
+ return problem(
125
+ "missing-contract-anchor",
126
+ `${selection.change}#${task.id} records no compiled fingerprint in its `
127
+ + "Evidence `Contract` anchor, so completion has nothing to compare and "
128
+ + "the task has no drift detection. Run `keel gate task-start --record` "
129
+ + "for this task, which rewrites the anchor in place, then complete it."
130
+ );
131
+ }
132
+
133
+ function unstartedInferenceProblem(selection) {
134
+ const task = selection.selected[0];
135
+ if (hasRecordedAnchor(selection, task)) return null;
136
+ const checked = [...selection.tasks].filter((item) => item.checked).pop();
137
+ return problem(
138
+ "ambiguous-completion-selection",
139
+ `task-complete inferred ${selection.change}#${task.id}, the first unchecked `
140
+ + "task, but that task records no start fingerprint in its Evidence "
141
+ + "`Contract` anchor, so it has not started and there is nothing to "
142
+ + `compare. ${
143
+ checked
144
+ ? `The most recently checked task is ${checked.id}. `
145
+ : ""
146
+ }Name the task you mean with \`--task\`, or run \`task-start --record\` `
147
+ + "first."
148
+ );
149
+ }
150
+
106
151
  function contractAnchorPlan(selection, task) {
107
152
  const lines = selection.content.split("\n");
108
- const index = selection.tasks.findIndex((item) => item.id === task.id);
109
- const end =
110
- index + 1 < selection.tasks.length
111
- ? selection.tasks[index + 1].line
112
- : lines.length;
153
+ // The task carries its own extent, so the anchor search cannot reach a
154
+ // `- Contract:` line sitting in a trailing change-level section.
155
+ const end = task.endLine !== undefined ? task.endLine : lines.length;
113
156
  for (let cursor = task.line; cursor < end; cursor += 1) {
114
157
  const match = lines[cursor].match(/^(\s*)-\s*Contract:\s*(.*?)(\r?)$/);
115
158
  if (match) {
@@ -559,10 +602,30 @@ function completionChecks(repo, task, contract = null) {
559
602
  function taskComplete(repo, options) {
560
603
  const selection = loadSelection(repo, options);
561
604
  const task = selection.selected[0];
605
+ // Selection ambiguity short-circuits, because the gate does not know which
606
+ // task the caller meant and evaluating the wrong one is the defect. A named
607
+ // task is not ambiguous: its missing anchor is one problem among however many
608
+ // else it has, so it joins the list rather than hiding the rest.
609
+ if (!options.task) {
610
+ const ambiguous = unstartedInferenceProblem(selection);
611
+ if (ambiguous) {
612
+ return gateResult(
613
+ "task-complete",
614
+ "fail",
615
+ selection.change,
616
+ [task.id],
617
+ [ambiguous],
618
+ [],
619
+ null
620
+ );
621
+ }
622
+ }
562
623
  const contract = compileTaskContract(repo, selection.change, task);
563
624
  const usableContract = contract.diagnostics.length === 0 ? contract : null;
564
625
  const checks = completionChecks(repo, task, usableContract);
565
626
  checks.problems.push(...contract.diagnostics);
627
+ const missingAnchor = missingAnchorProblem(selection, task);
628
+ if (missingAnchor) checks.problems.push(missingAnchor);
566
629
  const scope = scopeEvidence(
567
630
  repo,
568
631
  task,
@@ -68,7 +68,21 @@ function parseTasks(content) {
68
68
  });
69
69
  }
70
70
  for (let index = 0; index < tasks.length; index += 1) {
71
- const end = index + 1 < tasks.length ? tasks[index + 1].line : lines.length;
71
+ // A task body ends at the next task or the next `##` heading, whichever
72
+ // comes first. Without the heading bound a change-level section such as
73
+ // `## Invalidates` was appended to whichever field was open last — the
74
+ // Evidence, in every shipped template — so a token quoted there made the
75
+ // Evidence non-concrete and the gate blamed a task that was fine.
76
+ const nextTask =
77
+ index + 1 < tasks.length ? tasks[index + 1].line : lines.length;
78
+ let end = nextTask;
79
+ for (let cursor = tasks[index].line + 1; cursor < nextTask; cursor += 1) {
80
+ if (/^\s*##\s/.test(lines[cursor])) {
81
+ end = cursor;
82
+ break;
83
+ }
84
+ }
85
+ tasks[index].endLine = end;
72
86
  const bodyLines = lines.slice(tasks[index].line, end);
73
87
  tasks[index].body = bodyLines.join("\n");
74
88
  tasks[index].fields = new Map();
@@ -192,6 +206,16 @@ function verification(task) {
192
206
  }
193
207
 
194
208
  function commandLabelProblems(task) {
209
+ // A task that declared no verification form at all is reported once, by
210
+ // requiredFieldProblems, as the one field it is missing. Its orphan Evidence
211
+ // labels are a consequence of that same absence, and restating them here is
212
+ // the cascade that buries the actionable line.
213
+ if (
214
+ fieldValues(task, "Verify").length === 0
215
+ && fieldValues(task, "Commands").length === 0
216
+ ) {
217
+ return [];
218
+ }
195
219
  const problems = [];
196
220
  const seen = new Set();
197
221
  const labels = [];
@@ -222,9 +246,18 @@ function commandLabelProblems(task) {
222
246
  seen.add(command[1]);
223
247
  labels.push(command[1]);
224
248
  if (!isConcrete(command[2])) {
249
+ // Name the matched slot, the way the Verify diagnostic already does. The
250
+ // unqualified wording described the consequence, so an author with
251
+ // several slots in one check had to guess which one was read.
252
+ const token = unfilledToken(command[2]);
225
253
  problems.push({
226
254
  code: "missing-command-check",
227
- message: `${command[1]} must define a concrete public check.`,
255
+ message: token
256
+ ? `${command[1]} carries the unfilled slot \`${token}\`, so it does `
257
+ + "not define a concrete public check. Replace that slot with the "
258
+ + "value the check actually runs against, or fence it in inline "
259
+ + "code when it is a documented pattern rather than a slot."
260
+ : `${command[1]} must define a concrete public check.`,
228
261
  });
229
262
  }
230
263
  }
@@ -343,38 +376,44 @@ function requiredFieldProblems(task) {
343
376
  },
344
377
  ];
345
378
  }
346
- }
347
- const required = compact
348
- ? ["Covers", "Verify", "Evidence"]
349
- : [
350
- "Owner",
351
- "Mode",
352
- "Covers",
353
- "Read",
354
- "Commands",
355
- "Acceptance",
356
- "Candidate Boundary",
357
- "Stop Rules",
358
- "Evidence",
359
- "Report",
379
+ // Neither verification form declared. That is a compact v4 task missing one
380
+ // field, not an expanded v3 task missing nine — and listing the v3 set here
381
+ // reported a schema this author never chose.
382
+ if (!isConcrete(field(task, "Commands"))) {
383
+ return [
384
+ {
385
+ code: "missing-verification-form",
386
+ message:
387
+ "The task declares no verification form. Add a `Verify` field with "
388
+ + "a `Strategy:` entry and one `M<n>:` check per behavior the task "
389
+ + "proves. The expanded v3 `Commands` field is the other accepted "
390
+ + "form; the remaining v3 fields are not required.",
391
+ },
392
+ // The rest of the compact set is still reported, so a near-empty task
393
+ // learns everything it is missing. Only the v3 cascade is replaced.
394
+ ...missingFieldProblems(task, ["Covers", "Evidence"]),
360
395
  ];
361
- const problems = required
396
+ }
397
+ }
398
+ // The expanded set is the compact set with `Commands` in place of `Verify`.
399
+ // Owner, Mode, Read, and Acceptance resolve to documented defaults or derive
400
+ // from Covers, Report is consumed nowhere, and Candidate Boundary and Stop
401
+ // Rules belong to couplingProblems, which requires them when the coupling
402
+ // contract does. Requiring them here reported fields that were already in
403
+ // effect.
404
+ return missingFieldProblems(
405
+ task,
406
+ compact ? ["Covers", "Verify", "Evidence"] : ["Covers", "Commands", "Evidence"]
407
+ );
408
+ }
409
+
410
+ function missingFieldProblems(task, names) {
411
+ return names
362
412
  .filter((name) => !isConcrete(field(task, name)))
363
413
  .map((name) => ({
364
414
  code: "missing-field",
365
415
  message: `${name} must be concrete.`,
366
416
  }));
367
- if (
368
- !compact
369
- && !isConcrete(field(task, "Autonomy boundary"))
370
- && !isConcrete(field(task, "Stop if"))
371
- ) {
372
- problems.push({
373
- code: "missing-boundary",
374
- message: "Stop if or Autonomy boundary must be concrete.",
375
- });
376
- }
377
- return problems;
378
417
  }
379
418
 
380
419
  function canonical(value) {
@@ -808,8 +847,18 @@ function compileTaskContract(repo, change, task) {
808
847
  if (!autonomy.some((item) => /^Pre-authorized fallback:/i.test(item))) {
809
848
  autonomy.push("Pre-authorized fallback: none");
810
849
  }
850
+ // A question is unresolved authority when it is the subject of its Covers
851
+ // entry. Scanning the whole field also matched a resolved question named as
852
+ // supporting detail beside the fact that closed it, and the only fix
853
+ // available to the author was deleting the reference — so the check punished
854
+ // the traceability it exists to protect.
811
855
  const questionIds = [
812
- ...new Set(field(task, "Covers").match(/\bQ\d+\b/g) || []),
856
+ ...new Set(
857
+ normalizedValues(task, "Covers", { ordered: true })
858
+ .map((entry) => entry.match(/^(Q\d+)\b/))
859
+ .filter(Boolean)
860
+ .map((match) => match[1])
861
+ ),
813
862
  ];
814
863
  const fallback = autonomy.find((item) =>
815
864
  /^Pre-authorized fallback:/i.test(item)