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 +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 +72 -18
- 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}
|
|
@@ -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
|
-
|
|
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,
|
|
313
|
-
var
|
|
314
|
-
|
|
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*(
|
|
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()+" <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
|
|
416
461
|
for(var w=0;w<chain.length;w++)cb+=" › "+(w===chain.length-1?"<b>"+esc(chain[w].name)+"</b>":"<a data-d='"+w+"'>"+esc(chain[w].name)+"</a>");
|
|
417
|
-
// Per-level programme tally — the status-board essence in one glance
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
var
|
|
421
|
-
|
|
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
|
-
|
|
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+=' '+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.
|
|
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",
|