forma-arch 0.10.1 → 0.11.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/lib/validate.mjs CHANGED
@@ -1,14 +1,14 @@
1
- // validate.mjs — hold the model to the schema it declares. Shared by `gen` (after write) and `check`.
1
+ // validate.mjs — hold the model to the schema it declares. Shared by `gen` and `check`.
2
2
 
3
3
  // Zero deps (ADR-0001), so the engine is a hand-written walker over the ONLY keywords the
4
4
  // shipped lib/schema/c4-model.schema.json uses:
5
5
  // type (object|array|string|integer|number|boolean|null), required, properties,
6
6
  // additionalProperties (enforced only where it is literally false), items (single schema, no
7
- // tuple form), enum, minItems, minimum, maximum, pattern.
7
+ // tuple form), enum, minItems, minProperties, minimum, maximum, pattern, anyOf and local $ref.
8
8
  // Annotation-only keywords are read and deliberately NOT enforced: $schema, $id, title,
9
9
  // description, default, format — `format: "date-time"`/`"uri"` are not checked, exactly as ajv
10
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/
11
+ // This is NOT a general JSON Schema engine. A third-party schema using oneOf/allOf/external $ref/
12
12
  // patternProperties/tuple items/dependencies would be silently UNDER-validated: the unknown
13
13
  // keyword is ignored, never an error. Widen this file before pointing it at another schema.
14
14
  import { readFileSync } from 'node:fs'
@@ -28,7 +28,18 @@ const typeOk = (value, type) => {
28
28
  const formatTypeError = (path, expected, value) => path + ': expected ' + expected + ', got ' + jsType(value)
29
29
  const childPath = (path, key) => (path === '<root>' ? key : path + '.' + key)
30
30
  const enumText = (set) => set.map((v) => typeof v === 'string' ? v : String(v)).join(', ')
31
- const validateAgainstSchema = (value, schema, path, errs) => {
31
+ const localRef = (root, ref) => {
32
+ if (typeof ref !== 'string' || !ref.startsWith('#/')) return null
33
+ return ref.slice(2).split('/').map((x) => x.replace(/~1/g, '/').replace(/~0/g, '~'))
34
+ .reduce((at, key) => at && at[key], root)
35
+ }
36
+ const validateAgainstSchema = (value, schema, path, errs, root) => {
37
+ if (schema.$ref) {
38
+ const resolved = localRef(root, schema.$ref)
39
+ if (!resolved) errs.push(path + ': unresolved local schema reference ' + schema.$ref)
40
+ else validateAgainstSchema(value, resolved, path, errs, root)
41
+ return
42
+ }
32
43
  const declared = schema.type || null
33
44
  if (declared && !typeOk(value, declared)) { errs.push(formatTypeError(path, declared, value)); return }
34
45
  if (schema.required && typeIsObject(value)) {
@@ -37,17 +48,23 @@ const validateAgainstSchema = (value, schema, path, errs) => {
37
48
  if (schema.properties && typeIsObject(value)) {
38
49
  for (const [k, s] of Object.entries(schema.properties)) {
39
50
  if (!Object.prototype.hasOwnProperty.call(value, k)) continue
40
- validateAgainstSchema(value[k], s, childPath(path, k), errs)
51
+ validateAgainstSchema(value[k], s, childPath(path, k), errs, root)
41
52
  }
42
53
  }
43
54
  if (schema.additionalProperties === false && typeIsObject(value)) {
44
55
  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
56
  }
46
57
  if (Array.isArray(value) && schema.items && typeof schema.items === 'object') {
47
- value.forEach((item, i) => validateAgainstSchema(item, schema.items, path + '[' + i + ']', errs))
58
+ value.forEach((item, i) => validateAgainstSchema(item, schema.items, path + '[' + i + ']', errs, root))
48
59
  }
49
60
  // outside the items branch on purpose: minItems constrains the array, not its element schema
50
61
  if (schema.minItems != null && Array.isArray(value) && value.length < schema.minItems) errs.push(path + ': expected at least ' + schema.minItems + ' item, got ' + value.length)
62
+ if (schema.minProperties != null && typeIsObject(value) && Object.keys(value).length < schema.minProperties) errs.push(path + ': expected at least ' + schema.minProperties + ' property, got ' + Object.keys(value).length)
63
+ if (schema.anyOf && !schema.anyOf.some((branch) => {
64
+ const branchErrs = []
65
+ validateAgainstSchema(value, branch, path, branchErrs, root)
66
+ return branchErrs.length === 0
67
+ })) errs.push(path + ': does not satisfy any allowed schema shape')
51
68
  if (schema.enum && schema.enum.includes(value) === false) {
52
69
  const expected = enumText(schema.enum)
53
70
  errs.push(path + ': ' + JSON.stringify(value) + ' is not one of [' + expected + ']')
@@ -61,6 +78,201 @@ export const validateModel = (model, schemaPath = new URL('./schema/c4-model.sch
61
78
  let schema
62
79
  try { schema = JSON.parse(readFileSync(schemaPath, 'utf-8')) } catch (e) { return ['<root>: unable to read schema - ' + String((e && e.message) || e)] }
63
80
  const errs = []
64
- validateAgainstSchema(model, schema, '<root>', errs)
81
+ validateAgainstSchema(model, schema, '<root>', errs, schema)
82
+ return errs
83
+ }
84
+
85
+ const own = (o, k) => Object.prototype.hasOwnProperty.call(o || {}, k)
86
+ const clone = (v) => JSON.parse(JSON.stringify(v))
87
+ const plain = (v) => v !== null && !Array.isArray(v) && typeof v === 'object'
88
+ const edgeKey = (e) => [e.from, e.to, e.label || '', e.kind || ''].join('\u0000')
89
+ const NODE_SET_FIELDS = new Set([
90
+ 'name', 'drill', 'tech', 'description', 'status', 'link', 'evidence', 'meta', 'category',
91
+ 'status2', 'completion', 'statusWord', 'current', 'issues', 'verify', 'func', 'descSource',
92
+ 'descInputHash',
93
+ ])
94
+ const EDGE_SET_FIELDS = new Set(['from', 'to', 'label', 'kind', 'status', 'evidence', 'estatus'])
95
+
96
+ function timelinePatchShape(checkpoint, at, errs) {
97
+ const patch = checkpoint.patch || {}
98
+ if (!plain(patch)) { errs.push(`${at}.patch: expected object`); return null }
99
+ for (const k of Object.keys(patch)) if (!['nodes', 'edges'].includes(k)) errs.push(`${at}.patch: unexpected property "${k}"`)
100
+ const nodes = patch.nodes || {}, edges = patch.edges || {}
101
+ if (!plain(nodes)) errs.push(`${at}.patch.nodes: expected object`)
102
+ if (!plain(edges)) errs.push(`${at}.patch.edges: expected object`)
103
+ if (!plain(nodes) || !plain(edges)) return null
104
+ for (const k of Object.keys(nodes)) if (!['add', 'update', 'remove'].includes(k)) errs.push(`${at}.patch.nodes: unexpected property "${k}"`)
105
+ for (const k of Object.keys(edges)) if (!['add', 'rewire', 'remove'].includes(k)) errs.push(`${at}.patch.edges: unexpected property "${k}"`)
106
+ const arrays = {
107
+ nodeAdd: nodes.add || [], nodeUpdate: nodes.update || [], nodeRemove: nodes.remove || [],
108
+ edgeAdd: edges.add || [], edgeRewire: edges.rewire || [], edgeRemove: edges.remove || [],
109
+ }
110
+ for (const [k, v] of Object.entries(arrays)) if (!Array.isArray(v)) errs.push(`${at}.${k}: expected array`)
111
+ return Object.values(arrays).every(Array.isArray) ? arrays : null
112
+ }
113
+
114
+ function matchEdges(edges, match) {
115
+ if (!plain(match)) return []
116
+ return edges.map((edge, index) => ({ edge, index })).filter(({ edge }) =>
117
+ edge.from === match.from && edge.to === match.to &&
118
+ (!own(match, 'label') || (edge.label || '') === match.label) &&
119
+ (!own(match, 'kind') || (edge.kind || '') === match.kind))
120
+ }
121
+
122
+ function graphErrors(model, at) {
123
+ const errs = []
124
+ const ids = new Set()
125
+ for (const n of model.nodes || []) {
126
+ if (ids.has(n.id)) errs.push(`${at}: duplicate node id "${n.id}"`)
127
+ ids.add(n.id)
128
+ }
129
+ for (const n of model.nodes || []) if (n.parent != null && !ids.has(n.parent)) errs.push(`${at}: node "${n.id}" has unknown parent "${n.parent}"`)
130
+ for (const n of model.nodes || []) {
131
+ const seen = new Set([n.id])
132
+ let p = n.parent
133
+ while (p != null) {
134
+ if (seen.has(p)) { errs.push(`${at}: parent cycle reaches "${p}" from "${n.id}"`); break }
135
+ seen.add(p)
136
+ const parent = (model.nodes || []).find((x) => x.id === p)
137
+ if (!parent) break
138
+ p = parent.parent
139
+ }
140
+ }
141
+ const edgeKeys = new Set()
142
+ for (const e of model.edges || []) {
143
+ if (!ids.has(e.from) || !ids.has(e.to)) errs.push(`${at}: edge "${e.from}" -> "${e.to}" has an unknown endpoint`)
144
+ const key = edgeKey(e)
145
+ if (edgeKeys.has(key)) errs.push(`${at}: duplicate edge "${e.from}" -> "${e.to}"${e.label ? ` (${e.label})` : ''}`)
146
+ edgeKeys.add(key)
147
+ }
65
148
  return errs
66
149
  }
150
+
151
+ // Apply an optional timeline without ever replacing the generated baseline with a second graph.
152
+ // The return value carries one materialized state per checkpoint plus the local patch metadata the
153
+ // viewer needs to accent ONLY the change from the immediately preceding checkpoint.
154
+ export function materializeTimeline(model, options = {}) {
155
+ const timeline = model && model.timeline
156
+ if (!timeline) return { errors: [], states: [] }
157
+ const errs = []
158
+ if (!plain(timeline)) return { errors: ['timeline: expected object'], states: [] }
159
+ if (typeof timeline.source !== 'string' || !timeline.source.trim()) errs.push('timeline.source: expected a non-empty repo-relative path')
160
+ else if (/^(?:[a-z]:[\\/]|[\\/])/i.test(timeline.source) || timeline.source.split(/[\\/]/).includes('..')) errs.push(`timeline.source: expected a path inside the repository, got ${timeline.source}`)
161
+ else if (options.sourceExists && !options.sourceExists(timeline.source)) errs.push(`timeline.source: file missing: ${timeline.source}`)
162
+ if (!Array.isArray(timeline.checkpoints) || !timeline.checkpoints.length) errs.push('timeline.checkpoints: expected at least one checkpoint')
163
+ if (errs.length) return { errors: errs, states: [] }
164
+
165
+ const reserved = new Set(['as-is']), states = []
166
+ let current = { ...clone(model), nodes: clone(model.nodes || []), edges: clone(model.edges || []) }
167
+ const baseSchemaErrors = validateModel(current)
168
+ for (const e of baseSchemaErrors) errs.push(`timeline baseline schema: ${e}`)
169
+ for (let ci = 0; ci < timeline.checkpoints.length; ci++) {
170
+ const cp = timeline.checkpoints[ci], at = `timeline.checkpoints[${ci}]`
171
+ if (!plain(cp)) { errs.push(`${at}: expected object`); continue }
172
+ if (typeof cp.id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(cp.id)) errs.push(`${at}.id: expected lowercase [a-z0-9._-] identifier`)
173
+ else if (reserved.has(cp.id)) errs.push(`${at}.id: duplicate or reserved checkpoint "${cp.id}"`)
174
+ else reserved.add(cp.id)
175
+ if (typeof cp.label !== 'string' || !cp.label.trim()) errs.push(`${at}.label: expected a non-empty string`)
176
+ if (own(cp, 'badge') && typeof cp.badge !== 'string') errs.push(`${at}.badge: expected string`)
177
+ for (const k of Object.keys(cp)) if (!['id', 'label', 'badge', 'patch'].includes(k)) errs.push(`${at}: unexpected property "${k}"`)
178
+ const ops = timelinePatchShape(cp, at, errs)
179
+ if (!ops) continue
180
+
181
+ const next = { ...current, nodes: clone(current.nodes), edges: clone(current.edges) }
182
+ const touchedNodes = new Set(), removedNodes = new Set(), touchedEdges = new WeakSet()
183
+ const delta = { nodes: [], edges: [], removedNodes: [], removedEdges: [], counts: { add: 0, update: 0, rewire: 0, remove: 0 } }
184
+ const byId = () => new Map(next.nodes.map((n) => [n.id, n]))
185
+
186
+ // Nodes are added parent-first so a typo cannot hide behind a later cumulative checkpoint.
187
+ for (let i = 0; i < ops.nodeAdd.length; i++) {
188
+ const op = ops.nodeAdd[i], p = `${at}.patch.nodes.add[${i}]`
189
+ if (!plain(op) || !plain(op.node)) { errs.push(`${p}: expected {node, change}`); continue }
190
+ for (const k of Object.keys(op)) if (!['node', 'change'].includes(k)) errs.push(`${p}: unexpected property "${k}"`)
191
+ if (typeof op.change !== 'string' || !op.change.trim()) errs.push(`${p}.change: expected a non-empty string`)
192
+ const n = op.node
193
+ if (own(n, 'target')) errs.push(`${p}.node.target: forbidden in timeline patches; the last checkpoint is the target`)
194
+ if (!n.id || byId().has(n.id) || touchedNodes.has(n.id)) { errs.push(`${p}: node id "${n.id || ''}" already exists or is touched twice`); continue }
195
+ if (n.parent != null && !byId().has(n.parent)) { errs.push(`${p}: parent "${n.parent}" must exist before child "${n.id}"`); continue }
196
+ next.nodes.push(clone(n)); touchedNodes.add(n.id)
197
+ delta.nodes.push({ id: n.id, type: 'ADD', change: op.change }); delta.counts.add++
198
+ }
199
+
200
+ for (let i = 0; i < ops.nodeUpdate.length; i++) {
201
+ const op = ops.nodeUpdate[i], p = `${at}.patch.nodes.update[${i}]`
202
+ if (!plain(op) || !plain(op.set)) { errs.push(`${p}: expected {id, set, change}`); continue }
203
+ for (const k of Object.keys(op)) if (!['id', 'set', 'change'].includes(k)) errs.push(`${p}: unexpected property "${k}"`)
204
+ if (typeof op.change !== 'string' || !op.change.trim()) errs.push(`${p}.change: expected a non-empty string`)
205
+ const target = byId().get(op.id)
206
+ if (!target || touchedNodes.has(op.id)) { errs.push(`${p}: node "${op.id}" is unknown or touched twice`); continue }
207
+ const bad = Object.keys(op.set).filter((k) => !NODE_SET_FIELDS.has(k))
208
+ if (bad.length) errs.push(`${p}.set: forbidden field(s) ${bad.join(', ')}; id/parent/level/kind/target require a structural patch`)
209
+ if (!Object.keys(op.set).length) errs.push(`${p}.set: expected at least one field`)
210
+ if (bad.length || !Object.keys(op.set).length) continue
211
+ Object.assign(target, clone(op.set)); touchedNodes.add(op.id)
212
+ delta.nodes.push({ id: op.id, type: 'UPDATE', change: op.change }); delta.counts.update++
213
+ }
214
+
215
+ // Remove/rewire existing relations before adding new ones or deleting their endpoints.
216
+ for (let i = 0; i < ops.edgeRemove.length; i++) {
217
+ const op = ops.edgeRemove[i], p = `${at}.patch.edges.remove[${i}]`
218
+ if (!plain(op) || !plain(op.match)) { errs.push(`${p}: expected {match, change}`); continue }
219
+ for (const k of Object.keys(op)) if (!['match', 'change'].includes(k)) errs.push(`${p}: unexpected property "${k}"`)
220
+ if (typeof op.change !== 'string' || !op.change.trim()) errs.push(`${p}.change: expected a non-empty string`)
221
+ const found = matchEdges(next.edges, op.match)
222
+ if (found.length !== 1 || touchedEdges.has(found[0] && found[0].edge)) { errs.push(`${p}: selector must resolve exactly one untouched edge, got ${found.length}`); continue }
223
+ const gone = next.edges.splice(found[0].index, 1)[0]; touchedEdges.add(gone)
224
+ delta.removedEdges.push({ ...clone(gone), type: 'REMOVE', change: op.change }); delta.counts.remove++
225
+ }
226
+
227
+ for (let i = 0; i < ops.edgeRewire.length; i++) {
228
+ const op = ops.edgeRewire[i], p = `${at}.patch.edges.rewire[${i}]`
229
+ if (!plain(op) || !plain(op.match) || !plain(op.set)) { errs.push(`${p}: expected {match, set, change}`); continue }
230
+ for (const k of Object.keys(op)) if (!['match', 'set', 'change'].includes(k)) errs.push(`${p}: unexpected property "${k}"`)
231
+ if (typeof op.change !== 'string' || !op.change.trim()) errs.push(`${p}.change: expected a non-empty string`)
232
+ const bad = Object.keys(op.set).filter((k) => !EDGE_SET_FIELDS.has(k))
233
+ if (bad.length) errs.push(`${p}.set: unexpected field(s) ${bad.join(', ')}`)
234
+ if (!own(op.set, 'from') && !own(op.set, 'to')) errs.push(`${p}.set: a rewire must change from and/or to`)
235
+ const found = matchEdges(next.edges, op.match)
236
+ if (found.length !== 1 || touchedEdges.has(found[0] && found[0].edge) || bad.length || (!own(op.set, 'from') && !own(op.set, 'to'))) {
237
+ if (found.length !== 1 || touchedEdges.has(found[0] && found[0].edge)) errs.push(`${p}: selector must resolve exactly one untouched edge, got ${found.length}`)
238
+ continue
239
+ }
240
+ const edge = next.edges[found[0].index], before = clone(edge)
241
+ Object.assign(edge, clone(op.set)); touchedEdges.add(edge)
242
+ delta.edges.push({ ...clone(edge), type: 'REWIRE', change: op.change, before }); delta.counts.rewire++
243
+ }
244
+
245
+ for (let i = 0; i < ops.edgeAdd.length; i++) {
246
+ const op = ops.edgeAdd[i], p = `${at}.patch.edges.add[${i}]`
247
+ if (!plain(op) || !plain(op.edge)) { errs.push(`${p}: expected {edge, change}`); continue }
248
+ for (const k of Object.keys(op)) if (!['edge', 'change'].includes(k)) errs.push(`${p}: unexpected property "${k}"`)
249
+ if (typeof op.change !== 'string' || !op.change.trim()) errs.push(`${p}.change: expected a non-empty string`)
250
+ const e = op.edge
251
+ if (!e.from || !e.to || !byId().has(e.from) || !byId().has(e.to)) { errs.push(`${p}: edge endpoints must exist in this checkpoint`); continue }
252
+ if (next.edges.some((x) => edgeKey(x) === edgeKey(e))) { errs.push(`${p}: duplicate edge "${e.from}" -> "${e.to}"`); continue }
253
+ next.edges.push(clone(e)); delta.edges.push({ ...clone(e), type: 'ADD', change: op.change }); delta.counts.add++
254
+ }
255
+
256
+ for (let i = 0; i < ops.nodeRemove.length; i++) {
257
+ const op = ops.nodeRemove[i], p = `${at}.patch.nodes.remove[${i}]`
258
+ if (!plain(op)) { errs.push(`${p}: expected {id, change}`); continue }
259
+ for (const k of Object.keys(op)) if (!['id', 'change'].includes(k)) errs.push(`${p}: unexpected property "${k}"`)
260
+ if (typeof op.change !== 'string' || !op.change.trim()) errs.push(`${p}.change: expected a non-empty string`)
261
+ const target = byId().get(op.id)
262
+ if (!target || touchedNodes.has(op.id) || removedNodes.has(op.id)) { errs.push(`${p}: node "${op.id}" is unknown or touched twice`); continue }
263
+ const liveChild = next.nodes.find((n) => n.parent === op.id && !removedNodes.has(n.id))
264
+ const incident = next.edges.find((e) => e.from === op.id || e.to === op.id)
265
+ if (liveChild) { errs.push(`${p}: remove child "${liveChild.id}" before parent "${op.id}"`); continue }
266
+ if (incident) { errs.push(`${p}: remove or rewire incident edges before node "${op.id}"`); continue }
267
+ next.nodes = next.nodes.filter((n) => n.id !== op.id); removedNodes.add(op.id)
268
+ delta.removedNodes.push({ id: op.id, name: target.name, type: 'REMOVE', change: op.change }); delta.counts.remove++
269
+ }
270
+
271
+ for (const e of graphErrors(next, `${at} (${cp.id})`)) errs.push(e)
272
+ const projected = clone(next); delete projected.timeline
273
+ for (const e of validateModel(projected)) errs.push(`${at} (${cp.id}) schema: ${e}`)
274
+ states.push({ id: cp.id, label: cp.label, badge: cp.badge || '', model: next, delta })
275
+ current = next
276
+ }
277
+ return { errors: errs, states }
278
+ }
package/lib/verify.mjs CHANGED
@@ -57,10 +57,17 @@ for (const it of issues) {
57
57
  const nodes = refs.get(n); if (!nodes) continue
58
58
  closed++
59
59
  for (const node of nodes) {
60
- node.status2 = 'done'; node.completion = 100
60
+ // #43: a closed issue justifies a VERDICT, never a percentage. Writing completion = 100 here
61
+ // was the widest path back to the defect this release exists to close: the number carried no
62
+ // citation, so the publication gate - which grades provenance - waved it through as a
63
+ // measurement. Nothing in forma measures completion; a closed issue least of all.
64
+ node.status2 = 'done'
65
+ delete node.completion
61
66
  // 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%'
67
+ // node turning green — a box claiming both at once. Marking done owns the badge too. It used
68
+ // to own it by writing '100%', which put a percentage back on screen through the side door;
69
+ // clearing the word lets the badge fall through to the verdict, which is what is known.
70
+ if (node.statusWord) delete node.statusWord
64
71
  const mark = `(#${n} CLOSED` // re-running must not stack prefixes
65
72
  if (!String(node.current || '').includes(mark)) {
66
73
  node.current = `Closed with evidence ${mark}, gh ${ts}). ${String(node.current || '').trim()}`.trim()