forma-arch 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,7 +61,7 @@ Column roles are detected by header name (`capability`/`feature`/`description`
61
61
  "verify": { "source": "ADR-040 on main" }, "issues": ["#534"] } } }
62
62
  ```
63
63
 
64
- The overlay is the authority: every field it sets wins over anything derived, and `check` stops re-deriving that field. To describe one box by hand without a table, put the sentence in `descriptions`, keyed `"<containerId>/<node-name-without-extension>"` — `"core/alpha"` for the leaf `alpha.js` in container `core`, `"core/core"` for the container itself, since a container is its own container. It outranks every other source.
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
65
 
66
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.
67
67
 
package/lib/gen.mjs CHANGED
@@ -2,7 +2,7 @@
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'
@@ -26,6 +26,7 @@ const ENRICH_MODEL = arg('--enrich-model', null)
26
26
  const STATUS = arg('--status', join(REPO, 'docs/architecture/c4-status.json')) // curated programme state, optional
27
27
  const STATUS_SET = process.argv.includes('--status')
28
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
29
30
 
30
31
  const topo = JSON.parse(readFileSync(TOPO, 'utf-8'))
31
32
  const rp = (p) => join(REPO, p)
@@ -134,8 +135,12 @@ if (docRows.length) console.log(`[gen-c4] docSources: ${docRows.length} row(s)
134
135
 
135
136
  // enrich: fill hologram defaults (category, 6-status, completion, current/target) where absent
136
137
  for (const n of nodes) {
137
- const par = n.parent ? byId.get(n.parent) : null
138
- if (!n.category) n.category = n.kind === 'leaf' ? (par && par.category) || 'leaf' : n.kind
138
+ // A leaf's category is the CONTAINER it belongs to, not its parent's category. Inheriting the
139
+ // parent's handed every leaf in the repo the literal string "container", and the viewer's
140
+ // catalogue collapse groups childless siblings BY CATEGORY — so a repo's leaves collapsed into a
141
+ // single box labelled "container" (53 of them on the Go repo this was measured on). With the real
142
+ // container they group the way that feature was written for, or stay apart when they belong apart.
143
+ if (!n.category) n.category = n.kind === 'leaf' ? (byId.get(containerOf(n, byId)) || {}).name || 'leaf' : n.kind
139
144
  // Progress a DOCUMENT states outright — the only generated alternative to hand-writing the
140
145
  // overlay. Derived, so it is re-derived by `check` rather than trusted; the curated overlay
141
146
  // (§WP-A1, applied further down) still overrides every field of it.
@@ -177,6 +182,53 @@ for (const n of nodes) {
177
182
  // what a module DOES comes from its docs (§1a), what it's WORTH is programme state.
178
183
  const STATUS_FIELDS = new Set(['status2', 'completion', 'statusWord', 'current', 'target', 'verify', 'issues'])
179
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
+
180
232
  let statusPath = null
181
233
  if (existsSync(STATUS) || STATUS_SET) {
182
234
  if (!existsSync(STATUS)) fail(`--status: file missing: ${STATUS}`)
@@ -184,19 +236,10 @@ if (existsSync(STATUS) || STATUS_SET) {
184
236
  try { ov = JSON.parse(readFileSync(STATUS, 'utf-8')) } catch (e) { fail(`--status: ${STATUS} is not valid JSON: ${(e && e.message) || e}`) }
185
237
  let decorated = 0
186
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: '))
187
241
  const target = byId.get(id)
188
- if (!target) fail(`status overlay: unknown node id "${id}" it is not in the model (stale overlay?)`)
189
- for (const [k, v] of Object.entries(patch)) {
190
- if (k.startsWith('$')) continue // $comment and friends
191
- 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)' : ''}`)
192
- if (k === 'status2' && !STATUS2.has(v)) fail(`status overlay: "${id}" status2 "${v}" — expected one of ${[...STATUS2].join('|')}`)
193
- 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)}`)
194
- if (k === 'issues') {
195
- if (!Array.isArray(v)) fail(`status overlay: "${id}" issues must be an array`)
196
- for (const is of v) if (!/^#?\d+$/.test(String(is))) fail(`status overlay: "${id}" issue ${JSON.stringify(is)} — expected "#123"`)
197
- }
198
- target[k] = v
199
- }
242
+ for (const [k, v] of Object.entries(patch)) { if (!k.startsWith('$')) target[k] = v }
200
243
  decorated++
201
244
  }
202
245
  statusPath = relative(REPO, STATUS)
package/lib/init.mjs CHANGED
@@ -5,6 +5,7 @@
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'
8
9
  import { goPackages } from './lang.mjs'
9
10
  import { loadDocRows } from './docmap.mjs'
10
11
 
@@ -132,8 +133,19 @@ const docSources = []
132
133
  }
133
134
  })(join(REPO, 'docs'), 0)
134
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
+
135
147
  const topo = {
136
- meta: { repo: basename(REPO), stack: lang, seededBy: 'forma init' },
148
+ meta: { repo: basename(REPO), stack: lang, seededBy: 'forma init', ...(ghRepo ? { ghRepo } : {}) },
137
149
  docPath: 'docs/architecture/ARCHITECTURE.md',
138
150
  levels: ['context', 'container', 'component', 'leaf'],
139
151
  nodes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-arch",
3
- "version": "0.7.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",