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 +16 -2
- package/lib/gen.mjs +17 -0
- package/lib/init.mjs +126 -16
- package/lib/viewer/c4-hologram.html +101 -18
- 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/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 {
|
|
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\`.`)
|
|
@@ -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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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+=" › "+(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
|
-
|
|
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.
|
|
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",
|