Compare commits

..

1 Commits

Author SHA1 Message Date
znetsixe
30c5dc8508 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>
2026-05-11 14:50:44 +02:00

303
scripts/wikiGen.js Normal file
View 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 };