@christang/keel 5.66.0 → 5.68.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 -0
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +20 -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/plugins/keel/skills/keel-run-single-task-goal/SKILL.md +6 -10
- package/plugins/keel/skills/keel-run-single-task-goal/guidance.md +30 -0
- package/plugins/keel/skills/keel-tdd-or-test-first/SKILL.md +4 -1
- package/scripts/validate_plugin.py +824 -8
- package/src/core/config.js +54 -0
- package/src/core/context.js +16 -0
- package/src/core/gates.js +185 -1
- package/src/core/task-contract.js +122 -1
package/src/core/config.js
CHANGED
|
@@ -81,6 +81,7 @@ const CONFIG_DECLARATIONS = [
|
|
|
81
81
|
"triage",
|
|
82
82
|
"delegation",
|
|
83
83
|
"full_mode_paths",
|
|
84
|
+
"executor_tier",
|
|
84
85
|
];
|
|
85
86
|
|
|
86
87
|
const CONFIG_RELATIVE_PATH = path.join("keel", "config.yaml");
|
|
@@ -402,6 +403,55 @@ function readDelegationPolicy(repo) {
|
|
|
402
403
|
return { declared: true, tier, unknown: [], accepted };
|
|
403
404
|
}
|
|
404
405
|
|
|
406
|
+
// Which guidance an executor loads, declared by the repository rather than
|
|
407
|
+
// judged by the executor. #135's argument is that "do I need this help?" is the
|
|
408
|
+
// judgement a weak executor gets most wrong, so the skip is written down; the
|
|
409
|
+
// absent default therefore reads the guidance, and the worst an unconfigured
|
|
410
|
+
// repository does is pay for a read.
|
|
411
|
+
const EXECUTOR_TIERS = ["standard", "high"];
|
|
412
|
+
const EXECUTOR_TIER_DEFAULT = "standard";
|
|
413
|
+
|
|
414
|
+
// The tier reaches guidance and nothing else. It is a separate key from
|
|
415
|
+
// `delegation:`'s tier on purpose: that one names who runs a *delegated* task,
|
|
416
|
+
// and a repository may hand routine work to a weak delegate while its own
|
|
417
|
+
// session is strong, so one field cannot answer both without lying about one.
|
|
418
|
+
function readExecutorTier(repo) {
|
|
419
|
+
const declared = configScalar(repo, "executor_tier");
|
|
420
|
+
const accepted = [...EXECUTOR_TIERS];
|
|
421
|
+
if (!declared) {
|
|
422
|
+
return {
|
|
423
|
+
declared: false,
|
|
424
|
+
tier: EXECUTOR_TIER_DEFAULT,
|
|
425
|
+
unknown: [],
|
|
426
|
+
accepted,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
// Fail closed, the same rule `delegation:` and `authorize:` follow — except
|
|
430
|
+
// that closed here means *reading* the guidance rather than skipping it. A
|
|
431
|
+
// misspelling must not silently buy the reduction it asked for.
|
|
432
|
+
if (!EXECUTOR_TIERS.includes(declared)) {
|
|
433
|
+
return {
|
|
434
|
+
declared: true,
|
|
435
|
+
tier: EXECUTOR_TIER_DEFAULT,
|
|
436
|
+
unknown: [declared],
|
|
437
|
+
accepted,
|
|
438
|
+
message: executorTierUnreadableMessage(declared, accepted),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
return { declared: true, tier: declared, unknown: [], accepted };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Names the value and the alternatives, never just the key: a reader told their
|
|
445
|
+
// declaration is wrong without being told what is right has to go find the
|
|
446
|
+
// documentation the declaration was supposed to replace.
|
|
447
|
+
function executorTierUnreadableMessage(value, accepted) {
|
|
448
|
+
return (
|
|
449
|
+
`keel/config.yaml declares executor_tier: ${value}, which is not one of `
|
|
450
|
+
+ `${accepted.join(", ")}; the ${EXECUTOR_TIER_DEFAULT} tier applies until `
|
|
451
|
+
+ "it is corrected, so skill guidance is read rather than skipped"
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
405
455
|
function configScalar(repo, key) {
|
|
406
456
|
const configPath = path.join(repo, "keel", "config.yaml");
|
|
407
457
|
if (!fs.existsSync(configPath)) return null;
|
|
@@ -548,12 +598,16 @@ function triageIssue(repo, labels, issue = null) {
|
|
|
548
598
|
module.exports = {
|
|
549
599
|
CONFIG_RELATIVE_PATH,
|
|
550
600
|
DELEGATION_TIERS,
|
|
601
|
+
EXECUTOR_TIERS,
|
|
602
|
+
EXECUTOR_TIER_DEFAULT,
|
|
551
603
|
CONFIG_DECLARATIONS,
|
|
552
604
|
STANDING_AUTHORIZATION_ACTIONS,
|
|
553
605
|
SCOPED_AUTHORIZATION_ACTIONS,
|
|
554
606
|
readFullModePaths,
|
|
555
607
|
fullModePathsUnreadableMessage,
|
|
556
608
|
readDelegationPolicy,
|
|
609
|
+
readExecutorTier,
|
|
610
|
+
executorTierUnreadableMessage,
|
|
557
611
|
readPrecedentStore,
|
|
558
612
|
readStandingAuthorization,
|
|
559
613
|
readTriagePolicy,
|
package/src/core/context.js
CHANGED
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
readStandingAuthorization,
|
|
16
16
|
readFullModePaths,
|
|
17
17
|
fullModePathsUnreadableMessage,
|
|
18
|
+
readExecutorTier,
|
|
18
19
|
} = require("./config");
|
|
19
20
|
|
|
20
21
|
const NEXT_ACTIONS = new Set([
|
|
@@ -696,6 +697,14 @@ function resolveContext(repo, options) {
|
|
|
696
697
|
} else {
|
|
697
698
|
context.routing = routing.paths;
|
|
698
699
|
}
|
|
700
|
+
// Reported every session, declared or not, unlike `full_mode_paths` above:
|
|
701
|
+
// the default is the one a reader most needs to see, because a repository
|
|
702
|
+
// that declared nothing is loading guidance it may not want and has no other
|
|
703
|
+
// surface that would tell it so.
|
|
704
|
+
const executor = readExecutorTier(repo);
|
|
705
|
+
context.executorTier = executor.tier;
|
|
706
|
+
if (executor.unknown.length > 0) context.warnings.push(executor.message);
|
|
707
|
+
|
|
699
708
|
// Set here rather than by the caller, so every consumer of the projection —
|
|
700
709
|
// text, JSON, and any host reading it — carries the version without having
|
|
701
710
|
// to know to add it.
|
|
@@ -748,6 +757,13 @@ function renderContext(result) {
|
|
|
748
757
|
for (const entry of result.routing || []) {
|
|
749
758
|
lines.push(`Routing: ${entry.path} always routes Full — ${entry.reason}`);
|
|
750
759
|
}
|
|
760
|
+
if (result.executorTier) {
|
|
761
|
+
lines.push(
|
|
762
|
+
`Executor tier: ${result.executorTier} — affects which skill guidance is `
|
|
763
|
+
+ "read and nothing else; no gate, criterion, evidence requirement, or "
|
|
764
|
+
+ "Review changes with it"
|
|
765
|
+
);
|
|
766
|
+
}
|
|
751
767
|
for (const reason of result.reasons) lines.push(`Reason: ${reason}`);
|
|
752
768
|
for (const warning of result.warnings) lines.push(`Warning: ${warning}`);
|
|
753
769
|
return `${lines.join("\n")}\n`;
|
package/src/core/gates.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// Keel 4.1.0 deterministic gate contract.
|
|
4
4
|
|
|
5
|
+
const crypto = require("crypto");
|
|
5
6
|
const fs = require("fs");
|
|
6
7
|
const path = require("path");
|
|
7
8
|
const { spawnSync } = require("child_process");
|
|
@@ -293,6 +294,112 @@ function taskShapeWarnings(repo, selection, task, compiled) {
|
|
|
293
294
|
];
|
|
294
295
|
}
|
|
295
296
|
|
|
297
|
+
// The requirement names a change declares as new, read from the `## ADDED
|
|
298
|
+
// Requirements` sections of its delta specs. A `## MODIFIED` requirement is
|
|
299
|
+
// deliberately not here: behavior that changed is still behavior an equivalence
|
|
300
|
+
// task can legitimately claim is measurement-stable.
|
|
301
|
+
function addedRequirementNames(repo, change) {
|
|
302
|
+
const specsRoot = path.join(repo, "openspec", "changes", change, "specs");
|
|
303
|
+
const names = new Set();
|
|
304
|
+
let capabilities = [];
|
|
305
|
+
try {
|
|
306
|
+
capabilities = fs.readdirSync(specsRoot);
|
|
307
|
+
} catch {
|
|
308
|
+
return names;
|
|
309
|
+
}
|
|
310
|
+
for (const capability of capabilities) {
|
|
311
|
+
const specPath = path.join(specsRoot, capability, "spec.md");
|
|
312
|
+
let content = "";
|
|
313
|
+
try {
|
|
314
|
+
content = fs.readFileSync(specPath, "utf8");
|
|
315
|
+
} catch {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
// Only the ADDED section, bounded by the next `## ` heading, so a
|
|
319
|
+
// requirement listed under MODIFIED or REMOVED is not read as new.
|
|
320
|
+
for (const section of content.split(/^##\s+/m).slice(1)) {
|
|
321
|
+
if (!/^ADDED Requirements\s*$/m.test(section.split(/\r?\n/)[0])) continue;
|
|
322
|
+
for (const match of section.matchAll(/^###\s+Requirement:\s*(.+?)\s*$/gm)) {
|
|
323
|
+
names.add(match[1]);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return names;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// A task's declared strategy, from the text it wrote. Both forms: the compact
|
|
331
|
+
// `Strategy:` entry under `Verify`, and the expanded `Verification Strategy`
|
|
332
|
+
// field beside `Commands`.
|
|
333
|
+
function declaredStrategy(task) {
|
|
334
|
+
const compact = String(field(task, "Verify") || "").match(
|
|
335
|
+
/^\s*-?\s*Strategy:\s*(.+?)\s*$/m
|
|
336
|
+
);
|
|
337
|
+
const value = compact
|
|
338
|
+
? compact[1]
|
|
339
|
+
: String(field(task, "Verification Strategy") || "");
|
|
340
|
+
return value.trim().toLowerCase();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// A `Covers` entry naming a spec scenario: `capability / Requirement / Scenario`.
|
|
344
|
+
// Identifier entries (`D4`, `F1`, `E2`) have no slashes and are not spec claims.
|
|
345
|
+
function specCoverEntries(task) {
|
|
346
|
+
return String(field(task, "Covers") || "")
|
|
347
|
+
.split(/\r?\n/)
|
|
348
|
+
.map((line) => line.replace(/^\s*-\s*/, "").trim())
|
|
349
|
+
.filter((entry) => entry.split("/").length >= 3)
|
|
350
|
+
.map((entry) => entry.split("/").map((part) => part.trim()));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// `equivalence` owes no red, which makes it the first strategy reached for by a
|
|
354
|
+
// task that should have one. A task covering a scenario its own change *adds* is
|
|
355
|
+
// claiming that behavior is new and that behavior is unchanged at the same time,
|
|
356
|
+
// and without this guard one of the two claims has no proof anywhere in the
|
|
357
|
+
// change. Satisfied only by a sibling covering the same entry, never by the mere
|
|
358
|
+
// presence of a red-green task somewhere in the change.
|
|
359
|
+
function equivalenceEscapeProblems(repo, selection, task, compiled) {
|
|
360
|
+
const strategy = String(
|
|
361
|
+
((compiled.capsule || {}).verification || {}).strategy || ""
|
|
362
|
+
).toLowerCase();
|
|
363
|
+
if (strategy !== "equivalence") return [];
|
|
364
|
+
const added = addedRequirementNames(repo, selection.change);
|
|
365
|
+
if (added.size === 0) return [];
|
|
366
|
+
const problems = [];
|
|
367
|
+
for (const parts of specCoverEntries(task)) {
|
|
368
|
+
const requirement = parts[1];
|
|
369
|
+
if (!added.has(requirement)) continue;
|
|
370
|
+
const entry = parts.join(" / ");
|
|
371
|
+
const scenario = parts.slice(2).join(" / ");
|
|
372
|
+
const covered = selection.tasks.some((sibling) => {
|
|
373
|
+
if (sibling.id === task.id) return false;
|
|
374
|
+
// Read from the sibling's own `Verify` text rather than by compiling it.
|
|
375
|
+
// Compiling made the guard depend on the sibling being otherwise valid: a
|
|
376
|
+
// sibling with any unrelated contract error produced no capsule, so its
|
|
377
|
+
// strategy read as absent and it silently stopped satisfying the guard.
|
|
378
|
+
const siblingStrategy = declaredStrategy(sibling);
|
|
379
|
+
if (!RED_GREEN_VERIFICATION_STRATEGIES.has(siblingStrategy)) return false;
|
|
380
|
+
// The same entry, not any entry. A guard satisfied by the presence of a
|
|
381
|
+
// red-green task would be a check that the change contains one, which
|
|
382
|
+
// every change with more than one task passes.
|
|
383
|
+
return specCoverEntries(sibling).some(
|
|
384
|
+
(other) => other.join(" / ") === entry
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
if (!covered) {
|
|
388
|
+
problems.push(
|
|
389
|
+
problem(
|
|
390
|
+
"equivalence-covers-added-behavior",
|
|
391
|
+
`This task declares \`Strategy: equivalence\` and covers `
|
|
392
|
+
+ `"${scenario}", a scenario this change adds. Behavior that is new `
|
|
393
|
+
+ "is not behavior that is unchanged, and no task of this change "
|
|
394
|
+
+ "proves the new half: name a red-green task that covers the same "
|
|
395
|
+
+ "entry, or move this Covers entry to the task that implements it."
|
|
396
|
+
)
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return problems;
|
|
401
|
+
}
|
|
402
|
+
|
|
296
403
|
function taskStart(repo, options) {
|
|
297
404
|
const selection = loadSelection(repo, options);
|
|
298
405
|
const task = selection.selected[0];
|
|
@@ -300,6 +407,7 @@ function taskStart(repo, options) {
|
|
|
300
407
|
const problems = [
|
|
301
408
|
...compiled.diagnostics,
|
|
302
409
|
...invalidationProblems(repo, selection.content, selection.tasks, selection.change),
|
|
410
|
+
...equivalenceEscapeProblems(repo, selection, task, compiled),
|
|
303
411
|
];
|
|
304
412
|
// Recording the current fingerprint is idempotent: --record replaces the
|
|
305
413
|
// selected task's Contract anchor whatever it holds, so reauthorizing a task
|
|
@@ -454,6 +562,72 @@ function commandLabels(task) {
|
|
|
454
562
|
].map((match) => match[1]);
|
|
455
563
|
}
|
|
456
564
|
|
|
565
|
+
// `artifact <path> sha256:<digest>`, or null for prose. The path is
|
|
566
|
+
// repo-relative; the digest is what Review is entitled to assume it is reading.
|
|
567
|
+
const ARTIFACT_EVIDENCE =
|
|
568
|
+
/^artifact\s+(\S+)\s+sha256:([0-9a-f]{64})\s*$/i;
|
|
569
|
+
|
|
570
|
+
function artifactReference(value) {
|
|
571
|
+
const match = String(value || "").trim().match(ARTIFACT_EVIDENCE);
|
|
572
|
+
return match ? { path: match[1], digest: match[2].toLowerCase() } : null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Keel checks identity and reads nothing else: it does not parse the artifact,
|
|
576
|
+
// does not know what a field is, and compares nothing in it. The claim that the
|
|
577
|
+
// numbers agree stays the author's, recorded before Review exactly as
|
|
578
|
+
// `Fails with:` and `Detects:` are. What the digest buys is that the file Review
|
|
579
|
+
// opens is the file the author meant, which a retelling cannot offer.
|
|
580
|
+
function artifactProblems(repo, change, label, reference) {
|
|
581
|
+
if (!reference) return [];
|
|
582
|
+
// The inverse of the `Durable owner:` rule, and for the opposite reason: a
|
|
583
|
+
// follow-up pointer has to outlive the change, while an evidence artifact has
|
|
584
|
+
// to travel with it. `openspec archive` moves the change directory and
|
|
585
|
+
// nothing else, so a path outside it is one the archive is guaranteed to
|
|
586
|
+
// leave behind — and an evidence pointer that breaks on archive is worse than
|
|
587
|
+
// a retelling, which at least survives.
|
|
588
|
+
const changeDir = path.join("openspec", "changes", change);
|
|
589
|
+
const normalized = reference.path.split(path.sep).join("/");
|
|
590
|
+
if (!normalized.startsWith(`${changeDir.split(path.sep).join("/")}/`)) {
|
|
591
|
+
return [
|
|
592
|
+
problem(
|
|
593
|
+
"artifact-outside-change",
|
|
594
|
+
`${label} Evidence references \`${reference.path}\`, which is outside `
|
|
595
|
+
+ `\`${changeDir}\`. Archiving moves the change directory and nothing `
|
|
596
|
+
+ "else, so this pointer breaks the moment the change is archived. "
|
|
597
|
+
+ "Put the artifact inside the change's own directory."
|
|
598
|
+
),
|
|
599
|
+
];
|
|
600
|
+
}
|
|
601
|
+
const absolute = path.join(repo, reference.path);
|
|
602
|
+
if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
|
|
603
|
+
return [
|
|
604
|
+
problem(
|
|
605
|
+
"artifact-missing",
|
|
606
|
+
`${label} Evidence references the artifact \`${reference.path}\`, and `
|
|
607
|
+
+ "no file is there. An unresolvable reference is worse than a "
|
|
608
|
+
+ "retelling: the retelling at least carries the result."
|
|
609
|
+
),
|
|
610
|
+
];
|
|
611
|
+
}
|
|
612
|
+
const actual = crypto
|
|
613
|
+
.createHash("sha256")
|
|
614
|
+
.update(fs.readFileSync(absolute))
|
|
615
|
+
.digest("hex");
|
|
616
|
+
if (actual !== reference.digest) {
|
|
617
|
+
return [
|
|
618
|
+
problem(
|
|
619
|
+
"artifact-digest-mismatch",
|
|
620
|
+
`${label} Evidence records \`${reference.path}\` at `
|
|
621
|
+
+ `sha256:${reference.digest}, and the file there hashes to `
|
|
622
|
+
+ `sha256:${actual}. Re-record the digest if the command was re-run, `
|
|
623
|
+
+ "or correct the path — naming both is what lets a reader tell a "
|
|
624
|
+
+ "stale record from a pointer at the wrong file."
|
|
625
|
+
),
|
|
626
|
+
];
|
|
627
|
+
}
|
|
628
|
+
return [];
|
|
629
|
+
}
|
|
630
|
+
|
|
457
631
|
function evidenceValue(task, label) {
|
|
458
632
|
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
459
633
|
const match = field(task, "Evidence").match(
|
|
@@ -1049,11 +1223,21 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
|
|
|
1049
1223
|
problems.push(problem("missing-commands", "Commands must define at least one M<n>."));
|
|
1050
1224
|
}
|
|
1051
1225
|
for (const label of commands) {
|
|
1052
|
-
|
|
1226
|
+
const recorded = evidenceValue(task, label);
|
|
1227
|
+
if (!isConcrete(recorded)) {
|
|
1053
1228
|
problems.push(
|
|
1054
1229
|
problem("missing-evidence", `Missing concrete Evidence for ${label}.`)
|
|
1055
1230
|
);
|
|
1231
|
+
continue;
|
|
1056
1232
|
}
|
|
1233
|
+
// An A/B pairing or a sweep summary *is* the output of one command, and
|
|
1234
|
+
// retelling it into tasks.md is a transcription that can be wrong and that
|
|
1235
|
+
// nobody can re-check (issue #142). A reference is checked rather than
|
|
1236
|
+
// tolerated: as prose it would already have passed as concrete, so without
|
|
1237
|
+
// this the form would buy nothing at all.
|
|
1238
|
+
problems.push(
|
|
1239
|
+
...artifactProblems(repo, change, label, artifactReference(recorded))
|
|
1240
|
+
);
|
|
1057
1241
|
}
|
|
1058
1242
|
// A declared measurement is held against the check's own bare `M<n>` Evidence
|
|
1059
1243
|
// — the entry where the command and its output are recorded. A literal that
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const crypto = require("crypto");
|
|
4
|
+
const { execFileSync } = require("child_process");
|
|
4
5
|
const fs = require("fs");
|
|
5
6
|
const path = require("path");
|
|
6
7
|
|
|
@@ -143,6 +144,13 @@ const SUPPORTED_VERIFICATION_STRATEGIES = [
|
|
|
143
144
|
"snapshot-characterization",
|
|
144
145
|
"rendered-behavior",
|
|
145
146
|
"evidence-first",
|
|
147
|
+
// Zero difference, not "nothing could fail first". `evidence-first` is the
|
|
148
|
+
// only other strategy without a red-green obligation, and it is scoped by an
|
|
149
|
+
// absence; an A/B against a base is the opposite — a criterion stronger than
|
|
150
|
+
// red-green, because it also catches the change that incidentally moved a
|
|
151
|
+
// result. Issue #142 measured a task re-recording its contract twice to get
|
|
152
|
+
// past the shape rather than the criterion.
|
|
153
|
+
"equivalence",
|
|
146
154
|
];
|
|
147
155
|
|
|
148
156
|
const RED_GREEN_VERIFICATION_STRATEGIES = new Set([
|
|
@@ -257,6 +265,77 @@ function isPassingReviewStatus(value) {
|
|
|
257
265
|
);
|
|
258
266
|
}
|
|
259
267
|
|
|
268
|
+
// A git ref resolved locally, or null. Reads the repository the gate is already
|
|
269
|
+
// reading and reaches nothing else: a gate that fetched would stop being local
|
|
270
|
+
// and offline, which is the property its verdict rests on.
|
|
271
|
+
function resolveCommit(repo, ref) {
|
|
272
|
+
try {
|
|
273
|
+
return execFileSync(
|
|
274
|
+
"git",
|
|
275
|
+
["-C", repo, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
|
|
276
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
|
277
|
+
).trim() || null;
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Four ways an `equivalence` task's shape can be complete and still compare
|
|
284
|
+
// nothing. Each names the declaration it is about: a diagnostic naming the
|
|
285
|
+
// strategy would send the author to the one line that is correct.
|
|
286
|
+
function equivalenceProblems(repo, taskVerification) {
|
|
287
|
+
const problems = [];
|
|
288
|
+
const declaredFields = taskVerification.fieldsDeclared;
|
|
289
|
+
if (!isConcrete(taskVerification.base)) {
|
|
290
|
+
problems.push({
|
|
291
|
+
code: "missing-equivalence-base",
|
|
292
|
+
message:
|
|
293
|
+
"equivalence compares one code path at two commits and declares which "
|
|
294
|
+
+ "one it is compared against. Add a `Base:` entry beside `Strategy:` "
|
|
295
|
+
+ "naming a git ref — without it the criterion is `the numbers are the "
|
|
296
|
+
+ "same as some other numbers`.",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (taskVerification.fields.length === 0) {
|
|
300
|
+
problems.push({
|
|
301
|
+
code: "missing-equivalence-fields",
|
|
302
|
+
message: declaredFields
|
|
303
|
+
? "`Fields:` is declared and resolves to an empty set, so the "
|
|
304
|
+
+ "comparison has nothing to compare. It reads as a declaration to "
|
|
305
|
+
+ "every reader except the comparison; name the fields, separated by "
|
|
306
|
+
+ "commas."
|
|
307
|
+
: "equivalence declares which fields are compared. Add a `Fields:` "
|
|
308
|
+
+ "entry beside `Strategy:` listing them, separated by commas — a "
|
|
309
|
+
+ "comparison with no field set agrees with everything.",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (isConcrete(taskVerification.base)) {
|
|
313
|
+
const base = resolveCommit(repo, taskVerification.base);
|
|
314
|
+
if (!base) {
|
|
315
|
+
problems.push({
|
|
316
|
+
code: "unresolvable-equivalence-base",
|
|
317
|
+
message:
|
|
318
|
+
`\`Base: ${taskVerification.base}\` resolves to no commit in this `
|
|
319
|
+
+ "repository. The base is read locally and never fetched, so a ref "
|
|
320
|
+
+ "that exists only on a remote is not one this gate can see.",
|
|
321
|
+
});
|
|
322
|
+
} else {
|
|
323
|
+
const head = resolveCommit(repo, "HEAD");
|
|
324
|
+
if (head && head === base) {
|
|
325
|
+
problems.push({
|
|
326
|
+
code: "equivalence-base-is-head",
|
|
327
|
+
message:
|
|
328
|
+
`\`Base: ${taskVerification.base}\` resolves to ${base}, which is `
|
|
329
|
+
+ "HEAD. An A/B against itself always agrees, so the check would "
|
|
330
|
+
+ "pass having compared nothing — the one shape here that is "
|
|
331
|
+
+ "complete, resolvable, and still empty.",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return problems;
|
|
337
|
+
}
|
|
338
|
+
|
|
260
339
|
function verification(task) {
|
|
261
340
|
const compact = fieldValues(task, "Verify");
|
|
262
341
|
const strategyEntry = compact.find((entry) => /^Strategy:\s*/i.test(entry));
|
|
@@ -266,8 +345,19 @@ function verification(task) {
|
|
|
266
345
|
// command, and a reason that took an `M<n>` label would be a check the author
|
|
267
346
|
// never wrote and evidence nobody can record.
|
|
268
347
|
const reasonEntry = compact.find((entry) => /^Reason:\s*/i.test(entry));
|
|
348
|
+
// `equivalence` compares one code path at two commits. Neither half of that
|
|
349
|
+
// fits in a check: the check is the command, and what it cannot say by itself
|
|
350
|
+
// is which commit it is compared against and which fields are compared. #142
|
|
351
|
+
// proposed a third field for the command too; commands already have exactly
|
|
352
|
+
// one home here, and a second would put half of them outside the labelled
|
|
353
|
+
// evidence `task-complete` enforces.
|
|
354
|
+
const baseEntry = compact.find((entry) => /^Base:\s*/i.test(entry));
|
|
355
|
+
const fieldsEntry = compact.find((entry) => /^Fields:\s*/i.test(entry));
|
|
269
356
|
const isVerificationField = (entry) =>
|
|
270
|
-
/^Strategy:\s*/i.test(entry)
|
|
357
|
+
/^Strategy:\s*/i.test(entry)
|
|
358
|
+
|| /^Reason:\s*/i.test(entry)
|
|
359
|
+
|| /^Base:\s*/i.test(entry)
|
|
360
|
+
|| /^Fields:\s*/i.test(entry);
|
|
271
361
|
const commandSource = compact.length > 0
|
|
272
362
|
? compact.filter((entry) => !isVerificationField(entry))
|
|
273
363
|
: fieldValues(task, "Commands");
|
|
@@ -342,6 +432,24 @@ function verification(task) {
|
|
|
342
432
|
? reasonEntry.replace(/^Reason:\s*/i, "")
|
|
343
433
|
: field(task, "Verification Reason")
|
|
344
434
|
),
|
|
435
|
+
base: normalizeText(
|
|
436
|
+
baseEntry ? baseEntry.replace(/^Base:\s*/i, "") : field(task, "Verification Base")
|
|
437
|
+
),
|
|
438
|
+
// A set, so "declared but empty" is a state the gate can see. `Fields:` with
|
|
439
|
+
// nothing behind it is the shape that passes while comparing nothing, and it
|
|
440
|
+
// reads as a declaration to everyone except the comparison.
|
|
441
|
+
// Whether the line was written at all, kept beside the parsed set so a
|
|
442
|
+
// refusal can tell an author who wrote nothing from one who wrote an empty
|
|
443
|
+
// set. To the comparison they are the same state; to the author they are
|
|
444
|
+
// opposite mistakes.
|
|
445
|
+
fieldsDeclared: Boolean(fieldsEntry || field(task, "Verification Fields")),
|
|
446
|
+
fields: (fieldsEntry
|
|
447
|
+
? fieldsEntry.replace(/^Fields:\s*/i, "")
|
|
448
|
+
: field(task, "Verification Fields") || ""
|
|
449
|
+
)
|
|
450
|
+
.split(",")
|
|
451
|
+
.map((entry) => normalizeText(entry))
|
|
452
|
+
.filter(Boolean),
|
|
345
453
|
commands,
|
|
346
454
|
};
|
|
347
455
|
}
|
|
@@ -1191,6 +1299,11 @@ function compileTaskContract(repo, change, task) {
|
|
|
1191
1299
|
+ "needs no reason.",
|
|
1192
1300
|
});
|
|
1193
1301
|
}
|
|
1302
|
+
if (taskVerification.strategy.toLowerCase() === "equivalence") {
|
|
1303
|
+
resolved.diagnostics.push(
|
|
1304
|
+
...equivalenceProblems(repo, taskVerification)
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1194
1307
|
const couplingMode = normalizeText(field(task, "Coupling")).toLowerCase()
|
|
1195
1308
|
|| "none";
|
|
1196
1309
|
const candidateBoundary = normalizedValues(task, "Candidate Boundary", {
|
|
@@ -1311,6 +1424,14 @@ function compileTaskContract(repo, change, task) {
|
|
|
1311
1424
|
// other task keeps the capsule shape and fingerprint it had before the
|
|
1312
1425
|
// field existed.
|
|
1313
1426
|
...(taskVerification.reason ? { reason: taskVerification.reason } : {}),
|
|
1427
|
+
// Same rule: emitted only by the strategy that declares them, so every
|
|
1428
|
+
// existing task's capsule shape and fingerprint are untouched. They belong
|
|
1429
|
+
// in the capsule rather than only in the file because a declaration
|
|
1430
|
+
// outside the fingerprint could be edited after the run it describes.
|
|
1431
|
+
...(taskVerification.base ? { base: taskVerification.base } : {}),
|
|
1432
|
+
...(taskVerification.fields.length > 0
|
|
1433
|
+
? { fields: taskVerification.fields }
|
|
1434
|
+
: {}),
|
|
1314
1435
|
// Emit a tag only when the check opts out of a default, so an untagged
|
|
1315
1436
|
// check keeps the capsule shape and fingerprint it had before either tag
|
|
1316
1437
|
// existed. `layer` appears only for `fast`, `regression` only when true.
|