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 +16 -2
- package/lib/docmap.mjs +27 -4
- package/lib/gen.mjs +21 -1
- package/lib/init.mjs +126 -16
- package/lib/viewer/c4-hologram.html +91 -19
- package/package.json +1 -1
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/)** —
|
|
12
|
-
|
|
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
|
|
158
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
.
|
|
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+=" › "+(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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|