#!/usr/bin/env node 'use strict'; const fs = require('fs'); const path = require('path'); function parseManifest(manifestPath) { if (!fs.existsSync(manifestPath)) return null; const src = fs.readFileSync(manifestPath, 'utf8'); const lines = src.split(/\r?\n/); const sections = []; let currentSection = null; let inTable = false; for (const line of lines) { const headerMatch = line.match(/^##\s+(.+)$/); if (headerMatch) { currentSection = { title: headerMatch[1].trim(), keys: [] }; sections.push(currentSection); inTable = false; continue; } if (!currentSection) continue; if (/^\|\s*(Key|Topic|Field|#)\s*\|/i.test(line)) { inTable = true; continue; } if (inTable && /^\|[\s-]+\|/.test(line)) continue; if (inTable && !line.trim().startsWith('|')) { if (line.trim().startsWith('### ')) continue; inTable = false; continue; } if (!inTable) continue; const cells = line.split('|').slice(1, -1).map((c) => c.trim()); if (cells.length === 0) continue; const keyCell = cells[0]; const keyMatch = keyCell.match(/`([^`]+)`/); if (keyMatch) currentSection.keys.push(keyMatch[1]); } return sections; } function findTestFiles(nodeDir) { 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()) walk(full); else if (entry.isFile() && /\.test\.js$/.test(entry.name)) out.push(full); } } walk(path.join(nodeDir, 'test')); return out; } function keyIsCovered(key, testSources) { if (!key) return true; if (/^[a-z]+$/i.test(key) && key.length <= 4) return true; const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(`['"\\\`]${escaped}['"\\\`]|\\.${escaped}\\b|\\b${escaped}\\b`); return testSources.some((src) => pattern.test(src)); } function verifyNode(nodeDir, opts) { const nodeName = path.basename(nodeDir); const manifestPath = path.join(nodeDir, 'test/_output-manifest.md'); const findings = []; const sections = parseManifest(manifestPath); if (sections === null) { findings.push({ severity: opts.strict ? 'error' : 'warn', msg: 'test/_output-manifest.md does not exist (output-coverage rule §3 requires it).' }); return { node: nodeName, findings, manifest: null }; } if (sections.length === 0) { findings.push({ severity: 'error', msg: 'test/_output-manifest.md has no sections (## headings) — empty or malformed.' }); return { node: nodeName, findings, manifest: sections }; } const totalKeys = sections.reduce((s, sec) => s + sec.keys.length, 0); if (totalKeys === 0) { findings.push({ severity: 'error', msg: 'No keys parsed from any section table — check that ` `tickmarks wrap each Key/Topic/Field cell.' }); return { node: nodeName, findings, manifest: sections }; } const testFiles = findTestFiles(nodeDir); const testSources = testFiles.map((f) => fs.readFileSync(f, 'utf8')); if (testFiles.length === 0) { findings.push({ severity: 'error', msg: 'manifest declares keys but no test/**/*.test.js files exist.' }); return { node: nodeName, findings, manifest: sections }; } for (const section of sections) { for (const key of section.keys) { if (!keyIsCovered(key, testSources)) { findings.push({ severity: 'warn', section: section.title, key, msg: `key \`${key}\` declared in "${section.title}" but no test file references it.`, }); } } } return { node: nodeName, findings, manifest: sections, totalKeys }; } 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 report(result, json) { if (json) { process.stdout.write(JSON.stringify(result) + '\n'); return result.findings.some((f) => f.severity === 'error'); } const errs = result.findings.filter((f) => f.severity === 'error').length; const warns = result.findings.filter((f) => f.severity === 'warn').length; if (errs === 0 && warns === 0) { process.stdout.write(`OK ${result.node} (manifest covers ${result.totalKeys} keys)\n`); return false; } const tag = errs ? 'FAIL' : 'WARN'; process.stdout.write(`\n${tag} ${result.node} (${errs} err, ${warns} warn)\n`); for (const f of result.findings) { const t = f.severity === 'error' ? 'ERR ' : 'WARN'; process.stdout.write(` ${t} ${f.msg}\n`); } return errs > 0; } function main() { const args = process.argv.slice(2); const json = args.includes('--json'); const strict = args.includes('--strict'); 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 result = verifyNode(nodeDir, { strict }); if (report(result, json)) fail = true; } process.exit(fail ? 1 : 0); } if (require.main === module) main(); module.exports = { parseManifest, verifyNode, keyIsCovered };