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:
52
tools/wiki-gen/README.md
Normal file
52
tools/wiki-gen/README.md
Normal 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.
|
||||||
150
tools/wiki-gen/bin/wiki-gen.js
Normal file
150
tools/wiki-gen/bin/wiki-gen.js
Normal 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 };
|
||||||
13
tools/wiki-gen/package.json
Normal file
13
tools/wiki-gen/package.json
Normal 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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user