@subbly/plugin-lint 0.0.7 → 0.0.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @subbly/plugin-lint
2
2
 
3
+ ## 0.0.9
4
+
5
+ ### Patch Changes
6
+
7
+ - A plugin workflow is now a folder, as in the builder: `co.subbly.builder/workflows/<slug>/` holds `workflow.json`, `nodes/<id>.js` for each code node, and optional `fixtures/<name>.json`. The folder is the declaration, so a manifest that still lists `workflows` fails rule `manifest/namespace-unknown-key`, and a `<slug>.md` file fails `workflow/stray-file`. New rules check the folder name (`workflow/folder-slug`, `workflow/slug-length`), the definition file (`workflow/missing-file`, `workflow/parse`, `workflow/package-json`, `workflow/code-file`), and the keys a node names (`workflow/env`, `workflow/credential`). The cron rules and the `schedule` and `model` checks are gone with the old declaration. The linter does not run the builder's full workflow check, so a clean lint is not yet a release.
8
+
9
+ ## 0.0.8
10
+
11
+ ### Patch Changes
12
+
13
+ - Plugin automations are now workflows, as in the builder. The manifest key is `extensions["co.subbly.builder"].workflows` and the instruction files live in `co.subbly.builder/workflows/<slug>.md`. The rule ids `automation/*` are now `workflow/*`. A manifest that still declares `automations` fails rule `manifest/namespace-unknown-key`, as the builder refresh rejects it.
14
+
3
15
  ## 0.0.7
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -58,7 +58,7 @@ export default [
58
58
  ]
59
59
  ```
60
60
 
61
- The gate is cross-file: whether a `plugin.json` is valid depends on `marketplace.json`, and a missing `automations/<slug>.md` is a defect in a file that does not exist. ESLint visits one existing file at a time, so the engine runs once per invocation and hands each file the findings that belong to it. Every rule reaches the editor, absence checks included.
61
+ The gate is cross-file: whether a `plugin.json` is valid depends on `marketplace.json`, and a missing `workflows/<slug>/workflow.json` is a defect in a file that does not exist. ESLint visits one existing file at a time, so the engine runs once per invocation and hands each file the findings that belong to it. Every rule reaches the editor, absence checks included.
62
62
 
63
63
  ## Library
64
64
 
@@ -74,7 +74,7 @@ console.log(report.errors.length)
74
74
 
75
75
  ## What it checks
76
76
 
77
- `marketplace.json` and every plugin manifest, `mcp.json` servers, skill and agent frontmatter, automation declarations against their files, cron floors, script lockfiles, plugin layout, and sandbox paths written in prose. Run `subbly-plugin-lint --rules` for the full list.
77
+ `marketplace.json` and every plugin manifest, `mcp.json` servers, skill and agent frontmatter, workflow folders and the keys their nodes name, script lockfiles, plugin layout, and sandbox paths written in prose. Run `subbly-plugin-lint --rules` for the full list.
78
78
 
79
79
  ## Two severities
80
80
 
package/index.mjs CHANGED
@@ -8,4 +8,3 @@
8
8
 
9
9
  export { lint, envVarName, PLUGIN_SCHEMA_URL, MCP_SCHEMA_URL, NAMESPACE, AGENT_TOOLS, DEFAULT_ENV_KEYS, CURRENT_LINK } from './src/rules.mjs'
10
10
  export { RULES, FORMATTERS, Report } from './src/report.mjs'
11
- export { SCHEDULE_FLOOR_MINUTES, parseCron, meetsFloor, CronUnsupported } from './src/cron.mjs'
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@subbly/plugin-lint",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "The Subbly marketplace release gate, offline. Lints marketplace.json, plugin manifests, mcp.json, skills, agents and automations.",
6
+ "description": "The Subbly marketplace release gate, offline. Lints marketplace.json, plugin manifests, mcp.json, skills, agents and workflows.",
7
7
  "license": "MIT",
8
8
  "author": "Subbly LTD <oleg@subbly.co> (https://www.subbly.co)",
9
9
  "publishConfig": {
@@ -1,7 +1,7 @@
1
1
  // ESLint bridge.
2
2
  //
3
3
  // The release gate is cross-file: whether plugin.json is valid depends on
4
- // marketplace.json, and a missing automations/<slug>.md is a defect in a file
4
+ // marketplace.json, and a missing workflows/<slug>.md is a defect in a file
5
5
  // that does not exist. ESLint visits one existing file at a time, so rules
6
6
  // cannot be written that way directly.
7
7
  //
package/src/report.mjs CHANGED
@@ -50,18 +50,16 @@ export const RULES = {
50
50
  'field/default-required': 'a default plugin cannot declare required fields',
51
51
  'field/reserved-key': 'a field key must not restate the PLUGIN_ prefix the builder adds',
52
52
 
53
- // automations
54
- 'automation/key-slug': 'automation keys must be valid slugs',
55
- 'automation/unknown-key': 'a declaration holds exactly name, schedule and model',
56
- 'automation/name': 'name is required, 1-100 characters',
57
- 'automation/schedule-syntax': 'schedule must be a cron expression the builder can parse',
58
- 'automation/schedule-floor': 'schedule must fire at most every 15 minutes',
59
- 'automation/schedule-unverified': 'this cron syntax cannot be checked offline',
60
- 'automation/model': 'model must be normal or intelligent-high',
61
- 'automation/missing-file': 'every declared automation needs its markdown file',
62
- 'automation/orphan-file': 'every automation file needs a declaration',
63
- 'automation/empty-file': 'an automation file must not be empty',
64
- 'automation/frontmatter': 'automation frontmatter is not stripped and leaks into the instruction',
53
+ // workflows
54
+ 'workflow/folder-slug': 'a workflow folder is named by a valid slug',
55
+ 'workflow/slug-length': '<plugin-slug>__<slug> must be at most 64 characters',
56
+ 'workflow/stray-file': 'the workflows folder holds only workflow folders',
57
+ 'workflow/missing-file': 'a workflow folder needs workflow.json',
58
+ 'workflow/parse': 'workflow.json must be a JSON object',
59
+ 'workflow/package-json': 'a workflow folder has no package.json',
60
+ 'workflow/env': 'a code node reads only the config fields of its own plugin',
61
+ 'workflow/credential': "a node names only a credential of the plugin's own connectors",
62
+ 'workflow/code-file': 'a code node needs its file',
65
63
  'setup/missing-install-skill': 'setup: true requires a skills/install skill',
66
64
 
67
65
  // connectors and mcp.json
package/src/rules.mjs CHANGED
@@ -3,7 +3,6 @@
3
3
  // Mirrors subbly-builder branch feat/plugins-mcp:
4
4
  // packages/plugins/src/marketplace/{marketplace-manifest,plugin-manifest,mcp-config}.ts
5
5
  // packages/core/src/types/plugin-manifest.ts
6
- // packages/core/src/lib/automations/schedule.ts
7
6
  //
8
7
  // error = the refresh throws, so NO plugin in the marketplace releases.
9
8
  // warn = the builder accepts it, but it is wrong, dead, or broken at runtime.
@@ -15,7 +14,6 @@ import { execFileSync } from 'node:child_process'
15
14
  import { Report } from './report.mjs'
16
15
  import { parseJsonSource, locator } from './json-source.mjs'
17
16
  import { readFrontmatter, lineOf, colOf } from './frontmatter.mjs'
18
- import { parseCron, meetsFloor, CronUnsupported, SCHEDULE_FLOOR_MINUTES } from './cron.mjs'
19
17
 
20
18
  // --- constants lifted verbatim from the builder -----------------------------
21
19
 
@@ -34,10 +32,9 @@ const CONNECTOR_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,49}$/
34
32
  const CONTENT_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i
35
33
 
36
34
  const MANIFEST_KEYS = ['$schema', 'name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords', 'extensions']
37
- const NAMESPACE_KEYS = ['displayName', 'image', 'default', 'fields', 'automations', 'setup', 'connectors']
35
+ const NAMESPACE_KEYS = ['displayName', 'image', 'default', 'fields', 'setup', 'connectors']
38
36
  const FIELD_KEYS = ['key', 'label', 'description', 'type', 'required', 'secret', 'public', 'pattern', 'options']
39
- const AUTOMATION_KEYS = ['name', 'schedule', 'model']
40
- const AUTOMATION_MODELS = ['normal', 'intelligent-high']
37
+ const WORKFLOW_SLUG_MAX = 64
41
38
  const SERVER_KEYS = ['type', 'url', 'headers']
42
39
 
43
40
  export const AGENT_TOOLS = ['read_file', 'write_file', 'edit_file', 'apply_patch', 'execute_command', 'get_stock_image', 'web_fetch', 'web_search', 'generate_image', 'restart_preview', 'apply_to_preview', 'tool_run', 'tool_search', 'tool_describe', 'skill']
@@ -53,7 +50,7 @@ export const DEFAULT_ENV_KEYS = [
53
50
 
54
51
  const RESERVED_SLUG = 'custom'
55
52
 
56
- const MISPLACED = ['agents', 'automations', 'scripts', 'instructions.md']
53
+ const MISPLACED = ['agents', 'workflows', 'scripts', 'instructions.md']
57
54
 
58
55
  // The builder-maintained symlink to the installed version, the only stable
59
56
  // segment prose may use in a sandbox path.
@@ -409,7 +406,6 @@ function lintPluginManifest(root, entry, report) {
409
406
  check.boolean('manifest/boolean', `${ns}.setup`, ext.setup)
410
407
 
411
408
  const fields = lintFields(report, file, at, check, ns, ext, entry.slug)
412
- const automations = lintAutomationDeclarations(report, file, at, check, ns, ext)
413
409
  const connectors = lintConnectors(report, file, at, check, ns, ext)
414
410
 
415
411
  if (ext.default === true && fields.some((f) => f.required === true)) {
@@ -421,7 +417,7 @@ function lintPluginManifest(root, entry, report) {
421
417
  )
422
418
  }
423
419
 
424
- return { fieldKeys: fields.map((f) => f.key).filter(Boolean), automations, connectors, setup: ext.setup === true }
420
+ return { fields, fieldKeys: fields.map((f) => f.key).filter(Boolean), connectors, setup: ext.setup === true }
425
421
  }
426
422
 
427
423
  function lintFields(report, file, at, check, ns, ext, slug) {
@@ -510,67 +506,6 @@ function lintFields(report, file, at, check, ns, ext, slug) {
510
506
  return ext.fields.filter(isObject)
511
507
  }
512
508
 
513
- function lintAutomationDeclarations(report, file, at, check, ns, ext) {
514
- if (ext.automations === undefined) return []
515
- if (!isObject(ext.automations)) {
516
- report.error('automation/key-slug', file, `${ns}.automations: expected object keyed by slug, received ${typeOf(ext.automations)}`, at(`${ns}.automations`))
517
- return []
518
- }
519
-
520
- const slugs = []
521
- for (const [slug, decl] of Object.entries(ext.automations)) {
522
- const p = `${ns}.automations.${slug}`
523
- const label = `automations["${slug}"]`
524
-
525
- if (slug.length < 1 || slug.length > 64 || !SLUG_PATTERN.test(slug)) {
526
- report.error('automation/key-slug', file, `${label}: key ${SLUG_MESSAGE}`, at(p, { key: true }))
527
- continue
528
- }
529
- if (!isObject(decl)) {
530
- report.error('automation/unknown-key', file, `${label}: expected object, received ${typeOf(decl)}`, at(p))
531
- continue
532
- }
533
-
534
- check.strictKeys('automation/unknown-key', p, decl, AUTOMATION_KEYS, label)
535
- check.string('automation/name', `${p}.name`, decl.name, { min: 1, max: 100, trim: true, required: true, label: `${label}.name` })
536
-
537
- if (check.string('automation/schedule-syntax', `${p}.schedule`, decl.schedule, { min: 1, max: 100, required: true, label: `${label}.schedule` })) {
538
- lintSchedule(report, file, at(`${p}.schedule`), label, decl.schedule)
539
- }
540
-
541
- if (!AUTOMATION_MODELS.includes(decl.model)) {
542
- report.error(
543
- 'automation/model',
544
- file,
545
- `${label}.model: expected ${AUTOMATION_MODELS.join(' | ')}, received ${JSON.stringify(decl.model)}`,
546
- at(`${p}.model`)
547
- )
548
- }
549
- slugs.push(slug)
550
- }
551
- return slugs
552
- }
553
-
554
- function lintSchedule(report, file, pos, label, schedule) {
555
- let cron
556
- try {
557
- cron = parseCron(schedule)
558
- } catch (e) {
559
- if (e instanceof CronUnsupported) {
560
- report.warn('automation/schedule-unverified', file, `${label}.schedule contains "${e.message}", which cannot be checked offline`, {
561
- ...pos,
562
- hint: 'the builder may accept it; confirm the 15-minute floor by hand',
563
- })
564
- return
565
- }
566
- report.error('automation/schedule-syntax', file, `${label}.schedule: invalid cron expression (${e.message})`, pos)
567
- return
568
- }
569
- if (!meetsFloor(cron)) {
570
- report.error('automation/schedule-floor', file, `${label}.schedule must fire at most every ${SCHEDULE_FLOOR_MINUTES} minutes`, pos)
571
- }
572
- }
573
-
574
509
  function lintConnectors(report, file, at, check, ns, ext) {
575
510
  if (ext.connectors === undefined) return []
576
511
  if (!isObject(ext.connectors)) {
@@ -976,50 +911,101 @@ function lintAgents(root, entry, report) {
976
911
  }
977
912
  }
978
913
 
979
- // --- automations -----------------------------------------------------------
914
+ // --- workflows -----------------------------------------------------------
980
915
 
981
- function lintAutomationFiles(root, entry, declared, report) {
982
- const dir = join(entry.dir, NAMESPACE, 'automations')
983
- const present = new Set()
916
+ function lintWorkflowFiles(root, entry, manifest, report) {
917
+ const dir = join(entry.dir, NAMESPACE, 'workflows')
918
+ const entries = listDir(dir)
919
+ const folders = entries.filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name)
984
920
 
985
- for (const dirent of listDir(dir)) {
986
- if (!dirent.isFile() || !dirent.name.endsWith('.md')) continue
987
- present.add(dirent.name.slice(0, -3))
921
+ for (const dirent of entries.filter((dirent) => !dirent.isDirectory())) {
922
+ report.error('workflow/stray-file', relative(root, join(dir, dirent.name)), `Invalid workflows of ${entry.slug}: the workflows folder holds one folder per workflow, and ${dirent.name} is not a folder`, {
923
+ line: 1,
924
+ hint: `move it into ${NAMESPACE}/workflows/<slug>/, or delete it`,
925
+ })
988
926
  }
989
927
 
990
- // The builder reports orphans first, so a real run can surface only this one.
991
- const orphans = [...present].filter((s) => !declared.includes(s))
992
- for (const orphan of orphans) {
993
- report.error(
994
- 'automation/orphan-file',
995
- relative(root, join(dir, `${orphan}.md`)),
996
- `Invalid automations of ${entry.slug}: files without a manifest declaration: ${orphans.join(', ')}`,
997
- { line: 1, hint: `declare it in plugin.json under extensions["${NAMESPACE}"].automations, or delete it` }
998
- )
999
- }
928
+ // A shop marketplace stores its plugins as shop-<slug>, so its keys carry that prefix.
929
+ const slugs = [entry.slug, `shop-${entry.slug}`]
930
+ const servers = Object.keys(parseJsonSource(readIfExists(join(entry.dir, 'mcp.json')) ?? '{}').value?.mcpServers ?? {})
931
+ const envKeys = slugs.flatMap((slug) => (manifest?.fields ?? []).map((field) => envVarName(slug, field.key, field.public === true)))
932
+ const credentials = slugs.flatMap((slug) => servers.map((server) => `${slug}__${server}`))
1000
933
 
1001
- for (const slug of declared) {
1002
- const path = join(dir, `${slug}.md`)
1003
- if (!present.has(slug)) {
1004
- report.error(
1005
- 'automation/missing-file',
1006
- relative(root, join(entry.dir, 'plugin.json')),
1007
- `Invalid automations of ${entry.slug}: missing automations/${slug}.md`,
1008
- { hint: `create ${NAMESPACE}/automations/${slug}.md` }
1009
- )
934
+ for (const slug of folders) {
935
+ const folder = join(dir, slug)
936
+ if (slug.length > 64 || !SLUG_PATTERN.test(slug)) {
937
+ report.error('workflow/folder-slug', relative(root, folder), `Invalid workflow ${slug} of ${entry.slug}: the folder name is the workflow slug, and it ${SLUG_MESSAGE}`, { line: 1 })
1010
938
  continue
1011
939
  }
1012
- const text = readIfExists(path) ?? ''
1013
- if (text.trim() === '') {
1014
- report.error('automation/empty-file', relative(root, path), `Invalid automations of ${entry.slug}: automations/${slug}.md is empty`, { line: 1 })
940
+ const full = `${entry.slug}__${slug}`
941
+ if (full.length > WORKFLOW_SLUG_MAX) {
942
+ report.error('workflow/slug-length', relative(root, folder), `Invalid workflow ${slug} of ${entry.slug}: the workflow slug ${full} is longer than ${WORKFLOW_SLUG_MAX} characters`, { line: 1 })
1015
943
  }
1016
- if (text.trimStart().startsWith('---')) {
1017
- report.warn('automation/frontmatter', relative(root, path), 'frontmatter here is not parsed or stripped, so it lands verbatim in the agent instruction', {
1018
- line: 1,
1019
- hint: 'name, schedule and model belong in plugin.json',
944
+ if (existsSync(join(folder, 'package.json'))) {
945
+ report.error('workflow/package-json', relative(root, join(folder, 'package.json')), 'a workflow folder has no package.json; code nodes use fetch and the platform libraries only', { line: 1 })
946
+ }
947
+
948
+ const file = relative(root, join(folder, 'workflow.json'))
949
+ const doc = readJson(join(folder, 'workflow.json'), file, report, 'workflow/parse', `workflow.json of ${entry.slug}/${slug}`)
950
+ if (doc.missing) {
951
+ report.error('workflow/missing-file', file, `Invalid workflow ${slug} of ${entry.slug}: workflow.json is missing`, { line: 1 })
952
+ continue
953
+ }
954
+ if (doc.invalid) continue
955
+ if (!isObject(doc.value)) {
956
+ report.error('workflow/parse', file, `expected object, received ${typeOf(doc.value)}`, doc.at(''))
957
+ continue
958
+ }
959
+
960
+ lintWorkflowDefinition(report, file, doc, { folder, envKeys, credentials })
961
+ }
962
+ }
963
+
964
+ function lintWorkflowDefinition(report, file, { value, at }, { folder, envKeys, credentials }) {
965
+ if (value.credentials !== undefined) {
966
+ report.error('workflow/credential', file, "a plugin workflow declares no credentials: its keys come from the fields in plugin.json and the plugin's own connectors", at('credentials', { key: true }))
967
+ }
968
+
969
+ const nodes = isObject(value.trigger) ? [{ node: value.trigger, p: 'trigger' }] : []
970
+ const collect = (list, p) => {
971
+ if (!Array.isArray(list)) return
972
+ list.forEach((node, index) => {
973
+ if (!isObject(node)) return
974
+ nodes.push({ node, p: `${p}.${index}` })
975
+ collect(node.body?.nodes, `${p}.${index}.body.nodes`)
976
+ })
977
+ }
978
+ collect(value.nodes, 'nodes')
979
+
980
+ const key = value.trigger?.input?.verification?.key
981
+ const named = [
982
+ ...nodes.map(({ node, p }) => ({ credential: node.credential, p: `${p}.credential` })),
983
+ { credential: isObject(key) && key.field !== undefined ? key.credential : undefined, p: 'trigger.input.verification.key.credential' },
984
+ ]
985
+ for (const { credential, p } of named) {
986
+ if (typeof credential === 'string' && credential.includes('__') && !credentials.includes(credential)) {
987
+ report.error('workflow/credential', file, `credential ${credential} is not a credential of this plugin's own connectors`, {
988
+ ...at(p),
989
+ hint: credentials.length > 0 ? `the plugin's own are ${credentials.join(', ')}` : 'the plugin has no mcp.json server to name',
1020
990
  })
1021
991
  }
1022
992
  }
993
+
994
+ for (const { node, p } of nodes) {
995
+ const env = Array.isArray(node.env) ? node.env : []
996
+ env.forEach((envKey, index) => {
997
+ if (!envKeys.includes(envKey)) {
998
+ report.error('workflow/env', file, `code node ${node.id} lists ${envKey} in env, but plugin.json declares no field for it`, {
999
+ ...at(`${p}.env.${index}`),
1000
+ hint: envKeys.length > 0 ? `the plugin's keys are ${envKeys.join(', ')}` : 'declare a field in plugin.json first',
1001
+ })
1002
+ }
1003
+ })
1004
+ const codeFile = typeof node.file === 'string' ? node.file : `nodes/${node.id}.js`
1005
+ if (node.type === 'code' && !existsSync(join(folder, codeFile))) {
1006
+ report.error('workflow/code-file', file, `code node ${node.id} has no file at ${codeFile}`, at(typeof node.file === 'string' ? `${p}.file` : p))
1007
+ }
1008
+ }
1023
1009
  }
1024
1010
 
1025
1011
  function lintSetup(root, entry, manifest, report) {
@@ -1179,7 +1165,7 @@ export function lint(root, { git = true } = {}) {
1179
1165
  lintMcp(root, entry, manifest, report)
1180
1166
  lintSkills(root, entry, report)
1181
1167
  lintAgents(root, entry, report)
1182
- lintAutomationFiles(root, entry, manifest?.automations ?? [], report)
1168
+ lintWorkflowFiles(root, entry, manifest, report)
1183
1169
  lintSetup(root, entry, manifest, report)
1184
1170
  lintLayout(root, entry, report)
1185
1171
  }
package/src/cron.mjs DELETED
@@ -1,131 +0,0 @@
1
- // Cron parsing and the builder 15-minute schedule floor.
2
- //
3
- // Mirrors packages/core/src/lib/automations/schedule.ts: 101 firings sampled
4
- // from 2026-01-01T00:00:00Z, rejected if any gap falls under the floor.
5
-
6
- export const SCHEDULE_FLOOR_MINUTES = 15
7
-
8
- const CRON_ALIASES = {
9
- '@hourly': '0 * * * *',
10
- '@daily': '0 0 * * *',
11
- '@weekly': '0 0 * * 0',
12
- '@monthly': '0 0 1 * *',
13
- '@yearly': '0 0 1 1 *',
14
- }
15
- const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
16
- const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
17
-
18
- export class CronUnsupported extends Error {}
19
-
20
- function expandField(spec, min, max, names) {
21
- const out = new Set()
22
- for (const part of spec.split(',')) {
23
- const piece = part.trim()
24
- if (piece === '') throw new Error('empty field element')
25
- if (/[LW#?]/i.test(piece)) throw new CronUnsupported(piece)
26
-
27
- const [rangePart, stepPart] = piece.split('/')
28
- const step = stepPart === undefined ? 1 : Number(stepPart)
29
- if (!Number.isInteger(step) || step < 1) throw new Error(`bad step "${stepPart}"`)
30
-
31
- let lo
32
- let hi
33
- if (rangePart === '*') {
34
- lo = min
35
- hi = max
36
- } else if (rangePart.includes('-')) {
37
- const [a, b] = rangePart.split('-')
38
- lo = toNumber(a, names)
39
- hi = toNumber(b, names)
40
- } else {
41
- lo = toNumber(rangePart, names)
42
- hi = stepPart === undefined ? lo : max
43
- }
44
- if (lo < min || hi > max || lo > hi) throw new Error(`value out of range in "${piece}"`)
45
- for (let v = lo; v <= hi; v += step) out.add(v)
46
- }
47
- return out
48
- }
49
-
50
- function toNumber(token, names) {
51
- const t = token.trim()
52
- if (/^\d+$/.test(t)) return Number(t)
53
- if (names) {
54
- const i = names.indexOf(t.toLowerCase().slice(0, 3))
55
- if (i !== -1) return i
56
- }
57
- throw new Error(`unrecognized value "${t}"`)
58
- }
59
-
60
- /** Returns {fields} or throws. Mirrors what cron-parser accepts, minus L/W/#. */
61
- export function parseCron(expression) {
62
- const expr = (CRON_ALIASES[expression.trim().toLowerCase()] ?? expression).trim()
63
- const parts = expr.split(/\s+/)
64
- if (parts.length !== 5 && parts.length !== 6) {
65
- throw new Error(`expected 5 or 6 fields, got ${parts.length}`)
66
- }
67
- const [sec, min, hour, dom, mon, dow] = parts.length === 6 ? parts : ['0', ...parts]
68
- const dowSet = expandField(dow, 0, 7, DAYS)
69
- if (dowSet.has(7)) dowSet.add(0)
70
- return {
71
- seconds: expandField(sec, 0, 59),
72
- minutes: expandField(min, 0, 59),
73
- hours: expandField(hour, 0, 23),
74
- dom: expandField(dom, 1, 31),
75
- months: expandField(mon, 1, 12, MONTHS),
76
- dow: dowSet,
77
- domRestricted: dom.trim() !== '*',
78
- dowRestricted: dow.trim() !== '*',
79
- }
80
- }
81
-
82
- /**
83
- * Replicates scheduleMeetsFloor: 101 firings from 2026-01-01T00:00:00Z, reject
84
- * if any gap is under 15 minutes. Iterates by day so a yearly cron stays cheap.
85
- */
86
- export function meetsFloor(cron) {
87
- if (cron.seconds.size > 1) return false // sub-minute firings
88
-
89
- const minutesOfDay = []
90
- for (const h of [...cron.hours].sort((a, b) => a - b)) {
91
- for (const m of [...cron.minutes].sort((a, b) => a - b)) minutesOfDay.push(h * 60 + m)
92
- }
93
- minutesOfDay.sort((a, b) => a - b)
94
- if (minutesOfDay.length === 0) return true
95
-
96
- // A same-day gap under the floor fails regardless of which days match.
97
- for (let i = 1; i < minutesOfDay.length; i++) {
98
- if (minutesOfDay[i] - minutesOfDay[i - 1] < SCHEDULE_FLOOR_MINUTES) return false
99
- }
100
-
101
- // Then walk real days for the wrap-around gap between consecutive fire days.
102
- const start = Date.UTC(2026, 0, 1)
103
- let firings = 0
104
- let previousDayIndex = null
105
- const DAY_LIMIT = 366 * 120
106
-
107
- for (let day = 0; day < DAY_LIMIT && firings <= 101; day++) {
108
- const date = new Date(start + day * 86400000)
109
- if (!dayMatches(cron, date)) continue
110
-
111
- if (previousDayIndex !== null) {
112
- const dayGap = (day - previousDayIndex) * 1440
113
- const wrap = dayGap - minutesOfDay[minutesOfDay.length - 1] + minutesOfDay[0]
114
- if (wrap < SCHEDULE_FLOOR_MINUTES) return false
115
- }
116
- previousDayIndex = day
117
- firings += minutesOfDay.length
118
- }
119
- return true
120
- }
121
-
122
- function dayMatches(cron, date) {
123
- if (!cron.months.has(date.getUTCMonth() + 1)) return false
124
- const domHit = cron.dom.has(date.getUTCDate())
125
- const dowHit = cron.dow.has(date.getUTCDay())
126
- // Standard cron: when both are restricted the match is OR, not AND.
127
- if (cron.domRestricted && cron.dowRestricted) return domHit || dowHit
128
- if (cron.domRestricted) return domHit
129
- if (cron.dowRestricted) return dowHit
130
- return true
131
- }