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/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.
|
|
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
|
-
|
|
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"
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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(
|
|
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.`)
|