forma-arch 0.5.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:
@@ -52,10 +70,16 @@ npx forma-arch <command> # or: npm i -D forma-arch
52
70
  | `--enricher` | Use it when | Network |
53
71
  |---|---|---|
54
72
  | `agent` | **An agent is driving forma.** Writes `enrich-plan.json` with the holes; the agent writes the sentences (reading the sources if it wants) and `gen --enrich-apply <file>` applies them with the same cache and provenance. | none |
55
- | `anthropic` (default) | Headless / CI, with `ANTHROPIC_API_KEY`. | REST |
73
+ | `anthropic` | Headless / CI, with `ANTHROPIC_API_KEY`. | REST |
56
74
  | `openai` | Same, with `OPENAI_API_KEY`. | REST |
57
75
  | `ollama` | Sensitive repos: a local model, nothing leaves the machine. | localhost |
58
76
 
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
79
+ default provider is a silent choice about your network and your API keys — and the old default
80
+ (`anthropic`) meant that anyone without `ANTHROPIC_API_KEY` exported got a skip line, exit 0 and the
81
+ same empty boxes they ran `--enrich` to fill.
82
+
59
83
  ## How it fits together
60
84
 
61
85
  ```
@@ -68,6 +92,10 @@ One source of truth (`c4-model.json`); two renderings (the interactive viewer an
68
92
  one deterministic check that keeps them honest. The file contract is
69
93
  [`lib/schema/c4-model.schema.json`](lib/schema/c4-model.schema.json).
70
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
+
71
99
  ## Model-agnostic by design
72
100
 
73
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/cluster.mjs CHANGED
@@ -13,14 +13,17 @@ export function containerOf(node, byId) {
13
13
  return n ? n.id : null
14
14
  }
15
15
 
16
- // Group a flat container's leaves by common `foo_*` prefix. Deterministic: sorted group keys.
16
+ // Group a flat container's leaves by common `foo<sep>*` prefix, where sep is any of - _ . — the
17
+ // three conventions real repos use (kebab dominates JS/TS, snake Python, dot some Java/config
18
+ // layouts). Splitting on `_` alone gave kebab and dot repos no component level at all.
19
+ // Deterministic: sorted group keys.
17
20
  // Returns { components: Node[], reparent: Map(leafId → componentId) }. Leftovers stay under the container.
18
21
  export function componentsFor(container, leaves, opts = {}) {
19
22
  const groupMin = opts.groupMin || 3
20
23
  const groups = new Map()
21
24
  for (const l of leaves) {
22
25
  const base = String(l.name)
23
- const i = base.indexOf('_')
26
+ const i = base.search(/[-_.]/)
24
27
  if (i <= 0) continue // no prefix (e.g. "health", "version") → stays flat
25
28
  const prefix = base.slice(0, i)
26
29
  if (!groups.has(prefix)) groups.set(prefix, [])
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)
@@ -60,31 +67,65 @@ function buildArc42Index(repo, docPath) {
60
67
  return idx
61
68
  }
62
69
 
63
- export function makeDescribeCtx({ repo, byId, descriptions, docPath, containerOf }) {
64
- return { repo, byId, D: descriptions || {}, containerOf, readmeCache: new Map(), arc42: buildArc42Index(repo, docPath) }
70
+ // Last resort for a node that HAS children: say what it measurably holds. Never the language —
71
+ // a box reading "TypeScript" states the one thing the reader can already see in the header.
72
+ function measuredFunc(node, ctx) {
73
+ const kids = [...ctx.byId.values()].filter((k) => k.parent === node.id)
74
+ .sort((a, b) => (String(a.name) < String(b.name) ? -1 : String(a.name) > String(b.name) ? 1 : 0))
75
+ if (!kids.length) return null
76
+ const count = (k) => kids.filter((x) => x.kind === k).length
77
+ const parts = [['container', count('container')], ['component', count('component')], ['file', count('leaf')]]
78
+ .filter((p) => p[1]).map((p) => `${p[1]} ${p[0]}${p[1] === 1 ? '' : 's'}`)
79
+ const names = kids.slice(0, 3).map((k) => k.name).join(', ')
80
+ return `${parts.join(' + ')}: ${names}${kids.length > 3 ? ', …' : '.'}`.slice(0, 240)
81
+ }
82
+
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' }
65
92
  }
66
93
 
67
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.
68
100
  export function resolveDescription(node, ctx) {
69
101
  const cont = ctx.containerOf(node, ctx.byId)
70
102
  const stem = String(node.name).replace(/\.\w+$/, '').replace(/ .*/, '')
71
103
  const key = cont ? `${cont}/${stem}` : null
72
104
  if (key && ctx.D[key]) return { func: ctx.D[key], descSource: 'curated' }
73
105
  if (node.description) return { func: node.description, descSource: 'curated' }
106
+ if (node.kind !== 'leaf') { const d = fromDocs(node, ctx); if (d) return d }
74
107
  const pth = (node.evidence || []).find((e) => e.type === 'path')
75
108
  if (pth) {
76
109
  const abs = join(ctx.repo, pth.ref)
77
110
  const ds = moduleDocstring(abs)
78
111
  if (ds) return { func: ds, descSource: 'docstring' }
79
- const rd = readmeFirstPara(dirname(abs), ctx.readmeCache)
112
+ const rd = readmeFirstPara(ownDir(abs), ctx.readmeCache)
113
+ if (rd) return { func: rd, descSource: 'readme' }
114
+ }
115
+ // A container's evidence is a GLOB over its own directory, never a `path` — which is why the
116
+ // README sitting in that very directory never reached it, and the box fell through to `tech`.
117
+ const glb = (node.evidence || []).find((e) => e.type === 'glob')
118
+ if (!pth && glb) {
119
+ const rd = readmeFirstPara(join(ctx.repo, glb.ref), ctx.readmeCache)
80
120
  if (rd) return { func: rd, descSource: 'readme' }
81
121
  }
122
+ const d = fromDocs(node, ctx) // leaves reach it here: after their own code, before the fallback
123
+ if (d) return d
82
124
  const a = ctx.arc42.get(norm(node.name)) || ctx.arc42.get(norm(node.id))
83
125
  if (a) return { func: a, descSource: 'arc42' }
84
126
  if (node.kind === 'leaf') {
85
127
  const cn = (ctx.byId.get(cont) || {}).name || cont || node.parent
86
128
  return { func: `Component of module ${cn}.`, descSource: 'fallback' }
87
129
  }
88
- if (node.kind === 'component') return { func: `Groups related files under ${node.name}.`, descSource: 'fallback' }
89
- return { func: node.description || '', descSource: 'fallback' }
130
+ return { func: node.description || measuredFunc(node, ctx) || '', descSource: 'fallback' }
90
131
  }
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/enrich.mjs CHANGED
@@ -36,15 +36,21 @@ export function descInputHash(node, ctx) {
36
36
 
37
37
  function promptFor(node, ctx, opts = {}) {
38
38
  const { cont, siblings } = siblingsOf(node, ctx)
39
+ // A container is its OWN container: unguarded, the prompt tells the model that "auth" belongs to
40
+ // the container "auth" and calls its own children its siblings. Its filesystem pointer is a glob
41
+ // over its directory, not a path, so it also had nothing to read.
42
+ const self = cont === node.id
39
43
  const contName = (ctx.byId && ctx.byId.get(cont) || {}).name || cont || 'the system'
40
44
  const path = (node.evidence || []).find((e) => e.type === 'path')
45
+ const glob = !path && (node.evidence || []).find((e) => e.type === 'glob')
41
46
  return [
42
- `In ONE plain sentence (max 18 words), say what the software module "${node.name}" does.`,
43
- `It belongs to the container "${contName}".`,
44
- siblings.length ? `Sibling modules: ${siblings.slice(0, 40).join(', ')}.` : '',
47
+ `In ONE plain sentence (max 18 words), say what the software ${self ? 'module group' : 'module'} "${node.name}" does.`,
48
+ self ? '' : `It belongs to the container "${contName}".`,
49
+ siblings.length ? `${self ? 'The modules it holds' : 'Sibling modules'}: ${siblings.slice(0, 40).join(', ')}.` : '',
45
50
  // Only the agent enricher can act on this: a REST provider has no filesystem. That asymmetry
46
51
  // IS the point of the agent mode — the model driving forma can read the source it describes.
47
52
  (opts.canRead && path) ? `Read the file at ${path.ref} if you need certainty.` : '',
53
+ (opts.canRead && glob) ? `Read the sources under ${glob.ref}/ if you need certainty.` : '',
48
54
  'Answer with the sentence only — no preamble, no quotes.',
49
55
  ].filter(Boolean).join('\n')
50
56
  }
@@ -103,8 +109,11 @@ export function mergeCache(nodes, cache) {
103
109
  }
104
110
 
105
111
  // The holes an enricher would fill: never-described nodes, plus llm prose whose inputs moved.
112
+ // Containers count. They were excluded on the theory that the topology describes them — but on a
113
+ // repo with no curated topology (i.e. anything `forma init` seeded) they are exactly the boxes
114
+ // left on a measured fallback, and the biggest ones on screen.
106
115
  export function holesIn(nodes, ctx) {
107
- return nodes.filter((n) => (n.kind === 'leaf' || n.kind === 'component') &&
116
+ return nodes.filter((n) => (n.kind === 'leaf' || n.kind === 'component' || n.kind === 'container') &&
108
117
  (n.descSource === 'fallback' || (n.descSource === 'llm' && n.descInputHash !== descInputHash(n, ctx))))
109
118
  }
110
119
 
package/lib/gen.mjs CHANGED
@@ -7,18 +7,21 @@ 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.3.0'
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
- const ENRICHER = arg('--enricher', 'anthropic')
24
+ const ENRICHER = arg('--enricher', null)
22
25
  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')
@@ -31,6 +34,12 @@ const fail = (m) => { console.error('[gen-c4] FAIL: ' + m); process.exit(1) }
31
34
  // (`length >= NaN` is false for every group), so a bad value fails loud instead.
32
35
  const posInt = (flag, dflt) => { const v = Number(arg(flag, dflt)); if (!Number.isInteger(v) || v < 1) fail(`${flag}: expected a positive integer, got "${arg(flag, dflt)}"`); return v }
33
36
  const CLUSTER_MIN = posInt('--cluster-min', 8), GROUP_MIN = posInt('--group-min', 3)
37
+ // §7 has no default provider ON PURPOSE. It used to be `anthropic`, i.e. the one path that needs
38
+ // an API key most people have not exported — so `--enrich` "worked", printed a skip line, exited 0
39
+ // and left every box empty: the defect this command exists to fix, reintroduced by its own default.
40
+ // Flipping the default to `agent` would be just as silent the other way (a CI job with a key would
41
+ // quietly stop filling boxes and start writing a plan file). So: choose, explicitly.
42
+ if (ENRICH && !ENRICHER) fail('--enrich needs an explicit --enricher: `agent` (no key — an agent writes the prose, see --enrich-apply), `anthropic`/`openai` (REST, needs the API key in the environment), `ollama` (localhost), or `echo` (offline, testing).')
34
43
 
35
44
  function walk(spec) {
36
45
  const dir = rp(spec.dir)
@@ -69,7 +78,10 @@ for (const spec of topo.leafSources) {
69
78
  id: `${spec.parent}__${f.replace(/[^a-z0-9]+/gi, '_')}`,
70
79
  level: 'leaf', parent: spec.parent, kind: 'leaf',
71
80
  name: f.replace(/\.[a-z0-9]+$/i, ''), status: 'current',
72
- 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}` }],
73
85
  })
74
86
  }
75
87
  // attach glob count to the parent (drift anchor)
@@ -114,12 +126,27 @@ if (CLUSTER) for (const cid of [...new Set(topo.leafSources.map((s) => s.parent)
114
126
  console.log(`[gen-c4] container ${cid}: clustered ${leaves.length} leaves into ${components.length} component(s)`)
115
127
  }
116
128
 
117
- // enrich: fill hologram defaults (category, 5-status, completion, current/target) where absent
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
+
135
+ // enrich: fill hologram defaults (category, 6-status, completion, current/target) where absent
118
136
  for (const n of nodes) {
119
137
  const par = n.parent ? byId.get(n.parent) : null
120
138
  if (!n.category) n.category = n.kind === 'leaf' ? (par && par.category) || 'leaf' : n.kind
121
- if (!n.status2) n.status2 = n.status === 'planned' ? 'planned' : 'done'
122
- if (n.completion == null) n.completion = n.status === 'planned' ? 0 : 100
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 } }
144
+ // Code can prove a file EXISTS; it cannot prove the work behind it is finished. Marking every
145
+ // undecorated node done/100 turned a virgin repo into a board reading "10/10 complete" — the
146
+ // exact false green this tool exists to kill. No overlay (§WP-A1), no verdict: `unknown`, and
147
+ // no completion at all (a percentage nobody curated is a made-up number, including 0).
148
+ if (!n.status2) n.status2 = n.status === 'planned' ? 'planned' : 'unknown'
149
+ if (n.completion == null && n.status === 'planned') n.completion = 0
123
150
  // `current` is left EMPTY unless curated (topology or status overlay): the viewer falls back to
124
151
  // `func`, which since §1a carries the module's real documentation. The old "Exists: <path>"
125
152
  // filler restated the evidence path in the one field meant for programme facts.
@@ -128,7 +155,7 @@ for (const n of nodes) {
128
155
 
129
156
  // §1a) func: plain-language "what it does" resolved from existing docs (curated → docstring → README
130
157
  // → arc42 → generated fallback), with provenance in descSource. No LLM here — pure parsing.
131
- const dctx = makeDescribeCtx({ repo: REPO, byId, descriptions: topo.descriptions || {}, docPath: topo.docPath, containerOf })
158
+ const dctx = makeDescribeCtx({ repo: REPO, byId, descriptions: topo.descriptions || {}, docPath: topo.docPath, containerOf, docIndex })
132
159
  for (const n of nodes) { const r = resolveDescription(n, dctx); n.func = r.func; n.descSource = r.descSource }
133
160
 
134
161
  // §1a-bis) a synthesized component has no doc of its own: before settling for "Groups related
@@ -138,7 +165,7 @@ for (const n of nodes) { const r = resolveDescription(n, dctx); n.func = r.func;
138
165
  const firstSentence = (s) => (String(s).match(/^[^.!?]*[.!?]/) || [String(s)])[0].trim()
139
166
  for (const n of nodes) {
140
167
  if (n.kind !== 'component' || n.descSource !== 'fallback') continue
141
- 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))
142
169
  .sort((a, b) => (String(a.name) < String(b.name) ? -1 : String(a.name) > String(b.name) ? 1 : 0)).slice(0, 3)
143
170
  const txt = kids.map((k) => firstSentence(k.func)).filter(Boolean).join(' ')
144
171
  if (txt) n.func = txt.length > 200 ? txt.slice(0, 199) + '…' : txt
@@ -208,7 +235,13 @@ if (ENRICH && ENRICHER === 'agent') {
208
235
  // code itself: for each container, count how many of ANOTHER container's exposed leaf names (class/
209
236
  // module names) appear as whole-word references in this container's files. count>0 ⇒ a real edge.
210
237
  // Language-agnostic (matches symbol names, not import syntax). Additive: never removes curated edges.
211
- 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')) {
212
245
  const STOP = new Set(['index', 'main', 'app', 'utils', 'util', 'types', 'model', 'base', 'core', 'const', 'style', 'theme'])
213
246
  const srcs = (topo.leafSources || []).filter((s) => byId.has(s.parent))
214
247
  const exposes = new Map(), text = new Map()
@@ -226,7 +259,11 @@ if (!process.argv.includes('--no-auto-edges')) {
226
259
  for (const to of exposes.keys()) {
227
260
  if (to === from || have.has(from + '|' + to)) continue
228
261
  let c = 0
229
- for (const nm of exposes.get(to)) { if (new RegExp('\\b' + nm.replace(/[^\w]/g, '') + '\\b').test(t)) c++ }
262
+ // A module name is matched VERBATIM (metacharacters escaped, never deleted: stripping them
263
+ // turned "session-store" into /\bsessionstore\b/, which matches nothing — every kebab-case
264
+ // repo, i.e. most JS/TS ones, rendered edges=0). `-` and `.` are word separators here, so
265
+ // the boundary is spelled out instead of using \b (which sits INSIDE "session-store").
266
+ for (const nm of exposes.get(to)) { if (new RegExp('(^|[^\\w-])' + nm.replace(/[.*+?^${}()|[\]\\\/-]/g, '\\$&') + '([^\\w-]|$)').test(t)) c++ }
230
267
  if (c > 0) { derived.push({ from, to, label: c + '×', kind: 'import', estatus: 'active' }); have.add(from + '|' + to); have.add(to + '|' + from) }
231
268
  }
232
269
  }
@@ -283,6 +320,10 @@ if (process.argv.includes('--from-docs')) {
283
320
  } catch (e) { model.meta.verifyError = 'from-docs: ' + String((e && e.message) || e) }
284
321
  }
285
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 - '))
286
327
  const counts = { total: nodes.length, leaves: nodes.filter((n) => n.kind === 'leaf').length, planned: nodes.filter((n) => n.status === 'planned').length }
287
328
  console.log(`[gen-c4] wrote ${OUT}`)
288
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
+ }
@@ -179,9 +179,10 @@
179
179
  "in-progress",
180
180
  "next",
181
181
  "planned",
182
- "problem"
182
+ "problem",
183
+ "unknown"
183
184
  ],
184
- "description": "Hologram completion status (colour)."
185
+ "description": "Hologram completion status (colour). `unknown` is the default for a node no curated overlay has ruled on — code proves existence, never completion."
185
186
  },
186
187
  "completion": {
187
188
  "type": "integer",
@@ -219,13 +220,14 @@
219
220
  "type": "string",
220
221
  "enum": [
221
222
  "curated",
223
+ "docmap",
222
224
  "docstring",
223
225
  "readme",
224
226
  "arc42",
225
227
  "fallback",
226
228
  "llm"
227
229
  ],
228
- "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)."
229
231
  },
230
232
  "descInputHash": {
231
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
+ }
@@ -9,7 +9,7 @@
9
9
  --bg:#050d14; --grid:rgba(62,231,255,.04); --halo:rgba(62,231,255,.10);
10
10
  --cyan:#3ee7ff; --sec:#6fa8bc; --ter:#4c7f94; --ter2:#41738a; --nm:#bfe9f7;
11
11
  --stagebg:rgba(4,12,19,.72); --stageborder:#143547; --nodebg:rgba(8,26,38,.92); --detailbg:rgba(6,20,30,.97);
12
- --border:#1e4a5e; --done:#2fe08a; --prog:#ffc74d; --next:#3ee7ff; --plan:#2d5a72; --planTxt:#7fb3c6; --prob:#ff5d5d;
12
+ --border:#1e4a5e; --done:#2fe08a; --prog:#ffc74d; --next:#3ee7ff; --plan:#2d5a72; --planTxt:#7fb3c6; --prob:#ff5d5d; --unk:#7d8f99;
13
13
  --edge:#2b6e8a; --edgeplan:#264d61; --elbl:#3d7f9c; --ddtxt:#7fb3c6; --now:#cfeefb; --tgt:#8fd8ec; --src:#4fbf8d;}
14
14
  *{box-sizing:border-box;margin:0;padding:0}
15
15
  body{font-family:"SF Mono",Menlo,Consolas,ui-monospace,monospace;background:var(--bg);color:var(--nm);overflow-x:hidden}
@@ -60,6 +60,8 @@ svg{width:100%;height:100%;display:block}
60
60
  .s-plan .tt,.s-plan .pp{fill:var(--planTxt)}
61
61
  .s-prob rect{stroke:var(--prob);filter:drop-shadow(0 0 8px rgba(255,93,93,.6))}
62
62
  .s-prob .tt,.s-prob .pp{fill:var(--prob)}
63
+ .s-unk rect{stroke:var(--unk);stroke-dasharray:1 3}
64
+ .s-unk .tt,.s-unk .pp{fill:var(--unk)}
63
65
  .mode-t .nd rect{stroke:var(--next)!important;stroke-dasharray:none!important;filter:drop-shadow(0 0 8px rgba(62,231,255,.5))!important}
64
66
  .mode-t .nd .tt,.mode-t .nd .pp{fill:var(--next)!important}
65
67
  .edge{stroke:var(--edge);stroke-width:1.3;fill:none;marker-end:url(#ar);cursor:help}
@@ -119,7 +121,7 @@ svg{width:100%;height:100%;display:block}
119
121
  html[data-skin="blueprint"]{--bg:#eef3f9;--grid:rgba(31,119,180,.10);--halo:rgba(31,119,180,.08);
120
122
  --cyan:#1f77b4;--sec:#5a6b7d;--ter:#6b7b8c;--ter2:#5a6b7d;--nm:#16273a;
121
123
  --stagebg:#ffffff;--stageborder:rgba(31,119,180,.25);--nodebg:#ffffff;--detailbg:#ffffff;--border:rgba(31,119,180,.35);
122
- --done:#1f9d57;--prog:#c07a12;--next:#1f77b4;--plan:#8592a3;--planTxt:#5a6b7d;--prob:#d1453b;
124
+ --done:#1f9d57;--prog:#c07a12;--next:#1f77b4;--plan:#8592a3;--planTxt:#5a6b7d;--prob:#d1453b;--unk:#8a97a3;
123
125
  --edge:#8aa0b4;--edgeplan:#b9c6d3;--elbl:#5a6b7d;--ddtxt:#40536a;--now:#16273a;--tgt:#1f5f8a;--src:#1f9d57}
124
126
  html[data-skin="blueprint"] h1{text-shadow:none}
125
127
  html[data-skin="blueprint"] .s-done rect,html[data-skin="blueprint"] .s-prog rect,html[data-skin="blueprint"] .s-next rect,html[data-skin="blueprint"] .s-prob rect{filter:none}
@@ -160,6 +162,7 @@ html[data-skin="blueprint"] button.on{text-shadow:none}
160
162
  <span><i style="background:#3ee7ff"></i><span id="lg-next"></span></span>
161
163
  <span><i style="background:#2d5a72"></i><span id="lg-plan"></span></span>
162
164
  <span><i style="background:#ff5d5d"></i><span id="lg-prob"></span></span>
165
+ <span><i style="background:#7d8f99"></i><span id="lg-unk"></span></span>
163
166
  <span class="hint" id="lg-hint"></span>
164
167
  </div>
165
168
  <div id="detail"></div>
@@ -176,6 +179,7 @@ var STRINGS={
176
179
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"re-arrange",
177
180
  printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", exportLayout:"Export layout JSON", print:"Print",
178
181
  legDone:"DONE (proven)", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
182
+ legUnk:"UNKNOWN (no curated state)", stUnk:"?",
179
183
  legHint:"— animated arrow = active flow · dashed = to build",
180
184
  crumbRoot:"Context", crumbLevel:"C4", lvlContext:"CONTEXT",
181
185
  lvlNames:{context:"Context",container:"Container",component:"Components",leaf:"Leaves"},
@@ -195,6 +199,7 @@ var STRINGS={
195
199
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"ridisponi",
196
200
  printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", exportLayout:"Esporta layout JSON", print:"Stampa",
197
201
  legDone:"FATTO (provato)", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
202
+ legUnk:"IGNOTO (stato non curato)", stUnk:"?",
198
203
  legHint:"— freccia animata = flusso attivo · tratteggio = da costruire",
199
204
  crumbRoot:"Contesto", crumbLevel:"C4", lvlContext:"CONTESTO",
200
205
  lvlNames:{context:"Contesto",container:"Container",component:"Componenti",leaf:"Foglie"},
@@ -219,7 +224,7 @@ var CATALOG_THRESHOLD=24; // group of same-category siblings above this collapse
219
224
  var LABEL_AUTO_MAX=14; // above this many arrows in the current level, fixed labels would knot the view
220
225
  var LABELS=null; // null = auto (by arrow count); true/false = user override for this session
221
226
  var SKINS=[["holo","◆ holo"],["blueprint","▭ blueprint"]], SKIN_IDS=["holo","blueprint"];
222
- var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob"};
227
+ var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob",unknown:"unk"};
223
228
  var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false
224
229
  var VBOX={}; // last fitted viewBox per level — frozen while dragging so the canvas does not rescale
225
230
  function $(i){return document.getElementById(i)}
@@ -258,9 +263,9 @@ function collapseCatalogs(pid,kids){
258
263
  }
259
264
  function makeCatalogNode(pid,g){
260
265
  // Aggregate status: worst-of-a-few heuristic — problem > next > in-progress > planned > done.
261
- var rank={problem:5,next:4,"in-progress":3,planned:2,done:1},best=0,bestKey="done",done=0,tot=g.members.length;
266
+ var rank={problem:5,next:4,"in-progress":3,planned:2,unknown:1.5,done:1},best=0,bestKey="unknown",done=0,tot=g.members.length;
262
267
  for(var i=0;i<g.members.length;i++){
263
- var s2=g.members[i].status2||(g.members[i].status==="planned"?"planned":"done");
268
+ var s2=g.members[i].status2||(g.members[i].status==="planned"?"planned":"unknown");
264
269
  if((rank[s2]||0)>best){best=rank[s2]||0;bestKey=s2;}
265
270
  if(s2==="done")done++;
266
271
  }
@@ -371,11 +376,11 @@ function draw(animate){
371
376
  }
372
377
  for(var j=0;j<kids.length;j++){
373
378
  var n=kids[j],p=pos[n.id];
374
- var st=STMAP[n.status2]||(n.status==="planned"?"plan":"done");
379
+ var st=STMAP[n.status2]||(n.status==="planned"?"plan":"unk");
375
380
  var pulse=(st==="next"||st==="prog")&&mode!=="target"&&!n.__catalog;
376
381
  var isCat=!!n.__catalog,dr=!isCat&&hasKids(n.id),cls="nd s-"+st+(dr?" drill":"")+(isCat?" cat":"")+(pulse?" pulse":"");
377
- var desc=isCat?(n.count+" "+STR.rosterCount):(mode==="target"?(n.target||n.func||""):(n.current||n.func||n.tech||""));
378
- var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":(n.tech||""))));
382
+ var desc=isCat?(n.count+" "+STR.rosterCount):(mode==="target"?(n.target||n.func||""):(n.current||n.func||""));
383
+ var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":STR.stUnk)));
379
384
  // the title runs until the badge, so reserve the badge's actual width (9px text ≈ 5.4px/char)
380
385
  // instead of a flat 70px — a fixed reserve clipped names that had all the room they needed
381
386
  var tmax=Math.floor((p.w-34-String(badge).length*5.4)/6.7),tt=n.name;
@@ -414,12 +419,12 @@ function draw(animate){
414
419
  // its members), so the tally never changes when the catalogue threshold does.
415
420
  var cnt={},tot=0,sum=0,nc=0,st3;
416
421
  for(j=0;j<realKids.length;j++){
417
- st3=STMAP[realKids[j].status2]||(realKids[j].status==="planned"?"plan":"done");
422
+ st3=STMAP[realKids[j].status2]||(realKids[j].status==="planned"?"plan":"unk");
418
423
  cnt[st3]=(cnt[st3]||0)+1;tot++;
419
424
  if(realKids[j].completion!=null){sum+=(+realKids[j].completion||0);nc++;}
420
425
  }
421
426
  if(tot){
422
- var DOT={done:"#2fe08a",prog:"#ffc74d",next:"#3ee7ff",plan:"#2d5a72",prob:"#ff5d5d"},ord=["done","prog","next","plan","prob"],agg="";
427
+ var DOT={done:"#2fe08a",prog:"#ffc74d",next:"#3ee7ff",plan:"#2d5a72",prob:"#ff5d5d",unk:"#7d8f99"},ord=["done","prog","next","plan","prob","unk"],agg="";
423
428
  for(j=0;j<ord.length;j++)if(cnt[ord[j]])agg+='<i style="background:'+DOT[ord[j]]+'"></i>'+cnt[ord[j]];
424
429
  if(nc)agg+=' &nbsp;'+esc(STR.aggProgress)+' <b>'+Math.round(sum/nc)+'%</b>';
425
430
  cb='<span class="agg">'+agg+'</span>'+cb;
@@ -461,7 +466,7 @@ function cssv(v){try{return getComputedStyle(document.documentElement).getProper
461
466
 
462
467
  function showDetail(n){
463
468
  if(n&&n.__catalog)return showRoster(n);
464
- var src=srcOf(n),cur=n.current||n.func||n.tech||"",showSrc=src&&String(cur).indexOf(src)<0;
469
+ var src=srcOf(n),cur=n.current||n.func||"",showSrc=src&&String(cur).indexOf(src)<0;
465
470
  $("detail").innerHTML='<span class="cl" id="dc">'+esc(STR.detClose)+'</span><h3>'+esc(n.name)+' <span class="cat">· '+esc(n.category||n.kind||"")+(n.tech?" · "+esc(n.tech):"")+'</span></h3>'
466
471
  +(n.func?'<div class="row"><span class="lbl">'+esc(STR.detFunc)+'</span><span class="func">'+esc(n.func)+'</span></div>':'')
467
472
  +(cur&&cur!==n.func?'<div class="row"><span class="lbl">'+esc(STR.detNow)+'</span><span class="now">'+esc(cur)+'</span></div>':'')
@@ -482,7 +487,7 @@ function showRoster(cat){
482
487
  $("detail").innerHTML=head;$("detail").style.display="block";
483
488
  var dc=$("dc");if(dc)dc.addEventListener("click",function(){$("detail").style.display="none";});
484
489
  function rowHtml(m){
485
- var d=m.current||m.func||m.tech||"";
490
+ var d=m.current||m.func||"";
486
491
  var pct=m.completion!=null?(m.completion+"%"):(m.statusWord||"");
487
492
  return '<span class="ritem">'+(pct?'<span class="rp">'+esc(pct)+'</span>':'')
488
493
  +'<span class="rn">'+esc(m.name)+'</span>'
@@ -587,7 +592,7 @@ function applyStrings(){
587
592
  set("pexp",STR.printExport);set("pexpSvg",STR.exportSvg);set("pexpPng",STR.exportPng);
588
593
  set("pexpLayout",STR.exportLayout);set("pexpPrint",STR.print);
589
594
  set("lg-done",STR.legDone);set("lg-prog",STR.legProg);set("lg-next",STR.legNext);
590
- set("lg-plan",STR.legPlan);set("lg-prob",STR.legProb);set("lg-hint",STR.legHint);
595
+ set("lg-plan",STR.legPlan);set("lg-prob",STR.legProb);set("lg-unk",STR.legUnk);set("lg-hint",STR.legHint);
591
596
  }
592
597
  // ---- FEATURE #2: PRINT / EXPORT (all client-side, offline) ----
593
598
  function exportBaseName(){
@@ -604,7 +609,7 @@ function collectSvgCss(){
604
609
  if(!rules)continue;
605
610
  for(j=0;j<rules.length;j++){var r=rules[j],t=r.cssText||"";
606
611
  // keep rules that target svg-internal classes/elements + the color vars on :root/html
607
- if(/\.(nd|edge|edgehit|elbl|s-done|s-prog|s-next|s-plan|s-prob|mode-t|tt|kk|dd|pp|plus|cat)\b/.test(t)
612
+ if(/\.(nd|edge|edgehit|elbl|s-done|s-prog|s-next|s-plan|s-prob|s-unk|mode-t|tt|kk|dd|pp|plus|cat)\b/.test(t)
608
613
  ||/(^|[\s,])svg\b/.test(t)||/:root|html\[data-skin/.test(t)){
609
614
  // drop animations for a static export (keepdefs stable)
610
615
  out.push(t);
@@ -617,7 +622,7 @@ function collectSvgCss(){
617
622
  // with the on-screen palette. Returns a "--k:v;--k:v" inline-style string.
618
623
  var SVG_VARS=["--bg","--grid","--halo","--cyan","--sec","--ter","--ter2","--nm",
619
624
  "--stagebg","--stageborder","--nodebg","--detailbg","--border","--done","--prog",
620
- "--next","--plan","--planTxt","--prob","--edge","--edgeplan","--elbl","--ddtxt",
625
+ "--next","--plan","--planTxt","--prob","--unk","--edge","--edgeplan","--elbl","--ddtxt",
621
626
  "--now","--tgt","--src"];
622
627
  function resolvedVars(){
623
628
  var cs;try{cs=getComputedStyle(document.documentElement);}catch(e){return "";}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-arch",
3
- "version": "0.5.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",