forma-arch 0.10.1 → 0.11.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 +50 -1
- package/lib/check.mjs +8 -1
- package/lib/docmap.mjs +43 -4
- package/lib/gen.mjs +13 -6
- package/lib/schema/c4-model.schema.json +314 -1
- package/lib/validate.mjs +219 -7
- package/lib/verify.mjs +10 -3
- package/lib/viewer/c4-hologram.html +205 -46
- package/package.json +1 -1
package/lib/validate.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
// validate.mjs — hold the model to the schema it declares. Shared by `gen`
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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()
|