forma-arch 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,10 +29,29 @@ npx forma-arch <command> # or: npm i -D forma-arch
29
29
  | `forma check` | Deterministic drift check — **fails if the model no longer matches the code** |
30
30
  | `forma doc` | Project the arc42 scaffold (`ARCHITECTURE.scaffold.md`), or `--attach <file>` to inject a governed block into an existing doc |
31
31
  | `forma serve` | Open the live explorer at `http://localhost:4173` |
32
+ | `forma verify` | Refresh status from live GitHub issues through your `gh` CLI — the **only** networked command |
32
33
 
33
- **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 (`--no-cluster` to disable).
34
+ **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).
34
35
 
35
- **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. 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; `--enricher anthropic|openai|ollama`).
36
+ **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.
37
+
38
+ ```json
39
+ { "nodes": { "engine": { "status2": "in-progress", "completion": 60, "statusWord": "v2 in progress",
40
+ "current": "Live on ACA: RAG + citations. Hardening this week.",
41
+ "target": "Multi-surface substrate with client-ready output.",
42
+ "verify": { "source": "ADR-040 on main" }, "issues": ["#534"] } } }
43
+ ```
44
+
45
+ **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.
46
+
47
+ **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:
48
+
49
+ | `--enricher` | Use it when | Network |
50
+ |---|---|---|
51
+ | `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 |
52
+ | `anthropic` (default) | Headless / CI, with `ANTHROPIC_API_KEY`. | REST |
53
+ | `openai` | Same, with `OPENAI_API_KEY`. | REST |
54
+ | `ollama` | Sensitive repos: a local model, nothing leaves the machine. | localhost |
36
55
 
37
56
  ## How it fits together
38
57
 
@@ -59,9 +78,10 @@ The viewer is a live C4 map, not a static picture:
59
78
 
60
79
  - **Click any box** to read its explanation — what it does, current state, target, verification source — at *every* level, from context down to a leaf.
61
80
  - **Double-click a box** (or its `[+] DRILL`) to descend into it; **BACK**, the breadcrumb, or `ESC` climb back out.
62
- - **Drag boxes** to lay out the view your way; **RESET LAYOUT** restores the auto-arrangement.
63
- - **Hover an arrow** to reveal its relationship label.
64
- - **PRINT / EXPORT** to SVG or PNG for docs and slides.
81
+ - **Drag boxes** to lay out the view your way; **RESET LAYOUT** restores the arrangement (your curated hints if the topology has them, the automatic one otherwise). To keep a layout, drag it, pick **Export layout JSON**, and paste the result under `"layout"` in the topology — `gen` carries it into `meta.layout` and the viewer pins those boxes, auto-arranging everything else clear of them.
82
+ - **Arrow labels** are painted on the diagram while the level stays readable (≤14 arrows) and turn off above that; **LABELS** forces them on or off, and hovering an arrow always reveals its label.
83
+ - **PRINT / EXPORT** to SVG or PNG for docs and slides — exported arrows carry their labels.
84
+ - The breadcrumb names the C4 level you are on (`C4-L1 · CONTEXT` → `C4-L3 · COMPONENTS`).
65
85
 
66
86
  ## Skins
67
87
 
package/bin/forma.mjs CHANGED
@@ -12,7 +12,7 @@ const LIB = join(HERE, '..', 'lib')
12
12
  const cmd = process.argv[2]
13
13
  const rest = process.argv.slice(3)
14
14
 
15
- const MAP = { init: 'init.mjs', gen: 'gen.mjs', check: 'check.mjs', doc: 'doc.mjs', serve: 'serve.mjs' }
15
+ const MAP = { init: 'init.mjs', gen: 'gen.mjs', check: 'check.mjs', doc: 'doc.mjs', serve: 'serve.mjs', verify: 'verify.mjs' }
16
16
 
17
17
  if (cmd === '-v' || cmd === '--version') {
18
18
  const pkg = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf-8'))
@@ -28,6 +28,7 @@ Usage: forma <command> [--repo <path>]
28
28
  check deterministic drift check — fails if the model no longer matches the code
29
29
  doc project the arc42 scaffold (ARCHITECTURE.scaffold.md) from the model
30
30
  serve open the live explorer at http://localhost:4173
31
+ verify refresh status from live GitHub issues via your gh CLI (the only networked command)
31
32
 
32
33
  The file contract is lib/schema/c4-model.schema.json. Enrichment (curate the topology, write the
33
34
  arc42 prose) is model-agnostic — any agent edits the same JSON/Markdown.`)
package/lib/check.mjs CHANGED
@@ -75,17 +75,39 @@ for (const p of topo.plannedLeaves || []) if (p.sourceMustContain) {
75
75
 
76
76
  // 6) attach-mode doc-block freshness — if docPath carries forma markers, the region between them
77
77
  // must equal a freshly rendered block. Opt-in: no markers → nothing runs. No LLM, no network.
78
- const docRel = model.source && model.source.docPath
79
- if (docRel && existsSync(rp(docRel))) {
78
+ // Governs docPath AND every doc registered by `forma doc --attach` (source.attachedDocs): a block
79
+ // injected into a file no gate looks at is the false-green this whole command exists to kill.
80
+ const src = model.source || {}
81
+ const registered = new Set(src.attachedDocs || []) // registry membership PROVES a block was injected
82
+ const governed = [...new Set([src.docPath, ...registered].filter(Boolean))]
83
+ const deregister = 'remove it from source.attachedDocs if the block is gone for good'
84
+ for (const docRel of governed) {
85
+ if (!existsSync(rp(docRel))) {
86
+ // docPath may simply never have been attached; a REGISTERED doc that vanished is drift.
87
+ if (registered.has(docRel)) fail(`DOC BLOCK: ${docRel} is registered in source.attachedDocs but the file is gone — ${deregister}.`)
88
+ continue
89
+ }
80
90
  const text = readFileSync(rp(docRel), 'utf-8')
81
91
  const inner = extractBetween(text)
82
92
  if (inner != null) {
83
93
  if (norm(inner) !== norm(renderBlock(model, { repo: REPO }))) fail(`DOC BLOCK DRIFT: ${docRel} forma block is stale — run \`forma doc --attach\`.`)
84
94
  } else if (text.includes('<!-- forma:begin') || text.includes('<!-- forma:end')) {
85
95
  fail(`DOC BLOCK: malformed forma markers in ${docRel} (begin/end mismatch).`)
96
+ } else if (registered.has(docRel)) {
97
+ // Both markers deleted: the generated block stopped being governed while its (now frozen)
98
+ // text keeps shipping to readers. Exactly the false green attachedDocs exists to prevent.
99
+ fail(`DOC BLOCK: ${docRel} is registered in source.attachedDocs but has no forma markers — re-run \`forma doc --attach\`, or ${deregister}.`)
86
100
  }
87
101
  }
88
102
 
103
+ // 7) the status overlay decorates nodes by id: an id that no longer resolves is drift (the node was
104
+ // renamed or deleted and the curated state was left behind). Form only — never the prose itself.
105
+ if (src.statusPath && existsSync(rp(src.statusPath))) {
106
+ let ov = null
107
+ try { ov = JSON.parse(readFileSync(rp(src.statusPath), 'utf-8')) } catch (e) { fail(`status overlay: ${src.statusPath} is not valid JSON: ${(e && e.message) || e}`) }
108
+ if (ov) for (const id of Object.keys(ov.nodes || {})) if (!byId.has(id)) fail(`status overlay: "${id}" is not a node in the model — ${src.statusPath} is stale.`)
109
+ }
110
+
89
111
  // SOFT: LLM-enriched prose whose inputs changed is only a reminder, never a gate failure (structure
90
112
  // is the gate; enrichment freshness is advisory). check never calls the LLM — it recomputes the hash.
91
113
  for (const n of model.nodes || []) {
package/lib/doc.mjs CHANGED
@@ -83,7 +83,18 @@ if (attachIdx > -1) {
83
83
  catch { console.error('[forma doc] --attach: malformed forma markers in ' + relative(REPO, target) + ' — fix by hand'); process.exit(1) }
84
84
  mkdirSync(join(target, '..'), { recursive: true })
85
85
  writeFileSync(target, out)
86
- console.log(`[forma doc] spliced generated block into ${relative(REPO, target)} — ${containers.length} container(s); prose outside the markers untouched.`)
86
+ const rel = relative(REPO, target)
87
+ console.log(`[forma doc] spliced generated block into ${rel} — ${containers.length} container(s); prose outside the markers untouched.`)
88
+ // Register the target so `forma check` governs THIS file too, not just source.docPath — an
89
+ // injected block nobody gates is exactly the false-green forma exists to kill. `gen` carries
90
+ // the registry forward across regens.
91
+ const known = m.source.attachedDocs || []
92
+ if (rel !== m.source.docPath && !known.includes(rel)) {
93
+ m.source.attachedDocs = [...known, rel].sort()
94
+ writeFileSync(MODEL, JSON.stringify(m, null, 2) + '\n')
95
+ console.log(`[forma doc] registered ${rel} in ${relative(REPO, MODEL)} — \`forma check\` now governs its block.`)
96
+ console.log('[forma doc] commit the model: the registry lives in it, and a lost model un-governs the doc.')
97
+ }
87
98
  process.exit(0)
88
99
  }
89
100
  writeFileSync(OUT, md)
package/lib/enrich.mjs CHANGED
Binary file
package/lib/gen.mjs CHANGED
@@ -8,23 +8,29 @@ import { execSync } from 'node:child_process'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { containerOf, componentsFor } from './cluster.mjs'
10
10
  import { makeDescribeCtx, resolveDescription } from './describe.mjs'
11
- import { loadCache, mergeCache, enrich } from './enrich.mjs'
11
+ import { loadCache, mergeCache, enrich, agentPlan, applyFills } from './enrich.mjs'
12
12
 
13
13
  const HERE = dirname(fileURLToPath(import.meta.url))
14
14
  const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
15
15
  const REPO = arg('--repo', process.cwd())
16
16
  const TOPO = arg('--topology', join(REPO, 'docs/architecture/c4-topology.json'))
17
17
  const OUT = arg('--out', join(REPO, 'docs/architecture/c4-model.json'))
18
- const SCHEMA_VERSION = '1.2.0'
18
+ const SCHEMA_VERSION = '1.3.0'
19
19
  const CLUSTER = !process.argv.includes('--no-cluster') // §2: auto-cluster flat containers; --no-cluster to disable
20
20
  const ENRICH = process.argv.includes('--enrich') // §7: opt-in LLM prose for description holes
21
21
  const ENRICHER = arg('--enricher', 'anthropic')
22
22
  const ENRICH_MODEL = arg('--enrich-model', null)
23
- const CLUSTER_MIN = 8, GROUP_MIN = 3
23
+ const STATUS = arg('--status', join(REPO, 'docs/architecture/c4-status.json')) // curated programme state, optional
24
+ const STATUS_SET = process.argv.includes('--status')
25
+ const APPLY = arg('--enrich-apply', null) // prose written by the agent driving forma
24
26
 
25
27
  const topo = JSON.parse(readFileSync(TOPO, 'utf-8'))
26
28
  const rp = (p) => join(REPO, p)
27
29
  const fail = (m) => { console.error('[gen-c4] FAIL: ' + m); process.exit(1) }
30
+ // §2 thresholds, overridable. Guarded: a non-integer would silently disable clustering
31
+ // (`length >= NaN` is false for every group), so a bad value fails loud instead.
32
+ 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
+ const CLUSTER_MIN = posInt('--cluster-min', 8), GROUP_MIN = posInt('--group-min', 3)
28
34
 
29
35
  function walk(spec) {
30
36
  const dir = rp(spec.dir)
@@ -114,7 +120,9 @@ for (const n of nodes) {
114
120
  if (!n.category) n.category = n.kind === 'leaf' ? (par && par.category) || 'leaf' : n.kind
115
121
  if (!n.status2) n.status2 = n.status === 'planned' ? 'planned' : 'done'
116
122
  if (n.completion == null) n.completion = n.status === 'planned' ? 0 : 100
117
- if (!n.current) { const pth = (n.evidence || []).find((e) => e.type === 'path'); n.current = pth ? `Exists: ${pth.ref}` : (n.tech || '') }
123
+ // `current` is left EMPTY unless curated (topology or status overlay): the viewer falls back to
124
+ // `func`, which since §1a carries the module's real documentation. The old "Exists: <path>"
125
+ // filler restated the evidence path in the one field meant for programme facts.
118
126
  if (n.target == null) n.target = ''
119
127
  }
120
128
 
@@ -123,12 +131,74 @@ for (const n of nodes) {
123
131
  const dctx = makeDescribeCtx({ repo: REPO, byId, descriptions: topo.descriptions || {}, docPath: topo.docPath, containerOf })
124
132
  for (const n of nodes) { const r = resolveDescription(n, dctx); n.func = r.func; n.descSource = r.descSource }
125
133
 
134
+ // §1a-bis) a synthesized component has no doc of its own: before settling for "Groups related
135
+ // files under X", compose its box from its children's docs (first sentence of up to 3, ordered by
136
+ // name — deterministic). descSource stays 'fallback': this is a heuristic, and --enrich may still
137
+ // improve it. Separate pass so it does not depend on components being added after their leaves.
138
+ const firstSentence = (s) => (String(s).match(/^[^.!?]*[.!?]/) || [String(s)])[0].trim()
139
+ for (const n of nodes) {
140
+ if (n.kind !== 'component' || n.descSource !== 'fallback') continue
141
+ const kids = nodes.filter((k) => k.parent === n.id && ['curated', 'docstring', 'readme'].includes(k.descSource))
142
+ .sort((a, b) => (String(a.name) < String(b.name) ? -1 : String(a.name) > String(b.name) ? 1 : 0)).slice(0, 3)
143
+ const txt = kids.map((k) => firstSentence(k.func)).filter(Boolean).join(' ')
144
+ if (txt) n.func = txt.length > 200 ? txt.slice(0, 199) + '…' : txt
145
+ }
146
+
147
+ // §WP-A1) programme-status overlay: the one channel for curated STATE (what is true now, where it
148
+ // must land, what proves it). Code can't know it, so a human/agent writes it in c4-status.json and
149
+ // gen validates only its FORM — ids resolve, fields are known, enums/ranges hold. Never `func`:
150
+ // what a module DOES comes from its docs (§1a), what it's WORTH is programme state.
151
+ const STATUS_FIELDS = new Set(['status2', 'completion', 'statusWord', 'current', 'target', 'verify', 'issues'])
152
+ const STATUS2 = new Set(['done', 'in-progress', 'next', 'planned', 'problem'])
153
+ let statusPath = null
154
+ if (existsSync(STATUS) || STATUS_SET) {
155
+ if (!existsSync(STATUS)) fail(`--status: file missing: ${STATUS}`)
156
+ let ov
157
+ try { ov = JSON.parse(readFileSync(STATUS, 'utf-8')) } catch (e) { fail(`--status: ${STATUS} is not valid JSON: ${(e && e.message) || e}`) }
158
+ let decorated = 0
159
+ for (const [id, patch] of Object.entries(ov.nodes || {})) {
160
+ const target = byId.get(id)
161
+ if (!target) fail(`status overlay: unknown node id "${id}" — it is not in the model (stale overlay?)`)
162
+ for (const [k, v] of Object.entries(patch)) {
163
+ if (k.startsWith('$')) continue // $comment and friends
164
+ 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)' : ''}`)
165
+ if (k === 'status2' && !STATUS2.has(v)) fail(`status overlay: "${id}" status2 "${v}" — expected one of ${[...STATUS2].join('|')}`)
166
+ 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)}`)
167
+ if (k === 'issues') {
168
+ if (!Array.isArray(v)) fail(`status overlay: "${id}" issues must be an array`)
169
+ for (const is of v) if (!/^#?\d+$/.test(String(is))) fail(`status overlay: "${id}" issue ${JSON.stringify(is)} — expected "#123"`)
170
+ }
171
+ target[k] = v
172
+ }
173
+ decorated++
174
+ }
175
+ statusPath = relative(REPO, STATUS)
176
+ console.log(`[gen-c4] status overlay: ${decorated} node(s) decorated from ${statusPath}`)
177
+ }
178
+
126
179
  // §7) enrichment (prose only, never structure/gate). Cache-merge ALWAYS: preserve prior LLM prose
127
180
  // whose inputs are unchanged, so a plain regen doesn't discard it. --enrich additionally fills the
128
181
  // remaining holes over the network (best-effort — a failure keeps the deterministic fallback).
129
182
  const ectx = { repo: REPO, byId, containerOf }
130
- mergeCache(nodes, ectx, loadCache(OUT))
131
- if (ENRICH) {
183
+ mergeCache(nodes, loadCache(OUT))
184
+ // --enrich-apply: prose written by the agent that drives forma (see --enricher agent below).
185
+ // Independent of --enrich, and applied first — an explicit fill outranks anything generated.
186
+ if (APPLY) {
187
+ if (!existsSync(APPLY)) fail(`--enrich-apply: file missing: ${APPLY}`)
188
+ let fills
189
+ try { fills = JSON.parse(readFileSync(APPLY, 'utf-8')).fills } catch (e) { fail(`--enrich-apply: ${APPLY} is not valid JSON: ${(e && e.message) || e}`) }
190
+ if (!Array.isArray(fills)) fail(`--enrich-apply: ${APPLY} must contain {"fills":[{"id","func"}]}`)
191
+ try { console.log(`[gen-c4] --enrich-apply: ${applyFills(nodes, ectx, fills)} fill(s) applied`) }
192
+ catch (e) { fail(String((e && e.message) || e)) }
193
+ }
194
+ if (ENRICH && ENRICHER === 'agent') {
195
+ // No network: emit the work for the agent already in the session, keep the deterministic model.
196
+ const plan = agentPlan(nodes, ectx)
197
+ const planPath = join(dirname(OUT), 'enrich-plan.json')
198
+ writeFileSync(planPath, JSON.stringify({ entries: plan }, null, 2) + '\n')
199
+ console.log(`[gen-c4] --enricher agent: ${plan.length} hole(s) → ${relative(REPO, planPath)} — write the prose yourself, then:`)
200
+ console.log(`[gen-c4] forma gen --enrich-apply ${relative(REPO, join(dirname(OUT), 'enrich-fill.json'))}`)
201
+ } else if (ENRICH) {
132
202
  try { const r = await enrich(nodes, ectx, { provider: ENRICHER, model: ENRICH_MODEL }); console.log(`[gen-c4] --enrich (${ENRICHER}): filled ${r.filled}/${r.holes} description hole(s)`) }
133
203
  catch (e) { console.error('[gen-c4] --enrich skipped: ' + String((e && e.message) || e)) }
134
204
  }
@@ -173,11 +243,26 @@ const gitOpts = { cwd: REPO, stdio: ['ignore', 'pipe', 'ignore'] }
173
243
  try { commit = execSync('git rev-parse HEAD', gitOpts).toString().trim() } catch {}
174
244
  try { branch = execSync('git rev-parse --abbrev-ref HEAD', gitOpts).toString().trim() } catch {}
175
245
 
246
+ // §1b) carry the attach registry forward from the prior model — `forma doc --attach` writes it
247
+ // there, and without this a plain regen would drop it and un-govern the attached docs.
248
+ // The registry lives ONLY in the generated model, so that model has to be committed (or at least
249
+ // kept) for the gate to keep governing. If a prior model is there but unreadable, say so rather
250
+ // than silently ungoverning every attached doc.
251
+ let attachedDocs = []
252
+ if (existsSync(OUT)) {
253
+ try { attachedDocs = JSON.parse(readFileSync(OUT, 'utf-8')).source.attachedDocs || [] }
254
+ catch (e) { console.error(`[gen-c4] WARNING: ${OUT} is unreadable (${(e && e.message) || e}) — any source.attachedDocs registry in it is lost; re-run \`forma doc --attach\` for each attached doc.`) }
255
+ }
256
+
257
+ // generatedAt is the ONE per-run volatile field (a plain `gen` x2 must diff on nothing else).
258
+ // meta.verifiedAt is reserved for `forma verify` (network, opt-in) — gen never writes it.
176
259
  const model = {
177
260
  schemaVersion: SCHEMA_VERSION,
178
261
  generatedAt: new Date().toISOString(),
179
- source: { repo: topo.meta.repo, commit, branch, docPath: topo.docPath, generator: 'gen-c4-model@' + SCHEMA_VERSION },
180
- meta: { ...topo.meta, verifiedAt: new Date().toISOString(), verifyMethod: 'code+topology (gh re-verify optional)' },
262
+ source: { repo: topo.meta.repo, commit, branch, docPath: topo.docPath, generator: 'gen-c4-model@' + SCHEMA_VERSION, ...(attachedDocs.length ? { attachedDocs } : {}), ...(statusPath ? { statusPath } : {}) },
263
+ // topo.layout (optional curated coordinates, keyed by parent id) rides along verbatim: the
264
+ // viewer pins those nodes and auto-arranges the rest. `meta` is the model's free-form area.
265
+ meta: { ...topo.meta, ...(topo.layout ? { layout: topo.layout } : {}), verifyMethod: 'code+topology (gh re-verify optional)' },
181
266
  levels: topo.levels,
182
267
  nodes,
183
268
  edges: topo.edges.map((e) => ({ status: 'current', estatus: e.estatus || 'active', ...e })),
package/lib/init.mjs CHANGED
@@ -22,13 +22,16 @@ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g
22
22
 
23
23
  if (existsSync(OUT) && !FORCE) { console.error(`[forma init] ${relative(REPO, OUT)} already exists — use --force to reseed.`); process.exit(1) }
24
24
 
25
- // 1) detect dominant language by extension count (skipping ignored dirs)
25
+ // 1) detect dominant language by extension count. Skips the SAME dirs the container pass skips
26
+ // (build junk AND data/fixture/doc dirs): counting files that can never become a container is how
27
+ // a repo full of Python fixtures gets detected as a Python repo and then finds no source dir.
26
28
  const counts = {}
27
29
  ;(function walk(dir, depth) {
28
30
  if (depth > 12) return
29
31
  let ents; try { ents = readdirSync(dir) } catch { return }
30
32
  for (const e of ents) {
31
33
  if (IGNORE.has(e) || e.startsWith('.')) continue
34
+ if (SKIP_DIRS.has(e.toLowerCase()) && !KEEP.has(e.toLowerCase())) continue
32
35
  const p = join(dir, e)
33
36
  let st; try { st = statSync(p) } catch { continue }
34
37
  if (st.isDirectory()) walk(p, depth + 1)
@@ -86,7 +89,7 @@ const skipped = []
86
89
  // loose files directly in root → an entry container
87
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 }) }
88
91
 
89
- if (leafSources.length === 0) { console.error('[forma init] found a source root but no directory with source files directly in it. Add leafSources manually.'); process.exit(1) }
92
+ 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) }
90
93
 
91
94
  const topo = {
92
95
  meta: { repo: basename(REPO), stack: lang, seededBy: 'forma init' },
@@ -45,6 +45,17 @@
45
45
  "type": "string",
46
46
  "description": "The arc42 architecture doc this model derives from."
47
47
  },
48
+ "statusPath": {
49
+ "type": "string",
50
+ "description": "Repo-relative curated programme-status overlay applied by `forma gen` (c4-status.json). The gate fails if it decorates a node id the model no longer has."
51
+ },
52
+ "attachedDocs": {
53
+ "type": "array",
54
+ "items": {
55
+ "type": "string"
56
+ },
57
+ "description": "Repo-relative docs carrying an injected forma block (written by `forma doc --attach`). The drift gate governs every one of them, not just docPath."
58
+ },
48
59
  "generator": {
49
60
  "type": "string"
50
61
  }
package/lib/verify.mjs ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ // forma verify — refresh programme status from LIVE GitHub issues, through the user's `gh` CLI.
3
+ // This is the ONLY command that touches the network, and it is opt-in: `gen` and `check` stay
4
+ // deterministic and offline forever (that is the product). It never touches structure — no nodes,
5
+ // no edges, no func — only the state fields of nodes that reference an issue.
6
+ // Usage: node verify.mjs [--repo <path>] [--model <path>] [--gh-repo <owner/repo>] [--gh-cmd <cmd>]
7
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
8
+ import { execFileSync } from 'node:child_process'
9
+ import { join } from 'node:path'
10
+
11
+ const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
12
+ const REPO = arg('--repo', process.cwd())
13
+ const MODEL = arg('--model', join(REPO, 'docs/architecture/c4-model.json'))
14
+ const GH_CMD = arg('--gh-cmd', 'gh') // split on whitespace; the tests point it at a stub
15
+ const LIMIT = arg('--limit', '250')
16
+ const fail = (m) => { console.error('[forma verify] ' + m); process.exit(1) }
17
+
18
+ if (!existsSync(MODEL)) fail(`model missing: ${MODEL} — run \`forma gen\` first.`)
19
+ const model = JSON.parse(readFileSync(MODEL, 'utf-8'))
20
+ const GH_REPO = arg('--gh-repo', (model.meta && model.meta.ghRepo) || null)
21
+ if (!GH_REPO) fail('no target repo: pass --gh-repo <owner/repo>, or set meta.ghRepo in the topology.')
22
+
23
+ // Which nodes claim which issue — from issues[] and from verify.issue.
24
+ const num = (v) => { const m = /^#?(\d+)$/.exec(String(v)); return m ? Number(m[1]) : null }
25
+ const refs = new Map()
26
+ for (const n of model.nodes || []) {
27
+ const claimed = [...(n.issues || []), ...(n.verify && n.verify.issue != null ? [n.verify.issue] : [])]
28
+ for (const raw of claimed) {
29
+ const i = num(raw); if (!i) continue
30
+ if (!refs.has(i)) refs.set(i, [])
31
+ refs.get(i).push(n)
32
+ }
33
+ }
34
+ if (!refs.size) { console.log('[forma verify] no issue references in the model — nothing to verify (add issues[] via the status overlay).'); process.exit(0) }
35
+
36
+ // ONE call for every issue: state for the whole repo, then match locally.
37
+ const parts = String(GH_CMD).split(/\s+/).filter(Boolean)
38
+ let raw
39
+ try {
40
+ raw = execFileSync(parts[0], [...parts.slice(1), 'issue', 'list', '--repo', GH_REPO, '--state', 'all', '--limit', String(LIMIT), '--json', 'number,state'],
41
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] })
42
+ } catch (e) {
43
+ const why = (e && e.code === 'ENOENT') ? `\`${parts[0]}\` not found — install the GitHub CLI or pass --gh-cmd` : String((e && (e.stderr || e.message)) || e).trim()
44
+ console.error(`[forma verify] ${why}`)
45
+ fail('model left untouched.')
46
+ }
47
+ let issues
48
+ try { issues = JSON.parse(raw) } catch { fail(`unexpected output from \`${parts[0]}\` (wanted JSON): ${String(raw).slice(0, 160)}`) }
49
+ if (!Array.isArray(issues)) fail('unexpected output: expected a JSON array of {number,state}.')
50
+
51
+ const ts = new Date().toISOString()
52
+ const seen = new Set()
53
+ let closed = 0, decorated = 0
54
+ for (const it of issues) {
55
+ const n = Number(it.number); seen.add(n)
56
+ if (String(it.state || '').toUpperCase() !== 'CLOSED') continue
57
+ const nodes = refs.get(n); if (!nodes) continue
58
+ closed++
59
+ for (const node of nodes) {
60
+ node.status2 = 'done'; node.completion = 100
61
+ // The badge shows statusWord when there is one, so a curated "NEXT"/"50%" would survive the
62
+ // node turning green — a box claiming both at once. Marking done owns the badge too.
63
+ if (node.statusWord) node.statusWord = '100%'
64
+ const mark = `(#${n} CLOSED` // re-running must not stack prefixes
65
+ if (!String(node.current || '').includes(mark)) {
66
+ node.current = `Closed with evidence ${mark}, gh ${ts}). ${String(node.current || '').trim()}`.trim()
67
+ decorated++
68
+ }
69
+ }
70
+ }
71
+ const unknown = [...refs.keys()].filter((n) => !seen.has(n))
72
+
73
+ model.meta = model.meta || {}
74
+ model.meta.verifiedAt = ts // the ONE field only verify writes — gen must stay single-volatile (R2)
75
+ model.meta.verifyMethod = 'gh live'
76
+ writeFileSync(MODEL, JSON.stringify(model, null, 2) + '\n')
77
+ console.log(`[forma verify] ${GH_REPO}: ${refs.size} referenced issue(s), ${closed} closed → ${decorated} node(s) marked done`)
78
+ if (unknown.length) console.log(`[forma verify] not returned by gh (closed long ago, wrong repo, or beyond --limit ${LIMIT}): ${unknown.map((n) => '#' + n).join(', ')}`)
79
+ console.log(`[forma verify] fact base stamped ${ts} — structure untouched, \`forma check\` unaffected.`)
@@ -64,6 +64,7 @@ svg{width:100%;height:100%;display:block}
64
64
  .edge.plan{stroke:var(--edgeplan);stroke-dasharray:3 6;opacity:.6;marker-end:url(#arp)}
65
65
  .edge.hot{stroke:var(--cyan);stroke-width:2.6;opacity:1}
66
66
  .edgehit{stroke:transparent;stroke-width:14;fill:none;cursor:help}
67
+ .elbl{font-size:8.2px;fill:var(--elbl);letter-spacing:.04em;pointer-events:none}
67
68
  @keyframes dash{to{stroke-dashoffset:-24}}
68
69
  .nd.pulse rect{animation:pul 2.4s ease-in-out infinite}
69
70
  @keyframes pul{0%,100%{filter:drop-shadow(0 0 8px rgba(62,231,255,.5))}50%{filter:drop-shadow(0 0 18px rgba(62,231,255,.95))}}
@@ -132,12 +133,14 @@ html[data-skin="blueprint"] button.on{text-shadow:none}
132
133
  <button id="bnow" class="on" type="button"></button>
133
134
  <button id="btgt" type="button"></button>
134
135
  <button id="brf" type="button"></button>
136
+ <button id="blbl" type="button"></button>
135
137
  <button id="brl" type="button"></button>
136
138
  <span class="menuwrap">
137
139
  <button id="pexp" type="button" aria-haspopup="true" aria-expanded="false"></button>
138
140
  <span id="pmenu" role="menu">
139
141
  <button id="pexpSvg" type="button" role="menuitem"></button>
140
142
  <button id="pexpPng" type="button" role="menuitem"></button>
143
+ <button id="pexpLayout" type="button" role="menuitem"></button>
141
144
  <button id="pexpPrint" type="button" role="menuitem"></button>
142
145
  </span>
143
146
  </span>
@@ -165,9 +168,10 @@ var STRINGS={
165
168
  en:{
166
169
  subhelp:"click = explain (any level) · double-click or [+] = drill in · drag to move · hover an arrow = label · ESC/BACK",
167
170
  back:"← BACK", now:"CURRENT STATE", tgt:"TARGET STATE", reverify:"RE-VERIFY",
171
+ labels:"LABELS", labelsTitle:"arrow labels (auto-off above 14 arrows)",
168
172
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"re-arrange",
169
- printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", print:"Print",
170
- legDone:"DONE", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
173
+ printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", exportLayout:"Export layout JSON", print:"Print",
174
+ legDone:"DONE (proven)", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
171
175
  legHint:"— animated arrow = active flow · dashed = to build",
172
176
  crumbRoot:"Context", crumbLevel:"C4", lvlContext:"CONTEXT",
173
177
  lvlNames:{context:"Context",container:"Container",component:"Components",leaf:"Leaves"},
@@ -175,7 +179,7 @@ var STRINGS={
175
179
  detTgt:"Target state (project)", detSrc:"Verification source", detIssue:"Issue",
176
180
  detTarget:"TARGET",
177
181
  rosterSearch:"filter…", rosterCount:"members", rosterEmpty:"no match",
178
- stampBase:"Fact base:", stampMethod:"method:", stampVerify:"code+topology",
182
+ stampBase:"Fact base:", stampMethod:"method:", stampVerify:"code+topology", staticSrc:"(static source)",
179
183
  errEmpty:"Model unreachable or empty.",
180
184
  errNotFound:"Model not found (./c4-model.json). Open via `forma serve` or inject window.__C4_MODEL__.",
181
185
  errUnavail:"Model unavailable."
@@ -183,9 +187,10 @@ var STRINGS={
183
187
  it:{
184
188
  subhelp:"click = spiega (ogni livello) · doppio-click o [+] = entra · trascina per spostare · hover su una freccia = etichetta · ESC/BACK",
185
189
  back:"← BACK", now:"STATO ATTUALE", tgt:"STATO FINITO", reverify:"RE-VERIFICA",
190
+ labels:"ETICHETTE", labelsTitle:"etichette sugli archi (spente sopra i 14 archi)",
186
191
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"ridisponi",
187
- printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", print:"Stampa",
188
- legDone:"FATTO", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
192
+ printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", exportLayout:"Esporta layout JSON", print:"Stampa",
193
+ legDone:"FATTO (provato)", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
189
194
  legHint:"— freccia animata = flusso attivo · tratteggio = da costruire",
190
195
  crumbRoot:"Contesto", crumbLevel:"C4", lvlContext:"CONTESTO",
191
196
  lvlNames:{context:"Contesto",container:"Container",component:"Componenti",leaf:"Foglie"},
@@ -193,7 +198,7 @@ var STRINGS={
193
198
  detTgt:"Stato finito (progetto)", detSrc:"Fonte verifica", detIssue:"Issue",
194
199
  detTarget:"TARGET",
195
200
  rosterSearch:"filtra…", rosterCount:"elementi", rosterEmpty:"nessun risultato",
196
- stampBase:"Base fatti:", stampMethod:"metodo:", stampVerify:"code+topology",
201
+ stampBase:"Base fatti:", stampMethod:"metodo:", stampVerify:"code+topology", staticSrc:"(fonte statica)",
197
202
  errEmpty:"Modello non raggiungibile o vuoto.",
198
203
  errNotFound:"Modello non trovato (./c4-model.json). Apri via `forma serve` o inietta window.__C4_MODEL__.",
199
204
  errUnavail:"Modello non disponibile."
@@ -207,13 +212,18 @@ function pickLang(model){
207
212
  return STRINGS[cand]?cand:"en";
208
213
  }
209
214
  var CATALOG_THRESHOLD=24; // group of same-category siblings above this collapses to one catalogue node
215
+ var LABEL_AUTO_MAX=14; // above this many arrows in the current level, fixed labels would knot the view
216
+ var LABELS=null; // null = auto (by arrow count); true/false = user override for this session
210
217
  var SKINS=[["holo","◆ holo"],["blueprint","▭ blueprint"]], SKIN_IDS=["holo","blueprint"];
211
218
  var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob"};
212
- var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={};
219
+ var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false;
213
220
  function $(i){return document.getElementById(i)}
214
221
  function esc(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/"/g,"&quot;")}
215
222
  function setSkin(s){var k=SKIN_IDS.indexOf(s)>-1?s:"holo";document.documentElement.dataset.skin=k;var el=$("skin");if(el)el.value=k;}
216
- function keyOf(){return stack.join(">")||"root";}
223
+ // Layout key = the id of the level's parent ("root" at the top). Stable and documentable: it is
224
+ // exactly the key used by meta.layout hints and by the exported layout JSON.
225
+ function keyOf(){return stack[stack.length-1]||"root";}
226
+ function hintsFor(k){return (M&&M.meta&&M.meta.layout&&M.meta.layout[k])||null;}
217
227
  function childrenOf(pid){var o=[];for(var i=0;i<M.nodes.length;i++){var n=M.nodes[i];if((pid==null&&!n.parent)||n.parent===pid)o.push(n);}return o;}
218
228
  // FEATURE #4: collapse large homogeneous catalogs.
219
229
  // Given real children, returns render items where any group of >CATALOG_THRESHOLD
@@ -260,8 +270,25 @@ function nameOf(id){var n=node(id);return n?n.name:id;}
260
270
  function srcOf(n){if(n.verify){if(typeof n.verify==="string")return n.verify;if(n.verify.source)return n.verify.source;if(n.verify.ref)return n.verify.ref;if(n.verify.issue)return "#"+n.verify.issue;}
261
271
  var ev=(n.evidence||[]).filter(function(e){return e.type==="doc"||e.type==="path";})[0];return ev?ev.ref:((M.meta&&M.meta.verifyMethod)||"");}
262
272
 
263
- function seedLayout(kids){
264
- var NW=228,NH=106,pos={},i,hub=null;
273
+ var NW=228,NH=118; // NH fits 3 description lines without hitting the [+] DRILL
274
+ // Curated coordinates from meta.layout win; everything else keeps the automatic arrangement,
275
+ // placed BELOW the pinned block so a partial hint set can never overlap generated nodes.
276
+ function seedLayout(kids,hints){
277
+ var pinned=[],free=[],i;
278
+ for(i=0;i<kids.length;i++)(hints&&hints[kids[i].id]?pinned:free).push(kids[i]);
279
+ if(!pinned.length)return autoLayout(kids);
280
+ var pos={},maxX=0,maxY=0,h;
281
+ for(i=0;i<pinned.length;i++){var q=hints[pinned[i].id];
282
+ h={x:+q.x||0,y:+q.y||0,w:+q.w||NW,h:+q.h||NH};pos[pinned[i].id]=h;
283
+ maxX=Math.max(maxX,h.x+h.w);maxY=Math.max(maxY,h.y+h.h);}
284
+ var W=Math.max(1020,maxX+64),H=Math.max(560,maxY+58);
285
+ if(free.length){var sub=autoLayout(free),oy=maxY+58;
286
+ for(i=0;i<free.length;i++){var p=sub.pos[free[i].id];pos[free[i].id]={x:p.x,y:oy+p.y,w:p.w,h:p.h};}
287
+ W=Math.max(W,sub.W);H=Math.max(H,oy+sub.H);}
288
+ return {W:W,H:H,pos:pos};
289
+ }
290
+ function autoLayout(kids){
291
+ var pos={},i,hub=null;
265
292
  for(i=0;i<kids.length;i++)if(kids[i].kind==="system"){hub=kids[i];break;}
266
293
  if(hub&&kids.length<=10){
267
294
  var W=1080,H=660,cx=W/2,cy=H/2;
@@ -280,9 +307,9 @@ function seedLayout(kids){
280
307
  pos[kids[i].id]={x:offx+c*(NW+gx),y:py+r*(NH+gy),w:NW,h:NH};}
281
308
  return {W:W,H:H,pos:pos};
282
309
  }
283
- function layoutFor(kids){var k=keyOf();if(!POS[k])POS[k]=seedLayout(kids);
310
+ function layoutFor(kids){var k=keyOf();if(!POS[k])POS[k]=seedLayout(kids,hintsFor(k));
284
311
  // make sure every current kid has a slot (model may have changed)
285
- var lay=POS[k];for(var i=0;i<kids.length;i++)if(!lay.pos[kids[i].id]){var s=seedLayout(kids);POS[k]=s;return s;}return lay;}
312
+ var lay=POS[k];for(var i=0;i<kids.length;i++)if(!lay.pos[kids[i].id]){var s=seedLayout(kids,hintsFor(k));POS[k]=s;return s;}return lay;}
286
313
 
287
314
  function draw(animate){
288
315
  if(!M)return;
@@ -300,11 +327,17 @@ function draw(animate){
300
327
  var s='<svg viewBox="0 0 '+lay.W+' '+lay.H+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t":"")+'">'
301
328
  +'<defs><marker id="ar" markerWidth="9" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,9 3.5,0 7" fill="'+cssv("--edge")+'"/></marker>'
302
329
  +'<marker id="arp" markerWidth="9" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,9 3.5,0 7" fill="'+cssv("--edgeplan")+'"/></marker></defs>';
330
+ // fixed arrow labels: on by default while the level stays readable, else hover-only (the tooltip
331
+ // is always there). A level with 40 arrows would be unreadable with every label painted at once.
332
+ var showLbl=(LABELS===null)?(edges.length<=LABEL_AUTO_MAX):LABELS;
333
+ var blbl=$("blbl");if(blbl)blbl.className=showLbl?"on":"";
303
334
  for(var i=0;i<edges.length;i++){
304
335
  var a=pos[edges[i].from],b=pos[edges[i].to];if(!a||!b)continue;
305
- var d=edgePath(a,b),cl=(edges[i].estatus==="to-build"?"plan":"flow");
306
- s+='<path class="edge '+cl+'" data-e="'+i+'" d="'+d+'"/>';
307
- s+='<path class="edgehit" data-e="'+i+'" data-label="'+esc(edges[i].label||"")+'" d="'+d+'"/>';
336
+ var e=edgePath(a,b),cl=(edges[i].estatus==="to-build"?"plan":"flow");
337
+ s+='<path class="edge '+cl+'" data-e="'+i+'" d="'+e.d+'"/>';
338
+ s+='<path class="edgehit" data-e="'+i+'" data-label="'+esc(edges[i].label||"")+'" d="'+e.d+'"/>';
339
+ if(showLbl&&edges[i].label){var lb=String(edges[i].label);if(lb.length>28)lb=lb.slice(0,27)+"…";
340
+ s+='<text class="elbl" x="'+e.mx+'" y="'+(e.my-4)+'" text-anchor="middle">'+esc(lb)+'</text>';}
308
341
  }
309
342
  for(var j=0;j<kids.length;j++){
310
343
  var n=kids[j],p=pos[n.id];
@@ -313,18 +346,21 @@ function draw(animate){
313
346
  var isCat=!!n.__catalog,dr=!isCat&&hasKids(n.id),cls="nd s-"+st+(dr?" drill":"")+(isCat?" cat":"")+(pulse?" pulse":"");
314
347
  var desc=isCat?(n.count+" "+STR.rosterCount):(mode==="target"?(n.target||n.func||""):(n.current||n.func||n.tech||""));
315
348
  var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":(n.tech||""))));
316
- var tmax=Math.floor((p.w-70)/6.7),tt=n.name;if(String(tt).length>tmax)tt=String(tt).slice(0,tmax-1)+"…";
349
+ // the title runs until the badge, so reserve the badge's actual width (9px text ≈ 5.4px/char)
350
+ // instead of a flat 70px — a fixed reserve clipped names that had all the room they needed
351
+ var tmax=Math.floor((p.w-34-String(badge).length*5.4)/6.7),tt=n.name;
352
+ if(String(tt).length>tmax)tt=String(tt).slice(0,Math.max(1,tmax-1))+"…";
317
353
  s+='<g class="'+cls+'" data-id="'+esc(n.id)+'">'
318
354
  +'<rect x="'+p.x+'" y="'+p.y+'" width="'+p.w+'" height="'+p.h+'" rx="10"/>'
319
355
  +'<text class="kk" x="'+(p.x+13)+'" y="'+(p.y+16)+'">'+esc(n.category||n.kind||"")+'</text>'
320
356
  +'<text class="tt" x="'+(p.x+13)+'" y="'+(p.y+33)+'">'+esc(tt)+'</text>'
321
357
  +'<text class="pp" x="'+(p.x+p.w-13)+'" y="'+(p.y+16)+'" text-anchor="end">'+esc(badge)+'</text>';
322
- var words=String(desc).split(" "),line="",ln=0,cpl=Math.floor((p.w-26)/5.4),MAXL=2;
323
- for(var k=0;k<words.length&&ln<MAXL;k++){
324
- if((line+words[k]).length>cpl){s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+ln*12.5)+'">'+esc(line.trim())+'</text>';line=words[k]+" ";ln++;}
325
- else line+=words[k]+" ";}
326
- if(ln<MAXL&&line)s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+ln*12.5)+'">'+esc(line.length>cpl?line.slice(0,cpl-1)+"…":line.trim())+'</text>';
327
- else if(ln===MAXL&&line.trim())s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+ln*12.5)+'">…</text>';
358
+ // Description lines: how many fit comes from the geometry (text starts at y+51, 12.5px apart,
359
+ // and must clear the footer control at h-25), so a curated layout hint with short boxes cannot
360
+ // print through [+] DRILL. wrapDesc is pure and clamps every line to the box width.
361
+ var floorY=p.h-((dr||isCat)?25:6),MAXL=Math.min(3,Math.floor((floorY-51)/12.5)+1);
362
+ var dlines=wrapDesc(desc,Math.floor((p.w-26)/5.4),MAXL);
363
+ for(var q=0;q<dlines.length;q++)s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+q*12.5)+'">'+esc(dlines[q])+'</text>';
328
364
  if(dr)s+='<rect class="drillhit" data-drill="1" x="'+(p.x+p.w-96)+'" y="'+(p.y+p.h-25)+'" width="88" height="20" rx="5"/>'
329
365
  +'<text class="plus" x="'+(p.x+p.w-13)+'" y="'+(p.y+p.h-9)+'" text-anchor="end">[+] DRILL</text>';
330
366
  else if(isCat)s+='<text class="plus" x="'+(p.x+p.w-13)+'" y="'+(p.y+p.h-9)+'" text-anchor="end">[≡] '+esc(String(n.count))+'</text>';
@@ -337,18 +373,37 @@ function draw(animate){
337
373
  st2.classList.toggle("anim",animate!==false);
338
374
  // breadcrumb
339
375
  var chain=[],c=pid;while(c){var nn=node(c);chain.unshift(nn);c=nn?nn.parent:null;}
340
- var lvl=pid?levelName(kids[0]&&kids[0].level):STR.lvlContext;
341
- var cb=STR.crumbLevel+" · "+lvl.toUpperCase()+" &nbsp;&nbsp; <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
376
+ var lvlKey=pid?(kids[0]&&kids[0].level):"context";
377
+ var lvl=pid?levelName(lvlKey):STR.lvlContext;
378
+ // C4 notation: number the level by its position in the model's own `levels` list (L1 = context).
379
+ var lvlIdx=((M.levels||[]).indexOf(lvlKey));
380
+ var cb=(lvlIdx>-1?STR.crumbLevel+"-L"+(lvlIdx+1):STR.crumbLevel)+" · "+lvl.toUpperCase()+" &nbsp;&nbsp; <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
342
381
  for(var w=0;w<chain.length;w++)cb+=" &rsaquo; "+(w===chain.length-1?"<b>"+esc(chain[w].name)+"</b>":"<a data-d='"+w+"'>"+esc(chain[w].name)+"</a>");
343
382
  $("crumb").innerHTML=cb;
344
383
  $("back").disabled=stack.length<2;
345
384
  $("detail").style.display="none";
346
385
  }
386
+ // Greedy word wrap into at most `max` lines of `cpl` characters. Every returned line is clamped to
387
+ // cpl — an unbreakable token (a long class name, a URL) would otherwise paint outside the box, and
388
+ // there is no clip-path on the node. The last line carries the ellipsis when text was dropped,
389
+ // rather than spending a whole line on it (on a short curated box that was half the visible text).
390
+ function wrapDesc(desc,cpl,max){
391
+ if(max<1||cpl<2)return [];
392
+ var words=String(desc==null?"":desc).split(" "),all=[],line="",i;
393
+ for(i=0;i<words.length;i++){if(line&&(line+words[i]).length>cpl){all.push(line.trim());line="";}line+=words[i]+" ";}
394
+ if(line.trim())all.push(line.trim());
395
+ var out=all.slice(0,max),dropped=all.length>out.length;
396
+ if(dropped&&out.length)out[out.length-1]=out[out.length-1]+"…";
397
+ for(i=0;i<out.length;i++)if(out[i].length>cpl)out[i]=out[i].slice(0,cpl-1)+"…";
398
+ return out;
399
+ }
400
+ // Returns the path plus the point ON the curve at t=0.5 — the label anchor. (The quadratic's
401
+ // control point sits at twice the bow, so anchoring a label there would float it off the arrow.)
347
402
  function edgePath(a,b){
348
403
  var ax=a.x+a.w/2,ay=a.y+a.h/2,bx=b.x+b.w/2,by=b.y+b.h/2,dx=bx-ax,dy=by-ay,L=Math.sqrt(dx*dx+dy*dy)||1;
349
404
  var x1=ax+(a.w/2+10)*dx/L,y1=ay+(a.h/2+10)*dy/L,x2=bx-(b.w/2+12)*dx/L,y2=by-(b.h/2+12)*dy/L;
350
- var mx=(x1+x2)/2+(y2-y1)*.12,my=(y1+y2)/2-(x2-x1)*.12;
351
- return "M"+x1+","+y1+" Q"+mx+","+my+" "+x2+","+y2;
405
+ var cx=(x1+x2)/2+(y2-y1)*.12,cy=(y1+y2)/2-(x2-x1)*.12;
406
+ return {d:"M"+x1+","+y1+" Q"+cx+","+cy+" "+x2+","+y2, mx:(x1+2*cx+x2)/4, my:(y1+2*cy+y2)/4};
352
407
  }
353
408
  function levelName(l){return (STR.lvlNames&&STR.lvlNames[l])||l||"—";}
354
409
  function cssv(v){try{return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||"#2b6e8a";}catch(e){return "#2b6e8a";}}
@@ -456,7 +511,15 @@ function wire(){
456
511
  document.addEventListener("keydown",function(e){if(e.key==="Escape"&&stack.length>1){stack.pop();draw(true);}});
457
512
  $("bnow").addEventListener("click",function(){mode="now";$("bnow").className="on";$("btgt").className="";draw(false);});
458
513
  $("btgt").addEventListener("click",function(){mode="target";$("btgt").className="on";$("bnow").className="";draw(false);});
459
- $("brf").addEventListener("click",function(){var m=(M&&M.meta)||{};$("stamp").textContent=STR.stampBase+" "+(m.verifiedAt||(M&&M.source&&M.source.commit)||"—")+" · "+STR.stampMethod+" "+(m.verifyMethod||STR.stampVerify);});
514
+ $("brf").addEventListener("click",function(){
515
+ // Served model ⇒ actually re-read it (a `forma verify` run in the terminal shows up here without
516
+ // losing the level, the layout or the mode). Injected/offline ⇒ re-stamp, and say why.
517
+ if(!FETCHED){stamp(STR.staticSrc);return;}
518
+ fetch("./c4-model.json?ts="+(new Date()).getTime()).then(function(r){return r.json();})
519
+ .then(function(m){if(m&&m.nodes&&m.nodes.length){M=m;stamp("");draw(false);}else stamp(STR.staticSrc);})
520
+ .catch(function(){stamp(STR.staticSrc);});
521
+ });
522
+ $("blbl").addEventListener("click",function(){LABELS=!($("blbl").className==="on");draw(false);});
460
523
  $("brl").addEventListener("click",function(){delete POS[keyOf()];draw(true);});
461
524
  $("skin").innerHTML=SKINS.map(function(s){return '<option value="'+s[0]+'">'+s[1]+'</option>';}).join("");
462
525
  $("skin").addEventListener("change",function(e){setSkin(e.target.value);});
@@ -467,7 +530,9 @@ function applyStrings(){
467
530
  function set(id,txt){var e=$(id);if(e)e.textContent=txt;}
468
531
  set("subhelp",STR.subhelp);set("back",STR.back);set("bnow",STR.now);set("btgt",STR.tgt);
469
532
  set("brf",STR.reverify);set("brl",STR.resetLayout);var brl=$("brl");if(brl)brl.title=STR.resetLayoutTitle;
470
- set("pexp",STR.printExport);set("pexpSvg",STR.exportSvg);set("pexpPng",STR.exportPng);set("pexpPrint",STR.print);
533
+ set("blbl",STR.labels);var bl=$("blbl");if(bl)bl.title=STR.labelsTitle;
534
+ set("pexp",STR.printExport);set("pexpSvg",STR.exportSvg);set("pexpPng",STR.exportPng);
535
+ set("pexpLayout",STR.exportLayout);set("pexpPrint",STR.print);
471
536
  set("lg-done",STR.legDone);set("lg-prog",STR.legProg);set("lg-next",STR.legNext);
472
537
  set("lg-plan",STR.legPlan);set("lg-prob",STR.legProb);set("lg-hint",STR.legHint);
473
538
  }
@@ -486,7 +551,7 @@ function collectSvgCss(){
486
551
  if(!rules)continue;
487
552
  for(j=0;j<rules.length;j++){var r=rules[j],t=r.cssText||"";
488
553
  // keep rules that target svg-internal classes/elements + the color vars on :root/html
489
- if(/\.(nd|edge|edgehit|s-done|s-prog|s-next|s-plan|s-prob|mode-t|tt|kk|dd|pp|plus|cat)\b/.test(t)
554
+ 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)
490
555
  ||/(^|[\s,])svg\b/.test(t)||/:root|html\[data-skin/.test(t)){
491
556
  // drop animations for a static export (keepdefs stable)
492
557
  out.push(t);
@@ -531,6 +596,17 @@ function triggerDownload(blob,filename){
531
596
  a.href=url;a.download=filename;document.body.appendChild(a);a.click();
532
597
  document.body.removeChild(a);setTimeout(function(){URL.revokeObjectURL(url);},4000);
533
598
  }
599
+ // Every level you have arranged, in the exact shape meta.layout expects — drag the boxes, export,
600
+ // paste into the topology's "layout" and `gen` bakes it in. No storage: the repo is the memory.
601
+ function exportLayout(){
602
+ var out={},k,id,p;
603
+ for(k in POS){if(!POS.hasOwnProperty(k))continue;
604
+ var lvl={},any=false;
605
+ for(id in POS[k].pos){if(!POS[k].pos.hasOwnProperty(id))continue;
606
+ p=POS[k].pos[id];lvl[id]={x:Math.round(p.x),y:Math.round(p.y),w:Math.round(p.w),h:Math.round(p.h)};any=true;}
607
+ if(any)out[k]=lvl;}
608
+ triggerDownload(new Blob([JSON.stringify({layout:out},null,2)+"\n"],{type:"application/json"}),exportBaseName()+"-layout.json");
609
+ }
534
610
  function exportSvg(){
535
611
  var s=serializeSvg();if(!s)return;
536
612
  triggerDownload(new Blob([s.xml],{type:"image/svg+xml;charset=utf-8"}),exportBaseName()+".svg");
@@ -564,23 +640,34 @@ function wireExport(){
564
640
  document.addEventListener("keydown",function(e){if(e.key==="Escape")close();});
565
641
  var sv=$("pexpSvg");if(sv)sv.addEventListener("click",function(){close();exportSvg();});
566
642
  var pn=$("pexpPng");if(pn)pn.addEventListener("click",function(){close();exportPng();});
643
+ var lay=$("pexpLayout");if(lay)lay.addEventListener("click",function(){close();exportLayout();});
567
644
  var pr=$("pexpPrint");if(pr)pr.addEventListener("click",function(){close();window.print();});
568
645
  }
569
646
  function moveTip(ev){var st=$("stage").getBoundingClientRect(),t=$("etip");
570
647
  var x=ev.clientX-st.left+12,y=ev.clientY-st.top+12;
571
648
  t.style.left=Math.min(x,st.width-t.offsetWidth-6)+"px";t.style.top=Math.min(y,st.height-t.offsetHeight-6)+"px";}
572
649
 
650
+ // Fact-base line. `suffix` marks a RE-VERIFY that could not re-read the model (injected/offline).
651
+ function stamp(suffix){
652
+ var m=(M&&M.meta)||{};
653
+ $("stamp").textContent=STR.stampBase+" "+(m.verifiedAt||(M&&M.generatedAt)||(M&&M.source&&M.source.commit)||"—")
654
+ +" · "+STR.stampMethod+" "+(m.verifyMethod||STR.stampVerify)+(suffix?" "+suffix:"");
655
+ }
573
656
  function boot(model){
574
657
  M=model;
575
658
  LANG=pickLang(M);STR=STRINGS[LANG];
576
659
  try{document.documentElement.setAttribute("lang",LANG);}catch(e){}
577
660
  applyStrings();
578
661
  if(!M||!M.nodes||!M.nodes.length){$("err").style.display="block";$("err").textContent=STR.errEmpty;return;}
579
- if(M.source&&M.source.repo)$("title").textContent="Forma · "+M.source.repo;
580
- var m=M.meta||{};$("stamp").textContent=STR.stampBase+" "+(m.verifiedAt||(M.source&&M.source.commit)||"—")+" · "+(m.verifyMethod||STR.stampVerify);
581
- setSkin((new URLSearchParams(location.search).get("skin"))||window.__C4_SKIN__||(m.skin)||"holo");
662
+ var ttl=(M.meta&&M.meta.title)||((M.source&&M.source.repo)?"Forma · "+M.source.repo:null);
663
+ if(ttl){$("title").textContent=ttl;try{document.title=ttl;}catch(e){}}
664
+ stamp("");
665
+ setSkin((new URLSearchParams(location.search).get("skin"))||window.__C4_SKIN__||(M.meta&&M.meta.skin)||"holo");
582
666
  stack=[null];mode="now";POS={};
583
667
  draw(true);
668
+ // Minimal hook for embedders (the Cowork artifact re-verifies through its own MCP bridge and
669
+ // needs to push the result back in without forking the viewer).
670
+ window.__C4_API__={model:function(){return M;},redraw:function(){draw(false);},stamp:function(t){$("stamp").textContent=t;}};
584
671
  }
585
672
  function start(){
586
673
  // Pick language from URL/global up-front so header + errors render even if the model never loads.
@@ -589,6 +676,7 @@ function start(){
589
676
  applyStrings();
590
677
  wire();
591
678
  if(window.__C4_MODEL__){boot(window.__C4_MODEL__);return;}
679
+ FETCHED=true; // the model has a live URL ⇒ RE-VERIFY can re-read it instead of only re-stamping
592
680
  try{fetch("./c4-model.json").then(function(r){return r.json();}).then(boot).catch(function(){
593
681
  $("err").style.display="block";$("err").textContent=STR.errNotFound;});}
594
682
  catch(e){$("err").style.display="block";$("err").textContent=STR.errUnavail;}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-arch",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",