clearotron 0.3.2-beta.11 → 0.3.2-beta.13

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.
Files changed (122) hide show
  1. package/.env.example +1 -1
  2. package/CONTRIBUTING.md +6 -5
  3. package/INSTALL.md +3 -4
  4. package/README.md +2 -1
  5. package/bin/clearotron.mjs +14 -0
  6. package/bin/connect.mjs +68 -3
  7. package/bin/example.mjs +12 -1
  8. package/bin/key.mjs +6 -1
  9. package/bin/onboard.mjs +23 -3
  10. package/bin/passphrase.mjs +4 -2
  11. package/bin/start.mjs +18 -6
  12. package/build-info.json +2 -2
  13. package/docs/CLIENT-MCP.md +6 -6
  14. package/docs/DELIVERY.md +3 -3
  15. package/docs/ONBOARDING.md +1 -1
  16. package/docs/PORTAL.md +3 -3
  17. package/docs/RELEASES.md +1 -1
  18. package/docs/SECURITY.md +1 -1
  19. package/docs/architecture/04-configuration-reference.md +4 -4
  20. package/docs/architecture/05-config-governance.md +10 -10
  21. package/docs/architecture/06-operations-runbook.md +3 -3
  22. package/docs/architecture/07-quality-and-audit.md +1 -1
  23. package/docs/architecture/08-development-guide.md +2 -2
  24. package/docs/architecture/09-security-and-data.md +1 -1
  25. package/docs/decisions/0002-no-dark-functionality.md +1 -1
  26. package/docs/decisions/0006-what-the-public-repository-carries.md +3 -3
  27. package/driver/CHANGELOG.md +29 -0
  28. package/driver/ask-ledger.mjs +1 -1
  29. package/driver/band-shape.mjs +1 -1
  30. package/driver/bundled-demos.mjs +2 -2
  31. package/driver/card-budget.mjs +2 -2
  32. package/driver/case-law-ledger.mjs +2 -2
  33. package/driver/connotation-search.mjs +5 -5
  34. package/driver/contract-e3-backlog.mjs +13 -13
  35. package/driver/contract-vocabulary.mjs +5 -5
  36. package/driver/coverage-form.mjs +2 -2
  37. package/driver/demo-container.mjs +26 -2
  38. package/driver/disposition-tool.mjs +2 -2
  39. package/driver/engine/CONTRACT.md +3 -3
  40. package/driver/engine/mcp/gather-config.mjs +1 -1
  41. package/driver/engine/mcp/recording-server.mjs +1 -1
  42. package/driver/engine/mcp/supplemental.mjs +22 -5
  43. package/driver/engine/openai-agent.mjs +2 -2
  44. package/driver/findings-model.mjs +21 -6
  45. package/driver/gateway.mjs +1 -1
  46. package/driver/knockout-next-step.mjs +72 -0
  47. package/driver/named-band.mjs +1 -1
  48. package/driver/package.json +1 -1
  49. package/driver/pipeline-knockout.mjs +27 -1
  50. package/driver/pipeline.mjs +4 -4
  51. package/driver/placement-union.mjs +1 -1
  52. package/driver/portal-mcp-client.mjs +1 -1
  53. package/driver/portal-report.mjs +25 -3
  54. package/driver/portal-service.mjs +25 -13
  55. package/driver/predelivery-lint.mjs +9 -4
  56. package/driver/progress.mjs +37 -1
  57. package/driver/publish/render-knockout.mjs +14 -9
  58. package/driver/publish/render.mjs +6 -6
  59. package/driver/record-discard.mjs +1 -1
  60. package/driver/register-availability.mjs +1 -1
  61. package/driver/register-count.mjs +1 -1
  62. package/driver/register-digest-record.mjs +1 -1
  63. package/driver/register-plan.mjs +81 -11
  64. package/driver/report-card-record.mjs +2 -2
  65. package/driver/result-noun-fields.mjs +3 -1
  66. package/driver/roster-verdict.mjs +2 -2
  67. package/driver/search-policy.mjs +1 -1
  68. package/driver/skeptic-record.mjs +1 -1
  69. package/driver/stages-knockout.mjs +1 -1
  70. package/driver/stages.mjs +1 -1
  71. package/driver/suite-census.json +72 -24
  72. package/driver/systemd/README.md +1 -1
  73. package/driver/unit-inventory.mjs +2 -2
  74. package/driver/verify-knockout.mjs +0 -27
  75. package/driver/verify.mjs +1 -1
  76. package/mcp-server/CHANGELOG.md +8 -0
  77. package/mcp-server/CONNECT.md +8 -8
  78. package/mcp-server/lib/runs.mjs +1 -1
  79. package/mcp-server/package.json +1 -1
  80. package/mcp-server/serve.mjs +27 -0
  81. package/package.json +1 -1
  82. package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
  83. package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
  84. package/portal-ui/dist/index.html +2 -2
  85. package/portal-ui/package.json +1 -1
  86. package/providers/free-tier/src/capabilities.js +2 -2
  87. package/providers/jx/src/core.js +3 -3
  88. package/providers/jx/src/turn-envelope.mjs +1 -1
  89. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  90. package/providers/oauth-mcp-bridge/package.json +1 -1
  91. package/providers/perplexity/README.md +1 -1
  92. package/providers/signa/src/capabilities.js +2 -2
  93. package/providers/signa/src/core.js +2 -2
  94. package/providers/uspto-local/src/core.js +1 -1
  95. package/providers/uspto-local/src/sync.js +1 -1
  96. package/scripts/README.md +2 -6
  97. package/scripts/ask-ai-render-check.mjs +26 -1
  98. package/scripts/citation-anchor-report.mjs +1 -1
  99. package/scripts/citation-line-check.mjs +3 -3
  100. package/scripts/dead-names.mjs +17 -16
  101. package/scripts/e2e.mjs +1 -1
  102. package/scripts/env-audit.mjs +7 -1
  103. package/scripts/env-classify.mjs +1 -1
  104. package/scripts/pack-publishable.mjs +1 -1
  105. package/scripts/release-artifact-seal.mjs +2 -2
  106. package/scripts/repo-writes.mjs +42 -0
  107. package/scripts/report-sections-render-check.mjs +245 -0
  108. package/scripts/report-theme-render-check.mjs +31 -3
  109. package/scripts/settings-render-check.mjs +15 -8
  110. package/scripts/strip-tracker-citations.mjs +4 -4
  111. package/scripts/test-run.mjs +108 -6
  112. package/shared/browser-temp-root.mjs +10 -3
  113. package/shared/client-door.mjs +38 -6
  114. package/shared/connect-clients.mjs +2 -0
  115. package/shared/identifier-scan.mjs +2 -2
  116. package/shared/invocation.mjs +1 -1
  117. package/shared/parent-watch.mjs +33 -0
  118. package/shared/reference-guard-classes.mjs +5 -3
  119. package/shared/running-start.mjs +14 -3
  120. package/shared/scope.mjs +22 -3
  121. package/shared/stdio-connect.mjs +39 -18
  122. package/shared/writing-standard-classes.mjs +2 -3
@@ -119,3 +119,45 @@ export function explainRepoWrites(rows) {
119
119
  " touching the tree to tell the two apart.)",
120
120
  ];
121
121
  }
122
+
123
+ // ── the home the run executes as ────────────────────────────────────────────────────────────────────
124
+ //
125
+ // THE SAME RULE, ONE FOLDER OUT. The suite is documented as offline with no side effects, and a fresh
126
+ // clone's run left `~/trademark/telemetry/trademark-mcp-access.jsonl` behind (measured 2026-09-19, one
127
+ // line per run): a test spawned the stdio server with the real home, and `doctor` on that machine then
128
+ // reported the client door's access log as being written. These are the product's own folders under a
129
+ // home: the pool, workspace, queue and telemetry, and the settings, records and revocation list. A run
130
+ // leaves them as it found them. The rest of a home — npm's cache, a browser's profile — belongs to the
131
+ // tools a run uses, and is theirs to write.
132
+
133
+ /** The product's own folders under a home, relative to it. */
134
+ export const HOME_DATA = Object.freeze(["trademark", join(".config", "clearotron")]);
135
+
136
+ /** Every path under the home's product folders, stamped. A folder that does not exist is not walked. */
137
+ export function snapshotHome(home) {
138
+ const out = new Map();
139
+ for (const rel of HOME_DATA) {
140
+ const p = join(resolve(home), rel);
141
+ let isDir = false;
142
+ try { isDir = lstatSync(p).isDirectory(); } catch { continue; }
143
+ if (!isDir) { out.set(p, "not-a-directory"); continue; }
144
+ out.set(p, "dir");
145
+ for (const [k, v] of snapshotRepo(p)) out.set(k, v);
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** What a reader is told when a run wrote under the home's product folders. */
151
+ export function explainHomeWrites(rows, home) {
152
+ return [
153
+ "",
154
+ `[test-run] THIS RUN WROTE UNDER THE HOME IT RAN AS (${home}), in the product's own folders:`,
155
+ ...rows,
156
+ "",
157
+ " A test that starts a product command hands it a HOME under its own temp directory. Without one, the",
158
+ " command reads and writes the pool, queue, telemetry and settings of whoever runs the suite.",
159
+ "",
160
+ " (A service of this account writing there while the run was in flight prints this too. Run again",
161
+ " with HOME set to an empty temp directory to tell the two apart.)",
162
+ ];
163
+ }
@@ -0,0 +1,245 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ // report-sections-render-check.mjs — the report's section strip sits under the Reads and shows how far the
4
+ // reader has got, measured in a real browser.
5
+ //
6
+ // node scripts/report-sections-render-check.mjs [--shots <dir>] [--keep]
7
+ //
8
+ // THE DEFECT, from the owner on the test instance (2026-09-19): scrolling the report never moved the strip,
9
+ // so "Summary" stayed marked whatever was in view; and with sixteen reads the strip sat BESIDE the Reads, the
10
+ // reads wrapping into two ragged columns while the strip floated at mid-height. His ruling: the strip goes
11
+ // under the Reads on its own full-width row, and it shows PROGRESS. Every section reached so far is marked,
12
+ // none at the top, and scrolling back up un-marks them the same way. A click still jumps.
13
+ //
14
+ // WHAT IS MEASURED: a real renderer's report (the demo's full country search, put through the demo's own
15
+ // publisher and the portal's own embed preparation) inside the real portal build, with twelve reads of one
16
+ // mark. At 1440 and 400 wide, in both themes: the strip's row is under the Reads row; then the page is
17
+ // scrolled so each section's start reaches the bottom of the pinned header, down and back up, and the
18
+ // number of filled entries must equal the number of sections reached. Where each section starts is read
19
+ // from INSIDE the frame, independently of what the document reports to the portal. Last, one entry is
20
+ // pressed and the page must jump to that section and mark it.
21
+ import { spawn, spawnSync } from 'node:child_process'
22
+ import { createServer } from 'node:http'
23
+ import { readFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
24
+ import { join, dirname, extname, normalize } from 'node:path'
25
+ import { tmpdir } from 'node:os'
26
+ import { fileURLToPath } from 'node:url'
27
+ import { reapOnExit } from '../shared/reap-on-exit.mjs'
28
+ import { browserRun } from '../shared/browser-temp-root.mjs'
29
+ import { prepareReportForEmbed } from '../driver/portal-report.mjs'
30
+
31
+ const HERE = dirname(fileURLToPath(import.meta.url))
32
+ const ROOT = join(HERE, '..')
33
+ const DIST = join(ROOT, 'portal-ui', 'dist')
34
+ const keep = process.argv.includes('--keep')
35
+ const argValue = (flag) => (process.argv.includes(flag) ? process.argv[process.argv.indexOf(flag) + 1] : null)
36
+ const shotsDir = argValue('--shots')
37
+ if (!existsSync(join(DIST, 'index.html'))) { console.error(`no build at ${DIST} — run: npm run build:ui`); process.exit(2) }
38
+ if (shotsDir) mkdirSync(shotsDir, { recursive: true })
39
+
40
+ // ── A REAL REPORT ───────────────────────────────────────────────────────────────────────────────────────
41
+ const pool = mkdtempSync(join(tmpdir(), 'sections-check-'))
42
+ const published = spawnSync(process.execPath, [join(ROOT, 'bin', 'example.mjs'), '--once', '--pool', pool, '--product', 'full-country-search'], { encoding: 'utf8' })
43
+ if (published.status !== 0) { console.error(`the demo publisher failed:\n${published.stderr || published.stdout}`); process.exit(2) }
44
+ const runDir = readdirSync(pool).map((d) => join(pool, d)).find((d) => existsSync(join(d, 'report.html')))
45
+ if (!runDir) { console.error(`the demo publisher wrote no report under ${pool}`); process.exit(2) }
46
+ const prepared = prepareReportForEmbed(readFileSync(join(runDir, 'report.html'), 'utf8'), {})
47
+ const SECTIONS = prepared.sections.map((s) => s.id)
48
+ if (SECTIONS.length < 4) { console.error(`the report announces ${SECTIONS.length} sections; this check needs a long one`); process.exit(2) }
49
+
50
+ // ── TWELVE READS OF ONE MARK ────────────────────────────────────────────────────────────────────────────
51
+ const KEY = 'acme'
52
+ const MARK = 'VENQORI'
53
+ const PRODUCTS = [['full-country-search', 'Full country search'], ['knockout-search', 'Knockout search'],
54
+ ['multi-country-focus-search', 'Multi-country focus search'], ['global-preliminary-search', 'Global preliminary search']]
55
+ const RUNS = Array.from({ length: 12 }, (_, i) => {
56
+ const [product, productName] = PRODUCTS[i % PRODUCTS.length]
57
+ const day = String(18 - i).padStart(2, '0')
58
+ return {
59
+ account: KEY, kind: 'clearance', state: 'delivered', band: 'Moderate', tone: 'medium', bands: [], marks: [],
60
+ reportSchema: 2, held: false, report: `/portal/report/tmpa-read-${i + 1}/`, step: null, stepN: null, stepTotal: null,
61
+ reason: null, failedStage: null, pausedKind: null, resetsAt: null, startedAt: null, queuePos: null,
62
+ projectKey: null, projectName: null, markName: MARK, title: MARK, date: `2026-09-${day}`,
63
+ issuedAt: `2026-09-${day}T12:00:00.000Z`, product, productName, runId: `tmpa-read-${i + 1}`,
64
+ }
65
+ })
66
+ const RUN_ID = RUNS[0].runId
67
+
68
+ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json', '.png': 'image/png', '.woff2': 'font/woff2' }
69
+ const json = (res, body) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)) }
70
+ const file = (res, full) => {
71
+ if (!existsSync(full)) { res.writeHead(404); res.end('no'); return }
72
+ res.writeHead(200, { 'content-type': MIME[extname(full)] ?? 'application/octet-stream' })
73
+ res.end(readFileSync(full))
74
+ }
75
+ const server = createServer((req, res) => {
76
+ const p = decodeURIComponent(new URL(req.url, 'http://localhost').pathname)
77
+ if (p === '/portal/api/me') return json(res, { email: 'reader@example-firm.com', permissions: { run: true, manage: false }, access: [{ kind: 'account', account: KEY }], accounts: [KEY], accountNames: { [KEY]: 'Acme' } })
78
+ if (p === '/portal/admin/roster') return json(res, { customers: [{ key: KEY, name: 'Acme' }] })
79
+ if (p === '/portal/admin/families') return json(res, { of: {}, names: {} })
80
+ if (p === '/portal/api/runs') return json(res, { runs: RUNS })
81
+ if (p === '/portal/api/searches') return json(res, { account: KEY, products: [], recipes: [], read: { available: false, maxBrief: 0, note: null } })
82
+ if (p === '/portal/api/mcp-access') return json(res, { url: null, keyUrl: null, email: 'reader@example-firm.com', enabled: false, stdio: null, aiConnected: null, offers: [] })
83
+ if (/^\/portal\/api\/run\/[^/]+\/summary$/.test(p)) return json(res, { summary: [] })
84
+ // Every read serves the one real document; its own resources resolve against the run directory and the
85
+ // pool, as they do from a real pool.
86
+ const inReport = /^\/portal\/report\/[^/]+\/(.*)$/.exec(p)
87
+ if (inReport) {
88
+ if (!inReport[1]) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(prepared.html) }
89
+ return file(res, join(runDir, normalize(inReport[1]).replace(/^(\.\.[/\\])+/, '')))
90
+ }
91
+ if (p.startsWith('/portal/report/')) return file(res, join(pool, normalize(p.slice('/portal/report/'.length)).replace(/^(\.\.[/\\])+/, '')))
92
+ const base = p.split('?')[0]
93
+ return file(res, join(DIST, base === '/' || (base.startsWith('/portal') && !base.includes('.')) ? '/index.html' : base.replace(/^\/portal/, '')))
94
+ })
95
+ await new Promise((r) => server.listen(0, '127.0.0.1', r))
96
+ const origin = `http://127.0.0.1:${server.address().port}`
97
+
98
+ // ── THE BROWSER ────────────────────────────────────────────────────────────────────────────────────────
99
+ const { profile, env: chromeEnv, keep: keepRoot } = browserRun('report-sections-check-')
100
+ if (keep) keepRoot()
101
+ const chrome = spawn('google-chrome', [
102
+ '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
103
+ '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1',
104
+ `--user-data-dir=${profile}`, '--window-size=1440,900', '--remote-debugging-port=0', 'about:blank',
105
+ ], { stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: chromeEnv })
106
+ reapOnExit(chrome)
107
+ const wsUrl = await new Promise((resolve, reject) => {
108
+ let devtools = ''
109
+ const t = setTimeout(() => reject(new Error(`chrome reported no devtools endpoint in 60s:\n${devtools || '(nothing)'}`)), 60000)
110
+ chrome.stderr.on('data', (c) => { devtools += c; const m = devtools.match(/ws:\/\/[^\s]+/); if (m) { clearTimeout(t); resolve(m[0]) } })
111
+ })
112
+ const ws = new WebSocket(wsUrl)
113
+ let id = 0
114
+ const pending = new Map()
115
+ // The report's frame is sandboxed to an opaque origin, so Chrome runs it as a process of its own and it
116
+ // is not in the page's frame tree. It is attached to as its own target; the newest one is the document on
117
+ // screen.
118
+ let frameSession = null
119
+ ws.addEventListener('message', (e) => {
120
+ const m = JSON.parse(e.data)
121
+ if (m.method === 'Target.attachedToTarget' && m.params?.targetInfo?.type === 'iframe') frameSession = m.params.sessionId
122
+ if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) }
123
+ })
124
+ await new Promise((r) => ws.addEventListener('open', r))
125
+ const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) })
126
+ const { result: targets } = await send('Target.getTargets')
127
+ const page = targets.targetInfos.find((t) => t.type === 'page')
128
+ const { result: sess } = await send('Target.attachToTarget', { targetId: page.targetId, flatten: true })
129
+ const sessionId = sess.sessionId
130
+ const cmd = (method, params) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId, method, params })) })
131
+ const value = async (expr) => (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value ?? null
132
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms))
133
+ const until = async (expr, ms = 15000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await value(expr)) return true; await wait(100) } return false }
134
+ await cmd('Page.enable')
135
+ await cmd('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true })
136
+
137
+ // Where each section starts, read inside the frame's own document — not the list it posted to the portal.
138
+ const anchorsInFrame = async () => {
139
+ if (!frameSession) return null
140
+ const answer = await new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId: frameSession, method: 'Runtime.evaluate',
141
+ params: { expression: `${JSON.stringify(SECTIONS)}.map((id) => { const el = document.getElementById(id); return el ? Math.round(el.getBoundingClientRect().top + window.scrollY) : null })`, returnByValue: true } })) })
142
+ return answer.result?.result?.value ?? null
143
+ }
144
+ const strip = () => value(`[...document.querySelectorAll('nav.report-sections .report-section')].map((b) => (b.dataset.reached === 'true' ? 'y' : 'x') + (b.getAttribute('aria-current') === 'true' ? '*' : '')).join(' ')`)
145
+ const filled = (s) => (s ?? '').split(' ').filter((e) => e.startsWith('y')).length
146
+ const current = (s) => (s ?? '').split(' ').findIndex((e) => e.endsWith('*'))
147
+ // Put a document offset at the reading line — the pinned header's bottom, plus the margin a jump lands at.
148
+ const scrollToDocY = async (docY) => {
149
+ for (let i = 0; i < 6; i++) {
150
+ const delta = await value(`(() => { const f = document.querySelector('iframe'); const h = document.querySelector('.report-head');
151
+ const d = f.getBoundingClientRect().top + ${docY} - (h.getBoundingClientRect().bottom + 10);
152
+ window.scrollTo(0, window.scrollY + d); return d })()`)
153
+ await wait(250)
154
+ if (Math.abs(delta ?? 0) < 1) break
155
+ }
156
+ }
157
+ const atFoot = () => value(`window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2`)
158
+ const atTop = () => value(`window.scrollY <= 0`)
159
+ const shot = async (name) => {
160
+ if (!shotsDir) return
161
+ await wait(250)
162
+ const { result } = await cmd('Page.captureScreenshot', { format: 'png' })
163
+ writeFileSync(join(shotsDir, name), Buffer.from(result.data, 'base64'))
164
+ }
165
+
166
+ const fail = []
167
+ const ok = []
168
+ for (const width of [1440, 400]) {
169
+ await cmd('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 600 })
170
+ for (const theme of ['light', 'dark']) {
171
+ const at = `${width} ${theme}`
172
+ await cmd('Page.navigate', { url: `${origin}/portal/` })
173
+ await value(`(() => { try { localStorage.setItem('cordillera-theme', '${theme}') } catch (e) {} return 1 })()`)
174
+ await cmd('Page.navigate', { url: `${origin}/portal/result/${RUN_ID}` })
175
+ if (!(await until(`document.querySelectorAll('nav.report-sections .report-section').length === ${SECTIONS.length} && document.querySelectorAll('.report-nav .pill').length === ${RUNS.length}`))) {
176
+ fail.push(`${at}: the header never showed ${RUNS.length} reads and ${SECTIONS.length} sections`)
177
+ continue
178
+ }
179
+ await wait(800)
180
+
181
+ // THE PLACEMENT: the strip's row is under the Reads row and starts where it starts.
182
+ const box = await value(`(() => { const r = document.querySelector('.report-nav > div').getBoundingClientRect(); const s = document.querySelector('nav.report-sections').getBoundingClientRect();
183
+ return { readsBottom: r.bottom, readsLeft: r.left, stripTop: s.top, stripLeft: s.left, stripRight: s.right, navRight: document.querySelector('.report-nav').getBoundingClientRect().right } })()`)
184
+ if (!box) fail.push(`${at}: the Reads row or the strip is missing`)
185
+ else if (box.stripTop < box.readsBottom - 1) fail.push(`${at}: the strip (top ${box.stripTop}) is beside the Reads (bottom ${box.readsBottom}), not under them`)
186
+ else if (Math.abs(box.stripLeft - box.readsLeft) > 1) fail.push(`${at}: the strip starts at ${box.stripLeft}, not where the Reads start (${box.readsLeft})`)
187
+ else if (Math.abs(box.stripRight - box.navRight) > 1) fail.push(`${at}: the strip's row is not the header's full width`)
188
+ else ok.push(`${at}: the strip is its own full-width row under the ${RUNS.length} reads`)
189
+ await value('window.scrollTo(0, 0)')
190
+ await wait(300)
191
+ await shot(`sections-${width}-${theme}-top.png`)
192
+
193
+ // THE PROGRESS, down and back up. Nothing at the top; one more filled at each section's start.
194
+ const anchors = await anchorsInFrame()
195
+ if (!anchors || anchors.some((a) => a === null)) { fail.push(`${at}: could not read where the sections start inside the frame`); continue }
196
+ const seen = []
197
+ const expectAt = async (label, want, wantCurrent) => {
198
+ const s = await strip()
199
+ seen.push(`${label}=${s}`)
200
+ if (filled(s) !== want || (wantCurrent !== undefined && current(s) !== wantCurrent)) fail.push(`${at}: at ${label} the strip reads "${s}", expected ${want} filled`)
201
+ }
202
+ await expectAt('top', 0, -1)
203
+ // One step into the document, the first section is reached.
204
+ await value('window.scrollTo(0, 40)')
205
+ await wait(300)
206
+ await expectAt('40px down', 1, 0)
207
+ const order = [...SECTIONS.keys(), ...[...SECTIONS.keys()].reverse().slice(1)]
208
+ for (const i of order) {
209
+ await scrollToDocY(anchors[i])
210
+ // The first section starts under the header, so reaching its start is the top of the page, where
211
+ // the owner's rule is that nothing is marked yet.
212
+ const [top, foot] = [await atTop(), await atFoot()]
213
+ await expectAt(SECTIONS[i], top ? 0 : foot ? SECTIONS.length : i + 1, top ? -1 : foot ? SECTIONS.length - 1 : i)
214
+ if (i === 1 && width === 1440) await shot(`sections-${width}-${theme}-two-reached.png`)
215
+ }
216
+ // Between two starts, the count is the one behind.
217
+ await scrollToDocY(Math.round((anchors[1] + anchors[2]) / 2))
218
+ await expectAt(`between ${SECTIONS[1]} and ${SECTIONS[2]}`, 2, 1)
219
+ await value('window.scrollTo(0, 0)')
220
+ await wait(300)
221
+ await expectAt('top again', 0, -1)
222
+ if (!fail.some((f) => f.startsWith(at))) ok.push(`${at}: progress followed the scroll both ways — ${seen.join(', ')}`)
223
+
224
+ // A CLICK STILL JUMPS, and the section jumped to counts as reached.
225
+ const target = 2
226
+ const before = await value('window.scrollY')
227
+ await value(`document.querySelectorAll('nav.report-sections .report-section')[${target}].click()`)
228
+ let settled = before, last = -1
229
+ for (let i = 0; i < 40 && settled !== last; i++) { last = settled; await wait(150); settled = await value('window.scrollY') }
230
+ const s = await strip()
231
+ const foot = await atFoot()
232
+ if (!(settled > before)) fail.push(`${at}: pressing "${SECTIONS[target]}" did not move the page`)
233
+ else if (filled(s) !== (foot ? SECTIONS.length : target + 1)) fail.push(`${at}: after the jump to "${SECTIONS[target]}" the strip reads "${s}"`)
234
+ else ok.push(`${at}: a press jumps to "${SECTIONS[target]}" and marks it — ${s}`)
235
+ }
236
+ }
237
+
238
+ for (const line of ok) console.log(` ok ${line}`)
239
+ for (const line of fail) console.log(` FAIL ${line}`)
240
+ try { ws.close() } catch { /* going anyway */ }
241
+ try { process.kill(-chrome.pid, 'SIGKILL') } catch { /* already gone */ }
242
+ server.close()
243
+ if (!keep) rmSync(pool, { recursive: true, force: true })
244
+ console.log(fail.length ? `\nreport-sections-render-check: ${fail.length} measurement(s) failed.` : `\nreport-sections-render-check: the strip sits under the reads and follows the reader, both ways, at both widths and both themes.`)
245
+ process.exit(fail.length ? 1 : 0)
@@ -16,6 +16,12 @@
16
16
  // switch is pressed, never an attribute set by hand, and three things must hold on each press: the
17
17
  // document took the theme, its background went the right way, and the load id did not change — the page
18
18
  // repainted in place rather than reloading.
19
+ //
20
+ // AND ONE SECTION MENU, AT BOTH THEMES. The portal draws the report's section menu in its own header and
21
+ // the frame must carry none. The document served here is shaped as a report rendered before 2026-09-18,
22
+ // with the menu beside the report's header rather than inside it. That is most of the archive, and the
23
+ // shape that left a second menu in the frame, its current item red on red in the dark theme. The probe
24
+ // counts the menus inside the frame; the portal's are counted on the page.
19
25
  import { spawn } from 'node:child_process'
20
26
  import { createServer } from 'node:http'
21
27
  import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
@@ -53,7 +59,7 @@ const FRAMEWORK = { framework_key: 'house-triage', title: 'House triage',
53
59
  const PROBE = `<script>(function(){
54
60
  var id=Math.random().toString(36).slice(2);
55
61
  function post(){try{parent.postMessage({themeProbe:1,loadId:id,theme:document.documentElement.getAttribute('data-theme'),
56
- bg:getComputedStyle(document.body).backgroundColor},'*');}catch(e){}}
62
+ bg:getComputedStyle(document.body).backgroundColor,menus:document.querySelectorAll('nav.strip,[data-sec]').length},'*');}catch(e){}}
57
63
  new MutationObserver(post).observe(document.documentElement,{attributes:true,attributeFilter:['data-theme']});
58
64
  post();
59
65
  })();</script>`
@@ -61,7 +67,18 @@ const REPORT = (() => {
61
67
  const html = renderKnockoutHtml({ marks: [{ name: MARK, rating: 'Low', classes: [9], basis: 'Nothing identical in the field screened.',
62
68
  factors: ['No identical name was found.'], counterFactors: [], mitigation: '', assessment: '', findings: [] }],
63
69
  batch: { executiveSummary: 'One name screened.' } }, FRAMEWORK, { runId: RUN_ID, overall: 'Low', identity: { identity: 'Knockout search' }, issuedDate: '2026-09-18' })
64
- const served = prepareReportForEmbed(html, {}).html
70
+ // THE OLD SHAPE: today's menu moved to just after the header's closing tag, where the renderer put it
71
+ // before 2026-09-18. A document with no menu at all would make the count below prove nothing.
72
+ const strip = html.match(/<nav class="[^"]*\bstrip\b[^"]*"[^>]*>[\s\S]*?<\/nav>/)?.[0]
73
+ if (!strip) { console.error('the rendered report has no section menu, so this check could not build the archived shape'); process.exit(2) }
74
+ const without = html.replace(strip, '')
75
+ const head = without.indexOf('<div class="rep-stickyhead')
76
+ const tags = /<\/?div\b[^>]*>/g
77
+ tags.lastIndex = head
78
+ let depth = 0, m, close = -1
79
+ while ((m = tags.exec(without))) { depth += m[0].startsWith('</') ? -1 : 1; if (depth === 0) { close = m.index + m[0].length; break } }
80
+ if (close < 0) { console.error('the report header has no closing tag, so this check could not build the archived shape'); process.exit(2) }
81
+ const served = prepareReportForEmbed(without.slice(0, close) + strip + without.slice(close), {}).html
65
82
  const at = served.lastIndexOf('</body>')
66
83
  return at < 0 ? served + PROBE : served.slice(0, at) + PROBE + served.slice(at)
67
84
  })()
@@ -151,9 +168,19 @@ const press = () => value(`(() => { const b = document.querySelector('button[ari
151
168
 
152
169
  const fail = []
153
170
  const ok = []
171
+ // ONE MENU: none in the frame, one in the portal's header, under whichever theme is showing.
172
+ const menus = async (theme, probe) => {
173
+ const portal = await value(`document.querySelectorAll('nav.report-sections').length`)
174
+ if (!probe || typeof probe.menus !== 'number') fail.push(`${theme}: the frame never reported its section menus`)
175
+ else if (probe.menus !== 0) fail.push(`${theme}: the frame carries ${probe.menus} section-menu element(s) beside the portal's own`)
176
+ else ok.push(`${theme}: no section menu inside the frame`)
177
+ if (portal !== 1) fail.push(`${theme}: the portal draws ${portal} section menu(s) where it should draw one`)
178
+ else ok.push(`${theme}: the portal draws the one section menu`)
179
+ }
154
180
  const light = await settle('light')
155
181
  if (!light || light.theme !== 'light') fail.push(`the embedded report never took the portal's light theme — last report from the frame: ${JSON.stringify(light)}`)
156
182
  else ok.push(`light: the report took the portal's theme, ground ${light.bg} (luminance ${luminance(light.bg)?.toFixed(2)})`)
183
+ await menus('light', light)
157
184
  await shot('report-light.png')
158
185
 
159
186
  if (!(await press())) fail.push('the portal\'s theme switch is not on the Result screen')
@@ -165,6 +192,7 @@ else {
165
192
  if (light && dark.loadId !== light.loadId) fail.push('the report reloaded to change theme — it must repaint in place')
166
193
  else ok.push('dark: repainted in place, the same load of the document')
167
194
  }
195
+ await menus('dark', dark)
168
196
  await shot('report-dark.png')
169
197
 
170
198
  await press()
@@ -178,5 +206,5 @@ for (const line of fail) console.log(` FAIL ${line}`)
178
206
  try { ws.close() } catch { /* going anyway */ }
179
207
  try { process.kill(-chrome.pid, 'SIGKILL') } catch { /* already gone */ }
180
208
  server.close()
181
- console.log(fail.length ? `\nreport-theme-render-check: ${fail.length} measurement(s) failed.` : `\nreport-theme-render-check: the embedded report follows the portal's theme, in place.`)
209
+ console.log(fail.length ? `\nreport-theme-render-check: ${fail.length} measurement(s) failed.` : `\nreport-theme-render-check: the embedded report follows the portal's theme in place, under the portal's one section menu.`)
182
210
  process.exit(fail.length ? 1 : 0)
@@ -148,6 +148,10 @@ const sessionId = sess.sessionId
148
148
  const cmd = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId, method, params })) })
149
149
  await cmd('Page.enable')
150
150
  // A probe that throws says so, rather than returning nothing for the assertions after it to misread.
151
+ // A value pasted into code the page evaluates, as a JavaScript string or object literal. JSON.stringify
152
+ // alone leaves `<`, `>`, `/` and the two line separators as they are; escaped, they read the same once
153
+ // parsed and cannot close or break the code they are pasted into.
154
+ const jsLiteral = (v) => JSON.stringify(v).replace(/[<>\/\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
151
155
  const evalIn = async (expr) => {
152
156
  const r = (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result
153
157
  if (r?.exceptionDetails) console.error(` ! a probe threw: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`)
@@ -184,7 +188,7 @@ async function reload(ready, what) {
184
188
  }
185
189
 
186
190
  async function setTheme(theme) {
187
- await evalIn(`(() => { document.documentElement.setAttribute('data-theme', ${JSON.stringify(theme)}); try { localStorage.setItem('cordillera-theme', ${JSON.stringify(theme)}) } catch {} return true })()`)
191
+ await evalIn(`(() => { document.documentElement.setAttribute('data-theme', ${jsLiteral(theme)}); try { localStorage.setItem('cordillera-theme', ${jsLiteral(theme)}) } catch {} return true })()`)
188
192
  await sleep(250)
189
193
  }
190
194
 
@@ -381,7 +385,10 @@ const SPEC = {
381
385
  caseLawGap: 'Until these are set up, a Full country search still runs and its report discloses the case-law gap instead of reporting no adverse case law.',
382
386
  adminLine: 'To change the address, the permissions or the companies on it, contact your Clearotron administrator.',
383
387
  blurLine: 'On the top bar. It covers every mark and company on screen, and an open report whole. It stays as you left it on this computer.',
384
- reset: 'Lost the passphrase? Run clearotron passphrase --reset on the machine running this portal. It mints a new one and prints it once.',
388
+ // The sign-in page's wording is the owner's, approved 2026-09-19 and used verbatim.
389
+ identity: (email) => `This Clearotron has one user: ${email}. Enter its passphrase.`,
390
+ reset: 'The passphrase was printed once when this Clearotron first started. Lost it? Run clearotron passphrase --reset on the machine running this portal. It prints a new one, once, for the same user.',
391
+ key: 'A key from clearotron key issue is for an AI assistant, not for this page.',
385
392
  addPeople: 'To add people, put it behind a login system such as your company single sign-on. How to set that up',
386
393
  }
387
394
 
@@ -543,18 +550,18 @@ for (const theme of ['light', 'dark']) {
543
550
  await setTheme(theme)
544
551
  const closed = (await evalIn(signInProbe)) ?? {}
545
552
  out[`${label}-closed-${theme}`] = closed
546
- ok(closed.heading === 'Sign in' && closed.identity === `Clearotron portal, as ${EMAIL}.` && closed.field && closed.button === 'Sign in',
547
- `the card keeps its heading, identity line, field and button (saw ${JSON.stringify({ h: closed.heading, id: closed.identity, button: closed.button })})`)
548
- ok(closed.lead === 'This Clearotron signs in one person: you.' && closed.leadBold, `one bold line under the button (saw ${JSON.stringify(closed.lead)})`)
553
+ ok(closed.heading === 'Sign in' && closed.identity === SPEC.identity(EMAIL) && closed.field && closed.button === 'Sign in',
554
+ `the card keeps its heading, names the install's one user, and keeps the field and button (saw ${JSON.stringify({ h: closed.heading, id: closed.identity, button: closed.button })})`)
555
+ ok(!closed.lead && !closed.leadBold, `the "signs in one person" line is gone from under the button (saw ${JSON.stringify(closed.lead)})`)
549
556
  ok(closed.summary === 'Administrator help' && closed.open === false && closed.hintsShown === 0,
550
557
  `the administrator's lines sit in a closed "Administrator help" fold (saw ${JSON.stringify({ summary: closed.summary, open: closed.open, shown: closed.hintsShown })})`)
551
558
  if (!reset) await capture(`sign-in-help-closed-${theme}`)
552
559
  await press('.card details.fold > summary')
553
560
  const opened = (await evalIn(signInProbe)) ?? {}
554
561
  out[`${label}-open-${theme}`] = opened
555
- ok(opened.open === true && opened.hintsShown === 2, `pressing it opens both lines (saw ${JSON.stringify({ open: opened.open, shown: opened.hintsShown })})`)
556
- ok(opened.hints[0] === (reset ? SPEC.reset.replace('clearotron passphrase --reset', reset) : SPEC.reset) && opened.hints[1] === SPEC.addPeople,
557
- `the fold holds the product's two lines word for word (saw ${JSON.stringify(opened.hints)})`)
562
+ ok(opened.open === true && opened.hintsShown === 3, `pressing it opens its three lines (saw ${JSON.stringify({ open: opened.open, shown: opened.hintsShown })})`)
563
+ ok(opened.hints[0] === (reset ? SPEC.reset.replace('clearotron passphrase --reset', reset) : SPEC.reset) && opened.hints[1] === SPEC.key && opened.hints[2] === SPEC.addPeople,
564
+ `the fold holds the owner's three lines word for word (saw ${JSON.stringify(opened.hints)})`)
558
565
  ok(opened.setUp?.text === 'How to set that up' && opened.setUp?.href === LOGIN_IN_FRONT_DOC, `with the "How to set that up" link (saw ${JSON.stringify(opened.setUp)})`)
559
566
  ok(opened.codeInside && !opened.overflowsPage && opened.cardWidth <= 420,
560
567
  `the reset command stays inside the card at its own width (card ${opened.cardWidth}px, ${opened.codeLines} line(s), inside: ${opened.codeInside})`)
@@ -2,15 +2,15 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-only
3
3
  // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
4
  //
5
- // Removes the internal citation OPENER from comments and prose in the tree (tracker issue 309).
5
+ // Removes the internal citation OPENER from comments and prose in the tree.
6
6
  //
7
7
  // The form is `tracker issue NNN — ` standing at the head of a sentence, where the citation is not part
8
8
  // of what the sentence says but a label in front of it. Stripping the opener leaves the sentence intact:
9
9
  //
10
- // // tracker issue 1149 — the walk must start at the repository root
10
+ // // tracker issue NNNN — the walk must start at the repository root
11
11
  // // the walk must start at the repository root
12
12
  //
13
- // assert.ok(x, "Refs tracker issue 2075 — an absent file is a finding")
13
+ // assert.ok(x, "Refs tracker issue NNNN — an absent file is a finding")
14
14
  // assert.ok(x, "an absent file is a finding")
15
15
  //
16
16
  // WHY THE PATTERN LOOKS OVER-SPECIFIED. Three parts of it are load-bearing and each was measured, not
@@ -20,7 +20,7 @@
20
20
  // and the replacement is `$1`, which puts that opener back. Without the group the sweep deletes the
21
21
  // opening quote of every test name it touches, and a broken string literal is a syntax error in the
22
22
  // lucky cases and a changed assertion in the unlucky ones.
23
- // · `\s+` AFTER THE SEPARATOR, never `\s*`. `tracker issue 1149-12` is an ITEM suffix, not a citation
23
+ // · `\s+` AFTER THE SEPARATOR, never `\s*`. `tracker issue NNNN-12` is an ITEM suffix, not a citation
24
24
  // followed by prose: there is no space after its hyphen. `\s*` eats the item number.
25
25
  // · THE `i` FLAG. `Refs tracker issue NNN` is capitalised at the head of a commit-style line and is a
26
26
  // fifth of the corpus.
@@ -46,11 +46,11 @@
46
46
  // about paths it did not create is how a tidy-up becomes an outage.
47
47
 
48
48
  import { spawn } from "node:child_process";
49
- import { mkdtempSync, mkdirSync, rmSync, readdirSync, statSync, existsSync, readFileSync, symlinkSync, copyFileSync, chmodSync } from "node:fs";
49
+ import { mkdtempSync, mkdirSync, rmSync, readdirSync, statSync, existsSync, readFileSync, symlinkSync, copyFileSync, chmodSync, writeFileSync, realpathSync } from "node:fs";
50
50
  import { delimiter, dirname, join, parse as parsePath, resolve, sep } from "node:path";
51
51
  import { fileURLToPath } from "node:url";
52
- import { tmpdir } from "node:os";
53
- import { snapshotRepo, repoWrites, explainRepoWrites } from "./repo-writes.mjs";
52
+ import { tmpdir, homedir } from "node:os";
53
+ import { snapshotRepo, repoWrites, explainRepoWrites, snapshotHome, explainHomeWrites } from "./repo-writes.mjs";
54
54
 
55
55
 
56
56
  // ── TAIL — THIS WRAPPER READS BOTH SPELLINGS; IT DOES NOT TRANSLATE THE ENVIRONMENT ───────────
@@ -271,8 +271,8 @@ process.env.CLEAROTRON_DEMO_PROFILES ??= "1";
271
271
  // driver/test/*.test.mjs. They are indistinguishable from code defects. An agent who runs the suite on a
272
272
  // branch, sees 295 red, and diffs the failing NAMES against a baseline taken the same way sees zero
273
273
  // regressions and calls the branch clean — and it is, but roughly 190 tests never executed, and a real
274
- // regression inside any of them is invisible by exactly that arithmetic. It has already happened: 's
275
- // first full-suite comparison was taken against a 295-fail baseline.
274
+ // regression inside any of them is invisible by exactly that arithmetic. It has already happened: a
275
+ // full-suite comparison was taken against a 295-fail baseline.
276
276
  //
277
277
  // So: refuse, name what is missing, and name the command. REFUSE RATHER THAN INSTALL — this wrapper is
278
278
  // what CI and scripts/publication-scan.mjs run the suite through, and a wrapper that can start a network
@@ -699,7 +699,88 @@ mkdirSync(process.env.CLEAROTRON_SUITE_TELEMETRY_DIR, { recursive: true });
699
699
  // Taken HERE, at the last statement before the child exists, so nothing this runner does to the tree
700
700
  // between the two reads can be mistaken for something a test did. The comparison is in `close`, below.
701
701
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
702
+
703
+ // ── AND NO PRODUCT COMMAND A TEST STARTS REBUILDS THIS CHECKOUT'S PORTAL BUNDLE ─────────────────────
704
+ //
705
+ // `start` rebuilds `portal-ui/dist` when it is older than `portal-ui/src` — right for a reader whose pull
706
+ // left the bundle behind, and a write into this checkout when a test starts `start` from it. Eight driver
707
+ // files do. On a clone where somebody once built the portal and then pulled, the first of them to run
708
+ // rebuilt the bundle mid-suite and the guard above failed the run; the next run found the bundle fresh and
709
+ // passed (measured 2026-09-18). A fresh clone never sees it: with no bundle there is nothing to rebuild.
710
+ //
711
+ // THE SAME SEAT AS THE ENGINE SHIMS, AND NO PRODUCT CHANGE. `start` runs the bare word `npm`, so a shim at
712
+ // the front of PATH answers it for every child that keeps this PATH. It refuses exactly one command —
713
+ // `npm run build:ui` with this checkout as its working directory — and hands everything else to the npm
714
+ // it found on PATH. `start` reports a refused rebuild and carries on serving, which is its behaviour on
715
+ // any failed build. A child that composes its own PATH walks past this, and the guard above still
716
+ // catches what it writes.
717
+ function onPath(name, pathValue) {
718
+ for (const d of String(pathValue ?? "").split(delimiter).filter(Boolean)) {
719
+ const p = join(d, name);
720
+ try { if (statSync(p).isFile()) return p; } catch { /* not here */ }
721
+ }
722
+ return null;
723
+ }
724
+ // COUNTED, because the refusal itself is printed into a child that a test usually captures: the run
725
+ // reports how many rebuilds it turned away, so a stale bundle is named once where a reader can see it.
726
+ let npmShimDir = null;
727
+ if (process.platform !== "win32") {
728
+ const realNpm = onPath("npm", process.env.PATH);
729
+ if (realNpm) {
730
+ const q = (v) => `'${String(v).replaceAll("'", "'\\''")}'`;
731
+ npmShimDir = join(root, "npm-shim");
732
+ mkdirSync(npmShimDir, { recursive: true });
733
+ writeFileSync(join(npmShimDir, "npm"), [
734
+ "#!/bin/sh",
735
+ "# Written by scripts/test-run.mjs for one suite run; removed with the run's temp root.",
736
+ `if [ "$1" = run ] && [ "$2" = build:ui ] && [ "$(pwd -P)" = ${q(realpathSync(REPO_ROOT))} ]; then`,
737
+ " echo '[test-run] refused: npm run build:ui in the checkout. A suite run does not rebuild its portal bundle.' >&2",
738
+ ` echo refused >> ${q(join(npmShimDir, "refused"))}`,
739
+ " exit 1",
740
+ "fi",
741
+ `exec ${q(realNpm)} "$@"`,
742
+ "",
743
+ ].join("\n"), { mode: 0o755 });
744
+ process.env.PATH = npmShimDir + delimiter + process.env.PATH;
745
+ }
746
+ }
702
747
  const repoBefore = snapshotRepo(REPO_ROOT);
748
+ // AND THE HOME IT RUNS AS, read at the same moment for the same reason (`repo-writes.mjs` says which
749
+ // folders and why). HOME as the child inherits it, which is the home every unpinned product command in
750
+ // the run resolves.
751
+ const RUN_HOME = String(process.env.HOME ?? "").trim() || homedir();
752
+ const homeBefore = snapshotHome(RUN_HOME);
753
+
754
+ // ── AND NOTHING THE RUN MADE IS LEFT IN THE MACHINE'S TEMP DIRECTORY ────────────────────────────────
755
+ //
756
+ // The run's own root is removed on every exit. What escapes it is a child handed an environment without
757
+ // TMPDIR: it falls back to the machine's temp directory, and nothing removes what it made there. Measured
758
+ // 2026-09-19: fifteen `clearotron-demo-*` directories per `npm test`, from the demo's temporary sample
759
+ // copies, and 36 GB accumulated on one box. Named by the product prefix, so the guard reads only what
760
+ // this product makes; owned by this account, so another user's run is not ours to count.
761
+ //
762
+ // THE OUTERMOST RUN WATCHES THE MACHINE'S TEMP ROOTS. A nested run watches only its own base, and only
763
+ // when a test arms it (CT_TEMP_LEAK_GUARD=1): the runner's own tests drive nested runs while the rest of
764
+ // the suite is working, and a nested guard reading the shared root would count its neighbours' files.
765
+ const LEAK_PREFIXES = Object.freeze(["clearotron-demo-"]);
766
+ const OUTERMOST = !String(process.env.CT_TEST_MACHINE_TMP ?? "").trim();
767
+ const LEAK_ROOTS = OUTERMOST
768
+ ? [...new Set([MACHINE_TMP, REAL_TMP, ...PLATFORM_TMP].map((p) => resolve(p)))]
769
+ : String(process.env.CT_TEMP_LEAK_GUARD ?? "") === "1" ? [resolve(REAL_TMP)] : [];
770
+ function tempLeftovers() {
771
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
772
+ const out = new Set();
773
+ for (const r of LEAK_ROOTS) {
774
+ let names;
775
+ try { names = readdirSync(r); } catch { continue; }
776
+ for (const n of names) {
777
+ if (!LEAK_PREFIXES.some((p) => n.startsWith(p))) continue;
778
+ try { if (uid == null || statSync(join(r, n)).uid === uid) out.add(join(r, n)); } catch { /* gone already */ }
779
+ }
780
+ }
781
+ return out;
782
+ }
783
+ const tempBefore = tempLeftovers();
703
784
 
704
785
 
705
786
  child = spawn(argv[0], argv.slice(1), {
@@ -741,7 +822,14 @@ child.on("error", (e) => {
741
822
  });
742
823
 
743
824
  child.on("close", (code, signal) => {
825
+ let refusedBuilds = 0;
826
+ try { if (npmShimDir) refusedBuilds = readFileSync(join(npmShimDir, "refused"), "utf8").split("\n").filter(Boolean).length; }
827
+ catch { /* none refused */ }
744
828
  cleanup();
829
+ if (refusedBuilds) {
830
+ console.error(`[test-run] turned away ${refusedBuilds} rebuild(s) of this checkout's portal bundle: it is older than`);
831
+ console.error(" portal-ui/src. `npm run build:ui` brings it current; until then doctor's arms report it stale.");
832
+ }
745
833
  // The exit code IS the result — CI reads it. Never swallow a failure to report a tidy cleanup.
746
834
  // A SIGNALLED RUN IS NOT EVIDENCE ABOUT WRITES: it was cancelled mid-flight, so a half-finished
747
835
  // fixture proves nothing and re-raising is the honest answer. The guard below never runs on that path.
@@ -749,8 +837,22 @@ child.on("close", (code, signal) => {
749
837
 
750
838
  const wrote = repoWrites(repoBefore, snapshotRepo(REPO_ROOT), REPO_ROOT);
751
839
  if (wrote.length) for (const line of explainRepoWrites(wrote)) console.error(line);
840
+ const wroteHome = repoWrites(homeBefore, snapshotHome(RUN_HOME), RUN_HOME);
841
+ if (wroteHome.length) for (const line of explainHomeWrites(wroteHome, RUN_HOME)) console.error(line);
842
+ const leftInTemp = [...tempLeftovers()].filter((p) => !tempBefore.has(p)).sort();
843
+ if (leftInTemp.length) {
844
+ console.error("");
845
+ console.error(`[test-run] THIS RUN LEFT ${leftInTemp.length} DIRECTOR${leftInTemp.length === 1 ? "Y" : "IES"} IN THE MACHINE'S TEMP DIRECTORY:`);
846
+ for (const p of leftInTemp) console.error(` + ${p}`);
847
+ console.error("");
848
+ console.error(" A child handed an environment without TMPDIR puts its temporary files in the machine's temp");
849
+ console.error(" directory, where nothing removes them. Pass `TMPDIR: tmpdir()` in that child's env, so they land");
850
+ console.error(" in this run's own root, which is removed on every exit.");
851
+ console.error("");
852
+ console.error(" (Another run on this account making the same directories at the same time prints this too.)");
853
+ }
752
854
  // THIS MAY TURN A GREEN RUN RED. IT MUST NEVER TURN A RED RUN GREEN — a failing suite keeps its own
753
855
  // exit code, because what the tests found matters more than what they wrote while finding it.
754
856
  const childCode = code ?? 1;
755
- process.exit(childCode !== 0 ? childCode : (wrote.length ? 1 : 0));
857
+ process.exit(childCode !== 0 ? childCode : (wrote.length || wroteHome.length || leftInTemp.length ? 1 : 0));
756
858
  });