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 +33 -4
- package/lib/describe.mjs +7 -1
- package/lib/docmap.mjs +44 -8
- package/lib/gen.mjs +28 -5
- package/lib/init.mjs +11 -10
- package/lib/lang.mjs +10 -9
- package/lib/viewer/c4-hologram.html +74 -11
- 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
|
}
|
|
@@ -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
|
|
145
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
// `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,
|
|
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
|
-
//
|
|
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}
|
|
@@ -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
|
-
|
|
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,
|
|
328
|
-
var
|
|
329
|
-
|
|
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*(
|
|
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
|
-
|
|
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.
|
|
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",
|