forma-arch 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/gen.mjs +58 -15
- package/lib/init.mjs +13 -1
- package/lib/viewer/c4-hologram.html +23 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ Column roles are detected by header name (`capability`/`feature`/`description`
|
|
|
61
61
|
"verify": { "source": "ADR-040 on main" }, "issues": ["#534"] } } }
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
The overlay is the authority: every field it sets wins over anything derived, and `check` stops re-deriving that field. To describe one box by hand without a table, put the sentence in `descriptions`, keyed `"<containerId>/<node-name-without-extension>"` — `"core/alpha"` for the leaf `alpha.js` in container `core`, `"core/core"` for the container itself, since a container is its own container. It outranks every other source.
|
|
64
|
+
The overlay is the authority: every field it sets wins over anything derived, and `check` stops re-deriving that field. You do not have to hand-edit that JSON — `forma gen --status-apply <file>` merges `{"nodes":{"<id>":{…}}}` into it, validating every patch **before** it touches disk, so a rejected fill leaves the committed overlay untouched. It is the state counterpart of `--enrich-apply`, for the fields no document can supply. To describe one box by hand without a table, put the sentence in `descriptions`, keyed `"<containerId>/<node-name-without-extension>"` — `"core/alpha"` for the leaf `alpha.js` in container `core`, `"core/core"` for the container itself, since a container is its own container. It outranks every other source.
|
|
65
65
|
|
|
66
66
|
**Curated state, verified against reality.** `forma verify` asks your `gh` CLI for the state of every issue the model references (`--gh-repo owner/repo`, or `meta.ghRepo` in the topology), marks the nodes whose issues are closed as done, and prefixes their `current` with dated evidence. It touches state, never structure, and re-running it never stacks the evidence. It is opt-in and separate on purpose: `gen` and `check` never open a socket. In the served viewer, **RE-VERIFY** re-reads the model without losing your level, layout or mode.
|
|
67
67
|
|
package/lib/gen.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// gen-c4-model.mjs — emit docs/architecture/c4-model.json from real code + curated topology.
|
|
3
3
|
// Leaves are walked LIVE from src/ (always current). Topology/context/runtime-edges are curated.
|
|
4
4
|
// Usage: node gen-c4-model.mjs [--repo <path>] [--topology <path>] [--out <path>]
|
|
5
|
-
import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from 'node:fs'
|
|
5
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync, statSync, mkdirSync } from 'node:fs'
|
|
6
6
|
import { join, dirname, basename, relative } from 'node:path'
|
|
7
7
|
import { execSync } from 'node:child_process'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
@@ -26,6 +26,7 @@ const ENRICH_MODEL = arg('--enrich-model', null)
|
|
|
26
26
|
const STATUS = arg('--status', join(REPO, 'docs/architecture/c4-status.json')) // curated programme state, optional
|
|
27
27
|
const STATUS_SET = process.argv.includes('--status')
|
|
28
28
|
const APPLY = arg('--enrich-apply', null) // prose written by the agent driving forma
|
|
29
|
+
const STATUS_APPLY = arg('--status-apply', null) // §WP-A7: programme STATE written by that agent
|
|
29
30
|
|
|
30
31
|
const topo = JSON.parse(readFileSync(TOPO, 'utf-8'))
|
|
31
32
|
const rp = (p) => join(REPO, p)
|
|
@@ -134,8 +135,12 @@ if (docRows.length) console.log(`[gen-c4] docSources: ${docRows.length} row(s)
|
|
|
134
135
|
|
|
135
136
|
// enrich: fill hologram defaults (category, 6-status, completion, current/target) where absent
|
|
136
137
|
for (const n of nodes) {
|
|
137
|
-
|
|
138
|
-
|
|
138
|
+
// A leaf's category is the CONTAINER it belongs to, not its parent's category. Inheriting the
|
|
139
|
+
// parent's handed every leaf in the repo the literal string "container", and the viewer's
|
|
140
|
+
// catalogue collapse groups childless siblings BY CATEGORY — so a repo's leaves collapsed into a
|
|
141
|
+
// single box labelled "container" (53 of them on the Go repo this was measured on). With the real
|
|
142
|
+
// container they group the way that feature was written for, or stay apart when they belong apart.
|
|
143
|
+
if (!n.category) n.category = n.kind === 'leaf' ? (byId.get(containerOf(n, byId)) || {}).name || 'leaf' : n.kind
|
|
139
144
|
// Progress a DOCUMENT states outright — the only generated alternative to hand-writing the
|
|
140
145
|
// overlay. Derived, so it is re-derived by `check` rather than trusted; the curated overlay
|
|
141
146
|
// (§WP-A1, applied further down) still overrides every field of it.
|
|
@@ -177,6 +182,53 @@ for (const n of nodes) {
|
|
|
177
182
|
// what a module DOES comes from its docs (§1a), what it's WORTH is programme state.
|
|
178
183
|
const STATUS_FIELDS = new Set(['status2', 'completion', 'statusWord', 'current', 'target', 'verify', 'issues'])
|
|
179
184
|
const STATUS2 = new Set(['done', 'in-progress', 'next', 'planned', 'problem'])
|
|
185
|
+
// One validator, two callers: the overlay FILE below, and --status-apply just above it. A writer
|
|
186
|
+
// that validated differently from the reader would commit a c4-status.json the very next `gen`
|
|
187
|
+
// rejects. Returns the complaints; the caller decides whether that is a fail or a refusal to write.
|
|
188
|
+
function statusPatchErrors(id, patch, known) {
|
|
189
|
+
const out = []
|
|
190
|
+
if (!known(id)) out.push(`unknown node id "${id}" — it is not in the model (stale overlay?)`)
|
|
191
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return [...out, `"${id}" must map to an object of state fields`]
|
|
192
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
193
|
+
if (k.startsWith('$')) continue // $comment and friends
|
|
194
|
+
if (!STATUS_FIELDS.has(k)) { out.push(`"${id}" has field "${k}"; allowed: ${[...STATUS_FIELDS].join(', ')}${k === 'func' ? ' (func comes from the docs, not the overlay)' : ''}`); continue }
|
|
195
|
+
if (k === 'status2' && !STATUS2.has(v)) out.push(`"${id}" status2 "${v}" — expected one of ${[...STATUS2].join('|')}`)
|
|
196
|
+
if (k === 'completion' && !(Number.isInteger(v) && v >= 0 && v <= 100)) out.push(`"${id}" completion must be an integer 0-100, got ${JSON.stringify(v)}`)
|
|
197
|
+
if (k === 'issues') {
|
|
198
|
+
if (!Array.isArray(v)) out.push(`"${id}" issues must be an array`)
|
|
199
|
+
else for (const is of v) if (!/^#?\d+$/.test(String(is))) out.push(`"${id}" issue ${JSON.stringify(is)} — expected "#123"`)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return out
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// §WP-A7) --status-apply: the WRITER the overlay never had. `c4-status.json` is the authority —
|
|
206
|
+
// every field it sets outranks anything §17 derives from a document — and nothing in the repo has
|
|
207
|
+
// ever written one, so on a fresh repo the authority channel was reachable only by hand-editing
|
|
208
|
+
// JSON. This is the state counterpart of `--enrich-apply`: an agent or a human hands in the fields
|
|
209
|
+
// a document cannot supply, and they land in the curated file, reviewed in the diff like any other.
|
|
210
|
+
if (STATUS_APPLY) {
|
|
211
|
+
if (!existsSync(STATUS_APPLY)) fail(`--status-apply: file missing: ${STATUS_APPLY}`)
|
|
212
|
+
let incoming
|
|
213
|
+
try { incoming = JSON.parse(readFileSync(STATUS_APPLY, 'utf-8')).nodes } catch (e) { fail(`--status-apply: ${STATUS_APPLY} is not valid JSON: ${(e && e.message) || e}`) }
|
|
214
|
+
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) fail(`--status-apply: ${STATUS_APPLY} must contain {"nodes":{"<id>":{…}}}`)
|
|
215
|
+
// Validate EVERYTHING before touching disk. A half-written overlay is a file the next `gen`
|
|
216
|
+
// refuses to read, in a repo where that file is committed.
|
|
217
|
+
const errs = Object.entries(incoming).flatMap(([id, patch]) => statusPatchErrors(id, patch, (x) => byId.has(x)))
|
|
218
|
+
if (errs.length) fail(`--status-apply: ${STATUS_APPLY} rejected, ${relative(REPO, STATUS)} left untouched:\n - ` + errs.join('\n - '))
|
|
219
|
+
let base = { nodes: {} }
|
|
220
|
+
if (existsSync(STATUS)) {
|
|
221
|
+
try { base = JSON.parse(readFileSync(STATUS, 'utf-8')) } catch (e) { fail(`--status-apply: existing ${relative(REPO, STATUS)} is not valid JSON: ${(e && e.message) || e}`) }
|
|
222
|
+
base.nodes = base.nodes || {}
|
|
223
|
+
}
|
|
224
|
+
for (const [id, patch] of Object.entries(incoming)) base.nodes[id] = { ...(base.nodes[id] || {}), ...patch }
|
|
225
|
+
// The first apply on a repo IS the one that creates the overlay, and a repo that has never run
|
|
226
|
+
// `forma doc` has no docs/architecture/ to create it in.
|
|
227
|
+
mkdirSync(dirname(STATUS), { recursive: true })
|
|
228
|
+
writeFileSync(STATUS, JSON.stringify(base, null, 2) + '\n')
|
|
229
|
+
console.log(`[gen-c4] --status-apply: ${Object.keys(incoming).length} node(s) merged into ${relative(REPO, STATUS)}`)
|
|
230
|
+
}
|
|
231
|
+
|
|
180
232
|
let statusPath = null
|
|
181
233
|
if (existsSync(STATUS) || STATUS_SET) {
|
|
182
234
|
if (!existsSync(STATUS)) fail(`--status: file missing: ${STATUS}`)
|
|
@@ -184,19 +236,10 @@ if (existsSync(STATUS) || STATUS_SET) {
|
|
|
184
236
|
try { ov = JSON.parse(readFileSync(STATUS, 'utf-8')) } catch (e) { fail(`--status: ${STATUS} is not valid JSON: ${(e && e.message) || e}`) }
|
|
185
237
|
let decorated = 0
|
|
186
238
|
for (const [id, patch] of Object.entries(ov.nodes || {})) {
|
|
239
|
+
const errs = statusPatchErrors(id, patch, (x) => byId.has(x))
|
|
240
|
+
if (errs.length) fail('status overlay: ' + errs.join('\n[gen-c4] FAIL: status overlay: '))
|
|
187
241
|
const target = byId.get(id)
|
|
188
|
-
|
|
189
|
-
for (const [k, v] of Object.entries(patch)) {
|
|
190
|
-
if (k.startsWith('$')) continue // $comment and friends
|
|
191
|
-
if (!STATUS_FIELDS.has(k)) fail(`status overlay: "${id}" has field "${k}"; allowed: ${[...STATUS_FIELDS].join(', ')}${k === 'func' ? ' (func comes from the docs, not the overlay)' : ''}`)
|
|
192
|
-
if (k === 'status2' && !STATUS2.has(v)) fail(`status overlay: "${id}" status2 "${v}" — expected one of ${[...STATUS2].join('|')}`)
|
|
193
|
-
if (k === 'completion' && !(Number.isInteger(v) && v >= 0 && v <= 100)) fail(`status overlay: "${id}" completion must be an integer 0-100, got ${JSON.stringify(v)}`)
|
|
194
|
-
if (k === 'issues') {
|
|
195
|
-
if (!Array.isArray(v)) fail(`status overlay: "${id}" issues must be an array`)
|
|
196
|
-
for (const is of v) if (!/^#?\d+$/.test(String(is))) fail(`status overlay: "${id}" issue ${JSON.stringify(is)} — expected "#123"`)
|
|
197
|
-
}
|
|
198
|
-
target[k] = v
|
|
199
|
-
}
|
|
242
|
+
for (const [k, v] of Object.entries(patch)) { if (!k.startsWith('$')) target[k] = v }
|
|
200
243
|
decorated++
|
|
201
244
|
}
|
|
202
245
|
statusPath = relative(REPO, STATUS)
|
package/lib/init.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// existing topology unless --force.
|
|
6
6
|
import { readdirSync, statSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
7
7
|
import { join, relative, basename } from 'node:path'
|
|
8
|
+
import { execFileSync } from 'node:child_process'
|
|
8
9
|
import { goPackages } from './lang.mjs'
|
|
9
10
|
import { loadDocRows } from './docmap.mjs'
|
|
10
11
|
|
|
@@ -132,8 +133,19 @@ const docSources = []
|
|
|
132
133
|
}
|
|
133
134
|
})(join(REPO, 'docs'), 0)
|
|
134
135
|
|
|
136
|
+
// `forma verify` is the one command that derives progress from something other than a claim in a
|
|
137
|
+
// document — the live state of a GitHub issue — and it needs `meta.ghRepo` to know where to look.
|
|
138
|
+
// `init` never wrote it, so on a freshly seeded repo verify had nowhere to point and exited. The
|
|
139
|
+
// remote already says it. Handles scp-style (git@host:owner/repo.git) and URL remotes alike.
|
|
140
|
+
const ghRepo = (() => {
|
|
141
|
+
let url
|
|
142
|
+
try { url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: REPO, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { return null }
|
|
143
|
+
const m = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(url)
|
|
144
|
+
return m ? m[1] : null
|
|
145
|
+
})()
|
|
146
|
+
|
|
135
147
|
const topo = {
|
|
136
|
-
meta: { repo: basename(REPO), stack: lang, seededBy: 'forma init' },
|
|
148
|
+
meta: { repo: basename(REPO), stack: lang, seededBy: 'forma init', ...(ghRepo ? { ghRepo } : {}) },
|
|
137
149
|
docPath: 'docs/architecture/ARCHITECTURE.md',
|
|
138
150
|
levels: ['context', 'container', 'component', 'leaf'],
|
|
139
151
|
nodes,
|
|
@@ -225,6 +225,21 @@ var LABEL_AUTO_MAX=14; // above this many arrows in the current level, fixed
|
|
|
225
225
|
var LABELS=null; // null = auto (by arrow count); true/false = user override for this session
|
|
226
226
|
var SKINS=[["holo","◆ holo"],["blueprint","▭ blueprint"]], SKIN_IDS=["holo","blueprint"];
|
|
227
227
|
var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob",unknown:"unk"};
|
|
228
|
+
// The per-level tally. The mean is over the nodes that CARRY a completion, and it reports how many
|
|
229
|
+
// those were: on a real Go repo, 25 containers at 100% next to 28 nobody has ruled on rendered as
|
|
230
|
+
// "progress 100%" — the invented green this tool exists to kill, in the one number a stakeholder
|
|
231
|
+
// reads first. Dividing by every child instead would be the mirror lie: a node with no verdict is
|
|
232
|
+
// not 0% done, which is exactly why `gen` refuses to write a completion nobody curated. So the
|
|
233
|
+
// percentage keeps its honest denominator and shows it.
|
|
234
|
+
function tallyOf(kids){
|
|
235
|
+
var cnt={},tot=0,sum=0,nc=0,st3,j;
|
|
236
|
+
for(j=0;j<kids.length;j++){
|
|
237
|
+
st3=STMAP[kids[j].status2]||(kids[j].status==="planned"?"plan":"unk");
|
|
238
|
+
cnt[st3]=(cnt[st3]||0)+1;tot++;
|
|
239
|
+
if(kids[j].completion!=null){sum+=(+kids[j].completion||0);nc++;}
|
|
240
|
+
}
|
|
241
|
+
return {cnt:cnt,tot:tot,ruled:nc,mean:nc?Math.round(sum/nc):null};
|
|
242
|
+
}
|
|
228
243
|
var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false
|
|
229
244
|
var VBOX={}; // last fitted viewBox per level — frozen while dragging so the canvas does not rescale
|
|
230
245
|
function $(i){return document.getElementById(i)}
|
|
@@ -414,19 +429,15 @@ function draw(animate){
|
|
|
414
429
|
var lvlIdx=((M.levels||[]).indexOf(lvlKey));
|
|
415
430
|
var cb=(lvlIdx>-1?STR.crumbLevel+"-L"+(lvlIdx+1):STR.crumbLevel)+" · "+lvl.toUpperCase()+" <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
|
|
416
431
|
for(var w=0;w<chain.length;w++)cb+=" › "+(w===chain.length-1?"<b>"+esc(chain[w].name)+"</b>":"<a data-d='"+w+"'>"+esc(chain[w].name)+"</a>");
|
|
417
|
-
// Per-level programme tally — the status-board essence in one glance
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
var
|
|
421
|
-
|
|
422
|
-
st3=STMAP[realKids[j].status2]||(realKids[j].status==="planned"?"plan":"unk");
|
|
423
|
-
cnt[st3]=(cnt[st3]||0)+1;tot++;
|
|
424
|
-
if(realKids[j].completion!=null){sum+=(+realKids[j].completion||0);nc++;}
|
|
425
|
-
}
|
|
426
|
-
if(tot){
|
|
432
|
+
// Per-level programme tally — the status-board essence in one glance. Counts use the real
|
|
433
|
+
// children (a collapsed catalogue still counts its members), so the tally never changes when the
|
|
434
|
+
// catalogue threshold does.
|
|
435
|
+
var T=tallyOf(realKids);
|
|
436
|
+
if(T.tot){
|
|
427
437
|
var DOT={done:"#2fe08a",prog:"#ffc74d",next:"#3ee7ff",plan:"#2d5a72",prob:"#ff5d5d",unk:"#7d8f99"},ord=["done","prog","next","plan","prob","unk"],agg="";
|
|
428
|
-
for(j=0;j<ord.length;j++)if(cnt[ord[j]])agg+='<i style="background:'+DOT[ord[j]]+'"></i>'+cnt[ord[j]];
|
|
429
|
-
|
|
438
|
+
for(j=0;j<ord.length;j++)if(T.cnt[ord[j]])agg+='<i style="background:'+DOT[ord[j]]+'"></i>'+T.cnt[ord[j]];
|
|
439
|
+
// The coverage rides WITH the percentage, never without it. See tallyOf.
|
|
440
|
+
if(T.ruled)agg+=' '+esc(STR.aggProgress)+' <b>'+T.mean+'%</b> '+T.ruled+'/'+T.tot;
|
|
430
441
|
cb='<span class="agg">'+agg+'</span>'+cb;
|
|
431
442
|
}
|
|
432
443
|
$("crumb").innerHTML=cb;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forma-arch",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Forma — present your architecture instead of slides. An interactive, stack-agnostic C4 explorer (context → container → component → leaf) generated from your code and kept true to it by a deterministic drift check.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"architecture",
|