forma-arch 0.8.0 → 0.10.0

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/docmap.mjs CHANGED
@@ -147,16 +147,36 @@ export function indexByNode(rows, nodes) {
147
147
  const paths = subtree(n.id)
148
148
  if (!paths.length) continue
149
149
  const hit = rows.filter((r) => r.refs.some((ref) => paths.some((p) => touches(ref, p))))
150
- if (hit.length) idx.set(n.id, hit)
150
+ if (hit.length) idx.set(n.id, { rows: hit, cover: coverOf(n, byId, kids, hit, paths) })
151
151
  }
152
152
  return idx
153
153
  }
154
154
 
155
+ // How much of a node the rows actually reach. `done / rows.length` answers "of the capabilities
156
+ // somebody wrote down, how many are finished" — never "how much of this module is finished". A
157
+ // container holding 8 files that a matrix names 3 of, all done, reads 100%: the other 5 are not
158
+ // counted as unfinished, they are not counted at all. So the number ships with its own reach.
159
+ // `whole` means a row names the module itself, i.e. the document IS talking about all of it.
160
+ function coverOf(node, byId, kids, hit, paths) {
161
+ const own = (node.evidence || []).filter((e) => e.type === 'path').map((e) => normRef(e.ref))
162
+ const whole = hit.some((r) => r.refs.some((ref) => own.some((p) => ref === p || p.startsWith(ref + '/'))))
163
+ const leaves = (kids.get(node.id) || []).map((k) => byId.get(k)).filter((k) => k && k.kind === 'leaf')
164
+ const glob = (node.evidence || []).find((e) => e.type === 'glob')
165
+ // Units = the boxes below it, or — for a node whose children are internal detail (a Go package) —
166
+ // the file count its own drift anchor already carries.
167
+ const total = leaves.length || (glob && glob.count) || paths.length
168
+ if (whole) return { named: total, total, whole: true }
169
+ const named = new Set()
170
+ for (const r of hit) for (const ref of r.refs) if (own.some((p) => ref.startsWith(p + '/'))) named.add(ref)
171
+ return { named: Math.min(named.size, total), total, whole: false }
172
+ }
173
+
155
174
  // The rows that DESCRIBE a node (past the cap it is only touched by them — see MAX_ROWS).
156
175
  export const describingRows = (idx, id) => {
157
- const rows = idx.get(id)
158
- return rows && rows.length <= MAX_ROWS ? rows : null
176
+ const e = idx.get(id)
177
+ return e && e.rows.length <= MAX_ROWS ? e.rows : null
159
178
  }
179
+ export const coverageFor = (idx, id) => (idx.get(id) || {}).cover || null
160
180
 
161
181
  // Programme state a document states outright: how many of the capabilities living in this node are
162
182
  // finished. Derived, never curated — the c4-status.json overlay still overrides it (gen.mjs §WP-A1),
@@ -165,9 +185,12 @@ export function statusFor(idx, id) {
165
185
  const rows = describingRows(idx, id)
166
186
  if (!rows || rows.some((r) => r.done == null)) return null // no status column: describe only
167
187
  const done = rows.filter((r) => r.done).length
188
+ const c = coverageFor(idx, id)
168
189
  return {
169
190
  status2: done === rows.length ? 'done' : done === 0 ? 'planned' : 'in-progress',
170
191
  completion: Math.round((done / rows.length) * 100),
171
- source: `${rows[0].from} (${done}/${rows.length} done)`,
192
+ // The citation says what it is: a repo document declaring itself finished, not a verification.
193
+ source: `${rows[0].from} (${done}/${rows.length} declared done)`,
194
+ ...(c ? { coverage: c } : {}),
172
195
  }
173
196
  }
package/lib/gen.mjs CHANGED
@@ -165,7 +165,10 @@ for (const n of nodes) {
165
165
  const ds = !n.status2 && n.kind !== 'system' ? statusFor(docIndex, n.id) : null
166
166
  // `derived: true` is the marker `check` keys off to know this state must keep re-deriving. A
167
167
  // string match on the citation would break the moment anyone rewords it.
168
- if (ds) { n.status2 = ds.status2; if (n.completion == null) n.completion = ds.completion; if (!n.verify) n.verify = { source: ds.source, derived: true } }
168
+ // `coverage` rides in `verify` (additionalProperties: true there, so no schema churn) and says how
169
+ // much of the node the citation actually reaches — the difference between "3 of 3 rows are done"
170
+ // and "this module is done".
171
+ if (ds) { n.status2 = ds.status2; if (n.completion == null) n.completion = ds.completion; if (!n.verify) n.verify = { source: ds.source, derived: true, ...(ds.coverage ? { coverage: ds.coverage } : {}) } }
169
172
  // Code can prove a file EXISTS; it cannot prove the work behind it is finished. Marking every
170
173
  // undecorated node done/100 turned a virgin repo into a board reading "10/10 complete" — the
171
174
  // exact false green this tool exists to kill. No overlay (§WP-A1), no verdict: `unknown`, and
@@ -178,6 +181,14 @@ for (const n of nodes) {
178
181
  if (n.target == null) n.target = ''
179
182
  }
180
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
+
181
192
  // §1a) func: plain-language "what it does" resolved from existing docs (curated → docstring → README
182
193
  // → arc42 → generated fallback), with provenance in descSource. No LLM here — pure parsing.
183
194
  const dctx = makeDescribeCtx({ repo: REPO, byId, descriptions: topo.descriptions || {}, docPath: topo.docPath, containerOf, docIndex })
@@ -301,6 +312,15 @@ if (ENRICH && ENRICHER === 'agent') {
301
312
  // A language that DECLARES its dependencies gets its adapter instead (lib/lang.mjs): guessing from
302
313
  // names where an `import` block states the fact is strictly worse, and gets the direction wrong.
303
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.')
304
324
  const derived = goEdges({ repo: REPO, nodes, edges: topo.edges })
305
325
  topo.edges = [...(topo.edges || []), ...derived]
306
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\`.`)
@@ -89,7 +89,7 @@ svg.dense .edge.hot{opacity:1;stroke-width:2.6}
89
89
  #etip{position:absolute;pointer-events:none;display:none;z-index:6;background:var(--detailbg);border:1px solid var(--border);
90
90
  color:var(--nm);font-size:10px;letter-spacing:.03em;padding:3px 8px;border-radius:5px;white-space:nowrap;box-shadow:0 3px 12px rgba(0,0,0,.45)}
91
91
  #legend{display:flex;gap:13px;font-size:9.5px;color:var(--sec);margin:10px 0 0;flex-wrap:wrap}
92
- #legend i{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px}
92
+ #legend i{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px;box-sizing:border-box}
93
93
  .hint{color:var(--ter2)}
94
94
  #detail{margin-top:12px;border:1px solid var(--border);border-radius:10px;padding:13px;background:var(--detailbg);display:none}
95
95
  #detail h3{color:var(--cyan);font-size:12.5px;margin-bottom:8px;letter-spacing:.05em}
@@ -165,6 +165,10 @@ html[data-skin="blueprint"] button.on{text-shadow:none}
165
165
  <div id="stage"><div id="etip"></div><div id="err" style="display:none"></div></div>
166
166
  <div id="legend">
167
167
  <span><i style="background:#2fe08a"></i><span id="lg-done"></span></span>
168
+ <!-- Same colour, hollow: a green derived from a repo document saying it is finished is not the
169
+ same evidence as a green from a closed issue, and the legend is where a reader learns that
170
+ one mark means two things. -->
171
+ <span><i style="border:1.5px solid #2fe08a"></i><span id="lg-decl"></span></span>
168
172
  <span><i style="background:#ffc74d"></i><span id="lg-prog"></span></span>
169
173
  <span><i style="background:#3ee7ff"></i><span id="lg-next"></span></span>
170
174
  <span><i style="background:#2d5a72"></i><span id="lg-plan"></span></span>
@@ -185,8 +189,10 @@ var STRINGS={
185
189
  labels:"LABELS", labelsTitle:"arrow labels (auto-off above 14 arrows)",
186
190
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"re-arrange",
187
191
  printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", exportLayout:"Export layout JSON", print:"Print",
188
- legDone:"DONE (proven)", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
192
+ legDone:"DONE (proven)", legDecl:"DONE (declared)", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
189
193
  legUnk:"UNKNOWN (no curated state)", stUnk:"?",
194
+ detDecl:"Declared in", detCover:"Coverage", coverWhole:"the document names this module itself",
195
+ coverPart:"{n} of {t} units named by the document",
190
196
  legHint:"— animated arrow = active flow · dashed = to build",
191
197
  crumbRoot:"Context", crumbLevel:"C4", lvlContext:"CONTEXT",
192
198
  lvlNames:{context:"Context",container:"Container",component:"Components",leaf:"Leaves"},
@@ -205,8 +211,10 @@ var STRINGS={
205
211
  labels:"ETICHETTE", labelsTitle:"etichette sugli archi (spente sopra i 14 archi)",
206
212
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"ridisponi",
207
213
  printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", exportLayout:"Esporta layout JSON", print:"Stampa",
208
- legDone:"FATTO (provato)", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
214
+ legDone:"FATTO (provato)", legDecl:"FATTO (dichiarato)", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
209
215
  legUnk:"IGNOTO (stato non curato)", stUnk:"?",
216
+ detDecl:"Dichiarato in", detCover:"Copertura", coverWhole:"il documento nomina il modulo stesso",
217
+ coverPart:"{n} unità su {t} nominate dal documento",
210
218
  legHint:"— freccia animata = flusso attivo · tratteggio = da costruire",
211
219
  crumbRoot:"Contesto", crumbLevel:"C4", lvlContext:"CONTESTO",
212
220
  lvlNames:{context:"Contesto",container:"Container",component:"Componenti",leaf:"Foglie"},
@@ -238,12 +246,34 @@ var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"
238
246
  // reads first. Dividing by every child instead would be the mirror lie: a node with no verdict is
239
247
  // not 0% done, which is exactly why `gen` refuses to write a completion nobody curated. So the
240
248
  // percentage keeps its honest denominator and shows it.
241
- function tallyOf(kids){
242
- var cnt={},tot=0,sum=0,nc=0,st3,j;
243
- for(j=0;j<kids.length;j++){
244
- st3=STMAP[kids[j].status2]||(kids[j].status==="planned"?"plan":"unk");
245
- cnt[st3]=(cnt[st3]||0)+1;tot++;
246
- 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
+ function tallyOf(kids,kidsOf){
264
+ var cnt={},tot=0,sum=0,nc=0,st3,i,j,k,q,lv,use,n;
265
+ for(i=0;i<kids.length;i++){
266
+ lv=[];q=[kids[i]];
267
+ 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);}
268
+ use=null;
269
+ for(j=0;j<lv.length;j++)if(lv[j].completion!=null){use=lv;break;} // a finer answer exists
270
+ if(!use)use=kids[i].completion!=null?[kids[i]]:lv; // else the box speaks, if it can
271
+ for(j=0;j<use.length;j++){
272
+ n=use[j];
273
+ st3=STMAP[n.status2]||(n.status==="planned"?"plan":"unk");
274
+ cnt[st3]=(cnt[st3]||0)+1;tot++;
275
+ if(n.completion!=null){sum+=(+n.completion||0);nc++;}
276
+ }
247
277
  }
248
278
  return {cnt:cnt,tot:tot,ruled:nc,mean:nc?Math.round(sum/nc):null};
249
279
  }
@@ -368,12 +398,13 @@ function draw(animate){
368
398
  var kids=collapseCatalogs(pid,realKids);
369
399
  CATNODES={};for(var ci=0;ci<kids.length;ci++)if(kids[ci].__catalog)CATNODES[kids[ci].id]=kids[ci];
370
400
  var lay=layoutFor(kids),pos=lay.pos;
371
- var idset={},memberMap={};
372
- for(var z=0;z<kids.length;z++){idset[kids[z].id]=1;
373
- if(kids[z].__catalog)for(var mm=0;mm<kids[z].members.length;mm++)memberMap[kids[z].members[mm].id]=kids[z].id;}
374
- function mapId(id){return memberMap[id]||id;}
375
- var edges=(M.edges||[]).map(function(e){return {from:mapId(e.from),to:mapId(e.to),label:e.label,estatus:e.estatus};})
376
- .filter(function(e){return idset[e.from]&&idset[e.to]&&e.from!==e.to;});
401
+ // `vis` = which box stands for a node on THIS screen (itself, or the catalogue that swallowed it);
402
+ // everything else reaches the screen through an ancestor. See rollEdges.
403
+ var vis={},parent={},z,mm;
404
+ for(z=0;z<M.nodes.length;z++)parent[M.nodes[z].id]=M.nodes[z].parent||null;
405
+ for(z=0;z<kids.length;z++){vis[kids[z].id]=kids[z].id;
406
+ if(kids[z].__catalog)for(mm=0;mm<kids[z].members.length;mm++)vis[kids[z].members[mm].id]=kids[z].id;}
407
+ var edges=rollEdges(M.edges||[],vis,parent);
377
408
  // fixed arrow labels: on by default while the level stays readable, else hover-only (the tooltip
378
409
  // is always there). A level with 40 arrows would be unreadable with every label painted at once.
379
410
  var showLbl=(LABELS===null)?(edges.length<=LABEL_AUTO_MAX):LABELS;
@@ -461,8 +492,9 @@ function draw(animate){
461
492
  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>");
462
493
  // Per-level programme tally — the status-board essence in one glance. Counts use the real
463
494
  // children (a collapsed catalogue still counts its members), so the tally never changes when the
464
- // catalogue threshold does.
465
- var T=tallyOf(realKids);
495
+ // catalogue threshold does — nor when someone groups those children into domains, which is what
496
+ // `childrenOf` is doing here. See tallyOf.
497
+ var T=tallyOf(realKids,childrenOf);
466
498
  if(T.tot){
467
499
  var DOT={done:"#2fe08a",prog:"#ffc74d",next:"#3ee7ff",plan:"#2d5a72",prob:"#ff5d5d",unk:"#7d8f99"},ord=["done","prog","next","plan","prob","unk"],agg="";
468
500
  for(j=0;j<ord.length;j++)if(T.cnt[ord[j]])agg+='<i style="background:'+DOT[ord[j]]+'"></i>'+T.cnt[ord[j]];
@@ -491,6 +523,13 @@ function echoesName(desc,name){
491
523
  for(i=0;i<w.length;i++){if(!w[i]||own[w[i]]||DESC_NOISE[w[i]]||/^\d+$/.test(w[i]))continue;return false;}
492
524
  return true;
493
525
  }
526
+ // Pure, so it is testable without a DOM. Empty when nothing was derived — a curated or a
527
+ // gh-verified state has no document reach to report.
528
+ function coverText(n){
529
+ var c=n&&n.verify&&n.verify.coverage;if(!c)return "";
530
+ if(c.whole)return STR.coverWhole;
531
+ return STR.coverPart.replace("{n}",c.named).replace("{t}",c.total);
532
+ }
494
533
  function wrapDesc(desc,cpl,max){
495
534
  if(max<1||cpl<2)return [];
496
535
  var words=String(desc==null?"":desc).split(" "),all=[],line="",i;
@@ -501,6 +540,34 @@ function wrapDesc(desc,cpl,max){
501
540
  for(i=0;i<out.length;i++)if(out[i].length>cpl)out[i]=out[i].slice(0,cpl-1)+"…";
502
541
  return out;
503
542
  }
543
+ // Project every edge onto the boxes actually on screen, then merge the duplicates. `vis` maps a
544
+ // node id to the id of the box that stands for it here (itself, or the catalogue that swallowed
545
+ // it); `parent` maps every node id to its parent. An endpoint that is not on screen climbs to the
546
+ // ancestor that is — which is the whole point: before this, an edge whose ends were one level
547
+ // deeper simply was not drawn, so the domain level a stakeholder is shown had 189 relationships
548
+ // underneath it and zero arrows on it. Grouping was the only cure for a 53-box wall and it cost
549
+ // the entire graph.
550
+ // An edge landing on the SAME box at both ends is internal detail, not a relationship — it is
551
+ // drawn one level down, where both ends are boxes of their own.
552
+ // The count is summed because the arrow now stands for many: `label` carries "N×" from the Go
553
+ // adapter, so parse it and re-emit the total. A single contributing edge keeps its label VERBATIM,
554
+ // or a curated "reads from" would turn into "1×" the moment this ran.
555
+ // Pure, so it is testable without a DOM — and `scripts/presentable.mjs` grades the model by
556
+ // evaluating this very function, so the instrument cannot drift from what the reader sees.
557
+ function rollEdges(edges,vis,parent){
558
+ var out=[],ix={},i,e,f,t,c,k,cur,guard,g;
559
+ for(i=0;i<edges.length;i++){
560
+ e=edges[i];
561
+ f=null;cur=e.from;guard=0;while(cur!=null&&guard++<64){if(vis[cur]){f=vis[cur];break;}cur=parent[cur];}
562
+ t=null;cur=e.to;guard=0;while(cur!=null&&guard++<64){if(vis[cur]){t=vis[cur];break;}cur=parent[cur];}
563
+ if(!f||!t||f===t)continue;
564
+ c=parseInt(String(e.label),10);if(!(c>0))c=1;
565
+ k=f+"|"+t;
566
+ if(ix[k]==null){ix[k]=out.length;out.push({from:f,to:t,label:e.label,estatus:e.estatus,n:c});}
567
+ else{g=out[ix[k]];g.n+=c;g.label=g.n+"×";if(e.estatus!=="to-build")g.estatus=e.estatus;}
568
+ }
569
+ return out;
570
+ }
504
571
  // Returns the path plus the point ON the curve at t=0.5 — the label anchor. (The quadratic's
505
572
  // control point sits at twice the bow, so anchoring a label there would float it off the arrow.)
506
573
  // Endpoints are trimmed at the true RECTANGLE border (not a radial guess): with wide boxes the
@@ -525,7 +592,12 @@ function showDetail(n){
525
592
  +(n.func?'<div class="row"><span class="lbl">'+esc(STR.detFunc)+'</span><span class="func">'+esc(n.func)+'</span></div>':'')
526
593
  +(cur&&cur!==n.func?'<div class="row"><span class="lbl">'+esc(STR.detNow)+'</span><span class="now">'+esc(cur)+'</span></div>':'')
527
594
  +((n.target&&n.target!=="—")?'<div class="row"><span class="lbl">'+esc(STR.detTgt)+'</span><span class="tgt">'+esc(n.target)+'</span></div>':'')
528
- +(showSrc?'<div class="row"><span class="lbl">'+esc(STR.detSrc)+'</span><span class="src">'+esc(src)+'</span></div>':'')
595
+ // A number DERIVED from a repo document is a declaration, not a verification: the label says which
596
+ // one, and the coverage says how much of this module the citation actually reaches. "3 of 3 rows
597
+ // declared done" and "this module is done" are not the same sentence when the module holds 22
598
+ // files and the document names 3 of them.
599
+ +(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>':'')
600
+ +(coverText(n)?'<div class="row"><span class="lbl">'+esc(STR.detCover)+'</span><span class="now">'+esc(coverText(n))+'</span></div>':'')
529
601
  +((n.issues&&n.issues.length)?'<div class="row"><span class="lbl">'+esc(STR.detIssue)+'</span><span class="now">'+esc(n.issues.join(", "))+'</span></div>':'');
530
602
  $("detail").style.display="block";
531
603
  var dc=$("dc");if(dc)dc.addEventListener("click",function(){$("detail").style.display="none";});
@@ -645,7 +717,7 @@ function applyStrings(){
645
717
  set("blbl",STR.labels);var bl=$("blbl");if(bl)bl.title=STR.labelsTitle;
646
718
  set("pexp",STR.printExport);set("pexpSvg",STR.exportSvg);set("pexpPng",STR.exportPng);
647
719
  set("pexpLayout",STR.exportLayout);set("pexpPrint",STR.print);
648
- set("lg-done",STR.legDone);set("lg-prog",STR.legProg);set("lg-next",STR.legNext);
720
+ set("lg-done",STR.legDone);set("lg-decl",STR.legDecl);set("lg-prog",STR.legProg);set("lg-next",STR.legNext);
649
721
  set("lg-plan",STR.legPlan);set("lg-prob",STR.legProb);set("lg-unk",STR.legUnk);set("lg-hint",STR.legHint);
650
722
  }
651
723
  // ---- FEATURE #2: PRINT / EXPORT (all client-side, offline) ----
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-arch",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
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",