forma-arch 0.6.0 → 0.7.1

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. You do not have to hand-edit that JSON — `forma gen --status-apply <file>` merges `{"nodes":{"<id>":{…}}}` into it, validating every patch **before** it touches disk, so a rejected fill leaves the committed overlay untouched. It is the state counterpart of `--enrich-apply`, for the fields no document can supply. 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
@@ -2,20 +2,23 @@
2
2
  // gen-c4-model.mjs — emit docs/architecture/c4-model.json from real code + curated topology.
3
3
  // Leaves are walked LIVE from src/ (always current). Topology/context/runtime-edges are curated.
4
4
  // Usage: node gen-c4-model.mjs [--repo <path>] [--topology <path>] [--out <path>]
5
- import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from 'node:fs'
5
+ import { readFileSync, writeFileSync, readdirSync, existsSync, statSync, mkdirSync } from 'node:fs'
6
6
  import { join, dirname, basename, relative } from 'node:path'
7
7
  import { execSync } from 'node:child_process'
8
8
  import { fileURLToPath } from 'node:url'
9
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)
@@ -23,6 +26,7 @@ const ENRICH_MODEL = arg('--enrich-model', null)
23
26
  const STATUS = arg('--status', join(REPO, 'docs/architecture/c4-status.json')) // curated programme state, optional
24
27
  const STATUS_SET = process.argv.includes('--status')
25
28
  const APPLY = arg('--enrich-apply', null) // prose written by the agent driving forma
29
+ const STATUS_APPLY = arg('--status-apply', null) // §WP-A7: programme STATE written by that agent
26
30
 
27
31
  const topo = JSON.parse(readFileSync(TOPO, 'utf-8'))
28
32
  const rp = (p) => join(REPO, p)
@@ -75,7 +79,10 @@ for (const spec of topo.leafSources) {
75
79
  id: `${spec.parent}__${f.replace(/[^a-z0-9]+/gi, '_')}`,
76
80
  level: 'leaf', parent: spec.parent, kind: 'leaf',
77
81
  name: f.replace(/\.[a-z0-9]+$/i, ''), status: 'current',
78
- evidence: [{ type: 'path', ref: `${spec.dir}/${f}` }],
82
+ // `dir: "."` (loose top-level sources, or a Go package at the module root) must not produce
83
+ // "./x": the ref is a repo-relative key, and the oracle caught the Go adapter failing to map
84
+ // "./migrations" back to the import path <module>/migrations.
85
+ evidence: [{ type: 'path', ref: spec.dir === '.' ? f : `${spec.dir}/${f}` }],
79
86
  })
80
87
  }
81
88
  // attach glob count to the parent (drift anchor)
@@ -120,10 +127,25 @@ if (CLUSTER) for (const cid of [...new Set(topo.leafSources.map((s) => s.parent)
120
127
  console.log(`[gen-c4] container ${cid}: clustered ${leaves.length} leaves into ${components.length} component(s)`)
121
128
  }
122
129
 
130
+ // §docmap) join the repo's own capability tables to the nodes, ONCE: the same match feeds both the
131
+ // description (§1a below) and the programme state derived just under here.
132
+ const docRows = loadDocRows(REPO, topo.docSources)
133
+ const docIndex = indexByNode(docRows, nodes)
134
+ 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`)
135
+
123
136
  // enrich: fill hologram defaults (category, 6-status, completion, current/target) where absent
124
137
  for (const n of nodes) {
125
- const par = n.parent ? byId.get(n.parent) : null
126
- if (!n.category) n.category = n.kind === 'leaf' ? (par && par.category) || 'leaf' : n.kind
138
+ // A leaf's category is the CONTAINER it belongs to, not its parent's category. Inheriting the
139
+ // parent's handed every leaf in the repo the literal string "container", and the viewer's
140
+ // catalogue collapse groups childless siblings BY CATEGORY — so a repo's leaves collapsed into a
141
+ // single box labelled "container" (53 of them on the Go repo this was measured on). With the real
142
+ // container they group the way that feature was written for, or stay apart when they belong apart.
143
+ if (!n.category) n.category = n.kind === 'leaf' ? (byId.get(containerOf(n, byId)) || {}).name || 'leaf' : n.kind
144
+ // Progress a DOCUMENT states outright — the only generated alternative to hand-writing the
145
+ // overlay. Derived, so it is re-derived by `check` rather than trusted; the curated overlay
146
+ // (§WP-A1, applied further down) still overrides every field of it.
147
+ const ds = !n.status2 ? statusFor(docIndex, n.id) : null
148
+ if (ds) { n.status2 = ds.status2; if (n.completion == null) n.completion = ds.completion; if (!n.verify) n.verify = { source: ds.source } }
127
149
  // Code can prove a file EXISTS; it cannot prove the work behind it is finished. Marking every
128
150
  // undecorated node done/100 turned a virgin repo into a board reading "10/10 complete" — the
129
151
  // exact false green this tool exists to kill. No overlay (§WP-A1), no verdict: `unknown`, and
@@ -138,7 +160,7 @@ for (const n of nodes) {
138
160
 
139
161
  // §1a) func: plain-language "what it does" resolved from existing docs (curated → docstring → README
140
162
  // → 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 })
163
+ const dctx = makeDescribeCtx({ repo: REPO, byId, descriptions: topo.descriptions || {}, docPath: topo.docPath, containerOf, docIndex })
142
164
  for (const n of nodes) { const r = resolveDescription(n, dctx); n.func = r.func; n.descSource = r.descSource }
143
165
 
144
166
  // §1a-bis) a synthesized component has no doc of its own: before settling for "Groups related
@@ -148,7 +170,7 @@ for (const n of nodes) { const r = resolveDescription(n, dctx); n.func = r.func;
148
170
  const firstSentence = (s) => (String(s).match(/^[^.!?]*[.!?]/) || [String(s)])[0].trim()
149
171
  for (const n of nodes) {
150
172
  if (n.kind !== 'component' || n.descSource !== 'fallback') continue
151
- const kids = nodes.filter((k) => k.parent === n.id && ['curated', 'docstring', 'readme'].includes(k.descSource))
173
+ const kids = nodes.filter((k) => k.parent === n.id && ['curated', 'docmap', 'docstring', 'readme'].includes(k.descSource))
152
174
  .sort((a, b) => (String(a.name) < String(b.name) ? -1 : String(a.name) > String(b.name) ? 1 : 0)).slice(0, 3)
153
175
  const txt = kids.map((k) => firstSentence(k.func)).filter(Boolean).join(' ')
154
176
  if (txt) n.func = txt.length > 200 ? txt.slice(0, 199) + '…' : txt
@@ -160,6 +182,53 @@ for (const n of nodes) {
160
182
  // what a module DOES comes from its docs (§1a), what it's WORTH is programme state.
161
183
  const STATUS_FIELDS = new Set(['status2', 'completion', 'statusWord', 'current', 'target', 'verify', 'issues'])
162
184
  const STATUS2 = new Set(['done', 'in-progress', 'next', 'planned', 'problem'])
185
+ // One validator, two callers: the overlay FILE below, and --status-apply just above it. A writer
186
+ // that validated differently from the reader would commit a c4-status.json the very next `gen`
187
+ // rejects. Returns the complaints; the caller decides whether that is a fail or a refusal to write.
188
+ function statusPatchErrors(id, patch, known) {
189
+ const out = []
190
+ if (!known(id)) out.push(`unknown node id "${id}" — it is not in the model (stale overlay?)`)
191
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return [...out, `"${id}" must map to an object of state fields`]
192
+ for (const [k, v] of Object.entries(patch)) {
193
+ if (k.startsWith('$')) continue // $comment and friends
194
+ if (!STATUS_FIELDS.has(k)) { out.push(`"${id}" has field "${k}"; allowed: ${[...STATUS_FIELDS].join(', ')}${k === 'func' ? ' (func comes from the docs, not the overlay)' : ''}`); continue }
195
+ if (k === 'status2' && !STATUS2.has(v)) out.push(`"${id}" status2 "${v}" — expected one of ${[...STATUS2].join('|')}`)
196
+ if (k === 'completion' && !(Number.isInteger(v) && v >= 0 && v <= 100)) out.push(`"${id}" completion must be an integer 0-100, got ${JSON.stringify(v)}`)
197
+ if (k === 'issues') {
198
+ if (!Array.isArray(v)) out.push(`"${id}" issues must be an array`)
199
+ else for (const is of v) if (!/^#?\d+$/.test(String(is))) out.push(`"${id}" issue ${JSON.stringify(is)} — expected "#123"`)
200
+ }
201
+ }
202
+ return out
203
+ }
204
+
205
+ // §WP-A7) --status-apply: the WRITER the overlay never had. `c4-status.json` is the authority —
206
+ // every field it sets outranks anything §17 derives from a document — and nothing in the repo has
207
+ // ever written one, so on a fresh repo the authority channel was reachable only by hand-editing
208
+ // JSON. This is the state counterpart of `--enrich-apply`: an agent or a human hands in the fields
209
+ // a document cannot supply, and they land in the curated file, reviewed in the diff like any other.
210
+ if (STATUS_APPLY) {
211
+ if (!existsSync(STATUS_APPLY)) fail(`--status-apply: file missing: ${STATUS_APPLY}`)
212
+ let incoming
213
+ try { incoming = JSON.parse(readFileSync(STATUS_APPLY, 'utf-8')).nodes } catch (e) { fail(`--status-apply: ${STATUS_APPLY} is not valid JSON: ${(e && e.message) || e}`) }
214
+ if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) fail(`--status-apply: ${STATUS_APPLY} must contain {"nodes":{"<id>":{…}}}`)
215
+ // Validate EVERYTHING before touching disk. A half-written overlay is a file the next `gen`
216
+ // refuses to read, in a repo where that file is committed.
217
+ const errs = Object.entries(incoming).flatMap(([id, patch]) => statusPatchErrors(id, patch, (x) => byId.has(x)))
218
+ if (errs.length) fail(`--status-apply: ${STATUS_APPLY} rejected, ${relative(REPO, STATUS)} left untouched:\n - ` + errs.join('\n - '))
219
+ let base = { nodes: {} }
220
+ if (existsSync(STATUS)) {
221
+ try { base = JSON.parse(readFileSync(STATUS, 'utf-8')) } catch (e) { fail(`--status-apply: existing ${relative(REPO, STATUS)} is not valid JSON: ${(e && e.message) || e}`) }
222
+ base.nodes = base.nodes || {}
223
+ }
224
+ for (const [id, patch] of Object.entries(incoming)) base.nodes[id] = { ...(base.nodes[id] || {}), ...patch }
225
+ // The first apply on a repo IS the one that creates the overlay, and a repo that has never run
226
+ // `forma doc` has no docs/architecture/ to create it in.
227
+ mkdirSync(dirname(STATUS), { recursive: true })
228
+ writeFileSync(STATUS, JSON.stringify(base, null, 2) + '\n')
229
+ console.log(`[gen-c4] --status-apply: ${Object.keys(incoming).length} node(s) merged into ${relative(REPO, STATUS)}`)
230
+ }
231
+
163
232
  let statusPath = null
164
233
  if (existsSync(STATUS) || STATUS_SET) {
165
234
  if (!existsSync(STATUS)) fail(`--status: file missing: ${STATUS}`)
@@ -167,19 +236,10 @@ if (existsSync(STATUS) || STATUS_SET) {
167
236
  try { ov = JSON.parse(readFileSync(STATUS, 'utf-8')) } catch (e) { fail(`--status: ${STATUS} is not valid JSON: ${(e && e.message) || e}`) }
168
237
  let decorated = 0
169
238
  for (const [id, patch] of Object.entries(ov.nodes || {})) {
239
+ const errs = statusPatchErrors(id, patch, (x) => byId.has(x))
240
+ if (errs.length) fail('status overlay: ' + errs.join('\n[gen-c4] FAIL: status overlay: '))
170
241
  const target = byId.get(id)
171
- if (!target) fail(`status overlay: unknown node id "${id}" it is not in the model (stale overlay?)`)
172
- for (const [k, v] of Object.entries(patch)) {
173
- if (k.startsWith('$')) continue // $comment and friends
174
- if (!STATUS_FIELDS.has(k)) fail(`status overlay: "${id}" has field "${k}"; allowed: ${[...STATUS_FIELDS].join(', ')}${k === 'func' ? ' (func comes from the docs, not the overlay)' : ''}`)
175
- if (k === 'status2' && !STATUS2.has(v)) fail(`status overlay: "${id}" status2 "${v}" — expected one of ${[...STATUS2].join('|')}`)
176
- if (k === 'completion' && !(Number.isInteger(v) && v >= 0 && v <= 100)) fail(`status overlay: "${id}" completion must be an integer 0-100, got ${JSON.stringify(v)}`)
177
- if (k === 'issues') {
178
- if (!Array.isArray(v)) fail(`status overlay: "${id}" issues must be an array`)
179
- for (const is of v) if (!/^#?\d+$/.test(String(is))) fail(`status overlay: "${id}" issue ${JSON.stringify(is)} — expected "#123"`)
180
- }
181
- target[k] = v
182
- }
242
+ for (const [k, v] of Object.entries(patch)) { if (!k.startsWith('$')) target[k] = v }
183
243
  decorated++
184
244
  }
185
245
  statusPath = relative(REPO, STATUS)
@@ -218,7 +278,13 @@ if (ENRICH && ENRICHER === 'agent') {
218
278
  // code itself: for each container, count how many of ANOTHER container's exposed leaf names (class/
219
279
  // module names) appear as whole-word references in this container's files. count>0 ⇒ a real edge.
220
280
  // Language-agnostic (matches symbol names, not import syntax). Additive: never removes curated edges.
221
- if (!process.argv.includes('--no-auto-edges')) {
281
+ // A language that DECLARES its dependencies gets its adapter instead (lib/lang.mjs): guessing from
282
+ // names where an `import` block states the fact is strictly worse, and gets the direction wrong.
283
+ if (!process.argv.includes('--no-auto-edges') && isGo(topo.meta && topo.meta.stack)) {
284
+ const derived = goEdges({ repo: REPO, nodes, byId, containerOf, edges: topo.edges })
285
+ topo.edges = [...(topo.edges || []), ...derived]
286
+ console.log(`[gen-c4] auto-edges: +${derived.length} container edge(s) derived from Go import blocks`)
287
+ } else if (!process.argv.includes('--no-auto-edges')) {
222
288
  const STOP = new Set(['index', 'main', 'app', 'utils', 'util', 'types', 'model', 'base', 'core', 'const', 'style', 'theme'])
223
289
  const srcs = (topo.leafSources || []).filter((s) => byId.has(s.parent))
224
290
  const exposes = new Map(), text = new Map()
@@ -297,6 +363,10 @@ if (process.argv.includes('--from-docs')) {
297
363
  } catch (e) { model.meta.verifyError = 'from-docs: ' + String((e && e.message) || e) }
298
364
  }
299
365
  writeFileSync(OUT, JSON.stringify(model, null, 2) + '\n')
366
+ // topo.nodes are copied verbatim into the model (pass 1), so a bad curated kind used to leak in
367
+ // and only fail later — this catches it immediately and keeps invalid artifacts from being written.
368
+ const schemaErrors = validateModel(model)
369
+ if (schemaErrors.length) fail('model does not validate against lib/schema/c4-model.schema.json:\n - ' + schemaErrors.join('\n - '))
300
370
  const counts = { total: nodes.length, leaves: nodes.filter((n) => n.kind === 'leaf').length, planned: nodes.filter((n) => n.status === 'planned').length }
301
371
  console.log(`[gen-c4] wrote ${OUT}`)
302
372
  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,9 @@
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 { execFileSync } from 'node:child_process'
9
+ import { goPackages } from './lang.mjs'
10
+ import { loadDocRows } from './docmap.mjs'
8
11
 
9
12
  const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
10
13
  const REPO = arg('--repo', process.cwd())
@@ -55,20 +58,24 @@ function javaBase() {
55
58
  }
56
59
  return d
57
60
  }
58
- const root = ext[0] === 'java' ? (javaBase() || REPO) : (existsSync(join(REPO, 'src')) ? join(REPO, 'src') : REPO)
61
+ // Go modules are rooted at go.mod, never at src/ an import path is relative to the module root.
62
+ const root = ext[0] === 'java' ? (javaBase() || REPO) : ext[0] === 'go' ? REPO : (existsSync(join(REPO, 'src')) ? join(REPO, 'src') : REPO)
63
+ const GO = ext[0] === 'go'
64
+ const notSrc = GO ? /_test\.go$/ : null // a test file is not architecture (§Go adapter)
59
65
 
60
66
  // 3) build nodes + leafSources: one container per immediate subdir that holds source files directly
61
67
  const sysId = slug(basename(REPO))
62
68
  const nodes = [{ id: sysId, level: 'context', kind: 'system', name: basename(REPO), tech: lang, description: `${basename(REPO)} — drill for containers.` }]
63
69
  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 } }
70
+ 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
71
 
66
72
  // Recurse to the SHALLOWEST dirs that directly contain source files → each is a container.
67
73
  // (A dir with direct sources is a container; a dir with none is descended into. Works for nested
68
74
  // layouts like src/app/routes/*.ts or Python packages, not just one level.)
69
75
  const seen = new Set()
70
76
  const skipped = []
71
- ;(function findContainers(dir, depth) {
77
+ const claim = (rel) => { let id = slug(rel.replace(/[\\/]+/g, '_')); if (seen.has(id)) id = slug(id + '_' + leafSources.length); seen.add(id); return id }
78
+ const findContainers = (dir, depth) => {
72
79
  if (depth > 10) return
73
80
  let ents; try { ents = readdirSync(dir) } catch { return }
74
81
  for (const e of ents.filter((x) => !IGNORE.has(x) && !x.startsWith('.')).sort()) {
@@ -77,27 +84,76 @@ const skipped = []
77
84
  if (!st.isDirectory()) continue
78
85
  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
86
  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)
87
+ const id = claim(relative(root, p) || e)
84
88
  nodes.push({ id, level: 'container', kind: 'container', parent: sysId, name: e, tech: lang })
85
89
  leafSources.push({ parent: id, dir: relative(REPO, p), match: matchRe })
86
90
  } else findContainers(p, depth + 1)
87
91
  }
88
- })(root, 0)
92
+ }
93
+ const skipDir = (e) => IGNORE.has(e) || e.startsWith('.') || (SKIP_DIRS.has(e.toLowerCase()) && !KEEP.has(e.toLowerCase()))
94
+
95
+ // Go: the container is the PACKAGE, not the shallowest directory that happens to hold code. Go
96
+ // nests packages freely (internal/store, internal/server, …) and `internal/` itself is usually not
97
+ // one — stopping at the first level collapses thirty units of architecture into a single box. The
98
+ // leaf is the package too: the files inside it are internal organisation nobody presents, so the
99
+ // leafSource points at the parent directory and matches the package directory by name (a leaf must
100
+ // stay a real, re-derivable entry on disk — that is what the drift gate re-counts).
101
+ if (GO) {
102
+ for (const rel of goPackages(root, skipDir)) {
103
+ // ponytail: a package at the module ROOT falls through to the loose-files branch below, which
104
+ // keeps the old file-per-leaf shape (there is no parent directory to match its name in). Its
105
+ // box is real but stays edge-less, since goEdges only follows directory leaves. Rare layout —
106
+ // give the root package its own directory (cmd/…) and it behaves like every other one.
107
+ if (!rel) continue
108
+ const cut = rel.lastIndexOf('/')
109
+ const id = claim(rel)
110
+ nodes.push({ id, level: 'container', kind: 'container', parent: sysId, name: rel, tech: lang })
111
+ leafSources.push({ parent: id, dir: cut < 0 ? '.' : rel.slice(0, cut), match: '^' + rel.slice(cut + 1).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$', filesOnly: false })
112
+ }
113
+ } else findContainers(root, 0)
89
114
  // 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 }) }
115
+ 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
116
 
92
117
  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
118
 
119
+ // 4) find the documents that already say, in prose, what each part of the product does and whether
120
+ // it is finished — a feature matrix, a capability table, a requirements sheet. Detection is the
121
+ // reader itself: a doc qualifies when `loadDocRows` gets capability rows out of it, so nothing here
122
+ // can drift from what `gen` will actually parse, and no project's filenames are hardcoded.
123
+ // Threshold 3: a two-row table in a runbook is a config note, not a capability inventory.
124
+ const docSources = []
125
+ ;(function findDocs(dir, depth) {
126
+ if (depth > 4) return
127
+ let ents; try { ents = readdirSync(dir) } catch { return }
128
+ for (const e of ents.filter((x) => !IGNORE.has(x) && !x.startsWith('.')).sort()) {
129
+ const p = join(dir, e)
130
+ let st; try { st = statSync(p) } catch { continue }
131
+ if (st.isDirectory()) findDocs(p, depth + 1)
132
+ else if (/\.md$/i.test(e) && loadDocRows(REPO, [relative(REPO, p)], true).length >= 3) docSources.push(relative(REPO, p))
133
+ }
134
+ })(join(REPO, 'docs'), 0)
135
+
136
+ // `forma verify` is the one command that derives progress from something other than a claim in a
137
+ // document — the live state of a GitHub issue — and it needs `meta.ghRepo` to know where to look.
138
+ // `init` never wrote it, so on a freshly seeded repo verify had nowhere to point and exited. The
139
+ // remote already says it. Handles scp-style (git@host:owner/repo.git) and URL remotes alike.
140
+ const ghRepo = (() => {
141
+ let url
142
+ try { url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: REPO, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { return null }
143
+ const m = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(url)
144
+ return m ? m[1] : null
145
+ })()
146
+
94
147
  const topo = {
95
- meta: { repo: basename(REPO), stack: lang, seededBy: 'forma init' },
148
+ meta: { repo: basename(REPO), stack: lang, seededBy: 'forma init', ...(ghRepo ? { ghRepo } : {}) },
96
149
  docPath: 'docs/architecture/ARCHITECTURE.md',
97
150
  levels: ['context', 'container', 'component', 'leaf'],
98
151
  nodes,
99
152
  leafSources,
100
153
  edges: [],
154
+ // Capability tables: the box text and the progress for context/container come from HERE first,
155
+ // ahead of the code. Each entry is a path, or {path, describe, ref, status} to name the columns.
156
+ docSources,
101
157
  descriptions: {},
102
158
  _skipped: skipped,
103
159
  }
@@ -105,4 +161,5 @@ const topo = {
105
161
  mkdirSync(join(OUT, '..'), { recursive: true })
106
162
  writeFileSync(OUT, JSON.stringify(topo, null, 2) + '\n')
107
163
  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` : ''}`)
164
+ if (docSources.length) console.log(`[forma init] docSources: ${docSources.join(', ')} — capability rows will describe the containers they name.`)
108
165
  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.1",
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",