forma-arch 0.9.0 → 0.10.1

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 CHANGED
@@ -8,8 +8,22 @@ them.
8
8
  Forma is the companion to `arbiter`: arbiter governs the process, Forma shows the system and
9
9
  guarantees the picture matches reality.
10
10
 
11
- **[▶ Live demo](https://LucaDominici.github.io/forma/)** — forma's own architecture, generated by
12
- forma from this repo on every push to `main`.
11
+ **[▶ Live demo](https://LucaDominici.github.io/forma/)** — **not** this repo. It is `haben`, a
12
+ private 53-package Go application, generated by forma at haben commit `b1bb318`: six domains over
13
+ 53 packages, 192 edges read from real `import` blocks, and box text quoted from that repo's own
14
+ feature matrix. A tool proving itself on its own 28 nodes proves nothing you care about.
15
+
16
+ What you are looking at is checked, not asserted: at that commit `node scripts/presentable.mjs`
17
+ exits 0 on the model — every level under 24 boxes, every box carrying prose rather than a file
18
+ count, every level with more than one box drawing at least one arrow — and `forma check` exits 0
19
+ against haben's source at the same commit. The curation that turns 53 flat packages into six
20
+ domains is one human decision, checked in at [`docs/demo/curate.mjs`](docs/demo/curate.mjs) with
21
+ the commands to rebuild it.
22
+
23
+ The one thing the demo does not do is regenerate itself: haben is private, so Pages publishes a
24
+ snapshot committed from a local run rather than cloning it in CI. Nothing automated will notice
25
+ if it goes stale. Forma's own model lives in `docs/architecture/` and *is* drift-checked on every
26
+ push.
13
27
 
14
28
  ## Why
15
29
 
package/lib/gen.mjs CHANGED
@@ -181,6 +181,14 @@ for (const n of nodes) {
181
181
  if (n.target == null) n.target = ''
182
182
  }
183
183
 
184
+ // §33) the context actors `init` seeds are placeholders, and a placeholder nobody renamed is the
185
+ // first screen a stakeholder sees. Not a failure — the model is valid and the rest of it is real —
186
+ // but it must never pass unremarked, so the names are printed back verbatim. Matched on the `TODO:`
187
+ // prefix, which is also what a human writing "TODO: the family" gets, and not on the seeded ids: an
188
+ // actor that was renamed properly keeps its id and must stop being nagged about.
189
+ const anon = nodes.filter((n) => /^TODO:/.test(String(n.name)))
190
+ if (anon.length) console.error(`[gen-c4] note: ${anon.length} context box(es) still unnamed — ${anon.map((n) => `"${n.name}"`).join(', ')}. Rename them in the topology; nothing else derives them.`)
191
+
184
192
  // §1a) func: plain-language "what it does" resolved from existing docs (curated → docstring → README
185
193
  // → arc42 → generated fallback), with provenance in descSource. No LLM here — pure parsing.
186
194
  const dctx = makeDescribeCtx({ repo: REPO, byId, descriptions: topo.descriptions || {}, docPath: topo.docPath, containerOf, docIndex })
@@ -304,6 +312,15 @@ if (ENRICH && ENRICHER === 'agent') {
304
312
  // A language that DECLARES its dependencies gets its adapter instead (lib/lang.mjs): guessing from
305
313
  // names where an `import` block states the fact is strictly worse, and gets the direction wrong.
306
314
  if (!process.argv.includes('--no-auto-edges') && isGo(topo.meta && topo.meta.stack)) {
315
+ // Grouping 53 packages into domains is the only cure for a wall of boxes, and the intuitive way
316
+ // to curate it — stamping `kind: "component"` on the grouped package — takes 189 edges to 13
317
+ // without a word: goEdges reads a package's directory off its `glob` evidence and skips anything
318
+ // that is not kind "container". The node still LOOKS right in the JSON. Say it out loud, because
319
+ // the only other symptom is arrows the reader never knew were supposed to be there.
320
+ const mute = nodes.filter((n) => n.kind !== 'container' && (n.evidence || []).some((e) => e.type === 'glob'))
321
+ if (mute.length) console.error(`[gen-c4] WARNING: ${mute.length} node(s) carry glob evidence but kind ≠ "container", so no import edge is derived from them: ` +
322
+ mute.slice(0, 5).map((n) => `${n.id} (kind "${n.kind}")`).join(', ') + (mute.length > 5 ? `, +${mute.length - 5} more` : '') +
323
+ '\n[gen-c4] to group packages, keep kind:"container" and move only level/parent.')
307
324
  const derived = goEdges({ repo: REPO, nodes, edges: topo.edges })
308
325
  topo.edges = [...(topo.edges || []), ...derived]
309
326
  console.log(`[gen-c4] auto-edges: +${derived.length} container edge(s) derived from Go import blocks`)
package/lib/init.mjs CHANGED
@@ -29,6 +29,10 @@ if (existsSync(OUT) && !FORCE) { console.error(`[forma init] ${relative(REPO, OU
29
29
  // (build junk AND data/fixture/doc dirs): counting files that can never become a container is how
30
30
  // a repo full of Python fixtures gets detected as a Python repo and then finds no source dir.
31
31
  const counts = {}
32
+ // ext → Map(repo-relative dir → file count). The walk already sees every stack in the repo; only the
33
+ // dominant one used to survive it. Keeping the directories is what lets §34 below name the stacks
34
+ // init does NOT seed instead of dropping them in silence.
35
+ const extDirs = {}
32
36
  ;(function walk(dir, depth) {
33
37
  if (depth > 12) return
34
38
  let ents; try { ents = readdirSync(dir) } catch { return }
@@ -38,13 +42,29 @@ const counts = {}
38
42
  const p = join(dir, e)
39
43
  let st; try { st = statSync(p) } catch { continue }
40
44
  if (st.isDirectory()) walk(p, depth + 1)
41
- else { const m = e.match(/\.([a-z0-9]+)$/i); if (m && EXT[m[1].toLowerCase()]) counts[m[1].toLowerCase()] = (counts[m[1].toLowerCase()] || 0) + 1 }
45
+ else {
46
+ const m = e.match(/\.([a-z0-9]+)$/i)
47
+ if (!m || !EXT[m[1].toLowerCase()]) continue
48
+ const x = m[1].toLowerCase()
49
+ counts[x] = (counts[x] || 0) + 1
50
+ const rel = relative(REPO, dir) || '.'
51
+ const seenDirs = extDirs[x] || (extDirs[x] = new Map())
52
+ seenDirs.set(rel, (seenDirs.get(rel) || 0) + 1)
53
+ }
42
54
  }
43
55
  })(REPO, 0)
44
- const ext = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]
45
- if (!ext) { console.error('[forma init] no recognised source files found under ' + REPO); process.exit(1) }
46
- const lang = EXT[ext[0]]
47
- const matchRe = `\\.${ext[0]}$`
56
+ const ext = Object.entries(counts).sort((a, b) => b[1] - a[1])[0] || null
57
+ // A repo with no recognised source is not an error: init is best-effort, and since §33 it always has
58
+ // something true to write — the context. Exiting 1 here left the caller with no file at all.
59
+ if (!ext) console.error(`[forma init] no recognised source files under ${REPO} — seeding the context only, no containers.`)
60
+ const lang = ext ? EXT[ext[0]] : null
61
+ // One `match` per LANGUAGE, not per extension. A React repo where *.tsx outnumbers *.ts used to be
62
+ // modelled from the .tsx half alone, with the .ts half neither seeded nor mentioned — and §34's
63
+ // report would then hand back "TypeScript: not seeded" for the same directories it had just seeded,
64
+ // so pasting it stacked a second container on top of the first.
65
+ const extsOf = (name) => Object.keys(counts).filter((x) => EXT[x] === name).sort()
66
+ const anyOf = (exts) => (exts.length === 1 ? `\\.${exts[0]}$` : `\\.(${exts.join('|')})$`)
67
+ const matchRe = ext ? anyOf(extsOf(lang)) : null
48
68
 
49
69
  // 2) find the source root
50
70
  function javaBase() {
@@ -59,20 +79,40 @@ function javaBase() {
59
79
  return d
60
80
  }
61
81
  // Go modules are rooted at go.mod, never at src/ — an import path is relative to the module root.
62
- const root = ext[0] === 'java' ? (javaBase() || REPO) : ext[0] === 'go' ? REPO : (existsSync(join(REPO, 'src')) ? join(REPO, 'src') : REPO)
63
- const GO = ext[0] === 'go'
82
+ const root = !ext ? REPO : ext[0] === 'java' ? (javaBase() || REPO) : ext[0] === 'go' ? REPO : (existsSync(join(REPO, 'src')) ? join(REPO, 'src') : REPO)
83
+ const GO = !!ext && ext[0] === 'go'
64
84
  const notSrc = GO ? /_test\.go$/ : null // a test file is not architecture (§Go adapter)
65
85
 
66
86
  // 3) build nodes + leafSources: one container per immediate subdir that holds source files directly
67
87
  const sysId = slug(basename(REPO))
68
- const nodes = [{ id: sysId, level: 'context', kind: 'system', name: basename(REPO), tech: lang, description: `${basename(REPO)} drill for containers.` }]
88
+ // §33 the first screen a reader meets was ONE dashed box holding a generated sentence, and nothing
89
+ // told them that curating it is mandatory rather than nice: "who touches this" is slide one of any
90
+ // architecture talk. So init seeds the two roles every system has, EXPLICITLY anonymous. The `TODO:`
91
+ // prefix is the whole point — a plausible invented actor ("End user") would be indistinguishable
92
+ // from curated truth, and `gen` keys its reminder off exactly this prefix.
93
+ const PERSON = 'todo_person', EXTERNAL = 'todo_external'
94
+ const PLACEHOLDER = 'Placeholder seeded by `forma init` — rename it to the real one, or delete it.'
95
+ const nodes = [
96
+ { id: sysId, level: 'context', kind: 'system', name: basename(REPO), ...(lang ? { tech: lang } : {}), description: `${basename(REPO)} — drill for containers.` },
97
+ { id: PERSON, level: 'context', kind: 'person', name: 'TODO: who uses it', description: PLACEHOLDER },
98
+ { id: EXTERNAL, level: 'context', kind: 'external', name: 'TODO: what it depends on', description: PLACEHOLDER },
99
+ ]
100
+ // Seeded with the actors, not after them: a context screen of boxes with no arrows is a bulleted
101
+ // list in rectangles (predicate 4 of scripts/presentable.mjs), and the arrows are what say which
102
+ // side of the system each placeholder stands on — so renaming them is a fill-in, not a design job.
103
+ const edges = [
104
+ { from: PERSON, to: sysId, label: 'uses' },
105
+ { from: sysId, to: EXTERNAL, label: 'depends on' },
106
+ ]
69
107
  const leafSources = []
70
108
  const hasSrc = (dir) => { try { return readdirSync(dir).some((f) => { const p = join(dir, f); return statSync(p).isFile() && new RegExp(matchRe).test(f) && !(notSrc && notSrc.test(f)) }) } catch { return false } }
71
109
 
72
110
  // Recurse to the SHALLOWEST dirs that directly contain source files → each is a container.
73
111
  // (A dir with direct sources is a container; a dir with none is descended into. Works for nested
74
112
  // layouts like src/app/routes/*.ts or Python packages, not just one level.)
75
- const seen = new Set()
113
+ // The context ids are claimed up front: a directory named like the repo (or like a placeholder) would
114
+ // otherwise mint a duplicate id that only `gen` finds, one command later.
115
+ const seen = new Set([sysId, PERSON, EXTERNAL])
76
116
  const skipped = []
77
117
  const claim = (rel) => { let id = slug(rel.replace(/[\\/]+/g, '_')); if (seen.has(id)) id = slug(id + '_' + leafSources.length); seen.add(id); return id }
78
118
  const findContainers = (dir, depth) => {
@@ -111,11 +151,77 @@ if (GO) {
111
151
  nodes.push({ id, level: 'container', kind: 'container', parent: sysId, name: rel, tech: lang })
112
152
  leafSources.push({ parent: id, dir: rel, match: matchRe, exclude: '_test\\.go$', evidenceOnly: true })
113
153
  }
114
- } else findContainers(root, 0)
154
+ } else if (ext) findContainers(root, 0)
115
155
  // loose files directly in root → an entry container
116
- if (hasSrc(root)) { nodes.push({ id: 'app', level: 'container', kind: 'container', parent: sysId, name: 'app', tech: lang, description: 'Entry point / loose top-level sources.' }); leafSources.push({ parent: 'app', dir: relative(REPO, root) || '.', match: matchRe, ...(GO ? { exclude: '_test\\.go$', evidenceOnly: true } : {}) }) }
156
+ if (ext && hasSrc(root)) { nodes.push({ id: 'app', level: 'container', kind: 'container', parent: sysId, name: 'app', tech: lang, description: 'Entry point / loose top-level sources.' }); leafSources.push({ parent: 'app', dir: relative(REPO, root) || '.', match: matchRe, ...(GO ? { exclude: '_test\\.go$', evidenceOnly: true } : {}) }) }
117
157
 
118
- if (leafSources.length === 0) { console.error(`[forma init] detected ${lang} (*.${ext[0]}) as the dominant language, but no directory under ${relative(REPO, root) || '.'}/ holds *.${ext[0]} files directly. Pass --include <dir,...> if the sources live in a skipped dir, or add leafSources manually.`); process.exit(1) }
158
+ // Nothing to seed is a fact to report, not a reason to exit: the context is written either way, and
159
+ // `--include` is named for what it actually does — it un-skips a data/doc dir BY NAME (docs,
160
+ // fixtures, testdata, …). It never took a path, so `--include frontend/src` was a promise the flag
161
+ // could not keep; the stacks it was reached for are named by §34 below instead.
162
+ if (ext && leafSources.length === 0) console.error(`[forma init] detected ${lang} (*.${ext[0]}) as the dominant language, but no directory under ${relative(REPO, root) || '.'}/ holds *.${ext[0]} files directly. Add leafSources by hand, or re-run with --include <name,...> to un-skip one of ${[...SKIP_DIRS].sort().join(', ')}.`)
163
+
164
+ // §34 — name every stack init SAW and did not seed. One run models ONE language (the whole container
165
+ // pass keys on a single `match`), and saying nothing about the rest is how a Go + React monorepo came
166
+ // out as 53 Go packages with no trace of the application its users actually open.
167
+ // Seeding them all instead was measured on that repo and rejected: the shallowest-dir rule stops at
168
+ // `frontend/`, whose only direct sources are two build configs, so the 302-file app under
169
+ // `frontend/src` would be modelled as `knip.config.ts` + `vite.config.ts` — a box that names the
170
+ // frontend and contains none of it, which is the false green forma exists to kill. Reaching the real
171
+ // app needs a per-stack root convention (src/, app/, frontend/src/) that has no honest general rule,
172
+ // and even then the container level goes from 53 boxes to 69 with 16 more carrying no prose.
173
+ // So the entries are built here — exactly as `gen` needs them, ids already de-duplicated against the
174
+ // seeded ones — and parked in `_unseeded`, where moving them in is a cut-and-paste, not a rewrite.
175
+ // Keyed on the DIRECTORIES that did not become a container, not on "every language but the dominant
176
+ // one": sources the container pass never reached are unmodelled whatever language they are in, and a
177
+ // directory already seeded must never come back as something to paste on top of itself. Covered is
178
+ // per LANGUAGE, though — `scripts/` can be a Go package AND hold 160 unmodelled *.mjs.
179
+ const covered = new Set(leafSources.map((s) => s.dir))
180
+ const byLang = new Map()
181
+ for (const [x, dirs] of Object.entries(extDirs)) {
182
+ for (const d of dirs.keys()) {
183
+ if (covered.has(d) && EXT[x] === lang) continue
184
+ const L = byLang.get(EXT[x]) || new Set()
185
+ L.add(d)
186
+ byLang.set(EXT[x], L)
187
+ }
188
+ }
189
+ // Counted the way `gen` walks a leafSource — same match, same exclusion — because the walk that fed
190
+ // `extDirs` counts every file: six directories on a real Go repo hold nothing but `_test.go`, which
191
+ // the Go adapter deliberately excludes from architecture. Offered as entries they would seed twenty
192
+ // boxes of test files, or (with the exclusion) match zero and make `gen` exit 1 on a phantom node.
193
+ const realFiles = (dir, re, ex) => {
194
+ let out = 0
195
+ try { for (const f of readdirSync(join(REPO, dir))) { if (!re.test(f) || (ex && ex.test(f))) continue; try { if (statSync(join(REPO, dir, f)).isFile()) out++ } catch {} } } catch {}
196
+ return out
197
+ }
198
+ const unseeded = []
199
+ for (const [name, dirSet] of byLang) {
200
+ // Every extension of the language in ONE match, the same rule the seeded side follows: a pasted
201
+ // entry that under-matches models half a React app and reports it as whole.
202
+ const match = anyOf(extsOf(name))
203
+ const re = new RegExp(match), ex = name === 'Go' ? /_test\.go$/ : null
204
+ const dirs = [...dirSet].sort().map((d) => [d, realFiles(d, re, ex)]).filter(([, n]) => n > 0)
205
+ if (!dirs.length) continue
206
+ const entry = { stack: name, files: dirs.reduce((a, b) => a + b[1], 0), match, nodes: [], leafSources: [] }
207
+ for (const [d] of dirs) {
208
+ const id = claim(d === '.' ? `${name}_root` : d)
209
+ entry.nodes.push({ id, level: 'container', kind: 'container', parent: sysId, name: d === '.' ? `${name} (repo root)` : d, tech: name })
210
+ entry.leafSources.push({ parent: id, dir: d, match, ...(name === 'Go' ? { exclude: '_test\\.go$', evidenceOnly: true } : {}) })
211
+ }
212
+ // The biggest directory rides alongside the entry, never inside it: `_unseeded` is pasted into the
213
+ // topology by hand and every key in it has to be one `gen` understands.
214
+ unseeded.push([entry, [...dirs].sort((a, b) => b[1] - a[1])[0]])
215
+ }
216
+ unseeded.sort((a, b) => b[0].files - a[0].files)
217
+ const unseededNotes = [] // printed after the file exists — the message points AT the file
218
+ for (const [entry, big] of unseeded) {
219
+ const name = entry.stack
220
+ const bigSrc = entry.leafSources.find((s) => s.dir === big[0])
221
+ const bigNode = entry.nodes.find((n) => n.id === bigSrc.parent)
222
+ unseededNotes.push(`[forma init] ${name}: ${entry.files} file(s) in ${entry.leafSources.length} directory(ies) NOT seeded${name === lang ? ' — the container pass never reached them' : lang ? ` — init models one stack per run, and ${lang} won` : ''}. Largest: ${big[0]} (${big[1]} files).`)
223
+ unseededNotes.push(`[forma init] the ${entry.leafSources.length} ready-made pair(s) are under "_unseeded" — move .nodes/.leafSources into the top-level arrays to model them. Largest: ${JSON.stringify(bigNode)} + ${JSON.stringify(bigSrc)}`)
224
+ }
119
225
 
120
226
  // 4) find the documents that already say, in prose, what each part of the product does and whether
121
227
  // it is finished — a feature matrix, a capability table, a requirements sheet. Detection is the
@@ -146,21 +252,25 @@ const ghRepo = (() => {
146
252
  })()
147
253
 
148
254
  const topo = {
149
- meta: { repo: basename(REPO), stack: lang, seededBy: 'forma init', ...(ghRepo ? { ghRepo } : {}) },
255
+ meta: { repo: basename(REPO), ...(lang ? { stack: lang } : {}), seededBy: 'forma init', ...(ghRepo ? { ghRepo } : {}) },
150
256
  docPath: 'docs/architecture/ARCHITECTURE.md',
151
257
  levels: ['context', 'container', 'component', 'leaf'],
152
258
  nodes,
153
259
  leafSources,
154
- edges: [],
260
+ edges,
155
261
  // Capability tables: the box text and the progress for context/container come from HERE first,
156
262
  // ahead of the code. Each entry is a path, or {path, describe, ref, status} to name the columns.
157
263
  docSources,
158
264
  descriptions: {},
159
265
  _skipped: skipped,
266
+ ...(unseeded.length ? { _unseeded: unseeded.map(([e]) => e) } : {}),
160
267
  }
161
268
  // ensure output dir exists
162
269
  mkdirSync(join(OUT, '..'), { recursive: true })
163
270
  writeFileSync(OUT, JSON.stringify(topo, null, 2) + '\n')
164
- console.log(`[forma init] wrote ${relative(REPO, OUT)} — ${lang}, ${leafSources.length} container(s) seeded from ${relative(REPO, root) || '.'}/${skipped.length ? `, ${skipped.length} data/doc dir(s) skipped` : ''}`)
271
+ console.log(`[forma init] wrote ${relative(REPO, OUT)} — ${lang || 'no stack'}, ${leafSources.length} container(s) seeded from ${relative(REPO, root) || '.'}/${skipped.length ? `, ${skipped.length} data/doc dir(s) skipped` : ''}`)
272
+ for (const n of unseededNotes) console.error(n)
165
273
  if (docSources.length) console.log(`[forma init] docSources: ${docSources.join(', ')} — capability rows will describe the containers they name.`)
166
- console.log('[forma init] NEXT: curate names/descriptions + add context externals + run `forma gen`.')
274
+ // The context comes FIRST and alone on this line. It used to be the middle item of three, which read
275
+ // as one improvement among many; it is the only one without which the first screen says nothing.
276
+ console.log(`[forma init] NEXT: name the two "TODO:" actors in ${relative(REPO, OUT)} — that context screen is the first thing anyone sees. Then curate names/descriptions and run \`forma gen\`.`)
@@ -191,7 +191,7 @@ var STRINGS={
191
191
  printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", exportLayout:"Export layout JSON", print:"Print",
192
192
  legDone:"DONE (proven)", legDecl:"DONE (declared)", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
193
193
  legUnk:"UNKNOWN (no curated state)", stUnk:"?",
194
- detDecl:"Declared in", detCover:"Coverage", coverWhole:"the document names this module itself",
194
+ detDecl:"Declared in", detCover:"Coverage", detRoll:"Rolled up from", rollPart:"mean of the {n} of {t} parts inside that anyone ruled on", coverWhole:"the document names this module itself",
195
195
  coverPart:"{n} of {t} units named by the document",
196
196
  legHint:"— animated arrow = active flow · dashed = to build",
197
197
  crumbRoot:"Context", crumbLevel:"C4", lvlContext:"CONTEXT",
@@ -213,7 +213,7 @@ var STRINGS={
213
213
  printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", exportLayout:"Esporta layout JSON", print:"Stampa",
214
214
  legDone:"FATTO (provato)", legDecl:"FATTO (dichiarato)", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
215
215
  legUnk:"IGNOTO (stato non curato)", stUnk:"?",
216
- detDecl:"Dichiarato in", detCover:"Copertura", coverWhole:"il documento nomina il modulo stesso",
216
+ detDecl:"Dichiarato in", detCover:"Copertura", detRoll:"Somma dal basso", rollPart:"media delle {n} parti su {t} qui dentro su cui qualcuno si e pronunciato", coverWhole:"il documento nomina il modulo stesso",
217
217
  coverPart:"{n} unità su {t} nominate dal documento",
218
218
  legHint:"— freccia animata = flusso attivo · tratteggio = da costruire",
219
219
  crumbRoot:"Contesto", crumbLevel:"C4", lvlContext:"CONTESTO",
@@ -246,12 +246,58 @@ var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"
246
246
  // reads first. Dividing by every child instead would be the mirror lie: a node with no verdict is
247
247
  // not 0% done, which is exactly why `gen` refuses to write a completion nobody curated. So the
248
248
  // percentage keeps its honest denominator and shows it.
249
- function tallyOf(kids){
250
- var cnt={},tot=0,sum=0,nc=0,st3,j;
251
- for(j=0;j<kids.length;j++){
252
- st3=STMAP[kids[j].status2]||(kids[j].status==="planned"?"plan":"unk");
253
- cnt[st3]=(cnt[st3]||0)+1;tot++;
254
- if(kids[j].completion!=null){sum+=(+kids[j].completion||0);nc++;}
249
+ //
250
+ // It counts UNITS, not boxes: grouping is the only cure for a 53-box wall, and the invariant that
251
+ // makes grouping safe is that it must not change this line. The same repo read 25/53 flat and 2/6
252
+ // once six domains were curated over it — the act of drawing fewer boxes silently deleted 23
253
+ // verdicts, in the number a stakeholder reads first.
254
+ // So a box stands for its childless descendants, and it keeps its own voice in exactly one case:
255
+ // nobody ruled on those descendants AND the box itself carries a verdict. Descending
256
+ // unconditionally is the mirror mistake — this repo's own board curates a verdict on each
257
+ // container and none on the files inside, and it went from `4/4 100%` to `0/19` with no
258
+ // percentage at all, inventing grey where a human had written an answer. Falling back to the box
259
+ // unconditionally is the third mistake, and the worst: a domain of 9 packages nobody ruled on
260
+ // would contribute ONE unknown instead of nine, so 25/53 would print as 25/45 and the coverage
261
+ // would read better than it is — which is the number this whole function exists to keep honest.
262
+ // One consequence, deliberate: on a grouped level the dots count units (53), not boxes (6).
263
+ // What a box that GROUPS others can honestly say about itself. Nobody ruled on a domain — `gen`
264
+ // refuses to derive a verdict, everywhere, on purpose — so its badge read `?` while the 22 packages
265
+ // inside it carried 14 verdicts. Four of six domains showed `?` on the one screen a stakeholder is
266
+ // shown, and the board looked less finished than the repo was.
267
+ // The way out is not to invent a verdict but to report a FACT: the mean of the descendants somebody
268
+ // did rule on, and how many that was. `100%` alone would be the invented green this tool exists to
269
+ // kill — 14 of 14 ruled done says nothing about the other 8 — so the coverage is not optional
270
+ // decoration, it is half the claim. Same shape as the level tally and the docmap coverage.
271
+ // Returns null when the box has its own verdict (it speaks for itself) or when nothing below it
272
+ // was ruled (there is no fact to report).
273
+ function rollStatus(node,kidsOf){
274
+ if(node.completion!=null||node.statusWord)return null;
275
+ var lv=[],q=[node],n,k,i,guard=0;
276
+ while(q.length&&guard++<4096){n=q.pop();k=kidsOf(n.id);if(k.length){for(i=0;i<k.length;i++)q.push(k[i]);}else if(n!==node)lv.push(n);}
277
+ if(!lv.length)return null;
278
+ var sum=0,nc=0,rank={problem:5,next:4,"in-progress":3,planned:2,unknown:1.5,done:1},best=0,key="unknown",s2;
279
+ for(i=0;i<lv.length;i++){
280
+ if(lv[i].completion!=null){sum+=(+lv[i].completion||0);nc++;}
281
+ s2=lv[i].status2||(lv[i].status==="planned"?"planned":"unknown");
282
+ if((rank[s2]||0)>best){best=rank[s2]||0;key=s2;}
283
+ }
284
+ if(!nc)return null;
285
+ return {status2:key,mean:Math.round(sum/nc),ruled:nc,total:lv.length};
286
+ }
287
+ function tallyOf(kids,kidsOf){
288
+ var cnt={},tot=0,sum=0,nc=0,st3,i,j,k,q,lv,use,n;
289
+ for(i=0;i<kids.length;i++){
290
+ lv=[];q=[kids[i]];
291
+ while(q.length){n=q.pop();k=kidsOf(n.id);if(k.length){for(j=0;j<k.length;j++)q.push(k[j]);}else lv.push(n);}
292
+ use=null;
293
+ for(j=0;j<lv.length;j++)if(lv[j].completion!=null){use=lv;break;} // a finer answer exists
294
+ if(!use)use=kids[i].completion!=null?[kids[i]]:lv; // else the box speaks, if it can
295
+ for(j=0;j<use.length;j++){
296
+ n=use[j];
297
+ st3=STMAP[n.status2]||(n.status==="planned"?"plan":"unk");
298
+ cnt[st3]=(cnt[st3]||0)+1;tot++;
299
+ if(n.completion!=null){sum+=(+n.completion||0);nc++;}
300
+ }
255
301
  }
256
302
  return {cnt:cnt,tot:tot,ruled:nc,mean:nc?Math.round(sum/nc):null};
257
303
  }
@@ -376,12 +422,13 @@ function draw(animate){
376
422
  var kids=collapseCatalogs(pid,realKids);
377
423
  CATNODES={};for(var ci=0;ci<kids.length;ci++)if(kids[ci].__catalog)CATNODES[kids[ci].id]=kids[ci];
378
424
  var lay=layoutFor(kids),pos=lay.pos;
379
- var idset={},memberMap={};
380
- for(var z=0;z<kids.length;z++){idset[kids[z].id]=1;
381
- if(kids[z].__catalog)for(var mm=0;mm<kids[z].members.length;mm++)memberMap[kids[z].members[mm].id]=kids[z].id;}
382
- function mapId(id){return memberMap[id]||id;}
383
- var edges=(M.edges||[]).map(function(e){return {from:mapId(e.from),to:mapId(e.to),label:e.label,estatus:e.estatus};})
384
- .filter(function(e){return idset[e.from]&&idset[e.to]&&e.from!==e.to;});
425
+ // `vis` = which box stands for a node on THIS screen (itself, or the catalogue that swallowed it);
426
+ // everything else reaches the screen through an ancestor. See rollEdges.
427
+ var vis={},parent={},z,mm;
428
+ for(z=0;z<M.nodes.length;z++)parent[M.nodes[z].id]=M.nodes[z].parent||null;
429
+ for(z=0;z<kids.length;z++){vis[kids[z].id]=kids[z].id;
430
+ if(kids[z].__catalog)for(mm=0;mm<kids[z].members.length;mm++)vis[kids[z].members[mm].id]=kids[z].id;}
431
+ var edges=rollEdges(M.edges||[],vis,parent);
385
432
  // fixed arrow labels: on by default while the level stays readable, else hover-only (the tooltip
386
433
  // is always there). A level with 40 arrows would be unreadable with every label painted at once.
387
434
  var showLbl=(LABELS===null)?(edges.length<=LABEL_AUTO_MAX):LABELS;
@@ -424,7 +471,10 @@ function draw(animate){
424
471
  }
425
472
  for(var j=0;j<kids.length;j++){
426
473
  var n=kids[j],p=pos[n.id];
427
- var st=STMAP[n.status2]||(n.status==="planned"?"plan":"unk");
474
+ // A grouping box borrows its children's colour and badge — see rollStatus. The MODEL keeps saying
475
+ // `unknown` for it; this is the viewer reporting what is underneath, not gen inventing a verdict.
476
+ var roll=(n.__catalog||mode==="target")?null:rollStatus(n,childrenOf);
477
+ var st=STMAP[(roll?roll.status2:n.status2)]||(n.status==="planned"?"plan":"unk");
428
478
  var pulse=(st==="next"||st==="prog")&&mode!=="target"&&!n.__catalog;
429
479
  var isCat=!!n.__catalog,dr=!isCat&&hasKids(n.id),cls="nd s-"+st+(dr?" drill":"")+(isCat?" cat":"")+(pulse?" pulse":"");
430
480
  var desc=isCat?(n.count+" "+STR.rosterCount):(mode==="target"?(n.target||n.func||""):(n.current||n.func||""));
@@ -433,7 +483,9 @@ function draw(animate){
433
483
  // breathe. The measured fallback text is the generator's honest last resort, not a lie — it just
434
484
  // does not belong on screen when it says nothing the header has not already said.
435
485
  if(!isCat&&echoesName(desc,n.name))desc="";
436
- var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":STR.stUnk)));
486
+ // The coverage rides WITH the mean, never without it: `100%` on a domain where 14 of 22 packages
487
+ // were ruled is the invented green, and this is the badge a stakeholder reads first.
488
+ var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":(roll?roll.mean+"% "+roll.ruled+"/"+roll.total:STR.stUnk))));
437
489
  // the title runs until the badge, so reserve the badge's actual width (9px text ≈ 5.4px/char)
438
490
  // instead of a flat 70px — a fixed reserve clipped names that had all the room they needed
439
491
  var tmax=Math.floor((p.w-34-String(badge).length*5.4)/6.7),tt=n.name;
@@ -469,8 +521,9 @@ function draw(animate){
469
521
  for(var w=0;w<chain.length;w++)cb+=" &rsaquo; "+(w===chain.length-1?"<b>"+esc(chain[w].name)+"</b>":"<a data-d='"+w+"'>"+esc(chain[w].name)+"</a>");
470
522
  // Per-level programme tally — the status-board essence in one glance. Counts use the real
471
523
  // children (a collapsed catalogue still counts its members), so the tally never changes when the
472
- // catalogue threshold does.
473
- var T=tallyOf(realKids);
524
+ // catalogue threshold does — nor when someone groups those children into domains, which is what
525
+ // `childrenOf` is doing here. See tallyOf.
526
+ var T=tallyOf(realKids,childrenOf);
474
527
  if(T.tot){
475
528
  var DOT={done:"#2fe08a",prog:"#ffc74d",next:"#3ee7ff",plan:"#2d5a72",prob:"#ff5d5d",unk:"#7d8f99"},ord=["done","prog","next","plan","prob","unk"],agg="";
476
529
  for(j=0;j<ord.length;j++)if(T.cnt[ord[j]])agg+='<i style="background:'+DOT[ord[j]]+'"></i>'+T.cnt[ord[j]];
@@ -516,6 +569,34 @@ function wrapDesc(desc,cpl,max){
516
569
  for(i=0;i<out.length;i++)if(out[i].length>cpl)out[i]=out[i].slice(0,cpl-1)+"…";
517
570
  return out;
518
571
  }
572
+ // Project every edge onto the boxes actually on screen, then merge the duplicates. `vis` maps a
573
+ // node id to the id of the box that stands for it here (itself, or the catalogue that swallowed
574
+ // it); `parent` maps every node id to its parent. An endpoint that is not on screen climbs to the
575
+ // ancestor that is — which is the whole point: before this, an edge whose ends were one level
576
+ // deeper simply was not drawn, so the domain level a stakeholder is shown had 189 relationships
577
+ // underneath it and zero arrows on it. Grouping was the only cure for a 53-box wall and it cost
578
+ // the entire graph.
579
+ // An edge landing on the SAME box at both ends is internal detail, not a relationship — it is
580
+ // drawn one level down, where both ends are boxes of their own.
581
+ // The count is summed because the arrow now stands for many: `label` carries "N×" from the Go
582
+ // adapter, so parse it and re-emit the total. A single contributing edge keeps its label VERBATIM,
583
+ // or a curated "reads from" would turn into "1×" the moment this ran.
584
+ // Pure, so it is testable without a DOM — and `scripts/presentable.mjs` grades the model by
585
+ // evaluating this very function, so the instrument cannot drift from what the reader sees.
586
+ function rollEdges(edges,vis,parent){
587
+ var out=[],ix={},i,e,f,t,c,k,cur,guard,g;
588
+ for(i=0;i<edges.length;i++){
589
+ e=edges[i];
590
+ f=null;cur=e.from;guard=0;while(cur!=null&&guard++<64){if(vis[cur]){f=vis[cur];break;}cur=parent[cur];}
591
+ t=null;cur=e.to;guard=0;while(cur!=null&&guard++<64){if(vis[cur]){t=vis[cur];break;}cur=parent[cur];}
592
+ if(!f||!t||f===t)continue;
593
+ c=parseInt(String(e.label),10);if(!(c>0))c=1;
594
+ k=f+"|"+t;
595
+ if(ix[k]==null){ix[k]=out.length;out.push({from:f,to:t,label:e.label,estatus:e.estatus,n:c});}
596
+ else{g=out[ix[k]];g.n+=c;g.label=g.n+"×";if(e.estatus!=="to-build")g.estatus=e.estatus;}
597
+ }
598
+ return out;
599
+ }
519
600
  // Returns the path plus the point ON the curve at t=0.5 — the label anchor. (The quadratic's
520
601
  // control point sits at twice the bow, so anchoring a label there would float it off the arrow.)
521
602
  // Endpoints are trimmed at the true RECTANGLE border (not a radial guess): with wide boxes the
@@ -545,6 +626,8 @@ function showDetail(n){
545
626
  // declared done" and "this module is done" are not the same sentence when the module holds 22
546
627
  // files and the document names 3 of them.
547
628
  +(showSrc?'<div class="row"><span class="lbl">'+esc((n.verify&&n.verify.derived)?STR.detDecl:STR.detSrc)+'</span><span class="src">'+esc(src)+'</span></div>':'')
629
+ // A badge the viewer rolled up must say so, or it reads as a verdict somebody wrote about the box.
630
+ +((function(){var r=rollStatus(n,childrenOf);return r?'<div class="row"><span class="lbl">'+esc(STR.detRoll)+'</span><span class="now">'+esc(STR.rollPart.replace("{n}",r.ruled).replace("{t}",r.total))+'</span></div>':'';})())
548
631
  +(coverText(n)?'<div class="row"><span class="lbl">'+esc(STR.detCover)+'</span><span class="now">'+esc(coverText(n))+'</span></div>':'')
549
632
  +((n.issues&&n.issues.length)?'<div class="row"><span class="lbl">'+esc(STR.detIssue)+'</span><span class="now">'+esc(n.issues.join(", "))+'</span></div>':'');
550
633
  $("detail").style.display="block";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-arch",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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",