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