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/lib/gen.mjs CHANGED
@@ -6,17 +6,31 @@ 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, agentPlan, applyFills } 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.3.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 STATUS = arg('--status', join(REPO, 'docs/architecture/c4-status.json')) // curated programme state, optional
24
+ const STATUS_SET = process.argv.includes('--status')
25
+ const APPLY = arg('--enrich-apply', null) // prose written by the agent driving forma
16
26
 
17
27
  const topo = JSON.parse(readFileSync(TOPO, 'utf-8'))
18
28
  const rp = (p) => join(REPO, p)
19
29
  const fail = (m) => { console.error('[gen-c4] FAIL: ' + m); process.exit(1) }
30
+ // §2 thresholds, overridable. Guarded: a non-integer would silently disable clustering
31
+ // (`length >= NaN` is false for every group), so a bad value fails loud instead.
32
+ const posInt = (flag, dflt) => { const v = Number(arg(flag, dflt)); if (!Number.isInteger(v) || v < 1) fail(`${flag}: expected a positive integer, got "${arg(flag, dflt)}"`); return v }
33
+ const CLUSTER_MIN = posInt('--cluster-min', 8), GROUP_MIN = posInt('--group-min', 3)
20
34
 
21
35
  function walk(spec) {
22
36
  const dir = rp(spec.dir)
@@ -86,21 +100,107 @@ for (const p of topo.plannedLeaves || []) {
86
100
  evidence: [{ type: 'doc', ref: p.sourceRef }] })
87
101
  }
88
102
 
103
+ // §2) synthesize a component layer for flat containers (auto; --no-cluster to disable).
104
+ // Runs BEFORE the enrich/func loops so components inherit category/status/func. Leaves that
105
+ // join a component are re-parented to it; containerOf() keeps edge/check logic container-scoped.
106
+ if (CLUSTER) for (const cid of [...new Set(topo.leafSources.map((s) => s.parent))]) {
107
+ const container = byId.get(cid); if (!container) continue
108
+ const leaves = nodes.filter((n) => n.kind === 'leaf' && n.parent === cid) // pre-reparent: plain filter valid here
109
+ if (leaves.length <= CLUSTER_MIN) continue
110
+ const { components, reparent } = componentsFor(container, leaves, { groupMin: GROUP_MIN })
111
+ if (!components.length) { console.log(`[gen-c4] container ${cid}: ${leaves.length} leaf non-clusterable (flat)`); continue }
112
+ for (const c of components) add(c)
113
+ for (const n of leaves) { const to = reparent.get(n.id); if (to) n.parent = to }
114
+ console.log(`[gen-c4] container ${cid}: clustered ${leaves.length} leaves into ${components.length} component(s)`)
115
+ }
116
+
89
117
  // enrich: fill hologram defaults (category, 5-status, completion, current/target) where absent
90
118
  for (const n of nodes) {
91
119
  const par = n.parent ? byId.get(n.parent) : null
92
120
  if (!n.category) n.category = n.kind === 'leaf' ? (par && par.category) || 'leaf' : n.kind
93
121
  if (!n.status2) n.status2 = n.status === 'planned' ? 'planned' : 'done'
94
122
  if (n.completion == null) n.completion = n.status === 'planned' ? 0 : 100
95
- if (!n.current) { const pth = (n.evidence || []).find((e) => e.type === 'path'); n.current = pth ? `Exists: ${pth.ref}` : (n.tech || '') }
123
+ // `current` is left EMPTY unless curated (topology or status overlay): the viewer falls back to
124
+ // `func`, which since §1a carries the module's real documentation. The old "Exists: <path>"
125
+ // filler restated the evidence path in the one field meant for programme facts.
96
126
  if (n.target == null) n.target = ''
97
127
  }
98
128
 
99
- // func: plain-language "what it does" (non-dev). Leaves from the descriptions map; else the description.
100
- const D = topo.descriptions || {}
129
+ // §1a) func: plain-language "what it does" resolved from existing docs (curated docstring → README
130
+ // arc42 → generated fallback), with provenance in descSource. No LLM here — pure parsing.
131
+ const dctx = makeDescribeCtx({ repo: REPO, byId, descriptions: topo.descriptions || {}, docPath: topo.docPath, containerOf })
132
+ for (const n of nodes) { const r = resolveDescription(n, dctx); n.func = r.func; n.descSource = r.descSource }
133
+
134
+ // §1a-bis) a synthesized component has no doc of its own: before settling for "Groups related
135
+ // files under X", compose its box from its children's docs (first sentence of up to 3, ordered by
136
+ // name — deterministic). descSource stays 'fallback': this is a heuristic, and --enrich may still
137
+ // improve it. Separate pass so it does not depend on components being added after their leaves.
138
+ const firstSentence = (s) => (String(s).match(/^[^.!?]*[.!?]/) || [String(s)])[0].trim()
101
139
  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}.` : '')
140
+ if (n.kind !== 'component' || n.descSource !== 'fallback') continue
141
+ const kids = nodes.filter((k) => k.parent === n.id && ['curated', 'docstring', 'readme'].includes(k.descSource))
142
+ .sort((a, b) => (String(a.name) < String(b.name) ? -1 : String(a.name) > String(b.name) ? 1 : 0)).slice(0, 3)
143
+ const txt = kids.map((k) => firstSentence(k.func)).filter(Boolean).join(' ')
144
+ if (txt) n.func = txt.length > 200 ? txt.slice(0, 199) + '…' : txt
145
+ }
146
+
147
+ // §WP-A1) programme-status overlay: the one channel for curated STATE (what is true now, where it
148
+ // must land, what proves it). Code can't know it, so a human/agent writes it in c4-status.json and
149
+ // gen validates only its FORM — ids resolve, fields are known, enums/ranges hold. Never `func`:
150
+ // what a module DOES comes from its docs (§1a), what it's WORTH is programme state.
151
+ const STATUS_FIELDS = new Set(['status2', 'completion', 'statusWord', 'current', 'target', 'verify', 'issues'])
152
+ const STATUS2 = new Set(['done', 'in-progress', 'next', 'planned', 'problem'])
153
+ let statusPath = null
154
+ if (existsSync(STATUS) || STATUS_SET) {
155
+ if (!existsSync(STATUS)) fail(`--status: file missing: ${STATUS}`)
156
+ let ov
157
+ try { ov = JSON.parse(readFileSync(STATUS, 'utf-8')) } catch (e) { fail(`--status: ${STATUS} is not valid JSON: ${(e && e.message) || e}`) }
158
+ let decorated = 0
159
+ for (const [id, patch] of Object.entries(ov.nodes || {})) {
160
+ const target = byId.get(id)
161
+ if (!target) fail(`status overlay: unknown node id "${id}" — it is not in the model (stale overlay?)`)
162
+ for (const [k, v] of Object.entries(patch)) {
163
+ if (k.startsWith('$')) continue // $comment and friends
164
+ if (!STATUS_FIELDS.has(k)) fail(`status overlay: "${id}" has field "${k}"; allowed: ${[...STATUS_FIELDS].join(', ')}${k === 'func' ? ' (func comes from the docs, not the overlay)' : ''}`)
165
+ if (k === 'status2' && !STATUS2.has(v)) fail(`status overlay: "${id}" status2 "${v}" — expected one of ${[...STATUS2].join('|')}`)
166
+ if (k === 'completion' && !(Number.isInteger(v) && v >= 0 && v <= 100)) fail(`status overlay: "${id}" completion must be an integer 0-100, got ${JSON.stringify(v)}`)
167
+ if (k === 'issues') {
168
+ if (!Array.isArray(v)) fail(`status overlay: "${id}" issues must be an array`)
169
+ for (const is of v) if (!/^#?\d+$/.test(String(is))) fail(`status overlay: "${id}" issue ${JSON.stringify(is)} — expected "#123"`)
170
+ }
171
+ target[k] = v
172
+ }
173
+ decorated++
174
+ }
175
+ statusPath = relative(REPO, STATUS)
176
+ console.log(`[gen-c4] status overlay: ${decorated} node(s) decorated from ${statusPath}`)
177
+ }
178
+
179
+ // §7) enrichment (prose only, never structure/gate). Cache-merge ALWAYS: preserve prior LLM prose
180
+ // whose inputs are unchanged, so a plain regen doesn't discard it. --enrich additionally fills the
181
+ // remaining holes over the network (best-effort — a failure keeps the deterministic fallback).
182
+ const ectx = { repo: REPO, byId, containerOf }
183
+ mergeCache(nodes, loadCache(OUT))
184
+ // --enrich-apply: prose written by the agent that drives forma (see --enricher agent below).
185
+ // Independent of --enrich, and applied first — an explicit fill outranks anything generated.
186
+ if (APPLY) {
187
+ if (!existsSync(APPLY)) fail(`--enrich-apply: file missing: ${APPLY}`)
188
+ let fills
189
+ try { fills = JSON.parse(readFileSync(APPLY, 'utf-8')).fills } catch (e) { fail(`--enrich-apply: ${APPLY} is not valid JSON: ${(e && e.message) || e}`) }
190
+ if (!Array.isArray(fills)) fail(`--enrich-apply: ${APPLY} must contain {"fills":[{"id","func"}]}`)
191
+ try { console.log(`[gen-c4] --enrich-apply: ${applyFills(nodes, ectx, fills)} fill(s) applied`) }
192
+ catch (e) { fail(String((e && e.message) || e)) }
193
+ }
194
+ if (ENRICH && ENRICHER === 'agent') {
195
+ // No network: emit the work for the agent already in the session, keep the deterministic model.
196
+ const plan = agentPlan(nodes, ectx)
197
+ const planPath = join(dirname(OUT), 'enrich-plan.json')
198
+ writeFileSync(planPath, JSON.stringify({ entries: plan }, null, 2) + '\n')
199
+ console.log(`[gen-c4] --enricher agent: ${plan.length} hole(s) → ${relative(REPO, planPath)} — write the prose yourself, then:`)
200
+ console.log(`[gen-c4] forma gen --enrich-apply ${relative(REPO, join(dirname(OUT), 'enrich-fill.json'))}`)
201
+ } else if (ENRICH) {
202
+ 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)`) }
203
+ catch (e) { console.error('[gen-c4] --enrich skipped: ' + String((e && e.message) || e)) }
104
204
  }
105
205
 
106
206
  // 4b) derive container↔container edges from REAL code references (deterministic, additive).
@@ -113,7 +213,7 @@ if (!process.argv.includes('--no-auto-edges')) {
113
213
  const srcs = (topo.leafSources || []).filter((s) => byId.has(s.parent))
114
214
  const exposes = new Map(), text = new Map()
115
215
  for (const s of srcs) {
116
- exposes.set(s.parent, [...new Set(nodes.filter((n) => n.parent === s.parent && n.kind === 'leaf')
216
+ exposes.set(s.parent, [...new Set(nodes.filter((n) => n.kind === 'leaf' && containerOf(n, byId) === s.parent)
117
217
  .map((n) => String(n.name)).filter((nm) => nm.length >= 5 && !STOP.has(nm.toLowerCase())))])
118
218
  let t = ''; const dir = rp(s.dir)
119
219
  try { for (const f of readdirSync(dir)) { const fp = join(dir, f); if (statSync(fp).isFile()) t += '\n' + readFileSync(fp, 'utf-8') } } catch {}
@@ -143,11 +243,26 @@ const gitOpts = { cwd: REPO, stdio: ['ignore', 'pipe', 'ignore'] }
143
243
  try { commit = execSync('git rev-parse HEAD', gitOpts).toString().trim() } catch {}
144
244
  try { branch = execSync('git rev-parse --abbrev-ref HEAD', gitOpts).toString().trim() } catch {}
145
245
 
246
+ // §1b) carry the attach registry forward from the prior model — `forma doc --attach` writes it
247
+ // there, and without this a plain regen would drop it and un-govern the attached docs.
248
+ // The registry lives ONLY in the generated model, so that model has to be committed (or at least
249
+ // kept) for the gate to keep governing. If a prior model is there but unreadable, say so rather
250
+ // than silently ungoverning every attached doc.
251
+ let attachedDocs = []
252
+ if (existsSync(OUT)) {
253
+ try { attachedDocs = JSON.parse(readFileSync(OUT, 'utf-8')).source.attachedDocs || [] }
254
+ catch (e) { console.error(`[gen-c4] WARNING: ${OUT} is unreadable (${(e && e.message) || e}) — any source.attachedDocs registry in it is lost; re-run \`forma doc --attach\` for each attached doc.`) }
255
+ }
256
+
257
+ // generatedAt is the ONE per-run volatile field (a plain `gen` x2 must diff on nothing else).
258
+ // meta.verifiedAt is reserved for `forma verify` (network, opt-in) — gen never writes it.
146
259
  const model = {
147
260
  schemaVersion: SCHEMA_VERSION,
148
261
  generatedAt: new Date().toISOString(),
149
- source: { repo: topo.meta.repo, commit, branch, docPath: topo.docPath, generator: 'gen-c4-model@' + SCHEMA_VERSION },
150
- meta: { ...topo.meta, verifiedAt: new Date().toISOString(), verifyMethod: 'code+topology (gh re-verify optional)' },
262
+ source: { repo: topo.meta.repo, commit, branch, docPath: topo.docPath, generator: 'gen-c4-model@' + SCHEMA_VERSION, ...(attachedDocs.length ? { attachedDocs } : {}), ...(statusPath ? { statusPath } : {}) },
263
+ // topo.layout (optional curated coordinates, keyed by parent id) rides along verbatim: the
264
+ // viewer pins those nodes and auto-arranges the rest. `meta` is the model's free-form area.
265
+ meta: { ...topo.meta, ...(topo.layout ? { layout: topo.layout } : {}), verifyMethod: 'code+topology (gh re-verify optional)' },
151
266
  levels: topo.levels,
152
267
  nodes,
153
268
  edges: topo.edges.map((e) => ({ status: 'current', estatus: e.estatus || 'active', ...e })),
package/lib/init.mjs CHANGED
@@ -11,18 +11,27 @@ 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
 
17
23
  if (existsSync(OUT) && !FORCE) { console.error(`[forma init] ${relative(REPO, OUT)} already exists — use --force to reseed.`); process.exit(1) }
18
24
 
19
- // 1) detect dominant language by extension count (skipping ignored dirs)
25
+ // 1) detect dominant language by extension count. Skips the SAME dirs the container pass skips
26
+ // (build junk AND data/fixture/doc dirs): counting files that can never become a container is how
27
+ // a repo full of Python fixtures gets detected as a Python repo and then finds no source dir.
20
28
  const counts = {}
21
29
  ;(function walk(dir, depth) {
22
30
  if (depth > 12) return
23
31
  let ents; try { ents = readdirSync(dir) } catch { return }
24
32
  for (const e of ents) {
25
33
  if (IGNORE.has(e) || e.startsWith('.')) continue
34
+ if (SKIP_DIRS.has(e.toLowerCase()) && !KEEP.has(e.toLowerCase())) continue
26
35
  const p = join(dir, e)
27
36
  let st; try { st = statSync(p) } catch { continue }
28
37
  if (st.isDirectory()) walk(p, depth + 1)
@@ -58,6 +67,7 @@ const hasSrc = (dir) => { try { return readdirSync(dir).some((f) => { const p =
58
67
  // (A dir with direct sources is a container; a dir with none is descended into. Works for nested
59
68
  // layouts like src/app/routes/*.ts or Python packages, not just one level.)
60
69
  const seen = new Set()
70
+ const skipped = []
61
71
  ;(function findContainers(dir, depth) {
62
72
  if (depth > 10) return
63
73
  let ents; try { ents = readdirSync(dir) } catch { return }
@@ -65,6 +75,7 @@ const seen = new Set()
65
75
  const p = join(dir, e)
66
76
  let st; try { st = statSync(p) } catch { continue }
67
77
  if (!st.isDirectory()) continue
78
+ 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
79
  if (hasSrc(p)) {
69
80
  const rel = relative(root, p) || e
70
81
  let id = slug(rel.replace(/[\\/]+/g, '_'))
@@ -78,7 +89,7 @@ const seen = new Set()
78
89
  // loose files directly in root → an entry container
79
90
  if (hasSrc(root)) { nodes.push({ id: 'app', level: 'container', kind: 'container', parent: sysId, name: 'app', tech: lang, description: 'Entry point / loose top-level sources.' }); leafSources.push({ parent: 'app', dir: relative(REPO, root) || '.', match: matchRe }) }
80
91
 
81
- if (leafSources.length === 0) { console.error('[forma init] found a source root but no directory with source files directly in it. Add leafSources manually.'); process.exit(1) }
92
+ if (leafSources.length === 0) { console.error(`[forma init] detected ${lang} (*.${ext[0]}) as the dominant language, but no directory under ${relative(REPO, root) || '.'}/ holds *.${ext[0]} files directly. Pass --include <dir,...> if the sources live in a skipped dir, or add leafSources manually.`); process.exit(1) }
82
93
 
83
94
  const topo = {
84
95
  meta: { repo: basename(REPO), stack: lang, seededBy: 'forma init' },
@@ -88,9 +99,10 @@ const topo = {
88
99
  leafSources,
89
100
  edges: [],
90
101
  descriptions: {},
102
+ _skipped: skipped,
91
103
  }
92
104
  // ensure output dir exists
93
105
  mkdirSync(join(OUT, '..'), { recursive: true })
94
106
  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) || '.'}/`)
107
+ 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
108
  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
+ }
@@ -45,6 +45,17 @@
45
45
  "type": "string",
46
46
  "description": "The arc42 architecture doc this model derives from."
47
47
  },
48
+ "statusPath": {
49
+ "type": "string",
50
+ "description": "Repo-relative curated programme-status overlay applied by `forma gen` (c4-status.json). The gate fails if it decorates a node id the model no longer has."
51
+ },
52
+ "attachedDocs": {
53
+ "type": "array",
54
+ "items": {
55
+ "type": "string"
56
+ },
57
+ "description": "Repo-relative docs carrying an injected forma block (written by `forma doc --attach`). The drift gate governs every one of them, not just docPath."
58
+ },
48
59
  "generator": {
49
60
  "type": "string"
50
61
  }
@@ -203,6 +214,22 @@
203
214
  "func": {
204
215
  "type": "string",
205
216
  "description": "Plain-language 'what it does' for non-developers."
217
+ },
218
+ "descSource": {
219
+ "type": "string",
220
+ "enum": [
221
+ "curated",
222
+ "docstring",
223
+ "readme",
224
+ "arc42",
225
+ "fallback",
226
+ "llm"
227
+ ],
228
+ "description": "Provenance of func: how the description was resolved (§1a chain; 'llm' = optional enrichment)."
229
+ },
230
+ "descInputHash": {
231
+ "type": "string",
232
+ "description": "sha256 of the inputs that produced an LLM-enriched func; check recomputes it to flag stale prose."
206
233
  }
207
234
  }
208
235
  }
package/lib/verify.mjs ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ // forma verify — refresh programme status from LIVE GitHub issues, through the user's `gh` CLI.
3
+ // This is the ONLY command that touches the network, and it is opt-in: `gen` and `check` stay
4
+ // deterministic and offline forever (that is the product). It never touches structure — no nodes,
5
+ // no edges, no func — only the state fields of nodes that reference an issue.
6
+ // Usage: node verify.mjs [--repo <path>] [--model <path>] [--gh-repo <owner/repo>] [--gh-cmd <cmd>]
7
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
8
+ import { execFileSync } from 'node:child_process'
9
+ import { join } from 'node:path'
10
+
11
+ const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
12
+ const REPO = arg('--repo', process.cwd())
13
+ const MODEL = arg('--model', join(REPO, 'docs/architecture/c4-model.json'))
14
+ const GH_CMD = arg('--gh-cmd', 'gh') // split on whitespace; the tests point it at a stub
15
+ const LIMIT = arg('--limit', '250')
16
+ const fail = (m) => { console.error('[forma verify] ' + m); process.exit(1) }
17
+
18
+ if (!existsSync(MODEL)) fail(`model missing: ${MODEL} — run \`forma gen\` first.`)
19
+ const model = JSON.parse(readFileSync(MODEL, 'utf-8'))
20
+ const GH_REPO = arg('--gh-repo', (model.meta && model.meta.ghRepo) || null)
21
+ if (!GH_REPO) fail('no target repo: pass --gh-repo <owner/repo>, or set meta.ghRepo in the topology.')
22
+
23
+ // Which nodes claim which issue — from issues[] and from verify.issue.
24
+ const num = (v) => { const m = /^#?(\d+)$/.exec(String(v)); return m ? Number(m[1]) : null }
25
+ const refs = new Map()
26
+ for (const n of model.nodes || []) {
27
+ const claimed = [...(n.issues || []), ...(n.verify && n.verify.issue != null ? [n.verify.issue] : [])]
28
+ for (const raw of claimed) {
29
+ const i = num(raw); if (!i) continue
30
+ if (!refs.has(i)) refs.set(i, [])
31
+ refs.get(i).push(n)
32
+ }
33
+ }
34
+ if (!refs.size) { console.log('[forma verify] no issue references in the model — nothing to verify (add issues[] via the status overlay).'); process.exit(0) }
35
+
36
+ // ONE call for every issue: state for the whole repo, then match locally.
37
+ const parts = String(GH_CMD).split(/\s+/).filter(Boolean)
38
+ let raw
39
+ try {
40
+ raw = execFileSync(parts[0], [...parts.slice(1), 'issue', 'list', '--repo', GH_REPO, '--state', 'all', '--limit', String(LIMIT), '--json', 'number,state'],
41
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] })
42
+ } catch (e) {
43
+ const why = (e && e.code === 'ENOENT') ? `\`${parts[0]}\` not found — install the GitHub CLI or pass --gh-cmd` : String((e && (e.stderr || e.message)) || e).trim()
44
+ console.error(`[forma verify] ${why}`)
45
+ fail('model left untouched.')
46
+ }
47
+ let issues
48
+ try { issues = JSON.parse(raw) } catch { fail(`unexpected output from \`${parts[0]}\` (wanted JSON): ${String(raw).slice(0, 160)}`) }
49
+ if (!Array.isArray(issues)) fail('unexpected output: expected a JSON array of {number,state}.')
50
+
51
+ const ts = new Date().toISOString()
52
+ const seen = new Set()
53
+ let closed = 0, decorated = 0
54
+ for (const it of issues) {
55
+ const n = Number(it.number); seen.add(n)
56
+ if (String(it.state || '').toUpperCase() !== 'CLOSED') continue
57
+ const nodes = refs.get(n); if (!nodes) continue
58
+ closed++
59
+ for (const node of nodes) {
60
+ node.status2 = 'done'; node.completion = 100
61
+ // The badge shows statusWord when there is one, so a curated "NEXT"/"50%" would survive the
62
+ // node turning green — a box claiming both at once. Marking done owns the badge too.
63
+ if (node.statusWord) node.statusWord = '100%'
64
+ const mark = `(#${n} CLOSED` // re-running must not stack prefixes
65
+ if (!String(node.current || '').includes(mark)) {
66
+ node.current = `Closed with evidence ${mark}, gh ${ts}). ${String(node.current || '').trim()}`.trim()
67
+ decorated++
68
+ }
69
+ }
70
+ }
71
+ const unknown = [...refs.keys()].filter((n) => !seen.has(n))
72
+
73
+ model.meta = model.meta || {}
74
+ model.meta.verifiedAt = ts // the ONE field only verify writes — gen must stay single-volatile (R2)
75
+ model.meta.verifyMethod = 'gh live'
76
+ writeFileSync(MODEL, JSON.stringify(model, null, 2) + '\n')
77
+ console.log(`[forma verify] ${GH_REPO}: ${refs.size} referenced issue(s), ${closed} closed → ${decorated} node(s) marked done`)
78
+ if (unknown.length) console.log(`[forma verify] not returned by gh (closed long ago, wrong repo, or beyond --limit ${LIMIT}): ${unknown.map((n) => '#' + n).join(', ')}`)
79
+ console.log(`[forma verify] fact base stamped ${ts} — structure untouched, \`forma check\` unaffected.`)