@llui/vite-plugin 0.0.31 → 0.0.34

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.
@@ -1,846 +0,0 @@
1
- import ts from 'typescript';
2
- import { collectStatePathsFromSource, collectAccessorPathSets } from './collect-deps.js';
3
- const INTERACTIVE_ELEMENTS = new Set([
4
- 'button',
5
- 'a',
6
- 'input',
7
- 'select',
8
- 'textarea',
9
- 'details',
10
- 'summary',
11
- ]);
12
- const ELEMENT_HELPERS = new Set([
13
- 'a',
14
- 'abbr',
15
- 'article',
16
- 'aside',
17
- 'b',
18
- 'blockquote',
19
- 'br',
20
- 'button',
21
- 'canvas',
22
- 'code',
23
- 'dd',
24
- 'details',
25
- 'dialog',
26
- 'div',
27
- 'dl',
28
- 'dt',
29
- 'em',
30
- 'fieldset',
31
- 'figcaption',
32
- 'figure',
33
- 'footer',
34
- 'form',
35
- 'h1',
36
- 'h2',
37
- 'h3',
38
- 'h4',
39
- 'h5',
40
- 'h6',
41
- 'header',
42
- 'hr',
43
- 'i',
44
- 'iframe',
45
- 'img',
46
- 'input',
47
- 'label',
48
- 'legend',
49
- 'li',
50
- 'main',
51
- 'mark',
52
- 'nav',
53
- 'ol',
54
- 'optgroup',
55
- 'option',
56
- 'output',
57
- 'p',
58
- 'pre',
59
- 'progress',
60
- 'section',
61
- 'select',
62
- 'small',
63
- 'span',
64
- 'strong',
65
- 'sub',
66
- 'summary',
67
- 'sup',
68
- 'table',
69
- 'tbody',
70
- 'td',
71
- 'textarea',
72
- 'tfoot',
73
- 'th',
74
- 'thead',
75
- 'time',
76
- 'tr',
77
- 'ul',
78
- 'video',
79
- ]);
80
- export function diagnose(source) {
81
- const sf = ts.createSourceFile('input.ts', source, ts.ScriptTarget.Latest, true);
82
- const diagnostics = [];
83
- // Collect Msg type variants for exhaustive update() check
84
- const msgVariants = collectMsgVariants(sf);
85
- // Collect state access paths for bitmask warning (shared scanner with collect-deps.ts)
86
- const statePaths = collectStatePathsFromSource(sf);
87
- function visit(node) {
88
- checkMapOnState(node, sf, diagnostics);
89
- checkExhaustiveUpdate(node, sf, diagnostics, msgVariants);
90
- checkAccessibility(node, sf, diagnostics);
91
- checkControlledInput(node, sf, diagnostics);
92
- checkChildStaticProps(node, sf, diagnostics);
93
- checkBitmaskOverflow(node, sf, diagnostics, statePaths);
94
- checkNamespaceImport(node, sf, diagnostics);
95
- checkSpreadChildren(node, sf, diagnostics);
96
- checkEmptyProps(node, sf, diagnostics);
97
- checkStaticOn(node, sf, diagnostics);
98
- ts.forEachChild(node, visit);
99
- }
100
- visit(sf);
101
- return diagnostics;
102
- }
103
- // ── "Almost-optimized" diagnostics ───────────────────────────────
104
- // Warns when a user writes `import * as L from '@llui/dom'` — the compiler
105
- // can only recognize named-import helpers, so namespace imports disable
106
- // template cloning/elSplit for every element call in the file.
107
- function checkNamespaceImport(node, sf, diagnostics) {
108
- if (!ts.isImportDeclaration(node))
109
- return;
110
- if (!ts.isStringLiteral(node.moduleSpecifier))
111
- return;
112
- if (node.moduleSpecifier.text !== '@llui/dom')
113
- return;
114
- const clause = node.importClause;
115
- if (!clause?.namedBindings)
116
- return;
117
- if (!ts.isNamespaceImport(clause.namedBindings))
118
- return;
119
- const name = clause.namedBindings.name.text;
120
- const { line, column } = pos(clause.namedBindings, sf);
121
- diagnostics.push({
122
- rule: 'namespace-import',
123
- message: `Namespace import '${name}' from '@llui/dom' at line ${line} disables compiler optimizations. Use named imports instead: import { div, text, ... } from '@llui/dom'.`,
124
- line,
125
- column,
126
- });
127
- }
128
- // Warns when a children array contains a spread — the compiler can't
129
- // analyze variable-length children, so it bails on template cloning and
130
- // falls back to runtime elSplit. Not fatal, but silent.
131
- //
132
- // Scope-aware: when the spread source (or an array-method's receiver)
133
- // resolves to a locally-bounded binding — `const x = [...]`, `const x =
134
- // fn(...)`, `const x = other.map(...)` where `other` is bounded — the
135
- // child count is statically known and `each()` is not a usable fix.
136
- // Those cases stay silent; only truly dynamic spreads warn.
137
- function checkSpreadChildren(node, sf, diagnostics) {
138
- if (!ts.isCallExpression(node))
139
- return;
140
- if (!ts.isIdentifier(node.expression))
141
- return;
142
- if (!ELEMENT_HELPERS.has(node.expression.text))
143
- return;
144
- // Children could be at arguments[0] (children-only overload) or arguments[1]
145
- for (const arg of node.arguments) {
146
- if (!ts.isArrayLiteralExpression(arg))
147
- continue;
148
- for (const el of arg.elements) {
149
- if (!ts.isSpreadElement(el))
150
- continue;
151
- if (isBoundedSpreadSource(el.expression, sf))
152
- continue;
153
- const { line, column } = pos(arg, sf);
154
- diagnostics.push({
155
- rule: 'spread-in-children',
156
- message: `Spread in children array of '${node.expression.text}()' at line ${line} disables template-clone compilation. For dynamic child counts, use each() instead.`,
157
- line,
158
- column,
159
- });
160
- return;
161
- }
162
- }
163
- }
164
- // Array iteration methods whose result spreads are the red flag we want
165
- // to catch — users should use each() instead. Function calls generally
166
- // return Node[] from structural primitives or user view helpers and are
167
- // the legitimate way to compose output.
168
- const ARRAY_ITERATION_METHODS = new Set([
169
- 'map',
170
- 'filter',
171
- 'flatMap',
172
- 'slice',
173
- 'concat',
174
- 'reverse',
175
- 'sort',
176
- ]);
177
- /**
178
- * Classify a spread-source expression as "bounded" — i.e., the child
179
- * count is statically knowable and `each()` is not an applicable fix.
180
- * Returns true when the spread should stay silent, false when the
181
- * spread is genuinely suspect (state-derived, unresolved, inline
182
- * array-method call on a non-bounded receiver).
183
- */
184
- function isBoundedSpreadSource(expr, sf) {
185
- // Identifier spread `...foo` — resolve the binding.
186
- if (ts.isIdentifier(expr)) {
187
- const init = resolveBindingInitializer(expr, sf);
188
- if (init === null)
189
- return false;
190
- return isBoundedInitializer(init, sf);
191
- }
192
- // Call-expression spread.
193
- if (ts.isCallExpression(expr)) {
194
- const callee = expr.expression;
195
- // Array-method call: `...x.map(...)`, `...arr.concat([...])`, etc.
196
- if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)) {
197
- if (!ARRAY_ITERATION_METHODS.has(callee.name.text)) {
198
- // Non-array-method method call (e.g. `...my.overlay()`) — presume
199
- // structural/helper. Same as before.
200
- return true;
201
- }
202
- // Array method — bounded if the receiver resolves to a bounded
203
- // array source. Inline literals (e.g. `...[1,2,3].map(...)`)
204
- // stay suspect intentionally so authors see the warning on the
205
- // canonical dynamic-mapping shape.
206
- return isBoundedArrayReceiver(callee.expression, sf);
207
- }
208
- // Plain function call `...fn()` — presume structural/helper.
209
- return true;
210
- }
211
- // Anything else (array literal inline, etc.) — treat as suspect for
212
- // now. Inline `...[...]` at a call site is unusual and worth flagging.
213
- return false;
214
- }
215
- /**
216
- * Is the initializer a bounded expression? Array literals and
217
- * function-call results both qualify; method calls recurse on their
218
- * receivers.
219
- */
220
- function isBoundedInitializer(init, sf) {
221
- // `const foo = [...]` — bounded.
222
- if (ts.isArrayLiteralExpression(init))
223
- return true;
224
- // `const foo = x as const` / `x as T` — look through the assertion.
225
- if (ts.isAsExpression(init) || ts.isTypeAssertionExpression(init)) {
226
- return isBoundedInitializer(init.expression, sf);
227
- }
228
- // `const foo = someCall(...)` — treat plain-call results as bounded
229
- // structural output. Same heuristic the original syntactic rule used.
230
- if (ts.isCallExpression(init)) {
231
- const callee = init.expression;
232
- if (ts.isIdentifier(callee))
233
- return true;
234
- if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)) {
235
- if (!ARRAY_ITERATION_METHODS.has(callee.name.text))
236
- return true;
237
- // Method call is an array method — bounded iff receiver is.
238
- return isBoundedArrayReceiver(callee.expression, sf);
239
- }
240
- }
241
- return false;
242
- }
243
- /**
244
- * A method-call receiver (the `x` in `x.map(...)`) is bounded when
245
- * resolved to a named array-literal binding. Inline literals are
246
- * intentionally NOT bounded here — callers who inline `[1,2,3].map(...)`
247
- * should still see the warning.
248
- */
249
- function isBoundedArrayReceiver(receiver, sf) {
250
- if (!ts.isIdentifier(receiver))
251
- return false;
252
- const init = resolveBindingInitializer(receiver, sf);
253
- if (init === null)
254
- return false;
255
- if (ts.isArrayLiteralExpression(init))
256
- return true;
257
- if (ts.isAsExpression(init) || ts.isTypeAssertionExpression(init)) {
258
- return ts.isArrayLiteralExpression(init.expression);
259
- }
260
- return false;
261
- }
262
- /**
263
- * Walk the identifier's ancestor scopes looking for a matching
264
- * VariableDeclaration. Returns its initializer (or null if the name
265
- * resolves to a function parameter, import, or nothing at all).
266
- */
267
- function resolveBindingInitializer(ident, sf) {
268
- const name = ident.text;
269
- let scope = ident.parent;
270
- while (scope) {
271
- const decl = findVariableDeclarationInScope(scope, name, ident);
272
- if (decl)
273
- return decl.initializer ?? null;
274
- if (scope === sf)
275
- break;
276
- scope = scope.parent;
277
- }
278
- return null;
279
- }
280
- /**
281
- * Scan a scope's immediate statements for a `const/let/var name = ...`
282
- * declaration. Does not descend into inner function bodies — those are
283
- * visible only from within themselves.
284
- */
285
- function findVariableDeclarationInScope(scope, name, from) {
286
- let found = null;
287
- function visit(node) {
288
- if (found)
289
- return;
290
- // Don't descend into nested function bodies other than the one
291
- // containing `from` — that walking is handled by the outer loop.
292
- if (node !== from.parent &&
293
- (ts.isFunctionDeclaration(node) ||
294
- ts.isFunctionExpression(node) ||
295
- ts.isArrowFunction(node) ||
296
- ts.isMethodDeclaration(node) ||
297
- ts.isConstructorDeclaration(node))) {
298
- // Still scan the parameters? No — parameters aren't VariableDeclarations.
299
- return;
300
- }
301
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name) {
302
- found = node;
303
- return;
304
- }
305
- ts.forEachChild(node, visit);
306
- }
307
- ts.forEachChild(scope, visit);
308
- return found;
309
- }
310
- // Warns when an element helper is called with an empty props object — the
311
- // attrs argument is optional, so `h1({}, [...])` should be `h1([...])`.
312
- function checkEmptyProps(node, sf, diagnostics) {
313
- if (!ts.isCallExpression(node))
314
- return;
315
- if (!ts.isIdentifier(node.expression))
316
- return;
317
- if (!ELEMENT_HELPERS.has(node.expression.text))
318
- return;
319
- const firstArg = node.arguments[0];
320
- if (!firstArg || !ts.isObjectLiteralExpression(firstArg))
321
- return;
322
- if (firstArg.properties.length !== 0)
323
- return;
324
- const { line, column } = pos(firstArg, sf);
325
- diagnostics.push({
326
- rule: 'empty-props',
327
- message: `Empty props object passed to '${node.expression.text}()' at line ${line}. The attrs argument is optional — omit it: ${node.expression.text}([...]).`,
328
- line,
329
- column,
330
- });
331
- }
332
- function pos(node, sf) {
333
- const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
334
- return { line: line + 1, column: character + 1 };
335
- }
336
- function _isInsideEachRender(node) {
337
- let current = node.parent;
338
- while (current) {
339
- if ((ts.isArrowFunction(current) || ts.isFunctionExpression(current)) &&
340
- current.parameters.length >= 1) {
341
- const param = current.parameters[0];
342
- // Options bag: ({ item, ... }) => ...
343
- const hasItemParam = ts.isObjectBindingPattern(param.name) &&
344
- param.name.elements.some((el) => ts.isBindingElement(el) && ts.isIdentifier(el.name) && el.name.text === 'item');
345
- if (hasItemParam) {
346
- const propAssign = current.parent;
347
- if (ts.isPropertyAssignment(propAssign) &&
348
- ts.isIdentifier(propAssign.name) &&
349
- propAssign.name.text === 'render') {
350
- return true;
351
- }
352
- }
353
- }
354
- current = current.parent;
355
- }
356
- return false;
357
- }
358
- // ── .map() on state arrays ───────────────────────────────────────
359
- function checkMapOnState(node, sf, diagnostics) {
360
- if (!ts.isCallExpression(node))
361
- return;
362
- if (!ts.isPropertyAccessExpression(node.expression))
363
- return;
364
- if (node.expression.name.text !== 'map')
365
- return;
366
- // Check if receiver involves a state parameter reference
367
- if (!referencesStateParam(node.expression.expression))
368
- return;
369
- // Check if we're inside a view function
370
- if (!isInsideViewFunction(node))
371
- return;
372
- const { line, column } = pos(node, sf);
373
- diagnostics.push({
374
- rule: 'map-on-state',
375
- message: `Array .map() on state-derived value at line ${line}. Use each() for reactive lists that update when the array changes.`,
376
- line,
377
- column,
378
- });
379
- }
380
- function referencesStateParam(node) {
381
- if (ts.isPropertyAccessExpression(node)) {
382
- return referencesStateParam(node.expression);
383
- }
384
- if (ts.isIdentifier(node)) {
385
- // Check if the identifier is a parameter named 'state' or 's' or matches common patterns
386
- // Simple heuristic: check if it's a parameter of the view function
387
- const name = node.text;
388
- return name === 'state' || name === 's' || name === '_state';
389
- }
390
- return false;
391
- }
392
- function isInsideViewFunction(node) {
393
- let current = node.parent;
394
- while (current) {
395
- if (ts.isPropertyAssignment(current)) {
396
- if (ts.isIdentifier(current.name) && current.name.text === 'view') {
397
- return true;
398
- }
399
- }
400
- current = current.parent;
401
- }
402
- return false;
403
- }
404
- // ── Exhaustive update() ──────────────────────────────────────────
405
- function collectMsgVariants(sf) {
406
- const variants = new Set();
407
- for (const stmt of sf.statements) {
408
- if (!ts.isTypeAliasDeclaration(stmt))
409
- continue;
410
- if (stmt.name.text !== 'Msg')
411
- continue;
412
- // Walk the union to find { type: 'literal' } members
413
- collectUnionVariants(stmt.type, variants);
414
- }
415
- return variants;
416
- }
417
- function collectUnionVariants(type, variants) {
418
- if (ts.isUnionTypeNode(type)) {
419
- for (const member of type.types) {
420
- collectUnionVariants(member, variants);
421
- }
422
- return;
423
- }
424
- if (ts.isTypeLiteralNode(type)) {
425
- for (const member of type.members) {
426
- if (!ts.isPropertySignature(member))
427
- continue;
428
- if (!ts.isIdentifier(member.name) || member.name.text !== 'type')
429
- continue;
430
- if (member.type && ts.isLiteralTypeNode(member.type)) {
431
- if (ts.isStringLiteral(member.type.literal)) {
432
- variants.add(member.type.literal.text);
433
- }
434
- }
435
- }
436
- }
437
- }
438
- function checkExhaustiveUpdate(node, sf, diagnostics, msgVariants) {
439
- if (msgVariants.size === 0)
440
- return;
441
- if (!ts.isPropertyAssignment(node))
442
- return;
443
- if (!ts.isIdentifier(node.name) || node.name.text !== 'update')
444
- return;
445
- // Find the switch statement in the update body
446
- const fn = node.initializer;
447
- if (!ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn))
448
- return;
449
- const body = ts.isBlock(fn.body) ? fn.body : null;
450
- if (!body)
451
- return;
452
- const handledCases = new Set();
453
- let hasDefault = false;
454
- function findSwitch(n) {
455
- if (ts.isSwitchStatement(n)) {
456
- for (const clause of n.caseBlock.clauses) {
457
- if (ts.isDefaultClause(clause)) {
458
- hasDefault = true;
459
- }
460
- else if (ts.isCaseClause(clause) && ts.isStringLiteral(clause.expression)) {
461
- handledCases.add(clause.expression.text);
462
- }
463
- }
464
- }
465
- ts.forEachChild(n, findSwitch);
466
- }
467
- findSwitch(body);
468
- if (hasDefault)
469
- return;
470
- const missing = [...msgVariants].filter((v) => !handledCases.has(v));
471
- if (missing.length === 0)
472
- return;
473
- const { line, column } = pos(node, sf);
474
- diagnostics.push({
475
- rule: 'exhaustive-update',
476
- message: `update() does not handle message type${missing.length > 1 ? 's' : ''} ${missing.map((m) => `'${m}'`).join(', ')} at line ${line}.`,
477
- line,
478
- column,
479
- });
480
- }
481
- // ── Accessibility ────────────────────────────────────────────────
482
- function checkAccessibility(node, sf, diagnostics) {
483
- if (!ts.isCallExpression(node))
484
- return;
485
- if (!ts.isIdentifier(node.expression))
486
- return;
487
- const tag = node.expression.text;
488
- if (!ELEMENT_HELPERS.has(tag))
489
- return;
490
- const propsArg = node.arguments[0];
491
- if (!propsArg || !ts.isObjectLiteralExpression(propsArg))
492
- return;
493
- const props = getStaticPropKeys(propsArg);
494
- // img without alt
495
- if (tag === 'img' && !props.has('alt')) {
496
- const { line, column } = pos(node, sf);
497
- diagnostics.push({
498
- rule: 'accessibility',
499
- message: `<img> at line ${line} has no 'alt' attribute. Add alt text for screen readers, or alt='' for decorative images.`,
500
- line,
501
- column,
502
- });
503
- }
504
- // onClick on non-interactive element without role
505
- if (props.has('onClick') && !INTERACTIVE_ELEMENTS.has(tag) && !props.has('role')) {
506
- const { line, column } = pos(node, sf);
507
- diagnostics.push({
508
- rule: 'accessibility',
509
- message: `onClick on <${tag}> at line ${line} without role and tabIndex. Non-interactive elements with click handlers are not keyboard-accessible. Add role='button' and tabIndex={0}, or use <button>.`,
510
- line,
511
- column,
512
- });
513
- }
514
- }
515
- // ── Controlled input ─────────────────────────────────────────────
516
- function checkControlledInput(node, sf, diagnostics) {
517
- if (!ts.isCallExpression(node))
518
- return;
519
- if (!ts.isIdentifier(node.expression))
520
- return;
521
- const tag = node.expression.text;
522
- if (tag !== 'input' && tag !== 'textarea')
523
- return;
524
- const propsArg = node.arguments[0];
525
- if (!propsArg || !ts.isObjectLiteralExpression(propsArg))
526
- return;
527
- const props = getProps(propsArg);
528
- // Check if value is a reactive binding (arrow function)
529
- const valueProp = props.get('value');
530
- if (!valueProp)
531
- return;
532
- if (!ts.isArrowFunction(valueProp) && !ts.isFunctionExpression(valueProp))
533
- return;
534
- // Must have onInput
535
- if (!props.has('onInput') && !props.has('onChange')) {
536
- const { line, column } = pos(node, sf);
537
- diagnostics.push({
538
- rule: 'controlled-input',
539
- message: `Controlled input at line ${line}: reactive 'value' binding without 'onInput' handler. The binding will overwrite user input on every state update.`,
540
- line,
541
- column,
542
- });
543
- }
544
- }
545
- function getStaticPropKeys(obj) {
546
- const keys = new Set();
547
- for (const prop of obj.properties) {
548
- if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
549
- keys.add(prop.name.text);
550
- }
551
- }
552
- return keys;
553
- }
554
- function getProps(obj) {
555
- const map = new Map();
556
- for (const prop of obj.properties) {
557
- if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
558
- map.set(prop.name.text, prop.initializer);
559
- }
560
- }
561
- return map;
562
- }
563
- // ── child() static props ────────────────────────────────────────
564
- function checkChildStaticProps(node, sf, diagnostics) {
565
- if (!ts.isCallExpression(node))
566
- return;
567
- if (!ts.isIdentifier(node.expression) || node.expression.text !== 'child')
568
- return;
569
- const arg = node.arguments[0];
570
- if (!arg || !ts.isObjectLiteralExpression(arg))
571
- return;
572
- for (const prop of arg.properties) {
573
- if (!ts.isPropertyAssignment(prop))
574
- continue;
575
- if (!ts.isIdentifier(prop.name) || prop.name.text !== 'props')
576
- continue;
577
- // props must be a function, not an object literal
578
- if (ts.isObjectLiteralExpression(prop.initializer)) {
579
- const { line, column } = pos(node, sf);
580
- diagnostics.push({
581
- rule: 'child-static-props',
582
- message: `child() at line ${line}: 'props' is a static object literal. It must be a reactive accessor function (s => ({ ... })) so props update when parent state changes.`,
583
- line,
584
- column,
585
- });
586
- continue;
587
- }
588
- // props accessor: warn when the returned object contains fresh
589
- // object/array literals. The prop-diff in `child()` compares by
590
- // reference per top-level key (Object.is), so a freshly-constructed
591
- // nested value reports changed on every parent update — propsMsg
592
- // fires every render, which is wasted work at best and an infinite
593
- // loop vector when combined with a naive `onMsg` forwarder.
594
- if (ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer)) {
595
- const returned = getReturnedObjectLiteral(prop.initializer);
596
- if (!returned)
597
- continue;
598
- for (const keyProp of returned.properties) {
599
- if (!ts.isPropertyAssignment(keyProp))
600
- continue;
601
- const init = keyProp.initializer;
602
- if (!ts.isObjectLiteralExpression(init) && !ts.isArrayLiteralExpression(init))
603
- continue;
604
- const keyName = ts.isIdentifier(keyProp.name)
605
- ? keyProp.name.text
606
- : ts.isStringLiteral(keyProp.name)
607
- ? keyProp.name.text
608
- : '<?>';
609
- const kind = ts.isArrayLiteralExpression(init) ? 'array' : 'object';
610
- const { line, column } = pos(keyProp, sf);
611
- diagnostics.push({
612
- rule: 'child-static-props',
613
- message: `child() at line ${line}: the 'props' accessor returns a fresh ${kind} literal for '${keyName}'. Prop diffing uses Object.is per key, so a freshly-constructed reference reports changed every render — propsMsg will fire on every parent update. Hoist to a module-level constant, reuse a reference from state, or return null from propsMsg when the value is unchanged.`,
614
- line,
615
- column,
616
- });
617
- }
618
- }
619
- }
620
- }
621
- function getReturnedObjectLiteral(fn) {
622
- const body = fn.body;
623
- if (ts.isParenthesizedExpression(body) && ts.isObjectLiteralExpression(body.expression)) {
624
- return body.expression;
625
- }
626
- if (ts.isObjectLiteralExpression(body))
627
- return body;
628
- if (ts.isBlock(body)) {
629
- for (const stmt of body.statements) {
630
- if (!ts.isReturnStatement(stmt) || !stmt.expression)
631
- continue;
632
- const expr = stmt.expression;
633
- if (ts.isObjectLiteralExpression(expr))
634
- return expr;
635
- if (ts.isParenthesizedExpression(expr) && ts.isObjectLiteralExpression(expr.expression)) {
636
- return expr.expression;
637
- }
638
- }
639
- }
640
- return null;
641
- }
642
- // ── Bitmask overflow warning ────────────────────────────────────
643
- // The path-scan walker lives in `collect-deps.ts` and is shared with
644
- // the runtime bit-assignment path. Keeping one scanner means one truth
645
- // about what counts as a reactive accessor.
646
- function checkBitmaskOverflow(node, sf, diagnostics, paths) {
647
- // Only emit once, on the component() call
648
- if (!ts.isCallExpression(node))
649
- return;
650
- if (!ts.isIdentifier(node.expression) || node.expression.text !== 'component')
651
- return;
652
- const pathCount = paths.size;
653
- if (pathCount <= 31)
654
- return;
655
- const overflow = pathCount - 31;
656
- const { line, column } = pos(node, sf);
657
- // Group paths by top-level field so authors know which slice to extract.
658
- // `resolveSimpleChain` already truncates to depth 2 (e.g. "user.name"),
659
- // so splitting on "." gives us the top-level field.
660
- const byTopLevel = new Map();
661
- for (const p of paths) {
662
- const top = p.split('.', 1)[0];
663
- byTopLevel.set(top, (byTopLevel.get(top) ?? 0) + 1);
664
- }
665
- const sorted = [...byTopLevel.entries()].sort((a, b) => b[1] - a[1]);
666
- // Pick the top fields whose combined path count would bring us under
667
- // the 31 limit. These are the best candidates to extract.
668
- const candidates = [];
669
- let saved = 0;
670
- for (const [field, n] of sorted) {
671
- if (pathCount - saved <= 31)
672
- break;
673
- candidates.push(field);
674
- saved += n;
675
- }
676
- const breakdown = sorted.map(([field, n]) => `${field} (${n})`).join(', ');
677
- const candidateList = candidates.map((f) => `\`${f}\``).join(', ');
678
- // Co-occurrence analysis: identify top-level fields whose every
679
- // sub-path always fires in the same accessor sets. Those are prime
680
- // candidates to read as a single object — the parent path (one bit)
681
- // replaces multiple sub-paths (one bit each), saving (count - 1) bits
682
- // toward the 31 limit without the larger surgery that `child()`
683
- // extraction requires.
684
- const accessorSets = collectAccessorPathSets(sf);
685
- const cooccurringFields = findCooccurringFields(paths, accessorSets);
686
- const cooccurrenceNote = cooccurringFields.length > 0
687
- ? `\n\nCo-occurrence detected: ` +
688
- cooccurringFields
689
- .map(({ field, saved }) => `every sub-path under \`${field}\` always fires together; reading \`s.${field}\` as one unit saves ${saved} bit${saved === 1 ? '' : 's'}`)
690
- .join('; ') +
691
- `. Bundle those reads into a single \`s.${cooccurringFields[0].field}\` ` +
692
- `access (e.g. \`const ${cooccurringFields[0].field} = s.${cooccurringFields[0].field}\`) ` +
693
- `before extraction — cheaper refactor, same budget relief.`
694
- : '';
695
- diagnostics.push({
696
- rule: 'bitmask-overflow',
697
- message: `Component at line ${line} has ${pathCount} unique state access paths ` +
698
- `(${overflow} past the 31-path limit). Paths 32..${pathCount} fall back to ` +
699
- `FULL_MASK — their changes re-evaluate every binding in the component, ` +
700
- `negating the bitmask optimization for those updates.\n\n` +
701
- `Top-level fields by path count: ${breakdown}.` +
702
- cooccurrenceNote +
703
- `\n\n` +
704
- `Recommended fix: extract ${candidateList} into ${candidates.length === 1 ? 'a' : ''} ` +
705
- `child component${candidates.length === 1 ? '' : 's'} via \`child()\` ` +
706
- `(see /api/dom#child). Each child gets its own 31-path bitmask, so the ` +
707
- `extracted paths no longer count against the parent's limit. ` +
708
- `Alternative: use \`sliceHandler\` to embed a state machine that owns ` +
709
- `the field's reducer.`,
710
- line,
711
- column,
712
- });
713
- }
714
- /**
715
- * Identify top-level fields whose every sub-path fires in the SAME set
716
- * of accessors — the signature of paths that could share a single bit
717
- * if the author read the parent object as one unit. Returns a list of
718
- * `{ field, saved }` records where `saved = sub-path count - 1` (the
719
- * bits freed by collapsing to the parent read).
720
- *
721
- * Only meaningful for fields with 2+ sub-paths; single-path top-level
722
- * fields already occupy exactly one bit.
723
- */
724
- function findCooccurringFields(paths, accessorSets) {
725
- // Group paths by top-level field. Only fields whose depth-2 paths
726
- // uniformly share the same appearance-signature are candidates.
727
- const subPathsByTop = new Map();
728
- for (const p of paths) {
729
- const dot = p.indexOf('.');
730
- if (dot < 0)
731
- continue; // depth-1 path — no bundling opportunity
732
- const top = p.slice(0, dot);
733
- const arr = subPathsByTop.get(top) ?? [];
734
- arr.push(p);
735
- subPathsByTop.set(top, arr);
736
- }
737
- // For each path, record the set of accessors that read it.
738
- const appearances = new Map();
739
- for (let i = 0; i < accessorSets.length; i++) {
740
- for (const path of accessorSets[i]) {
741
- if (!appearances.has(path))
742
- appearances.set(path, new Set());
743
- appearances.get(path).add(i);
744
- }
745
- }
746
- const out = [];
747
- for (const [field, subPaths] of subPathsByTop) {
748
- if (subPaths.length < 2)
749
- continue;
750
- // Compute the signature for each sub-path and check they all match.
751
- const first = appearances.get(subPaths[0]) ?? new Set();
752
- let uniform = true;
753
- for (let i = 1; i < subPaths.length; i++) {
754
- const set = appearances.get(subPaths[i]) ?? new Set();
755
- if (!setsEqual(first, set)) {
756
- uniform = false;
757
- break;
758
- }
759
- }
760
- if (uniform)
761
- out.push({ field, saved: subPaths.length - 1 });
762
- }
763
- return out.sort((a, b) => b.saved - a.saved);
764
- }
765
- function setsEqual(a, b) {
766
- if (a.size !== b.size)
767
- return false;
768
- for (const x of a)
769
- if (!b.has(x))
770
- return false;
771
- return true;
772
- }
773
- // ── scope/branch `on` reads no state ────────────────────────────
774
- // If the discriminant accessor doesn't read any state paths, the key
775
- // never changes after mount and the subtree never rebuilds. Likely a
776
- // bug — warn so the author can verify intent.
777
- function checkStaticOn(node, sf, diagnostics) {
778
- if (!ts.isCallExpression(node))
779
- return;
780
- if (!ts.isIdentifier(node.expression))
781
- return;
782
- const name = node.expression.text;
783
- if (name !== 'scope' && name !== 'branch')
784
- return;
785
- const optsArg = node.arguments[0];
786
- if (!optsArg || !ts.isObjectLiteralExpression(optsArg))
787
- return;
788
- const onProp = optsArg.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === 'on');
789
- if (!onProp)
790
- return;
791
- const onValue = onProp.initializer;
792
- if (!ts.isArrowFunction(onValue) && !ts.isFunctionExpression(onValue))
793
- return;
794
- // Extract paths rooted at `on`'s single parameter. Zero-param
795
- // on (`on: () => 'x'`) definitionally reads no state and must warn.
796
- const params = onValue.parameters;
797
- const paths = new Set();
798
- if (params.length === 1) {
799
- const param = params[0].name;
800
- if (!ts.isIdentifier(param))
801
- return;
802
- collectPathsInBody(onValue.body, param.text, paths);
803
- }
804
- else if (params.length !== 0) {
805
- return;
806
- }
807
- if (paths.size > 0)
808
- return;
809
- const { line, column } = pos(node, sf);
810
- diagnostics.push({
811
- rule: 'static-on',
812
- message: `${name}() at line ${line}: 'on' reads no state — the key never ` +
813
- `changes, so the subtree mounts once and never rebuilds. ` +
814
- `Is this intentional? If so, consider replacing with a static ` +
815
- `builder; if not, reference the state field(s) that drive the ` +
816
- `discriminant.`,
817
- line,
818
- column,
819
- });
820
- }
821
- // Minimal state-path extractor used only by checkStaticOn; it needs the
822
- // same "chain rooted at paramName" logic as the shared collector but
823
- // without walking into nested reactive-accessor arrows (we only care
824
- // about reads inside `on`'s immediate body).
825
- function collectPathsInBody(body, paramName, out) {
826
- if (ts.isPropertyAccessExpression(body)) {
827
- const parts = [];
828
- let current = body;
829
- while (ts.isPropertyAccessExpression(current)) {
830
- parts.unshift(current.name.text);
831
- current = current.expression;
832
- }
833
- if (ts.isIdentifier(current) && current.text === paramName) {
834
- out.add(parts.slice(0, 2).join('.'));
835
- }
836
- }
837
- if (ts.isElementAccessExpression(body)) {
838
- if (ts.isIdentifier(body.expression) &&
839
- body.expression.text === paramName &&
840
- ts.isStringLiteral(body.argumentExpression)) {
841
- out.add(body.argumentExpression.text);
842
- }
843
- }
844
- ts.forEachChild(body, (child) => collectPathsInBody(child, paramName, out));
845
- }
846
- //# sourceMappingURL=diagnostics.js.map