diff --git a/tools/contract-verify/README.md b/tools/contract-verify/README.md new file mode 100644 index 0000000..eb363b5 --- /dev/null +++ b/tools/contract-verify/README.md @@ -0,0 +1,33 @@ +# @evolv/contract-verify + +Verify that `nodes//CONTRACT.md`'s topic table matches the canonical +registry in `nodes//src/commands/index.js`. + +## Usage + +```bash +# verify every node with a CONTRACT.md +node tools/contract-verify/bin/contract-verify.js + +# verify one node +node tools/contract-verify/bin/contract-verify.js nodes/rotatingMachine + +# CI-friendly JSON output +node tools/contract-verify/bin/contract-verify.js --json +``` + +Exit code `1` on any drift; `0` if every checked node agrees. + +## What it checks + +1. Every `topic:` in the registry appears as a canonical row in the `## Inputs` table. +2. Every canonical row in the `## Inputs` table is registered. +3. The `aliases:` array on each descriptor matches the "Aliases" column. + +## What it does NOT check + +- Payload schemas (registry's `payloadSchema` vs. the "Payload" column). +- The "Effect" description column. +- Output ports — see `tools/output-manifest-verify/`. + +Run after touching `src/commands/index.js` or `CONTRACT.md` in any node. diff --git a/tools/contract-verify/bin/contract-verify.js b/tools/contract-verify/bin/contract-verify.js new file mode 100644 index 0000000..6ec86c9 --- /dev/null +++ b/tools/contract-verify/bin/contract-verify.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function loadRegistry(nodeDir) { + const registryPath = path.join(nodeDir, 'src/commands/index.js'); + if (!fs.existsSync(registryPath)) { + return { topics: [], aliases: new Map(), missing: true }; + } + delete require.cache[require.resolve(registryPath)]; + const descriptors = require(registryPath); + if (!Array.isArray(descriptors)) { + throw new Error(`commands/index.js must export an array; got ${typeof descriptors}`); + } + const topics = []; + const aliases = new Map(); + for (const d of descriptors) { + if (!d || typeof d.topic !== 'string') continue; + topics.push(d.topic); + aliases.set(d.topic, Array.isArray(d.aliases) ? d.aliases.slice() : []); + } + return { topics, aliases, missing: false }; +} + +function parseContractTable(contractPath) { + if (!fs.existsSync(contractPath)) return { topics: [], aliases: new Map(), missing: true }; + const src = fs.readFileSync(contractPath, 'utf8'); + const lines = src.split(/\r?\n/); + const topics = []; + const aliases = new Map(); + let inTable = false; + for (const line of lines) { + if (/^\|\s*Canonical\s*\|/i.test(line)) { inTable = true; continue; } + if (inTable && /^\|[\s-]+\|/.test(line)) continue; + if (inTable && !line.trim().startsWith('|')) { + if (line.trim() === '' || /^#/.test(line.trim())) { inTable = false; continue; } + continue; + } + if (!inTable) continue; + const cells = line.split('|').slice(1, -1).map((c) => c.trim()); + if (cells.length < 2) continue; + const canonical = stripBackticks(cells[0]); + if (!canonical) continue; + topics.push(canonical); + aliases.set(canonical, parseAliases(cells[1])); + } + return { topics, aliases, missing: false }; +} + +function stripBackticks(s) { + const m = s.match(/`([^`]+)`/); + return m ? m[1] : ''; +} + +function parseAliases(cell) { + if (!cell) return []; + if (/^[—\-–]$/.test(cell.trim())) return []; + if (/^\(legacy/i.test(cell.trim())) return []; + return [...cell.matchAll(/`([^`]+)`/g)].map((m) => m[1]); +} + +function diff(registry, contract) { + const inRegOnly = registry.topics.filter((t) => !contract.topics.includes(t)); + const inContractOnly = contract.topics.filter((t) => !registry.topics.includes(t)); + const aliasMismatch = []; + for (const topic of registry.topics) { + if (!contract.topics.includes(topic)) continue; + const ra = (registry.aliases.get(topic) || []).slice().sort(); + const ca = (contract.aliases.get(topic) || []).slice().sort(); + if (ra.join(',') !== ca.join(',')) { + aliasMismatch.push({ topic, registry: ra, contract: ca }); + } + } + return { inRegOnly, inContractOnly, aliasMismatch }; +} + +function report(nodeName, result, json) { + if (json) { + process.stdout.write(JSON.stringify({ node: nodeName, ...result }, null, 2) + '\n'); + return; + } + const lines = [`\n[${nodeName}]`]; + if (result.inRegOnly.length === 0 && result.inContractOnly.length === 0 && result.aliasMismatch.length === 0) { + lines.push(' OK — registry and CONTRACT.md agree.'); + } else { + if (result.inRegOnly.length) { + lines.push(' Registry has, CONTRACT.md missing:'); + for (const t of result.inRegOnly) lines.push(` - ${t}`); + } + if (result.inContractOnly.length) { + lines.push(' CONTRACT.md has, registry missing:'); + for (const t of result.inContractOnly) lines.push(` - ${t}`); + } + if (result.aliasMismatch.length) { + lines.push(' Alias drift:'); + for (const m of result.aliasMismatch) { + lines.push(` - ${m.topic}: registry=[${m.registry.join(',')}] vs contract=[${m.contract.join(',')}]`); + } + } + } + process.stdout.write(lines.join('\n') + '\n'); +} + +function findNodes(repoRoot) { + const nodesDir = path.join(repoRoot, 'nodes'); + return fs.readdirSync(nodesDir) + .filter((n) => fs.existsSync(path.join(nodesDir, n, 'CONTRACT.md'))) + .filter((n) => n !== 'generalFunctions') + .map((n) => path.join(nodesDir, n)); +} + +function main() { + const args = process.argv.slice(2); + const json = args.includes('--json'); + const positional = args.filter((a) => !a.startsWith('--')); + const repoRoot = path.resolve(__dirname, '../../..'); + const targets = positional.length === 0 + ? findNodes(repoRoot) + : positional.map((p) => path.resolve(p)); + let fail = false; + for (const nodeDir of targets) { + const nodeName = path.basename(nodeDir); + let registry, contract; + try { registry = loadRegistry(nodeDir); } + catch (err) { + process.stderr.write(`\n[${nodeName}] ERROR loading registry: ${err.message}\n`); + fail = true; + continue; + } + contract = parseContractTable(path.join(nodeDir, 'CONTRACT.md')); + if (registry.missing && contract.missing) { + process.stderr.write(`\n[${nodeName}] skipped — no commands/index.js and no CONTRACT.md\n`); + continue; + } + if (registry.missing) { + process.stderr.write(`\n[${nodeName}] NOTE: CONTRACT.md exists but no commands/index.js\n`); + continue; + } + if (contract.missing) { + process.stderr.write(`\n[${nodeName}] NOTE: commands/index.js exists but no CONTRACT.md\n`); + fail = true; + continue; + } + const result = diff(registry, contract); + if (result.inRegOnly.length || result.inContractOnly.length || result.aliasMismatch.length) fail = true; + report(nodeName, result, json); + } + process.exit(fail ? 1 : 0); +} + +if (require.main === module) main(); + +module.exports = { loadRegistry, parseContractTable, diff }; diff --git a/tools/contract-verify/package.json b/tools/contract-verify/package.json new file mode 100644 index 0000000..90caada --- /dev/null +++ b/tools/contract-verify/package.json @@ -0,0 +1,13 @@ +{ + "name": "@evolv/contract-verify", + "version": "0.1.0", + "private": true, + "description": "Verify nodes//CONTRACT.md topic table against nodes//src/commands/index.js", + "bin": { + "evolv-contract-verify": "bin/contract-verify.js" + }, + "scripts": { + "test": "node --test test/*.test.js" + }, + "license": "UNLICENSED" +} diff --git a/tools/flow-lint/README.md b/tools/flow-lint/README.md new file mode 100644 index 0000000..4b0cdb2 --- /dev/null +++ b/tools/flow-lint/README.md @@ -0,0 +1,47 @@ +# @evolv/flow-lint + +Lint Node-RED flow JSON against the EVOLV flow-layout rule +(`.claude/rules/node-red-flow-layout.md`). + +## Usage + +```bash +# every examples/*.flow.json across all nodes +node tools/flow-lint/bin/flow-lint.js + +# one flow +node tools/flow-lint/bin/flow-lint.js nodes/rotatingMachine/examples/01-Basic.flow.json + +# CI / JSON output (one line per flow) +node tools/flow-lint/bin/flow-lint.js --json +``` + +Exit code `1` on any `error`-severity finding; `0` otherwise (warnings do +not fail). + +## Rules + +| Rule | Severity | Catches | +|---|---|---| +| `UI_XY` | error | `ui-*` nodes with both `x` and `y` at 0 — pile up at canvas origin. | +| `UI_CHART_PROP` | error | `ui-chart` missing one of the required-or-it-renders-blank properties (interpolation, yAxisProperty, …). | +| `UI_CHART_TYPE` | warn | `width` / `height` as string instead of number. | +| `UI_CHART_PALETTE` | warn | `colors` array with fewer than 3 entries. | +| `INJECT_JSON_PROPS` | error | `inject` with `payloadType:"json"` but missing `props[]` — silently treated as string. | +| `LINK_CHANNEL_NAME` | warn | `link out` / `link in` without a `cmd:` / `evt:` / `setup:` prefix. | +| `LINK_BROKEN` | error | `link out` / `link in` referencing a non-existent node id. | +| `LINK_PAIR_TYPE` | error | `link out` paired with anything other than `link in`, or vice versa. | +| `LINK_PAIR_ASYMMETRIC` | error | One side references the other but the partner does not link back. | +| `DEBUG_LOG_IN_FLOW` | warn | `enableLog:"debug"` shipped in a flow — fills container log. | +| `SELF_LOOP` | error | Any node wired to itself. | +| `GROUP_WIDTH_SUM` | warn | `ui-group` widths on a `ui-page` summing to non-multiple of 12 — leaves grid gaps. | + +## Why use it + +Every rule corresponds to a bug we've actually hit: +- `UI_XY` and `UI_CHART_PROP` — the dashboard pile-up + blank-chart bugs. +- `INJECT_JSON_PROPS` — the silent-string inject bug. +- `LINK_PAIR_ASYMMETRIC` — drift between named channels. +- `SELF_LOOP` — the 250k-msg/s `replace_all` incident. + +Run before committing any flow change. diff --git a/tools/flow-lint/bin/flow-lint.js b/tools/flow-lint/bin/flow-lint.js new file mode 100644 index 0000000..b0bcdd3 --- /dev/null +++ b/tools/flow-lint/bin/flow-lint.js @@ -0,0 +1,262 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const REQUIRED_CHART_PROPS = [ + 'chartType', 'interpolation', 'category', 'categoryType', + 'xAxisType', 'xAxisPropertyType', 'yAxisProperty', 'yAxisPropertyType', + 'action', 'width', 'height', 'colors', +]; + +const CHANNEL_PREFIXES = ['cmd:', 'evt:', 'setup:']; + +function lintFlow(flowPath) { + const findings = []; + let nodes; + try { + const raw = fs.readFileSync(flowPath, 'utf8'); + nodes = JSON.parse(raw); + } catch (err) { + findings.push({ rule: 'PARSE', severity: 'error', msg: `JSON parse failed: ${err.message}` }); + return findings; + } + if (!Array.isArray(nodes)) { + findings.push({ rule: 'SHAPE', severity: 'error', msg: 'Top-level must be an array of nodes.' }); + return findings; + } + const byId = new Map(nodes.filter((n) => n && n.id).map((n) => [n.id, n])); + for (const n of nodes) { + if (!n || typeof n.type !== 'string') continue; + checkUiNodeXY(n, findings); + checkUiChart(n, findings); + checkInject(n, findings); + checkLinkPair(n, byId, findings); + checkDebugLog(n, findings); + checkBackwardWires(n, findings); + } + checkGroupWidths(nodes, findings); + return findings; +} + +const UI_CONFIG_TYPES = new Set(['ui-base', 'ui-theme', 'ui-page', 'ui-group', 'ui-spacer', 'ui-control']); + +function checkUiNodeXY(n, findings) { + if (!n.type.startsWith('ui-')) return; + if (UI_CONFIG_TYPES.has(n.type)) return; + if ((!n.x && !n.y) || (n.x === 0 && n.y === 0)) { + findings.push({ + rule: 'UI_XY', + severity: 'error', + node: n.id, + msg: `${n.type} "${n.name || n.id}" has no editor position (x/y both 0 or missing) — will pile up at canvas origin.`, + }); + } +} + +function checkUiChart(n, findings) { + if (n.type !== 'ui-chart') return; + const missing = REQUIRED_CHART_PROPS.filter((p) => n[p] === undefined || n[p] === null || n[p] === ''); + for (const m of missing) { + if (m === 'xAxisProperty' || m === 'xAxisFormat') continue; + findings.push({ + rule: 'UI_CHART_PROP', + severity: 'error', + node: n.id, + msg: `ui-chart "${n.name || n.id}" missing required property: ${m}`, + }); + } + if (typeof n.width === 'string') { + findings.push({ rule: 'UI_CHART_TYPE', severity: 'warn', node: n.id, msg: `ui-chart width should be number, got string "${n.width}"` }); + } + if (typeof n.height === 'string') { + findings.push({ rule: 'UI_CHART_TYPE', severity: 'warn', node: n.id, msg: `ui-chart height should be number, got string "${n.height}"` }); + } + if (Array.isArray(n.colors) && n.colors.length < 3) { + findings.push({ rule: 'UI_CHART_PALETTE', severity: 'warn', node: n.id, msg: 'ui-chart palette has <3 colors; series may collide.' }); + } +} + +function checkInject(n, findings) { + if (n.type !== 'inject') return; + if (n.payloadType !== 'json') return; + if (!Array.isArray(n.props) || n.props.length === 0) { + findings.push({ + rule: 'INJECT_JSON_PROPS', + severity: 'error', + node: n.id, + msg: `inject "${n.name || n.id}" has payloadType=json but no props[] — Node-RED will silently treat payload as string.`, + }); + return; + } + const payloadProp = n.props.find((p) => p && p.p === 'payload'); + if (payloadProp && payloadProp.vt && payloadProp.vt !== 'json') { + findings.push({ + rule: 'INJECT_JSON_PROPS', + severity: 'warn', + node: n.id, + msg: `inject "${n.name || n.id}" has payloadType=json but props.payload.vt=${payloadProp.vt}.`, + }); + } +} + +function checkLinkPair(n, byId, findings) { + if (n.type !== 'link out' && n.type !== 'link in') return; + if (n.name && !CHANNEL_PREFIXES.some((p) => n.name.startsWith(p))) { + findings.push({ + rule: 'LINK_CHANNEL_NAME', + severity: 'warn', + node: n.id, + msg: `${n.type} "${n.name}" should start with cmd: / evt: / setup: (channel naming convention).`, + }); + } + if (!Array.isArray(n.links)) return; + for (const linkedId of n.links) { + const other = byId.get(linkedId); + if (!other) { + findings.push({ + rule: 'LINK_BROKEN', + severity: 'error', + node: n.id, + msg: `${n.type} "${n.name || n.id}" points at non-existent node id "${linkedId}".`, + }); + continue; + } + const otherType = n.type === 'link out' ? 'link in' : 'link out'; + if (other.type !== otherType) { + findings.push({ + rule: 'LINK_PAIR_TYPE', + severity: 'error', + node: n.id, + msg: `${n.type} "${n.name || n.id}" pairs with non-${otherType} "${other.type}".`, + }); + continue; + } + if (Array.isArray(other.links) && !other.links.includes(n.id)) { + findings.push({ + rule: 'LINK_PAIR_ASYMMETRIC', + severity: 'error', + node: n.id, + msg: `${n.type} "${n.name || n.id}" → ${otherType} "${other.name || other.id}" — partner does not link back.`, + }); + } + } +} + +function checkDebugLog(n, findings) { + if (n.enableLog === 'debug') { + findings.push({ + rule: 'DEBUG_LOG_IN_FLOW', + severity: 'warn', + node: n.id, + msg: `${n.type} "${n.name || n.id}" has enableLog:"debug" — fills container log in seconds; remove before commit.`, + }); + } +} + +function checkBackwardWires(n, findings) { + if (!Array.isArray(n.wires) || typeof n.x !== 'number') return; + for (const portWires of n.wires) { + if (!Array.isArray(portWires)) continue; + for (const targetId of portWires) { + if (targetId === n.id) { + findings.push({ + rule: 'SELF_LOOP', + severity: 'error', + node: n.id, + msg: `${n.type} "${n.name || n.id}" wires to itself — will fire >250k msg/s.`, + }); + } + } + } +} + +function checkGroupWidths(nodes, findings) { + const pages = new Map(); + for (const n of nodes) { + if (!n || n.type !== 'ui-group') continue; + if (typeof n.width !== 'number' || !n.page) continue; + const arr = pages.get(n.page) || []; + arr.push({ id: n.id, name: n.name, width: n.width }); + pages.set(n.page, arr); + } + for (const [pageId, groups] of pages) { + const total = groups.reduce((s, g) => s + g.width, 0); + if (total % 12 !== 0) { + findings.push({ + rule: 'GROUP_WIDTH_SUM', + severity: 'warn', + msg: `ui-page "${pageId}" has groups summing to width=${total}; should be a multiple of 12 (12-column grid).`, + }); + } + } +} + +function findFlows(repoRoot) { + const out = []; + function walk(dir) { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; + walk(full); + } else if (entry.isFile() && /\.flow\.json$|\d+.*\.json$/.test(entry.name) && /examples?/.test(full)) { + out.push(full); + } + } + } + walk(path.join(repoRoot, 'nodes')); + walk(path.join(repoRoot, 'examples')); + return out; +} + +function report(flowPath, findings, json) { + if (json) { + process.stdout.write(JSON.stringify({ flow: flowPath, findings }) + '\n'); + return findings.some((f) => f.severity === 'error'); + } + if (findings.length === 0) { + process.stdout.write(`OK ${flowPath}\n`); + return false; + } + const errs = findings.filter((f) => f.severity === 'error').length; + const warns = findings.filter((f) => f.severity === 'warn').length; + process.stdout.write(`\n${errs ? 'FAIL' : 'WARN'} ${flowPath} (${errs} err, ${warns} warn)\n`); + for (const f of findings) { + const tag = f.severity === 'error' ? 'ERR ' : 'WARN'; + const nodeRef = f.node ? `[${f.node}] ` : ''; + process.stdout.write(` ${tag} ${f.rule}: ${nodeRef}${f.msg}\n`); + } + return errs > 0; +} + +function main() { + const args = process.argv.slice(2); + const json = args.includes('--json'); + const positional = args.filter((a) => !a.startsWith('--')); + const repoRoot = path.resolve(__dirname, '../../..'); + const targets = positional.length === 0 + ? findFlows(repoRoot) + : positional.flatMap((p) => { + const full = path.resolve(p); + if (fs.existsSync(full) && fs.statSync(full).isDirectory()) return findFlows(full); + return [full]; + }); + if (targets.length === 0) { + process.stderr.write('No flow files found.\n'); + process.exit(2); + } + let fail = false; + for (const flowPath of targets) { + const findings = lintFlow(flowPath); + const flowFail = report(flowPath, findings, json); + if (flowFail) fail = true; + } + process.exit(fail ? 1 : 0); +} + +if (require.main === module) main(); + +module.exports = { lintFlow }; diff --git a/tools/flow-lint/package.json b/tools/flow-lint/package.json new file mode 100644 index 0000000..f45a66a --- /dev/null +++ b/tools/flow-lint/package.json @@ -0,0 +1,13 @@ +{ + "name": "@evolv/flow-lint", + "version": "0.1.0", + "private": true, + "description": "Lint Node-RED flows (examples/*.flow.json) against EVOLV flow-layout rules", + "bin": { + "evolv-flow-lint": "bin/flow-lint.js" + }, + "scripts": { + "test": "node --test test/*.test.js" + }, + "license": "UNLICENSED" +}