forma-arch 0.1.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/LICENSE +201 -0
- package/NOTICE +10 -0
- package/README.md +59 -0
- package/bin/forma.mjs +39 -0
- package/lib/check.mjs +73 -0
- package/lib/doc.mjs +101 -0
- package/lib/gen.mjs +173 -0
- package/lib/init.mjs +96 -0
- package/lib/schema/c4-model.schema.json +274 -0
- package/lib/serve.mjs +27 -0
- package/lib/viewer/c4-hologram.html +285 -0
- package/package.json +51 -0
package/lib/gen.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gen-c4-model.mjs — emit docs/architecture/c4-model.json from real code + curated topology.
|
|
3
|
+
// Leaves are walked LIVE from src/ (always current). Topology/context/runtime-edges are curated.
|
|
4
|
+
// Usage: node gen-c4-model.mjs [--repo <path>] [--topology <path>] [--out <path>]
|
|
5
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from 'node:fs'
|
|
6
|
+
import { join, dirname, basename, relative } from 'node:path'
|
|
7
|
+
import { execSync } from 'node:child_process'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
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 TOPO = arg('--topology', join(REPO, 'docs/architecture/c4-topology.json'))
|
|
14
|
+
const OUT = arg('--out', join(REPO, 'docs/architecture/c4-model.json'))
|
|
15
|
+
const SCHEMA_VERSION = '1.1.0'
|
|
16
|
+
|
|
17
|
+
const topo = JSON.parse(readFileSync(TOPO, 'utf-8'))
|
|
18
|
+
const rp = (p) => join(REPO, p)
|
|
19
|
+
const fail = (m) => { console.error('[gen-c4] FAIL: ' + m); process.exit(1) }
|
|
20
|
+
|
|
21
|
+
function walk(spec) {
|
|
22
|
+
const dir = rp(spec.dir)
|
|
23
|
+
if (!existsSync(dir)) fail(`leafSource dir missing: ${spec.dir}`)
|
|
24
|
+
const re = new RegExp(spec.match), ex = spec.exclude ? new RegExp(spec.exclude) : null
|
|
25
|
+
return readdirSync(dir).filter((f) => {
|
|
26
|
+
const full = join(dir, f)
|
|
27
|
+
if (spec.filesOnly !== false && statSync(full).isDirectory()) return false
|
|
28
|
+
if (!re.test(f)) return false
|
|
29
|
+
if (ex && ex.test(f)) return false
|
|
30
|
+
return true
|
|
31
|
+
}).sort()
|
|
32
|
+
}
|
|
33
|
+
function countMatches(file, pattern, unique) {
|
|
34
|
+
const p = rp(file)
|
|
35
|
+
if (!existsSync(p)) fail(`countFrom file missing: ${file}`)
|
|
36
|
+
const txt = readFileSync(p, 'utf-8')
|
|
37
|
+
const m = txt.match(new RegExp(pattern, 'g')) || []
|
|
38
|
+
return unique ? new Set(m).size : m.length
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const nodes = []
|
|
42
|
+
const byId = new Map()
|
|
43
|
+
const add = (n) => { if (byId.has(n.id)) fail('dup node id ' + n.id); byId.set(n.id, n); nodes.push(n) }
|
|
44
|
+
|
|
45
|
+
// 1) curated non-leaf nodes (context/container/component)
|
|
46
|
+
for (const n of topo.nodes) add({ status: 'current', ...n })
|
|
47
|
+
|
|
48
|
+
// 2) live leaves from src/
|
|
49
|
+
for (const spec of topo.leafSources) {
|
|
50
|
+
if (!byId.has(spec.parent)) fail('leafSource parent unknown: ' + spec.parent)
|
|
51
|
+
const files = walk(spec)
|
|
52
|
+
if (files.length === 0) fail(`leafSource matched 0 files (phantom node?): ${spec.dir}`)
|
|
53
|
+
for (const f of files) {
|
|
54
|
+
add({
|
|
55
|
+
id: `${spec.parent}__${f.replace(/[^a-z0-9]+/gi, '_')}`,
|
|
56
|
+
level: 'leaf', parent: spec.parent, kind: 'leaf',
|
|
57
|
+
name: f.replace(/\.[a-z0-9]+$/i, ''), status: 'current',
|
|
58
|
+
evidence: [{ type: 'path', ref: `${spec.dir}/${f}` }],
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
// attach glob count to the parent (drift anchor)
|
|
62
|
+
const parent = byId.get(spec.parent)
|
|
63
|
+
parent.evidence = [...(parent.evidence || []), { type: 'glob', ref: spec.dir, count: files.length }]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3) curated leaves (mixed-location, with optional computed counts)
|
|
67
|
+
for (const l of topo.curatedLeaves || []) {
|
|
68
|
+
if (!byId.has(l.parent)) fail('curatedLeaf parent unknown: ' + l.parent)
|
|
69
|
+
if (l.evidence && !existsSync(rp(l.evidence))) fail('curatedLeaf evidence missing: ' + l.evidence)
|
|
70
|
+
let tech = l.tech
|
|
71
|
+
if (l.countFrom) { const c = countMatches(l.countFrom.file, l.countFrom.pattern, l.countFrom.unique); tech = `${c} ${l.tech || ''}`.trim() }
|
|
72
|
+
add({ id: l.id, level: 'leaf', parent: l.parent, kind: 'leaf', name: l.name, tech, status: 'current',
|
|
73
|
+
evidence: l.evidence ? [{ type: 'path', ref: l.evidence }] : [] })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 4) planned leaves — verify their premise still holds in the docs (non-vacuous)
|
|
77
|
+
for (const p of topo.plannedLeaves || []) {
|
|
78
|
+
if (!byId.has(p.parent)) fail('plannedLeaf parent unknown: ' + p.parent)
|
|
79
|
+
if (p.sourceRef && !existsSync(rp(p.sourceRef))) fail('plannedLeaf sourceRef missing: ' + p.sourceRef)
|
|
80
|
+
if (p.sourceMustContain) {
|
|
81
|
+
const txt = readFileSync(rp(p.sourceRef), 'utf-8')
|
|
82
|
+
if (!txt.includes(p.sourceMustContain)) fail(`planned "${p.name}" premise changed — "${p.sourceMustContain}" not in ${p.sourceRef}. Update the roadmap/model.`)
|
|
83
|
+
}
|
|
84
|
+
add({ id: p.id, level: 'leaf', parent: p.parent, kind: 'leaf', name: p.name, tech: p.tech, status: 'planned',
|
|
85
|
+
status2: p.status2, completion: p.completion, current: p.current, target: p.target, verify: p.verify,
|
|
86
|
+
evidence: [{ type: 'doc', ref: p.sourceRef }] })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// enrich: fill hologram defaults (category, 5-status, completion, current/target) where absent
|
|
90
|
+
for (const n of nodes) {
|
|
91
|
+
const par = n.parent ? byId.get(n.parent) : null
|
|
92
|
+
if (!n.category) n.category = n.kind === 'leaf' ? (par && par.category) || 'leaf' : n.kind
|
|
93
|
+
if (!n.status2) n.status2 = n.status === 'planned' ? 'planned' : 'done'
|
|
94
|
+
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 || '') }
|
|
96
|
+
if (n.target == null) n.target = ''
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// func: plain-language "what it does" (non-dev). Leaves from the descriptions map; else the description.
|
|
100
|
+
const D = topo.descriptions || {}
|
|
101
|
+
for (const n of nodes) {
|
|
102
|
+
const key = n.parent ? `${n.parent}/${String(n.name).replace(/\.\w+$/, '').replace(/ .*/, '')}` : null
|
|
103
|
+
n.func = (key && D[key]) || n.description || (n.kind === 'leaf' ? `Component of module ${(byId.get(n.parent) || {}).name || n.parent}.` : '')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 4b) derive container↔container edges from REAL code references (deterministic, additive).
|
|
107
|
+
// Auto-walk gives structure (containers+leaves) but not relationships. Here we recover them from the
|
|
108
|
+
// code itself: for each container, count how many of ANOTHER container's exposed leaf names (class/
|
|
109
|
+
// module names) appear as whole-word references in this container's files. count>0 ⇒ a real edge.
|
|
110
|
+
// Language-agnostic (matches symbol names, not import syntax). Additive: never removes curated edges.
|
|
111
|
+
if (!process.argv.includes('--no-auto-edges')) {
|
|
112
|
+
const STOP = new Set(['index', 'main', 'app', 'utils', 'util', 'types', 'model', 'base', 'core', 'const', 'style', 'theme'])
|
|
113
|
+
const srcs = (topo.leafSources || []).filter((s) => byId.has(s.parent))
|
|
114
|
+
const exposes = new Map(), text = new Map()
|
|
115
|
+
for (const s of srcs) {
|
|
116
|
+
exposes.set(s.parent, [...new Set(nodes.filter((n) => n.parent === s.parent && n.kind === 'leaf')
|
|
117
|
+
.map((n) => String(n.name)).filter((nm) => nm.length >= 5 && !STOP.has(nm.toLowerCase())))])
|
|
118
|
+
let t = ''; const dir = rp(s.dir)
|
|
119
|
+
try { for (const f of readdirSync(dir)) { const fp = join(dir, f); if (statSync(fp).isFile()) t += '\n' + readFileSync(fp, 'utf-8') } } catch {}
|
|
120
|
+
text.set(s.parent, t)
|
|
121
|
+
}
|
|
122
|
+
const have = new Set((topo.edges || []).flatMap((e) => [e.from + '|' + e.to, e.to + '|' + e.from]))
|
|
123
|
+
const derived = []
|
|
124
|
+
for (const from of exposes.keys()) {
|
|
125
|
+
const t = text.get(from) || ''
|
|
126
|
+
for (const to of exposes.keys()) {
|
|
127
|
+
if (to === from || have.has(from + '|' + to)) continue
|
|
128
|
+
let c = 0
|
|
129
|
+
for (const nm of exposes.get(to)) { if (new RegExp('\\b' + nm.replace(/[^\w]/g, '') + '\\b').test(t)) c++ }
|
|
130
|
+
if (c > 0) { derived.push({ from, to, label: c + '×', kind: 'import', estatus: 'active' }); have.add(from + '|' + to); have.add(to + '|' + from) }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
topo.edges = [...(topo.edges || []), ...derived]
|
|
134
|
+
if (derived.length) console.log(`[gen-c4] auto-edges: +${derived.length} container edge(s) derived from code references`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 5) validate edges resolve
|
|
138
|
+
for (const e of topo.edges) { if (!byId.has(e.from)) fail('edge from unknown: ' + e.from); if (!byId.has(e.to)) fail('edge to unknown: ' + e.to) }
|
|
139
|
+
|
|
140
|
+
// 6) provenance
|
|
141
|
+
let commit = 'unknown', branch = 'unknown'
|
|
142
|
+
const gitOpts = { cwd: REPO, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
143
|
+
try { commit = execSync('git rev-parse HEAD', gitOpts).toString().trim() } catch {}
|
|
144
|
+
try { branch = execSync('git rev-parse --abbrev-ref HEAD', gitOpts).toString().trim() } catch {}
|
|
145
|
+
|
|
146
|
+
const model = {
|
|
147
|
+
schemaVersion: SCHEMA_VERSION,
|
|
148
|
+
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)' },
|
|
151
|
+
levels: topo.levels,
|
|
152
|
+
nodes,
|
|
153
|
+
edges: topo.edges.map((e) => ({ status: 'current', estatus: e.estatus || 'active', ...e })),
|
|
154
|
+
}
|
|
155
|
+
// --from-docs (optional): derive the TARGET layer from documentation (PRD→docs), not code.
|
|
156
|
+
// Surfaces project-status milestones + ADR statuses so "stato finito" is doc-driven. Best-effort.
|
|
157
|
+
if (process.argv.includes('--from-docs')) {
|
|
158
|
+
try {
|
|
159
|
+
const ps = existsSync(rp('docs/project-status.md')) ? readFileSync(rp('docs/project-status.md'), 'utf-8') : ''
|
|
160
|
+
const planned = [...ps.matchAll(/\|\s*(M\d+)\s*\|\s*PLANNED\s*\|[^|]*\|\s*([^|]+?)\s*\|/g)].map((m) => `${m[1]}: ${m[2].trim()}`)
|
|
161
|
+
let accepted = 0, adrs = 0
|
|
162
|
+
const adrDir = rp('docs/adr')
|
|
163
|
+
if (existsSync(adrDir)) for (const f of readdirSync(adrDir)) if (/\.md$/.test(f) && !/template/i.test(f)) { adrs++; if (/Status[:*\s]+Accepted/i.test(readFileSync(join(adrDir, f), 'utf-8'))) accepted++ }
|
|
164
|
+
model.meta.docTargets = planned
|
|
165
|
+
model.meta.adr = { total: adrs, accepted }
|
|
166
|
+
model.meta.verifyMethod = 'from-docs (project-status milestones + ADR statuses)'
|
|
167
|
+
console.log(`[gen-c4] --from-docs: ${planned.length} planned milestone(s), ${accepted}/${adrs} ADR accepted`)
|
|
168
|
+
} catch (e) { model.meta.verifyError = 'from-docs: ' + String((e && e.message) || e) }
|
|
169
|
+
}
|
|
170
|
+
writeFileSync(OUT, JSON.stringify(model, null, 2) + '\n')
|
|
171
|
+
const counts = { total: nodes.length, leaves: nodes.filter((n) => n.kind === 'leaf').length, planned: nodes.filter((n) => n.status === 'planned').length }
|
|
172
|
+
console.log(`[gen-c4] wrote ${OUT}`)
|
|
173
|
+
console.log(`[gen-c4] nodes=${counts.total} leaves=${counts.leaves} planned=${counts.planned} edges=${model.edges.length} commit=${commit.slice(0,8)}`)
|
package/lib/init.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// forma init — seed a c4-topology.json from the repo's real source directories.
|
|
3
|
+
// BEST-EFFORT: gives a valid, non-empty starting point (no cold-start). The human/agent then
|
|
4
|
+
// curates groupings, context externals, and plain-language descriptions. Never overwrites an
|
|
5
|
+
// existing topology unless --force.
|
|
6
|
+
import { readdirSync, statSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
7
|
+
import { join, relative, basename } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
|
|
10
|
+
const REPO = arg('--repo', process.cwd())
|
|
11
|
+
const OUT = arg('--out', join(REPO, 'docs/architecture/c4-topology.json'))
|
|
12
|
+
const FORCE = process.argv.includes('--force')
|
|
13
|
+
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', 'out', 'vendor', 'coverage', '.next', '.gradle', 'bin', 'obj'])
|
|
14
|
+
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
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'x'
|
|
16
|
+
|
|
17
|
+
if (existsSync(OUT) && !FORCE) { console.error(`[forma init] ${relative(REPO, OUT)} already exists — use --force to reseed.`); process.exit(1) }
|
|
18
|
+
|
|
19
|
+
// 1) detect dominant language by extension count (skipping ignored dirs)
|
|
20
|
+
const counts = {}
|
|
21
|
+
;(function walk(dir, depth) {
|
|
22
|
+
if (depth > 12) return
|
|
23
|
+
let ents; try { ents = readdirSync(dir) } catch { return }
|
|
24
|
+
for (const e of ents) {
|
|
25
|
+
if (IGNORE.has(e) || e.startsWith('.')) continue
|
|
26
|
+
const p = join(dir, e)
|
|
27
|
+
let st; try { st = statSync(p) } catch { continue }
|
|
28
|
+
if (st.isDirectory()) walk(p, depth + 1)
|
|
29
|
+
else { const m = e.match(/\.([a-z0-9]+)$/i); if (m && EXT[m[1].toLowerCase()]) counts[m[1].toLowerCase()] = (counts[m[1].toLowerCase()] || 0) + 1 }
|
|
30
|
+
}
|
|
31
|
+
})(REPO, 0)
|
|
32
|
+
const ext = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]
|
|
33
|
+
if (!ext) { console.error('[forma init] no recognised source files found under ' + REPO); process.exit(1) }
|
|
34
|
+
const lang = EXT[ext[0]]
|
|
35
|
+
const matchRe = `\\.${ext[0]}$`
|
|
36
|
+
|
|
37
|
+
// 2) find the source root
|
|
38
|
+
function javaBase() {
|
|
39
|
+
let d = join(REPO, 'src/main/java')
|
|
40
|
+
if (!existsSync(d)) return null
|
|
41
|
+
// descend while there is exactly one subdir (the package chain) until it branches
|
|
42
|
+
for (;;) {
|
|
43
|
+
const subs = readdirSync(d).filter((x) => { try { return statSync(join(d, x)).isDirectory() } catch { return false } })
|
|
44
|
+
const files = readdirSync(d).some((x) => new RegExp(matchRe).test(x))
|
|
45
|
+
if (subs.length === 1 && !files) d = join(d, subs[0]); else break
|
|
46
|
+
}
|
|
47
|
+
return d
|
|
48
|
+
}
|
|
49
|
+
const root = ext[0] === 'java' ? (javaBase() || REPO) : (existsSync(join(REPO, 'src')) ? join(REPO, 'src') : REPO)
|
|
50
|
+
|
|
51
|
+
// 3) build nodes + leafSources: one container per immediate subdir that holds source files directly
|
|
52
|
+
const sysId = slug(basename(REPO))
|
|
53
|
+
const nodes = [{ id: sysId, level: 'context', kind: 'system', name: basename(REPO), tech: lang, description: `${basename(REPO)} — drill for containers.` }]
|
|
54
|
+
const leafSources = []
|
|
55
|
+
const hasSrc = (dir) => { try { return readdirSync(dir).some((f) => { const p = join(dir, f); return statSync(p).isFile() && new RegExp(matchRe).test(f) }) } catch { return false } }
|
|
56
|
+
|
|
57
|
+
// Recurse to the SHALLOWEST dirs that directly contain source files → each is a container.
|
|
58
|
+
// (A dir with direct sources is a container; a dir with none is descended into. Works for nested
|
|
59
|
+
// layouts like src/app/routes/*.ts or Python packages, not just one level.)
|
|
60
|
+
const seen = new Set()
|
|
61
|
+
;(function findContainers(dir, depth) {
|
|
62
|
+
if (depth > 10) return
|
|
63
|
+
let ents; try { ents = readdirSync(dir) } catch { return }
|
|
64
|
+
for (const e of ents.filter((x) => !IGNORE.has(x) && !x.startsWith('.')).sort()) {
|
|
65
|
+
const p = join(dir, e)
|
|
66
|
+
let st; try { st = statSync(p) } catch { continue }
|
|
67
|
+
if (!st.isDirectory()) continue
|
|
68
|
+
if (hasSrc(p)) {
|
|
69
|
+
const rel = relative(root, p) || e
|
|
70
|
+
let id = slug(rel.replace(/[\\/]+/g, '_'))
|
|
71
|
+
if (seen.has(id)) id = slug(id + '_' + leafSources.length)
|
|
72
|
+
seen.add(id)
|
|
73
|
+
nodes.push({ id, level: 'container', kind: 'container', parent: sysId, name: e, tech: lang })
|
|
74
|
+
leafSources.push({ parent: id, dir: relative(REPO, p), match: matchRe })
|
|
75
|
+
} else findContainers(p, depth + 1)
|
|
76
|
+
}
|
|
77
|
+
})(root, 0)
|
|
78
|
+
// loose files directly in root → an entry container
|
|
79
|
+
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
|
+
|
|
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) }
|
|
82
|
+
|
|
83
|
+
const topo = {
|
|
84
|
+
meta: { repo: basename(REPO), stack: lang, seededBy: 'forma init' },
|
|
85
|
+
docPath: 'docs/architecture/ARCHITECTURE.md',
|
|
86
|
+
levels: ['context', 'container', 'component', 'leaf'],
|
|
87
|
+
nodes,
|
|
88
|
+
leafSources,
|
|
89
|
+
edges: [],
|
|
90
|
+
descriptions: {},
|
|
91
|
+
}
|
|
92
|
+
// ensure output dir exists
|
|
93
|
+
mkdirSync(join(OUT, '..'), { recursive: true })
|
|
94
|
+
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) || '.'}/`)
|
|
96
|
+
console.log('[forma init] NEXT: curate names/descriptions + add context externals + run `forma gen`.')
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://github.com/LucaDominici/forma/blob/main/lib/schema/c4-model.schema.json",
|
|
4
|
+
"title": "C4 model \u2014 stack-agnostic architecture graph",
|
|
5
|
+
"description": "Reusable contract for the C4 live dashboard. A generator conforms iff its output validates; the viewer renders anything that validates. Stack-specific facts live only in tech/meta.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"schemaVersion",
|
|
9
|
+
"generatedAt",
|
|
10
|
+
"source",
|
|
11
|
+
"levels",
|
|
12
|
+
"nodes",
|
|
13
|
+
"edges"
|
|
14
|
+
],
|
|
15
|
+
"additionalProperties": false,
|
|
16
|
+
"properties": {
|
|
17
|
+
"schemaVersion": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
|
|
20
|
+
"description": "Semver. The viewer renders any JSON with the same MAJOR; MINOR/PATCH are additive-only."
|
|
21
|
+
},
|
|
22
|
+
"generatedAt": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"format": "date-time"
|
|
25
|
+
},
|
|
26
|
+
"source": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"required": [
|
|
29
|
+
"repo",
|
|
30
|
+
"commit",
|
|
31
|
+
"generator"
|
|
32
|
+
],
|
|
33
|
+
"additionalProperties": true,
|
|
34
|
+
"properties": {
|
|
35
|
+
"repo": {
|
|
36
|
+
"type": "string"
|
|
37
|
+
},
|
|
38
|
+
"commit": {
|
|
39
|
+
"type": "string"
|
|
40
|
+
},
|
|
41
|
+
"branch": {
|
|
42
|
+
"type": "string"
|
|
43
|
+
},
|
|
44
|
+
"docPath": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "The arc42 architecture doc this model derives from."
|
|
47
|
+
},
|
|
48
|
+
"generator": {
|
|
49
|
+
"type": "string"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"meta": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"description": "Repo-level escape hatch (archetype, stack, overlays, version). Never required by the viewer.",
|
|
56
|
+
"additionalProperties": true
|
|
57
|
+
},
|
|
58
|
+
"levels": {
|
|
59
|
+
"type": "array",
|
|
60
|
+
"minItems": 1,
|
|
61
|
+
"items": {
|
|
62
|
+
"type": "string"
|
|
63
|
+
},
|
|
64
|
+
"description": "Ordered C4 levels present (e.g. context, container, component, code)."
|
|
65
|
+
},
|
|
66
|
+
"nodes": {
|
|
67
|
+
"type": "array",
|
|
68
|
+
"minItems": 1,
|
|
69
|
+
"items": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"required": [
|
|
72
|
+
"id",
|
|
73
|
+
"level",
|
|
74
|
+
"name",
|
|
75
|
+
"kind"
|
|
76
|
+
],
|
|
77
|
+
"additionalProperties": false,
|
|
78
|
+
"properties": {
|
|
79
|
+
"id": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"description": "Stable, unique, natural-key-like."
|
|
82
|
+
},
|
|
83
|
+
"level": {
|
|
84
|
+
"type": "string"
|
|
85
|
+
},
|
|
86
|
+
"name": {
|
|
87
|
+
"type": "string"
|
|
88
|
+
},
|
|
89
|
+
"kind": {
|
|
90
|
+
"type": "string",
|
|
91
|
+
"enum": [
|
|
92
|
+
"person",
|
|
93
|
+
"system",
|
|
94
|
+
"external",
|
|
95
|
+
"container",
|
|
96
|
+
"component",
|
|
97
|
+
"store",
|
|
98
|
+
"boundary",
|
|
99
|
+
"leaf"
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
"parent": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"description": "Containment / boundary node id."
|
|
105
|
+
},
|
|
106
|
+
"drill": {
|
|
107
|
+
"type": "string",
|
|
108
|
+
"description": "Level id to open when drilling into this node."
|
|
109
|
+
},
|
|
110
|
+
"tech": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"description": "Free text; NOT enumerated."
|
|
113
|
+
},
|
|
114
|
+
"description": {
|
|
115
|
+
"type": "string"
|
|
116
|
+
},
|
|
117
|
+
"status": {
|
|
118
|
+
"type": "string",
|
|
119
|
+
"enum": [
|
|
120
|
+
"current",
|
|
121
|
+
"partial",
|
|
122
|
+
"planned",
|
|
123
|
+
"deprecated",
|
|
124
|
+
"unknown"
|
|
125
|
+
],
|
|
126
|
+
"default": "current"
|
|
127
|
+
},
|
|
128
|
+
"link": {
|
|
129
|
+
"type": "string",
|
|
130
|
+
"format": "uri"
|
|
131
|
+
},
|
|
132
|
+
"evidence": {
|
|
133
|
+
"type": "array",
|
|
134
|
+
"items": {
|
|
135
|
+
"type": "object",
|
|
136
|
+
"required": [
|
|
137
|
+
"type",
|
|
138
|
+
"ref"
|
|
139
|
+
],
|
|
140
|
+
"additionalProperties": false,
|
|
141
|
+
"properties": {
|
|
142
|
+
"type": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"description": "Open vocabulary: path|doc|adr|url|test|glob."
|
|
145
|
+
},
|
|
146
|
+
"ref": {
|
|
147
|
+
"type": "string"
|
|
148
|
+
},
|
|
149
|
+
"count": {
|
|
150
|
+
"type": "integer",
|
|
151
|
+
"description": "For glob evidence: number of files matched (drift anchor)."
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
"meta": {
|
|
157
|
+
"type": "object",
|
|
158
|
+
"additionalProperties": true
|
|
159
|
+
},
|
|
160
|
+
"category": {
|
|
161
|
+
"type": "string",
|
|
162
|
+
"description": "Sub-type tag shown top-left (surface/core/gen/out/gate/...)."
|
|
163
|
+
},
|
|
164
|
+
"status2": {
|
|
165
|
+
"type": "string",
|
|
166
|
+
"enum": [
|
|
167
|
+
"done",
|
|
168
|
+
"in-progress",
|
|
169
|
+
"next",
|
|
170
|
+
"planned",
|
|
171
|
+
"problem"
|
|
172
|
+
],
|
|
173
|
+
"description": "Hologram completion status (colour)."
|
|
174
|
+
},
|
|
175
|
+
"completion": {
|
|
176
|
+
"type": "integer",
|
|
177
|
+
"minimum": 0,
|
|
178
|
+
"maximum": 100
|
|
179
|
+
},
|
|
180
|
+
"statusWord": {
|
|
181
|
+
"type": "string",
|
|
182
|
+
"description": "Optional badge word overriding the %."
|
|
183
|
+
},
|
|
184
|
+
"current": {
|
|
185
|
+
"type": "string",
|
|
186
|
+
"description": "STATO ATTUALE (fatti) \u2014 what is true now."
|
|
187
|
+
},
|
|
188
|
+
"target": {
|
|
189
|
+
"type": "string",
|
|
190
|
+
"description": "STATO FINITO (progetto) \u2014 where it must land."
|
|
191
|
+
},
|
|
192
|
+
"issues": {
|
|
193
|
+
"type": "array",
|
|
194
|
+
"items": {
|
|
195
|
+
"type": "string"
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
"verify": {
|
|
199
|
+
"type": "object",
|
|
200
|
+
"additionalProperties": true,
|
|
201
|
+
"description": "Verification source (gh issue/PR + state + ts)."
|
|
202
|
+
},
|
|
203
|
+
"func": {
|
|
204
|
+
"type": "string",
|
|
205
|
+
"description": "Plain-language 'what it does' for non-developers."
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
"edges": {
|
|
211
|
+
"type": "array",
|
|
212
|
+
"items": {
|
|
213
|
+
"type": "object",
|
|
214
|
+
"required": [
|
|
215
|
+
"from",
|
|
216
|
+
"to"
|
|
217
|
+
],
|
|
218
|
+
"additionalProperties": false,
|
|
219
|
+
"properties": {
|
|
220
|
+
"from": {
|
|
221
|
+
"type": "string"
|
|
222
|
+
},
|
|
223
|
+
"to": {
|
|
224
|
+
"type": "string"
|
|
225
|
+
},
|
|
226
|
+
"label": {
|
|
227
|
+
"type": "string"
|
|
228
|
+
},
|
|
229
|
+
"level": {
|
|
230
|
+
"type": "string"
|
|
231
|
+
},
|
|
232
|
+
"kind": {
|
|
233
|
+
"type": "string",
|
|
234
|
+
"description": "import | runtime | ci \u2014 provenance of the relationship."
|
|
235
|
+
},
|
|
236
|
+
"status": {
|
|
237
|
+
"type": "string",
|
|
238
|
+
"enum": [
|
|
239
|
+
"current",
|
|
240
|
+
"planned",
|
|
241
|
+
"deprecated"
|
|
242
|
+
]
|
|
243
|
+
},
|
|
244
|
+
"evidence": {
|
|
245
|
+
"type": "array",
|
|
246
|
+
"items": {
|
|
247
|
+
"type": "object",
|
|
248
|
+
"required": [
|
|
249
|
+
"type",
|
|
250
|
+
"ref"
|
|
251
|
+
],
|
|
252
|
+
"properties": {
|
|
253
|
+
"type": {
|
|
254
|
+
"type": "string"
|
|
255
|
+
},
|
|
256
|
+
"ref": {
|
|
257
|
+
"type": "string"
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
"estatus": {
|
|
263
|
+
"type": "string",
|
|
264
|
+
"enum": [
|
|
265
|
+
"active",
|
|
266
|
+
"to-build"
|
|
267
|
+
],
|
|
268
|
+
"description": "active = animated flow; to-build = dashed."
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
package/lib/serve.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// forma serve — tiny static server for the interactive viewer + model. Local dev only.
|
|
3
|
+
import { createServer } from 'node:http'
|
|
4
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
5
|
+
import { join, extname, resolve, dirname, sep } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
|
|
9
|
+
const REPO = arg('--repo', process.cwd())
|
|
10
|
+
const PORT = parseInt(arg('--port', '4173'), 10)
|
|
11
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const dir = resolve(REPO, 'docs/architecture')
|
|
13
|
+
const viewerFallback = join(HERE, 'viewer', 'c4-hologram.html')
|
|
14
|
+
const TYPES = { '.html': 'text/html', '.json': 'application/json', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml' }
|
|
15
|
+
|
|
16
|
+
createServer((req, res) => {
|
|
17
|
+
let p = decodeURIComponent((req.url || '/').split('?')[0])
|
|
18
|
+
if (p === '/' || p === '') p = '/c4-viewer.html'
|
|
19
|
+
// resolve within dir and refuse anything that escapes it (no path traversal)
|
|
20
|
+
const file = resolve(dir, '.' + p)
|
|
21
|
+
if (file !== dir && !file.startsWith(dir + sep)) { res.writeHead(403); return res.end('forbidden') }
|
|
22
|
+
let target = file
|
|
23
|
+
if (p === '/c4-viewer.html' && !existsSync(target)) target = viewerFallback
|
|
24
|
+
if (!existsSync(target)) { res.writeHead(404); return res.end('not found: ' + p) }
|
|
25
|
+
res.writeHead(200, { 'Content-Type': TYPES[extname(target)] || 'application/octet-stream' })
|
|
26
|
+
res.end(readFileSync(target))
|
|
27
|
+
}).listen(PORT, () => console.log(`[forma] serving ${dir} → http://localhost:${PORT}/ (model: /c4-model.json)`))
|