@christang/keel 5.62.0 → 5.64.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.
@@ -280,6 +280,65 @@ function readStandingAuthorization(repo) {
280
280
  return { declared, scopes, unknown, message: null };
281
281
  }
282
282
 
283
+ // `full_mode_paths:` — the paths whose change always routes Full, whatever the
284
+ // diff size says. One direction only: there is no key that holds a path *out* of
285
+ // the complete flow however large its change, because every other declaration in
286
+ // this file removes a confirmation and never a gate, and a routing entry that
287
+ // skipped the flow would be the first to break that.
288
+ //
289
+ // Each entry carries its reason. A bare path declares that a file is special and
290
+ // leaves a reader unable to recognise the sibling the list does not name; the
291
+ // reason is the part that transfers, so an entry without one is reported rather
292
+ // than read.
293
+ //
294
+ // Neither existing reader can hold the form. `configList` takes one token per
295
+ // item and a sentence is not one; `configMap` keys on `\w+`, which
296
+ // `results/experiments.jsonl` is not, and values on a single token. So this gets
297
+ // its own reader, confined to this key rather than loosening a pattern the other
298
+ // declarations rely on being exact.
299
+ const FULL_MODE_PATH_ENTRY = /^\s+-\s*(\S+?)\s*:\s*(\S.*?)\s*$/;
300
+
301
+ function readFullModePaths(repo) {
302
+ const configPath = path.join(repo, "keel", "config.yaml");
303
+ const paths = [];
304
+ const unreadable = [];
305
+ if (!fs.existsSync(configPath)) return { paths, unreadable };
306
+ const opener = /^full_mode_paths\s*:\s*$/;
307
+ let inBlock = false;
308
+ for (const line of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) {
309
+ if (/^\s*#/.test(line)) continue;
310
+ if (opener.test(line)) {
311
+ inBlock = true;
312
+ continue;
313
+ }
314
+ if (!inBlock) continue;
315
+ if (line.trim() === "") continue;
316
+ const item = line.match(/^\s+-\s*(.*?)\s*$/);
317
+ // Anything that is not a list item closes the block, exactly as it does for
318
+ // the other two readers.
319
+ if (!item) break;
320
+ const entry = line.match(FULL_MODE_PATH_ENTRY);
321
+ if (!entry) {
322
+ unreadable.push(item[1]);
323
+ continue;
324
+ }
325
+ // The path's shape is read and its existence is not. An entry may name a
326
+ // file that does not exist yet, which is much of the point: the schema
327
+ // change that has not happened is the one worth routing Full.
328
+ paths.push({ path: entry[1], reason: entry[2] });
329
+ }
330
+ return { paths, unreadable };
331
+ }
332
+
333
+ function fullModePathsUnreadableMessage(unreadable) {
334
+ return `keel/config.yaml declares a full_mode_paths ${
335
+ unreadable.length === 1 ? "entry" : "entries"
336
+ } Keel could not read: ${unreadable.join(", ")}. Write each entry as `
337
+ + "`- <path>: <reason>`. The reason is required: a bare path says a file is "
338
+ + "special without saying what makes it so, and the agent reading it cannot "
339
+ + "then recognise the sibling the list does not name.";
340
+ }
341
+
283
342
  // A nested block of `name: value` entries under one top-level key. Delegation
284
343
  // needs a key with a value rather than a bare list, so it cannot reuse
285
344
  // configList; the reader stays line-oriented for the same reason the others do.
@@ -476,6 +535,8 @@ module.exports = {
476
535
  DELEGATION_TIERS,
477
536
  STANDING_AUTHORIZATION_ACTIONS,
478
537
  SCOPED_AUTHORIZATION_ACTIONS,
538
+ readFullModePaths,
539
+ fullModePathsUnreadableMessage,
479
540
  readDelegationPolicy,
480
541
  readPrecedentStore,
481
542
  readStandingAuthorization,
@@ -11,7 +11,11 @@ const {
11
11
  field,
12
12
  parseTasks,
13
13
  } = require("./task-contract");
14
- const { readStandingAuthorization } = require("./config");
14
+ const {
15
+ readStandingAuthorization,
16
+ readFullModePaths,
17
+ fullModePathsUnreadableMessage,
18
+ } = require("./config");
15
19
 
16
20
  const NEXT_ACTIONS = new Set([
17
21
  "discuss",
@@ -671,6 +675,27 @@ function resolveContext(repo, options) {
671
675
  if (authorization.unknown.length > 0) {
672
676
  context.warnings.push(authorization.message);
673
677
  }
678
+ // Routing is the first decision of a session and the only durable rule with
679
+ // no gate behind it, so a project's declared exceptions are reported here —
680
+ // the one surface the protocol already requires an agent to read before
681
+ // deciding anything. Reported only when a declaration exists: a line printed
682
+ // every session for the repositories that declared nothing is a line a reader
683
+ // learns to skip (#131).
684
+ const routing = readFullModePaths(repo);
685
+ if (routing.unreadable.length > 0) {
686
+ // The other declarations in this file fail closed, and closed for them
687
+ // means *less proceeds without a human* — `authorize:` authorizes nothing,
688
+ // `triage:` admits nothing. The shared principle is to fail toward more
689
+ // scrutiny, and for a declaration whose whole purpose is to add process,
690
+ // more scrutiny is more Full mode. Reporting the entries it could read
691
+ // would let a typo silently lower the floor, which is the outcome the
692
+ // one-directional design exists to prevent.
693
+ context.routing = [];
694
+ context.routingUnreadable = true;
695
+ context.warnings.push(fullModePathsUnreadableMessage(routing.unreadable));
696
+ } else {
697
+ context.routing = routing.paths;
698
+ }
674
699
  // Set here rather than by the caller, so every consumer of the projection —
675
700
  // text, JSON, and any host reading it — carries the version without having
676
701
  // to know to add it.
@@ -714,6 +739,15 @@ function renderContext(result) {
714
739
  + ` (${result.selection.source})`
715
740
  );
716
741
  }
742
+ if (result.routingUnreadable) {
743
+ lines.push(
744
+ "Routing: every change routes Full until keel/config.yaml's "
745
+ + "full_mode_paths is corrected"
746
+ );
747
+ }
748
+ for (const entry of result.routing || []) {
749
+ lines.push(`Routing: ${entry.path} always routes Full — ${entry.reason}`);
750
+ }
717
751
  for (const reason of result.reasons) lines.push(`Reason: ${reason}`);
718
752
  for (const warning of result.warnings) lines.push(`Warning: ${warning}`);
719
753
  return `${lines.join("\n")}\n`;
package/src/core/gates.js CHANGED
@@ -1022,6 +1022,68 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
1022
1022
  );
1023
1023
  }
1024
1024
  }
1025
+ // A declared measurement is held against the check's own bare `M<n>` Evidence
1026
+ // — the entry where the command and its output are recorded. A literal that
1027
+ // output does not contain is either unpasted or not measured, and free prose
1028
+ // gives a reader no way to tell those from a real reading (issue #132).
1029
+ for (const entry of contract ? contract.capsule.verification.commands : []) {
1030
+ if (!entry.measured || !entry.label) continue;
1031
+ const recorded = evidenceValue(task, entry.label);
1032
+ if (!isConcrete(recorded)) continue;
1033
+ if (!String(recorded).includes(entry.measured)) {
1034
+ problems.push(
1035
+ problem(
1036
+ "measurement-missing-from-evidence",
1037
+ `${entry.label} declares the measurement \`${entry.measured}\`, and `
1038
+ + `its recorded ${entry.label} Evidence does not contain that `
1039
+ + "string. Paste the output the number came from, or correct the "
1040
+ + "declaration — which moves the contract fingerprint, because a "
1041
+ + "number reconciled to the output after the run is a transcription "
1042
+ + "of it.",
1043
+ )
1044
+ );
1045
+ }
1046
+ }
1047
+ // A declared injection is enforced whatever the strategy and whatever the
1048
+ // tags. It is not a red-green artifact: `.red` proves the check failed before
1049
+ // the implementation existed and a failure signature predicts the red of an
1050
+ // *absent* feature, while an injection answers what neither can — whether the
1051
+ // check still fails once the feature exists and is broken. A `(regression)`
1052
+ // check is where it matters most, because it has no honest red at all, so
1053
+ // exempting it here would remove the clause from its best use (issue #132).
1054
+ //
1055
+ // Keel does not run the mutation. What it holds is that the failure the author
1056
+ // declared before the run appears in what they recorded after it.
1057
+ for (const entry of contract ? contract.capsule.verification.commands : []) {
1058
+ if (!entry.detects || !entry.label) continue;
1059
+ const recorded = evidenceValue(task, `${entry.label}.detects`);
1060
+ if (!isConcrete(recorded)) {
1061
+ problems.push(
1062
+ problem(
1063
+ "missing-injection-evidence",
1064
+ `${entry.label} declares that \`${entry.detects.mutation}\` must make `
1065
+ + `it fail with \`${entry.detects.failure}\`, and records no `
1066
+ + `${entry.label}.detects Evidence. Run the mutation, record what it `
1067
+ + "printed, and revert it — a green check that has never been made "
1068
+ + "to fail on the defect it names is not evidence that it would.",
1069
+ )
1070
+ );
1071
+ continue;
1072
+ }
1073
+ if (!String(recorded).includes(entry.detects.failure)) {
1074
+ problems.push(
1075
+ problem(
1076
+ "injection-missing-declared-failure",
1077
+ `${entry.label} declares that its injection fails with `
1078
+ + `\`${entry.detects.failure}\`, and the recorded `
1079
+ + `${entry.label}.detects Evidence does not contain that string. `
1080
+ + "Record what the mutation actually printed, or correct the "
1081
+ + "declaration — which moves the contract fingerprint, because an "
1082
+ + "injection edited after the run is a transcription of it.",
1083
+ )
1084
+ );
1085
+ }
1086
+ }
1025
1087
  const strategy = contract
1026
1088
  ? contract.capsule.verification.strategy.toLowerCase()
1027
1089
  : "";
@@ -153,28 +153,88 @@ const RED_GREEN_VERIFICATION_STRATEGIES = new Set([
153
153
  // Tags an M<n> check may carry after its label, as a comma-separated set.
154
154
  const COMMAND_TAGS = new Set(["fast", "full", "regression"]);
155
155
 
156
- // A check may end by declaring the failure its red is expected to show, so that
157
- // what the red proves is written down before the red is run. The clause closes
158
- // the check: `Fails with:` followed by one inline-code literal and nothing more.
159
- // End-anchored on purpose — a check that describes this rule mentions the marker
160
- // mid-sentence, and a mention is not a declaration.
161
- const FAILURE_SIGNATURE = /\bFails with:[ \t]*`([^`\n]+)`[ \t]*$/i;
162
- const FAILURE_MARKER = /\bFails with:/i;
156
+ // A check may end by declaring things about itself, so that each is written down
157
+ // before the run it describes. Every clause has the same shape: it closes the
158
+ // clause sequence with inline-code literals, it lives inside the check text and
159
+ // therefore inside the contract fingerprint, and it is enforced by requiring its
160
+ // literal in a named Evidence entry. Keel judges none of them.
161
+ //
162
+ // The clauses chain. A check is one line — `fieldValues` splits the field per
163
+ // line and treats each as its own entry — so a single end-anchored slot would
164
+ // make the clauses mutually exclusive, and the case that motivated `Detects:`
165
+ // declares an injection beside a failure signature on one check (issue #132).
166
+ //
167
+ // Each is still anchored at the end of what remains, on purpose: a check that
168
+ // describes this rule mentions a marker mid-sentence, and a mention is not a
169
+ // declaration.
170
+ //
171
+ // - `Fails with:` — the failure the check's red must show. Predicts the red of an
172
+ // *absent* feature.
173
+ // - `Detects:` — a mutation that puts a defect in, and the failure it must
174
+ // produce. Answers the question a red cannot: the red of a *broken* feature.
175
+ // The two can be entirely unrelated, which is the whole reason this exists.
176
+ const DECLARATION_CLAUSES = [
177
+ {
178
+ name: "failure",
179
+ pattern: /\bFails with:[ \t]*`([^`\n]+)`[ \t]*$/i,
180
+ marker: /\bFails with:/i,
181
+ build: (match) => match[1].trim(),
182
+ },
183
+ {
184
+ name: "detects",
185
+ pattern: /\bDetects:[ \t]*`([^`\n]+)`[ \t]*->[ \t]*`([^`\n]+)`[ \t]*$/i,
186
+ marker: /\bDetects:/i,
187
+ build: (match) => ({
188
+ mutation: match[1].trim(),
189
+ failure: match[2].trim(),
190
+ }),
191
+ },
192
+ {
193
+ // `Measured:` — a literal the check's own recorded output must contain. The
194
+ // failure class is a number that reads like a measurement and is an estimate
195
+ // or a recollection; free prose cannot tell a reader which it is. Opt-in on
196
+ // purpose: a universal rule over every number in Evidence would reach 847
197
+ // inline-code spans in this repository's own archive, most of them version
198
+ // strings, counts the author computed, and quoted references that appear in
199
+ // no command output, and each would be a false stop.
200
+ name: "measured",
201
+ pattern: /\bMeasured:[ \t]*`([^`\n]+)`[ \t]*$/i,
202
+ marker: /\bMeasured:/i,
203
+ build: (match) => match[1].trim(),
204
+ },
205
+ ];
163
206
 
164
- // Classify a check's `Fails with:` marker. `signature` is the declared literal;
165
- // `malformed` marks a marker that is present and is not a closing clause — a
166
- // typo shape, and ignoring it would leave the author believing a signature is
167
- // enforced when none was parsed. A marker written inside inline code is quoted
168
- // material rather than a declaration, the meaning inline code already carries
169
- // here, which is what lets this file's own tasks name the marker.
170
- function failureSignature(check) {
171
- const text = String(check || "");
172
- const match = text.match(FAILURE_SIGNATURE);
173
- if (match) return { signature: match[1].trim(), malformed: false };
207
+ // Strip one matching trailing clause at a time until none matches, then report a
208
+ // marker surviving in the remaining prose as malformed. Malformed rather than
209
+ // ignored: a declaration that parsed as nothing reads to its author as a check
210
+ // being enforced. A marker inside inline code is quoted material, the meaning
211
+ // inline code already carries here, which is what lets this file's own tasks
212
+ // name the markers.
213
+ function declarationClauses(check) {
214
+ let text = String(check || "");
215
+ const declared = {};
216
+ for (let matched = true; matched; ) {
217
+ matched = false;
218
+ for (const clause of DECLARATION_CLAUSES) {
219
+ const match = text.match(clause.pattern);
220
+ if (!match) continue;
221
+ if (!(clause.name in declared)) declared[clause.name] = clause.build(match);
222
+ text = text.slice(0, match.index).replace(/[ \t]+$/, "");
223
+ matched = true;
224
+ break;
225
+ }
226
+ }
174
227
  const remainder = withoutInlineCode(text);
228
+ const malformed = {};
229
+ for (const clause of DECLARATION_CLAUSES) {
230
+ malformed[clause.name] =
231
+ !(clause.name in declared) && clause.marker.test(remainder);
232
+ }
175
233
  return {
176
- signature: null,
177
- malformed: FAILURE_MARKER.test(remainder),
234
+ signature: declared.failure == null ? null : declared.failure,
235
+ detects: declared.detects == null ? null : declared.detects,
236
+ measured: declared.measured == null ? null : declared.measured,
237
+ malformed,
178
238
  };
179
239
  }
180
240
 
@@ -226,6 +286,10 @@ function verification(task) {
226
286
  check: entry,
227
287
  failsWith: null,
228
288
  malformedSignature: false,
289
+ detects: null,
290
+ malformedInjection: false,
291
+ measured: null,
292
+ malformedMeasurement: false,
229
293
  };
230
294
  }
231
295
  const tags = (match[2] || "")
@@ -240,17 +304,25 @@ function verification(task) {
240
304
  check: entry,
241
305
  failsWith: null,
242
306
  malformedSignature: false,
307
+ detects: null,
308
+ malformedInjection: false,
309
+ measured: null,
310
+ malformedMeasurement: false,
243
311
  };
244
312
  }
245
313
  const check = normalizeText(match[3]);
246
- const failure = failureSignature(check);
314
+ const clauses = declarationClauses(check);
247
315
  return {
248
316
  label: match[1],
249
317
  layer: tags.includes("fast") ? "fast" : "full",
250
318
  regression: tags.includes("regression"),
251
319
  check,
252
- failsWith: failure.signature,
253
- malformedSignature: failure.malformed,
320
+ failsWith: clauses.signature,
321
+ malformedSignature: clauses.malformed.failure,
322
+ detects: clauses.detects,
323
+ malformedInjection: clauses.malformed.detects,
324
+ measured: clauses.measured,
325
+ malformedMeasurement: clauses.malformed.measured,
254
326
  };
255
327
  });
256
328
  return {
@@ -443,6 +515,29 @@ function failureSignatureProblems(task) {
443
515
  });
444
516
  continue;
445
517
  }
518
+ if (entry.malformedMeasurement) {
519
+ problems.push({
520
+ code: "malformed-measurement",
521
+ message:
522
+ `${entry.label} carries a \`Measured:\` marker that does not close `
523
+ + "the check with a literal. Write it as `Measured: `<literal>`` at "
524
+ + "the end of the check, or fence the marker in inline code when the "
525
+ + "check is describing it rather than declaring one.",
526
+ });
527
+ continue;
528
+ }
529
+ if (entry.malformedInjection) {
530
+ problems.push({
531
+ code: "malformed-injection",
532
+ message:
533
+ `${entry.label} carries a \`Detects:\` marker that does not close `
534
+ + "the check with a mutation and the failure it must produce. Write "
535
+ + "it as `Detects: `<mutation>` -> `<failure>`` at the end of the "
536
+ + "check, or fence the marker in inline code when the check is "
537
+ + "describing it rather than declaring one.",
538
+ });
539
+ continue;
540
+ }
446
541
  if (!entry.failsWith) continue;
447
542
  if (!redGreen) {
448
543
  problems.push({
@@ -1229,6 +1324,8 @@ function compileTaskContract(repo, change, task) {
1229
1324
  // field is what the gate reads. Emitted only when declared, so every
1230
1325
  // check without one keeps the capsule shape and fingerprint it had.
1231
1326
  if (entry.failsWith) emitted.failsWith = entry.failsWith;
1327
+ if (entry.detects) emitted.detects = entry.detects;
1328
+ if (entry.measured) emitted.measured = entry.measured;
1232
1329
  return emitted;
1233
1330
  }),
1234
1331
  },