@foldrun/core 1.2.0 → 1.4.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/dist/src/scan.js CHANGED
@@ -28,6 +28,8 @@
28
28
  import crypto from "node:crypto";
29
29
  import matter from "gray-matter";
30
30
  import { refNames } from "./refs.js";
31
+ import { resolveNetwork } from "./network.js";
32
+ import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.js";
31
33
  export const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
32
34
  export const SCAN_RULES = [
33
35
  {
@@ -74,7 +76,8 @@ export const SCAN_RULES = [
74
76
  level: "warn",
75
77
  title: "Reads untrusted content, holds private data and can send it out",
76
78
  help: "An agent with all three can be told — by any page it reads — to send what it holds anywhere it can reach. " +
77
- "Take one leg away: narrow `network:` to the sites it needs, split the reading and the sending into two agents, " +
79
+ "Take one leg away: take it off the open network (`network: { level: trusted, allow: [the sites it needs] }` — a bare list only adds sites, " +
80
+ "and an agent that reads the web stays open), split the reading and the sending into two agents, " +
78
81
  "or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
79
82
  owasp: ["ASI01", "ASI02", "ASI03"],
80
83
  },
@@ -118,9 +121,32 @@ export const SCAN_RULES = [
118
121
  id: "config/unknown-field",
119
122
  category: "config",
120
123
  level: "warn",
121
- title: "A frontmatter field foldrun does not read — likely a typo",
124
+ title: "A frontmatter field foldrun does not read here",
122
125
  help: "A misspelt field is not an error to YAML, so it is silently ignored: `tolls: [web]` leaves the agent with no tools. " +
123
- "Use the field the message suggests.",
126
+ "A near-miss names the field it meant; a field read in another kind of file says where it belongs (warnings). " +
127
+ "A field like nothing foldrun reads is a note, since another tool may own it.",
128
+ },
129
+ {
130
+ id: "config/wrong-type",
131
+ category: "config",
132
+ level: "warn",
133
+ title: "A frontmatter field holding the wrong kind of value",
134
+ help: "A list where one name was meant, a map where a word was meant: the runtime does not read it. The message says what the field takes.",
135
+ },
136
+ {
137
+ id: "config/invalid-value",
138
+ category: "config",
139
+ level: "warn",
140
+ title: "A frontmatter value that does nothing",
141
+ help: "`effort: extreme`, `size: giant`, `trigger: sometimes`, a cron the scheduler cannot read: each is ignored or falls back, " +
142
+ "so the agent or flow quietly does something else. The message lists what the field takes.",
143
+ },
144
+ {
145
+ id: "config/missing-field",
146
+ category: "config",
147
+ level: "warn",
148
+ title: "A required frontmatter field is missing",
149
+ help: "A skill with no description is never loaded: the model chooses skills by their descriptions.",
124
150
  },
125
151
  {
126
152
  id: "check/ignore-needs-reason",
@@ -272,6 +298,41 @@ function credentialLike(value) {
272
298
  export function redactSecret(secret) {
273
299
  return secret.length <= 8 ? "****" : `${secret.slice(0, 4)}…${secret.slice(-2)}`;
274
300
  }
301
+ /** Every credential in a piece of text, as [value, what]. One reading for
302
+ * the secret rule and for any other rule that quotes the line. */
303
+ function credentialsIn(line) {
304
+ const out = [];
305
+ const seen = new Set();
306
+ const add = (v, what) => {
307
+ if (!seen.has(v)) {
308
+ seen.add(v);
309
+ out.push([v, what]);
310
+ }
311
+ };
312
+ for (const [re, what] of SECRET_SHAPES) {
313
+ if (what === "a private key")
314
+ continue; // a header names a key; it is not one
315
+ for (const m of line.matchAll(re)) {
316
+ if (!PLACEHOLDER.test(m[0]) && entropy(m[0]) >= 3)
317
+ add(m[0], what);
318
+ }
319
+ }
320
+ for (const m of line.matchAll(ASSIGNED))
321
+ if (credentialLike(m[2]))
322
+ add(m[2], `\`${m[1]}\` with a literal value`);
323
+ for (const m of line.matchAll(BEARER))
324
+ if (credentialLike(m[1]))
325
+ add(m[1], "a bearer token");
326
+ return out;
327
+ }
328
+ /** The text with every credential in it redacted — for a finding that
329
+ * quotes source, so no message ever carries a key whole. */
330
+ export function redactText(text) {
331
+ let out = text;
332
+ for (const [v] of credentialsIn(text).sort((a, b) => b[0].length - a[0].length))
333
+ out = out.split(v).join(redactSecret(v));
334
+ return out;
335
+ }
275
336
  function scanSecrets(file, lines, push) {
276
337
  for (let i = 0; i < lines.length; i++) {
277
338
  const line = lines[i];
@@ -351,13 +412,13 @@ function scanContent(file, lines, push) {
351
412
  const kind = HIDDEN_NAMES.find(([re]) => re.test(line))?.[1] ?? "invisible characters";
352
413
  const n = line.match(HIDDEN)?.length ?? 0;
353
414
  const tagged = /\uDB40[\uDC00-\uDC7F]/.test(line) ? ` that read as: "${decodeTags(line).slice(0, 80)}"` : "";
354
- push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${tagged} — invisible to a reviewer, read by the model`);
415
+ push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${redactText(tagged)} — invisible to a reviewer, read by the model`);
355
416
  }
356
417
  // An instruction quoted inside a fence is an example of one, not one.
357
418
  if (data && !fenced) {
358
419
  const m = INJECTION.find((re) => re.test(line));
359
420
  if (m)
360
- push("content/injection-phrase", file.path, i + 1, line, `"${line.trim().slice(0, 90)}" — agents read this file as data, and this line reads as an order`);
421
+ push("content/injection-phrase", file.path, i + 1, line, `"${redactText(line.trim()).slice(0, 90)}" — agents read this file as data, and this line reads as an order`);
361
422
  }
362
423
  if (md) {
363
424
  for (const b of line.matchAll(BLOB)) {
@@ -422,28 +483,6 @@ function unpinned(pkg) {
422
483
  return !/[=<>~!]/.test(s) && !/.@./.test(s.replace(/^@/, "x"));
423
484
  }
424
485
  // ── frontmatter ────────────────────────────────────────────────────────────
425
- /** What foldrun reads in an agent's frontmatter — the fields documented in
426
- * agents.md, the Claude Code subagent fields an import carries, and the
427
- * older spellings check already teaches about. Used only to suggest the
428
- * field a typo meant: an unknown key that is nothing like any of these is
429
- * left alone, since another tool may own it. */
430
- const AGENT_FIELDS = [
431
- "name", "description", "model", "effort", "size", "tools", "disallowedTools", "skills", "hooks", "scripts",
432
- "apis", "mcpServers", "agents", "subagents", "secrets", "limits", "network", "provider", "permissionMode",
433
- "runtime", "ask", "web", "budget", "timezone", "language", "region", "currency", "units", "schedule", "color",
434
- "memory", "foldrun_version", "use", "kind", "type", "notify", "triggers", "inputs", "outputs",
435
- ];
436
- const FLOW_FIELDS = [
437
- "name", "description", "trigger", "schedule", "timezone", "language", "region", "currency", "units", "model",
438
- "effort", "overlap", "priority", "throttle", "debounce", "sla", "catchup", "pause_when", "disable_after",
439
- "approvers", "approve_within", "notify", "at", "every", "budget", "limits", "size", "hooks", "inputs",
440
- "webhook", "signing_secret", "signature", "idempotency", "filter", "after", "tags", "network", "secrets",
441
- ];
442
- const TOOL_FIELDS = [
443
- "name", "description", "transport", "type", "base", "methods", "headers", "query", "auth", "run", "args",
444
- "command", "url", "env", "outward", "runtime", "secrets", "network", "timeout", "spec", "operations", "body",
445
- "oauth", "limits", "input", "output", "schema", "kind",
446
- ];
447
486
  function editDistance(a, b) {
448
487
  const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
449
488
  for (let j = 1; j <= b.length; j++)
@@ -502,15 +541,13 @@ function docKind(p) {
502
541
  }
503
542
  // ── agents: what one can reach, taken together ─────────────────────────────
504
543
  const WRITE_METHODS = /\b(?:POST|PUT|PATCH|DELETE)\b/i;
505
- function scanAgents(agents, outward, toolDefs, push) {
544
+ function scanAgents(agents, outward, toolDefs, push, networks) {
506
545
  const byName = new Map(agents.map((a) => [a.name, a]));
507
546
  for (const a of agents) {
508
547
  const d = a.data;
509
548
  const tools = refNames(d.tools);
510
549
  const denied = new Set(refNames(d.disallowedTools));
511
550
  const has = (t) => tools.includes(t) && !denied.has(t);
512
- const network = d.network;
513
- const netLevel = typeof network === "string" ? network : Array.isArray(network) ? "list" : network && typeof network === "object" ? "list" : null;
514
551
  // Untrusted: it reads the open web. (A webhook body is the other source;
515
552
  // that is a flow's trigger, and the flow-level rule is a later one.)
516
553
  const readsWeb = has("web") || tools.some((t) => /browse|fetch|scrape|crawl/i.test(t) && has(t));
@@ -519,16 +556,18 @@ function scanAgents(agents, outward, toolDefs, push) {
519
556
  const apis = Array.isArray(d.apis) ? d.apis : [];
520
557
  const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers) : [];
521
558
  const privateLeg = [...secrets.map((s) => `secret ${s}`), ...apis.map((x) => `api ${x?.name ?? "?"}`), ...mcp.map((m) => `MCP ${m}`)];
522
- // Out: a sandbox open to every site (an agent that reads the web is open
523
- // unless it says otherwise), a tool marked outward, or an API it may write to.
524
- const openNet = netLevel === "open" || (readsWeb && netLevel === null);
559
+ // Out: a sandbox open to every site, a tool marked outward, or an API it
560
+ // may write to. The level is the runtime's own resolution — agent, then
561
+ // workspace, then account, and open by default for an agent that reads
562
+ // the web; a bare list of sites adds to a level, it does not set one.
563
+ const openNet = resolveNetwork({ account: networks.account, workspace: networks.workspace, agent: d.network, readsWeb }).level === "open";
525
564
  const outwardTools = tools.filter((t) => outward.has(t) && !denied.has(t));
526
565
  const writeApis = apis.filter((x) => WRITE_METHODS.test(JSON.stringify(x?.methods ?? x?.method ?? "")));
527
566
  const outLeg = [...(openNet ? ["an open network"] : []), ...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x) => `writes to api ${x?.name ?? "?"}`)];
528
567
  const gated = refNames(d.hooks).length > 0 || d.permissionMode === "plan";
529
568
  if (readsWeb && privateLeg.length && outLeg.length && !gated) {
530
569
  push("security/lethal-trifecta", a.file, a.line, `trifecta ${a.name}`, `${a.name} reads the web, holds ${privateLeg.slice(0, 3).join(", ")}${privateLeg.length > 3 ? ", …" : ""} and can reach ${outLeg.slice(0, 2).join(" and ")} — ` +
531
- `a page it reads can tell it where to send what it holds. Narrow network:, split it, or add a hooks: gate`);
570
+ `a page it reads can tell it where to send what it holds. Take it off the open network (network: { level: trusted, allow: [...] }), split it, or add a hooks: gate`);
532
571
  }
533
572
  // Sub-agents declaring tools the parent lacks.
534
573
  for (const s of refNames(d.subagents)) {
@@ -621,7 +660,7 @@ export function scanFiles(files, options = {}) {
621
660
  const findings = [];
622
661
  const seen = new Set();
623
662
  const ignoresByFile = new Map();
624
- const push = (rule, file, line, text, message) => {
663
+ const push = (rule, file, line, text, message, level) => {
625
664
  if (!enabled(rule))
626
665
  return;
627
666
  const ig = ignoresByFile.get(file);
@@ -631,7 +670,7 @@ export function scanFiles(files, options = {}) {
631
670
  if (seen.has(key))
632
671
  return;
633
672
  seen.add(key);
634
- findings.push({ rule, level: scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
673
+ findings.push({ rule, level: level ?? scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
635
674
  };
636
675
  const outward = new Set(options.outwardTools ?? []);
637
676
  const agents = [];
@@ -642,18 +681,23 @@ export function scanFiles(files, options = {}) {
642
681
  scanSecrets(f, lines, push);
643
682
  scanContent(f, lines, push);
644
683
  scanSupplyChain(f, lines, push);
645
- const kind = docKind(f.path);
646
- if (!kind)
647
- continue;
648
- const data = frontOf(f.content);
684
+ const schema = schemaKindOf(f.path);
685
+ // A skill with no frontmatter at all is the clearest one that never
686
+ // loads: read it as empty, so its required fields are reported.
687
+ const data = schema || docKind(f.path) ? (frontOf(f.content) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
649
688
  if (!data)
650
689
  continue;
651
- const known = kind === "agent" ? AGENT_FIELDS : kind === "flow" ? FLOW_FIELDS : TOOL_FIELDS;
652
- for (const key of Object.keys(data)) {
653
- const near = nearestField(key, known);
654
- if (near && near !== key)
655
- push("config/unknown-field", f.path, keyLine(lines, key), `field ${key}`, `\`${key}:\` is not a field foldrun reads — did you mean \`${near}:\`? As written it is ignored`);
690
+ if (schema) {
691
+ for (const p of frontmatterProblems(schema, data, nearestField)) {
692
+ // Fingerprint the key's own line, not the parsed value: a YAML alias
693
+ // can make the value circular, and JSON of it would throw.
694
+ const at = keyLine(lines, p.key);
695
+ push(p.rule, f.path, at, `${p.rule} ${at ? lines[at - 1] : p.key}`, p.message, p.level);
696
+ }
656
697
  }
698
+ const kind = docKind(f.path);
699
+ if (!kind)
700
+ continue;
657
701
  if (kind === "agent") {
658
702
  const rel = f.path.replace(/^library\//, "");
659
703
  const name = /^agents\/([^/]+)\//.exec(rel)?.[1] ?? rel.replace(/^.*\/|\.md$/g, "");
@@ -666,7 +710,8 @@ export function scanFiles(files, options = {}) {
666
710
  toolDefs.set(f.path, { data, lines });
667
711
  }
668
712
  }
669
- scanAgents(agents, outward, toolDefs, push);
713
+ const workspaceMd = files.find((f) => f.path === "AGENTS.md");
714
+ scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
670
715
  return findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
671
716
  }
672
717
  /**
@@ -698,7 +743,9 @@ export function applyBaseline(findings, baseline) {
698
743
  const b = baseline;
699
744
  if (!b || b.version !== 1 || !Array.isArray(b.findings))
700
745
  return { kept: findings, baselined: 0 };
701
- const known = new Set(b.findings.map((f) => `${f.rule}\0${f.file}\0${f.fingerprint}`));
746
+ // An entry that is not {rule, file, fingerprint} hides nothing.
747
+ const entries = b.findings.filter((f) => !!f && typeof f === "object" && typeof f.rule === "string" && typeof f.file === "string" && typeof f.fingerprint === "string");
748
+ const known = new Set(entries.map((f) => `${f.rule}\0${f.file}\0${f.fingerprint}`));
702
749
  const kept = findings.filter((f) => !known.has(`${f.rule}\0${f.file}\0${f.fingerprint}`));
703
750
  return { kept, baselined: findings.length - kept.length };
704
751
  }
@@ -1,3 +1,4 @@
1
+ import { EFFORT_LEVELS, FLOW_TRIGGERS } from "./frontmatter-fields.ts";
1
2
  import type { BrowserPodOutcome } from "./browser-pod.ts";
2
3
  import { singleWorkspace } from "./paths.ts";
3
4
  import type { TestEffect } from "./test-mode.ts";
@@ -354,7 +355,7 @@ export type Tier = keyof typeof MODEL_TIERS;
354
355
  * `model:` does — one vocabulary, not two. */
355
356
  export declare function resolveTier(value: unknown): Tier | null;
356
357
  export declare function resolveModel(value: unknown): string;
357
- export declare const EFFORT_LEVELS: readonly ["low", "medium", "high", "xhigh", "max"];
358
+ export { EFFORT_LEVELS } from "./frontmatter-fields.ts";
358
359
  export type Effort = (typeof EFFORT_LEVELS)[number];
359
360
  /**
360
361
  * `null` for both "not set" and "not a level we know" — unlike a model, the
@@ -607,7 +608,7 @@ export interface FlowInfo {
607
608
  frontProblems: string[];
608
609
  steps: FlowStep[];
609
610
  }
610
- export declare const FLOW_TRIGGERS: readonly ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"];
611
+ export { FLOW_TRIGGERS } from "./frontmatter-fields.ts";
611
612
  export type FlowTrigger = (typeof FLOW_TRIGGERS)[number];
612
613
  export interface AgentAssets {
613
614
  skills: string[];
@@ -615,6 +616,12 @@ export interface AgentAssets {
615
616
  }
616
617
  export declare function agentAssets(tenant: string, workspace: string, agent: string): AgentAssets;
617
618
  export declare function listAgents(tenant: string, workspace: string): AgentInfo[];
619
+ /** The option keys a step actually has. An indented `word: rest` that is
620
+ * NOT one of these is a line of the instruction — "Warning: do not
621
+ * publish" is prose that happens to contain a colon, and it used to be
622
+ * swallowed as an unknown option, silently, along with any typo of a real
623
+ * key. The consistency suite reads this list against the docs. */
624
+ export declare const STEP_OPTION_KEYS: ReadonlySet<string>;
618
625
  /** The most fan-out instances `parallel:` may run at once — the fan-out's
619
626
  * own hard cap, since more slots than items is no cap. */
620
627
  export declare const PARALLEL_CAP = 20;
package/dist/src/store.js CHANGED
@@ -20,6 +20,7 @@
20
20
  //
21
21
  // An account is a named directory (per OKF: tenancy is a hosting concern).
22
22
  import { expandPlatformVars } from "./host-env.js";
23
+ import { FLOW_TRIGGERS } from "./frontmatter-fields.js";
23
24
  import fs from "node:fs";
24
25
  import path from "node:path";
25
26
  import { recordRevision, registerTreeReader } from "./history.js";
@@ -899,7 +900,7 @@ export function resolveModel(value) {
899
900
  // model is which brain, effort is how long it thinks before answering. The
900
901
  // five levels are the SDK's own union — we neither invent our own scale nor
901
902
  // narrow theirs, so a level added upstream is one line here.
902
- export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
903
+ export { EFFORT_LEVELS } from "./frontmatter-fields.js";
903
904
  const EFFORT_ALIASES = {
904
905
  low: "low",
905
906
  min: "low",
@@ -1020,7 +1021,8 @@ export function parseApis(raw) {
1020
1021
  }
1021
1022
  return out;
1022
1023
  }
1023
- export const FLOW_TRIGGERS = ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"];
1024
+ // The list lives with every other frontmatter field (frontmatter-fields.ts).
1025
+ export { FLOW_TRIGGERS } from "./frontmatter-fields.js";
1024
1026
  export function agentAssets(tenant, workspace, agent) {
1025
1027
  const base = path.join(workspaceDir(tenant, workspace), "agents", agent);
1026
1028
  const nameOf = (file, fallback) => {
@@ -1177,7 +1179,7 @@ const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
1177
1179
  * publish" is prose that happens to contain a colon, and it used to be
1178
1180
  * swallowed as an unknown option, silently, along with any typo of a real
1179
1181
  * key. The consistency suite reads this list against the docs. */
1180
- const STEP_OPTION_KEYS = new Set([
1182
+ export const STEP_OPTION_KEYS = new Set([
1181
1183
  "approve", "ask", "case", "delegate", "each", "effort", "else", "limits", "loop", "max", "max_turns", "model",
1182
1184
  "on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
1183
1185
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  // renders them.
11
11
 
12
12
  import { PROVIDERS } from "./providers.ts";
13
+ import { FRONTMATTER, type SchemaKind } from "./frontmatter-fields.ts";
13
14
 
14
15
  export interface Vocabulary {
15
16
  agents: string[];
@@ -207,8 +208,6 @@ const FIELDS: Record<string, Completion[]> = {
207
208
  { label: "trigger", insert: "trigger: manual" },
208
209
  { label: "schedule", insert: "schedule: \"0 6 * * MON\"" },
209
210
  { label: "timezone", insert: "timezone: Australia/Sydney" },
210
- { label: "language", insert: "language: en-AU" },
211
- { label: "region", insert: "region: au", hint: "currency, units, calendar, weekend and text direction follow" },
212
211
  { label: "model", insert: "model: fast", hint: "every step, unless the step says" },
213
212
  { label: "effort", insert: "effort: high", hint: "every step, unless the step says" },
214
213
  ],
@@ -264,6 +263,27 @@ export function fileKind(path: string): FileKind {
264
263
  return "note";
265
264
  }
266
265
 
266
+ /** Which frontmatter schema a completion kind offers. */
267
+ const SCHEMA_OF: Partial<Record<FileKind, SchemaKind>> = { agent: "agent", flow: "flow", tool: "tool", skill: "skill", evalFile: "eval" };
268
+
269
+ /**
270
+ * The fields to offer at the start of a frontmatter line: the hand-written
271
+ * snippets above where a field has one, then every other field the schema
272
+ * says this kind of file takes — so a field added to frontmatter-fields.ts is
273
+ * offered here with no second edit, and a snippet for a field the runtime no
274
+ * longer reads is dropped. AGENTS.md takes its own set, not an agent's.
275
+ */
276
+ function fieldsFor(path: string, kind: FileKind): Completion[] {
277
+ const agentsMd = /(^|\/)AGENTS\.md$/.test(path) || path === "project.md";
278
+ const schema = agentsMd ? FRONTMATTER["agents-md"] : SCHEMA_OF[kind] ? FRONTMATTER[SCHEMA_OF[kind]!] : null;
279
+ if (!schema) return FIELDS[kind];
280
+ const offered = schema.filter((f) => !f.legacy);
281
+ const keys = new Set(offered.map((f) => f.key));
282
+ const curated = FIELDS[kind].filter((c) => keys.has(c.label.split(".")[0]!));
283
+ const have = new Set(curated.map((c) => c.label.split(".")[0]));
284
+ return [...curated, ...offered.filter((f) => !have.has(f.key)).map((f) => ({ label: f.key, insert: `${f.key}: `, hint: f.help }))];
285
+ }
286
+
267
287
  /**
268
288
  * Does [[ ]] in this file get expanded into a path, or stay literal?
269
289
  *
@@ -654,9 +674,9 @@ export function completionsAt(
654
674
  // this kind of file. This is where you learn the format by writing it.
655
675
  const fmEnd = text.indexOf("\n---", text.indexOf("---") + 3);
656
676
  const inFrontmatter = text.startsWith("---") && (fmEnd === -1 || cursor <= fmEnd);
657
- if (inFrontmatter && /^[a-zA-Z_]*$/.test(line)) {
677
+ if (inFrontmatter && /^[a-zA-Z_][a-zA-Z_-]*$|^$/.test(line)) {
658
678
  const from = cursor - line.length;
659
- return { from, query: line, items: filter(FIELDS[kind], line), title: `${kind} fields` };
679
+ return { from, query: line, items: filter(fieldsFor(path, kind), line), title: `${kind} fields` };
660
680
  }
661
681
 
662
682
  return null;
package/src/cron.ts ADDED
@@ -0,0 +1,120 @@
1
+ // Cron: the one reading of a flow's `schedule:`.
2
+ //
3
+ // Moved here from the platform's scheduler so `foldrun check`, the deploy and
4
+ // the scheduler ask the same function whether a schedule is one — a cron the
5
+ // scheduler cannot parse used to be skipped silently, and nothing earlier
6
+ // said so. The scheduler imports this; the expansion is unchanged.
7
+
8
+
9
+ export const NAMED: Record<string, string> = {
10
+ "@hourly": "0 * * * *",
11
+ "@daily": "0 0 * * *",
12
+ "@midnight": "0 0 * * *",
13
+ "@weekly": "0 0 * * 0",
14
+ "@monthly": "0 0 1 * *",
15
+ };
16
+
17
+ export const DOW_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
18
+ const MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
19
+
20
+ // Expand one cron field ("*", "*/15", "1-5", "mon,fri", "3") to a value set.
21
+ function expandField(field: string, min: number, max: number, names?: string[]): Set<number> | null {
22
+ const out = new Set<number>();
23
+ for (const part of field.split(",")) {
24
+ const [rangePart, stepPart] = part.split("/");
25
+ const step = stepPart ? Number(stepPart) : 1;
26
+ if (!Number.isInteger(step) || step < 1) return null;
27
+
28
+ let lo = min;
29
+ let hi = max;
30
+ if (rangePart !== "*") {
31
+ const bounds = rangePart.split("-");
32
+ const parse = (raw: string): number | null => {
33
+ const token = raw.trim().toLowerCase();
34
+ if (names) {
35
+ // A name counts from the field's own minimum: SUN is 0 on the
36
+ // weekday field, JAN is 1 on the month field. Indexed from zero,
37
+ // JAN read as 0 (refused — the flow never fired) and FEB…DEC as
38
+ // the month before.
39
+ const idx = names.indexOf(token);
40
+ if (idx !== -1) return min + idx;
41
+ }
42
+ const n = Number(token);
43
+ return Number.isInteger(n) ? n : null;
44
+ };
45
+ const a = parse(bounds[0]);
46
+ if (a === null) return null;
47
+ lo = a;
48
+ if (bounds.length > 1) {
49
+ const b = parse(bounds[1]);
50
+ if (b === null) return null;
51
+ hi = b;
52
+ } else {
53
+ hi = stepPart ? max : a; // "5" → just 5; "5/10" → 5,15,25...
54
+ }
55
+ }
56
+ if (lo < min || hi > max || lo > hi) return null;
57
+ for (let v = lo; v <= hi; v += step) out.add(v);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ export interface Cron {
63
+ minute: Set<number>;
64
+ hour: Set<number>;
65
+ dom: Set<number>;
66
+ month: Set<number>;
67
+ dow: Set<number>;
68
+ domRestricted: boolean;
69
+ dowRestricted: boolean;
70
+ /** Neither minute nor hour is a wildcard: a job at a time of day, which
71
+ * is what the DST rules in cronMatches apply to (as in Vixie cron). */
72
+ fixedTime: boolean;
73
+ }
74
+
75
+ /** `@daily` → its five fields; anything else as written. An own-property
76
+ * lookup, so `schedule: constructor` is a bad cron, not a crash. */
77
+ function expand(expr: string): string {
78
+ const key = expr.trim().toLowerCase();
79
+ return (Object.hasOwn(NAMED, key) ? NAMED[key]! : expr).trim();
80
+ }
81
+
82
+ export function parseCron(expr: string): Cron | null {
83
+ if (typeof expr !== "string") return null;
84
+ const normalized = expand(expr);
85
+ const fields = normalized.split(/\s+/);
86
+ if (fields.length !== 5) return null;
87
+ const [mi, ho, dm, mo, dw] = fields;
88
+
89
+ const minute = expandField(mi, 0, 59);
90
+ const hour = expandField(ho, 0, 23);
91
+ const dom = expandField(dm, 1, 31);
92
+ const month = expandField(mo, 1, 12, MONTH_NAMES);
93
+ const dowRaw = expandField(dw, 0, 7, DOW_NAMES);
94
+ if (!minute || !hour || !dom || !month || !dowRaw) return null;
95
+
96
+ // cron allows both 0 and 7 for Sunday.
97
+ const dowSet = new Set([...dowRaw].map((d) => (d === 7 ? 0 : d)));
98
+
99
+ return {
100
+ minute,
101
+ hour,
102
+ dom,
103
+ month,
104
+ dow: dowSet,
105
+ domRestricted: dm.trim() !== "*",
106
+ dowRestricted: dw.trim() !== "*",
107
+ fixedTime: !mi.startsWith("*") && !ho.startsWith("*"),
108
+ };
109
+ }
110
+
111
+
112
+ /** Why `schedule:` is not a cron the scheduler can run, or null when it is. */
113
+ export function cronProblem(expr: unknown): string | null {
114
+ if (typeof expr !== "string" || !expr.trim()) return "schedule: is a cron expression — five fields, or @hourly, @daily, @weekly, @monthly";
115
+ if (parseCron(expr)) return null;
116
+ const fields = expand(expr).split(/\s+/).length;
117
+ return fields !== 5
118
+ ? `schedule: "${expr}" has ${fields} field${fields === 1 ? "" : "s"}; cron has five — minute hour day-of-month month day-of-week ("0 9 * * MON"), or @hourly, @daily, @weekly, @monthly. As written the flow never fires`
119
+ : `schedule: "${expr}" is not a cron the scheduler can read — a field is out of range or not a number (minute 0-59, hour 0-23, day 1-31, month 1-12, weekday 0-7 or sun-sat). As written the flow never fires`;
120
+ }