diff --git a/scripts/wikiGen.js b/scripts/wikiGen.js new file mode 100644 index 0000000..232bd25 --- /dev/null +++ b/scripts/wikiGen.js @@ -0,0 +1,303 @@ +#!/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 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 | 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 effect = d.description ? String(d.description) : topicEffectFallback(d.topic); + lines.push(`| ${topic} | ${aliases} | ${payload} | ${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 };