forma-arch 0.1.0 → 0.3.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/README.md +16 -2
- package/lib/check.mjs +26 -1
- package/lib/cluster.mjs +44 -0
- package/lib/describe.mjs +90 -0
- package/lib/doc.mjs +24 -34
- package/lib/enrich.mjs +0 -0
- package/lib/gen.mjs +37 -7
- package/lib/init.mjs +10 -1
- package/lib/render.mjs +74 -0
- package/lib/schema/c4-model.schema.json +16 -0
- package/lib/viewer/c4-hologram.html +582 -267
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,11 +25,15 @@ npx forma-arch <command> # or: npm i -D forma-arch
|
|
|
25
25
|
| Command | What it does |
|
|
26
26
|
|---|---|
|
|
27
27
|
| `forma init` | Seed `docs/architecture/c4-topology.json` from your source dirs (best-effort; then curate) |
|
|
28
|
-
| `forma gen` | Walk `src/` leaves + derive container edges from cross-references
|
|
28
|
+
| `forma gen` | Walk `src/` leaves + derive container edges from cross-references; fill box text from docstrings/READMEs; group flat containers into components → `c4-model.json` |
|
|
29
29
|
| `forma check` | Deterministic drift check — **fails if the model no longer matches the code** |
|
|
30
|
-
| `forma doc` | Project the arc42 scaffold (`ARCHITECTURE.scaffold.md`)
|
|
30
|
+
| `forma doc` | Project the arc42 scaffold (`ARCHITECTURE.scaffold.md`), or `--attach <file>` to inject a governed block into an existing doc |
|
|
31
31
|
| `forma serve` | Open the live explorer at `http://localhost:4173` |
|
|
32
32
|
|
|
33
|
+
**Box text comes from your docs.** `gen` fills each box with the module's docstring (Python `"""…"""`, JS/TS leading block), else the directory `README.md`, else a mapped arc42 section — so the explorer shows meaning, not a list of symbols. On a flat directory of many `foo_*` files it also synthesizes a **component** layer (`--no-cluster` to disable).
|
|
34
|
+
|
|
35
|
+
**One source of truth.** `forma doc --attach docs/architecture/arc42.md` injects the generated diagrams/tables between `<!-- forma:begin -->` / `<!-- forma:end -->` markers in your existing doc; your prose lives outside them, and `forma check` fails if that block drifts. Where a repo lacks docs, `forma gen --enrich` can fill the remaining box holes with an LLM (opt-in, cached, never on the deterministic gate; `--enricher anthropic|openai|ollama`).
|
|
36
|
+
|
|
33
37
|
## How it fits together
|
|
34
38
|
|
|
35
39
|
```
|
|
@@ -49,6 +53,16 @@ code, the check is deterministic. The only human (or agent) step is curating the
|
|
|
49
53
|
and writing the arc42 prose — and *any* model can do that against the documented JSON/Markdown
|
|
50
54
|
contract. The Claude skill in `adapters/` is a thin wrapper, not the product.
|
|
51
55
|
|
|
56
|
+
## Explore
|
|
57
|
+
|
|
58
|
+
The viewer is a live C4 map, not a static picture:
|
|
59
|
+
|
|
60
|
+
- **Click any box** to read its explanation — what it does, current state, target, verification source — at *every* level, from context down to a leaf.
|
|
61
|
+
- **Double-click a box** (or its `[+] DRILL`) to descend into it; **BACK**, the breadcrumb, or `ESC` climb back out.
|
|
62
|
+
- **Drag boxes** to lay out the view your way; **RESET LAYOUT** restores the auto-arrangement.
|
|
63
|
+
- **Hover an arrow** to reveal its relationship label.
|
|
64
|
+
- **PRINT / EXPORT** to SVG or PNG for docs and slides.
|
|
65
|
+
|
|
52
66
|
## Skins
|
|
53
67
|
|
|
54
68
|
The viewer ships with swappable skins (`holo`, `blueprint`) via a dropdown or `?skin=`. Themes are
|
package/lib/check.mjs
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
7
7
|
import { join, dirname } from 'node:path'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { containerOf } from './cluster.mjs'
|
|
10
|
+
import { renderBlock, extractBetween, norm } from './render.mjs'
|
|
11
|
+
import { descInputHash } from './enrich.mjs'
|
|
9
12
|
|
|
10
13
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
11
14
|
const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
|
|
@@ -20,6 +23,7 @@ if (!existsSync(MODEL)) { console.error('[check-c4] FAIL: model missing (no SKIP
|
|
|
20
23
|
if (!existsSync(TOPO)) { console.error('[check-c4] FAIL: topology missing: ' + TOPO); process.exit(1) }
|
|
21
24
|
const model = JSON.parse(readFileSync(MODEL, 'utf-8'))
|
|
22
25
|
const topo = JSON.parse(readFileSync(TOPO, 'utf-8'))
|
|
26
|
+
const byId = new Map((model.nodes || []).map((n) => [n.id, n]))
|
|
23
27
|
|
|
24
28
|
// 1) schemaVersion present + basic shape
|
|
25
29
|
if (!/^\d+\.\d+\.\d+$/.test(model.schemaVersion || '')) fail('missing/invalid schemaVersion')
|
|
@@ -36,7 +40,7 @@ function walk(spec) {
|
|
|
36
40
|
}
|
|
37
41
|
for (const spec of topo.leafSources) {
|
|
38
42
|
const live = walk(spec).length
|
|
39
|
-
const inModel = model.nodes.filter((n) => n.
|
|
43
|
+
const inModel = model.nodes.filter((n) => n.kind === 'leaf' && n.status === 'current' && containerOf(n, byId) === spec.parent).length
|
|
40
44
|
if (live === 0) fail(`phantom parent "${spec.parent}": src glob ${spec.dir} matches 0 files`)
|
|
41
45
|
if (live !== inModel) fail(`DRIFT: ${spec.parent} — src has ${live} files, model has ${inModel}. Regenerate c4-model.json.`)
|
|
42
46
|
}
|
|
@@ -69,5 +73,26 @@ for (const p of topo.plannedLeaves || []) if (p.sourceMustContain) {
|
|
|
69
73
|
if (!existsSync(f) || !readFileSync(f, 'utf-8').includes(p.sourceMustContain)) fail(`planned "${p.name}" premise broken in ${p.sourceRef}`)
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
// 6) attach-mode doc-block freshness — if docPath carries forma markers, the region between them
|
|
77
|
+
// must equal a freshly rendered block. Opt-in: no markers → nothing runs. No LLM, no network.
|
|
78
|
+
const docRel = model.source && model.source.docPath
|
|
79
|
+
if (docRel && existsSync(rp(docRel))) {
|
|
80
|
+
const text = readFileSync(rp(docRel), 'utf-8')
|
|
81
|
+
const inner = extractBetween(text)
|
|
82
|
+
if (inner != null) {
|
|
83
|
+
if (norm(inner) !== norm(renderBlock(model, { repo: REPO }))) fail(`DOC BLOCK DRIFT: ${docRel} forma block is stale — run \`forma doc --attach\`.`)
|
|
84
|
+
} else if (text.includes('<!-- forma:begin') || text.includes('<!-- forma:end')) {
|
|
85
|
+
fail(`DOC BLOCK: malformed forma markers in ${docRel} (begin/end mismatch).`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// SOFT: LLM-enriched prose whose inputs changed is only a reminder, never a gate failure (structure
|
|
90
|
+
// is the gate; enrichment freshness is advisory). check never calls the LLM — it recomputes the hash.
|
|
91
|
+
for (const n of model.nodes || []) {
|
|
92
|
+
if (n.descSource === 'llm' && n.descInputHash && n.descInputHash !== descInputHash(n, { repo: REPO, byId, containerOf })) {
|
|
93
|
+
console.warn(`[check-c4] note: enrichment stale for ${n.id} — run \`forma gen --enrich\` to refresh (non-blocking).`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
72
97
|
if (errs.length) { console.error('[check-c4] FAIL (' + errs.length + '):\n - ' + errs.join('\n - ')); process.exit(1) }
|
|
73
98
|
console.log('[check-c4] OK — model is adherent to src/ (leaves, table count, evidence, planned premises all verified)')
|
package/lib/cluster.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cluster.mjs — shared structural helpers for the C4 model. Pure, no side effects at import.
|
|
3
|
+
// containerOf: climb the parent chain to the enclosing CONTAINER. Leaves may be re-parented under
|
|
4
|
+
// synthetic component nodes (§2), so "the container" is no longer simply node.parent.
|
|
5
|
+
// componentsFor: deterministic prefix-cluster synthesis for flat containers (§2).
|
|
6
|
+
|
|
7
|
+
const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'x'
|
|
8
|
+
|
|
9
|
+
// The id of the nearest container-kind ancestor (or self). byId = Map(id → node).
|
|
10
|
+
export function containerOf(node, byId) {
|
|
11
|
+
let n = node, guard = 0
|
|
12
|
+
while (n && n.kind !== 'container' && n.parent && guard++ < 64) n = byId.get(n.parent)
|
|
13
|
+
return n ? n.id : null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Group a flat container's leaves by common `foo_*` prefix. Deterministic: sorted group keys.
|
|
17
|
+
// Returns { components: Node[], reparent: Map(leafId → componentId) }. Leftovers stay under the container.
|
|
18
|
+
export function componentsFor(container, leaves, opts = {}) {
|
|
19
|
+
const groupMin = opts.groupMin || 3
|
|
20
|
+
const groups = new Map()
|
|
21
|
+
for (const l of leaves) {
|
|
22
|
+
const base = String(l.name)
|
|
23
|
+
const i = base.indexOf('_')
|
|
24
|
+
if (i <= 0) continue // no prefix (e.g. "health", "version") → stays flat
|
|
25
|
+
const prefix = base.slice(0, i)
|
|
26
|
+
if (!groups.has(prefix)) groups.set(prefix, [])
|
|
27
|
+
groups.get(prefix).push(l)
|
|
28
|
+
}
|
|
29
|
+
const keys = [...groups.keys()].filter((k) => groups.get(k).length >= groupMin).sort()
|
|
30
|
+
if (keys.length === 0) return { components: [], reparent: new Map() }
|
|
31
|
+
// no structural gain if a single surviving group swallows every leaf
|
|
32
|
+
const covered = keys.reduce((s, k) => s + groups.get(k).length, 0)
|
|
33
|
+
if (keys.length === 1 && covered === leaves.length) return { components: [], reparent: new Map() }
|
|
34
|
+
const components = [], reparent = new Map()
|
|
35
|
+
for (const k of keys) {
|
|
36
|
+
const cid = `${container.id}__grp__${slug(k)}`
|
|
37
|
+
components.push({
|
|
38
|
+
id: cid, level: 'component', kind: 'component', parent: container.id, name: k,
|
|
39
|
+
status: 'current', category: container.category || 'container',
|
|
40
|
+
})
|
|
41
|
+
for (const l of groups.get(k)) reparent.set(l.id, cid)
|
|
42
|
+
}
|
|
43
|
+
return { components, reparent }
|
|
44
|
+
}
|
package/lib/describe.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// describe.mjs — deterministic description resolver (§1a). Pure parsing, NO network.
|
|
3
|
+
// Fills a node's plain-language `func` from EXISTING docs (curated → docstring → README → arc42),
|
|
4
|
+
// so boxes show MEANING, not a bare filename. A generated string is only the last resort.
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
6
|
+
import { join, dirname } from 'node:path'
|
|
7
|
+
|
|
8
|
+
const norm = (s) => String(s).toLowerCase().trim()
|
|
9
|
+
|
|
10
|
+
// First non-empty paragraph, markdown/whitespace stripped, capped to the viewer's ~2-line clamp.
|
|
11
|
+
const firstPara = (text) => {
|
|
12
|
+
let out = ''
|
|
13
|
+
for (const line of String(text).replace(/\r\n/g, '\n').split('\n')) {
|
|
14
|
+
const l = line.trim()
|
|
15
|
+
if (/^#{1,6}\s/.test(l)) continue // skip markdown headings
|
|
16
|
+
if (!l) { if (out) break; else continue } // blank ends the paragraph (once started)
|
|
17
|
+
out += (out ? ' ' : '') + l
|
|
18
|
+
}
|
|
19
|
+
out = out.replace(/[*_`>]+/g, '').replace(/\s+/g, ' ').trim()
|
|
20
|
+
return out ? out.slice(0, 240) : null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ponytail: leading-block heuristic, no AST — a docstring placed AFTER code is missed.
|
|
24
|
+
function moduleDocstring(absPath) {
|
|
25
|
+
let src; try { src = readFileSync(absPath, 'utf-8') } catch { return null }
|
|
26
|
+
const body = src.replace(/^/, '').replace(/^#![^\n]*\n/, '')
|
|
27
|
+
if (/\.py$/i.test(absPath)) {
|
|
28
|
+
const m = body.match(/^\s*(?:#[^\n]*\n\s*)*(?:r|u|b)?("""|''')([\s\S]*?)\1/i)
|
|
29
|
+
return m ? firstPara(m[2]) : null
|
|
30
|
+
}
|
|
31
|
+
if (/\.(mjs|cjs|js|jsx|ts|tsx|mts|cts)$/i.test(absPath)) {
|
|
32
|
+
const block = body.match(/^\s*\/\*\*?([\s\S]*?)\*\//)
|
|
33
|
+
if (block) return firstPara(block[1].replace(/^\s*\*\s?/gm, '').replace(/@\w+/g, ''))
|
|
34
|
+
const cmt = []
|
|
35
|
+
for (const l of body.replace(/^\s+/, '').split('\n')) { const t = l.trim(); if (t.startsWith('//')) cmt.push(t.replace(/^\/\/+\s?/, '')); else break }
|
|
36
|
+
return cmt.length ? firstPara(cmt.join(' ')) : null
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readmeFirstPara(dir, cache) {
|
|
42
|
+
if (cache.has(dir)) return cache.get(dir)
|
|
43
|
+
let out = null
|
|
44
|
+
try { const p = join(dir, 'README.md'); if (existsSync(p)) out = firstPara(readFileSync(p, 'utf-8')) } catch {}
|
|
45
|
+
cache.set(dir, out)
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Map(normalizedHeading → lead paragraph) from the mapped arc42 doc, read once. ponytail: exact
|
|
50
|
+
// normalized-heading match, no fuzzy mapping.
|
|
51
|
+
function buildArc42Index(repo, docPath) {
|
|
52
|
+
const idx = new Map()
|
|
53
|
+
if (!docPath) return idx
|
|
54
|
+
let text; try { text = readFileSync(join(repo, docPath), 'utf-8') } catch { return idx }
|
|
55
|
+
const parts = text.split(/^#{1,6}\s+(.+)$/m) // [pre, heading, body, heading, body, ...]
|
|
56
|
+
for (let i = 1; i < parts.length; i += 2) {
|
|
57
|
+
const lead = firstPara(parts[i + 1] || '')
|
|
58
|
+
if (parts[i] && lead) idx.set(norm(parts[i]), lead)
|
|
59
|
+
}
|
|
60
|
+
return idx
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function makeDescribeCtx({ repo, byId, descriptions, docPath, containerOf }) {
|
|
64
|
+
return { repo, byId, D: descriptions || {}, containerOf, readmeCache: new Map(), arc42: buildArc42Index(repo, docPath) }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// First hit wins. descSource records provenance for §7 enrichment + debugging.
|
|
68
|
+
export function resolveDescription(node, ctx) {
|
|
69
|
+
const cont = ctx.containerOf(node, ctx.byId)
|
|
70
|
+
const stem = String(node.name).replace(/\.\w+$/, '').replace(/ .*/, '')
|
|
71
|
+
const key = cont ? `${cont}/${stem}` : null
|
|
72
|
+
if (key && ctx.D[key]) return { func: ctx.D[key], descSource: 'curated' }
|
|
73
|
+
if (node.description) return { func: node.description, descSource: 'curated' }
|
|
74
|
+
const pth = (node.evidence || []).find((e) => e.type === 'path')
|
|
75
|
+
if (pth) {
|
|
76
|
+
const abs = join(ctx.repo, pth.ref)
|
|
77
|
+
const ds = moduleDocstring(abs)
|
|
78
|
+
if (ds) return { func: ds, descSource: 'docstring' }
|
|
79
|
+
const rd = readmeFirstPara(dirname(abs), ctx.readmeCache)
|
|
80
|
+
if (rd) return { func: rd, descSource: 'readme' }
|
|
81
|
+
}
|
|
82
|
+
const a = ctx.arc42.get(norm(node.name)) || ctx.arc42.get(norm(node.id))
|
|
83
|
+
if (a) return { func: a, descSource: 'arc42' }
|
|
84
|
+
if (node.kind === 'leaf') {
|
|
85
|
+
const cn = (ctx.byId.get(cont) || {}).name || cont || node.parent
|
|
86
|
+
return { func: `Component of module ${cn}.`, descSource: 'fallback' }
|
|
87
|
+
}
|
|
88
|
+
if (node.kind === 'component') return { func: `Groups related files under ${node.name}.`, descSource: 'fallback' }
|
|
89
|
+
return { func: node.description || '', descSource: 'fallback' }
|
|
90
|
+
}
|
package/lib/doc.mjs
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// are filled from the model — so they can never drift from it. The JUDGMENT sections (goals,
|
|
5
5
|
// strategy, decisions, risks) are stubbed with TODO(forma) for a human/agent to write.
|
|
6
6
|
// Output: docs/architecture/ARCHITECTURE.scaffold.md (never overwrites a real ARCHITECTURE.md).
|
|
7
|
-
import { readFileSync, writeFileSync, existsSync,
|
|
8
|
-
import { join, relative } from 'node:path'
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
8
|
+
import { join, relative, isAbsolute } from 'node:path'
|
|
9
|
+
import { renderParts, renderBlock, replaceBetween } from './render.mjs'
|
|
9
10
|
|
|
10
11
|
const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
|
|
11
12
|
const REPO = arg('--repo', process.cwd())
|
|
@@ -13,38 +14,9 @@ const MODEL = arg('--model', join(REPO, 'docs/architecture/c4-model.json'))
|
|
|
13
14
|
const OUT = arg('--out', join(REPO, 'docs/architecture/ARCHITECTURE.scaffold.md'))
|
|
14
15
|
if (!existsSync(MODEL)) { console.error('[forma doc] model missing: ' + MODEL + ' — run `forma gen` first.'); process.exit(1) }
|
|
15
16
|
const m = JSON.parse(readFileSync(MODEL, 'utf-8'))
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const C4 = { person: 'Person', system: 'System', external: 'System_Ext', container: 'Container', component: 'Component', store: 'ContainerDb', boundary: 'Container_Boundary', leaf: 'Component' }
|
|
20
|
-
const q = (s) => String(s == null ? '' : s).replace(/"/g, '')
|
|
21
|
-
const el = (n) => `${C4[n.kind] || 'Container'}(${sid(n.id)}, "${q(n.name)}"${n.tech ? `, "${q(n.tech)}"` : ''})`
|
|
22
|
-
const rels = (ids) => m.edges.filter((e) => ids.has(e.from) && ids.has(e.to)).map((e) => ` Rel(${sid(e.from)}, ${sid(e.to)}, "${(e.label || '').replace(/"/g, '')}")`).join('\n')
|
|
23
|
-
|
|
24
|
-
// context = nodes with no parent
|
|
25
|
-
const ctx = m.nodes.filter((n) => !n.parent)
|
|
26
|
-
const ctxIds = new Set(ctx.map((n) => n.id))
|
|
27
|
-
const contextDiagram = `\`\`\`mermaid\nC4Context\n title System Context\n${ctx.map((n) => ' ' + el(n)).join('\n')}\n${rels(ctxIds)}\n\`\`\``
|
|
28
|
-
|
|
29
|
-
// containers = children of the system node
|
|
30
|
-
const sys = ctx.find((n) => n.kind === 'system') || ctx[0]
|
|
31
|
-
const containers = m.nodes.filter((n) => n.parent === (sys && sys.id))
|
|
32
|
-
const contIds = new Set(containers.map((n) => n.id))
|
|
33
|
-
const containerDiagram = containers.length ? `\`\`\`mermaid\nC4Container\n title Container view — ${sys.name}\n${containers.map((n) => ' ' + el(n)).join('\n')}\n${rels(contIds)}\n\`\`\`` : '_No containers in the model yet._'
|
|
34
|
-
const leafCount = (id) => m.nodes.filter((n) => n.parent === id && n.kind === 'leaf').length
|
|
35
|
-
const containerTable = containers.length
|
|
36
|
-
? ['| Container | Tech | Leaves | What it does |', '|---|---|---|---|', ...containers.map((n) => `| ${n.name} | ${n.tech || '—'} | ${leafCount(n.id)} | ${(n.func || n.description || 'TODO(forma): describe').replace(/\n/g, ' ')} |`)].join('\n')
|
|
37
|
-
: '_No containers yet._'
|
|
38
|
-
|
|
39
|
-
// ADR index if present
|
|
40
|
-
const adrDir = join(REPO, 'docs/adr')
|
|
41
|
-
let adrs = '_No `docs/adr/` found — record decisions as ADRs._'
|
|
42
|
-
if (existsSync(adrDir)) {
|
|
43
|
-
const files = readdirSync(adrDir).filter((f) => /\.md$/.test(f) && !/readme|template/i.test(f)).sort()
|
|
44
|
-
if (files.length) adrs = files.map((f) => { const t = (readFileSync(join(adrDir, f), 'utf-8').match(/^#\s+(.+)/m) || [, f])[1]; return `- [${t}](../adr/${f})`; }).join('\n')
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const stack = (m.meta && m.meta.stack) || (sys && sys.tech) || 'TODO'
|
|
17
|
+
// Deterministic, model-derived sections come from the shared side-effect-free renderer, so
|
|
18
|
+
// `forma doc` (writer) and `forma check` (attach freshness gate) render the block identically.
|
|
19
|
+
const { contextDiagram, containerTable, containerDiagram, adrs, stack, sys, containers } = renderParts(m, { repo: REPO })
|
|
48
20
|
const TODO = (s) => `> TODO(forma): ${s}`
|
|
49
21
|
const md = `<!-- forma: arc42 scaffold projected from c4-model.json. Deterministic sections are generated;
|
|
50
22
|
do not edit them by hand — re-run \`forma doc\`. Fill the TODO(forma) prose, then promote to ARCHITECTURE.md. -->
|
|
@@ -96,6 +68,24 @@ ${TODO('known risks and debt, with links to any register.')}
|
|
|
96
68
|
## 12. Glossary
|
|
97
69
|
${TODO('domain and project terms.')}
|
|
98
70
|
`
|
|
71
|
+
// --attach <file>: inject the deterministic block between sentinel markers in an EXISTING doc
|
|
72
|
+
// (default: model.source.docPath), leaving human prose outside the markers untouched. `forma check`
|
|
73
|
+
// then governs that block. Default (no --attach) writes the standalone scaffold, exactly as before.
|
|
74
|
+
const attachIdx = process.argv.indexOf('--attach')
|
|
75
|
+
if (attachIdx > -1) {
|
|
76
|
+
const next = process.argv[attachIdx + 1]
|
|
77
|
+
let target = (next && !next.startsWith('--')) ? next : (m.source && m.source.docPath)
|
|
78
|
+
if (!target) { console.error('[forma doc] --attach: no target (pass a file or set docPath in the model)'); process.exit(1) }
|
|
79
|
+
target = isAbsolute(target) ? target : join(REPO, target)
|
|
80
|
+
const prior = existsSync(target) ? readFileSync(target, 'utf-8') : ''
|
|
81
|
+
let out
|
|
82
|
+
try { out = replaceBetween(prior, renderBlock(m, { repo: REPO })) }
|
|
83
|
+
catch { console.error('[forma doc] --attach: malformed forma markers in ' + relative(REPO, target) + ' — fix by hand'); process.exit(1) }
|
|
84
|
+
mkdirSync(join(target, '..'), { recursive: true })
|
|
85
|
+
writeFileSync(target, out)
|
|
86
|
+
console.log(`[forma doc] spliced generated block into ${relative(REPO, target)} — ${containers.length} container(s); prose outside the markers untouched.`)
|
|
87
|
+
process.exit(0)
|
|
88
|
+
}
|
|
99
89
|
writeFileSync(OUT, md)
|
|
100
90
|
console.log(`[forma doc] wrote ${relative(REPO, OUT)} — ${containers.length} container(s), context + container diagrams from the model.`)
|
|
101
91
|
console.log('[forma doc] deterministic sections filled; write the TODO(forma) prose, then promote to ARCHITECTURE.md.')
|
package/lib/enrich.mjs
ADDED
|
Binary file
|
package/lib/gen.mjs
CHANGED
|
@@ -6,13 +6,21 @@ import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from '
|
|
|
6
6
|
import { join, dirname, basename, relative } from 'node:path'
|
|
7
7
|
import { execSync } from 'node:child_process'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { containerOf, componentsFor } from './cluster.mjs'
|
|
10
|
+
import { makeDescribeCtx, resolveDescription } from './describe.mjs'
|
|
11
|
+
import { loadCache, mergeCache, enrich } from './enrich.mjs'
|
|
9
12
|
|
|
10
13
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
11
14
|
const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
|
|
12
15
|
const REPO = arg('--repo', process.cwd())
|
|
13
16
|
const TOPO = arg('--topology', join(REPO, 'docs/architecture/c4-topology.json'))
|
|
14
17
|
const OUT = arg('--out', join(REPO, 'docs/architecture/c4-model.json'))
|
|
15
|
-
const SCHEMA_VERSION = '1.
|
|
18
|
+
const SCHEMA_VERSION = '1.2.0'
|
|
19
|
+
const CLUSTER = !process.argv.includes('--no-cluster') // §2: auto-cluster flat containers; --no-cluster to disable
|
|
20
|
+
const ENRICH = process.argv.includes('--enrich') // §7: opt-in LLM prose for description holes
|
|
21
|
+
const ENRICHER = arg('--enricher', 'anthropic')
|
|
22
|
+
const ENRICH_MODEL = arg('--enrich-model', null)
|
|
23
|
+
const CLUSTER_MIN = 8, GROUP_MIN = 3
|
|
16
24
|
|
|
17
25
|
const topo = JSON.parse(readFileSync(TOPO, 'utf-8'))
|
|
18
26
|
const rp = (p) => join(REPO, p)
|
|
@@ -86,6 +94,20 @@ for (const p of topo.plannedLeaves || []) {
|
|
|
86
94
|
evidence: [{ type: 'doc', ref: p.sourceRef }] })
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
// §2) synthesize a component layer for flat containers (auto; --no-cluster to disable).
|
|
98
|
+
// Runs BEFORE the enrich/func loops so components inherit category/status/func. Leaves that
|
|
99
|
+
// join a component are re-parented to it; containerOf() keeps edge/check logic container-scoped.
|
|
100
|
+
if (CLUSTER) for (const cid of [...new Set(topo.leafSources.map((s) => s.parent))]) {
|
|
101
|
+
const container = byId.get(cid); if (!container) continue
|
|
102
|
+
const leaves = nodes.filter((n) => n.kind === 'leaf' && n.parent === cid) // pre-reparent: plain filter valid here
|
|
103
|
+
if (leaves.length <= CLUSTER_MIN) continue
|
|
104
|
+
const { components, reparent } = componentsFor(container, leaves, { groupMin: GROUP_MIN })
|
|
105
|
+
if (!components.length) { console.log(`[gen-c4] container ${cid}: ${leaves.length} leaf non-clusterable (flat)`); continue }
|
|
106
|
+
for (const c of components) add(c)
|
|
107
|
+
for (const n of leaves) { const to = reparent.get(n.id); if (to) n.parent = to }
|
|
108
|
+
console.log(`[gen-c4] container ${cid}: clustered ${leaves.length} leaves into ${components.length} component(s)`)
|
|
109
|
+
}
|
|
110
|
+
|
|
89
111
|
// enrich: fill hologram defaults (category, 5-status, completion, current/target) where absent
|
|
90
112
|
for (const n of nodes) {
|
|
91
113
|
const par = n.parent ? byId.get(n.parent) : null
|
|
@@ -96,11 +118,19 @@ for (const n of nodes) {
|
|
|
96
118
|
if (n.target == null) n.target = ''
|
|
97
119
|
}
|
|
98
120
|
|
|
99
|
-
// func: plain-language "what it does"
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
121
|
+
// §1a) func: plain-language "what it does" resolved from existing docs (curated → docstring → README
|
|
122
|
+
// → arc42 → generated fallback), with provenance in descSource. No LLM here — pure parsing.
|
|
123
|
+
const dctx = makeDescribeCtx({ repo: REPO, byId, descriptions: topo.descriptions || {}, docPath: topo.docPath, containerOf })
|
|
124
|
+
for (const n of nodes) { const r = resolveDescription(n, dctx); n.func = r.func; n.descSource = r.descSource }
|
|
125
|
+
|
|
126
|
+
// §7) enrichment (prose only, never structure/gate). Cache-merge ALWAYS: preserve prior LLM prose
|
|
127
|
+
// whose inputs are unchanged, so a plain regen doesn't discard it. --enrich additionally fills the
|
|
128
|
+
// remaining holes over the network (best-effort — a failure keeps the deterministic fallback).
|
|
129
|
+
const ectx = { repo: REPO, byId, containerOf }
|
|
130
|
+
mergeCache(nodes, ectx, loadCache(OUT))
|
|
131
|
+
if (ENRICH) {
|
|
132
|
+
try { const r = await enrich(nodes, ectx, { provider: ENRICHER, model: ENRICH_MODEL }); console.log(`[gen-c4] --enrich (${ENRICHER}): filled ${r.filled}/${r.holes} description hole(s)`) }
|
|
133
|
+
catch (e) { console.error('[gen-c4] --enrich skipped: ' + String((e && e.message) || e)) }
|
|
104
134
|
}
|
|
105
135
|
|
|
106
136
|
// 4b) derive container↔container edges from REAL code references (deterministic, additive).
|
|
@@ -113,7 +143,7 @@ if (!process.argv.includes('--no-auto-edges')) {
|
|
|
113
143
|
const srcs = (topo.leafSources || []).filter((s) => byId.has(s.parent))
|
|
114
144
|
const exposes = new Map(), text = new Map()
|
|
115
145
|
for (const s of srcs) {
|
|
116
|
-
exposes.set(s.parent, [...new Set(nodes.filter((n) => n.
|
|
146
|
+
exposes.set(s.parent, [...new Set(nodes.filter((n) => n.kind === 'leaf' && containerOf(n, byId) === s.parent)
|
|
117
147
|
.map((n) => String(n.name)).filter((nm) => nm.length >= 5 && !STOP.has(nm.toLowerCase())))])
|
|
118
148
|
let t = ''; const dir = rp(s.dir)
|
|
119
149
|
try { for (const f of readdirSync(dir)) { const fp = join(dir, f); if (statSync(fp).isFile()) t += '\n' + readFileSync(fp, 'utf-8') } } catch {}
|
package/lib/init.mjs
CHANGED
|
@@ -11,6 +11,12 @@ const REPO = arg('--repo', process.cwd())
|
|
|
11
11
|
const OUT = arg('--out', join(REPO, 'docs/architecture/c4-topology.json'))
|
|
12
12
|
const FORCE = process.argv.includes('--force')
|
|
13
13
|
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', 'out', 'vendor', 'coverage', '.next', '.gradle', 'bin', 'obj'])
|
|
14
|
+
// A real directory that holds files but is not an architecture container (data/fixtures/docs).
|
|
15
|
+
// Kept separate from IGNORE (which is build/VCS junk, invisible even to language detection).
|
|
16
|
+
const DATA_DIRS = new Set(['docs', 'fixtures', 'testdata', 'demo', 'corpus', 'assets', 'examples'])
|
|
17
|
+
const SKIP_TESTS = process.argv.includes('--skip-tests') // test/ is real code by default; opt in to skip it
|
|
18
|
+
const KEEP = new Set((arg('--include', '') || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean))
|
|
19
|
+
const SKIP_DIRS = new Set([...DATA_DIRS, ...(SKIP_TESTS ? ['test', 'tests'] : [])])
|
|
14
20
|
const EXT = { ts: 'TypeScript', tsx: 'TypeScript', js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript', mts: 'TypeScript', cts: 'TypeScript', py: 'Python', go: 'Go', java: 'Java', rs: 'Rust', rb: 'Ruby', php: 'PHP', cs: 'C#', kt: 'Kotlin', swift: 'Swift', cpp: 'C++', c: 'C' }
|
|
15
21
|
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'x'
|
|
16
22
|
|
|
@@ -58,6 +64,7 @@ const hasSrc = (dir) => { try { return readdirSync(dir).some((f) => { const p =
|
|
|
58
64
|
// (A dir with direct sources is a container; a dir with none is descended into. Works for nested
|
|
59
65
|
// layouts like src/app/routes/*.ts or Python packages, not just one level.)
|
|
60
66
|
const seen = new Set()
|
|
67
|
+
const skipped = []
|
|
61
68
|
;(function findContainers(dir, depth) {
|
|
62
69
|
if (depth > 10) return
|
|
63
70
|
let ents; try { ents = readdirSync(dir) } catch { return }
|
|
@@ -65,6 +72,7 @@ const seen = new Set()
|
|
|
65
72
|
const p = join(dir, e)
|
|
66
73
|
let st; try { st = statSync(p) } catch { continue }
|
|
67
74
|
if (!st.isDirectory()) continue
|
|
75
|
+
if (SKIP_DIRS.has(e.toLowerCase()) && !KEEP.has(e.toLowerCase())) { skipped.push({ dir: relative(REPO, p), reason: 'data/fixtures/docs dir (name match) — curate if wrong' }); continue }
|
|
68
76
|
if (hasSrc(p)) {
|
|
69
77
|
const rel = relative(root, p) || e
|
|
70
78
|
let id = slug(rel.replace(/[\\/]+/g, '_'))
|
|
@@ -88,9 +96,10 @@ const topo = {
|
|
|
88
96
|
leafSources,
|
|
89
97
|
edges: [],
|
|
90
98
|
descriptions: {},
|
|
99
|
+
_skipped: skipped,
|
|
91
100
|
}
|
|
92
101
|
// ensure output dir exists
|
|
93
102
|
mkdirSync(join(OUT, '..'), { recursive: true })
|
|
94
103
|
writeFileSync(OUT, JSON.stringify(topo, null, 2) + '\n')
|
|
95
|
-
console.log(`[forma init] wrote ${relative(REPO, OUT)} — ${lang}, ${leafSources.length} container(s) seeded from ${relative(REPO, root) || '.'}
|
|
104
|
+
console.log(`[forma init] wrote ${relative(REPO, OUT)} — ${lang}, ${leafSources.length} container(s) seeded from ${relative(REPO, root) || '.'}/${skipped.length ? `, ${skipped.length} data/doc dir(s) skipped` : ''}`)
|
|
96
105
|
console.log('[forma init] NEXT: curate names/descriptions + add context externals + run `forma gen`.')
|
package/lib/render.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// render.mjs — pure, side-effect-free renderers for the arc42 projection.
|
|
3
|
+
// Shared by `forma doc` (writer) and `forma check` (attach-mode freshness gate) so both render the
|
|
4
|
+
// governed block identically. NO writeFileSync / console / process at module scope.
|
|
5
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { containerOf } from './cluster.mjs'
|
|
8
|
+
|
|
9
|
+
export const BEGIN = '<!-- forma:begin (generated — do not edit) -->'
|
|
10
|
+
export const END = '<!-- forma:end -->'
|
|
11
|
+
|
|
12
|
+
// The deterministic, model-derived parts (never the TODO judgment prose, never volatile fields).
|
|
13
|
+
export function renderParts(model, opts = {}) {
|
|
14
|
+
const repo = opts.repo || process.cwd()
|
|
15
|
+
const m = model
|
|
16
|
+
const byId = new Map(m.nodes.map((n) => [n.id, n]))
|
|
17
|
+
const sid = (s) => String(s).replace(/[^A-Za-z0-9_]/g, '_')
|
|
18
|
+
const C4 = { person: 'Person', system: 'System', external: 'System_Ext', container: 'Container', component: 'Component', store: 'ContainerDb', boundary: 'Container_Boundary', leaf: 'Component' }
|
|
19
|
+
const q = (s) => String(s == null ? '' : s).replace(/"/g, '')
|
|
20
|
+
const el = (n) => `${C4[n.kind] || 'Container'}(${sid(n.id)}, "${q(n.name)}"${n.tech ? `, "${q(n.tech)}"` : ''})`
|
|
21
|
+
const rels = (ids) => m.edges.filter((e) => ids.has(e.from) && ids.has(e.to)).map((e) => ` Rel(${sid(e.from)}, ${sid(e.to)}, "${(e.label || '').replace(/"/g, '')}")`).join('\n')
|
|
22
|
+
|
|
23
|
+
const ctx = m.nodes.filter((n) => !n.parent)
|
|
24
|
+
const ctxIds = new Set(ctx.map((n) => n.id))
|
|
25
|
+
const contextDiagram = `\`\`\`mermaid\nC4Context\n title System Context\n${ctx.map((n) => ' ' + el(n)).join('\n')}\n${rels(ctxIds)}\n\`\`\``
|
|
26
|
+
|
|
27
|
+
const sys = ctx.find((n) => n.kind === 'system') || ctx[0]
|
|
28
|
+
const containers = m.nodes.filter((n) => n.parent === (sys && sys.id))
|
|
29
|
+
const contIds = new Set(containers.map((n) => n.id))
|
|
30
|
+
const containerDiagram = containers.length ? `\`\`\`mermaid\nC4Container\n title Container view — ${sys.name}\n${containers.map((n) => ' ' + el(n)).join('\n')}\n${rels(contIds)}\n\`\`\`` : '_No containers in the model yet._'
|
|
31
|
+
const leafCount = (id) => m.nodes.filter((n) => n.kind === 'leaf' && containerOf(n, byId) === id).length
|
|
32
|
+
const containerTable = containers.length
|
|
33
|
+
? ['| Container | Tech | Leaves | What it does |', '|---|---|---|---|', ...containers.map((n) => `| ${n.name} | ${n.tech || '—'} | ${leafCount(n.id)} | ${(n.func || n.description || 'TODO(forma): describe').replace(/\n/g, ' ')} |`)].join('\n')
|
|
34
|
+
: '_No containers yet._'
|
|
35
|
+
|
|
36
|
+
const adrDir = join(repo, 'docs/adr')
|
|
37
|
+
let adrs = '_No `docs/adr/` found — record decisions as ADRs._'
|
|
38
|
+
if (existsSync(adrDir)) {
|
|
39
|
+
const files = readdirSync(adrDir).filter((f) => /\.md$/.test(f) && !/readme|template/i.test(f)).sort()
|
|
40
|
+
if (files.length) adrs = files.map((f) => { const t = (readFileSync(join(adrDir, f), 'utf-8').match(/^#\s+(.+)/m) || [, f])[1]; return `- [${t}](../adr/${f})`; }).join('\n')
|
|
41
|
+
}
|
|
42
|
+
const stack = (m.meta && m.meta.stack) || (sys && sys.tech) || 'TODO'
|
|
43
|
+
return { contextDiagram, containerTable, containerDiagram, adrs, stack, sys, containers }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The governed region injected between the sentinel markers (deterministic subset only).
|
|
47
|
+
export function renderBlock(model, opts = {}) {
|
|
48
|
+
const p = renderParts(model, opts)
|
|
49
|
+
return [
|
|
50
|
+
'### Context', '', p.contextDiagram, '',
|
|
51
|
+
'### Building blocks', '', p.containerTable, '', p.containerDiagram, '',
|
|
52
|
+
'### Architecture decisions', '', p.adrs,
|
|
53
|
+
].join('\n')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Comparison normalizer: CRLF→LF, strip trailing whitespace, trim surrounding blank lines.
|
|
57
|
+
export const norm = (s) => String(s).replace(/\r\n/g, '\n').replace(/[ \t]+$/gm, '').replace(/^\n+|\n+$/g, '')
|
|
58
|
+
|
|
59
|
+
// Inner content between markers, or null unless BOTH are present and ordered.
|
|
60
|
+
export function extractBetween(text) {
|
|
61
|
+
const b = text.indexOf(BEGIN), e = text.indexOf(END)
|
|
62
|
+
if (b === -1 || e === -1 || e < b) return null
|
|
63
|
+
return text.slice(b + BEGIN.length, e)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Splice the block between markers; append at EOF (creating them) if absent; throw if exactly one present.
|
|
67
|
+
export function replaceBetween(text, block) {
|
|
68
|
+
const b = text.indexOf(BEGIN), e = text.indexOf(END)
|
|
69
|
+
const body = `${BEGIN}\n\n${block}\n\n${END}`
|
|
70
|
+
if (b !== -1 && e !== -1 && e > b) return text.slice(0, b) + body + text.slice(e + END.length)
|
|
71
|
+
if (b !== -1 || e !== -1) throw new Error('malformed forma markers')
|
|
72
|
+
const sep = text && !text.endsWith('\n') ? '\n\n' : (text ? '\n' : '')
|
|
73
|
+
return text + sep + body + '\n'
|
|
74
|
+
}
|
|
@@ -203,6 +203,22 @@
|
|
|
203
203
|
"func": {
|
|
204
204
|
"type": "string",
|
|
205
205
|
"description": "Plain-language 'what it does' for non-developers."
|
|
206
|
+
},
|
|
207
|
+
"descSource": {
|
|
208
|
+
"type": "string",
|
|
209
|
+
"enum": [
|
|
210
|
+
"curated",
|
|
211
|
+
"docstring",
|
|
212
|
+
"readme",
|
|
213
|
+
"arc42",
|
|
214
|
+
"fallback",
|
|
215
|
+
"llm"
|
|
216
|
+
],
|
|
217
|
+
"description": "Provenance of func: how the description was resolved (§1a chain; 'llm' = optional enrichment)."
|
|
218
|
+
},
|
|
219
|
+
"descInputHash": {
|
|
220
|
+
"type": "string",
|
|
221
|
+
"description": "sha256 of the inputs that produced an LLM-enriched func; check recomputes it to flag stale prose."
|
|
206
222
|
}
|
|
207
223
|
}
|
|
208
224
|
}
|