@foldrun/core 0.3.0 → 0.5.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 +27 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +3 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +224 -4
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +26 -2
- package/dist/src/egress.js +8 -1
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +9 -0
- package/dist/src/evals.js +26 -13
- package/dist/src/flow-lint.d.ts +12 -0
- package/dist/src/flow-lint.js +331 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +43 -4
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +132 -8
- package/dist/src/notify.js +360 -58
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +87 -0
- package/dist/src/platform.d.ts +144 -4
- package/dist/src/platform.js +27 -3
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +233 -0
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +206 -17
- package/dist/src/run-container.js +547 -80
- package/dist/src/runner.d.ts +179 -10
- package/dist/src/runner.js +1423 -169
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +15 -2
- package/dist/src/script-tools.js +44 -4
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +83 -0
- package/dist/src/secret-health.js +177 -0
- package/dist/src/secrets.d.ts +38 -1
- package/dist/src/secrets.js +197 -96
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +95 -11
- package/dist/src/step-exec.d.ts +106 -1
- package/dist/src/step-exec.js +558 -85
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +492 -38
- package/dist/src/store.js +882 -69
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +152 -33
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +71 -24
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +23 -0
- package/dist/src/triggers.js +101 -12
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +50 -11
- package/index.ts +3 -0
- package/package.json +5 -5
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/approvals.ts +253 -2
- package/src/browser-pod.ts +313 -0
- package/src/budget.ts +178 -0
- package/src/catalog.ts +3 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +224 -4
- package/src/deploy.ts +137 -6
- package/src/egress.ts +32 -4
- package/src/eol.ts +7 -0
- package/src/evals.ts +36 -11
- package/src/flow-lint.ts +371 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +50 -4
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +391 -44
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +83 -0
- package/src/platform.ts +158 -5
- package/src/provider-health.ts +275 -0
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +611 -76
- package/src/runner.ts +1463 -165
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +55 -6
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +230 -0
- package/src/secrets.ts +191 -95
- package/src/starter.ts +96 -11
- package/src/step-exec.ts +548 -41
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +149 -24
- package/src/store.ts +1227 -74
- package/src/subagents.ts +189 -0
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +159 -32
- package/src/translator.ts +74 -23
- package/src/trigger-log.ts +160 -0
- package/src/triggers.ts +102 -12
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +58 -11
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/eol.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Flow (and other markdown) rewrites work on LF text: the parser and the
|
|
2
|
+
// line rules read "\n"-separated lines, and a CRLF line's trailing "\r"
|
|
3
|
+
// defeats them (an option line never matched, so setting it again added a
|
|
4
|
+
// second copy; a CRLF step line was no step at all). So each rewrite takes a
|
|
5
|
+
// CRLF file as LF and gives it back as CRLF.
|
|
6
|
+
export const toLF = (raw: string) => raw.replace(/\r\n/g, "\n");
|
|
7
|
+
export const asRaw = (raw: string, out: string) => (raw.includes("\r\n") ? out.replace(/\r?\n/g, "\r\n") : out);
|
package/src/evals.ts
CHANGED
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
type Effort,
|
|
49
49
|
} from "./store.ts";
|
|
50
50
|
import { startFlowRun, loadFlow } from "./runner.ts";
|
|
51
|
-
import { platform } from "./platform.ts";
|
|
51
|
+
import { platform, platformQueues } from "./platform.ts";
|
|
52
52
|
import type { FlowStep } from "./store.ts";
|
|
53
53
|
|
|
54
54
|
export type Assertion =
|
|
@@ -80,6 +80,15 @@ export interface EvalInfo {
|
|
|
80
80
|
* `manual`: only when a person presses Run or the CLI asks — for a flow
|
|
81
81
|
* eval that costs a whole run and touches real systems every time. */
|
|
82
82
|
trigger: "deploy" | "manual";
|
|
83
|
+
/** An eval's runs are test runs — nothing outward, state untouched —
|
|
84
|
+
* unless the file says `live: true`. A flow eval that must really send
|
|
85
|
+
* to prove itself opts in; nothing opts in by accident. */
|
|
86
|
+
live: boolean;
|
|
87
|
+
/** A file of saved inputs for a flow, not a test: `inputs: true`. Its
|
|
88
|
+
* cases are named tasks the dashboard's "Run with…" and `foldrun invoke
|
|
89
|
+
* --inputs` start the flow with; nothing is asserted, so no eval run,
|
|
90
|
+
* deploy or `foldrun eval` ever runs them. */
|
|
91
|
+
inputs: boolean;
|
|
83
92
|
cases: EvalCase[];
|
|
84
93
|
}
|
|
85
94
|
|
|
@@ -187,6 +196,8 @@ export function parseEval(file: string, raw: string): EvalInfo {
|
|
|
187
196
|
model: resolveModel(data.model ?? "fast"),
|
|
188
197
|
effort: resolveEffort(data.effort ?? "low"),
|
|
189
198
|
trigger: data.trigger === "manual" ? "manual" : "deploy",
|
|
199
|
+
live: data.live === true,
|
|
200
|
+
inputs: data.inputs === true,
|
|
190
201
|
cases,
|
|
191
202
|
};
|
|
192
203
|
}
|
|
@@ -286,7 +297,8 @@ export function promoteRunToEval(
|
|
|
286
297
|
let expect: string[];
|
|
287
298
|
if (opts.expect && opts.expect.length > 0) {
|
|
288
299
|
expect = opts.expect.map((line) => {
|
|
289
|
-
const m = oneLine(line).match(/^([a-z-]+)
|
|
300
|
+
const m = oneLine(line).match(/^([a-z-]+):(.*)$/);
|
|
301
|
+
if (m) m[2] = m[2].trim();
|
|
290
302
|
if (!m || !(ASSERTION_TYPES as readonly string[]).includes(m[1]) || !m[2]) {
|
|
291
303
|
throw refuse(
|
|
292
304
|
`"${line}" is not an assertion — expected one of ${ASSERTION_TYPES.map((t) => `${t}:`).join(", ")} followed by a value`,
|
|
@@ -325,7 +337,7 @@ export function promoteRunToEval(
|
|
|
325
337
|
409,
|
|
326
338
|
);
|
|
327
339
|
}
|
|
328
|
-
content = `${existing.
|
|
340
|
+
content = `${existing.trimEnd()}\n\n${caseText}`;
|
|
329
341
|
} else {
|
|
330
342
|
// A flow eval costs a whole run and touches real systems, so it waits
|
|
331
343
|
// for a person to press Run rather than following every push.
|
|
@@ -389,16 +401,20 @@ export interface EvalResult {
|
|
|
389
401
|
error?: string;
|
|
390
402
|
}
|
|
391
403
|
|
|
392
|
-
/** Wait for a run to finish, with a ceiling so a hung agent can't hang an eval. */
|
|
393
404
|
/**
|
|
394
405
|
* A web replica never drives a run: it has no route to the cluster API and
|
|
395
406
|
* no right to create a pod, by design — so an eval started there (a push,
|
|
396
407
|
* the Run button) queues its run for the worker like any other. From 29 Aug
|
|
397
408
|
* every eval on the split platform died at "pod create failed: connection
|
|
398
409
|
* refused" for exactly this reason. A single process — the CLI, a laptop,
|
|
399
|
-
*
|
|
410
|
+
* no platform registered — still drives it right here, as it always did.
|
|
411
|
+
*
|
|
412
|
+
* The test is whether a platform owns the queue, not which role this
|
|
413
|
+
* process was started as: on a worker the role read "worker", the eval
|
|
414
|
+
* started its run in-process, and the run never passed through the
|
|
415
|
+
* account cap or the lanes the platform enforces on every other run.
|
|
400
416
|
*/
|
|
401
|
-
const runsViaQueue = () =>
|
|
417
|
+
const runsViaQueue = () => platformQueues();
|
|
402
418
|
|
|
403
419
|
async function beginEvalRun(
|
|
404
420
|
tenant: string,
|
|
@@ -407,8 +423,9 @@ async function beginEvalRun(
|
|
|
407
423
|
flowName: string,
|
|
408
424
|
model?: string | null,
|
|
409
425
|
effort?: string | null,
|
|
426
|
+
test = true,
|
|
410
427
|
) {
|
|
411
|
-
if (!runsViaQueue()) return startFlowRun(tenant, workspace, steps, flowName, model, [], effort);
|
|
428
|
+
if (!runsViaQueue()) return startFlowRun(tenant, workspace, steps, flowName, model, [], effort, null, { test });
|
|
412
429
|
// The job carries a model override but no effort one, and driveRun cannot
|
|
413
430
|
// re-read a flow called `eval:<name>`. Put the flow's defaults onto the
|
|
414
431
|
// steps that have none — which is what nearest-wins resolves to anyway.
|
|
@@ -417,7 +434,7 @@ async function beginEvalRun(
|
|
|
417
434
|
...(s.model || !model ? {} : { model }),
|
|
418
435
|
...(s.effort || !effort ? {} : { effort }),
|
|
419
436
|
}));
|
|
420
|
-
return platform.enqueueFlowRun(tenant, workspace, withDefaults, flowName, model ?? null, []);
|
|
437
|
+
return platform.enqueueFlowRun(tenant, workspace, withDefaults, flowName, model ?? null, [], null, { test });
|
|
421
438
|
}
|
|
422
439
|
|
|
423
440
|
// Waits for as long as the run runs. There was a cap here (5 minutes, then
|
|
@@ -539,8 +556,11 @@ export async function runEval(
|
|
|
539
556
|
): Promise<EvalResult> {
|
|
540
557
|
const startedAt = new Date().toISOString();
|
|
541
558
|
const results: CaseResult[] = [];
|
|
559
|
+
// Saved inputs carry no assertions: running them would start and bill a
|
|
560
|
+
// run per case only to report every one failed. Nothing to grade.
|
|
561
|
+
const cases = info.inputs ? [] : info.cases;
|
|
542
562
|
|
|
543
|
-
for (const testCase of
|
|
563
|
+
for (const testCase of cases) {
|
|
544
564
|
const target = info.flow ? null : info.agent;
|
|
545
565
|
let runId: string | null = null;
|
|
546
566
|
let output = "";
|
|
@@ -558,7 +578,9 @@ export async function runEval(
|
|
|
558
578
|
? { ...s, instruction: `${s.instruction}\n\n<run_task>\n${testCase.task}\n</run_task>` }
|
|
559
579
|
: s,
|
|
560
580
|
);
|
|
561
|
-
|
|
581
|
+
// Test unless the eval OR the flow says live: an eval that runs a
|
|
582
|
+
// flow written `live: true` was written by someone who knew.
|
|
583
|
+
run = await beginEvalRun(tenant, workspace, steps, `eval:${info.name}`, flow.model, flow.effort, !(info.live || flow.live));
|
|
562
584
|
} else {
|
|
563
585
|
if (!target) throw new Error("this eval names neither an agent nor a flow");
|
|
564
586
|
run = await beginEvalRun(
|
|
@@ -566,6 +588,9 @@ export async function runEval(
|
|
|
566
588
|
workspace,
|
|
567
589
|
[{ agent: target, instruction: testCase.task, group: 1, optional: false }],
|
|
568
590
|
`eval:${info.name}`,
|
|
591
|
+
undefined,
|
|
592
|
+
undefined,
|
|
593
|
+
!info.live,
|
|
569
594
|
);
|
|
570
595
|
}
|
|
571
596
|
runId = run.id;
|
|
@@ -752,7 +777,7 @@ export async function evaluateDeployed(
|
|
|
752
777
|
if (!stamp) return { ran: 0, skipped: "no revision to stamp the result with" };
|
|
753
778
|
// Only the evals that asked to run on deploy — a `trigger: manual` eval is
|
|
754
779
|
// one a person runs on purpose, because it costs a whole flow run.
|
|
755
|
-
const evals = listEvals(tenant, workspace).filter((e) => e.trigger !== "manual");
|
|
780
|
+
const evals = listEvals(tenant, workspace).filter((e) => e.trigger !== "manual" && !e.inputs);
|
|
756
781
|
if (evals.length === 0) return { ran: 0, skipped: "no evals" };
|
|
757
782
|
const dir = path.join(workspaceDir(tenant, workspace), "evals", ".results");
|
|
758
783
|
fs.mkdirSync(dir, { recursive: true });
|
package/src/flow-lint.ts
CHANGED
|
@@ -12,6 +12,19 @@
|
|
|
12
12
|
// blocked — flows still run, warnings are advisory.
|
|
13
13
|
|
|
14
14
|
import type { FlowInfo, FlowStep } from "./store.ts";
|
|
15
|
+
import { whenRowsPath } from "./store.ts";
|
|
16
|
+
import { timezoneProblem } from "./clock.ts";
|
|
17
|
+
|
|
18
|
+
/** What a `[[flow:x]]` step's options are called in a flow file, for the
|
|
19
|
+
* ones expandSubflows does not carry into the nested steps. */
|
|
20
|
+
const SUBFLOW_DROPPED: [keyof FlowStep, string][] = [
|
|
21
|
+
["approve", "an approval (!)"], ["when", "when:"], ["case", "case:"], ["else", "else:"],
|
|
22
|
+
["retry", "retry:"], ["timeout", "timeout:"], ["verify", "verify:"], ["model", "model:"],
|
|
23
|
+
["effort", "effort:"], ["onFail", "on-fail:"], ["waitSecs", "wait:"], ["waitFor", "wait:"],
|
|
24
|
+
["ask", "ask:"], ["preview", "preview:"], ["delegate", "delegate:"], ["loop", "loop:"],
|
|
25
|
+
["until", "until:"], ["each", "each:"], ["max", "max:"], ["output", "output:"],
|
|
26
|
+
["schema", "schema:"], ["parallel", "parallel:"], ["maxTurns", "max_turns:"], ["limits", "limits:"],
|
|
27
|
+
];
|
|
15
28
|
|
|
16
29
|
export interface FlowWarning {
|
|
17
30
|
/** Index into flow.steps, or null for a whole-flow warning. */
|
|
@@ -20,6 +33,13 @@ export interface FlowWarning {
|
|
|
20
33
|
line?: number;
|
|
21
34
|
message: string;
|
|
22
35
|
detail: string;
|
|
36
|
+
/** "error" for a value the parser could not read — `timeout: abc` — which
|
|
37
|
+
* `foldrun check` must fail on rather than note. Absent means advisory,
|
|
38
|
+
* which every structural warning in this file is. */
|
|
39
|
+
level?: "error" | "warn";
|
|
40
|
+
/** A stable tag for warnings a caller wants to single out — the deploy
|
|
41
|
+
* surfaces `kind: "outward"` as a non-blocking warning. */
|
|
42
|
+
kind?: string;
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
// Anaphora: phrases that only mean something if earlier output exists.
|
|
@@ -33,12 +53,63 @@ const REFERS_BACK =
|
|
|
33
53
|
* checks too. */
|
|
34
54
|
export interface KnownNames {
|
|
35
55
|
agents: string[];
|
|
56
|
+
/** Agents that hold a tool declaring `outward: true` — one that sends,
|
|
57
|
+
* posts, buys or publishes. The caller resolves the grants; this file only
|
|
58
|
+
* needs the answer. Absent means the caller did not look, and the outward
|
|
59
|
+
* rule is skipped rather than guessed at. */
|
|
60
|
+
outwardAgents?: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One @, something either side, a dot in the domain, no whitespace. Written
|
|
64
|
+
* as string checks rather than a regex: the obvious pattern backtracks
|
|
65
|
+
* polynomially on a domain full of dots, and a lint that can be made slow
|
|
66
|
+
* by a flow file is a lint someone will turn off. */
|
|
67
|
+
function looksLikeAddress(a: string): boolean {
|
|
68
|
+
const at = a.indexOf("@");
|
|
69
|
+
if (at < 1 || at !== a.lastIndexOf("@") || /\s/.test(a)) return false;
|
|
70
|
+
const domain = a.slice(at + 1);
|
|
71
|
+
const dot = domain.indexOf(".");
|
|
72
|
+
return dot > 0 && dot < domain.length - 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The assertion words a `verify:` may open with. Anything else is handed to
|
|
76
|
+
* a shell — see `checkVerify` in step-exec.ts, which owns this list. */
|
|
77
|
+
const VERIFY_ASSERTION = /^(contains|not-contains|matches|file|judge):\s/;
|
|
78
|
+
|
|
79
|
+
/** What a shell command has that a sentence does not: an operator that only
|
|
80
|
+
* means something to a shell, a path or bracket at the very start, or one
|
|
81
|
+
* of the commands these flows actually reach for. Deliberately NOT any
|
|
82
|
+
* punctuation a sentence might carry — the first version disqualified on
|
|
83
|
+
* `;`, `,`, `'` and `"`, so "the reply opens with GOOD; a GOOD reply names
|
|
84
|
+
* the SHA" read as shell and a guaranteed exit 127 sat in fix-desk unseen
|
|
85
|
+
* by the very lint written to catch it. */
|
|
86
|
+
const LOOKS_LIKE_SHELL =
|
|
87
|
+
/&&|\|\||\s\|\s|\$\(|\$\{|`|\s[<>]\s|\d?>[>&]?\s|^\[|^[.~/]|^\s*\b(test|grep|egrep|find|head|tail|cat|ls|node|npm|npx|bash|sh|zsh|python3?|jq|awk|sed|curl|git|make|exit|n=|[A-Za-z_]+=)/;
|
|
88
|
+
|
|
89
|
+
/** A `verify:` that is a claim in English rather than a command. Conservative
|
|
90
|
+
* on purpose, the same bargain as REFERS_BACK: it must have no assertion
|
|
91
|
+
* prefix, nothing shell-shaped anywhere in it, and enough words to be a
|
|
92
|
+
* sentence — so a bare `./check.sh` or a one-word command is left alone and
|
|
93
|
+
* the warning only fires where the author plainly wrote prose. */
|
|
94
|
+
function looksLikeProse(verify: string): boolean {
|
|
95
|
+
const v = verify.trim();
|
|
96
|
+
if (!v || VERIFY_ASSERTION.test(v)) return false;
|
|
97
|
+
if (LOOKS_LIKE_SHELL.test(v)) return false;
|
|
98
|
+
return v.split(/\s+/).length >= 4;
|
|
36
99
|
}
|
|
37
100
|
|
|
38
101
|
export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
|
|
39
102
|
const warnings: FlowWarning[] = [];
|
|
40
103
|
const agentNames = known ? new Set(known.agents) : null;
|
|
41
104
|
|
|
105
|
+
// A frontmatter enum the parser could not read, or a step line whose
|
|
106
|
+
// `[[target]]` is malformed — recorded by parseFlow, reported here at
|
|
107
|
+
// error level so `foldrun check` fails on it, the way a bad step option
|
|
108
|
+
// does. A value nothing can read is never a silent default.
|
|
109
|
+
for (const fp of flow.frontProblems ?? []) {
|
|
110
|
+
warnings.push({ step: null, level: "error", message: fp, detail: "The line is written but its value cannot be read, so a run would use a silent default instead of what the author meant. Write one of the values the option names, or a target in lower case, digits and dashes." });
|
|
111
|
+
}
|
|
112
|
+
|
|
42
113
|
// Cron's oldest trap, and an expensive one: when day-of-month AND
|
|
43
114
|
// day-of-week are both restricted they are OR'd, not AND'd — the scheduler
|
|
44
115
|
// does it too, correctly (`domRestricted && dowRestricted` → `domOk ||
|
|
@@ -47,6 +118,86 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
|
|
|
47
118
|
// eleven runs where one was meant. Nothing about the expression looks wrong,
|
|
48
119
|
// which is exactly why it needs saying here rather than being discovered on
|
|
49
120
|
// the bill.
|
|
121
|
+
// An approvers list only one word can satisfy. `approvers: [editors]` is
|
|
122
|
+
// a role that is not `admins` and not an address; mayApprove would then
|
|
123
|
+
// refuse every person, and the gate could only ever be answered by an
|
|
124
|
+
// emailed link. Said here rather than discovered at the gate.
|
|
125
|
+
const odd = (flow.approvers ?? []).filter((a) => a !== "admins" && !looksLikeAddress(a));
|
|
126
|
+
if (odd.length) {
|
|
127
|
+
warnings.push({
|
|
128
|
+
step: null,
|
|
129
|
+
message: `approvers: ${odd.join(", ")} — not an email address, and the only role word is admins`,
|
|
130
|
+
detail: "Each entry is an address that may answer this flow's gates, or the word `admins`. Anything else matches nobody, and a list nobody matches locks every gate.",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
// A flow with an approvers list and no gate at all is a rule the author
|
|
134
|
+
// believes is in force and which does nothing — the worst kind of safety
|
|
135
|
+
// control.
|
|
136
|
+
if (flow.approvers?.length && !flow.steps.some((s) => s.approve || s.ask)) {
|
|
137
|
+
warnings.push({
|
|
138
|
+
step: null,
|
|
139
|
+
message: "approvers: is set but no step in this flow is a gate",
|
|
140
|
+
detail:
|
|
141
|
+
"Nothing here waits for a person, so the list has no effect. Mark the step that needs " +
|
|
142
|
+
"a second pair of eyes with `!` (or `approve: true`), or drop the approvers: line.",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// A zone nobody can read is two faults at once: the cron fires at the
|
|
146
|
+
// wrong hour, and every step's calendar quietly falls through to the level
|
|
147
|
+
// above. Both are invisible in a green run, so `foldrun check` refuses it.
|
|
148
|
+
const zoneProblem = timezoneProblem(flow.timezone);
|
|
149
|
+
if (zoneProblem) {
|
|
150
|
+
warnings.push({
|
|
151
|
+
step: null,
|
|
152
|
+
level: "error",
|
|
153
|
+
message: zoneProblem,
|
|
154
|
+
detail:
|
|
155
|
+
"This is the zone the schedule fires in AND the calendar every step in this flow works " +
|
|
156
|
+
"to. Unreadable, the schedule falls back to UTC and each step inherits the workspace's " +
|
|
157
|
+
"zone instead of this one.",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// A deadline on a gate that does not exist reads as a safety net and is not
|
|
161
|
+
// one. Same reasoning, said separately because the fix is different.
|
|
162
|
+
if (flow.approveWithin && !flow.steps.some((s) => s.approve || s.ask)) {
|
|
163
|
+
warnings.push({
|
|
164
|
+
step: null,
|
|
165
|
+
message: "approve_within: is set but no step in this flow is a gate",
|
|
166
|
+
detail: "Nothing waits for a person, so nothing can expire. Mark a step with `!`, or drop the line.",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
// A throttle no schedule can ever trip: the flow cannot run more often
|
|
170
|
+
// than its cron fires, so a floor below that interval never fires either.
|
|
171
|
+
// Cheap to write, silently pointless, and a reader will believe it.
|
|
172
|
+
if (flow.throttle && flow.trigger === "schedule" && flow.schedule) {
|
|
173
|
+
warnings.push({
|
|
174
|
+
step: null,
|
|
175
|
+
message: `throttle: on a scheduled flow — check it is shorter than the schedule, or it will drop runs you meant to happen`,
|
|
176
|
+
detail:
|
|
177
|
+
"A schedule already decides how often this runs. A throttle longer than the gap between " +
|
|
178
|
+
"fires silently skips them; if that is what you want, say it in the cron instead.",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// `idempotency:` only has a delivery to read on a trigger that receives
|
|
182
|
+
// one. On a schedule or a chained flow there is no request and no field,
|
|
183
|
+
// so the key is inert.
|
|
184
|
+
if (flow.idempotency && flow.trigger !== "webhook" && flow.trigger !== "email") {
|
|
185
|
+
warnings.push({
|
|
186
|
+
step: null,
|
|
187
|
+
message: `idempotency: has nothing to read on trigger: ${flow.trigger}`,
|
|
188
|
+
detail:
|
|
189
|
+
"A delivery id comes off an incoming request, so this only does something for " +
|
|
190
|
+
"trigger: webhook or trigger: email. On any other trigger it is ignored.",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
// `catchup:` is about a fire that was missed, which only a clock can miss.
|
|
194
|
+
if (flow.catchup && flow.trigger !== "schedule") {
|
|
195
|
+
warnings.push({
|
|
196
|
+
step: null,
|
|
197
|
+
message: `catchup: has no effect on trigger: ${flow.trigger}`,
|
|
198
|
+
detail: "Only a schedule can miss a fire. Every other trigger happens when it happens.",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
50
201
|
if (flow.schedule) {
|
|
51
202
|
const [, , dom, , dow] = flow.schedule.trim().split(/\s+/);
|
|
52
203
|
if (dom && dow && dom !== "*" && dow !== "*") {
|
|
@@ -63,6 +214,9 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
|
|
|
63
214
|
|
|
64
215
|
// Trigger shapes that can never fire, said at check time rather than
|
|
65
216
|
// discovered as a flow that "never runs".
|
|
217
|
+
if (flow.budgetProblem) {
|
|
218
|
+
warnings.push({ step: null, message: flow.budgetProblem, detail: "A flow's `budget:` is the most one run may spend — a number in USD, or `unlimited`. A cap over a day, week or month belongs in the workspace's or the account's AGENTS.md, where it means every run put together." });
|
|
219
|
+
}
|
|
66
220
|
if (flow.trigger === "once" && !flow.at) {
|
|
67
221
|
warnings.push({ step: null, message: "trigger: once needs an `at:` instant", detail: "Write `at: 2026-09-05T09:00:00+10:00` (ISO 8601). Without a readable instant this flow never fires." });
|
|
68
222
|
}
|
|
@@ -87,6 +241,40 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
|
|
|
87
241
|
flow.steps.forEach((step, i) => {
|
|
88
242
|
const inFirstGroup = step.group === firstGroup;
|
|
89
243
|
|
|
244
|
+
// A value the parser refused. Not advisory: the run would go ahead on
|
|
245
|
+
// a number the author never wrote — `timeout: 15m` ran as one second
|
|
246
|
+
// for as long as this was a coercion — so the check fails instead.
|
|
247
|
+
for (const problem of step.problems ?? []) {
|
|
248
|
+
warnings.push({
|
|
249
|
+
step: i,
|
|
250
|
+
line: step.line,
|
|
251
|
+
level: "error",
|
|
252
|
+
message: problem,
|
|
253
|
+
detail:
|
|
254
|
+
"The option is written but its value cannot be read, and a run would not do what the " +
|
|
255
|
+
"line says. Durations are seconds or 90s / 30m / 4h / 3d; counts are whole numbers " +
|
|
256
|
+
"within the key's cap (retry and loop at most 5, max at most 20).",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// An instruction written over more than one line. It is read in full
|
|
261
|
+
// now, but the parser had to decide where it stopped, and the line
|
|
262
|
+
// below a wrapped instruction is one edit away from being prose. Said
|
|
263
|
+
// out loud so an author who meant to wrap can see it, and one who did
|
|
264
|
+
// not can see what got joined on.
|
|
265
|
+
if (step.wrapped) {
|
|
266
|
+
warnings.push({
|
|
267
|
+
step: i,
|
|
268
|
+
line: step.line,
|
|
269
|
+
message: `"${step.subflow ?? step.agent}" has an instruction spread over more than one line`,
|
|
270
|
+
detail:
|
|
271
|
+
"A step's instruction ends where the step ends: the indented lines directly under it " +
|
|
272
|
+
"are joined on, and the first blank line, option, heading, bullet or unindented line " +
|
|
273
|
+
"finishes it. Put the whole instruction on the step's own line — one line, however " +
|
|
274
|
+
"long — so what the agent is told does not depend on where the paragraph below begins.",
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
90
278
|
// The first group starts with context = null. An instruction that points
|
|
91
279
|
// at earlier output therefore points at nothing.
|
|
92
280
|
if (inFirstGroup && step.instruction && REFERS_BACK.test(step.instruction)) {
|
|
@@ -100,6 +288,31 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
|
|
|
100
288
|
});
|
|
101
289
|
}
|
|
102
290
|
|
|
291
|
+
// `../../storage/…` still works everywhere; `workspace/storage/…` is the
|
|
292
|
+
// spelling that also works in a shell, and the one the docs teach. Said
|
|
293
|
+
// once per step, advisory — never an error on a path that resolves.
|
|
294
|
+
{
|
|
295
|
+
const rowsOf = step.when ? whenRowsPath(step.when) : null;
|
|
296
|
+
const spots = [
|
|
297
|
+
step.eachPath ? ["each: rows of", step.eachPath] : null,
|
|
298
|
+
rowsOf ? ["when: rows of", rowsOf] : null,
|
|
299
|
+
step.verify ? ["verify:", step.verify] : null,
|
|
300
|
+
step.schemaPath ? ["schema:", step.schemaPath] : null,
|
|
301
|
+
step.instruction ? ["the instruction", step.instruction] : null,
|
|
302
|
+
].filter((x): x is [string, string] => !!x && /\.\.\/\.\.\/(storage|state)\b/.test(x[1]));
|
|
303
|
+
if (spots.length) {
|
|
304
|
+
warnings.push({
|
|
305
|
+
step: i,
|
|
306
|
+
line: step.line,
|
|
307
|
+
kind: "spelling",
|
|
308
|
+
message: `"${step.subflow ?? step.agent}" writes ../../storage or ../../state in ${spots.map((x) => x[0]).join(", ")}`,
|
|
309
|
+
detail:
|
|
310
|
+
"It still works. The documented spelling is workspace/storage/… and workspace/state/… — " +
|
|
311
|
+
"the same place, and it also resolves in the shell and in scripts.",
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
103
316
|
// A gate with no `preview:` shows only the previous step's reply and
|
|
104
317
|
// whatever files that reply happens to name. The person deciding sees a
|
|
105
318
|
// summary, not the thing — unless the agent remembered.
|
|
@@ -115,6 +328,45 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
|
|
|
115
328
|
});
|
|
116
329
|
}
|
|
117
330
|
|
|
331
|
+
// A step that can act on the outside world, with nothing to say whether
|
|
332
|
+
// it did. The two answers are a check the runtime can fail on, or a
|
|
333
|
+
// person; either is fine, neither is not. This is the one lint here that
|
|
334
|
+
// is an error: every other structural warning costs a reread, and this
|
|
335
|
+
// one costs a send nobody can take back.
|
|
336
|
+
if (known?.outwardAgents?.includes(step.agent ?? "") && !step.verify && !step.approve && !step.ask) {
|
|
337
|
+
warnings.push({
|
|
338
|
+
step: step.group,
|
|
339
|
+
line: step.line,
|
|
340
|
+
level: "error",
|
|
341
|
+
kind: "outward",
|
|
342
|
+
message: `[[${step.agent}]] can act outside this workspace and this step has no verify: and no gate`,
|
|
343
|
+
detail:
|
|
344
|
+
"A step that sends, posts, buys or publishes needs one of two things: a `verify:` the " +
|
|
345
|
+
"runtime can fail the step on — a receipt file, a ledger row, the reply's own verdict " +
|
|
346
|
+
"line — or a person, with `!` or `ask:`. Without either, a step that sent nothing and a " +
|
|
347
|
+
"step that sent everything end the same way: reported as done.",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// retry: re-runs a failed step from the start. For a step that sends,
|
|
352
|
+
// a failure can come after the send — the verify: or the JSON check
|
|
353
|
+
// failed on a step whose email went — and the retry sends it again. The
|
|
354
|
+
// runner declines that one retry (see actedThenFailedCheck in runner.ts)
|
|
355
|
+
// but a crash mid-send still reruns; the author should know.
|
|
356
|
+
if (known?.outwardAgents?.includes(step.agent ?? "") && step.retry) {
|
|
357
|
+
warnings.push({
|
|
358
|
+
step: i,
|
|
359
|
+
line: step.line,
|
|
360
|
+
kind: "outward",
|
|
361
|
+
message: `[[${step.agent}]] can act outside this workspace and this step has retry: ${step.retry}`,
|
|
362
|
+
detail:
|
|
363
|
+
"A retry runs the whole step again, sends included. The runner does not retry a failure " +
|
|
364
|
+
"that came from the check after the step's tools ran, but an attempt that failed mid-way — " +
|
|
365
|
+
"a timeout, a crash, a provider error after the first send — is retried and may repeat " +
|
|
366
|
+
"what it already did. Make the send idempotent (check a ledger before sending), or drop retry:.",
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
118
370
|
// `each: items` fans out over DATA, and only an `output: json` step in
|
|
119
371
|
// an earlier group produces any. Without one the step expands to
|
|
120
372
|
// nothing every time, quietly — the exact failure this file exists for.
|
|
@@ -133,6 +385,64 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
|
|
|
133
385
|
}
|
|
134
386
|
}
|
|
135
387
|
|
|
388
|
+
// Two options that read another option to mean anything.
|
|
389
|
+
if (step.parallel && !step.each) {
|
|
390
|
+
warnings.push({
|
|
391
|
+
step: i,
|
|
392
|
+
line: step.line,
|
|
393
|
+
message: `"${step.subflow ?? step.agent}" has parallel: but no each:`,
|
|
394
|
+
detail: "parallel: caps how many instances of a fan-out run at once. Without each: there is one instance, and the line does nothing.",
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
if ((step.schema !== undefined || step.schemaPath) && step.output !== "json") {
|
|
398
|
+
warnings.push({
|
|
399
|
+
step: i,
|
|
400
|
+
line: step.line,
|
|
401
|
+
message: `"${step.subflow ?? step.agent}" has schema: but no output: json`,
|
|
402
|
+
detail: "schema: describes the JSON value an output: json step returns. Without output: json nothing is parsed, so nothing is checked. Add `output: json`.",
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// The step's own agent. `delegate:` and `on-fail:` are checked below and
|
|
407
|
+
// the step's target was not, which is the reference that stops the run
|
|
408
|
+
// rather than degrading it: the runner reaches the step, finds nothing to
|
|
409
|
+
// run and fails it with `agent "<name>" not found in workspace`. A
|
|
410
|
+
// scheduled flow does that at 3am, three times, before anyone looks.
|
|
411
|
+
if (agentNames && step.agent && !step.subflow && !agentNames.has(step.agent)) {
|
|
412
|
+
warnings.push({
|
|
413
|
+
step: i,
|
|
414
|
+
line: step.line,
|
|
415
|
+
level: "error",
|
|
416
|
+
message: `[[${step.agent}]] is not an agent in this workspace`,
|
|
417
|
+
detail:
|
|
418
|
+
"This step has nothing to run. Every run of this flow fails here. Check the spelling " +
|
|
419
|
+
"against `agents/<name>/agent.md` — the name is the folder's, not the file's heading — " +
|
|
420
|
+
"or add the agent.",
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// A `verify:` written as a sentence. Two dialects share the key: a line
|
|
425
|
+
// beginning `contains:`, `not-contains:`, `matches:`, `file:` or
|
|
426
|
+
// `judge:` is an assertion, and ANYTHING else is handed to a shell. So
|
|
427
|
+
// `verify: the reply names the clone it made` runs a program called
|
|
428
|
+
// `the`, exits 127, and fails a step that did its job perfectly — every
|
|
429
|
+
// run, with an error about bash that says nothing about the flow. The
|
|
430
|
+
// fix is one word: the same sentence behind `judge:` is graded by a
|
|
431
|
+
// model, which is what the author meant.
|
|
432
|
+
if (step.verify && looksLikeProse(step.verify)) {
|
|
433
|
+
warnings.push({
|
|
434
|
+
step: i,
|
|
435
|
+
line: step.line,
|
|
436
|
+
level: "error",
|
|
437
|
+
message: `verify: reads as a sentence, so it will be run as a shell command`,
|
|
438
|
+
detail:
|
|
439
|
+
`\`${step.verify.slice(0, 60)}${step.verify.length > 60 ? "…" : ""}\` has no assertion ` +
|
|
440
|
+
"prefix, so the runtime executes it: the first word becomes the program and the step " +
|
|
441
|
+
"fails with `command not found`. Put `judge: ` in front to have a model grade the " +
|
|
442
|
+
"claim, or write it as a shell command that exits 0 when the claim holds.",
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
136
446
|
// A step's OTHER agent references — the ones no parser validates,
|
|
137
447
|
// because they are options rather than the step's target. Both fail
|
|
138
448
|
// silently and at the worst possible moment: a misspelled `delegate:`
|
|
@@ -176,7 +486,8 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
|
|
|
176
486
|
}
|
|
177
487
|
|
|
178
488
|
// `when:` tests the previous results, which the first group doesn't have.
|
|
179
|
-
|
|
489
|
+
// `when: rows of` reads a file instead, which may well exist already.
|
|
490
|
+
if (inFirstGroup && step.when && whenRowsPath(step.when) === null) {
|
|
180
491
|
warnings.push({
|
|
181
492
|
step: i,
|
|
182
493
|
line: step.line,
|
|
@@ -186,6 +497,25 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
|
|
|
186
497
|
"always be skipped. Move it after the step whose output it depends on.",
|
|
187
498
|
});
|
|
188
499
|
}
|
|
500
|
+
|
|
501
|
+
// A `[[flow:x]]` step is spliced in as the other flow's own steps, and
|
|
502
|
+
// only its `?` and its instruction survive the splice. Everything else
|
|
503
|
+
// written under it was dropped without a word — a `when:` that never
|
|
504
|
+
// gated, a `!` that never asked.
|
|
505
|
+
if (step.subflow) {
|
|
506
|
+
const dropped = SUBFLOW_DROPPED.filter(([field]) => step[field] !== undefined).map(([, name]) => name);
|
|
507
|
+
if (dropped.length) {
|
|
508
|
+
warnings.push({
|
|
509
|
+
step: i,
|
|
510
|
+
line: step.line,
|
|
511
|
+
message: `"flow:${step.subflow}" has ${dropped.join(", ")}, which a nested flow ignores`,
|
|
512
|
+
detail:
|
|
513
|
+
"A nested flow runs as its own steps, with their own options; what is written under the " +
|
|
514
|
+
"[[flow:…]] line is not applied to them. Put the option on the steps inside that flow, " +
|
|
515
|
+
"or make this an agent step.",
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
189
519
|
});
|
|
190
520
|
|
|
191
521
|
// The same agent twice in one parallel group gets two identical contexts and
|
|
@@ -209,6 +539,46 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
|
|
|
209
539
|
});
|
|
210
540
|
}
|
|
211
541
|
}
|
|
542
|
+
|
|
543
|
+
// A gate parks its whole group, and on approval every step in it starts
|
|
544
|
+
// together. So `5. [[editor]]` beside `5! [[publisher]]` does not mean
|
|
545
|
+
// "edit, then ask, then publish": the editor waits for the approval too,
|
|
546
|
+
// and the publisher runs alongside it, reading files the editor has not
|
|
547
|
+
// written yet. medium-desk's publisher did exactly that on 2026-09-28
|
|
548
|
+
// (run-mukdjeh8-cr66): post.json was still empty when it started.
|
|
549
|
+
const gated = (s: FlowStep) => Boolean(s.approve || s.ask || s.waitFor === "event");
|
|
550
|
+
const gates = steps.filter(gated);
|
|
551
|
+
const others = steps.filter((s) => !gated(s));
|
|
552
|
+
if (gates.length && others.length) {
|
|
553
|
+
const name = (s: FlowStep) => (s.subflow ? `flow:${s.subflow}` : s.agent);
|
|
554
|
+
warnings.push({
|
|
555
|
+
step: flow.steps.indexOf(gates[0]),
|
|
556
|
+
line: gates[0].line,
|
|
557
|
+
message: `"${name(gates[0])}" waits for a person but shares number ${group} with "${others.map(name).join('", "')}"`,
|
|
558
|
+
detail:
|
|
559
|
+
"Steps with the same number run together. The gate holds all of them until it is " +
|
|
560
|
+
"approved, then they start at once — so the gated step cannot use what the others " +
|
|
561
|
+
`produce, and the approver sees none of it. To run the gated step after them, number it ${group + 1}.`,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// A number with nothing under it is harmless to the runner, which only
|
|
567
|
+
// orders groups, but it is almost always one step numbered twice: 3, 3, 4,
|
|
568
|
+
// 5, 5!, 7 is a renumbering that missed the 5! that was meant to be 6.
|
|
569
|
+
const numbers = [...byGroup.keys()].sort((a, b) => a - b);
|
|
570
|
+
for (let i = 1; i < numbers.length; i++) {
|
|
571
|
+
if (numbers[i] - numbers[i - 1] < 2) continue;
|
|
572
|
+
const missing = numbers[i - 1] + 1 === numbers[i] - 1 ? `${numbers[i - 1] + 1}` : `${numbers[i - 1] + 1} to ${numbers[i] - 1}`;
|
|
573
|
+
const shared = [...byGroup.entries()].filter(([, s]) => s.length > 1).map(([g]) => g);
|
|
574
|
+
warnings.push({
|
|
575
|
+
step: null,
|
|
576
|
+
message: `step numbers go from ${numbers[i - 1]} to ${numbers[i]}; nothing is numbered ${missing}`,
|
|
577
|
+
detail:
|
|
578
|
+
"The same number means \"run together\", so a gap usually means a step kept an old number " +
|
|
579
|
+
"after a renumbering" +
|
|
580
|
+
(shared.length ? ` — ${shared.map((g) => `number ${g}`).join(", ")} has more than one step; check whether one of them belongs in the gap.` : "."),
|
|
581
|
+
});
|
|
212
582
|
}
|
|
213
583
|
|
|
214
584
|
return warnings;
|