@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 +12 -0
- package/README.md +2 -2
- package/index.mjs +0 -1
- package/package.json +2 -2
- package/src/eslint-plugin.mjs +1 -1
- package/src/report.mjs +10 -12
- package/src/rules.mjs +89 -103
- package/src/cron.mjs +0 -131
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 `
|
|
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,
|
|
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.
|
|
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
|
|
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": {
|
package/src/eslint-plugin.mjs
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
54
|
-
'
|
|
55
|
-
'
|
|
56
|
-
'
|
|
57
|
-
'
|
|
58
|
-
'
|
|
59
|
-
'
|
|
60
|
-
'
|
|
61
|
-
'
|
|
62
|
-
'
|
|
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', '
|
|
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
|
|
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', '
|
|
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),
|
|
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
|
-
// ---
|
|
914
|
+
// --- workflows -----------------------------------------------------------
|
|
980
915
|
|
|
981
|
-
function
|
|
982
|
-
const dir = join(entry.dir, NAMESPACE, '
|
|
983
|
-
const
|
|
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
|
|
986
|
-
|
|
987
|
-
|
|
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
|
-
//
|
|
991
|
-
const
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
|
1002
|
-
const
|
|
1003
|
-
if (!
|
|
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
|
|
1013
|
-
if (
|
|
1014
|
-
report.error('
|
|
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 (
|
|
1017
|
-
report.
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
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
|
-
}
|