@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.
- package/README.md +63 -3
- package/assets/bootstrap/AGENTS.md +2 -1
- package/bin/keel.js +35 -0
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/scripts/validate_plugin.py +699 -16
- package/src/core/config.js +61 -0
- package/src/core/context.js +35 -1
- package/src/core/gates.js +62 -0
- package/src/core/task-contract.js +119 -22
package/src/core/config.js
CHANGED
|
@@ -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,
|
package/src/core/context.js
CHANGED
|
@@ -11,7 +11,11 @@ const {
|
|
|
11
11
|
field,
|
|
12
12
|
parseTasks,
|
|
13
13
|
} = require("./task-contract");
|
|
14
|
-
const {
|
|
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
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
// enforced
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
253
|
-
malformedSignature:
|
|
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
|
},
|