forma-arch 0.6.0 → 0.7.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 CHANGED
@@ -17,6 +17,11 @@ A hand-drawn architecture diagram is stale the moment code changes. Forma walks
17
17
  real structure, infers relationships from cross-references to exported symbol names (heuristic, additive), and **fails a check** when the model
18
18
  and the code disagree. What you present is what actually exists.
19
19
 
20
+ Where the language *declares* its architecture, forma reads the declaration instead of guessing. On
21
+ **Go** the container and the leaf are the **package** (any directory with a non-test `.go`, however
22
+ deeply nested), `_test.go` files are not architecture, and every edge comes from an `import` block —
23
+ so the direction is right by construction. Every other stack uses the heuristic above.
24
+
20
25
  ## Install
21
26
 
22
27
  ```sh
@@ -36,6 +41,17 @@ npx forma-arch <command> # or: npm i -D forma-arch
36
41
 
37
42
  **Box text comes from your docs.** `gen` fills each box with the module's docstring (Python `"""…"""`, JS/TS leading block), else the directory `README.md`, else a mapped arc42 section — so the explorer shows meaning, not a list of symbols. On a flat directory of many `foo_*` files it also synthesizes a **component** layer, described from its children's docs (`--no-cluster` to disable; `--cluster-min <n>` = leaves before a container is clustered, default 8; `--group-min <n>` = files sharing a prefix before they become a component, default 3).
38
43
 
44
+ **Above the leaf, your feature matrix outranks the code.** A docstring is the right answer for one file and the wrong one for a whole container: a stakeholder does not ask which docstring the first file inside it has, they ask what that part of the product does for the user — and in a governed repo that sentence is already written in a capability table. `forma init` finds those tables and lists them under `docSources`; `gen` joins each row to the nodes its code references name, and quotes the row **verbatim** (`descSource: "docmap"`). Nothing is composed or paraphrased. A node named by more than three rows is not *described* by the matrix but merely *touched* by it, so it yields nothing and the code chain runs instead.
45
+
46
+ ```json
47
+ "docSources": ["docs/FEATURE_MATRIX.md",
48
+ { "path": "docs/spec.md", "describe": "capability", "ref": "code_ref", "status": "status" }]
49
+ ```
50
+
51
+ Column roles are detected by header name (`capability`/`feature`/`description`…, `code_ref`/`path`/`module`…, `status`/`state`) — name them explicitly when yours differ. Auto-detection additionally requires a status column, because "feature + file" is also the shape of a refactor plan and a task line does not belong in a stakeholder's box; a source you list by hand is trusted as written.
52
+
53
+ **Progress can be generated, not only hand-written.** Where those rows carry a status, `gen` derives the node's `status2` and `completion` from them — one of two capabilities shipped is `in-progress` at 50%, and `verify.source` names the document and the tally. This is derived, so it is never trusted: `forma check` re-reads the document and **fails** if the committed model claims a number the document no longer supports, exactly as it re-walks `src/` for structure. A node no document names stays `unknown` — the honest blank, not a made-up zero.
54
+
39
55
  **Programme state is curated, not guessed.** Code shows what exists, never how far along it is. Drop a `docs/architecture/c4-status.json` (`--status <path>` to move it) and `gen` decorates nodes by id with `status2`, `completion`, `statusWord`, `current`, `target`, `verify`, `issues` — never `func`, which belongs to the docs. `gen` validates the *form* (ids resolve, fields known, enums and issue numbers well-shaped) and never the prose; `forma check` fails if the overlay decorates a node the model no longer has.
40
56
 
41
57
  ```json
@@ -45,6 +61,8 @@ npx forma-arch <command> # or: npm i -D forma-arch
45
61
  "verify": { "source": "ADR-040 on main" }, "issues": ["#534"] } } }
46
62
  ```
47
63
 
64
+ The overlay is the authority: every field it sets wins over anything derived, and `check` stops re-deriving that field. To describe one box by hand without a table, put the sentence in `descriptions`, keyed `"<containerId>/<node-name-without-extension>"` — `"core/alpha"` for the leaf `alpha.js` in container `core`, `"core/core"` for the container itself, since a container is its own container. It outranks every other source.
65
+
48
66
  **Curated state, verified against reality.** `forma verify` asks your `gh` CLI for the state of every issue the model references (`--gh-repo owner/repo`, or `meta.ghRepo` in the topology), marks the nodes whose issues are closed as done, and prefixes their `current` with dated evidence. It touches state, never structure, and re-running it never stacks the evidence. It is opt-in and separate on purpose: `gen` and `check` never open a socket. In the served viewer, **RE-VERIFY** re-reads the model without losing your level, layout or mode.
49
67
 
50
68
  **One source of truth.** `forma doc --attach docs/architecture/arc42.md` injects the generated diagrams/tables between `<!-- forma:begin -->` / `<!-- forma:end -->` markers in your existing doc; your prose lives outside them, and `forma check` fails if that block drifts. Attached files are recorded in `source.attachedDocs`, so the gate governs **every** doc you attach — not just the model's `docPath`; deleting the markers (or the file) from a registered doc fails the check rather than quietly un-governing it. That registry lives in `c4-model.json`, so **commit the model** — a lost model takes the registry with it. Where a repo lacks docs, `forma gen --enrich` can fill the remaining box holes with an LLM — opt-in, cached, never on the deterministic gate:
@@ -56,7 +74,8 @@ npx forma-arch <command> # or: npm i -D forma-arch
56
74
  | `openai` | Same, with `OPENAI_API_KEY`. | REST |
57
75
  | `ollama` | Sensitive repos: a local model, nothing leaves the machine. | localhost |
58
76
 
59
- `--enricher` has **no default**: `forma gen --enrich` on its own fails loud and lists the four. A
77
+ `--enricher` has **no default**: `forma gen --enrich` on its own fails loud and lists what it
78
+ accepts — the four above, plus `echo`, an offline stub the test suite uses and you should not. A
60
79
  default provider is a silent choice about your network and your API keys — and the old default
61
80
  (`anthropic`) meant that anyone without `ANTHROPIC_API_KEY` exported got a skip line, exit 0 and the
62
81
  same empty boxes they ran `--enrich` to fill.
@@ -73,6 +92,10 @@ One source of truth (`c4-model.json`); two renderings (the interactive viewer an
73
92
  one deterministic check that keeps them honest. The file contract is
74
93
  [`lib/schema/c4-model.schema.json`](lib/schema/c4-model.schema.json).
75
94
 
95
+ Working on forma itself? [`docs/ORIENTATION.md`](docs/ORIENTATION.md) walks what each pass of
96
+ `gen`/`check` actually decides, with `path:line`, and audits which mechanisms nothing in the repo
97
+ currently feeds.
98
+
76
99
  ## Model-agnostic by design
77
100
 
78
101
  The engine is plain Node — no LLM required. Structure is auto-walked, relationships are derived from
package/lib/check.mjs CHANGED
@@ -9,6 +9,8 @@ import { fileURLToPath } from 'node:url'
9
9
  import { containerOf } from './cluster.mjs'
10
10
  import { renderBlock, extractBetween, norm } from './render.mjs'
11
11
  import { descInputHash } from './enrich.mjs'
12
+ import { loadDocRows, indexByNode, statusFor } from './docmap.mjs'
13
+ import { validateModel } from './validate.mjs'
12
14
 
13
15
  const HERE = dirname(fileURLToPath(import.meta.url))
14
16
  const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
@@ -23,6 +25,7 @@ if (!existsSync(MODEL)) { console.error('[check-c4] FAIL: model missing (no SKIP
23
25
  if (!existsSync(TOPO)) { console.error('[check-c4] FAIL: topology missing: ' + TOPO); process.exit(1) }
24
26
  const model = JSON.parse(readFileSync(MODEL, 'utf-8'))
25
27
  const topo = JSON.parse(readFileSync(TOPO, 'utf-8'))
28
+ for (const err of validateModel(model)) errs.push('SCHEMA: ' + err)
26
29
  const byId = new Map((model.nodes || []).map((n) => [n.id, n]))
27
30
 
28
31
  // 1) schemaVersion present + basic shape
@@ -102,10 +105,28 @@ for (const docRel of governed) {
102
105
 
103
106
  // 7) the status overlay decorates nodes by id: an id that no longer resolves is drift (the node was
104
107
  // renamed or deleted and the curated state was left behind). Form only — never the prose itself.
108
+ let overlay = {}
105
109
  if (src.statusPath && existsSync(rp(src.statusPath))) {
106
110
  let ov = null
107
111
  try { ov = JSON.parse(readFileSync(rp(src.statusPath), 'utf-8')) } catch (e) { fail(`status overlay: ${src.statusPath} is not valid JSON: ${(e && e.message) || e}`) }
108
- if (ov) for (const id of Object.keys(ov.nodes || {})) if (!byId.has(id)) fail(`status overlay: "${id}" is not a node in the model — ${src.statusPath} is stale.`)
112
+ if (ov) {
113
+ overlay = ov.nodes || {}
114
+ for (const id of Object.keys(overlay)) if (!byId.has(id)) fail(`status overlay: "${id}" is not a node in the model — ${src.statusPath} is stale.`)
115
+ }
116
+ }
117
+
118
+ // 8) doc-derived programme state is RE-DERIVED, never trusted. `gen` reads the repo's capability
119
+ // tables and writes status2/completion from them; without this the document could flip a row to
120
+ // BACKLOG while the committed model keeps showing a green box at 100% and no gate would notice —
121
+ // the false green this command exists to kill. Same contract as assertion 2: recompute the truth.
122
+ // Skipped per field where the curated overlay owns it, since there the overlay is the authority.
123
+ const docIdx = indexByNode(loadDocRows(REPO, topo.docSources), model.nodes || [])
124
+ for (const n of model.nodes || []) {
125
+ const want = statusFor(docIdx, n.id)
126
+ if (!want) continue
127
+ const owned = overlay[n.id] || {}
128
+ if (owned.status2 === undefined && n.status2 !== want.status2) fail(`DOC DRIFT: ${n.id} status2 is "${n.status2}" but ${want.source} derives "${want.status2}". Regenerate c4-model.json.`)
129
+ if (owned.completion === undefined && n.completion !== want.completion) fail(`DOC DRIFT: ${n.id} completion is ${n.completion} but ${want.source} derives ${want.completion}. Regenerate c4-model.json.`)
109
130
  }
110
131
 
111
132
  // SOFT: LLM-enriched prose whose inputs changed is only a reminder, never a gate failure (structure
package/lib/describe.mjs CHANGED
@@ -2,15 +2,22 @@
2
2
  // describe.mjs — deterministic description resolver (§1a). Pure parsing, NO network.
3
3
  // Fills a node's plain-language `func` from EXISTING docs (curated → docstring → README → arc42),
4
4
  // so boxes show MEANING, not a bare filename. A generated string is only the last resort.
5
- import { readFileSync, existsSync } from 'node:fs'
5
+ import { readFileSync, existsSync, statSync } from 'node:fs'
6
6
  import { join, dirname } from 'node:path'
7
+ import { describingRows } from './docmap.mjs'
7
8
 
8
9
  const norm = (s) => String(s).toLowerCase().trim()
10
+ // A leaf's path evidence is usually a file — but a Go package leaf IS a directory, and its README
11
+ // sits inside it, not one level up (dirname() would hand it the parent's README instead).
12
+ const ownDir = (p) => { try { return statSync(p).isDirectory() ? p : dirname(p) } catch { return dirname(p) } }
9
13
 
10
14
  // First non-empty paragraph, markdown/whitespace stripped, capped to the viewer's ~2-line clamp.
15
+ // A leading YAML front-matter block is metadata, not prose: without this every governed repo whose
16
+ // READMEs open with `---\ntitle: …` put "--- title: 'haben — README' docversion: '2.1.0'" in a box.
11
17
  const firstPara = (text) => {
12
18
  let out = ''
13
- for (const line of String(text).replace(/\r\n/g, '\n').split('\n')) {
19
+ const body = String(text).replace(/\r\n/g, '\n').replace(/^---\n[\s\S]*?\n---\n/, '')
20
+ for (const line of body.split('\n')) {
14
21
  const l = line.trim()
15
22
  if (/^#{1,6}\s/.test(l)) continue // skip markdown headings
16
23
  if (!l) { if (out) break; else continue } // blank ends the paragraph (once started)
@@ -73,23 +80,36 @@ function measuredFunc(node, ctx) {
73
80
  return `${parts.join(' + ')}: ${names}${kids.length > 3 ? ', …' : '.'}`.slice(0, 240)
74
81
  }
75
82
 
76
- export function makeDescribeCtx({ repo, byId, descriptions, docPath, containerOf }) {
77
- return { repo, byId, D: descriptions || {}, containerOf, readmeCache: new Map(), arc42: buildArc42Index(repo, docPath) }
83
+ export function makeDescribeCtx({ repo, byId, descriptions, docPath, containerOf, docIndex }) {
84
+ return { repo, byId, D: descriptions || {}, containerOf, docIndex: docIndex || new Map(), readmeCache: new Map(), arc42: buildArc42Index(repo, docPath) }
85
+ }
86
+
87
+ // What the repo's own capability tables say about this node, quoted verbatim (§docmap).
88
+ const fromDocs = (node, ctx) => {
89
+ const rows = describingRows(ctx.docIndex, node.id)
90
+ if (!rows) return null
91
+ return { func: rows.map((r) => r.text).join(' · ').slice(0, 240), descSource: 'docmap' }
78
92
  }
79
93
 
80
94
  // First hit wins. descSource records provenance for §7 enrichment + debugging.
95
+ //
96
+ // The chain is DOCUMENT-first above the leaf and CODE-first at the leaf, and that asymmetry is the
97
+ // point: a stakeholder looking at a container asks what that part of the product does for the user
98
+ // — a question the feature matrix answers and the docstring of the first file inside it does not.
99
+ // One level down, at a single file, the docstring IS the better answer.
81
100
  export function resolveDescription(node, ctx) {
82
101
  const cont = ctx.containerOf(node, ctx.byId)
83
102
  const stem = String(node.name).replace(/\.\w+$/, '').replace(/ .*/, '')
84
103
  const key = cont ? `${cont}/${stem}` : null
85
104
  if (key && ctx.D[key]) return { func: ctx.D[key], descSource: 'curated' }
86
105
  if (node.description) return { func: node.description, descSource: 'curated' }
106
+ if (node.kind !== 'leaf') { const d = fromDocs(node, ctx); if (d) return d }
87
107
  const pth = (node.evidence || []).find((e) => e.type === 'path')
88
108
  if (pth) {
89
109
  const abs = join(ctx.repo, pth.ref)
90
110
  const ds = moduleDocstring(abs)
91
111
  if (ds) return { func: ds, descSource: 'docstring' }
92
- const rd = readmeFirstPara(dirname(abs), ctx.readmeCache)
112
+ const rd = readmeFirstPara(ownDir(abs), ctx.readmeCache)
93
113
  if (rd) return { func: rd, descSource: 'readme' }
94
114
  }
95
115
  // A container's evidence is a GLOB over its own directory, never a `path` — which is why the
@@ -99,6 +119,8 @@ export function resolveDescription(node, ctx) {
99
119
  const rd = readmeFirstPara(join(ctx.repo, glb.ref), ctx.readmeCache)
100
120
  if (rd) return { func: rd, descSource: 'readme' }
101
121
  }
122
+ const d = fromDocs(node, ctx) // leaves reach it here: after their own code, before the fallback
123
+ if (d) return d
102
124
  const a = ctx.arc42.get(norm(node.name)) || ctx.arc42.get(norm(node.id))
103
125
  if (a) return { func: a, descSource: 'arc42' }
104
126
  if (node.kind === 'leaf') {
package/lib/docmap.mjs ADDED
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // docmap.mjs — the DOCUMENTARY source of the description chain (§1a), and the only deterministic
3
+ // producer of programme state. Pure parsing, NO network, NO invention.
4
+ //
5
+ // A governed repo already writes, in prose, what each part of it does for the user and whether it
6
+ // is finished: a feature matrix, a capability table, a requirements sheet. Those rows carry code
7
+ // references. This module joins those rows to model nodes by path, so a CONTAINER's box can read
8
+ // "Account domain: bank/broker kinds, free-cash, IBAN, soft-archive" (a sentence a human wrote in a
9
+ // repo document) instead of a docstring belonging to the first file inside it.
10
+ //
11
+ // Every sentence it yields is quoted from a document under `repo`; provenance is `descSource:
12
+ // 'docmap'` plus `verify.source` naming the file. Nothing here composes, paraphrases or infers text.
13
+ import { readFileSync, existsSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+
16
+ // A node matching MORE rows than this is not DESCRIBED by the matrix — it is merely TOUCHED by
17
+ // many features (on haben, `internal/store` is referenced by 12 rows and `internal/server` by 20).
18
+ // Stitching those first sentences together would invent a claim no document makes, so past the cap
19
+ // the node yields nothing and the rest of the chain runs. Same constant, same reason as the
20
+ // component composition in gen.mjs: three sentences is the most a box can carry honestly.
21
+ export const MAX_ROWS = 3
22
+
23
+ // Column roles, by header name. Overridable per source; these defaults are conventions
24
+ // (`capability`, `code_ref`, `status`), never one project's identifiers.
25
+ const ROLES = {
26
+ describe: /^(capabilit(y|ies)|feature|description|what|summary|purpose)$/i,
27
+ ref: /^(code_?refs?|code|paths?|refs?|sources?|modules?|files?|impl(ementation)?)$/i,
28
+ status: /^(status|state|progress)$/i,
29
+ }
30
+ const DONE = /^(done|shipped|complete[d]?|ready|✅)\b/i
31
+ // `./x` and `x/` are the same key as `x`; a bare `x` and `x/**` too.
32
+ const normRef = (s) => String(s).trim().replace(/^\.\//, '').replace(/\/?\*+.*$/, '').replace(/\/+$/, '')
33
+ // A ref is a path, not prose: `internal/account` or `deps.go`, never "2 kinds (bank/broker)".
34
+ const looksLikePath = (s) => /^[\w.@~-]+([/\\][\w.@~ -]+)*$/.test(s) && (s.includes('/') || /\.\w+$/.test(s))
35
+
36
+ const cells = (line) => line.split('|').slice(1, -1).map((c) => c.trim())
37
+ const isRule = (line) => /^\|[\s:|-]+\|$/.test(line.trim())
38
+
39
+ // Every pipe table in the text, as [header, ...rows] of equal width. A malformed row is dropped,
40
+ // not repaired: a half-parsed row would put arbitrary text in a stakeholder's box.
41
+ function tables(text) {
42
+ const out = []
43
+ let cur = null
44
+ for (const raw of String(text).replace(/\r\n/g, '\n').split('\n')) {
45
+ const line = raw.trim()
46
+ if (line.startsWith('|') && line.endsWith('|') && line.length > 2) {
47
+ if (isRule(line)) continue
48
+ const row = cells(line)
49
+ if (!cur) cur = { header: row, rows: [] }
50
+ else if (row.length === cur.header.length) cur.rows.push(row)
51
+ } else if (cur) { if (cur.rows.length) out.push(cur); cur = null }
52
+ }
53
+ if (cur && cur.rows.length) out.push(cur)
54
+ return out
55
+ }
56
+
57
+ const roleIndex = (header, role, override) => {
58
+ if (override) return header.findIndex((h) => h.toLowerCase() === String(override).toLowerCase())
59
+ return header.findIndex((h) => ROLES[role].test(h))
60
+ }
61
+
62
+ // Path tokens in a cell: backticked first (the markdown convention), else comma-separated.
63
+ function refsIn(cell) {
64
+ const ticked = [...String(cell).matchAll(/`([^`]+)`/g)].map((m) => normRef(m[1]))
65
+ const raw = ticked.length ? ticked : String(cell).split(',').map(normRef)
66
+ return [...new Set(raw.filter(looksLikePath))]
67
+ }
68
+
69
+ /**
70
+ * Read every `docSources` entry into flat rows.
71
+ * @param inventoryOnly require a status column too. `forma init` sets it when AUTO-DETECTING a
72
+ * source: "feature + file" is also the shape of a change plan ("C1 translator import |
73
+ * decision/ChildTimeline.tsx"), and auto-adopting one of those would put a task line in a
74
+ * stakeholder's box — inventing, which is the one thing this must never do. An inventory says
75
+ * whether each capability is finished; a work plan does not. A source listed BY HAND is trusted
76
+ * as-is, status column or not.
77
+ * @returns {{text:string, refs:string[], done:boolean|null, from:string}[]}
78
+ */
79
+ export function loadDocRows(repo, docSources, inventoryOnly = false) {
80
+ const out = []
81
+ for (const entry of docSources || []) {
82
+ // Normalize FIRST: a bare-string source is not an options bag, and probing one for `.ref` or
83
+ // `.match` reaches String.prototype instead of undefined — a truthy "override" that silently
84
+ // matched no column and made every capability table parse to zero rows.
85
+ const src = typeof entry === 'string' ? { path: entry } : (entry || {})
86
+ const rel = src.path
87
+ if (!rel) continue
88
+ const abs = join(repo, rel)
89
+ if (!existsSync(abs)) continue
90
+ let text
91
+ try { text = readFileSync(abs, 'utf-8') } catch { continue }
92
+ for (const t of tables(text)) {
93
+ const iD = roleIndex(t.header, 'describe', src.describe)
94
+ const iM = roleIndex(t.header, 'ref', src.ref)
95
+ if (iD < 0 || iM < 0) continue // not a capability table — a changelog or a config table
96
+ const iS = roleIndex(t.header, 'status', src.status)
97
+ if (inventoryOnly && iS < 0) continue // see loadDocRows' third argument
98
+ for (const r of t.rows) {
99
+ const refs = refsIn(r[iM])
100
+ const txt = String(r[iD]).replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim()
101
+ if (!refs.length || !txt) continue
102
+ out.push({ text: txt.slice(0, 240), refs, done: iS < 0 ? null : DONE.test(String(r[iS]).replace(/[*_`]+/g, '').trim()), from: rel })
103
+ }
104
+ }
105
+ }
106
+ return out
107
+ }
108
+
109
+ // A row's ref points AT a node when it names the node's own file/dir, something inside it, or the
110
+ // directory that contains it. `internal/store/account_repo.go` therefore reaches the `internal/store`
111
+ // package and NOT `internal/account` — the join has to be specific or every row lands on every
112
+ // ancestor.
113
+ const touches = (ref, path) => ref === path || ref.startsWith(path + '/') || path.startsWith(ref + '/')
114
+
115
+ /**
116
+ * Map every node id to the rows that name it, itself or through its descendants (a Go container's
117
+ * own evidence is a glob over its PARENT directory, so only its leaves carry the real path).
118
+ * @returns {Map<string, object[]>}
119
+ */
120
+ export function indexByNode(rows, nodes) {
121
+ const byId = new Map(nodes.map((n) => [n.id, n]))
122
+ const kids = new Map()
123
+ for (const n of nodes) if (n.parent) kids.set(n.parent, [...(kids.get(n.parent) || []), n.id])
124
+ const ownPaths = (n) => (n.evidence || []).filter((e) => e.type === 'path').map((e) => normRef(e.ref))
125
+ const subtree = (id, seen = new Set()) => {
126
+ if (seen.has(id)) return [] // a malformed parent cycle must not hang gen
127
+ seen.add(id)
128
+ const out = [...ownPaths(byId.get(id) || {})]
129
+ for (const k of kids.get(id) || []) out.push(...subtree(k, seen))
130
+ return out
131
+ }
132
+ const idx = new Map()
133
+ for (const n of nodes) {
134
+ const paths = subtree(n.id)
135
+ if (!paths.length) continue
136
+ const hit = rows.filter((r) => r.refs.some((ref) => paths.some((p) => touches(ref, p))))
137
+ if (hit.length) idx.set(n.id, hit)
138
+ }
139
+ return idx
140
+ }
141
+
142
+ // The rows that DESCRIBE a node (past the cap it is only touched by them — see MAX_ROWS).
143
+ export const describingRows = (idx, id) => {
144
+ const rows = idx.get(id)
145
+ return rows && rows.length <= MAX_ROWS ? rows : null
146
+ }
147
+
148
+ // Programme state a document states outright: how many of the capabilities living in this node are
149
+ // finished. Derived, never curated — the c4-status.json overlay still overrides it (gen.mjs §WP-A1),
150
+ // and `check` re-derives it from the same document rather than trusting the committed model.
151
+ export function statusFor(idx, id) {
152
+ const rows = describingRows(idx, id)
153
+ if (!rows || rows.some((r) => r.done == null)) return null // no status column: describe only
154
+ const done = rows.filter((r) => r.done).length
155
+ return {
156
+ status2: done === rows.length ? 'done' : done === 0 ? 'planned' : 'in-progress',
157
+ completion: Math.round((done / rows.length) * 100),
158
+ source: `${rows[0].from} (${done}/${rows.length} done)`,
159
+ }
160
+ }
package/lib/gen.mjs CHANGED
@@ -7,15 +7,18 @@ import { join, dirname, basename, relative } from 'node:path'
7
7
  import { execSync } from 'node:child_process'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { containerOf, componentsFor } from './cluster.mjs'
10
+ import { isGo, goEdges } from './lang.mjs'
10
11
  import { makeDescribeCtx, resolveDescription } from './describe.mjs'
12
+ import { loadDocRows, indexByNode, describingRows, statusFor } from './docmap.mjs'
11
13
  import { loadCache, mergeCache, enrich, agentPlan, applyFills } from './enrich.mjs'
14
+ import { validateModel } from './validate.mjs'
12
15
 
13
16
  const HERE = dirname(fileURLToPath(import.meta.url))
14
17
  const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
15
18
  const REPO = arg('--repo', process.cwd())
16
19
  const TOPO = arg('--topology', join(REPO, 'docs/architecture/c4-topology.json'))
17
20
  const OUT = arg('--out', join(REPO, 'docs/architecture/c4-model.json'))
18
- const SCHEMA_VERSION = '1.4.0' // +status2:"unknown" (additive enum value → MINOR)
21
+ const SCHEMA_VERSION = '1.5.0' // +descSource:"docmap" (additive enum value → MINOR)
19
22
  const CLUSTER = !process.argv.includes('--no-cluster') // §2: auto-cluster flat containers; --no-cluster to disable
20
23
  const ENRICH = process.argv.includes('--enrich') // §7: opt-in LLM prose for description holes
21
24
  const ENRICHER = arg('--enricher', null)
@@ -75,7 +78,10 @@ for (const spec of topo.leafSources) {
75
78
  id: `${spec.parent}__${f.replace(/[^a-z0-9]+/gi, '_')}`,
76
79
  level: 'leaf', parent: spec.parent, kind: 'leaf',
77
80
  name: f.replace(/\.[a-z0-9]+$/i, ''), status: 'current',
78
- evidence: [{ type: 'path', ref: `${spec.dir}/${f}` }],
81
+ // `dir: "."` (loose top-level sources, or a Go package at the module root) must not produce
82
+ // "./x": the ref is a repo-relative key, and the oracle caught the Go adapter failing to map
83
+ // "./migrations" back to the import path <module>/migrations.
84
+ evidence: [{ type: 'path', ref: spec.dir === '.' ? f : `${spec.dir}/${f}` }],
79
85
  })
80
86
  }
81
87
  // attach glob count to the parent (drift anchor)
@@ -120,10 +126,21 @@ if (CLUSTER) for (const cid of [...new Set(topo.leafSources.map((s) => s.parent)
120
126
  console.log(`[gen-c4] container ${cid}: clustered ${leaves.length} leaves into ${components.length} component(s)`)
121
127
  }
122
128
 
129
+ // §docmap) join the repo's own capability tables to the nodes, ONCE: the same match feeds both the
130
+ // description (§1a below) and the programme state derived just under here.
131
+ const docRows = loadDocRows(REPO, topo.docSources)
132
+ const docIndex = indexByNode(docRows, nodes)
133
+ if (docRows.length) console.log(`[gen-c4] docSources: ${docRows.length} row(s) → ${docIndex.size} node(s) touched, ${nodes.filter((n) => describingRows(docIndex, n.id)).length} described`)
134
+
123
135
  // enrich: fill hologram defaults (category, 6-status, completion, current/target) where absent
124
136
  for (const n of nodes) {
125
137
  const par = n.parent ? byId.get(n.parent) : null
126
138
  if (!n.category) n.category = n.kind === 'leaf' ? (par && par.category) || 'leaf' : n.kind
139
+ // Progress a DOCUMENT states outright — the only generated alternative to hand-writing the
140
+ // overlay. Derived, so it is re-derived by `check` rather than trusted; the curated overlay
141
+ // (§WP-A1, applied further down) still overrides every field of it.
142
+ const ds = !n.status2 ? statusFor(docIndex, n.id) : null
143
+ if (ds) { n.status2 = ds.status2; if (n.completion == null) n.completion = ds.completion; if (!n.verify) n.verify = { source: ds.source } }
127
144
  // Code can prove a file EXISTS; it cannot prove the work behind it is finished. Marking every
128
145
  // undecorated node done/100 turned a virgin repo into a board reading "10/10 complete" — the
129
146
  // exact false green this tool exists to kill. No overlay (§WP-A1), no verdict: `unknown`, and
@@ -138,7 +155,7 @@ for (const n of nodes) {
138
155
 
139
156
  // §1a) func: plain-language "what it does" resolved from existing docs (curated → docstring → README
140
157
  // → arc42 → generated fallback), with provenance in descSource. No LLM here — pure parsing.
141
- const dctx = makeDescribeCtx({ repo: REPO, byId, descriptions: topo.descriptions || {}, docPath: topo.docPath, containerOf })
158
+ const dctx = makeDescribeCtx({ repo: REPO, byId, descriptions: topo.descriptions || {}, docPath: topo.docPath, containerOf, docIndex })
142
159
  for (const n of nodes) { const r = resolveDescription(n, dctx); n.func = r.func; n.descSource = r.descSource }
143
160
 
144
161
  // §1a-bis) a synthesized component has no doc of its own: before settling for "Groups related
@@ -148,7 +165,7 @@ for (const n of nodes) { const r = resolveDescription(n, dctx); n.func = r.func;
148
165
  const firstSentence = (s) => (String(s).match(/^[^.!?]*[.!?]/) || [String(s)])[0].trim()
149
166
  for (const n of nodes) {
150
167
  if (n.kind !== 'component' || n.descSource !== 'fallback') continue
151
- const kids = nodes.filter((k) => k.parent === n.id && ['curated', 'docstring', 'readme'].includes(k.descSource))
168
+ const kids = nodes.filter((k) => k.parent === n.id && ['curated', 'docmap', 'docstring', 'readme'].includes(k.descSource))
152
169
  .sort((a, b) => (String(a.name) < String(b.name) ? -1 : String(a.name) > String(b.name) ? 1 : 0)).slice(0, 3)
153
170
  const txt = kids.map((k) => firstSentence(k.func)).filter(Boolean).join(' ')
154
171
  if (txt) n.func = txt.length > 200 ? txt.slice(0, 199) + '…' : txt
@@ -218,7 +235,13 @@ if (ENRICH && ENRICHER === 'agent') {
218
235
  // code itself: for each container, count how many of ANOTHER container's exposed leaf names (class/
219
236
  // module names) appear as whole-word references in this container's files. count>0 ⇒ a real edge.
220
237
  // Language-agnostic (matches symbol names, not import syntax). Additive: never removes curated edges.
221
- if (!process.argv.includes('--no-auto-edges')) {
238
+ // A language that DECLARES its dependencies gets its adapter instead (lib/lang.mjs): guessing from
239
+ // names where an `import` block states the fact is strictly worse, and gets the direction wrong.
240
+ if (!process.argv.includes('--no-auto-edges') && isGo(topo.meta && topo.meta.stack)) {
241
+ const derived = goEdges({ repo: REPO, nodes, byId, containerOf, edges: topo.edges })
242
+ topo.edges = [...(topo.edges || []), ...derived]
243
+ console.log(`[gen-c4] auto-edges: +${derived.length} container edge(s) derived from Go import blocks`)
244
+ } else if (!process.argv.includes('--no-auto-edges')) {
222
245
  const STOP = new Set(['index', 'main', 'app', 'utils', 'util', 'types', 'model', 'base', 'core', 'const', 'style', 'theme'])
223
246
  const srcs = (topo.leafSources || []).filter((s) => byId.has(s.parent))
224
247
  const exposes = new Map(), text = new Map()
@@ -297,6 +320,10 @@ if (process.argv.includes('--from-docs')) {
297
320
  } catch (e) { model.meta.verifyError = 'from-docs: ' + String((e && e.message) || e) }
298
321
  }
299
322
  writeFileSync(OUT, JSON.stringify(model, null, 2) + '\n')
323
+ // topo.nodes are copied verbatim into the model (pass 1), so a bad curated kind used to leak in
324
+ // and only fail later — this catches it immediately and keeps invalid artifacts from being written.
325
+ const schemaErrors = validateModel(model)
326
+ if (schemaErrors.length) fail('model does not validate against lib/schema/c4-model.schema.json:\n - ' + schemaErrors.join('\n - '))
300
327
  const counts = { total: nodes.length, leaves: nodes.filter((n) => n.kind === 'leaf').length, planned: nodes.filter((n) => n.status === 'planned').length }
301
328
  console.log(`[gen-c4] wrote ${OUT}`)
302
329
  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 CHANGED
@@ -5,6 +5,8 @@
5
5
  // existing topology unless --force.
6
6
  import { readdirSync, statSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
7
7
  import { join, relative, basename } from 'node:path'
8
+ import { goPackages } from './lang.mjs'
9
+ import { loadDocRows } from './docmap.mjs'
8
10
 
9
11
  const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
10
12
  const REPO = arg('--repo', process.cwd())
@@ -55,20 +57,24 @@ function javaBase() {
55
57
  }
56
58
  return d
57
59
  }
58
- const root = ext[0] === 'java' ? (javaBase() || REPO) : (existsSync(join(REPO, 'src')) ? join(REPO, 'src') : REPO)
60
+ // Go modules are rooted at go.mod, never at src/ an import path is relative to the module root.
61
+ const root = ext[0] === 'java' ? (javaBase() || REPO) : ext[0] === 'go' ? REPO : (existsSync(join(REPO, 'src')) ? join(REPO, 'src') : REPO)
62
+ const GO = ext[0] === 'go'
63
+ const notSrc = GO ? /_test\.go$/ : null // a test file is not architecture (§Go adapter)
59
64
 
60
65
  // 3) build nodes + leafSources: one container per immediate subdir that holds source files directly
61
66
  const sysId = slug(basename(REPO))
62
67
  const nodes = [{ id: sysId, level: 'context', kind: 'system', name: basename(REPO), tech: lang, description: `${basename(REPO)} — drill for containers.` }]
63
68
  const leafSources = []
64
- 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 } }
69
+ const hasSrc = (dir) => { try { return readdirSync(dir).some((f) => { const p = join(dir, f); return statSync(p).isFile() && new RegExp(matchRe).test(f) && !(notSrc && notSrc.test(f)) }) } catch { return false } }
65
70
 
66
71
  // Recurse to the SHALLOWEST dirs that directly contain source files → each is a container.
67
72
  // (A dir with direct sources is a container; a dir with none is descended into. Works for nested
68
73
  // layouts like src/app/routes/*.ts or Python packages, not just one level.)
69
74
  const seen = new Set()
70
75
  const skipped = []
71
- ;(function findContainers(dir, depth) {
76
+ const claim = (rel) => { let id = slug(rel.replace(/[\\/]+/g, '_')); if (seen.has(id)) id = slug(id + '_' + leafSources.length); seen.add(id); return id }
77
+ const findContainers = (dir, depth) => {
72
78
  if (depth > 10) return
73
79
  let ents; try { ents = readdirSync(dir) } catch { return }
74
80
  for (const e of ents.filter((x) => !IGNORE.has(x) && !x.startsWith('.')).sort()) {
@@ -77,20 +83,55 @@ const skipped = []
77
83
  if (!st.isDirectory()) continue
78
84
  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 }
79
85
  if (hasSrc(p)) {
80
- const rel = relative(root, p) || e
81
- let id = slug(rel.replace(/[\\/]+/g, '_'))
82
- if (seen.has(id)) id = slug(id + '_' + leafSources.length)
83
- seen.add(id)
86
+ const id = claim(relative(root, p) || e)
84
87
  nodes.push({ id, level: 'container', kind: 'container', parent: sysId, name: e, tech: lang })
85
88
  leafSources.push({ parent: id, dir: relative(REPO, p), match: matchRe })
86
89
  } else findContainers(p, depth + 1)
87
90
  }
88
- })(root, 0)
91
+ }
92
+ const skipDir = (e) => IGNORE.has(e) || e.startsWith('.') || (SKIP_DIRS.has(e.toLowerCase()) && !KEEP.has(e.toLowerCase()))
93
+
94
+ // Go: the container is the PACKAGE, not the shallowest directory that happens to hold code. Go
95
+ // nests packages freely (internal/store, internal/server, …) and `internal/` itself is usually not
96
+ // one — stopping at the first level collapses thirty units of architecture into a single box. The
97
+ // leaf is the package too: the files inside it are internal organisation nobody presents, so the
98
+ // leafSource points at the parent directory and matches the package directory by name (a leaf must
99
+ // stay a real, re-derivable entry on disk — that is what the drift gate re-counts).
100
+ if (GO) {
101
+ for (const rel of goPackages(root, skipDir)) {
102
+ // ponytail: a package at the module ROOT falls through to the loose-files branch below, which
103
+ // keeps the old file-per-leaf shape (there is no parent directory to match its name in). Its
104
+ // box is real but stays edge-less, since goEdges only follows directory leaves. Rare layout —
105
+ // give the root package its own directory (cmd/…) and it behaves like every other one.
106
+ if (!rel) continue
107
+ const cut = rel.lastIndexOf('/')
108
+ const id = claim(rel)
109
+ nodes.push({ id, level: 'container', kind: 'container', parent: sysId, name: rel, tech: lang })
110
+ leafSources.push({ parent: id, dir: cut < 0 ? '.' : rel.slice(0, cut), match: '^' + rel.slice(cut + 1).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$', filesOnly: false })
111
+ }
112
+ } else findContainers(root, 0)
89
113
  // loose files directly in root → an entry container
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 }) }
114
+ 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, ...(GO ? { exclude: '_test\\.go$' } : {}) }) }
91
115
 
92
116
  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) }
93
117
 
118
+ // 4) find the documents that already say, in prose, what each part of the product does and whether
119
+ // it is finished — a feature matrix, a capability table, a requirements sheet. Detection is the
120
+ // reader itself: a doc qualifies when `loadDocRows` gets capability rows out of it, so nothing here
121
+ // can drift from what `gen` will actually parse, and no project's filenames are hardcoded.
122
+ // Threshold 3: a two-row table in a runbook is a config note, not a capability inventory.
123
+ const docSources = []
124
+ ;(function findDocs(dir, depth) {
125
+ if (depth > 4) return
126
+ let ents; try { ents = readdirSync(dir) } catch { return }
127
+ for (const e of ents.filter((x) => !IGNORE.has(x) && !x.startsWith('.')).sort()) {
128
+ const p = join(dir, e)
129
+ let st; try { st = statSync(p) } catch { continue }
130
+ if (st.isDirectory()) findDocs(p, depth + 1)
131
+ else if (/\.md$/i.test(e) && loadDocRows(REPO, [relative(REPO, p)], true).length >= 3) docSources.push(relative(REPO, p))
132
+ }
133
+ })(join(REPO, 'docs'), 0)
134
+
94
135
  const topo = {
95
136
  meta: { repo: basename(REPO), stack: lang, seededBy: 'forma init' },
96
137
  docPath: 'docs/architecture/ARCHITECTURE.md',
@@ -98,6 +139,9 @@ const topo = {
98
139
  nodes,
99
140
  leafSources,
100
141
  edges: [],
142
+ // Capability tables: the box text and the progress for context/container come from HERE first,
143
+ // ahead of the code. Each entry is a path, or {path, describe, ref, status} to name the columns.
144
+ docSources,
101
145
  descriptions: {},
102
146
  _skipped: skipped,
103
147
  }
@@ -105,4 +149,5 @@ const topo = {
105
149
  mkdirSync(join(OUT, '..'), { recursive: true })
106
150
  writeFileSync(OUT, JSON.stringify(topo, null, 2) + '\n')
107
151
  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` : ''}`)
152
+ if (docSources.length) console.log(`[forma init] docSources: ${docSources.join(', ')} — capability rows will describe the containers they name.`)
108
153
  console.log('[forma init] NEXT: curate names/descriptions + add context externals + run `forma gen`.')
package/lib/lang.mjs ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ // lang.mjs — per-language adapters for TOPOLOGY and EDGES. Pure, no side effects at import.
3
+ //
4
+ // The name-matching heuristic in gen.mjs is the fallback for stacks that never say what depends on
5
+ // what. Some languages do say it, and guessing where a declaration exists is strictly worse. Go is
6
+ // the first case: its unit of architecture is the PACKAGE (any directory holding non-test *.go),
7
+ // and its dependency edge is the `import` block — both machine-readable, both unambiguous.
8
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ // `forma init` writes meta.stack = 'Go'; a hand-curated topology opts in the same way.
12
+ export const isGo = (stack) => /^go\b/i.test(String(stack || ''))
13
+
14
+ // ponytail: name-based, no build-constraint evaluation — a file carrying `//go:build ignore` (or a
15
+ // tag not in the default build) still counts, so forma can see a package `go list` does not. The
16
+ // import it declares is real source either way; upgrade to reading the constraint line if it bites.
17
+ const isGoSrc = (f) => /\.go$/.test(f) && !/_test\.go$/.test(f)
18
+ const posix = (p) => String(p).replace(/\\/g, '/')
19
+
20
+ // Every directory under `root` holding at least one NON-TEST .go file is a package. Recurses past
21
+ // packages (Go nests them freely) and returns repo-relative posix dirs, sorted, '' for the module
22
+ // root. `skip(name)` filters directories (build junk, vendor/, data dirs) — the caller owns that
23
+ // policy so init keeps one source of truth for it.
24
+ export function goPackages(root, skip) {
25
+ const out = []
26
+ ;(function walk(dir, rel, depth) {
27
+ if (depth > 12) return
28
+ let ents; try { ents = readdirSync(dir).sort() } catch { return }
29
+ let src = false
30
+ const subs = []
31
+ for (const e of ents) {
32
+ let st; try { st = statSync(join(dir, e)) } catch { continue }
33
+ if (st.isDirectory()) { if (!skip(e)) subs.push(e) } else if (isGoSrc(e)) src = true
34
+ }
35
+ if (src) out.push(rel)
36
+ for (const s of subs) walk(join(dir, s), rel ? rel + '/' + s : s, depth + 1)
37
+ })(root, '', 0)
38
+ return out
39
+ }
40
+
41
+ // The import paths a package declares, one entry per (file, path) pair so the count is "how many
42
+ // files import this". Handles `import "x"`, grouped `import ( … )`, aliases (`foo "x"`, `. "x"`,
43
+ // `_ "x"`) and trailing comments. Test files are excluded, so are their imports.
44
+ // ponytail: regex over gofmt'd source, no AST — an unformatted `import(` block on one line is missed.
45
+ export function goImports(absDir) {
46
+ const out = []
47
+ let files; try { files = readdirSync(absDir).filter(isGoSrc).sort() } catch { return out }
48
+ for (const f of files) {
49
+ let src; try { src = readFileSync(join(absDir, f), 'utf-8') } catch { continue }
50
+ const here = new Set()
51
+ for (const m of src.matchAll(/^import\s*\(([\s\S]*?)^\)/gm)) {
52
+ for (const line of m[1].split('\n')) {
53
+ const q = line.replace(/\/\/.*$/, '').trim().match(/"([^"]+)"$/)
54
+ if (q) here.add(q[1])
55
+ }
56
+ }
57
+ for (const m of src.matchAll(/^import\s+(?:[\w.]+\s+)?"([^"]+)"/gm)) here.add(m[1])
58
+ out.push(...[...here].sort())
59
+ }
60
+ return out
61
+ }
62
+
63
+ const goModulePath = (repo) => {
64
+ try { return (readFileSync(join(repo, 'go.mod'), 'utf-8').match(/^module\s+(\S+)/m) || [])[1] || null } catch { return null }
65
+ }
66
+
67
+ // Container→container edges from the import blocks. A Go package leaf carries its own DIRECTORY as
68
+ // path evidence, so the model itself says which directory each container is — no id convention to
69
+ // keep in sync. Only intra-module imports become edges (the stdlib and third parties are outside
70
+ // the model). Direction is right by construction: from = the importer.
71
+ // Deliberately NOT the bidirectional dedup the heuristic pass uses — a declared import is a fact,
72
+ // and a curated edge pointing the other way must not suppress it.
73
+ export function goEdges({ repo, nodes, byId, containerOf, edges }) {
74
+ const mod = goModulePath(repo)
75
+ if (!mod) return []
76
+ const pkgs = []
77
+ for (const n of nodes) {
78
+ if (n.kind !== 'leaf') continue
79
+ const ev = (n.evidence || []).find((e) => e.type === 'path')
80
+ if (!ev) continue
81
+ try { if (!statSync(join(repo, ev.ref)).isDirectory()) continue } catch { continue }
82
+ pkgs.push({ dir: posix(ev.ref), container: containerOf(n, byId) })
83
+ }
84
+ const byImport = new Map(pkgs.map((p) => [p.dir === '.' ? mod : mod + '/' + p.dir, p.container]))
85
+ const have = new Set((edges || []).map((e) => e.from + '|' + e.to))
86
+ const out = []
87
+ for (const p of pkgs) {
88
+ const counts = new Map()
89
+ for (const imp of goImports(join(repo, p.dir))) {
90
+ const to = byImport.get(imp)
91
+ if (!to || to === p.container) continue
92
+ counts.set(to, (counts.get(to) || 0) + 1)
93
+ }
94
+ for (const [to, c] of [...counts].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
95
+ const k = p.container + '|' + to
96
+ if (have.has(k)) continue
97
+ have.add(k)
98
+ out.push({ from: p.container, to, label: c + '×', kind: 'import', estatus: 'active' })
99
+ }
100
+ }
101
+ return out
102
+ }
@@ -220,13 +220,14 @@
220
220
  "type": "string",
221
221
  "enum": [
222
222
  "curated",
223
+ "docmap",
223
224
  "docstring",
224
225
  "readme",
225
226
  "arc42",
226
227
  "fallback",
227
228
  "llm"
228
229
  ],
229
- "description": "Provenance of func: how the description was resolved (§1a chain; 'llm' = optional enrichment)."
230
+ "description": "Provenance of func: how the description was resolved (§1a chain; 'docmap' = a capability row in a repo document; 'llm' = optional enrichment)."
230
231
  },
231
232
  "descInputHash": {
232
233
  "type": "string",
@@ -0,0 +1,66 @@
1
+ // validate.mjs — hold the model to the schema it declares. Shared by `gen` (after write) and `check`.
2
+
3
+ // Zero deps (ADR-0001), so the engine is a hand-written walker over the ONLY keywords the
4
+ // shipped lib/schema/c4-model.schema.json uses:
5
+ // type (object|array|string|integer|number|boolean|null), required, properties,
6
+ // additionalProperties (enforced only where it is literally false), items (single schema, no
7
+ // tuple form), enum, minItems, minimum, maximum, pattern.
8
+ // Annotation-only keywords are read and deliberately NOT enforced: $schema, $id, title,
9
+ // description, default, format — `format: "date-time"`/`"uri"` are not checked, exactly as ajv
10
+ // without ajv-formats treats them in draft-07.
11
+ // This is NOT a general JSON Schema engine. A third-party schema using oneOf/allOf/$ref/
12
+ // patternProperties/tuple items/dependencies would be silently UNDER-validated: the unknown
13
+ // keyword is ignored, never an error. Widen this file before pointing it at another schema.
14
+ import { readFileSync } from 'node:fs'
15
+
16
+ const typeIsObject = (v) => v !== null && !Array.isArray(v) && typeof v === 'object'
17
+ const jsType = (v) => v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v
18
+ const typeOk = (value, type) => {
19
+ if (type === 'object') return typeIsObject(value)
20
+ if (type === 'array') return Array.isArray(value)
21
+ if (type === 'string') return typeof value === 'string'
22
+ if (type === 'integer') return Number.isInteger(value)
23
+ if (type === 'number') return typeof value === 'number'
24
+ if (type === 'boolean') return typeof value === 'boolean'
25
+ if (type === 'null') return value === null
26
+ return true
27
+ }
28
+ const formatTypeError = (path, expected, value) => path + ': expected ' + expected + ', got ' + jsType(value)
29
+ const childPath = (path, key) => (path === '<root>' ? key : path + '.' + key)
30
+ const enumText = (set) => set.map((v) => typeof v === 'string' ? v : String(v)).join(', ')
31
+ const validateAgainstSchema = (value, schema, path, errs) => {
32
+ const declared = schema.type || null
33
+ if (declared && !typeOk(value, declared)) { errs.push(formatTypeError(path, declared, value)); return }
34
+ if (schema.required && typeIsObject(value)) {
35
+ for (const key of schema.required) if (!Object.prototype.hasOwnProperty.call(value, key)) errs.push(path + ': missing required property "' + key + '"')
36
+ }
37
+ if (schema.properties && typeIsObject(value)) {
38
+ for (const [k, s] of Object.entries(schema.properties)) {
39
+ if (!Object.prototype.hasOwnProperty.call(value, k)) continue
40
+ validateAgainstSchema(value[k], s, childPath(path, k), errs)
41
+ }
42
+ }
43
+ if (schema.additionalProperties === false && typeIsObject(value)) {
44
+ for (const key of Object.keys(value)) if (!schema.properties || !Object.prototype.hasOwnProperty.call(schema.properties, key)) errs.push(path + ': unexpected property "' + key + '" (additionalProperties: false)')
45
+ }
46
+ if (Array.isArray(value) && schema.items && typeof schema.items === 'object') {
47
+ value.forEach((item, i) => validateAgainstSchema(item, schema.items, path + '[' + i + ']', errs))
48
+ }
49
+ // outside the items branch on purpose: minItems constrains the array, not its element schema
50
+ if (schema.minItems != null && Array.isArray(value) && value.length < schema.minItems) errs.push(path + ': expected at least ' + schema.minItems + ' item, got ' + value.length)
51
+ if (schema.enum && schema.enum.includes(value) === false) {
52
+ const expected = enumText(schema.enum)
53
+ errs.push(path + ': ' + JSON.stringify(value) + ' is not one of [' + expected + ']')
54
+ }
55
+ if (schema.minimum != null && typeof value === 'number' && value < schema.minimum) errs.push(path + ': ' + value + ' is less than the minimum ' + schema.minimum)
56
+ if (schema.maximum != null && typeof value === 'number' && value > schema.maximum) errs.push(path + ': ' + value + ' is greater than the maximum ' + schema.maximum)
57
+ if (schema.pattern && typeof value === 'string' && !new RegExp(schema.pattern).test(value)) errs.push(path + ': ' + JSON.stringify(value) + ' does not match ' + schema.pattern)
58
+ }
59
+
60
+ export const validateModel = (model, schemaPath = new URL('./schema/c4-model.schema.json', import.meta.url)) => {
61
+ let schema
62
+ try { schema = JSON.parse(readFileSync(schemaPath, 'utf-8')) } catch (e) { return ['<root>: unable to read schema - ' + String((e && e.message) || e)] }
63
+ const errs = []
64
+ validateAgainstSchema(model, schema, '<root>', errs)
65
+ return errs
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-arch",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Forma — present your architecture instead of slides. An interactive, stack-agnostic C4 explorer (context → container → component → leaf) generated from your code and kept true to it by a deterministic drift check.",
5
5
  "keywords": [
6
6
  "architecture",