forma-arch 0.2.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 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 to exported symbol names → `c4-model.json` |
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`) from the model |
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
  ```
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.parent === spec.parent && n.kind === 'leaf' && n.status === 'current').length
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)')
@@ -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
+ }
@@ -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, readdirSync } from 'node:fs'
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
- 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 name = (id) => (byId.get(id) || { name: id }).name
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.1.0'
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" (non-dev). Leaves from the descriptions map; else the description.
100
- const D = topo.descriptions || {}
101
- for (const n of nodes) {
102
- const key = n.parent ? `${n.parent}/${String(n.name).replace(/\.\w+$/, '').replace(/ .*/, '')}` : null
103
- n.func = (key && D[key]) || n.description || (n.kind === 'leaf' ? `Component of module ${(byId.get(n.parent) || {}).name || n.parent}.` : '')
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.parent === s.parent && n.kind === 'leaf')
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-arch",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",