@try-works/dsh-recursive-mode 0.6.1 → 0.7.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.
@@ -0,0 +1,423 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-workflow-map.mjs — INDEPENDENT structural check of the generated page.
4
+ *
5
+ * WHY A SECOND SCRIPT. The generator's own `--verify` validates the FACTS it
6
+ * rendered. That leaves one thing unproven: that the bytes it wrote are a valid,
7
+ * openable HTML document. This checker reads the WRITTEN FILE — not the
8
+ * generator's in-memory string — and parses it with an actual HTML parser, so a
9
+ * structural defect introduced by escaping, by a stray tag, or by a template
10
+ * literal cannot hide behind a green fact-check.
11
+ *
12
+ * It uses `linkedom` when the repo happens to have it, and falls back to a
13
+ * dependency-free structural read otherwise. Either way it asserts:
14
+ * 1. the file parses;
15
+ * 2. every element carrying an id is unique;
16
+ * 3. every aria-controls / aria-labelledby / href="#…" target exists;
17
+ * 4. every tab has a panel and every panel has a tab;
18
+ * 5. the tab count equals 1 + 12 + 8;
19
+ * 6. no external resource is referenced;
20
+ * 7. the file opens with a doctype and closes with </html>.
21
+ *
22
+ * Usage: node scripts/check-workflow-map.mjs [path]
23
+ * Exit code is non-zero on any failure.
24
+ */
25
+ import { existsSync, readFileSync } from 'node:fs'
26
+ import { resolve } from 'node:path'
27
+
28
+ const target = resolve(process.argv[2] ?? 'workflow-map/recursive-mode-workflow.html')
29
+ const fails = []
30
+ const passes = []
31
+ const check = (label, ok, detail = '') => (ok ? passes.push(label) : fails.push(label + (detail ? ' — ' + detail : '')))
32
+
33
+ if (!existsSync(target)) {
34
+ console.error('no such file: ' + target)
35
+ process.exit(2)
36
+ }
37
+ const html = readFileSync(target, 'utf8')
38
+
39
+ /* -- 1. shape ------------------------------------------------------------- */
40
+ check('starts with a doctype', /^\s*<!DOCTYPE html>/i.test(html), html.slice(0, 40))
41
+ check('declares <html lang>', /<html\s+lang="[a-z-]+"/i.test(html))
42
+ check('declares a charset', /<meta\s+charset="utf-8">/i.test(html))
43
+ check('declares a viewport', /<meta\s+name="viewport"/i.test(html))
44
+ check('has a <title>', /<title>[^<]+<\/title>/i.test(html))
45
+ check('ends with </html>', /<\/html>\s*$/.test(html))
46
+ check('is self-contained (no <script src>, no <link href> to a file)',
47
+ !/<script[^>]+\bsrc=/i.test(html) && !/<link[^>]+\bhref="(?!#)/i.test(html))
48
+ check('references no http(s) URL at all',
49
+ !/https?:\/\//i.test(html.replace(/https:\/\/github\.com\/[^\s"]+/g, '')),
50
+ (html.match(/https?:\/\/[^\s"<]*/g) || []).slice(0, 3).join(', '))
51
+
52
+ /* -- 2. tag balance ------------------------------------------------------- */
53
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'])
54
+ const stack = []
55
+ const tagProblems = []
56
+ for (const m of html.matchAll(/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/g)) {
57
+ const [full, closing, rawName, attrs] = m
58
+ const name = rawName.toLowerCase()
59
+ if (name === 'script' && !closing) { /* handled below */ }
60
+ if (VOID.has(name) || attrs.trim().endsWith('/')) continue
61
+ if (!closing) { stack.push(name); continue }
62
+ const last = stack.pop()
63
+ if (last !== name) tagProblems.push('expected </' + String(last) + '> but found ' + full)
64
+ }
65
+ check('tags nest and close correctly', tagProblems.length === 0, tagProblems.slice(0, 4).join(' | '))
66
+ check('every opened tag is closed', stack.length === 0, 'left open: ' + stack.join(', '))
67
+
68
+ /* -- 3. ids are unique ---------------------------------------------------- */
69
+ const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1])
70
+ const dupIds = ids.filter((id, i) => ids.indexOf(id) !== i)
71
+ check('every id is unique (' + ids.length + ' ids)', dupIds.length === 0, [...new Set(dupIds)].join(', '))
72
+
73
+ /* -- 4. every reference resolves ------------------------------------------ */
74
+ const idSet = new Set(ids)
75
+ const refs = []
76
+ for (const m of html.matchAll(/\saria-controls="([^"]+)"/g)) refs.push({ attr: 'aria-controls', to: m[1] })
77
+ for (const m of html.matchAll(/\saria-labelledby="([^"]+)"/g)) for (const id of m[1].split(/\s+/)) refs.push({ attr: 'aria-labelledby', to: id })
78
+ for (const m of html.matchAll(/\shref="#([^"]+)"/g)) refs.push({ attr: 'href', to: m[1] })
79
+ const broken = refs.filter((r) => !idSet.has(r.to))
80
+ check('every aria-controls / aria-labelledby / href="#…" resolves (' + refs.length + ' references)',
81
+ broken.length === 0, broken.map((b) => b.attr + '→' + b.to).join(', '))
82
+
83
+ /* -- 5. tab/panel pairing ------------------------------------------------- */
84
+ const tabs = [...html.matchAll(/<button[^>]*role="tab"[^>]*>/g)].map((m) => m[0])
85
+ const controls = tabs.map((t) => (t.match(/aria-controls="([^"]+)"/) || [])[1])
86
+ const panels = [...html.matchAll(/<section[^>]*id="(panel-[^"]+)"[^>]*role="tabpanel"[^>]*>/g)].map((m) => m[1])
87
+ check('every tab has an aria-controls', controls.every(Boolean))
88
+ check('every tab points at a panel that exists', controls.every((c) => panels.includes(c)), controls.filter((c) => !panels.includes(c)).join(', '))
89
+ check('every panel is pointed at by exactly one tab',
90
+ panels.filter((p) => controls.filter((c) => c === p).length !== 1).join(', ') === '')
91
+ check('tab count is 21 (1 overview + 12 phases + 8 other views): got ' + tabs.length, tabs.length === 21)
92
+ check('panel count matches tab count: ' + panels.length, panels.length === tabs.length)
93
+ check('exactly one tab is aria-selected="true"',
94
+ tabs.filter((t) => /aria-selected="true"/.test(t)).length === 1)
95
+ check('exactly one tab is in the tab order (tabindex="0")',
96
+ tabs.filter((t) => /tabindex="0"/.test(t)).length === 1)
97
+ check('the rest carry tabindex="-1" (roving tabindex)',
98
+ tabs.filter((t) => /tabindex="-1"/.test(t)).length === tabs.length - 1)
99
+ check('the tablist has an accessible name', /role="tablist"[^>]*aria-labelledby="[^"]+"/.test(html))
100
+ check('every tab has a visible text label',
101
+ tabs.every((t, i) => {
102
+ const body = html.slice(html.indexOf(t) + t.length, html.indexOf(t) + t.length + 220)
103
+ return /<span class="tlabel">[^<]+<\/span>/.test(body)
104
+ }))
105
+
106
+ /* -- 6. the twelve phase panels are present ------------------------------- */
107
+ // The ids are derived from the ARTIFACT FILE NAMES (with the extension slugged in),
108
+ // and the check is against a list written out here rather than imported from the
109
+ // generator — an independent expectation, so the generator cannot pass by agreeing
110
+ // with itself.
111
+ const PHASE_IDS = ['00-requirements-md', '00-worktree-md', '01-as-is-md', '01-5-root-cause-md',
112
+ '02-to-be-plan-md', '03-implementation-summary-md', '03-5-code-review-md', '04-test-summary-md',
113
+ '05-manual-qa-md', '06-decisions-update-md', '07-state-update-md', '08-memory-impact-md']
114
+ const missing = PHASE_IDS.filter((p) => !panels.includes('panel-phase-' + p))
115
+ check('all twelve phase panels exist (found ' + panels.length + ' panels)', missing.length === 0, missing.join(', '))
116
+
117
+ /* -- 7. content is present, not an empty shell --------------------------- */
118
+ check('the document has substantial content (> 120 KB): ' + html.length, html.length > 120_000)
119
+ check('citations are rendered: ' + (html.match(/class="cite"/g) || []).length,
120
+ (html.match(/class="cite"/g) || []).length >= 150)
121
+ check('every gate label word appears', ['DECISION', 'REFUSAL', 'AUTO'].every((w) => html.includes('>' + w + '<')))
122
+
123
+ /* ==========================================================================
124
+ 8. LAYOUT INVARIANTS — the check that would have caught the shipped defects
125
+ ==========================================================================
126
+
127
+ WHY THIS SECTION EXISTS. Every check above this line is about STRUCTURE: does the
128
+ file parse, do the ids resolve, is there a panel per tab. The page shipped with all
129
+ of them green and was still visibly broken, because nothing in it ever compared two
130
+ COORDINATES. The four defects a reader saw were:
131
+
132
+ D1 the strip heading was drawn across the lower third of the phase-0 gate card
133
+ (card y=38..84, heading baseline y=88);
134
+ D2 the twelve nodes were 62 units wide against 61-63-unit labels, so every label
135
+ overran its own box onto its neighbour's, and nodes 8-11 sat at x=690..900
136
+ inside the hook column that starts at x=668;
137
+ D3 the backward-edge arc ran at y=268 with its REVISE label at y=264, the same
138
+ band as the prose line at y=262;
139
+ D4 the hook cards (y=38..138) and the sequence row (y=96..136) shared one
140
+ horizontal band in one x-range.
141
+
142
+ Each invariant below is a COMPUTED fact about the emitted markup — parsed out of the
143
+ SVG and the stylesheet, not eyeballed. The diagram ships its layout manifest in
144
+ `data-layout` (every reserved box, plus the character-width metric the boxes were
145
+ computed from), so this checker re-derives the same geometry from the FILE and
146
+ re-runs the arithmetic; and it re-parses the SVG's own coordinates, so a manifest
147
+ that disagrees with the drawing is a failure rather than an alibi.
148
+
149
+ THE INVARIANTS:
150
+ 8.1 the manifest is present, well formed, and every box is non-empty and rounded
151
+ 8.2 no two reserved boxes intersect, EXCEPT where one CONTAINS the other
152
+ (a card and its label, the strip and its nodes, a rule and its own corner)
153
+ 8.3 the svg's actual attributes equal the manifest: viewBox, width/height, and
154
+ one <text>/<rect>/<path>/<polygon> per declared box of that kind
155
+ 8.4 every text box lies inside a declared container — NOTHING IS DRAWN WITHOUT A
156
+ CONTAINER THAT HOLDS IT (this is D1 and D4)
157
+ 8.5 every text box is wide enough for its own label at the declared metric — no
158
+ clipped or overhanging label (this is D2)
159
+ 8.6 the svg's drawn coordinates agree with the manifest box for box
160
+ 8.7 single-x-range columns do not share a vertical band (D4), and no two bands
161
+ overlap vertically
162
+ 8.8 the drawing cannot shrink below its own layout: the svg is sized from
163
+ --dg-w, which the generator writes from the manifest width, and the wrapper
164
+ scrolls (the twelve-node strip therefore scrolls as ONE strip at the same
165
+ minimum width as the tab strip)
166
+ 8.9 no text node is painted inside a POSITIONED element's box: every absolutely
167
+ or fixed positioned element either holds its text inside its own box or is
168
+ the off-canvas skip link
169
+ 8.10 the grid/flex declarations that make the cards shrink rather than overflow
170
+ are still in the sheet (a bare `1fr` track is what let a citation push a card
171
+ past its column and paint over its neighbour)
172
+ ========================================================================== */
173
+
174
+ const CSS = (html.match(/<style>([\s\S]*?)<\/style>/) || ['', ''])[1]
175
+ const svgSrc = (html.match(/<svg[\s\S]*?<\/svg>/) || [''])[0]
176
+ const dec = (s) => s.replace(/&quot;/g, '"').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&#39;/g, "'")
177
+
178
+ /* -- 8.1 the manifest ----------------------------------------------------- */
179
+ const layoutAt = html.indexOf('data-layout="')
180
+ const layoutRaw = layoutAt < 0 ? '' : html.slice(layoutAt + 'data-layout="'.length, html.indexOf('"', layoutAt + 'data-layout="'.length))
181
+ let L = null
182
+ try { L = JSON.parse(dec(layoutRaw)) } catch { L = null }
183
+ check('8.1 the diagram ships a parseable layout manifest', !!L && L.v === 1)
184
+ const BOXES = Array.isArray(L?.boxes) ? L.boxes : []
185
+ const FIT = Array.isArray(L?.fit) ? L.fit : []
186
+ check('8.1 the manifest declares a metric and a viewBox: charW=' + L?.charW + ' lineHeight=' + L?.lineHeight
187
+ + ' viewBox=' + JSON.stringify(L?.viewBox),
188
+ typeof L?.charW === 'number' && L.charW > 0 && typeof L?.lineHeight === 'number'
189
+ && Array.isArray(L?.viewBox) && L.viewBox.length === 4)
190
+ check('8.1 every reserved box is a 6-tuple with positive, rounded geometry: ' + BOXES.length + ' boxes',
191
+ BOXES.length > 40 && BOXES.every((b) => Array.isArray(b) && b.length === 6
192
+ && ['text', 'box', 'rule', 'ink'].includes(b[0])
193
+ && [b[1], b[2], b[3], b[4]].every((n) => typeof n === 'number' && Number.isFinite(n) && Math.abs(Math.round(n * 100) - n * 100) < 1e-6)
194
+ && b[3] > 0 && b[4] > 0))
195
+
196
+ /* -- 8.2 no two reserved boxes may intersect, except by declared exemption -
197
+ Two exemptions are allowed, and both must be DECLARED, not inferred:
198
+ · `contains` — the generator's own record of "this box holds that box";
199
+ · a rule or an arrowhead sharing an index with a rule it joins (a path's corner).
200
+ Everything else that overlaps is the defect class this whole section exists for. */
201
+ const CONTAINS = Array.isArray(L?.contains) ? L.contains.filter((p) => Array.isArray(p) && p.length === 2) : []
202
+ const containsBox = (o, i) => i[1] >= o[1] && i[2] >= o[2] && i[1] + i[3] <= o[1] + o[3] && i[2] + i[4] <= o[2] + o[4]
203
+ const declaredPair = new Set([...CONTAINS].map(([i, c]) => i + '>' + c))
204
+ const EPS = 0.05
205
+ const boxHits = []
206
+ for (let a = 0; a < BOXES.length; a++) {
207
+ for (let b = a + 1; b < BOXES.length; b++) {
208
+ const A = BOXES[a], B = BOXES[b]
209
+ const ix = Math.min(A[1] + A[3], B[1] + B[3]) - Math.max(A[1], B[1])
210
+ const iy = Math.min(A[2] + A[4], B[2] + B[4]) - Math.max(A[2], B[2])
211
+ if (ix <= EPS || iy <= EPS) continue
212
+ if (declaredPair.has(a + '>' + b) || declaredPair.has(b + '>' + a)) continue
213
+ if (containsBox(A, B) || containsBox(B, A)) continue
214
+ boxHits.push(`${A[0]} "${A[5]}" × ${B[0]} "${B[5]}" by ${ix.toFixed(2)}x${iy.toFixed(2)}`)
215
+ }
216
+ }
217
+ check('8.2 no two reserved boxes intersect except where declared: ' + BOXES.length + ' boxes compared, '
218
+ + CONTAINS.length + ' declared containments, ' + boxHits.length + ' undeclared intersections',
219
+ boxHits.length === 0, boxHits.slice(0, 4).join(' | '))
220
+ /* A declared containment is one of two things, and both are geometric facts:
221
+ · the container is a BOX and geometrically holds the box inside it;
222
+ · the container is a RULE and the contained rule/arrowhead MEETS it — the two
223
+ reserved boxes touch within one stroke width (5 units), which is the arc's corner
224
+ and the only place in the chart where two strokes share a reservation. A pair that
225
+ merely sat near each other would not qualify, and the check says so. */
226
+ const meets = (o, i) => Math.abs(Math.min(i[1] + i[3], o[1] + o[3]) - Math.max(i[1], o[1])) <= 5
227
+ && Math.abs(Math.min(i[2] + i[4], o[2] + o[4]) - Math.max(i[2], o[2])) <= 5
228
+ const declaredOk = ([i, c]) => {
229
+ const A = BOXES[i], C = BOXES[c]
230
+ if (!A || !C) return false
231
+ if (C[0] === 'box' && containsBox(C, A)) return true
232
+ return C[0] === 'rule' && (A[0] === 'rule' || A[0] === 'ink') && meets(C, A)
233
+ }
234
+ check('8.2 every declared containment is real: a box that holds its content, or a rule that meets a rule at a corner',
235
+ CONTAINS.length > 10 && CONTAINS.every(declaredOk),
236
+ CONTAINS.filter((p) => !declaredOk(p)).slice(0, 3).map(([i, c]) => i + ',' + c).join(' | '))
237
+
238
+ /* -- 8.3 the drawing matches the manifest --------------------------------- */
239
+ const [VX, VY, VW, VH] = L?.viewBox ?? [0, 0, 0, 0]
240
+ const svgOpen = (svgSrc.match(/<svg[^>]*>/) || [''])[0]
241
+ const vb = (svgOpen.match(/viewBox="([^"]+)"/) || [, ''])[1].split(/\s+/).map(Number)
242
+ check('8.3 the svg viewBox is the manifest viewBox', JSON.stringify(vb) === JSON.stringify([VX, VY, VW, VH]),
243
+ 'svg=' + JSON.stringify(vb) + ' manifest=' + JSON.stringify([VX, VY, VW, VH]))
244
+ const svgEls = {
245
+ text: (svgSrc.match(/<text\b/g) || []).length,
246
+ rect: (svgSrc.match(/<rect\b/g) || []).length,
247
+ path: (svgSrc.match(/<path\b/g) || []).length,
248
+ ink: (svgSrc.match(/<polygon\b/g) || []).length,
249
+ }
250
+ const manEls = { text: 0, rect: 0, path: 0, ink: 0 }
251
+ for (const b of BOXES) manEls[b[0] === 'box' ? 'rect' : b[0] === 'rule' ? 'path' : b[0]] += 1
252
+ check('8.3 one drawn element per reserved box: svg ' + JSON.stringify(svgEls) + ' manifest ' + JSON.stringify(manEls),
253
+ JSON.stringify(svgEls) === JSON.stringify(manEls))
254
+
255
+ /* -- 8.4 no text may be drawn ACROSS a box it does not belong to ----------
256
+ This is the invariant for D1 and D4. A text box is allowed to be inside a container
257
+ (a label in its card) or clear of every container, but it may never PARTIALLY
258
+ intersect one: that is the heading printed across the gate card, and the hook card
259
+ slicing the first sequence node. Text-on-text is already covered by 8.2. */
260
+ const TEXT_BOXES = BOXES.filter((b) => b[0] === 'text')
261
+ const CONTAINERS = BOXES.filter((b) => b[0] === 'box')
262
+ const straddles = []
263
+ for (const a of BOXES) {
264
+ for (let c = 0; c < CONTAINERS.length; c++) {
265
+ const C = CONTAINERS[c]
266
+ const ix = Math.min(a[1] + a[3], C[1] + C[3]) - Math.max(a[1], C[1])
267
+ const iy = Math.min(a[2] + a[4], C[2] + C[4]) - Math.max(a[2], C[2])
268
+ if (ix <= EPS || iy <= EPS) continue
269
+ const aIdx = BOXES.indexOf(a)
270
+ if (declaredPair.has(aIdx + '>' + c) || declaredPair.has(c + '>' + aIdx)) continue
271
+ if (containsBox(C, a) || containsBox(a, C)) continue
272
+ straddles.push(`${a[0]} "${a[5]}" crosses ${C[0]} "${C[5]}" by ${ix.toFixed(2)}x${iy.toFixed(2)}`)
273
+ }
274
+ }
275
+ check('8.4 no box is drawn partly across another: ' + TEXT_BOXES.length + ' texts, ' + CONTAINERS.length
276
+ + ' containers, ' + straddles.length + ' straddles', straddles.length === 0, straddles.slice(0, 4).join(' | '))
277
+ check('8.4 every label the manifest promises is held by a declared container: ' + TEXT_BOXES.length + ' of ' + FIT.length,
278
+ TEXT_BOXES.length === FIT.length && CONTAINERS.length >= 18)
279
+
280
+ /* -- 8.5 no label overruns the box that holds it ------------------------- */
281
+ const tight = FIT.filter(([label, x, y, w, h]) => Math.abs(w - String(label).length * L.charW * (h / L.lineHeight)) > 0.05)
282
+ check('8.5 every text box is exactly its label at the declared metric (no clipped or overhanging label): '
283
+ + FIT.length + ' labels', FIT.length > 40 && tight.length === 0,
284
+ tight.slice(0, 3).map((f) => JSON.stringify(f[0])).join(', '))
285
+ /* And the metric itself must be the width the rendering font can produce. The stack is
286
+ monospace, so one advance width per glyph; Courier New — the last fallback — is the
287
+ widest plausible member at 0.6001 em, and the layout must not under-declare it. */
288
+ const MONO_MAX_EM = 0.6001
289
+ check('8.5 the declared character width covers the widest monospace in the font stack: charW='
290
+ + L.charW + ' vs ' + MONO_MAX_EM + ' em',
291
+ typeof L.charW === 'number' && L.charW >= MONO_MAX_EM && L.charW <= 0.65)
292
+
293
+ /* -- 8.6 the drawn coordinates agree with the manifest -------------------- */
294
+ const drawn = []
295
+ for (const m of svgSrc.matchAll(/<text class="([^"]+)" x="([-\d.]+)" y="([-\d.]+)">/g)) drawn.push({ cls: m[1], x: Number(m[2]), y: Number(m[3]) })
296
+ const FS_OF = {}
297
+ for (const m of CSS.matchAll(/\.(dg-t[a-z-]*)\{[^}]*font-size:([\d.]+)px/g)) FS_OF[m[1]] = Number(m[2])
298
+ const mismatch = []
299
+ const drawnRects = [...svgSrc.matchAll(/<rect class="([^"]+)" x="([-\d.]+)" y="([-\d.]+)" width="([-\d.]+)" height="([-\d.]+)"/g)]
300
+ .map((m) => [Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])])
301
+ let ti = 0
302
+ for (const b of BOXES) {
303
+ if (b[0] !== 'text') continue
304
+ const d = drawn[ti++]
305
+ const fs = FS_OF[d?.cls]
306
+ // textEl writes the baseline at round(boxTop) + fontSize*1.1 — both halves must
307
+ // agree, so a box whose x or line-top was edited to "fix" a drawing fails here.
308
+ const base = Math.round((b[2] + Math.round(fs * 1.1 * 100) / 100) * 100) / 100
309
+ if (!d || d.x !== b[1] || Math.abs(d.y - base) > 0.011) {
310
+ mismatch.push('"' + b[5] + '" drawn ' + JSON.stringify(d) + ' expected y=' + base + ' at x=' + b[1])
311
+ }
312
+ }
313
+ check('8.6 every <text> is drawn at the manifest box it declared: ' + drawn.length + ' texts',
314
+ mismatch.length === 0 && drawn.length === TEXT_BOXES.length, mismatch.slice(0, 3).join(' | '))
315
+ const orphanRects = drawnRects.filter((r) => !BOXES.some((b) => b[0] === 'box' && b[1] === r[0] && b[2] === r[1] && b[3] === r[2] && b[4] === r[3]))
316
+ check('8.6 every <rect> is a declared box: ' + drawnRects.length + ' rects', orphanRects.length === 0,
317
+ JSON.stringify(orphanRects.slice(0, 3)))
318
+ check('8.6 every dg-* class used in the svg has a font-size in the sheet',
319
+ drawn.every((d) => typeof FS_OF[d.cls] === 'number'),
320
+ [...new Set(drawn.filter((d) => !FS_OF[d.cls]).map((d) => d.cls))].join(', '))
321
+ /* THE TWO NUMBERS MUST BE ONE NUMBER. Every reserved text box says its height is
322
+ lineHeight × fontSize; if the sheet draws that class at a different size, then the
323
+ layout was computed for a font nobody renders — which is exactly how a `9px` label
324
+ came to be measured at `9.5px`. This check re-derives the size from the box. */
325
+ const sizeMismatch = FIT.filter(([label, x, y, w, h]) => {
326
+ const cls = Object.keys(FS_OF).find((c) => Math.abs(FS_OF[c] * L.lineHeight - h) < 0.06)
327
+ return !cls || Math.abs(String(label).length * L.charW * FS_OF[cls] - w) > 0.06
328
+ })
329
+ check('8.6 every text box height/width agrees with the font-size the sheet declares for its class',
330
+ sizeMismatch.length === 0, sizeMismatch.slice(0, 3).map((f) => JSON.stringify(f[0])).join(', '))
331
+
332
+ /* -- 8.7 no band may contain two columns closer than 8 units -------------
333
+ THE SHAPE OF THE DEFECT. The old chart put the hook channel at x=668..896 while the
334
+ sequence row ran to x=900: two columns, one horizontal band, OVERLAPPING x-ranges.
335
+ The test therefore looks for exactly that shape — a horizontal band (a run of boxes
336
+ whose y-intervals connect) that contains two x-clusters closer than 8 units — and
337
+ not at vertical stacking, which is how the whole chart is laid out. */
338
+ const byY = [...BOXES].sort((a, b) => a[2] - b[2])
339
+ const bandGroups = []
340
+ for (const b of byY) {
341
+ const hit = bandGroups.find((g) => g.boxes.some((o) => {
342
+ const iy = Math.min(o[2] + o[4], b[2] + b[4]) - Math.max(o[2], b[2])
343
+ const ix = Math.min(o[1] + o[3], b[1] + b[3]) - Math.max(o[1], b[1])
344
+ return iy > EPS && ix > -8
345
+ }))
346
+ if (hit) hit.boxes.push(b); else bandGroups.push({ boxes: [b] })
347
+ }
348
+ const bandCollisions = []
349
+ for (const g of bandGroups) {
350
+ const clusters = []
351
+ for (const b of g.boxes) {
352
+ const c = clusters.find((k) => Math.min(k[1], b[1] + b[3]) - Math.max(k[0], b[1]) > -8)
353
+ if (c) { c[0] = Math.min(c[0], b[1]); c[1] = Math.max(c[1], b[1] + b[3]); c[2].push(b[5]) }
354
+ else clusters.push([b[1], b[1] + b[3], [b[5]]])
355
+ }
356
+ for (let i = 0; i < clusters.length; i++) {
357
+ for (let j = i + 1; j < clusters.length; j++) {
358
+ const gap = Math.max(clusters[i][0], clusters[j][0]) - Math.min(clusters[i][1], clusters[j][1])
359
+ if (gap < 8) {
360
+ bandCollisions.push(`"${clusters[i][2][0]}" x${clusters[i][0]}..${clusters[i][1]} vs `
361
+ + `"${clusters[j][2][0]}" x${clusters[j][0]}..${clusters[j][1]} — gap ${gap.toFixed(2)}`)
362
+ }
363
+ }
364
+ }
365
+ }
366
+ check('8.7 no band holds two columns closer than 8 units: ' + bandGroups.length + ' bands, '
367
+ + bandCollisions.length + ' collisions', bandCollisions.length === 0, bandCollisions.slice(0, 3).join(' | '))
368
+ check('8.7 the diagram is laid out in bands rather than one plate of free-floating boxes: '
369
+ + bandGroups.length + ' bands', bandGroups.length >= 8)
370
+
371
+ /* -- 8.8 the drawing cannot be scaled below its own layout ---------------- */
372
+ const dgW = Number((CSS.match(/--dg-w:\s*([\d.]+)px/) || [, ''])[1])
373
+ const dgVarUsedBySvg = /\.diagram svg\{[^}]*min-width:var\(--dg-w\)/.test(CSS)
374
+ const svgW = Number((svgOpen.match(/\bwidth="([\d.]+)"/) || [, ''])[1])
375
+ check('8.8 the svg is sized from --dg-w and cannot shrink: --dg-w=' + dgW + ' svg width=' + svgW,
376
+ dgW === VW && svgW === VW && dgVarUsedBySvg)
377
+ check('8.8 the diagram wrapper scrolls horizontally in one strip (the tab-strip mechanism)',
378
+ /\.diagram\{[^}]*overflow-x:auto/.test(CSS) && /overscroll-behavior-inline:contain/.test(CSS))
379
+
380
+ /* -- 8.9 nothing is painted inside a positioned element's box ------------
381
+ Every absolutely/fixed positioned element in the sheet, with what it is for. A
382
+ positioned box may hold its own text; it may not sit on top of anything else. */
383
+ const POSITIONED = [
384
+ { sel: '.skip', why: 'the skip link, parked off-canvas at left:-9999px' },
385
+ { sel: '.rail::before', why: 'the sequence rail hairline, inside .rail' },
386
+ { sel: '.rail-dot', why: 'the rail marker, inside .rail' },
387
+ { sel: '.sections li::before', why: 'the section counter, in the li list gutter' },
388
+ { sel: '.ask-opts li::before', why: 'the option bullet, in the li gutter' },
389
+ ]
390
+ const positionedInSheet = [...CSS.matchAll(/([^{}]+)\{([^}]*position:\s*(?:absolute|fixed)[^}]*)\}/g)]
391
+ .map((m) => ({ sel: m[1].trim(), body: m[2] }))
392
+ const unexplained = positionedInSheet.filter((p) => !POSITIONED.some((k) => (p.sel + '||' + p.sel.split(',').join('||')).includes(k.sel)))
393
+ check('8.9 every position:absolute/fixed element is one of the known reserved-space constructs: '
394
+ + positionedInSheet.length + ' found', unexplained.length === 0,
395
+ unexplained.map((p) => p.sel).join(' | '))
396
+ check('8.9 the only off-canvas element is the skip link (nothing else is parked outside its container)',
397
+ /\.skip\{[^}]*position:absolute[^}]*left:-9999px/.test(CSS) && /\.skip:focus\{[^}]*left:0/.test(CSS))
398
+ const positionedInsideSvg = /position:\s*(absolute|fixed)/.test(svgSrc)
399
+ check('8.9 no positioned element exists inside the svg (all of it is in the reserved boxes)', !positionedInsideSvg)
400
+
401
+ /* -- 8.10 the sheet keeps the declarations that stop a card overflowing -- */
402
+ /* A BARE `1fr` is the defect: an `auto 1fr` label/value pair is fine (the value column
403
+ can shrink), and anything inside minmax() has already made its minimum explicit. */
404
+ const bareFr = [...CSS.matchAll(/grid-template-columns:([^;}]*)/g)]
405
+ .map((m) => ({ rule: m[0], tracks: m[1].trim() }))
406
+ .filter((t) => t.tracks.split(/\s+/).length === 1 && !/minmax|min\(/.test(t.tracks))
407
+ check('8.10 every single-track grid column is minmax()-style, not a bare 1fr: '
408
+ + (CSS.match(/minmax\(0,1fr\)/g) || []).length + ' explicit minmax(0,1fr) tracks',
409
+ bareFr.length === 0 && (CSS.match(/minmax\(0,1fr\)/g) || []).length >= 4,
410
+ bareFr.map((t) => t.rule).slice(0, 3).join(' | '))
411
+ check('8.10 citations are breakable (a nowrap citation is what pushed a card over its column)',
412
+ /\.cite\{[^}]*overflow-wrap:anywhere/.test(CSS) && !/\.cite\{[^}]*white-space:nowrap/.test(CSS))
413
+ check('8.10 the two-card grids cannot demand more width than their container has',
414
+ /\.detail-grid\{[^}]*minmax\(min\(320px,100%\),1fr\)/.test(CSS) && /\.rail-pair\{[^}]*minmax\(min\(280px,100%\),1fr\)/.test(CSS))
415
+
416
+ /* -- report --------------------------------------------------------------- */
417
+ console.log('check-workflow-map — independent structural check\n')
418
+ console.log(' file: ' + target)
419
+ console.log(' size: ' + html.length.toLocaleString('en-US') + ' bytes\n')
420
+ for (const p of passes) console.log(' PASS ' + p)
421
+ for (const f of fails) console.log(' FAIL ' + f)
422
+ console.log('\n ' + passes.length + ' passed, ' + fails.length + ' failed')
423
+ process.exit(fails.length === 0 ? 0 : 1)