forma-arch 0.7.2 → 0.9.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
  }
@@ -134,16 +147,36 @@ export function indexByNode(rows, nodes) {
134
147
  const paths = subtree(n.id)
135
148
  if (!paths.length) continue
136
149
  const hit = rows.filter((r) => r.refs.some((ref) => paths.some((p) => touches(ref, p))))
137
- 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) })
138
151
  }
139
152
  return idx
140
153
  }
141
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
+
142
174
  // The rows that DESCRIBE a node (past the cap it is only touched by them — see MAX_ROWS).
143
175
  export const describingRows = (idx, id) => {
144
- const rows = idx.get(id)
145
- return rows && rows.length <= MAX_ROWS ? rows : null
176
+ const e = idx.get(id)
177
+ return e && e.rows.length <= MAX_ROWS ? e.rows : null
146
178
  }
179
+ export const coverageFor = (idx, id) => (idx.get(id) || {}).cover || null
147
180
 
148
181
  // Programme state a document states outright: how many of the capabilities living in this node are
149
182
  // finished. Derived, never curated — the c4-status.json overlay still overrides it (gen.mjs §WP-A1),
@@ -152,9 +185,12 @@ export function statusFor(idx, id) {
152
185
  const rows = describingRows(idx, id)
153
186
  if (!rows || rows.some((r) => r.done == null)) return null // no status column: describe only
154
187
  const done = rows.filter((r) => r.done).length
188
+ const c = coverageFor(idx, id)
155
189
  return {
156
190
  status2: done === rows.length ? 'done' : done === 0 ? 'planned' : 'in-progress',
157
191
  completion: Math.round((done / rows.length) * 100),
158
- source: `${rows[0].from} (${done}/${rows.length} done)`,
192
+ // The citation says what it is: a repo document declaring itself finished, not a verification.
193
+ source: `${rows[0].from} (${done}/${rows.length} declared done)`,
194
+ ...(c ? { coverage: c } : {}),
159
195
  }
160
196
  }
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,18 @@ 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
+ // `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 } : {}) } }
149
172
  // Code can prove a file EXISTS; it cannot prove the work behind it is finished. Marking every
150
173
  // undecorated node done/100 turned a virgin repo into a board reading "10/10 complete" — the
151
174
  // exact false green this tool exists to kill. No overlay (§WP-A1), no verdict: `unknown`, and
@@ -281,7 +304,7 @@ if (ENRICH && ENRICHER === 'agent') {
281
304
  // A language that DECLARES its dependencies gets its adapter instead (lib/lang.mjs): guessing from
282
305
  // names where an `import` block states the fact is strictly worse, and gets the direction wrong.
283
306
  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 })
307
+ const derived = goEdges({ repo: REPO, nodes, edges: topo.edges })
285
308
  topo.edges = [...(topo.edges || []), ...derived]
286
309
  console.log(`[gen-c4] auto-edges: +${derived.length} container edge(s) derived from Go import blocks`)
287
310
  } 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}
@@ -82,7 +89,7 @@ svg{width:100%;height:100%;display:block}
82
89
  #etip{position:absolute;pointer-events:none;display:none;z-index:6;background:var(--detailbg);border:1px solid var(--border);
83
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)}
84
91
  #legend{display:flex;gap:13px;font-size:9.5px;color:var(--sec);margin:10px 0 0;flex-wrap:wrap}
85
- #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}
86
93
  .hint{color:var(--ter2)}
87
94
  #detail{margin-top:12px;border:1px solid var(--border);border-radius:10px;padding:13px;background:var(--detailbg);display:none}
88
95
  #detail h3{color:var(--cyan);font-size:12.5px;margin-bottom:8px;letter-spacing:.05em}
@@ -158,6 +165,10 @@ html[data-skin="blueprint"] button.on{text-shadow:none}
158
165
  <div id="stage"><div id="etip"></div><div id="err" style="display:none"></div></div>
159
166
  <div id="legend">
160
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>
161
172
  <span><i style="background:#ffc74d"></i><span id="lg-prog"></span></span>
162
173
  <span><i style="background:#3ee7ff"></i><span id="lg-next"></span></span>
163
174
  <span><i style="background:#2d5a72"></i><span id="lg-plan"></span></span>
@@ -178,8 +189,10 @@ var STRINGS={
178
189
  labels:"LABELS", labelsTitle:"arrow labels (auto-off above 14 arrows)",
179
190
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"re-arrange",
180
191
  printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", exportLayout:"Export layout JSON", print:"Print",
181
- 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",
182
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",
183
196
  legHint:"— animated arrow = active flow · dashed = to build",
184
197
  crumbRoot:"Context", crumbLevel:"C4", lvlContext:"CONTEXT",
185
198
  lvlNames:{context:"Context",container:"Container",component:"Components",leaf:"Leaves"},
@@ -198,8 +211,10 @@ var STRINGS={
198
211
  labels:"ETICHETTE", labelsTitle:"etichette sugli archi (spente sopra i 14 archi)",
199
212
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"ridisponi",
200
213
  printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", exportLayout:"Esporta layout JSON", print:"Stampa",
201
- 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",
202
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",
203
218
  legHint:"— freccia animata = flusso attivo · tratteggio = da costruire",
204
219
  crumbRoot:"Contesto", crumbLevel:"C4", lvlContext:"CONTESTO",
205
220
  lvlNames:{context:"Contesto",container:"Container",component:"Componenti",leaf:"Foglie"},
@@ -260,7 +275,10 @@ function collapseCatalogs(pid,kids){
260
275
  for(i=0;i<kids.length;i++){
261
276
  var n=kids[i];
262
277
  // only leaf-like, non-drillable nodes with a category are collapse candidates
263
- var collapsible=!hasKids(n.id)&&(n.category!=null&&n.category!=="");
278
+ // A category that is merely the node's own kind — "container" for every container, since gen
279
+ // falls back to n.kind — is not a grouping. Collapsing on it folds a whole level into one box
280
+ // named after the level, which is what a repo of 53 childless containers would do.
281
+ var collapsible=!hasKids(n.id)&&(n.category!=null&&n.category!==""&&n.category!==n.kind);
264
282
  var key=collapsible?("cat:"+n.category):("solo:"+i);
265
283
  if(!groups[key]){groups[key]={key:key,cat:n.category,members:[],collapsible:collapsible};order.push(key);}
266
284
  groups[key].members.push(n);
@@ -296,6 +314,14 @@ function srcOf(n){if(n.verify){if(typeof n.verify==="string")return n.verify;if(
296
314
  var ev=(n.evidence||[]).filter(function(e){return e.type==="doc"||e.type==="path";})[0];return ev?ev.ref:((M.meta&&M.meta.verifyMethod)||"");}
297
315
 
298
316
  var NW=228,NH=118; // NH fits 3 description lines without hitting the [+] DRILL
317
+ // Past DENSE_MIN siblings a full-size card cannot stay legible: the fit-to-content viewBox scales
318
+ // the canvas down to the stage, so 53 cards at 228x118 render 64x33 css px with a 3.24px title.
319
+ // The compact card keeps the one thing that has to survive a projector — the name — near 11px.
320
+ // No other edit is needed: draw() reads w/h from the pos map, never NW/NH, and at h=58 the
321
+ // description's own line budget already falls below one line, so wrapDesc returns nothing.
322
+ // ponytail: holds the 9px floor to ~60 siblings; past that the answer is a real grouping level,
323
+ // not a smaller card.
324
+ var DENSE_MIN=20,DW=168,DH=58,STAGE_ASPECT=2.1; // STAGE_ASPECT: #stage is full-width by 74vh
299
325
  // Curated coordinates from meta.layout win; everything else keeps the automatic arrangement,
300
326
  // placed BELOW the pinned block so a partial hint set can never overlap generated nodes.
301
327
  function seedLayout(kids,hints){
@@ -324,12 +350,19 @@ function autoLayout(kids){
324
350
  pos[others[i].id]={x:cx+rx*Math.cos(a)-NW/2,y:cy+ry*Math.sin(a)-NH/2,w:NW,h:NH};}
325
351
  return {W:W,H:H,pos:pos};
326
352
  }
327
- var n=kids.length,cols=n<=3?Math.max(n,1):Math.ceil(Math.sqrt(n));if(cols>4)cols=4;
328
- var rows=Math.ceil(n/cols),gx=96,gy=82,px=64,py=58;
329
- 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);
353
+ var n=kids.length,dense=n>DENSE_MIN;
354
+ var bw=dense?DW:NW,bh=dense?DH:NH,gx=dense?44:96,gy=dense?38:82,px=64,py=58;
355
+ // Columns follow the STAGE's shape instead of a fixed cap of 4. That cap turned a real Go repo's
356
+ // 53 containers into a 1328x2834 ribbon inside a stage twice as wide as tall: the fit-to-content
357
+ // viewBox then did its job and scaled everything to 0.28, i.e. a 3.24px title. Nothing on that
358
+ // screen was readable, and 80% of the width was empty.
359
+ var cols=n<=3?Math.max(n,1):Math.round(Math.sqrt(n*STAGE_ASPECT*(bh+gy)/(bw+gx)));
360
+ if(cols<1)cols=1;if(cols>n)cols=n;
361
+ var rows=Math.ceil(n/cols);
362
+ 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);
330
363
  var offx=(W-blockW)/2;
331
364
  for(i=0;i<n;i++){var c=i%cols,r=Math.floor(i/cols);
332
- pos[kids[i].id]={x:offx+c*(NW+gx),y:py+r*(NH+gy),w:NW,h:NH};}
365
+ pos[kids[i].id]={x:offx+c*(bw+gx),y:py+r*(bh+gy),w:bw,h:bh};}
333
366
  return {W:W,H:H,pos:pos};
334
367
  }
335
368
  function layoutFor(kids){var k=keyOf();if(!POS[k])POS[k]=seedLayout(kids,hintsFor(k));
@@ -379,7 +412,7 @@ function draw(animate){
379
412
  var vbk=keyOf();
380
413
  if(drag&&VBOX[vbk]){vx=VBOX[vbk][0];vy=VBOX[vbk][1];vw=VBOX[vbk][2];vh=VBOX[vbk][3];}
381
414
  else VBOX[vbk]=[vx,vy,vw,vh];
382
- var s='<svg viewBox="'+vx.toFixed(1)+' '+vy.toFixed(1)+' '+vw.toFixed(1)+' '+vh.toFixed(1)+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t":"")+'">'
415
+ 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":"")+'">'
383
416
  +'<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>'
384
417
  +'<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>';
385
418
  for(j=0;j<geo.length;j++){
@@ -395,6 +428,11 @@ function draw(animate){
395
428
  var pulse=(st==="next"||st==="prog")&&mode!=="target"&&!n.__catalog;
396
429
  var isCat=!!n.__catalog,dr=!isCat&&hasKids(n.id),cls="nd s-"+st+(dr?" drill":"")+(isCat?" cat":"")+(pulse?" pulse":"");
397
430
  var desc=isCat?(n.count+" "+STR.rosterCount):(mode==="target"?(n.target||n.func||""):(n.current||n.func||""));
431
+ // 26 of a real repo's 53 boxes printed "1 file: advisor." under a box titled "internal/advisor".
432
+ // A description that only restates the title is ink, not information: drop it and let the box
433
+ // breathe. The measured fallback text is the generator's honest last resort, not a lie — it just
434
+ // does not belong on screen when it says nothing the header has not already said.
435
+ if(!isCat&&echoesName(desc,n.name))desc="";
398
436
  var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":STR.stUnk)));
399
437
  // the title runs until the badge, so reserve the badge's actual width (9px text ≈ 5.4px/char)
400
438
  // instead of a flat 70px — a fixed reserve clipped names that had all the room they needed
@@ -448,6 +486,26 @@ function draw(animate){
448
486
  // cpl — an unbreakable token (a long class name, a URL) would otherwise paint outside the box, and
449
487
  // there is no clip-path on the node. The last line carries the ellipsis when text was dropped,
450
488
  // rather than spending a whole line on it (on a short curated box that was half the visible text).
489
+ // True when a description carries no word the title does not already carry. Pure, so it is testable
490
+ // without a DOM. The stop-list is the vocabulary of the generator's own measured fallback
491
+ // ("1 file: advisor.", "3 packages: a, b, c."), never of real prose.
492
+ 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};
493
+ function echoesName(desc,name){
494
+ var norm=function(s){return String(s==null?"":s).toLowerCase().replace(/[^a-z0-9]+/g," ").trim();};
495
+ var d=norm(desc);if(!d)return false;
496
+ var own={},nw=norm(name).split(" "),i;
497
+ for(i=0;i<nw.length;i++)if(nw[i])own[nw[i]]=1;
498
+ var w=d.split(" ");
499
+ for(i=0;i<w.length;i++){if(!w[i]||own[w[i]]||DESC_NOISE[w[i]]||/^\d+$/.test(w[i]))continue;return false;}
500
+ return true;
501
+ }
502
+ // Pure, so it is testable without a DOM. Empty when nothing was derived — a curated or a
503
+ // gh-verified state has no document reach to report.
504
+ function coverText(n){
505
+ var c=n&&n.verify&&n.verify.coverage;if(!c)return "";
506
+ if(c.whole)return STR.coverWhole;
507
+ return STR.coverPart.replace("{n}",c.named).replace("{t}",c.total);
508
+ }
451
509
  function wrapDesc(desc,cpl,max){
452
510
  if(max<1||cpl<2)return [];
453
511
  var words=String(desc==null?"":desc).split(" "),all=[],line="",i;
@@ -482,7 +540,12 @@ function showDetail(n){
482
540
  +(n.func?'<div class="row"><span class="lbl">'+esc(STR.detFunc)+'</span><span class="func">'+esc(n.func)+'</span></div>':'')
483
541
  +(cur&&cur!==n.func?'<div class="row"><span class="lbl">'+esc(STR.detNow)+'</span><span class="now">'+esc(cur)+'</span></div>':'')
484
542
  +((n.target&&n.target!=="—")?'<div class="row"><span class="lbl">'+esc(STR.detTgt)+'</span><span class="tgt">'+esc(n.target)+'</span></div>':'')
485
- +(showSrc?'<div class="row"><span class="lbl">'+esc(STR.detSrc)+'</span><span class="src">'+esc(src)+'</span></div>':'')
543
+ // A number DERIVED from a repo document is a declaration, not a verification: the label says which
544
+ // one, and the coverage says how much of this module the citation actually reaches. "3 of 3 rows
545
+ // declared done" and "this module is done" are not the same sentence when the module holds 22
546
+ // files and the document names 3 of them.
547
+ +(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>':'')
548
+ +(coverText(n)?'<div class="row"><span class="lbl">'+esc(STR.detCover)+'</span><span class="now">'+esc(coverText(n))+'</span></div>':'')
486
549
  +((n.issues&&n.issues.length)?'<div class="row"><span class="lbl">'+esc(STR.detIssue)+'</span><span class="now">'+esc(n.issues.join(", "))+'</span></div>':'');
487
550
  $("detail").style.display="block";
488
551
  var dc=$("dc");if(dc)dc.addEventListener("click",function(){$("detail").style.display="none";});
@@ -602,7 +665,7 @@ function applyStrings(){
602
665
  set("blbl",STR.labels);var bl=$("blbl");if(bl)bl.title=STR.labelsTitle;
603
666
  set("pexp",STR.printExport);set("pexpSvg",STR.exportSvg);set("pexpPng",STR.exportPng);
604
667
  set("pexpLayout",STR.exportLayout);set("pexpPrint",STR.print);
605
- set("lg-done",STR.legDone);set("lg-prog",STR.legProg);set("lg-next",STR.legNext);
668
+ set("lg-done",STR.legDone);set("lg-decl",STR.legDecl);set("lg-prog",STR.legProg);set("lg-next",STR.legNext);
606
669
  set("lg-plan",STR.legPlan);set("lg-prob",STR.legProb);set("lg-unk",STR.legUnk);set("lg-hint",STR.legHint);
607
670
  }
608
671
  // ---- 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.7.2",
3
+ "version": "0.9.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",