tools: add output-manifest-verify; extend flow-lint with fan-out checks

- tools/output-manifest-verify/ — enforces .claude/rules/output-coverage.md
  §3: every node ships test/_output-manifest.md and every declared key
  is referenced by at least one test file. First run shows only
  machineGroupControl has the manifest (16 keys covered); all other nodes
  warn. --strict escalates "missing manifest" to an error for CI gating.
- flow-lint gains two rules from the same output-coverage rule:
  * FN_OUTPUT_WIRES_MISMATCH — function declares outputs=N but wires has
    M arrays (causes silent dropped or duplicate emissions).
  * FN_PAYLOAD_NULL_LITERAL — function source contains `payload: null`
    literal (the η-null ui-chart crash pattern from 2026-05-14).
  First run found 1 instance in mgc/02-Dashboard.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-19 10:13:49 +02:00
parent ecd466f7a3
commit edef1cecbf
4 changed files with 231 additions and 0 deletions

View File

@@ -35,6 +35,7 @@ function lintFlow(flowPath) {
checkLinkPair(n, byId, findings);
checkDebugLog(n, findings);
checkBackwardWires(n, findings);
checkFunctionFanOut(n, findings);
}
checkGroupWidths(nodes, findings);
return findings;
@@ -172,6 +173,28 @@ function checkBackwardWires(n, findings) {
}
}
function checkFunctionFanOut(n, findings) {
if (n.type !== 'function') return;
const outputs = Number(n.outputs);
if (!Number.isFinite(outputs) || outputs <= 1) return;
if (!Array.isArray(n.wires) || n.wires.length !== outputs) {
findings.push({
rule: 'FN_OUTPUT_WIRES_MISMATCH',
severity: 'error',
node: n.id,
msg: `function "${n.name || n.id}" declares outputs=${outputs} but wires has ${Array.isArray(n.wires) ? n.wires.length : 'no'} arrays.`,
});
}
if (typeof n.func === 'string' && /payload\s*:\s*null\b/.test(n.func)) {
findings.push({
rule: 'FN_PAYLOAD_NULL_LITERAL',
severity: 'error',
node: n.id,
msg: `function "${n.name || n.id}" emits a literal { payload: null } — crashes ui-chart on first frame; return the whole msg as null instead so the port skips.`,
});
}
}
function checkGroupWidths(nodes, findings) {
const pages = new Map();
for (const n of nodes) {