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/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)`))