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 +29 -5
- package/bin/forma.mjs +2 -1
- package/lib/check.mjs +48 -1
- package/lib/cluster.mjs +44 -0
- package/lib/describe.mjs +90 -0
- package/lib/doc.mjs +35 -34
- package/lib/enrich.mjs +149 -0
- package/lib/gen.mjs +124 -9
- package/lib/init.mjs +15 -3
- package/lib/render.mjs +74 -0
- package/lib/schema/c4-model.schema.json +27 -0
- package/lib/verify.mjs +79 -0
- package/lib/viewer/c4-hologram.html +120 -32
- package/package.json +1 -1
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
|
|
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
|
+
| `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-
|
|
59
|
-
- **
|
|
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.
|
|
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)')
|
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,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
|
+
}
|