forma-arch 0.7.2 → 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 +33 -4
- package/lib/describe.mjs +7 -1
- package/lib/docmap.mjs +17 -4
- package/lib/gen.mjs +25 -5
- package/lib/init.mjs +11 -10
- package/lib/lang.mjs +10 -9
- package/lib/viewer/c4-hologram.html +49 -6
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
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,
|
|
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
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
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:
|
|
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
|
|
68
|
-
//
|
|
69
|
-
// keep in sync. Only intra-module imports become edges (the stdlib and third parties
|
|
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
|
-
|
|
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 !== '
|
|
79
|
-
const ev = (n.evidence || []).find((e) => e.type === '
|
|
80
|
+
if (n.kind !== 'container') continue
|
|
81
|
+
const ev = (n.evidence || []).find((e) => e.type === 'glob')
|
|
80
82
|
if (!ev) continue
|
|
81
|
-
|
|
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}
|
|
@@ -260,7 +267,10 @@ function collapseCatalogs(pid,kids){
|
|
|
260
267
|
for(i=0;i<kids.length;i++){
|
|
261
268
|
var n=kids[i];
|
|
262
269
|
// only leaf-like, non-drillable nodes with a category are collapse candidates
|
|
263
|
-
|
|
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);
|
|
264
274
|
var key=collapsible?("cat:"+n.category):("solo:"+i);
|
|
265
275
|
if(!groups[key]){groups[key]={key:key,cat:n.category,members:[],collapsible:collapsible};order.push(key);}
|
|
266
276
|
groups[key].members.push(n);
|
|
@@ -296,6 +306,14 @@ function srcOf(n){if(n.verify){if(typeof n.verify==="string")return n.verify;if(
|
|
|
296
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)||"");}
|
|
297
307
|
|
|
298
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
|
|
299
317
|
// Curated coordinates from meta.layout win; everything else keeps the automatic arrangement,
|
|
300
318
|
// placed BELOW the pinned block so a partial hint set can never overlap generated nodes.
|
|
301
319
|
function seedLayout(kids,hints){
|
|
@@ -324,12 +342,19 @@ function autoLayout(kids){
|
|
|
324
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};}
|
|
325
343
|
return {W:W,H:H,pos:pos};
|
|
326
344
|
}
|
|
327
|
-
var n=kids.length,
|
|
328
|
-
var
|
|
329
|
-
|
|
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);
|
|
330
355
|
var offx=(W-blockW)/2;
|
|
331
356
|
for(i=0;i<n;i++){var c=i%cols,r=Math.floor(i/cols);
|
|
332
|
-
pos[kids[i].id]={x:offx+c*(
|
|
357
|
+
pos[kids[i].id]={x:offx+c*(bw+gx),y:py+r*(bh+gy),w:bw,h:bh};}
|
|
333
358
|
return {W:W,H:H,pos:pos};
|
|
334
359
|
}
|
|
335
360
|
function layoutFor(kids){var k=keyOf();if(!POS[k])POS[k]=seedLayout(kids,hintsFor(k));
|
|
@@ -379,7 +404,7 @@ function draw(animate){
|
|
|
379
404
|
var vbk=keyOf();
|
|
380
405
|
if(drag&&VBOX[vbk]){vx=VBOX[vbk][0];vy=VBOX[vbk][1];vw=VBOX[vbk][2];vh=VBOX[vbk][3];}
|
|
381
406
|
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":"")+'">'
|
|
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":"")+'">'
|
|
383
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>'
|
|
384
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>';
|
|
385
410
|
for(j=0;j<geo.length;j++){
|
|
@@ -395,6 +420,11 @@ function draw(animate){
|
|
|
395
420
|
var pulse=(st==="next"||st==="prog")&&mode!=="target"&&!n.__catalog;
|
|
396
421
|
var isCat=!!n.__catalog,dr=!isCat&&hasKids(n.id),cls="nd s-"+st+(dr?" drill":"")+(isCat?" cat":"")+(pulse?" pulse":"");
|
|
397
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="";
|
|
398
428
|
var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":STR.stUnk)));
|
|
399
429
|
// the title runs until the badge, so reserve the badge's actual width (9px text ≈ 5.4px/char)
|
|
400
430
|
// instead of a flat 70px — a fixed reserve clipped names that had all the room they needed
|
|
@@ -448,6 +478,19 @@ function draw(animate){
|
|
|
448
478
|
// cpl — an unbreakable token (a long class name, a URL) would otherwise paint outside the box, and
|
|
449
479
|
// there is no clip-path on the node. The last line carries the ellipsis when text was dropped,
|
|
450
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
|
+
}
|
|
451
494
|
function wrapDesc(desc,cpl,max){
|
|
452
495
|
if(max<1||cpl<2)return [];
|
|
453
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.
|
|
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",
|