@christang/keel 5.3.1 → 5.3.4
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/assets/bootstrap/AGENTS.md +2 -2
- package/assets/openspec/schemas/keel-spec-driven/schema.yaml +28 -6
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +30 -7
- 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/scripts/session-start.js +8 -1
- package/scripts/bump_version.js +46 -8
- package/scripts/validate_plugin.py +1008 -129
- package/src/core/gates.js +196 -24
- package/src/core/task-contract.js +62 -16
package/src/core/gates.js
CHANGED
|
@@ -134,7 +134,10 @@ function taskStart(repo, options) {
|
|
|
134
134
|
const selection = loadSelection(repo, options);
|
|
135
135
|
const task = selection.selected[0];
|
|
136
136
|
const compiled = compileTaskContract(repo, selection.change, task);
|
|
137
|
-
const problems = [
|
|
137
|
+
const problems = [
|
|
138
|
+
...compiled.diagnostics,
|
|
139
|
+
...invalidationProblems(repo, selection.content, selection.tasks),
|
|
140
|
+
];
|
|
138
141
|
// Recording the current fingerprint is idempotent: --record replaces the
|
|
139
142
|
// selected task's Contract anchor whatever it holds, so reauthorizing a task
|
|
140
143
|
// whose authority changed — the path the guard's own drift messages direct
|
|
@@ -252,19 +255,52 @@ function reviewValue(task, label) {
|
|
|
252
255
|
// confirmed that an archive path resolves either, so an external tracker
|
|
253
256
|
// reference is no less checkable than what was already accepted; whether the
|
|
254
257
|
// owner is real stays a Review judgment.
|
|
255
|
-
const
|
|
256
|
-
|
|
258
|
+
const TRACKER_REFERENCE = /\bhttps?:\/\/\S/i;
|
|
259
|
+
|
|
260
|
+
// The owner forms, stated once so every refusal that lists them agrees with
|
|
261
|
+
// every other and with what the checks below actually accept.
|
|
262
|
+
const DURABLE_OWNER_FORMS =
|
|
263
|
+
"an absolute `https://…` tracker reference, or any repo-relative path that "
|
|
264
|
+
+ "exists — `keel/archive/…`, an `openspec/changes/…` artifact, or the "
|
|
265
|
+
+ "repository's own ledger; `keel/HANDOFF.md` is a pointer override rather "
|
|
266
|
+
+ "than an owner";
|
|
267
|
+
|
|
268
|
+
// Classify a declared `Durable owner:` value. A gate runs without network, so a
|
|
269
|
+
// URL is accepted on shape alone; a path is the one form it can actually check,
|
|
270
|
+
// and checking it is stricter than the prefix whitelist this replaced.
|
|
271
|
+
function durableOwnerVerdict(repo, value) {
|
|
272
|
+
const owner = String(value || "").trim();
|
|
273
|
+
if (!owner) return { ok: false, reason: "unrecognized" };
|
|
274
|
+
if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
|
|
275
|
+
if (TRACKER_REFERENCE.test(owner)) return { ok: true };
|
|
276
|
+
const candidate = owner.match(/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+/);
|
|
277
|
+
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
278
|
+
if (fs.existsSync(path.join(repo, candidate[0]))) return { ok: true };
|
|
279
|
+
return { ok: false, reason: "missing", path: candidate[0] };
|
|
280
|
+
}
|
|
257
281
|
|
|
258
282
|
function findingOwnerIsDurable(repo, findings) {
|
|
259
283
|
if (/keel\/HANDOFF\.md/i.test(findings)) return false;
|
|
260
284
|
if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
|
|
261
285
|
return true;
|
|
262
286
|
}
|
|
263
|
-
if (
|
|
264
|
-
|
|
287
|
+
if (TRACKER_REFERENCE.test(findings)) return true;
|
|
288
|
+
// A path counts here only when it is named as the owner. Findings is free
|
|
289
|
+
// prose, and a finding that merely mentions the source file it concerns has
|
|
290
|
+
// not thereby given that finding an owner.
|
|
291
|
+
const declared = findings.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
292
|
+
if (declared) return durableOwnerVerdict(repo, declared[1]).ok;
|
|
293
|
+
const artifact = findings.match(
|
|
265
294
|
/\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
|
|
266
295
|
);
|
|
267
|
-
|
|
296
|
+
if (artifact && fs.existsSync(path.join(repo, artifact[1]))) return true;
|
|
297
|
+
return /\bkeel\/archive\/[A-Za-z0-9._/-]+/i.test(findings)
|
|
298
|
+
&& fs.existsSync(
|
|
299
|
+
path.join(
|
|
300
|
+
repo,
|
|
301
|
+
findings.match(/\bkeel\/archive\/[A-Za-z0-9._/-]+/i)[0]
|
|
302
|
+
)
|
|
303
|
+
);
|
|
268
304
|
}
|
|
269
305
|
|
|
270
306
|
function gitPaths(repo) {
|
|
@@ -432,14 +468,24 @@ function completionChecks(repo, task, contract = null) {
|
|
|
432
468
|
? contract.capsule.verification.strategy.toLowerCase()
|
|
433
469
|
: "";
|
|
434
470
|
if (RED_GREEN_VERIFICATION_STRATEGIES.has(strategy)) {
|
|
471
|
+
// A `(regression)` check asserts that something already green is still
|
|
472
|
+
// green, so it has no honest red. It is exempt from red-green but not from
|
|
473
|
+
// evidence: the bare-label check above still applies to it.
|
|
474
|
+
const exempt = new Set(
|
|
475
|
+
(contract ? contract.capsule.verification.commands : [])
|
|
476
|
+
.filter((entry) => entry.regression)
|
|
477
|
+
.map((entry) => entry.label)
|
|
478
|
+
);
|
|
435
479
|
for (const label of commands) {
|
|
480
|
+
if (exempt.has(label)) continue;
|
|
436
481
|
for (const phase of ["red", "green"]) {
|
|
437
482
|
if (!isConcrete(evidenceValue(task, `${label}.${phase}`))) {
|
|
438
483
|
problems.push(
|
|
439
484
|
problem(
|
|
440
485
|
"missing-strategy-evidence",
|
|
441
486
|
`${strategy} requires concrete ${label}.${phase} Evidence for `
|
|
442
|
-
+ "the same behavior check."
|
|
487
|
+
+ "the same behavior check. Tag the check `(regression)` if it "
|
|
488
|
+
+ "asserts that something already green stays green."
|
|
443
489
|
)
|
|
444
490
|
);
|
|
445
491
|
}
|
|
@@ -501,9 +547,9 @@ function completionChecks(repo, task, contract = null) {
|
|
|
501
547
|
problem(
|
|
502
548
|
"finding-owner",
|
|
503
549
|
"Review Findings must be `none` or carry a durable owner — a "
|
|
504
|
-
+ "`Discard reason:`/`Discard rationale:` prefix,
|
|
505
|
-
+
|
|
506
|
-
+ "
|
|
550
|
+
+ "`Discard reason:`/`Discard rationale:` prefix, or "
|
|
551
|
+
+ `${DURABLE_OWNER_FORMS}. Name a path after \`Durable owner:\` so `
|
|
552
|
+
+ "it reads as the owner rather than a file the finding mentions."
|
|
507
553
|
)
|
|
508
554
|
);
|
|
509
555
|
}
|
|
@@ -543,7 +589,122 @@ function taskComplete(repo, options) {
|
|
|
543
589
|
);
|
|
544
590
|
}
|
|
545
591
|
|
|
546
|
-
|
|
592
|
+
// Follow-up Ownership governs work a change left undone. This is the opposite
|
|
593
|
+
// shape: statements left standing by work the change completed. It is asked at
|
|
594
|
+
// task-start rather than change-close because the whole value is that the
|
|
595
|
+
// affected paths enter Touch before implementation — asking at the close finds
|
|
596
|
+
// the same facts after the reauthorization it was meant to prevent.
|
|
597
|
+
//
|
|
598
|
+
// A location list is refused on purpose. The text that goes stale is the text
|
|
599
|
+
// the author was not already holding in mind, so a list of remembered files
|
|
600
|
+
// reproduces the failure; a searchable phrase is what turns the declaration
|
|
601
|
+
// into a grep. What the phrase says is the agent's judgment, not the gate's.
|
|
602
|
+
function invalidationProblems(repo, content, tasks) {
|
|
603
|
+
const heading = content.search(/^## Invalidates\s*$/m);
|
|
604
|
+
if (heading < 0) {
|
|
605
|
+
return [
|
|
606
|
+
problem(
|
|
607
|
+
"invalidation-declaration",
|
|
608
|
+
"tasks.md requires a `## Invalidates` section before its tasks are "
|
|
609
|
+
+ "executable: one `- I<n>: \"searchable phrase\" — where it lives. "
|
|
610
|
+
+ "Updated by: <task ids>` line per statement this change makes "
|
|
611
|
+
+ "stale, using `Durable owner:` or `Discard reason:` instead when "
|
|
612
|
+
+ "no task of this change updates it, or `- None.`."
|
|
613
|
+
),
|
|
614
|
+
];
|
|
615
|
+
}
|
|
616
|
+
const bodyStart = content.indexOf("\n", heading);
|
|
617
|
+
const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
|
|
618
|
+
const nextHeading = remainder.search(/^##\s+/m);
|
|
619
|
+
const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
|
|
620
|
+
if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
|
|
621
|
+
const entries = [
|
|
622
|
+
...section.matchAll(
|
|
623
|
+
/^\s*-\s+(I\d+)\s*:\s*([\s\S]*?)(?=^\s*-\s+I\d+\s*:|(?![\s\S]))/gm
|
|
624
|
+
),
|
|
625
|
+
];
|
|
626
|
+
if (entries.length === 0) {
|
|
627
|
+
return [
|
|
628
|
+
problem(
|
|
629
|
+
"invalidation-declaration",
|
|
630
|
+
"Invalidates must declare each `I<n>` entry — a quoted searchable "
|
|
631
|
+
+ "phrase, where it lives, and a closure (`Updated by:`, "
|
|
632
|
+
+ "`Durable owner:`, or `Discard reason:`) — or `- None.`."
|
|
633
|
+
),
|
|
634
|
+
];
|
|
635
|
+
}
|
|
636
|
+
const problems = [];
|
|
637
|
+
for (const entry of entries) {
|
|
638
|
+
const [, id, body] = entry;
|
|
639
|
+
if (!/"[^"\n]{3,}"/.test(body)) {
|
|
640
|
+
problems.push(
|
|
641
|
+
problem(
|
|
642
|
+
"invalidation-phrase",
|
|
643
|
+
`${id} names where to look but not what to look for. Quote the `
|
|
644
|
+
+ "wording a reader would search for, so the entry is a search "
|
|
645
|
+
+ "rather than a reminder."
|
|
646
|
+
)
|
|
647
|
+
);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const updated = body.match(/Updated by:\s*([0-9.,\s-]+)/i);
|
|
651
|
+
const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
652
|
+
const verdict = declaredOwner
|
|
653
|
+
? durableOwnerVerdict(repo, declaredOwner[1])
|
|
654
|
+
: { ok: false, reason: "absent" };
|
|
655
|
+
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
656
|
+
if (!updated && !verdict.ok && !discarded) {
|
|
657
|
+
if (verdict.reason === "missing") {
|
|
658
|
+
problems.push(
|
|
659
|
+
problem(
|
|
660
|
+
"invalidation-owner-missing",
|
|
661
|
+
`${id} names \`${verdict.path}\` as its durable owner, but no such `
|
|
662
|
+
+ "file exists in this repository."
|
|
663
|
+
)
|
|
664
|
+
);
|
|
665
|
+
} else if (verdict.reason === "handoff") {
|
|
666
|
+
problems.push(
|
|
667
|
+
problem(
|
|
668
|
+
"invalidation-closure",
|
|
669
|
+
`${id} names keel/HANDOFF.md, which is a pointer override rather `
|
|
670
|
+
+ `than a durable owner. Accepted forms: ${DURABLE_OWNER_FORMS}.`
|
|
671
|
+
)
|
|
672
|
+
);
|
|
673
|
+
} else {
|
|
674
|
+
problems.push(
|
|
675
|
+
problem(
|
|
676
|
+
"invalidation-closure",
|
|
677
|
+
`${id} lacks an updating task, a durable owner, or a discard `
|
|
678
|
+
+ "rationale. Close it with `Updated by: <task ids>` naming tasks "
|
|
679
|
+
+ "of this change, a `Discard reason:`, or a `Durable owner:` "
|
|
680
|
+
+ `naming ${DURABLE_OWNER_FORMS}.`
|
|
681
|
+
)
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
// Deliberately weaker than Expectation Coverage, which requires a checked
|
|
687
|
+
// task: at authoring time the updater has not run yet, so existence is the
|
|
688
|
+
// only honest assertion. Completion is then structural — the named task
|
|
689
|
+
// carries the paths in its Touch and passes its own gate.
|
|
690
|
+
if (updated) {
|
|
691
|
+
const ids = updated[1].match(/\d+(?:\.\d+)+/g) || [];
|
|
692
|
+
for (const taskId of ids) {
|
|
693
|
+
if (!tasks.some((task) => task.id === taskId)) {
|
|
694
|
+
problems.push(
|
|
695
|
+
problem(
|
|
696
|
+
"invalidation-owner",
|
|
697
|
+
`${id} names task ${taskId}, which this change does not define.`
|
|
698
|
+
)
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return problems;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function expectationProblems(repo, content, tasks) {
|
|
547
708
|
const heading = content.search(/^## Expectation Coverage\s*$/m);
|
|
548
709
|
if (heading < 0) {
|
|
549
710
|
return [
|
|
@@ -581,19 +742,30 @@ function expectationProblems(content, tasks) {
|
|
|
581
742
|
const [, id, body] = entry;
|
|
582
743
|
const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
|
|
583
744
|
const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
584
|
-
const
|
|
585
|
-
declaredOwner
|
|
586
|
-
|
|
587
|
-
|| SHARED_DURABLE_OWNER_FORMS.test(declaredOwner[1]))
|
|
588
|
-
);
|
|
745
|
+
const verdict = declaredOwner
|
|
746
|
+
? durableOwnerVerdict(repo, declaredOwner[1])
|
|
747
|
+
: { ok: false, reason: "absent" };
|
|
589
748
|
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
590
|
-
if (!covered && !
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
749
|
+
if (!covered && !verdict.ok && !discarded) {
|
|
750
|
+
if (verdict.reason === "missing") {
|
|
751
|
+
problems.push(
|
|
752
|
+
problem(
|
|
753
|
+
"expectation-owner-missing",
|
|
754
|
+
`${id} names \`${verdict.path}\` as its durable owner, but no such `
|
|
755
|
+
+ "file exists in this repository."
|
|
756
|
+
)
|
|
757
|
+
);
|
|
758
|
+
} else {
|
|
759
|
+
problems.push(
|
|
760
|
+
problem(
|
|
761
|
+
"expectation-closure",
|
|
762
|
+
`${id} lacks behavior coverage, durable owner, or discard `
|
|
763
|
+
+ "rationale. Close it with `Covered by: <task ids>`, a "
|
|
764
|
+
+ "`Discard reason:`, or a `Durable owner:` naming "
|
|
765
|
+
+ `${DURABLE_OWNER_FORMS}.`
|
|
766
|
+
)
|
|
767
|
+
);
|
|
768
|
+
}
|
|
597
769
|
continue;
|
|
598
770
|
}
|
|
599
771
|
if (covered) {
|
|
@@ -675,7 +847,7 @@ function changeClose(repo, options) {
|
|
|
675
847
|
)
|
|
676
848
|
);
|
|
677
849
|
}
|
|
678
|
-
problems.push(...expectationProblems(selection.content, selection.tasks));
|
|
850
|
+
problems.push(...expectationProblems(repo, selection.content, selection.tasks));
|
|
679
851
|
|
|
680
852
|
const changePath = path.dirname(selection.tasksPath);
|
|
681
853
|
if (!hasDeltaSpec(changePath)) {
|
|
@@ -130,6 +130,9 @@ const RED_GREEN_VERIFICATION_STRATEGIES = new Set([
|
|
|
130
130
|
"regression-first",
|
|
131
131
|
]);
|
|
132
132
|
|
|
133
|
+
// Tags an M<n> check may carry after its label, as a comma-separated set.
|
|
134
|
+
const COMMAND_TAGS = new Set(["fast", "full", "regression"]);
|
|
135
|
+
|
|
133
136
|
// Single source of truth for the accepted completion Review `Status`
|
|
134
137
|
// vocabulary. Consumed by both the completion gate (src/core/gates.js) and the
|
|
135
138
|
// context "already reviewed" probe (src/core/context.js) so the two never
|
|
@@ -156,12 +159,26 @@ function verification(task) {
|
|
|
156
159
|
? compact.filter((entry) => !/^Strategy:\s*/i.test(entry))
|
|
157
160
|
: fieldValues(task, "Commands");
|
|
158
161
|
const commands = commandSource.map((entry) => {
|
|
159
|
-
// An optional
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
// An optional tag set after the M<n> label. `fast`/`full` marks which checks
|
|
163
|
+
// the fast inner loop runs; `regression` marks a check that asserts
|
|
164
|
+
// something already green is still green, which has no honest red and is
|
|
165
|
+
// therefore exempt from the red-green evidence requirement. A check may
|
|
166
|
+
// carry both, so the tag is a comma-separated set rather than one word.
|
|
167
|
+
const match = entry.match(/^(M[1-9]\d*)(?:\s*\(([^)\n]*)\))?:\s*(.*)$/);
|
|
168
|
+
if (!match) return { label: null, layer: "full", regression: false, check: entry };
|
|
169
|
+
const tags = (match[2] || "")
|
|
170
|
+
.split(",")
|
|
171
|
+
.map((tag) => tag.trim().toLowerCase())
|
|
172
|
+
.filter(Boolean);
|
|
173
|
+
if (tags.some((tag) => !COMMAND_TAGS.has(tag))) {
|
|
174
|
+
return { label: null, layer: "full", regression: false, check: entry };
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
label: match[1],
|
|
178
|
+
layer: tags.includes("fast") ? "fast" : "full",
|
|
179
|
+
regression: tags.includes("regression"),
|
|
180
|
+
check: normalizeText(match[3]),
|
|
181
|
+
};
|
|
165
182
|
});
|
|
166
183
|
return {
|
|
167
184
|
compact: compact.length > 0,
|
|
@@ -189,7 +206,9 @@ function commandLabelProblems(task) {
|
|
|
189
206
|
malformed = true;
|
|
190
207
|
problems.push({
|
|
191
208
|
code: "invalid-command-label",
|
|
192
|
-
message: `Command entry must use an M<n> label
|
|
209
|
+
message: `Command entry must use an M<n> label, optionally followed by `
|
|
210
|
+
+ `a tag set drawn from ${[...COMMAND_TAGS].join(", ")} — `
|
|
211
|
+
+ `for example \`M2 (regression): …\`: ${entry}`,
|
|
193
212
|
});
|
|
194
213
|
continue;
|
|
195
214
|
}
|
|
@@ -264,7 +283,7 @@ function taskStartContractProblems(task) {
|
|
|
264
283
|
},
|
|
265
284
|
];
|
|
266
285
|
}
|
|
267
|
-
return commandLabelProblems(task);
|
|
286
|
+
return [...commandLabelProblems(task), ...regressionOnlyProblems(task)];
|
|
268
287
|
}
|
|
269
288
|
if (!touch.some((entry) => isConcrete(entry))) {
|
|
270
289
|
return [
|
|
@@ -273,9 +292,34 @@ function taskStartContractProblems(task) {
|
|
|
273
292
|
message: "implementation and plan-first require a concrete Touch path.",
|
|
274
293
|
},
|
|
275
294
|
...commandLabelProblems(task),
|
|
295
|
+
...regressionOnlyProblems(task),
|
|
276
296
|
];
|
|
277
297
|
}
|
|
278
|
-
return commandLabelProblems(task);
|
|
298
|
+
return [...commandLabelProblems(task), ...regressionOnlyProblems(task)];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// A red-green strategy whose every check is exempt from red-green is that
|
|
302
|
+
// strategy in name only, and the tag would become the escape hatch rather than
|
|
303
|
+
// the declaration it is meant to be.
|
|
304
|
+
function regressionOnlyProblems(task) {
|
|
305
|
+
const parsed = verification(task);
|
|
306
|
+
if (!RED_GREEN_VERIFICATION_STRATEGIES.has(parsed.strategy.toLowerCase())) {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
const labelled = parsed.commands.filter((entry) => entry.label);
|
|
310
|
+
if (labelled.length === 0 || labelled.some((entry) => !entry.regression)) {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
return [
|
|
314
|
+
{
|
|
315
|
+
code: "regression-only-strategy",
|
|
316
|
+
message:
|
|
317
|
+
`\`${parsed.strategy}\` requires at least one check that is not tagged `
|
|
318
|
+
+ "`(regression)`, because a regression check has no red to record. "
|
|
319
|
+
+ "Untag the check that proves the new behavior, or name a strategy "
|
|
320
|
+
+ "that is not red-green.",
|
|
321
|
+
},
|
|
322
|
+
];
|
|
279
323
|
}
|
|
280
324
|
|
|
281
325
|
function requiredFieldProblems(task) {
|
|
@@ -803,15 +847,17 @@ function compileTaskContract(repo, change, task) {
|
|
|
803
847
|
acceptance: [...new Set([...derivedAcceptance, ...explicitAcceptance])],
|
|
804
848
|
verification: {
|
|
805
849
|
strategy: taskVerification.strategy,
|
|
806
|
-
// Emit
|
|
807
|
-
//
|
|
850
|
+
// Emit a tag only when the check opts out of a default, so an untagged
|
|
851
|
+
// check keeps the capsule shape and fingerprint it had before either tag
|
|
852
|
+
// existed. `layer` appears only for `fast`, `regression` only when true.
|
|
808
853
|
commands: taskVerification.commands
|
|
809
854
|
.filter((entry) => entry.label)
|
|
810
|
-
.map((entry) =>
|
|
811
|
-
entry.
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
855
|
+
.map((entry) => {
|
|
856
|
+
const emitted = { label: entry.label, check: entry.check };
|
|
857
|
+
if (entry.layer && entry.layer !== "full") emitted.layer = entry.layer;
|
|
858
|
+
if (entry.regression) emitted.regression = true;
|
|
859
|
+
return emitted;
|
|
860
|
+
}),
|
|
815
861
|
},
|
|
816
862
|
boundaries: {
|
|
817
863
|
autonomy,
|