forma-arch 0.7.1 → 0.8.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/lib/check.mjs CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url'
9
9
  import { containerOf } from './cluster.mjs'
10
10
  import { renderBlock, extractBetween, norm } from './render.mjs'
11
11
  import { descInputHash } from './enrich.mjs'
12
- import { loadDocRows, indexByNode, statusFor } from './docmap.mjs'
12
+ import { loadDocRows, indexByNode, describingRows, statusFor } from './docmap.mjs'
13
13
  import { validateModel } from './validate.mjs'
14
14
 
15
15
  const HERE = dirname(fileURLToPath(import.meta.url))
@@ -43,7 +43,13 @@ function walk(spec) {
43
43
  }
44
44
  for (const spec of topo.leafSources) {
45
45
  const live = walk(spec).length
46
- const inModel = model.nodes.filter((n) => n.kind === 'leaf' && n.status === 'current' && containerOf(n, byId) === spec.parent).length
46
+ // An `evidenceOnly` source produces no leaves the count IS the assertion, carried on the
47
+ // container's glob evidence. Comparing leaves for those would compare 0 to 0 forever: that is
48
+ // exactly how the Go gate went vacuous, passing while a .go file was added or deleted.
49
+ // A model with no glob evidence predates the count; report 0 so it drifts loud, not `undefined`.
50
+ const inModel = spec.evidenceOnly
51
+ ? (((byId.get(spec.parent) || {}).evidence || []).find((e) => e.type === 'glob') || {}).count ?? 0
52
+ : model.nodes.filter((n) => n.kind === 'leaf' && n.status === 'current' && containerOf(n, byId) === spec.parent).length
47
53
  if (live === 0) fail(`phantom parent "${spec.parent}": src glob ${spec.dir} matches 0 files`)
48
54
  if (live !== inModel) fail(`DRIFT: ${spec.parent} — src has ${live} files, model has ${inModel}. Regenerate c4-model.json.`)
49
55
  }
@@ -120,14 +126,37 @@ if (src.statusPath && existsSync(rp(src.statusPath))) {
120
126
  // BACKLOG while the committed model keeps showing a green box at 100% and no gate would notice —
121
127
  // the false green this command exists to kill. Same contract as assertion 2: recompute the truth.
122
128
  // Skipped per field where the curated overlay owns it, since there the overlay is the authority.
123
- const docIdx = indexByNode(loadDocRows(REPO, topo.docSources), model.nodes || [])
129
+ const docPaths = (topo.docSources || []).map((e) => (typeof e === 'string' ? e : (e || {}).path)).filter(Boolean)
130
+ // A listed source that is GONE derives nothing — and "derives nothing" used to read as "no drift",
131
+ // so the committed model kept citing a file that no longer existed and the gate said OK.
132
+ for (const rel of docPaths) if (!existsSync(rp(rel))) fail(`DOC SOURCE: ${rel} is listed in docSources but the file is gone — every state derived from it is unverifiable.`)
133
+ const docRows = loadDocRows(REPO, topo.docSources)
134
+ for (const r of docRows.filter((x) => x.dead.length)) fail(`DOC REF: ${r.from} row "${r.text.slice(0, 60)}" cites ${r.dead.join(', ')}, which does not exist — the row silently stops counting.`)
135
+ const docIdx = indexByNode(docRows, model.nodes || [])
124
136
  for (const n of model.nodes || []) {
125
137
  const want = statusFor(docIdx, n.id)
126
- if (!want) continue
127
138
  const owned = overlay[n.id] || {}
139
+ if (!want) {
140
+ // A state DERIVED from a document must keep deriving. The document can lose the row (a renamed
141
+ // code_ref) or GROW past MAX_ROWS — either way the derivation falls silent while the committed
142
+ // green box and its "(N/N done)" citation keep shipping. Trusting that silence IS the false green.
143
+ if ((n.verify || {}).derived === true && owned.status2 === undefined && owned.completion === undefined && owned.verify === undefined) {
144
+ fail(`DOC DRIFT: ${n.id} still claims "${n.verify.source}" (${n.status2}/${n.completion}) but no row in ${docPaths.join(', ')} derives a state for it any more. Regenerate c4-model.json.`)
145
+ }
146
+ continue
147
+ }
128
148
  if (owned.status2 === undefined && n.status2 !== want.status2) fail(`DOC DRIFT: ${n.id} status2 is "${n.status2}" but ${want.source} derives "${want.status2}". Regenerate c4-model.json.`)
129
149
  if (owned.completion === undefined && n.completion !== want.completion) fail(`DOC DRIFT: ${n.id} completion is ${n.completion} but ${want.source} derives ${want.completion}. Regenerate c4-model.json.`)
130
150
  }
151
+ // The BOX TEXT is quoted from those same rows, and nothing re-derived it: edit the sentence, delete
152
+ // the document, or push the node past MAX_ROWS, and the quote keeps shipping with `descSource:
153
+ // "docmap"` vouching for a document that no longer says it.
154
+ for (const n of model.nodes || []) {
155
+ if (n.descSource !== 'docmap') continue
156
+ const rows = describingRows(docIdx, n.id)
157
+ const txt = rows ? rows.map((r) => r.text).join(' · ').slice(0, 240) : null
158
+ if (n.func !== txt) fail(`DOC DRIFT: ${n.id} quotes "${String(n.func).slice(0, 60)}" as coming from ${docPaths.join(', ')}, which no longer says it. Regenerate c4-model.json.`)
159
+ }
131
160
 
132
161
  // SOFT: LLM-enriched prose whose inputs changed is only a reminder, never a gate failure (structure
133
162
  // is the gate; enrichment freshness is advisory). check never calls the LLM — it recomputes the hash.
package/lib/describe.mjs CHANGED
@@ -72,7 +72,13 @@ function buildArc42Index(repo, docPath) {
72
72
  function measuredFunc(node, ctx) {
73
73
  const kids = [...ctx.byId.values()].filter((k) => k.parent === node.id)
74
74
  .sort((a, b) => (String(a.name) < String(b.name) ? -1 : String(a.name) > String(b.name) ? 1 : 0))
75
- if (!kids.length) return null
75
+ // A node with no children still measures: its glob evidence carries the file count the drift gate
76
+ // re-walks. Without this a Go package — one node, files as internal detail — fell through to an
77
+ // EMPTY box, where the old duplicate leaf at least said "1 file".
78
+ if (!kids.length) {
79
+ const g = (node.evidence || []).find((e) => e.type === 'glob')
80
+ return g && g.count ? `${g.count} source file${g.count === 1 ? '' : 's'}.` : null
81
+ }
76
82
  const count = (k) => kids.filter((x) => x.kind === k).length
77
83
  const parts = [['container', count('container')], ['component', count('component')], ['file', count('leaf')]]
78
84
  .filter((p) => p[1]).map((p) => `${p[1]} ${p[0]}${p[1] === 1 ? '' : 's'}`)
package/lib/docmap.mjs CHANGED
@@ -10,8 +10,8 @@
10
10
  //
11
11
  // Every sentence it yields is quoted from a document under `repo`; provenance is `descSource:
12
12
  // 'docmap'` plus `verify.source` naming the file. Nothing here composes, paraphrases or infers text.
13
- import { readFileSync, existsSync } from 'node:fs'
14
- import { join } from 'node:path'
13
+ import { readFileSync, existsSync, readdirSync } from 'node:fs'
14
+ import { join, dirname, basename } from 'node:path'
15
15
 
16
16
  // A node matching MORE rows than this is not DESCRIBED by the matrix — it is merely TOUCHED by
17
17
  // many features (on haben, `internal/store` is referenced by 12 rows and `internal/server` by 20).
@@ -74,8 +74,20 @@ function refsIn(cell) {
74
74
  * stakeholder's box — inventing, which is the one thing this must never do. An inventory says
75
75
  * whether each capability is finished; a work plan does not. A source listed BY HAND is trusted
76
76
  * as-is, status column or not.
77
- * @returns {{text:string, refs:string[], done:boolean|null, from:string}[]}
77
+ * @returns {{text:string, refs:string[], dead:string[], done:boolean|null, from:string}[]}
78
78
  */
79
+ // A ref is the row's EVIDENCE: the code it claims implements the capability. If nothing on disk
80
+ // answers to it, the claim is unfalsifiable — and worse, the row silently stops touching the node
81
+ // it used to describe, so an unfinished capability drops OUT of the completion denominator and the
82
+ // box turns green with a freshly re-derived "(1/1 done)". Measured: renaming one `code_ref` cell
83
+ // took a container from in-progress/50 to done/100, and `check` confirmed the new number.
84
+ // ponytail: prefix match, so a truncated ref (`src/billing/dunn`) still reads as alive — the price
85
+ // of supporting glob stems like `internal/imports/statement*.go`. Upgrade path: keep the raw cell
86
+ // and relax the rule only for refs that actually carried a `*`.
87
+ const alive = (repo, ref) => {
88
+ if (existsSync(join(repo, ref))) return true
89
+ try { return readdirSync(join(repo, dirname(ref))).some((e) => e.startsWith(basename(ref))) } catch { return false }
90
+ }
79
91
  export function loadDocRows(repo, docSources, inventoryOnly = false) {
80
92
  const out = []
81
93
  for (const entry of docSources || []) {
@@ -99,7 +111,8 @@ export function loadDocRows(repo, docSources, inventoryOnly = false) {
99
111
  const refs = refsIn(r[iM])
100
112
  const txt = String(r[iD]).replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim()
101
113
  if (!refs.length || !txt) continue
102
- out.push({ text: txt.slice(0, 240), refs, done: iS < 0 ? null : DONE.test(String(r[iS]).replace(/[*_`]+/g, '').trim()), from: rel })
114
+ out.push({ text: txt.slice(0, 240), refs, dead: refs.filter((x) => !alive(repo, x)),
115
+ done: iS < 0 ? null : DONE.test(String(r[iS]).replace(/[*_`]+/g, '').trim()), from: rel })
103
116
  }
104
117
  }
105
118
  }
package/lib/gen.mjs CHANGED
@@ -74,7 +74,11 @@ for (const spec of topo.leafSources) {
74
74
  if (!byId.has(spec.parent)) fail('leafSource parent unknown: ' + spec.parent)
75
75
  const files = walk(spec)
76
76
  if (files.length === 0) fail(`leafSource matched 0 files (phantom node?): ${spec.dir}`)
77
- for (const f of files) {
77
+ // `evidenceOnly`: the files are re-walked for the gate but never become boxes — the unit of
78
+ // architecture is the container itself (a Go package). Its own `path` evidence is what joins it
79
+ // to a capability table (docmap matches on `path`), points describe's README lookup at its own
80
+ // directory rather than its parent's, and gives `check` an evidence path to assert exists.
81
+ if (!spec.evidenceOnly) for (const f of files) {
78
82
  add({
79
83
  id: `${spec.parent}__${f.replace(/[^a-z0-9]+/gi, '_')}`,
80
84
  level: 'leaf', parent: spec.parent, kind: 'leaf',
@@ -87,7 +91,8 @@ for (const spec of topo.leafSources) {
87
91
  }
88
92
  // attach glob count to the parent (drift anchor)
89
93
  const parent = byId.get(spec.parent)
90
- parent.evidence = [...(parent.evidence || []), { type: 'glob', ref: spec.dir, count: files.length }]
94
+ parent.evidence = [...(parent.evidence || []), { type: 'glob', ref: spec.dir, count: files.length },
95
+ ...(spec.evidenceOnly ? [{ type: 'path', ref: spec.dir }] : [])]
91
96
  }
92
97
 
93
98
  // 3) curated leaves (mixed-location, with optional computed counts)
@@ -130,6 +135,14 @@ if (CLUSTER) for (const cid of [...new Set(topo.leafSources.map((s) => s.parent)
130
135
  // §docmap) join the repo's own capability tables to the nodes, ONCE: the same match feeds both the
131
136
  // description (§1a below) and the programme state derived just under here.
132
137
  const docRows = loadDocRows(REPO, topo.docSources)
138
+ // A row whose code_ref resolves to nothing does not merely go unverified: it stops TOUCHING its
139
+ // node, so the capability it describes leaves the completion denominator and the box goes green.
140
+ // Renaming one cell took a container from in-progress/50 to done/100 with a fresh "(1/1 done)"
141
+ // citation, and `check` re-derived and confirmed the new number. Fail on it.
142
+ const deadRows = docRows.filter((r) => r.dead.length)
143
+ if (deadRows.length) fail('docSources cite code that does not exist:\n - ' +
144
+ deadRows.map((r) => `${r.from}: "${r.text.slice(0, 60)}" → ${r.dead.join(', ')}`).join('\n - ') +
145
+ '\n[gen-c4] a row whose code_ref resolves to nothing silently stops counting: its capability drops out of the denominator and the box turns green. Fix the ref, or the path.')
133
146
  const docIndex = indexByNode(docRows, nodes)
134
147
  if (docRows.length) console.log(`[gen-c4] docSources: ${docRows.length} row(s) → ${docIndex.size} node(s) touched, ${nodes.filter((n) => describingRows(docIndex, n.id)).length} described`)
135
148
 
@@ -144,8 +157,15 @@ for (const n of nodes) {
144
157
  // Progress a DOCUMENT states outright — the only generated alternative to hand-writing the
145
158
  // overlay. Derived, so it is re-derived by `check` rather than trusted; the curated overlay
146
159
  // (§WP-A1, applied further down) still overrides every field of it.
147
- const ds = !n.status2 ? statusFor(docIndex, n.id) : null
148
- if (ds) { n.status2 = ds.status2; if (n.completion == null) n.completion = ds.completion; if (!n.verify) n.verify = { source: ds.source } }
160
+ // NOT the system node. Its subtree is the whole repo, so EVERY row in the document touches it and
161
+ // its denominator is "the rows somebody wrote", never "the repo". A three-row all-done matrix
162
+ // rendered the whole product as done/100 on the first screen anyone opens, with a container the
163
+ // document never mentions sitting right underneath at `unknown`. The whole-product verdict is
164
+ // exactly what the curated overlay is for.
165
+ const ds = !n.status2 && n.kind !== 'system' ? statusFor(docIndex, n.id) : null
166
+ // `derived: true` is the marker `check` keys off to know this state must keep re-deriving. A
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 } }
149
169
  // Code can prove a file EXISTS; it cannot prove the work behind it is finished. Marking every
150
170
  // undecorated node done/100 turned a virgin repo into a board reading "10/10 complete" — the
151
171
  // exact false green this tool exists to kill. No overlay (§WP-A1), no verdict: `unknown`, and
@@ -281,7 +301,7 @@ if (ENRICH && ENRICHER === 'agent') {
281
301
  // A language that DECLARES its dependencies gets its adapter instead (lib/lang.mjs): guessing from
282
302
  // names where an `import` block states the fact is strictly worse, and gets the direction wrong.
283
303
  if (!process.argv.includes('--no-auto-edges') && isGo(topo.meta && topo.meta.stack)) {
284
- const derived = goEdges({ repo: REPO, nodes, byId, containerOf, edges: topo.edges })
304
+ const derived = goEdges({ repo: REPO, nodes, edges: topo.edges })
285
305
  topo.edges = [...(topo.edges || []), ...derived]
286
306
  console.log(`[gen-c4] auto-edges: +${derived.length} container edge(s) derived from Go import blocks`)
287
307
  } else if (!process.argv.includes('--no-auto-edges')) {
package/lib/init.mjs CHANGED
@@ -95,24 +95,25 @@ const skipDir = (e) => IGNORE.has(e) || e.startsWith('.') || (SKIP_DIRS.has(e.to
95
95
  // Go: the container is the PACKAGE, not the shallowest directory that happens to hold code. Go
96
96
  // nests packages freely (internal/store, internal/server, …) and `internal/` itself is usually not
97
97
  // one — stopping at the first level collapses thirty units of architecture into a single box. The
98
- // leaf is the package too: the files inside it are internal organisation nobody presents, so the
99
- // leafSource points at the parent directory and matches the package directory by name (a leaf must
100
- // stay a real, re-derivable entry on disk that is what the drift gate re-counts).
98
+ // package is ONE node. It used to be a container AND a leaf matching that same directory, so
99
+ // drilling into a package showed the package again 53 of 53 on a real Go repo, a level drawn
100
+ // twice. Per #17 the leaf level stops at the package and the files inside stay internal detail, so
101
+ // the leafSource is `evidenceOnly`: the gate still re-walks the real *.go files, but they become a
102
+ // COUNT on the container instead of boxes. That count is also what the old shape could never see —
103
+ // it matched one fixed directory name, so adding or deleting a .go file passed the gate untouched.
101
104
  if (GO) {
102
105
  for (const rel of goPackages(root, skipDir)) {
103
- // ponytail: a package at the module ROOT falls through to the loose-files branch below, which
104
- // keeps the old file-per-leaf shape (there is no parent directory to match its name in). Its
105
- // box is real but stays edge-less, since goEdges only follows directory leaves. Rare layout
106
- // give the root package its own directory (cmd/…) and it behaves like every other one.
106
+ // A package at the module ROOT is handled by the loose-files branch below (there is no parent
107
+ // directory to name it from here). It is `evidenceOnly` too, and goEdges now keys on the
108
+ // container's own glob evidence, so `dir: "."` maps to the bare module path and it gets edges.
107
109
  if (!rel) continue
108
- const cut = rel.lastIndexOf('/')
109
110
  const id = claim(rel)
110
111
  nodes.push({ id, level: 'container', kind: 'container', parent: sysId, name: rel, tech: lang })
111
- leafSources.push({ parent: id, dir: cut < 0 ? '.' : rel.slice(0, cut), match: '^' + rel.slice(cut + 1).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$', filesOnly: false })
112
+ leafSources.push({ parent: id, dir: rel, match: matchRe, exclude: '_test\\.go$', evidenceOnly: true })
112
113
  }
113
114
  } else findContainers(root, 0)
114
115
  // loose files directly in root → an entry container
115
- 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$' } : {}) }) }
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 } : {}) }) }
116
117
 
117
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) }
118
119
 
package/lib/lang.mjs CHANGED
@@ -64,22 +64,23 @@ const goModulePath = (repo) => {
64
64
  try { return (readFileSync(join(repo, 'go.mod'), 'utf-8').match(/^module\s+(\S+)/m) || [])[1] || null } catch { return null }
65
65
  }
66
66
 
67
- // Container→container edges from the import blocks. A Go package leaf carries its own DIRECTORY as
68
- // path evidence, so the model itself says which directory each container is — no id convention to
69
- // keep in sync. Only intra-module imports become edges (the stdlib and third parties are outside
70
- // the model). Direction is right by construction: from = the importer.
67
+ // Container→container edges from the import blocks. A Go package container carries its own
68
+ // DIRECTORY as glob evidence, so the model itself says which directory each container is — no id
69
+ // convention to keep in sync. Only intra-module imports become edges (the stdlib and third parties
70
+ // are outside the model). Direction is right by construction: from = the importer.
71
71
  // Deliberately NOT the bidirectional dedup the heuristic pass uses — a declared import is a fact,
72
72
  // and a curated edge pointing the other way must not suppress it.
73
- export function goEdges({ repo, nodes, byId, containerOf, edges }) {
73
+ // Keyed on the container's OWN id, not on containerOf(): a package is now one node, and keying on
74
+ // the ancestor would collapse every edge inside a future `internal` grouping into a self-loop.
75
+ export function goEdges({ repo, nodes, edges }) {
74
76
  const mod = goModulePath(repo)
75
77
  if (!mod) return []
76
78
  const pkgs = []
77
79
  for (const n of nodes) {
78
- if (n.kind !== 'leaf') continue
79
- const ev = (n.evidence || []).find((e) => e.type === 'path')
80
+ if (n.kind !== 'container') continue
81
+ const ev = (n.evidence || []).find((e) => e.type === 'glob')
80
82
  if (!ev) continue
81
- try { if (!statSync(join(repo, ev.ref)).isDirectory()) continue } catch { continue }
82
- pkgs.push({ dir: posix(ev.ref), container: containerOf(n, byId) })
83
+ pkgs.push({ dir: posix(ev.ref), container: n.id })
83
84
  }
84
85
  const byImport = new Map(pkgs.map((p) => [p.dir === '.' ? mod : mod + '/' + p.dir, p.container]))
85
86
  const have = new Set((edges || []).map((e) => e.from + '|' + e.to))
@@ -68,6 +68,13 @@ svg{width:100%;height:100%;display:block}
68
68
  .edge.flow{stroke-dasharray:5 7;animation:dash 1.1s linear infinite}
69
69
  .edge.plan{stroke:var(--edgeplan);stroke-dasharray:3 6;opacity:.6;marker-end:url(#arp)}
70
70
  .edge.hot{stroke:var(--cyan);stroke-width:2.6;opacity:1}
71
+ /* A level past the label threshold is a hairball, and no layout saves it: on a real Go repo 172 of
72
+ 189 arrows cross a box that is not one of their endpoints, and that stays 85-91% at EVERY grid
73
+ shape tried. Only drawing less helps. They stop competing with the names — structure survives as
74
+ texture — and hover still brings one arrow back to full strength, which is where its label is. */
75
+ svg.dense .edge{stroke-width:.9;opacity:.26}
76
+ svg.dense .edge.flow{animation:none;stroke-dasharray:none}
77
+ svg.dense .edge.hot{opacity:1;stroke-width:2.6}
71
78
  .edgehit{stroke:transparent;stroke-width:14;fill:none;cursor:help}
72
79
  .elbl{font-size:8.2px;fill:var(--elbl);letter-spacing:.04em;pointer-events:none;
73
80
  paint-order:stroke;stroke:var(--stagebg);stroke-width:3px;stroke-linejoin:round}
@@ -225,6 +232,21 @@ var LABEL_AUTO_MAX=14; // above this many arrows in the current level, fixed
225
232
  var LABELS=null; // null = auto (by arrow count); true/false = user override for this session
226
233
  var SKINS=[["holo","◆ holo"],["blueprint","▭ blueprint"]], SKIN_IDS=["holo","blueprint"];
227
234
  var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob",unknown:"unk"};
235
+ // The per-level tally. The mean is over the nodes that CARRY a completion, and it reports how many
236
+ // those were: on a real Go repo, 25 containers at 100% next to 28 nobody has ruled on rendered as
237
+ // "progress 100%" — the invented green this tool exists to kill, in the one number a stakeholder
238
+ // reads first. Dividing by every child instead would be the mirror lie: a node with no verdict is
239
+ // not 0% done, which is exactly why `gen` refuses to write a completion nobody curated. So the
240
+ // 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++;}
247
+ }
248
+ return {cnt:cnt,tot:tot,ruled:nc,mean:nc?Math.round(sum/nc):null};
249
+ }
228
250
  var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false
229
251
  var VBOX={}; // last fitted viewBox per level — frozen while dragging so the canvas does not rescale
230
252
  function $(i){return document.getElementById(i)}
@@ -245,7 +267,10 @@ function collapseCatalogs(pid,kids){
245
267
  for(i=0;i<kids.length;i++){
246
268
  var n=kids[i];
247
269
  // only leaf-like, non-drillable nodes with a category are collapse candidates
248
- var collapsible=!hasKids(n.id)&&(n.category!=null&&n.category!=="");
270
+ // A category that is merely the node's own kind — "container" for every container, since gen
271
+ // falls back to n.kind — is not a grouping. Collapsing on it folds a whole level into one box
272
+ // named after the level, which is what a repo of 53 childless containers would do.
273
+ var collapsible=!hasKids(n.id)&&(n.category!=null&&n.category!==""&&n.category!==n.kind);
249
274
  var key=collapsible?("cat:"+n.category):("solo:"+i);
250
275
  if(!groups[key]){groups[key]={key:key,cat:n.category,members:[],collapsible:collapsible};order.push(key);}
251
276
  groups[key].members.push(n);
@@ -281,6 +306,14 @@ function srcOf(n){if(n.verify){if(typeof n.verify==="string")return n.verify;if(
281
306
  var ev=(n.evidence||[]).filter(function(e){return e.type==="doc"||e.type==="path";})[0];return ev?ev.ref:((M.meta&&M.meta.verifyMethod)||"");}
282
307
 
283
308
  var NW=228,NH=118; // NH fits 3 description lines without hitting the [+] DRILL
309
+ // Past DENSE_MIN siblings a full-size card cannot stay legible: the fit-to-content viewBox scales
310
+ // the canvas down to the stage, so 53 cards at 228x118 render 64x33 css px with a 3.24px title.
311
+ // The compact card keeps the one thing that has to survive a projector — the name — near 11px.
312
+ // No other edit is needed: draw() reads w/h from the pos map, never NW/NH, and at h=58 the
313
+ // description's own line budget already falls below one line, so wrapDesc returns nothing.
314
+ // ponytail: holds the 9px floor to ~60 siblings; past that the answer is a real grouping level,
315
+ // not a smaller card.
316
+ var DENSE_MIN=20,DW=168,DH=58,STAGE_ASPECT=2.1; // STAGE_ASPECT: #stage is full-width by 74vh
284
317
  // Curated coordinates from meta.layout win; everything else keeps the automatic arrangement,
285
318
  // placed BELOW the pinned block so a partial hint set can never overlap generated nodes.
286
319
  function seedLayout(kids,hints){
@@ -309,12 +342,19 @@ function autoLayout(kids){
309
342
  pos[others[i].id]={x:cx+rx*Math.cos(a)-NW/2,y:cy+ry*Math.sin(a)-NH/2,w:NW,h:NH};}
310
343
  return {W:W,H:H,pos:pos};
311
344
  }
312
- var n=kids.length,cols=n<=3?Math.max(n,1):Math.ceil(Math.sqrt(n));if(cols>4)cols=4;
313
- var rows=Math.ceil(n/cols),gx=96,gy=82,px=64,py=58;
314
- var blockW=cols*NW+(cols-1)*gx,W=Math.max(1020,blockW+px*2),H=Math.max(560,py*2+rows*NH+(rows-1)*gy);
345
+ var n=kids.length,dense=n>DENSE_MIN;
346
+ var bw=dense?DW:NW,bh=dense?DH:NH,gx=dense?44:96,gy=dense?38:82,px=64,py=58;
347
+ // Columns follow the STAGE's shape instead of a fixed cap of 4. That cap turned a real Go repo's
348
+ // 53 containers into a 1328x2834 ribbon inside a stage twice as wide as tall: the fit-to-content
349
+ // viewBox then did its job and scaled everything to 0.28, i.e. a 3.24px title. Nothing on that
350
+ // screen was readable, and 80% of the width was empty.
351
+ var cols=n<=3?Math.max(n,1):Math.round(Math.sqrt(n*STAGE_ASPECT*(bh+gy)/(bw+gx)));
352
+ if(cols<1)cols=1;if(cols>n)cols=n;
353
+ var rows=Math.ceil(n/cols);
354
+ var blockW=cols*bw+(cols-1)*gx,W=Math.max(1020,blockW+px*2),H=Math.max(560,py*2+rows*bh+(rows-1)*gy);
315
355
  var offx=(W-blockW)/2;
316
356
  for(i=0;i<n;i++){var c=i%cols,r=Math.floor(i/cols);
317
- pos[kids[i].id]={x:offx+c*(NW+gx),y:py+r*(NH+gy),w:NW,h:NH};}
357
+ pos[kids[i].id]={x:offx+c*(bw+gx),y:py+r*(bh+gy),w:bw,h:bh};}
318
358
  return {W:W,H:H,pos:pos};
319
359
  }
320
360
  function layoutFor(kids){var k=keyOf();if(!POS[k])POS[k]=seedLayout(kids,hintsFor(k));
@@ -364,7 +404,7 @@ function draw(animate){
364
404
  var vbk=keyOf();
365
405
  if(drag&&VBOX[vbk]){vx=VBOX[vbk][0];vy=VBOX[vbk][1];vw=VBOX[vbk][2];vh=VBOX[vbk][3];}
366
406
  else VBOX[vbk]=[vx,vy,vw,vh];
367
- var s='<svg viewBox="'+vx.toFixed(1)+' '+vy.toFixed(1)+' '+vw.toFixed(1)+' '+vh.toFixed(1)+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t":"")+'">'
407
+ var s='<svg viewBox="'+vx.toFixed(1)+' '+vy.toFixed(1)+' '+vw.toFixed(1)+' '+vh.toFixed(1)+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t ":"")+(edges.length>LABEL_AUTO_MAX?"dense":"")+'">'
368
408
  +'<defs><marker id="ar" markerWidth="9" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,9 3.5,0 7" fill="'+cssv("--edge")+'"/></marker>'
369
409
  +'<marker id="arp" markerWidth="9" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,9 3.5,0 7" fill="'+cssv("--edgeplan")+'"/></marker></defs>';
370
410
  for(j=0;j<geo.length;j++){
@@ -380,6 +420,11 @@ function draw(animate){
380
420
  var pulse=(st==="next"||st==="prog")&&mode!=="target"&&!n.__catalog;
381
421
  var isCat=!!n.__catalog,dr=!isCat&&hasKids(n.id),cls="nd s-"+st+(dr?" drill":"")+(isCat?" cat":"")+(pulse?" pulse":"");
382
422
  var desc=isCat?(n.count+" "+STR.rosterCount):(mode==="target"?(n.target||n.func||""):(n.current||n.func||""));
423
+ // 26 of a real repo's 53 boxes printed "1 file: advisor." under a box titled "internal/advisor".
424
+ // A description that only restates the title is ink, not information: drop it and let the box
425
+ // breathe. The measured fallback text is the generator's honest last resort, not a lie — it just
426
+ // does not belong on screen when it says nothing the header has not already said.
427
+ if(!isCat&&echoesName(desc,n.name))desc="";
383
428
  var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":STR.stUnk)));
384
429
  // the title runs until the badge, so reserve the badge's actual width (9px text ≈ 5.4px/char)
385
430
  // instead of a flat 70px — a fixed reserve clipped names that had all the room they needed
@@ -414,19 +459,15 @@ function draw(animate){
414
459
  var lvlIdx=((M.levels||[]).indexOf(lvlKey));
415
460
  var cb=(lvlIdx>-1?STR.crumbLevel+"-L"+(lvlIdx+1):STR.crumbLevel)+" · "+lvl.toUpperCase()+" &nbsp;&nbsp; <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
416
461
  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>");
417
- // Per-level programme tally — the status-board essence in one glance: the status mix of THIS
418
- // view plus mean completion. Counts use the real children (a collapsed catalogue still counts
419
- // its members), so the tally never changes when the catalogue threshold does.
420
- var cnt={},tot=0,sum=0,nc=0,st3;
421
- for(j=0;j<realKids.length;j++){
422
- st3=STMAP[realKids[j].status2]||(realKids[j].status==="planned"?"plan":"unk");
423
- cnt[st3]=(cnt[st3]||0)+1;tot++;
424
- if(realKids[j].completion!=null){sum+=(+realKids[j].completion||0);nc++;}
425
- }
426
- if(tot){
462
+ // Per-level programme tally — the status-board essence in one glance. Counts use the real
463
+ // children (a collapsed catalogue still counts its members), so the tally never changes when the
464
+ // catalogue threshold does.
465
+ var T=tallyOf(realKids);
466
+ if(T.tot){
427
467
  var DOT={done:"#2fe08a",prog:"#ffc74d",next:"#3ee7ff",plan:"#2d5a72",prob:"#ff5d5d",unk:"#7d8f99"},ord=["done","prog","next","plan","prob","unk"],agg="";
428
- for(j=0;j<ord.length;j++)if(cnt[ord[j]])agg+='<i style="background:'+DOT[ord[j]]+'"></i>'+cnt[ord[j]];
429
- if(nc)agg+=' &nbsp;'+esc(STR.aggProgress)+' <b>'+Math.round(sum/nc)+'%</b>';
468
+ for(j=0;j<ord.length;j++)if(T.cnt[ord[j]])agg+='<i style="background:'+DOT[ord[j]]+'"></i>'+T.cnt[ord[j]];
469
+ // The coverage rides WITH the percentage, never without it. See tallyOf.
470
+ if(T.ruled)agg+=' &nbsp;'+esc(STR.aggProgress)+' <b>'+T.mean+'%</b> '+T.ruled+'/'+T.tot;
430
471
  cb='<span class="agg">'+agg+'</span>'+cb;
431
472
  }
432
473
  $("crumb").innerHTML=cb;
@@ -437,6 +478,19 @@ function draw(animate){
437
478
  // cpl — an unbreakable token (a long class name, a URL) would otherwise paint outside the box, and
438
479
  // there is no clip-path on the node. The last line carries the ellipsis when text was dropped,
439
480
  // rather than spending a whole line on it (on a short curated box that was half the visible text).
481
+ // True when a description carries no word the title does not already carry. Pure, so it is testable
482
+ // without a DOM. The stop-list is the vocabulary of the generator's own measured fallback
483
+ // ("1 file: advisor.", "3 packages: a, b, c."), never of real prose.
484
+ var DESC_NOISE={file:1,files:1,package:1,packages:1,module:1,modules:1,dir:1,directory:1,folder:1,component:1,components:1,of:1,and:1,the:1};
485
+ function echoesName(desc,name){
486
+ var norm=function(s){return String(s==null?"":s).toLowerCase().replace(/[^a-z0-9]+/g," ").trim();};
487
+ var d=norm(desc);if(!d)return false;
488
+ var own={},nw=norm(name).split(" "),i;
489
+ for(i=0;i<nw.length;i++)if(nw[i])own[nw[i]]=1;
490
+ var w=d.split(" ");
491
+ 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
+ return true;
493
+ }
440
494
  function wrapDesc(desc,cpl,max){
441
495
  if(max<1||cpl<2)return [];
442
496
  var words=String(desc==null?"":desc).split(" "),all=[],line="",i;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-arch",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",