forma-arch 0.2.0 → 0.4.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,10 +25,33 @@ 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
+ | `forma verify` | Refresh status from live GitHub issues through your `gh` CLI — the **only** networked command |
33
+
34
+ **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, described from its children's docs (`--no-cluster` to disable; `--cluster-min <n>` = leaves before a container is clustered, default 8; `--group-min <n>` = files sharing a prefix before they become a component, default 3).
35
+
36
+ **Programme state is curated, not guessed.** Code shows what exists, never how far along it is. Drop a `docs/architecture/c4-status.json` (`--status <path>` to move it) and `gen` decorates nodes by id with `status2`, `completion`, `statusWord`, `current`, `target`, `verify`, `issues` — never `func`, which belongs to the docs. `gen` validates the *form* (ids resolve, fields known, enums and issue numbers well-shaped) and never the prose; `forma check` fails if the overlay decorates a node the model no longer has.
37
+
38
+ ```json
39
+ { "nodes": { "engine": { "status2": "in-progress", "completion": 60, "statusWord": "v2 in progress",
40
+ "current": "Live on ACA: RAG + citations. Hardening this week.",
41
+ "target": "Multi-surface substrate with client-ready output.",
42
+ "verify": { "source": "ADR-040 on main" }, "issues": ["#534"] } } }
43
+ ```
44
+
45
+ **Curated state, verified against reality.** `forma verify` asks your `gh` CLI for the state of every issue the model references (`--gh-repo owner/repo`, or `meta.ghRepo` in the topology), marks the nodes whose issues are closed as done, and prefixes their `current` with dated evidence. It touches state, never structure, and re-running it never stacks the evidence. It is opt-in and separate on purpose: `gen` and `check` never open a socket. In the served viewer, **RE-VERIFY** re-reads the model without losing your level, layout or mode.
46
+
47
+ **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. Attached files are recorded in `source.attachedDocs`, so the gate governs **every** doc you attach — not just the model's `docPath`; deleting the markers (or the file) from a registered doc fails the check rather than quietly un-governing it. That registry lives in `c4-model.json`, so **commit the model** — a lost model takes the registry with it. 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:
48
+
49
+ | `--enricher` | Use it when | Network |
50
+ |---|---|---|
51
+ | `agent` | **An agent is driving forma.** Writes `enrich-plan.json` with the holes; the agent writes the sentences (reading the sources if it wants) and `gen --enrich-apply <file>` applies them with the same cache and provenance. | none |
52
+ | `anthropic` (default) | Headless / CI, with `ANTHROPIC_API_KEY`. | REST |
53
+ | `openai` | Same, with `OPENAI_API_KEY`. | REST |
54
+ | `ollama` | Sensitive repos: a local model, nothing leaves the machine. | localhost |
32
55
 
33
56
  ## How it fits together
34
57
 
@@ -55,9 +78,10 @@ The viewer is a live C4 map, not a static picture:
55
78
 
56
79
  - **Click any box** to read its explanation — what it does, current state, target, verification source — at *every* level, from context down to a leaf.
57
80
  - **Double-click a box** (or its `[+] DRILL`) to descend into it; **BACK**, the breadcrumb, or `ESC` climb back out.
58
- - **Drag boxes** to lay out the view your way; **RESET LAYOUT** restores the auto-arrangement.
59
- - **Hover an arrow** to reveal its relationship label.
60
- - **PRINT / EXPORT** to SVG or PNG for docs and slides.
81
+ - **Drag boxes** to lay out the view your way; **RESET LAYOUT** restores the arrangement (your curated hints if the topology has them, the automatic one otherwise). To keep a layout, drag it, pick **Export layout JSON**, and paste the result under `"layout"` in the topology — `gen` carries it into `meta.layout` and the viewer pins those boxes, auto-arranging everything else clear of them.
82
+ - **Arrow labels** are painted on the diagram while the level stays readable (≤14 arrows) and turn off above that; **LABELS** forces them on or off, and hovering an arrow always reveals its label.
83
+ - **PRINT / EXPORT** to SVG or PNG for docs and slides — exported arrows carry their labels.
84
+ - The breadcrumb names the C4 level you are on (`C4-L1 · CONTEXT` → `C4-L3 · COMPONENTS`).
61
85
 
62
86
  ## Skins
63
87
 
package/bin/forma.mjs CHANGED
@@ -12,7 +12,7 @@ const LIB = join(HERE, '..', 'lib')
12
12
  const cmd = process.argv[2]
13
13
  const rest = process.argv.slice(3)
14
14
 
15
- const MAP = { init: 'init.mjs', gen: 'gen.mjs', check: 'check.mjs', doc: 'doc.mjs', serve: 'serve.mjs' }
15
+ const MAP = { init: 'init.mjs', gen: 'gen.mjs', check: 'check.mjs', doc: 'doc.mjs', serve: 'serve.mjs', verify: 'verify.mjs' }
16
16
 
17
17
  if (cmd === '-v' || cmd === '--version') {
18
18
  const pkg = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf-8'))
@@ -28,6 +28,7 @@ Usage: forma <command> [--repo <path>]
28
28
  check deterministic drift check — fails if the model no longer matches the code
29
29
  doc project the arc42 scaffold (ARCHITECTURE.scaffold.md) from the model
30
30
  serve open the live explorer at http://localhost:4173
31
+ verify refresh status from live GitHub issues via your gh CLI (the only networked command)
31
32
 
32
33
  The file contract is lib/schema/c4-model.schema.json. Enrichment (curate the topology, write the
33
34
  arc42 prose) is model-agnostic — any agent edits the same JSON/Markdown.`)
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,48 @@ 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
+ // Governs docPath AND every doc registered by `forma doc --attach` (source.attachedDocs): a block
79
+ // injected into a file no gate looks at is the false-green this whole command exists to kill.
80
+ const src = model.source || {}
81
+ const registered = new Set(src.attachedDocs || []) // registry membership PROVES a block was injected
82
+ const governed = [...new Set([src.docPath, ...registered].filter(Boolean))]
83
+ const deregister = 'remove it from source.attachedDocs if the block is gone for good'
84
+ for (const docRel of governed) {
85
+ if (!existsSync(rp(docRel))) {
86
+ // docPath may simply never have been attached; a REGISTERED doc that vanished is drift.
87
+ if (registered.has(docRel)) fail(`DOC BLOCK: ${docRel} is registered in source.attachedDocs but the file is gone — ${deregister}.`)
88
+ continue
89
+ }
90
+ const text = readFileSync(rp(docRel), 'utf-8')
91
+ const inner = extractBetween(text)
92
+ if (inner != null) {
93
+ if (norm(inner) !== norm(renderBlock(model, { repo: REPO }))) fail(`DOC BLOCK DRIFT: ${docRel} forma block is stale — run \`forma doc --attach\`.`)
94
+ } else if (text.includes('<!-- forma:begin') || text.includes('<!-- forma:end')) {
95
+ fail(`DOC BLOCK: malformed forma markers in ${docRel} (begin/end mismatch).`)
96
+ } else if (registered.has(docRel)) {
97
+ // Both markers deleted: the generated block stopped being governed while its (now frozen)
98
+ // text keeps shipping to readers. Exactly the false green attachedDocs exists to prevent.
99
+ fail(`DOC BLOCK: ${docRel} is registered in source.attachedDocs but has no forma markers — re-run \`forma doc --attach\`, or ${deregister}.`)
100
+ }
101
+ }
102
+
103
+ // 7) the status overlay decorates nodes by id: an id that no longer resolves is drift (the node was
104
+ // renamed or deleted and the curated state was left behind). Form only — never the prose itself.
105
+ if (src.statusPath && existsSync(rp(src.statusPath))) {
106
+ let ov = null
107
+ try { ov = JSON.parse(readFileSync(rp(src.statusPath), 'utf-8')) } catch (e) { fail(`status overlay: ${src.statusPath} is not valid JSON: ${(e && e.message) || e}`) }
108
+ if (ov) for (const id of Object.keys(ov.nodes || {})) if (!byId.has(id)) fail(`status overlay: "${id}" is not a node in the model — ${src.statusPath} is stale.`)
109
+ }
110
+
111
+ // SOFT: LLM-enriched prose whose inputs changed is only a reminder, never a gate failure (structure
112
+ // is the gate; enrichment freshness is advisory). check never calls the LLM — it recomputes the hash.
113
+ for (const n of model.nodes || []) {
114
+ if (n.descSource === 'llm' && n.descInputHash && n.descInputHash !== descInputHash(n, { repo: REPO, byId, containerOf })) {
115
+ console.warn(`[check-c4] note: enrichment stale for ${n.id} — run \`forma gen --enrich\` to refresh (non-blocking).`)
116
+ }
117
+ }
118
+
72
119
  if (errs.length) { console.error('[check-c4] FAIL (' + errs.length + '):\n - ' + errs.join('\n - ')); process.exit(1) }
73
120
  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,35 @@ ${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
+ const rel = relative(REPO, target)
87
+ console.log(`[forma doc] spliced generated block into ${rel} — ${containers.length} container(s); prose outside the markers untouched.`)
88
+ // Register the target so `forma check` governs THIS file too, not just source.docPath — an
89
+ // injected block nobody gates is exactly the false-green forma exists to kill. `gen` carries
90
+ // the registry forward across regens.
91
+ const known = m.source.attachedDocs || []
92
+ if (rel !== m.source.docPath && !known.includes(rel)) {
93
+ m.source.attachedDocs = [...known, rel].sort()
94
+ writeFileSync(MODEL, JSON.stringify(m, null, 2) + '\n')
95
+ console.log(`[forma doc] registered ${rel} in ${relative(REPO, MODEL)} — \`forma check\` now governs its block.`)
96
+ console.log('[forma doc] commit the model: the registry lives in it, and a lost model un-governs the doc.')
97
+ }
98
+ process.exit(0)
99
+ }
99
100
  writeFileSync(OUT, md)
100
101
  console.log(`[forma doc] wrote ${relative(REPO, OUT)} — ${containers.length} container(s), context + container diagrams from the model.`)
101
102
  console.log('[forma doc] deterministic sections filled; write the TODO(forma) prose, then promote to ARCHITECTURE.md.')
package/lib/enrich.mjs ADDED
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ // enrich.mjs — OPTIONAL LLM prose enrichment for description holes (§7). OFF by default.
3
+ // Contract: fills ONLY nodes whose deterministic descSource is 'fallback'; never structure, never
4
+ // the gate. Output is cached+committed in c4-model.json with descInputHash; `check` recomputes the
5
+ // hash (no network) and warns softly if stale. Providers hit their REST API via global fetch (zero-dep).
6
+ import { createHash } from 'node:crypto'
7
+ import { readFileSync } from 'node:fs'
8
+
9
+ const DEFAULT_MODEL = { anthropic: 'claude-haiku-4-5', openai: 'gpt-4o-mini', ollama: 'llama3.2', echo: 'echo' }
10
+
11
+ // Sibling leaves of the same container — the "neighbors" context for a hole.
12
+ function siblingsOf(node, ctx) {
13
+ const cont = ctx.containerOf ? ctx.containerOf(node, ctx.byId) : node.parent
14
+ const out = []
15
+ if (ctx.byId) for (const n of ctx.byId.values()) {
16
+ if (n.id !== node.id && n.kind === 'leaf' && (ctx.containerOf ? ctx.containerOf(n, ctx.byId) : n.parent) === cont) out.push(String(n.name))
17
+ }
18
+ return { cont, siblings: out.sort() }
19
+ }
20
+
21
+ // Stable inputs that define a node's description → hash. Change them and cached prose is stale.
22
+ // Pure, no network — check.mjs imports THIS (not the providers) to flag staleness.
23
+ export function descInputHash(node, ctx) {
24
+ const { cont, siblings } = siblingsOf(node, ctx)
25
+ const contName = (ctx.byId && ctx.byId.get(cont) || {}).name || cont || ''
26
+ const parts = [contName, node.name, node.kind, siblings.join(',')]
27
+ // A component's description is composed from its CHILDREN's docs, so their prose is one of its
28
+ // inputs. Without this, a child gaining a docstring leaves the hash unchanged: the cached LLM
29
+ // sentence is restored over the fresh documentation, `--enrich` sees no hole and `check` stays
30
+ // quiet — the box frozen with no way back short of hand-editing the model.
31
+ if (node.kind === 'component' && ctx.byId) {
32
+ parts.push([...ctx.byId.values()].filter((n) => n.parent === node.id).map((n) => String(n.func || '')).sort().join('|'))
33
+ }
34
+ return createHash('sha256').update(parts.join(' ')).digest('hex')
35
+ }
36
+
37
+ function promptFor(node, ctx, opts = {}) {
38
+ const { cont, siblings } = siblingsOf(node, ctx)
39
+ const contName = (ctx.byId && ctx.byId.get(cont) || {}).name || cont || 'the system'
40
+ const path = (node.evidence || []).find((e) => e.type === 'path')
41
+ return [
42
+ `In ONE plain sentence (max 18 words), say what the software module "${node.name}" does.`,
43
+ `It belongs to the container "${contName}".`,
44
+ siblings.length ? `Sibling modules: ${siblings.slice(0, 40).join(', ')}.` : '',
45
+ // Only the agent enricher can act on this: a REST provider has no filesystem. That asymmetry
46
+ // IS the point of the agent mode — the model driving forma can read the source it describes.
47
+ (opts.canRead && path) ? `Read the file at ${path.ref} if you need certainty.` : '',
48
+ 'Answer with the sentence only — no preamble, no quotes.',
49
+ ].filter(Boolean).join('\n')
50
+ }
51
+
52
+ async function callAnthropic(prompt, { model, apiKey }) {
53
+ const r = await fetch('https://api.anthropic.com/v1/messages', {
54
+ method: 'POST',
55
+ headers: { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
56
+ body: JSON.stringify({ model, max_tokens: 120, messages: [{ role: 'user', content: prompt }] }),
57
+ })
58
+ if (!r.ok) throw new Error(`anthropic ${r.status}: ${(await r.text()).slice(0, 160)}`)
59
+ const j = await r.json()
60
+ return (j.content || []).map((c) => c.text || '').join('').trim()
61
+ }
62
+ async function callOpenAI(prompt, { model, apiKey }) {
63
+ const r = await fetch('https://api.openai.com/v1/chat/completions', {
64
+ method: 'POST',
65
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
66
+ body: JSON.stringify({ model, max_tokens: 120, messages: [{ role: 'user', content: prompt }] }),
67
+ })
68
+ if (!r.ok) throw new Error(`openai ${r.status}: ${(await r.text()).slice(0, 160)}`)
69
+ const j = await r.json()
70
+ return (((j.choices || [])[0] || {}).message || {}).content?.trim() || ''
71
+ }
72
+ async function callOllama(prompt, { model }) {
73
+ const host = process.env.OLLAMA_HOST || 'http://localhost:11434'
74
+ const r = await fetch(`${host}/api/generate`, {
75
+ method: 'POST', headers: { 'content-type': 'application/json' },
76
+ body: JSON.stringify({ model, prompt, stream: false }),
77
+ })
78
+ if (!r.ok) throw new Error(`ollama ${r.status}`)
79
+ return String((await r.json()).response || '').trim()
80
+ }
81
+ async function callEcho() { return 'Auto-described (test enricher).' } // deterministic, no network — used by tests
82
+
83
+ const PROVIDERS = { anthropic: callAnthropic, openai: callOpenAI, ollama: callOllama, echo: callEcho }
84
+
85
+ // Cache: prior committed model → Map(id → node). mergeCache restores llm prose on unchanged inputs
86
+ // (runs ALWAYS, even without --enrich, so a plain regen doesn't destroy valid enrichment). Pure.
87
+ export function loadCache(priorModelPath) {
88
+ try { return new Map((JSON.parse(readFileSync(priorModelPath, 'utf-8')).nodes || []).map((n) => [n.id, n])) } catch { return new Map() }
89
+ }
90
+ // Stale prose (inputs changed) is restored TOO, keeping its old hash: a network outage must never
91
+ // make a box worse than it was. `check` keeps warning about it and the next --enrich refills it
92
+ // (see the hole selection below, which admits llm nodes whose hash no longer matches).
93
+ export function mergeCache(nodes, cache) {
94
+ let restored = 0
95
+ for (const n of nodes) {
96
+ if (n.descSource !== 'fallback') continue
97
+ const prev = cache.get(n.id)
98
+ if (prev && prev.descSource === 'llm' && prev.descInputHash && prev.func) {
99
+ n.func = prev.func; n.descSource = 'llm'; n.descInputHash = prev.descInputHash; restored++
100
+ }
101
+ }
102
+ return restored
103
+ }
104
+
105
+ // The holes an enricher would fill: never-described nodes, plus llm prose whose inputs moved.
106
+ export function holesIn(nodes, ctx) {
107
+ return nodes.filter((n) => (n.kind === 'leaf' || n.kind === 'component') &&
108
+ (n.descSource === 'fallback' || (n.descSource === 'llm' && n.descInputHash !== descInputHash(n, ctx))))
109
+ }
110
+
111
+ // --enricher agent: no network, no API key. When an AGENT drives forma the LLM is already in the
112
+ // room — asking it to call a REST API with a key it doesn't have is the one thing that made
113
+ // ollama look like a default. Emit the work instead, and let the agent write the prose.
114
+ export function agentPlan(nodes, ctx) {
115
+ return holesIn(nodes, ctx).map((n) => ({ id: n.id, prompt: promptFor(n, ctx, { canRead: true }), descInputHash: descInputHash(n, ctx) }))
116
+ }
117
+
118
+ // Apply prose an agent (or a human) wrote for those holes. Enrichment NEVER overwrites a real
119
+ // document: a fill aimed at a curated/docstring/readme/arc42 node is an error, not a silent skip.
120
+ export function applyFills(nodes, ctx, fills) {
121
+ const byId = new Map(nodes.map((n) => [n.id, n]))
122
+ let applied = 0
123
+ for (const f of fills) {
124
+ const n = byId.get(f && f.id)
125
+ if (!n) throw new Error(`--enrich-apply: unknown node id "${f && f.id}"`)
126
+ if (!f.func || !String(f.func).trim()) throw new Error(`--enrich-apply: "${f.id}" has no func text`)
127
+ if (n.descSource !== 'fallback' && n.descSource !== 'llm') throw new Error(`--enrich-apply: "${f.id}" is described by its ${n.descSource} — enrichment never overwrites documentation`)
128
+ n.func = String(f.func).trim(); n.descSource = 'llm'; n.descInputHash = descInputHash(n, ctx); applied++
129
+ }
130
+ return applied
131
+ }
132
+
133
+ // --enrich only: fill still-empty holes via the network. Best-effort: a provider error stops the
134
+ // pass (returns partial) rather than aborting gen — the caller keeps the deterministic fallback.
135
+ export async function enrich(nodes, ctx, opts = {}) {
136
+ const provider = opts.provider || 'anthropic'
137
+ const call = PROVIDERS[provider]
138
+ if (!call) throw new Error(`unknown enricher "${provider}" (anthropic|openai|ollama)`)
139
+ const model = opts.model || DEFAULT_MODEL[provider]
140
+ const apiKey = provider === 'anthropic' ? process.env.ANTHROPIC_API_KEY : provider === 'openai' ? process.env.OPENAI_API_KEY : ''
141
+ if ((provider === 'anthropic' || provider === 'openai') && !apiKey) throw new Error(`--enrich: ${provider} needs ${provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'} in the environment`)
142
+ const holes = holesIn(nodes, ctx)
143
+ let filled = 0
144
+ for (const n of holes) {
145
+ const text = await call(promptFor(n, ctx), { model, apiKey })
146
+ if (text) { n.func = text; n.descSource = 'llm'; n.descInputHash = descInputHash(n, ctx); filled++ }
147
+ }
148
+ return { filled, holes: holes.length }
149
+ }