tools: add wiki-gen — regenerates topic-contract AUTOGEN blocks

Generates the markdown table inside <!-- BEGIN AUTOGEN: topic-contract -->
blocks in nodes/<n>/wiki/Reference-Contracts.md from the canonical registry
at src/commands/index.js. Replaces the agent-written placeholders the wiki
uplift left behind.

- Accepts both labelled and unlabelled END markers; rewrites to canonical
  '<!-- END AUTOGEN: topic-contract -->' on regeneration so future runs are
  consistent.
- --check mode for CI (exit 1 if any block is out of date).
- Out of scope for now: data-model AUTOGEN block (requires instantiating
  the domain; the 9 agent-written placeholders for that block stay until
  a follow-up tool lands).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-19 10:11:45 +02:00
parent e15f402d47
commit b01b3de741
3 changed files with 215 additions and 0 deletions

52
tools/wiki-gen/README.md Normal file
View File

@@ -0,0 +1,52 @@
# @evolv/wiki-gen
Generate the AUTOGEN sections of per-node wikis from the source of truth
(`nodes/<n>/src/commands/index.js`).
## What it generates
Replaces content between these markers:
```markdown
<!-- BEGIN AUTOGEN: topic-contract — populate via wiki-gen tool (TODO) -->
... wiki-gen overwrites this block ...
<!-- END AUTOGEN: topic-contract -->
```
The 9 wikis uplifted in 2026-05 carry these markers in
`wiki/Reference-Contracts.md` (and some in `wiki/Home.md`); `wiki-gen`
keeps them in sync with the registry.
## Usage
```bash
# regenerate every node
node tools/wiki-gen/bin/wiki-gen.js
# one node
node tools/wiki-gen/bin/wiki-gen.js nodes/rotatingMachine
# CI check: fail if any AUTOGEN block is out of date
node tools/wiki-gen/bin/wiki-gen.js --check
```
## What it writes
For each registry descriptor:
| Column | Source |
|---|---|
| Canonical topic | `descriptor.topic` |
| Aliases | `descriptor.aliases` (deprecation candidates) |
| Payload | `descriptor.payloadSchema` |
| Unit | `descriptor.units.measure` + `descriptor.units.default` (or `—`) |
| Effect | `descriptor.description` |
## Out of scope (for now)
- The `data-model` AUTOGEN block (sample of `getOutput()`) — requires
instantiating the domain class, which depends on `generalFunctions`.
The 9 wikis carry hand-written placeholders inside those markers;
upgrading to runtime sampling is a follow-up.
Run after touching `src/commands/index.js` in any node, or as a CI gate.

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const TOPIC_BEGIN = /<!--\s*BEGIN AUTOGEN:\s*topic-contract.*?-->/;
const ANY_END = /<!--\s*END AUTOGEN(?::\s*[a-z0-9-]+)?\s*-->/g;
const CANONICAL_BEGIN = '<!-- BEGIN AUTOGEN: topic-contract -->';
const CANONICAL_END = '<!-- END AUTOGEN: topic-contract -->';
function loadRegistry(nodeDir) {
const registryPath = path.join(nodeDir, 'src/commands/index.js');
if (!fs.existsSync(registryPath)) return null;
delete require.cache[require.resolve(registryPath)];
const descriptors = require(registryPath);
if (!Array.isArray(descriptors)) {
throw new Error('commands/index.js must export an array of descriptors');
}
return descriptors;
}
function renderTopicTable(descriptors) {
const lines = [];
lines.push('| Canonical topic | Aliases | Payload | Unit | Effect |');
lines.push('|---|---|---|---|---|');
for (const d of descriptors) {
if (!d || typeof d.topic !== 'string') continue;
const canonical = '`' + d.topic + '`';
const aliases = (Array.isArray(d.aliases) && d.aliases.length)
? d.aliases.map((a) => '`' + a + '`').join(', ')
: '—';
const payload = renderPayload(d.payloadSchema);
const unit = renderUnit(d.units);
const effect = (d.description || '').replace(/\|/g, '\\|').trim() || '—';
lines.push(`| ${canonical} | ${aliases} | ${payload} | ${unit} | ${effect} |`);
}
return lines.join('\n');
}
function renderPayload(schema) {
if (!schema) return '—';
if (typeof schema === 'string') return '`' + schema + '`';
if (typeof schema !== 'object') return '—';
if (schema.type === 'any' || schema.type === undefined) return 'any';
if (typeof schema.type === 'string') return '`' + schema.type + '`';
return '—';
}
function renderUnit(units) {
if (!units || typeof units !== 'object') return '—';
const m = units.measure;
const d = units.default;
if (!m && !d) return '—';
if (m && d) return '`' + m + '` (default `' + d + '`)';
return '`' + (m || d) + '`';
}
function regenerateFile(filePath, replacement) {
if (!fs.existsSync(filePath)) return { changed: false, reason: 'file not found' };
const src = fs.readFileSync(filePath, 'utf8');
const beginMatch = src.match(TOPIC_BEGIN);
if (!beginMatch) return { changed: false, reason: 'no AUTOGEN markers' };
const afterBegin = beginMatch.index + beginMatch[0].length;
ANY_END.lastIndex = afterBegin;
const endMatch = ANY_END.exec(src);
if (!endMatch) return { changed: false, reason: 'BEGIN marker has no matching END' };
const endStart = endMatch.index;
const endStop = endStart + endMatch[0].length;
const block = `${CANONICAL_BEGIN}\n\n${replacement}\n\n${CANONICAL_END}`;
const next = src.slice(0, beginMatch.index) + block + src.slice(endStop);
if (next === src) return { changed: false, reason: 'already up to date' };
fs.writeFileSync(filePath, next, 'utf8');
return { changed: true };
}
function regenerateNode(nodeDir, opts) {
const nodeName = path.basename(nodeDir);
const descriptors = loadRegistry(nodeDir);
if (!descriptors) return { node: nodeName, skipped: true, reason: 'no commands/index.js' };
const table = renderTopicTable(descriptors);
const targets = [
path.join(nodeDir, 'wiki/Reference-Contracts.md'),
path.join(nodeDir, 'wiki/Home.md'),
];
const results = [];
for (const target of targets) {
const out = regenerateFile(target, table);
results.push({ file: path.relative(nodeDir, target), ...out });
}
return { node: nodeName, results };
}
function findNodes(repoRoot) {
const nodesDir = path.join(repoRoot, 'nodes');
return fs.readdirSync(nodesDir)
.filter((n) => fs.existsSync(path.join(nodesDir, n, 'src/commands/index.js')))
.map((n) => path.join(nodesDir, n));
}
function report(result, json) {
if (json) {
process.stdout.write(JSON.stringify(result) + '\n');
return;
}
if (result.skipped) {
process.stdout.write(`SKIP ${result.node} (${result.reason})\n`);
return;
}
for (const r of result.results) {
const tag = r.changed ? 'UPDATE' : (r.reason === 'no AUTOGEN markers' ? 'NO-MARK' : 'OK');
process.stdout.write(`${tag.padEnd(7)} ${result.node}/${r.file}${r.reason ? ' — ' + r.reason : ''}\n`);
}
}
function main() {
const args = process.argv.slice(2);
const json = args.includes('--json');
const check = args.includes('--check');
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 drift = false;
for (const nodeDir of targets) {
let res;
try { res = regenerateNode(nodeDir, { check }); }
catch (err) {
process.stderr.write(`[${path.basename(nodeDir)}] ERROR: ${err.message}\n`);
drift = true;
continue;
}
if (!res.skipped && check) {
for (const r of res.results) {
if (r.changed) drift = true;
}
}
report(res, json);
}
if (check && drift) {
process.stderr.write('\nAUTOGEN blocks are out of date. Run without --check to regenerate.\n');
process.exit(1);
}
process.exit(0);
}
if (require.main === module) main();
module.exports = { renderTopicTable, regenerateFile, loadRegistry };

View File

@@ -0,0 +1,13 @@
{
"name": "@evolv/wiki-gen",
"version": "0.1.0",
"private": true,
"description": "Generate the AUTOGEN sections of nodes/<n>/wiki/Reference-Contracts.md from commands/index.js",
"bin": {
"evolv-wiki-gen": "bin/wiki-gen.js"
},
"scripts": {
"test": "node --test test/*.test.js"
},
"license": "UNLICENSED"
}