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 +29 -1
- package/lib/check.mjs +22 -1
- package/lib/cluster.mjs +5 -2
- package/lib/describe.mjs +48 -7
- package/lib/docmap.mjs +160 -0
- package/lib/enrich.mjs +13 -4
- package/lib/gen.mjs +51 -10
- package/lib/init.mjs +54 -9
- package/lib/lang.mjs +102 -0
- package/lib/schema/c4-model.schema.json +5 -3
- package/lib/validate.mjs +66 -0
- package/lib/viewer/c4-hologram.html +20 -15
- package/package.json +1 -1
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`
|
|
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)
|
|
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 `
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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(
|
|
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
|
-
|
|
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 ?
|
|
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.
|
|
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',
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
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",
|
package/lib/validate.mjs
ADDED
|
@@ -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="
|
|
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":"
|
|
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":"
|
|
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||
|
|
378
|
-
var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":
|
|
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":"
|
|
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+=' '+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||
|
|
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||
|
|
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.
|
|
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",
|