P9.2: add scripts/wikiGen.js (shared wiki auto-gen for all nodes)
Two subcommands consumed by each node's package.json wiki:* scripts: contract <commands.js> — generates the topic-contract markdown table datamodel <specific.js> — instantiates the domain + walks getOutput() Both can splice between <!-- BEGIN/END AUTOGEN: topic-contract|data-model --> markers in a target file via --write <path>, or print to stdout. First consumer: pumpingStation (wired in its package.json wiki:contract + wiki:datamodel + wiki:all). Other 11 nodes will wire when each gets its first wiki page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
303
scripts/wikiGen.js
Normal file
303
scripts/wikiGen.js
Normal file
@@ -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 <commands-module> [--write <wiki-path>]
|
||||
* node wikiGen.js datamodel <specificClass-module> [--write <wiki-path>]
|
||||
*
|
||||
* `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
|
||||
* `<repo>/wiki/_partial-datamodel.md.template` instead of crashing.
|
||||
*
|
||||
* When `--write <wiki-path>` is given, the output is spliced between the
|
||||
* matching `<!-- BEGIN AUTOGEN: <marker> -->` / `<!-- END AUTOGEN: ... -->`
|
||||
* 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 <path-to-commands/index.js> [--write <wiki-path>]',
|
||||
' node wikiGen.js datamodel <path-to-specificClass.js> [--write <wiki-path>]',
|
||||
'',
|
||||
].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 = `<!-- BEGIN AUTOGEN: ${marker} -->`;
|
||||
const end = `<!-- END AUTOGEN: ${marker} -->`;
|
||||
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
|
||||
// `<node>/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 };
|
||||
Reference in New Issue
Block a user