#!/usr/bin/env node 'use strict'; /** * wikiGen.js — shared wiki auto-generation helper for every EVOLV node. * * Two subcommands: * * node wikiGen.js contract [--write ] * node wikiGen.js datamodel [--write ] * * `contract` walks the descriptor array exported by `src/commands/index.js` * and emits a markdown table mapping canonical topic → aliases → payload * schema → effect description. * * `datamodel` instantiates the domain with a minimal stub config, calls * `getOutput()` once, and emits a markdown table of (key, type, sample value). * If construction fails (because the domain needs a live runtime that isn't * trivially stubbable), the script falls back to a hand-curated partial at * `/wiki/_partial-datamodel.md.template` instead of crashing. * * When `--write ` is given, the output is spliced between the * matching `` / `` * markers in that file. Otherwise it prints to stdout. * * See `.claude/refactor/WIKI_TEMPLATE.md` (sections 5 and 8) and CONTRACTS.md * for the canonical topic naming and registry shape this script consumes. */ const fs = require('fs'); const path = require('path'); // ── CLI parsing ──────────────────────────────────────────────────────────── function parseArgs(argv) { const [, , subcmd, target, ...rest] = argv; const opts = { subcmd, target, write: null }; for (let i = 0; i < rest.length; i++) { if (rest[i] === '--write' && rest[i + 1]) { opts.write = rest[i + 1]; i++; } } return opts; } function usage() { process.stderr.write([ 'Usage:', ' node wikiGen.js contract [--write ]', ' node wikiGen.js datamodel [--write ]', '', ].join('\n')); } // ── Shared helpers ───────────────────────────────────────────────────────── function resolveAbs(p) { return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p); } function describeSchema(schema) { if (!schema) return '_unspecified_'; const t = schema.type; if (!t) return '_unspecified_'; if (t === 'any') return '`any`'; if (t === 'object') { const props = schema.properties || {}; const keys = Object.keys(props); if (!keys.length) return '`object`'; const parts = keys.map((k) => { const subType = props[k]?.type ?? 'any'; return `${k}:${subType}`; }); return '`{ ' + parts.join(', ') + ' }`'; } return '`' + t + '`'; } function topicEffectFallback(topic) { // Try to derive a short, plain-English effect from the canonical topic // when the descriptor doesn't carry a description field. Keep it terse — // a maintainer can override by adding `description` to the descriptor. const prefixes = { 'set.': 'Replaces the named state value with the supplied payload.', 'cmd.': 'Triggers an action / sequence — not idempotent.', 'data.': 'Pushes a value into the node\'s measurement stream.', 'query.': 'Read-only query; node replies on the same msg.', 'child.': 'Parent/child plumbing — registers or unregisters a child node.', }; for (const [pfx, line] of Object.entries(prefixes)) { if (topic.startsWith(pfx)) return line; } return '_(see handler)_'; } function spliceAutogen(filePath, marker, body) { const begin = ``; const end = ``; if (!fs.existsSync(filePath)) { throw new Error(`wikiGen: --write target '${filePath}' does not exist`); } const src = fs.readFileSync(filePath, 'utf8'); const bIdx = src.indexOf(begin); const eIdx = src.indexOf(end); if (bIdx < 0 || eIdx < 0 || eIdx < bIdx) { throw new Error(`wikiGen: markers '${marker}' not found in ${filePath}`); } const before = src.slice(0, bIdx + begin.length); const after = src.slice(eIdx); const out = before + '\n\n' + body.trimEnd() + '\n\n' + after; fs.writeFileSync(filePath, out, 'utf8'); } // ── Subcommand: contract ─────────────────────────────────────────────────── function describeUnits(units) { // Descriptor.units is the validated `{ measure, default }` pair the // commandRegistry stores; render it as ` (default )` so // a reader sees both the dimension and the canonical default that the // node coerces to. Em-dash for unit-less topics keeps the column tidy. if (!units || typeof units !== 'object') return '—'; const { measure, default: def } = units; if (!measure || !def) return '—'; return '`' + measure + '` (default `' + def + '`)'; } function renderContract(commandsPath) { const abs = resolveAbs(commandsPath); // eslint-disable-next-line import/no-dynamic-require, global-require const registry = require(abs); if (!Array.isArray(registry)) { throw new Error(`wikiGen contract: ${abs} does not export an array of descriptors`); } const lines = []; lines.push('| Canonical topic | Aliases | Payload | Unit | Effect |'); lines.push('|---|---|---|---|---|'); for (const d of registry) { const topic = '`' + d.topic + '`'; const aliases = (d.aliases && d.aliases.length) ? d.aliases.map((a) => '`' + a + '`').join(', ') : '_(none)_'; const payload = describeSchema(d.payloadSchema); const unit = describeUnits(d.units); const effect = d.description ? String(d.description) : topicEffectFallback(d.topic); lines.push(`| ${topic} | ${aliases} | ${payload} | ${unit} | ${effect} |`); } return lines.join('\n'); } // ── Subcommand: datamodel ────────────────────────────────────────────────── function inferSampleType(v) { if (v === null) return 'null'; if (Array.isArray(v)) return 'array'; return typeof v; } function trySampleValue(v) { if (v === null || v === undefined) return '`null`'; const t = typeof v; if (t === 'number' || t === 'boolean') return '`' + String(v) + '`'; if (t === 'string') return '`"' + v + '"`'; if (Array.isArray(v)) return '`[…]`'; if (t === 'object') return '`{…}`'; return '`' + String(v) + '`'; } // Heuristic unit map for top-level snapshot keys that aren't structured as // MeasurementContainer keys (e.g. `heightBasin`, `surfaceArea`). Best-effort // — the canonical place for unit semantics is the node's config schema; the // table below is just enough to keep the auto-generated data-model readable. const FLAT_KEY_UNITS = { heightBasin: 'm', basinHeight: 'm', inflowLevel: 'm', outflowLevel: 'm', overflowLevel: 'm', startLevel: 'm', stopLevel: 'm', minLevel: 'm', maxLevel: 'm', surfaceArea: 'm2', volEmptyBasin: 'm3', maxVol: 'm3', maxVolAtOverflow:'m3', minVol: 'm3', minVolAtInflow: 'm3', minVolAtOutflow: 'm3', percControl: '%', timeleft: 's', }; function inferUnitFromKey(key) { // MeasurementContainer-shaped keys take precedence: `{type}.{variant}.{position}.{childId}`. const parts = key.split('.'); if (parts.length >= 3) { const type = parts[0]; const map = { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K', level: 'm', volume: 'm3', volumePercent: '%', netFlowRate: 'm3/s', }; if (map[type]) return map[type]; } return FLAT_KEY_UNITS[key] || '—'; } function renderDatamodel(specificClassPath) { const abs = resolveAbs(specificClassPath); // eslint-disable-next-line import/no-dynamic-require, global-require const Domain = require(abs); // Minimum viable stub config — the BaseDomain pipeline pulls per-key // defaults from the JSON schema, so this only needs to supply the bits // that BaseDomain reads from `userConfig` directly. const stubConfig = { general: { name: `wikiGen-${Domain.name || 'domain'}`, id: `wikiGen-${Domain.name || 'domain'}-id`, logging: { enabled: false, logLevel: 'error' }, }, }; // Look for a hand-curated fallback alongside the wiki. Path is // `/wiki/_partial-datamodel.md.template` relative to the // *commands*-or-specificClass file's repo root. const repoRoot = findRepoRoot(abs); const fallback = repoRoot ? path.join(repoRoot, 'wiki', '_partial-datamodel.md.template') : null; let out; try { const instance = new Domain(stubConfig); out = instance.getOutput ? instance.getOutput() : null; if (!out || typeof out !== 'object') { throw new Error('getOutput() returned a non-object'); } } catch (err) { process.stderr.write(`wikiGen datamodel: live instantiation failed: ${err.message}\n`); if (fallback && fs.existsSync(fallback)) { process.stderr.write(`wikiGen datamodel: using hand-curated fallback ${fallback}\n`); return fs.readFileSync(fallback, 'utf8').trimEnd(); } process.stderr.write('wikiGen datamodel: no hand-curated fallback found — emitting placeholder\n'); return [ '| Key | Type | Unit | Sample |', '|---|---|---|---|', `| _live instantiation failed; provide ${fallback ? `\`wiki/_partial-datamodel.md.template\`` : 'a hand-curated template'}_ | — | — | — |`, ].join('\n'); } const lines = []; lines.push('| Key | Type | Unit | Sample |'); lines.push('|---|---|---|---|'); for (const k of Object.keys(out).sort()) { const v = out[k]; lines.push(`| \`${k}\` | ${inferSampleType(v)} | ${inferUnitFromKey(k)} | ${trySampleValue(v)} |`); } return lines.join('\n'); } function findRepoRoot(startPath) { let dir = path.dirname(startPath); for (let i = 0; i < 8; i++) { if (fs.existsSync(path.join(dir, 'package.json'))) return dir; const parent = path.dirname(dir); if (parent === dir) return null; dir = parent; } return null; } // ── Entry point ──────────────────────────────────────────────────────────── function main() { const opts = parseArgs(process.argv); if (!opts.subcmd || !opts.target) { usage(); process.exit(1); } let body; let marker; if (opts.subcmd === 'contract') { body = renderContract(opts.target); marker = 'topic-contract'; } else if (opts.subcmd === 'datamodel') { body = renderDatamodel(opts.target); marker = 'data-model'; } else { usage(); process.exit(1); } if (opts.write) { spliceAutogen(resolveAbs(opts.write), marker, body); process.stderr.write(`wikiGen: wrote ${marker} block into ${opts.write}\n`); } else { process.stdout.write(body + '\n'); } } if (require.main === module) { main(); } module.exports = { renderContract, renderDatamodel, spliceAutogen, describeSchema, describeUnits };