@christianirack/brainllm-runtime 0.0.0-stage → 0.1.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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/brain-anim.js +21 -0
  4. package/brain-ch.js +254 -0
  5. package/brain-consent.js +38 -0
  6. package/brain-data.js +547 -0
  7. package/brain-dev.js +100 -0
  8. package/brain-env.js +160 -0
  9. package/brain-err.js +242 -0
  10. package/brain-form.js +260 -0
  11. package/brain-guard.js +242 -0
  12. package/brain-hydrate.js +72 -0
  13. package/brain-i18n.js +111 -0
  14. package/brain-inspect.js +745 -0
  15. package/brain-island.js +106 -0
  16. package/brain-lazy.js +88 -0
  17. package/brain-mcp.js +58 -0
  18. package/brain-mock.js +80 -0
  19. package/brain-overlay.js +123 -0
  20. package/brain-persist.js +65 -0
  21. package/brain-router.js +353 -0
  22. package/brain-rpc.js +17 -0
  23. package/brain-schema.js +69 -0
  24. package/brain-wc.js +69 -0
  25. package/brain-why.js +198 -0
  26. package/brain-worker.js +66 -0
  27. package/brain.js +421 -0
  28. package/package.json +20 -4
  29. package/server/server.mjs +882 -0
  30. package/shim/dom.js +448 -0
  31. package/shim/probe.js +111 -0
  32. package/sw/brain-sw.js +103 -0
  33. package/themes/contrast.cssbrain +39 -0
  34. package/themes/dark.cssbrain +39 -0
  35. package/themes/glass.cssbrain +39 -0
  36. package/themes/paper.cssbrain +39 -0
  37. package/ui/button.brain +31 -0
  38. package/ui/center.brain +11 -0
  39. package/ui/checkbox.brain +13 -0
  40. package/ui/container.brain +11 -0
  41. package/ui/dialog.brain +29 -0
  42. package/ui/error-state.brain +53 -0
  43. package/ui/field.brain +34 -0
  44. package/ui/grid.brain +11 -0
  45. package/ui/input.brain +23 -0
  46. package/ui/lang-switch.brain +38 -0
  47. package/ui/menu.brain +64 -0
  48. package/ui/row.brain +12 -0
  49. package/ui/select.brain +29 -0
  50. package/ui/sidebar.brain +17 -0
  51. package/ui/spacer.brain +12 -0
  52. package/ui/stack.brain +10 -0
  53. package/ui/table.brain +73 -0
  54. package/ui/tabs.brain +33 -0
  55. package/ui/toast.brain +45 -0
package/shim/dom.js ADDED
@@ -0,0 +1,448 @@
1
+ // Minimal DOM to test the runtime in Node with no dependencies. Covers only what brain.js uses.
2
+ // Installs `document`, `Event` and `MouseEvent` on globalThis. `stats.moves` counts insertions of already-connected nodes.
3
+
4
+ export const stats = { moves: 0 }
5
+ const VOID = new Set(['area', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr'])
6
+ const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
7
+ const unesc = s => s.replace(/&(amp|lt|gt|quot|#39);/g, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[e])
8
+
9
+ export class Event {
10
+ constructor(type, init = {}) {
11
+ this.type = type
12
+ this.bubbles = !!init.bubbles
13
+ this.cancelable = !!init.cancelable
14
+ this.target = null
15
+ this.currentTarget = null
16
+ this.cancelBubble = false
17
+ this.defaultPrevented = false
18
+ this._p = []
19
+ }
20
+ composedPath() {
21
+ return this._p
22
+ }
23
+ stopImmediatePropagation() {
24
+ this.cancelBubble = this._stop = true
25
+ }
26
+ stopPropagation() {
27
+ this.cancelBubble = true
28
+ }
29
+ preventDefault() {
30
+ this.defaultPrevented = true
31
+ }
32
+ }
33
+ export class MouseEvent extends Event {}
34
+
35
+ // Minimal MutationObserver: only records for nodes connected to the document, plus takeRecords() (used by brain-inspect).
36
+ const MOS = new Set()
37
+ const rec = (type, target) => {
38
+ if (MOS.size && target.isConnected) for (const o of MOS) o._q.push({ type, target })
39
+ }
40
+ export class MutationObserver {
41
+ constructor(f) {
42
+ this._f = f
43
+ this._q = []
44
+ }
45
+ observe() {
46
+ MOS.add(this)
47
+ }
48
+ disconnect() {
49
+ MOS.delete(this)
50
+ this._q = []
51
+ }
52
+ takeRecords() {
53
+ const q = this._q
54
+ this._q = []
55
+ return q
56
+ }
57
+ }
58
+
59
+ class Node {
60
+ constructor(type, doc) {
61
+ this.nodeType = type
62
+ this.ownerDocument = doc
63
+ this.parentNode = null
64
+ this.childNodes = []
65
+ this._l = { cap: {}, bub: {} }
66
+ }
67
+ get firstChild() {
68
+ return this.childNodes[0] || null
69
+ }
70
+ get lastChild() {
71
+ return this.childNodes[this.childNodes.length - 1] || null
72
+ }
73
+ get nextSibling() {
74
+ const p = this.parentNode
75
+ return p ? p.childNodes[p.childNodes.indexOf(this) + 1] || null : null
76
+ }
77
+ get previousSibling() {
78
+ const p = this.parentNode
79
+ return p ? p.childNodes[p.childNodes.indexOf(this) - 1] || null : null
80
+ }
81
+ get isConnected() {
82
+ let n = this
83
+ while (n.parentNode) n = n.parentNode
84
+ return n.nodeType === 9
85
+ }
86
+ insertBefore(n, ref) {
87
+ if (n.nodeType === 11) {
88
+ for (const c of [...n.childNodes]) this.insertBefore(c, ref)
89
+ return n
90
+ }
91
+ if (n.parentNode) {
92
+ if (n.isConnected) stats.moves++
93
+ n.parentNode.removeChild(n)
94
+ }
95
+ const i = ref ? this.childNodes.indexOf(ref) : -1
96
+ if (ref && i < 0) throw Error('ref is not a child')
97
+ i < 0 ? this.childNodes.push(n) : this.childNodes.splice(i, 0, n)
98
+ n.parentNode = this
99
+ rec('childList', this)
100
+ return n
101
+ }
102
+ appendChild(n) {
103
+ return this.insertBefore(n, null)
104
+ }
105
+ removeChild(n) {
106
+ const i = this.childNodes.indexOf(n)
107
+ if (i < 0) throw Error('not a child')
108
+ this.childNodes.splice(i, 1)
109
+ n.parentNode = null
110
+ rec('childList', this)
111
+ return n
112
+ }
113
+ remove() {
114
+ if (this.parentNode) this.parentNode.removeChild(this)
115
+ }
116
+ replaceWith(...ns) {
117
+ const p = this.parentNode
118
+ for (const n of ns) p.insertBefore(n, this)
119
+ this.remove()
120
+ }
121
+ get textContent() {
122
+ return this.nodeType === 3 ? this.data : this.childNodes.filter(c => c.nodeType !== 8).map(c => c.textContent).join('')
123
+ }
124
+ set textContent(v) {
125
+ for (const c of this.childNodes) c.parentNode = null
126
+ this.childNodes = []
127
+ if (v !== '') this.appendChild(this.ownerDocument.createTextNode(v))
128
+ }
129
+ addEventListener(t, f, cap) {
130
+ const a = (this._l[cap ? 'cap' : 'bub'][t] ||= [])
131
+ if (!a.includes(f)) a.push(f) // the same listener twice is one listener (DOM spec)
132
+ }
133
+ removeEventListener(t, f, cap) {
134
+ const a = this._l[cap ? 'cap' : 'bub'][t] || []
135
+ const i = a.indexOf(f)
136
+ if (i >= 0) a.splice(i, 1)
137
+ }
138
+ dispatchEvent(e) {
139
+ e.target = this
140
+ const path = []
141
+ for (let n = this; n; n = n.parentNode || n.host) path.push(n)
142
+ if (path[path.length - 1].nodeType === 9 && path[path.length - 1].defaultView) path.push(path[path.length - 1].defaultView)
143
+ e._p = path
144
+ const fire = (n, k) => {
145
+ for (const f of [...(n._l[k][e.type] || [])]) {
146
+ if (e._stop) return
147
+ Object.defineProperty(e, 'currentTarget', { configurable: true, writable: true, value: n })
148
+ f.call(n, e)
149
+ }
150
+ }
151
+ for (let i = path.length - 1; i >= 0 && !e.cancelBubble; i--) fire(path[i], 'cap')
152
+ for (let i = 0; i < path.length && !e.cancelBubble; i++) {
153
+ fire(path[i], 'bub')
154
+ if (!e.bubbles) break
155
+ }
156
+ if (!e.defaultPrevented && e.type === 'click' && this.tagName === 'INPUT' && this.type === 'checkbox') this.checked = !this.checked
157
+ return !e.defaultPrevented
158
+ }
159
+ contains(n) {
160
+ for (; n; n = n.parentNode) if (n === this) return true
161
+ return false
162
+ }
163
+ // Supported selectors: lists (`a, b`), descendant (`a b`), compounds of `tag`, `.class`, `#id`, `[attr]`,
164
+ // `[attr=v]` (quoted or not) and `:not(<compound>)`. Enough for the runtime and the ui kit.
165
+ querySelectorAll(sel) {
166
+ const alts = sel.split(',').map(a => a.trim().split(/\s+/).map(compound))
167
+ const out = []
168
+ const walk = n => {
169
+ for (const c of n.childNodes) {
170
+ if (c.nodeType === 1 && alts.some(chain => matchChain(c, chain, this))) out.push(c)
171
+ walk(c)
172
+ }
173
+ }
174
+ walk(this)
175
+ return out
176
+ }
177
+ querySelector(sel) {
178
+ return this.querySelectorAll(sel)[0] || null
179
+ }
180
+ get innerHTML() {
181
+ return this.childNodes.map(ser).join('')
182
+ }
183
+ set innerHTML(h) {
184
+ this.textContent = ''
185
+ parse(h, this.nodeName === 'TEMPLATE' ? this.content : this, this.ownerDocument)
186
+ }
187
+ }
188
+
189
+ class Text extends Node {
190
+ constructor(d, doc) {
191
+ super(3, doc)
192
+ this._d = String(d)
193
+ }
194
+ get data() {
195
+ return this._d
196
+ }
197
+ set data(v) {
198
+ this._d = String(v)
199
+ rec('characterData', this)
200
+ }
201
+ get nodeValue() {
202
+ return this._d
203
+ }
204
+ set nodeValue(v) {
205
+ this.data = v
206
+ }
207
+ cloneNode() {
208
+ return new Text(this.data, this.ownerDocument)
209
+ }
210
+ }
211
+ class Comment extends Node {
212
+ constructor(d, doc) {
213
+ super(8, doc)
214
+ this._d = String(d)
215
+ }
216
+ get data() {
217
+ return this._d
218
+ }
219
+ set data(v) {
220
+ this._d = String(v)
221
+ rec('characterData', this)
222
+ }
223
+ get nodeValue() {
224
+ return this._d
225
+ }
226
+ set nodeValue(v) {
227
+ this.data = v
228
+ }
229
+ cloneNode() {
230
+ return new Comment(this.data, this.ownerDocument)
231
+ }
232
+ }
233
+ class Fragment extends Node {
234
+ constructor(doc) {
235
+ super(11, doc)
236
+ }
237
+ cloneNode(deep) {
238
+ const f = new Fragment(this.ownerDocument)
239
+ if (deep) for (const c of this.childNodes) f.appendChild(c.cloneNode(true))
240
+ return f
241
+ }
242
+ }
243
+
244
+ class Element extends Node {
245
+ constructor(tag, doc) {
246
+ super(1, doc)
247
+ this.tagName = this.nodeName = tag.toUpperCase()
248
+ this.attrs = new Map()
249
+ this.value = ''
250
+ this.checked = false
251
+ this.selected = false
252
+ const el = this
253
+ this.style = {
254
+ _p: new Map(),
255
+ setProperty(k, v) {
256
+ this._p.set(k, String(v))
257
+ },
258
+ removeProperty(k) {
259
+ this._p.delete(k)
260
+ },
261
+ getPropertyValue(k) {
262
+ return this._p.get(k) || ''
263
+ },
264
+ }
265
+ this.classList = {
266
+ _l: () => (el.getAttribute('class') || '').split(/\s+/).filter(Boolean),
267
+ contains: c => el.classList._l().includes(c),
268
+ toggle(c, on) {
269
+ const l = this._l().filter(x => x !== c)
270
+ if (on) l.push(c)
271
+ l.length ? el.setAttribute('class', l.join(' ')) : el.removeAttribute('class')
272
+ return on
273
+ },
274
+ }
275
+ this.dataset = new Proxy({}, { get: (_, k) => el.getAttribute('data-' + String(k).replace(/[A-Z]/g, c => '-' + c.toLowerCase())) ?? undefined })
276
+ if (this.tagName === 'TEMPLATE') this.content = new Fragment(doc)
277
+ }
278
+ get type() {
279
+ return this.getAttribute('type') || ''
280
+ }
281
+ setAttribute(k, v) {
282
+ this.attrs.set(k, String(v))
283
+ rec('attributes', this)
284
+ if (k === 'value') this.value = String(v)
285
+ if (k === 'checked') this.checked = true
286
+ }
287
+ get attributes() {
288
+ return [...this.attrs].map(([name, value]) => ({ name, value }))
289
+ }
290
+ replaceChildren(...ns) {
291
+ this.textContent = ''
292
+ for (const n of ns) this.appendChild(n)
293
+ }
294
+ getAttribute(k) {
295
+ return this.attrs.has(k) ? this.attrs.get(k) : null
296
+ }
297
+ hasAttribute(k) {
298
+ return this.attrs.has(k)
299
+ }
300
+ removeAttribute(k) {
301
+ if (this.attrs.delete(k)) rec('attributes', this)
302
+ }
303
+ // reflected attributes the runtime and the ui kit read
304
+ get id() {
305
+ return this.getAttribute('id') || ''
306
+ }
307
+ set id(v) {
308
+ this.setAttribute('id', v)
309
+ }
310
+ // HTMLFormElement.elements (namedItem only)
311
+ get elements() {
312
+ if (this.tagName !== 'FORM') return undefined
313
+ const all = this.querySelectorAll('[name]')
314
+ return { namedItem: n => all.find(e => e.getAttribute('name') === n) || null }
315
+ }
316
+ focus() {
317
+ this.ownerDocument.activeElement = this
318
+ }
319
+ cloneNode(deep) {
320
+ const e = new Element(this.tagName.toLowerCase(), this.ownerDocument)
321
+ for (const [k, v] of this.attrs) e.setAttribute(k, v)
322
+ if (deep) {
323
+ for (const c of this.childNodes) e.appendChild(c.cloneNode(true))
324
+ if (this.content) for (const c of this.content.childNodes) e.content.appendChild(c.cloneNode(true))
325
+ }
326
+ return e
327
+ }
328
+ get outerHTML() {
329
+ return ser(this)
330
+ }
331
+ }
332
+
333
+ function ser(n) {
334
+ if (n.nodeType === 3) return esc(n.data)
335
+ if (n.nodeType === 8) return `<!--${n.data}-->`
336
+ if (n.nodeType === 11) return n.childNodes.map(ser).join('')
337
+ const t = n.tagName.toLowerCase()
338
+ const a = [...n.attrs].map(([k, v]) => (v === '' ? ` ${k}` : ` ${k}="${esc(v).replace(/"/g, '&quot;')}"`)).join('')
339
+ return VOID.has(t) ? `<${t}${a}>` : `<${t}${a}>${n.childNodes.map(ser).join('')}</${t}>`
340
+ }
341
+
342
+ // Minimal HTML parser: tags, attributes, text, comments (including `<!>`), void elements and script raw text.
343
+ function parse(h, root, doc) {
344
+ const stack = [root]
345
+ const top = () => stack[stack.length - 1]
346
+ const re = /<!--([\s\S]*?)-->|<!>|<\/([\w-]+)\s*>|<([\w-]+)((?:\s+[^\s=\/>]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+|<)/g
347
+ let m
348
+ while ((m = re.exec(h))) {
349
+ if (m[0] === '<!>') top().appendChild(new Comment('', doc))
350
+ else if (m[1] != null) top().appendChild(new Comment(m[1], doc))
351
+ else if (m[2]) {
352
+ const t = m[2].toUpperCase()
353
+ for (let i = stack.length - 1; i > 0; i--)
354
+ if (stack[i].tagName === t) {
355
+ stack.length = i
356
+ break
357
+ }
358
+ } else if (m[3]) {
359
+ const el = new Element(m[3], doc)
360
+ for (const a of m[4].matchAll(/([^\s=\/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) el.setAttribute(a[1], unesc(a[2] ?? a[3] ?? a[4] ?? ''))
361
+ top().appendChild(el)
362
+ const t = m[3].toLowerCase()
363
+ if (t === 'script') {
364
+ const end = h.indexOf('</script>', re.lastIndex)
365
+ el.appendChild(new Text(h.slice(re.lastIndex, end), doc))
366
+ re.lastIndex = end + 9
367
+ } else if (!VOID.has(t) && !m[5]) stack.push(t === 'template' ? Object.assign(el.content, { tagName: 'TEMPLATE' }) : el)
368
+ } else top().appendChild(new Text(unesc(m[6]), doc))
369
+ }
370
+ }
371
+
372
+ class Document extends Node {
373
+ constructor() {
374
+ super(9, null)
375
+ this.ownerDocument = this
376
+ this.baseURI = 'http://localhost/'
377
+ this.activeElement = null
378
+ // <html><head></head><body></body></html>, as in a browser
379
+ this.documentElement = this.appendChild(new Element('html', this))
380
+ this.head = this.documentElement.appendChild(new Element('head', this))
381
+ this.body = this.documentElement.appendChild(new Element('body', this))
382
+ }
383
+ createElement(t) {
384
+ return new Element(t, this)
385
+ }
386
+ createTextNode(d) {
387
+ return new Text(d, this)
388
+ }
389
+ createComment(d) {
390
+ return new Comment(d, this)
391
+ }
392
+ createDocumentFragment() {
393
+ return new Fragment(this)
394
+ }
395
+ getElementById(id) {
396
+ const walk = n => {
397
+ for (const c of n.childNodes) {
398
+ if (c.nodeType !== 1) continue
399
+ if (c.getAttribute('id') === id) return c
400
+ const r = walk(c)
401
+ if (r) return r
402
+ }
403
+ return null
404
+ }
405
+ return walk(this)
406
+ }
407
+ }
408
+
409
+ // Selector helpers for querySelectorAll: a compound is a list of tests on one element.
410
+ function compound(str) {
411
+ const tests = []
412
+ const rx = /^(?:([\w-]+)|\.([\w-]+)|#([\w-]+)|\[([\w-]+)(?:=["']?([^"'\]]*)["']?)?\]|:not\(([^)]*)\))/
413
+ let rest = str
414
+ while (rest) {
415
+ const m = rx.exec(rest)
416
+ if (!m) throw Error('unsupported selector: ' + str)
417
+ rest = rest.slice(m[0].length)
418
+ if (m[1]) tests.push(e => e.tagName === m[1].toUpperCase())
419
+ else if (m[2]) tests.push(e => (e.getAttribute('class') || '').split(/\s+/).includes(m[2]))
420
+ else if (m[3]) tests.push(e => e.getAttribute('id') === m[3])
421
+ else if (m[4]) tests.push(e => e.hasAttribute(m[4]) && (m[5] == null || e.getAttribute(m[4]) === m[5]))
422
+ else {
423
+ const inner = compound(m[6])
424
+ tests.push(e => !inner(e))
425
+ }
426
+ }
427
+ return e => tests.every(t => t(e))
428
+ }
429
+ // chain = [c0, c1, …, cn]: the element matches cn and has ancestors (below root) matching the rest, in order.
430
+ function matchChain(el, chain, root) {
431
+ if (!chain[chain.length - 1](el)) return false
432
+ let i = chain.length - 2
433
+ for (let a = el.parentNode; i >= 0 && a && a !== root; a = a.parentNode) if (a.nodeType === 1 && chain[i](a)) i--
434
+ return i < 0
435
+ }
436
+
437
+ export function install() {
438
+ const doc = new Document()
439
+ // real window at the end of the event path (after document), as in the browser
440
+ const win = new Node(0, doc)
441
+ doc.defaultView = win
442
+ globalThis.window = win
443
+ globalThis.document = doc
444
+ globalThis.Event = Event
445
+ globalThis.MouseEvent = MouseEvent
446
+ globalThis.MutationObserver = MutationObserver
447
+ return doc
448
+ }
package/shim/probe.js ADDED
@@ -0,0 +1,111 @@
1
+ // BRAINLLM runaway probe (INTERNALS §30.2, #91). Runs after the prerender, in Node on the DOM shim: each view or island
2
+ // is mounted with fake timers and stepped for N seconds of virtual time (default 30 s, ~200 ms real) with no user
3
+ // input. It samples, per component (`data-b` hash): DOM nodes; per signal: the length of the lists it holds; and the
4
+ // animation frames requested. Sustained growth with no input, or a requestAnimationFrame loop re-armed in every frame
5
+ // for the whole run, is a runaway: B0907 findings with what grew and how fast. Repeated bounded work (a clock or a
6
+ // stopwatch updating its text) is not: it does not grow and it is what such a component is for. brain-pack (BUILD) calls it and fails the
7
+ // build, doctor and test on any finding.
8
+ // const findings = await probe({ core, mount: root => core.mount(View, root, props), seconds: 30 })
9
+ // → [{ code: 'B0907', comp: '<hash>' | null, what: 'nodes' | 'list' | 'raf', growth: '+40 nodes/s', msg, fix }]
10
+ const TICK = 100 // ms of virtual time per step
11
+ const FIX = 'bound the growth (slice the list, stop the interval, clean up the timer in the fx) or move the work behind a user action'
12
+
13
+ export async function probe({ core, mount, seconds = 30, document: doc = globalThis.document }) {
14
+ const G = globalThis, N = core.sig(0).constructor, P = N.prototype
15
+ // ---- fake clock: timers, animation frames and Date.now move only when the probe steps ----
16
+ let now = 0, seq = 0
17
+ const timers = new Map() // id → { at, f, every }
18
+ const saved = { st: G.setTimeout, ct: G.clearTimeout, si: G.setInterval, ci: G.clearInterval, raf: G.requestAnimationFrame, caf: G.cancelAnimationFrame, dn: Date.now }
19
+ const add = (f, ms, every) => (timers.set(++seq, { at: now + Math.max(0, +ms || 0), f, every: every ? Math.max(1, +ms || 1) : 0 }), seq)
20
+ G.setTimeout = (f, ms, ...a) => add(() => f(...a), ms, false)
21
+ G.setInterval = (f, ms, ...a) => add(() => f(...a), ms, true)
22
+ G.clearTimeout = G.clearInterval = id => timers.delete(id)
23
+ G.requestAnimationFrame = f => add(() => (rafTicks.add(tickNo), f(now)), 16, false)
24
+ G.cancelAnimationFrame = id => timers.delete(id)
25
+ const t0 = saved.dn.call(Date)
26
+ Date.now = () => t0 + now
27
+ // ---- instrumentation: list lengths per signal, ticks with an animation frame ----
28
+ const lists = new Map() // signal → { last: length }
29
+ const rafTicks = new Set()
30
+ let tickNo = 0
31
+ const vd = Object.getOwnPropertyDescriptor(P, 'v')
32
+ Object.defineProperty(P, 'v', {
33
+ configurable: true,
34
+ get: vd.get,
35
+ set(x) {
36
+ vd.set.call(this, x)
37
+ if (Array.isArray(x)) (lists.get(this) || lists.set(this, []).get(this)).last = x.length
38
+ },
39
+ })
40
+ const flush = async () => {
41
+ for (let i = 0; i < 3; i++) await new Promise(r => queueMicrotask(r))
42
+ }
43
+ // nodes per component: each `data-b` root counts its elements, nested components count apart
44
+ const perComp = () => {
45
+ const out = {}
46
+ const walk = (el, h) => {
47
+ const own = el.getAttribute && el.getAttribute('data-b')
48
+ const at = own || h
49
+ if (at) out[at] = (out[at] || 0) + 1
50
+ for (const c of el.childNodes || []) if (c.nodeType === 1) walk(c, at)
51
+ }
52
+ walk(root, null)
53
+ return out
54
+ }
55
+
56
+ const root = doc.createElement('div')
57
+ doc.body.appendChild(root)
58
+ const findings = []
59
+ let stop = null
60
+ try {
61
+ stop = mount(root)
62
+ await flush()
63
+ const samples = [] // per virtual second: { nodes: {hash: n}, lists: Map(signal → length) }
64
+ const steps = Math.round((seconds * 1000) / TICK)
65
+ for (let s = 0; s <= steps; s++) {
66
+ tickNo = s
67
+ now = s * TICK
68
+ // run every timer due by now, in time order (an interval re-arms itself)
69
+ for (;;) {
70
+ let next = null
71
+ for (const [id, t] of timers) if (t.at <= now && (!next || t.at < next[1].at)) next = [id, t]
72
+ if (!next) break
73
+ const [id, t] = next
74
+ if (t.every) t.at += t.every
75
+ else timers.delete(id)
76
+ try {
77
+ t.f()
78
+ } catch {} // a timer that throws is the error capture's business, not the probe's
79
+ }
80
+ await flush()
81
+ if (s % Math.round(1000 / TICK) === 0) samples.push({ nodes: perComp(), lists: new Map([...lists].map(([k, v]) => [k, v.last])) })
82
+ }
83
+ // ---- verdicts: growth in every second of the second half, and well above the start ----
84
+ const half = samples.slice(Math.floor(samples.length / 2))
85
+ const rising = vals => vals.length > 3 && vals.every((v, i) => i === 0 || v > vals[i - 1])
86
+ const hashes = new Set(samples.flatMap(x => Object.keys(x.nodes)))
87
+ for (const h of hashes) {
88
+ const v = half.map(x => x.nodes[h] || 0)
89
+ const all = samples.map(x => x.nodes[h] || 0)
90
+ if (rising(v) && all.at(-1) - all[0] >= 20) {
91
+ const rate = Math.round((all.at(-1) - all[0]) / seconds)
92
+ findings.push({ code: 'B0907', comp: h, what: 'nodes', growth: `+${rate} nodes/s`, msg: `component ${h} grows with no input: +${rate} nodes/s over ${seconds} s of virtual time`, fix: FIX })
93
+ }
94
+ }
95
+ for (const sig of lists.keys()) {
96
+ const v = half.map(x => x.lists.get(sig) ?? 0), all = samples.map(x => x.lists.get(sig) ?? 0)
97
+ if (rising(v) && all.at(-1) - all[0] >= 10) {
98
+ const rate = +((all.at(-1) - all[0]) / steps).toFixed(2)
99
+ findings.push({ code: 'B0907', comp: null, what: 'list', growth: `+${rate} item/tick`, msg: `a list grows with no input: +${rate} item/tick (${all[0]} → ${all.at(-1)} items) over ${seconds} s of virtual time`, fix: FIX })
100
+ }
101
+ }
102
+ if (rafTicks.size >= 0.95 * steps) findings.push({ code: 'B0907', comp: null, what: 'raf', growth: 'a frame every tick', msg: `a requestAnimationFrame loop re-arms itself in every frame with no input for ${seconds} s of virtual time`, fix: 'stop the loop when there is nothing to animate (or use a CSS / Web Animations transition)' })
103
+ } finally {
104
+ if (stop) stop()
105
+ root.remove()
106
+ Object.defineProperty(P, 'v', vd)
107
+ Object.assign(G, { setTimeout: saved.st, clearTimeout: saved.ct, setInterval: saved.si, clearInterval: saved.ci, requestAnimationFrame: saved.raf, cancelAnimationFrame: saved.caf })
108
+ Date.now = saved.dn
109
+ }
110
+ return findings
111
+ }
package/sw/brain-sw.js ADDED
@@ -0,0 +1,103 @@
1
+ // BRAINLLM service worker (INTERNALS §35.2). `brain build` emits dist/sw.js as one line
2
+ // `self.BRAIN_BUILD = { hash, precache: [...], offline: '/_offline.html' | null }` followed by this file unchanged.
3
+ // Strategies: /_b/* cache-first (hashed, immutable) · navigations network-first with a 3 s timeout, then the cached
4
+ // page, then the offline page · other same-origin GETs (unhashed public/ files) stale-while-revalidate · /_r/, non-GET
5
+ // and other origins are never touched. Every build has one cache, `brain-<sig>`, holding its chunks, pages and public
6
+ // files (§35.7). A new worker takes over at once and keeps only the previous build's cache, and only for the chunks an
7
+ // open tab still asks for: navigations are answered from the current build alone, so nothing old is shown by mistake.
8
+ // After taking over it tells every tab `brain:build <sig>` (the router then does full loads). Caches that are not per
9
+ // build are named `brain:*`. (`sw: false` is a separate kill worker emitted by the build.)
10
+ const B = self.BRAIN_BUILD || { hash: 'dev', precache: [], offline: null }
11
+ const BASE = B.base || '/' // app served under a base path (§23 #148); precache/offline URLs already carry it
12
+ const BUILD = 'brain-' + B.hash, META = 'brain:meta'
13
+ const NAV_TIMEOUT = 3000
14
+ // responses marked personal or uncacheable are never stored
15
+ const storable = r => r.ok && !/no-store|private/i.test(r.headers.get('cache-control') || '')
16
+ const OFFLINE =
17
+ '<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
18
+ '<title>Offline</title><main style="font:16px/1.5 system-ui;max-width:32rem;margin:15vh auto;padding:0 16px">' +
19
+ '<h1>You are offline</h1><p>This page has not been visited yet on this device. Reconnect and try again.</p></main></html>'
20
+
21
+ self.addEventListener('install', e => {
22
+ e.waitUntil(
23
+ (async () => {
24
+ const c = await caches.open(BUILD)
25
+ // one failing URL must not abort the install: cache what answers, the rest is cached on first use
26
+ // default cache mode: the page just filled the HTTP cache with these immutable files, so this costs no bytes
27
+ await Promise.allSettled(B.precache.map(u => fetch(u).then(r => r.ok && c.put(u, r))))
28
+ await self.skipWaiting()
29
+ })()
30
+ )
31
+ })
32
+
33
+ self.addEventListener('activate', e => {
34
+ e.waitUntil(
35
+ (async () => {
36
+ if (self.registration.navigationPreload) await self.registration.navigationPreload.enable()
37
+ // generations: [previous, current]; every other build cache is deleted
38
+ const meta = await caches.open(META)
39
+ const r = await meta.match('/__brain_generations')
40
+ const gens = r ? await r.json() : []
41
+ const keep = [...gens.filter(g => g !== BUILD).slice(-1), BUILD]
42
+ await meta.put('/__brain_generations', new Response(JSON.stringify(keep)))
43
+ for (const k of await caches.keys()) if (k.startsWith('brain-') && !keep.includes(k)) await caches.delete(k)
44
+ await self.clients.claim()
45
+ for (const c of await self.clients.matchAll({ type: 'window', includeUncontrolled: true })) c.postMessage('brain:build ' + B.hash)
46
+ })()
47
+ )
48
+ })
49
+
50
+ self.addEventListener('fetch', e => {
51
+ const req = e.request
52
+ if (req.method !== 'GET') return
53
+ const url = new URL(req.url)
54
+ if (url.origin !== self.location.origin || url.pathname.startsWith(BASE + '_r/') || url.pathname === BASE + 'sw.js') return
55
+ // offline: false (B.offline null) → navigations go straight to the network: no page cache, no offline page
56
+ if (req.mode === 'navigate') return B.offline ? e.respondWith(navigate(e)) : undefined
57
+ if (url.pathname.startsWith(BASE + '_b/')) return e.respondWith(hashed(req))
58
+ // only files the page itself loads (img, style, script, font, manifest…); fetch()/XHR data is never touched (§35:
59
+ // data is not cached by default)
60
+ if (req.destination) e.respondWith(revalidate(e))
61
+ })
62
+
63
+ // cache-first: any generation may hold it (an old tab asks for old chunks); misses are stored in the current build
64
+ async function hashed(req) {
65
+ const hit = await caches.match(req)
66
+ if (hit) return hit
67
+ const r = await fetch(req)
68
+ if (storable(r)) (await caches.open(BUILD)).put(req, r.clone())
69
+ return r
70
+ }
71
+
72
+ async function navigate(e) {
73
+ const net = (async () => {
74
+ const r = (await e.preloadResponse) || (await fetch(e.request))
75
+ if (storable(r) && (r.headers.get('content-type') || '').includes('text/html')) {
76
+ const copy = r.clone()
77
+ e.waitUntil(caches.open(BUILD).then(c => c.put(e.request, copy)))
78
+ }
79
+ return r
80
+ })()
81
+ e.waitUntil(net.catch(() => {}))
82
+ const timeout = new Promise(ok => setTimeout(() => ok(null), NAV_TIMEOUT))
83
+ const first = await Promise.race([net, timeout]).catch(() => null)
84
+ if (first) return first
85
+ const cached = await caches.match(e.request, { cacheName: BUILD }) // never a page of the previous build
86
+ if (cached) return cached
87
+ // nothing cached: a slow network is still better than the offline page
88
+ try {
89
+ return await net
90
+ } catch {
91
+ const off = B.offline && (await caches.match(B.offline, { cacheName: BUILD }))
92
+ return off || new Response(OFFLINE, { status: 503, headers: { 'content-type': 'text/html; charset=utf-8' } })
93
+ }
94
+ }
95
+
96
+ // stale-while-revalidate for unhashed same-origin files
97
+ async function revalidate(e) {
98
+ const c = await caches.open(BUILD)
99
+ const hit = await c.match(e.request)
100
+ const net = fetch(e.request).then(r => (storable(r) && c.put(e.request, r.clone()), r))
101
+ e.waitUntil(net.catch(() => {}))
102
+ return hit || net
103
+ }