@llui/vite-plugin 0.0.20 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/collect-deps.d.ts +21 -0
- package/dist/collect-deps.d.ts.map +1 -1
- package/dist/collect-deps.js +59 -13
- package/dist/collect-deps.js.map +1 -1
- package/dist/diagnostics.d.ts +7 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +302 -60
- package/dist/diagnostics.js.map +1 -1
- package/dist/index.d.ts +40 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +136 -9
- package/dist/index.js.map +1 -1
- package/dist/transform.d.ts +1 -1
- package/dist/transform.d.ts.map +1 -1
- package/dist/transform.js +20 -9
- package/dist/transform.js.map +1 -1
- package/package.json +1 -1
package/dist/diagnostics.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
|
+
import { collectStatePathsFromSource, collectAccessorPathSets } from './collect-deps.js';
|
|
2
3
|
const INTERACTIVE_ELEMENTS = new Set([
|
|
3
4
|
'button',
|
|
4
5
|
'a',
|
|
@@ -81,8 +82,8 @@ export function diagnose(source) {
|
|
|
81
82
|
const diagnostics = [];
|
|
82
83
|
// Collect Msg type variants for exhaustive update() check
|
|
83
84
|
const msgVariants = collectMsgVariants(sf);
|
|
84
|
-
// Collect state access paths for bitmask warning
|
|
85
|
-
const statePaths =
|
|
85
|
+
// Collect state access paths for bitmask warning (shared scanner with collect-deps.ts)
|
|
86
|
+
const statePaths = collectStatePathsFromSource(sf);
|
|
86
87
|
function visit(node) {
|
|
87
88
|
checkMapOnState(node, sf, diagnostics);
|
|
88
89
|
checkExhaustiveUpdate(node, sf, diagnostics, msgVariants);
|
|
@@ -93,6 +94,7 @@ export function diagnose(source) {
|
|
|
93
94
|
checkNamespaceImport(node, sf, diagnostics);
|
|
94
95
|
checkSpreadChildren(node, sf, diagnostics);
|
|
95
96
|
checkEmptyProps(node, sf, diagnostics);
|
|
97
|
+
checkStaticOn(node, sf, diagnostics);
|
|
96
98
|
ts.forEachChild(node, visit);
|
|
97
99
|
}
|
|
98
100
|
visit(sf);
|
|
@@ -117,6 +119,7 @@ function checkNamespaceImport(node, sf, diagnostics) {
|
|
|
117
119
|
const name = clause.namedBindings.name.text;
|
|
118
120
|
const { line, column } = pos(clause.namedBindings, sf);
|
|
119
121
|
diagnostics.push({
|
|
122
|
+
rule: 'namespace-import',
|
|
120
123
|
message: `Namespace import '${name}' from '@llui/dom' at line ${line} disables compiler optimizations. Use named imports instead: import { div, text, ... } from '@llui/dom'.`,
|
|
121
124
|
line,
|
|
122
125
|
column,
|
|
@@ -125,6 +128,12 @@ function checkNamespaceImport(node, sf, diagnostics) {
|
|
|
125
128
|
// Warns when a children array contains a spread — the compiler can't
|
|
126
129
|
// analyze variable-length children, so it bails on template cloning and
|
|
127
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.
|
|
128
137
|
function checkSpreadChildren(node, sf, diagnostics) {
|
|
129
138
|
if (!ts.isCallExpression(node))
|
|
130
139
|
return;
|
|
@@ -136,15 +145,14 @@ function checkSpreadChildren(node, sf, diagnostics) {
|
|
|
136
145
|
for (const arg of node.arguments) {
|
|
137
146
|
if (!ts.isArrayLiteralExpression(arg))
|
|
138
147
|
continue;
|
|
139
|
-
// Look for "suspicious" spreads — ones that aren't obviously returning
|
|
140
|
-
// Node[] from a structural primitive or user-defined view helper.
|
|
141
148
|
for (const el of arg.elements) {
|
|
142
149
|
if (!ts.isSpreadElement(el))
|
|
143
150
|
continue;
|
|
144
|
-
if (
|
|
151
|
+
if (isBoundedSpreadSource(el.expression, sf))
|
|
145
152
|
continue;
|
|
146
153
|
const { line, column } = pos(arg, sf);
|
|
147
154
|
diagnostics.push({
|
|
155
|
+
rule: 'spread-in-children',
|
|
148
156
|
message: `Spread in children array of '${node.expression.text}()' at line ${line} disables template-clone compilation. For dynamic child counts, use each() instead.`,
|
|
149
157
|
line,
|
|
150
158
|
column,
|
|
@@ -166,22 +174,139 @@ const ARRAY_ITERATION_METHODS = new Set([
|
|
|
166
174
|
'reverse',
|
|
167
175
|
'sort',
|
|
168
176
|
]);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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.
|
|
173
193
|
if (ts.isCallExpression(expr)) {
|
|
174
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;
|
|
175
234
|
if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)) {
|
|
176
|
-
|
|
177
|
-
|
|
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);
|
|
178
239
|
}
|
|
179
|
-
|
|
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))
|
|
180
256
|
return true;
|
|
257
|
+
if (ts.isAsExpression(init) || ts.isTypeAssertionExpression(init)) {
|
|
258
|
+
return ts.isArrayLiteralExpression(init.expression);
|
|
181
259
|
}
|
|
182
|
-
// Identifier spread (`...arr`) — suspect
|
|
183
260
|
return false;
|
|
184
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
|
+
}
|
|
185
310
|
// Warns when an element helper is called with an empty props object — the
|
|
186
311
|
// attrs argument is optional, so `h1({}, [...])` should be `h1([...])`.
|
|
187
312
|
function checkEmptyProps(node, sf, diagnostics) {
|
|
@@ -198,6 +323,7 @@ function checkEmptyProps(node, sf, diagnostics) {
|
|
|
198
323
|
return;
|
|
199
324
|
const { line, column } = pos(firstArg, sf);
|
|
200
325
|
diagnostics.push({
|
|
326
|
+
rule: 'empty-props',
|
|
201
327
|
message: `Empty props object passed to '${node.expression.text}()' at line ${line}. The attrs argument is optional — omit it: ${node.expression.text}([...]).`,
|
|
202
328
|
line,
|
|
203
329
|
column,
|
|
@@ -245,6 +371,7 @@ function checkMapOnState(node, sf, diagnostics) {
|
|
|
245
371
|
return;
|
|
246
372
|
const { line, column } = pos(node, sf);
|
|
247
373
|
diagnostics.push({
|
|
374
|
+
rule: 'map-on-state',
|
|
248
375
|
message: `Array .map() on state-derived value at line ${line}. Use each() for reactive lists that update when the array changes.`,
|
|
249
376
|
line,
|
|
250
377
|
column,
|
|
@@ -345,6 +472,7 @@ function checkExhaustiveUpdate(node, sf, diagnostics, msgVariants) {
|
|
|
345
472
|
return;
|
|
346
473
|
const { line, column } = pos(node, sf);
|
|
347
474
|
diagnostics.push({
|
|
475
|
+
rule: 'exhaustive-update',
|
|
348
476
|
message: `update() does not handle message type${missing.length > 1 ? 's' : ''} ${missing.map((m) => `'${m}'`).join(', ')} at line ${line}.`,
|
|
349
477
|
line,
|
|
350
478
|
column,
|
|
@@ -367,6 +495,7 @@ function checkAccessibility(node, sf, diagnostics) {
|
|
|
367
495
|
if (tag === 'img' && !props.has('alt')) {
|
|
368
496
|
const { line, column } = pos(node, sf);
|
|
369
497
|
diagnostics.push({
|
|
498
|
+
rule: 'accessibility',
|
|
370
499
|
message: `<img> at line ${line} has no 'alt' attribute. Add alt text for screen readers, or alt='' for decorative images.`,
|
|
371
500
|
line,
|
|
372
501
|
column,
|
|
@@ -376,6 +505,7 @@ function checkAccessibility(node, sf, diagnostics) {
|
|
|
376
505
|
if (props.has('onClick') && !INTERACTIVE_ELEMENTS.has(tag) && !props.has('role')) {
|
|
377
506
|
const { line, column } = pos(node, sf);
|
|
378
507
|
diagnostics.push({
|
|
508
|
+
rule: 'accessibility',
|
|
379
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>.`,
|
|
380
510
|
line,
|
|
381
511
|
column,
|
|
@@ -405,6 +535,7 @@ function checkControlledInput(node, sf, diagnostics) {
|
|
|
405
535
|
if (!props.has('onInput') && !props.has('onChange')) {
|
|
406
536
|
const { line, column } = pos(node, sf);
|
|
407
537
|
diagnostics.push({
|
|
538
|
+
rule: 'controlled-input',
|
|
408
539
|
message: `Controlled input at line ${line}: reactive 'value' binding without 'onInput' handler. The binding will overwrite user input on every state update.`,
|
|
409
540
|
line,
|
|
410
541
|
column,
|
|
@@ -447,6 +578,7 @@ function checkChildStaticProps(node, sf, diagnostics) {
|
|
|
447
578
|
if (ts.isObjectLiteralExpression(prop.initializer)) {
|
|
448
579
|
const { line, column } = pos(node, sf);
|
|
449
580
|
diagnostics.push({
|
|
581
|
+
rule: 'child-static-props',
|
|
450
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.`,
|
|
451
583
|
line,
|
|
452
584
|
column,
|
|
@@ -477,6 +609,7 @@ function checkChildStaticProps(node, sf, diagnostics) {
|
|
|
477
609
|
const kind = ts.isArrayLiteralExpression(init) ? 'array' : 'object';
|
|
478
610
|
const { line, column } = pos(keyProp, sf);
|
|
479
611
|
diagnostics.push({
|
|
612
|
+
rule: 'child-static-props',
|
|
480
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.`,
|
|
481
614
|
line,
|
|
482
615
|
column,
|
|
@@ -507,52 +640,9 @@ function getReturnedObjectLiteral(fn) {
|
|
|
507
640
|
return null;
|
|
508
641
|
}
|
|
509
642
|
// ── Bitmask overflow warning ────────────────────────────────────
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if ((ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
|
|
514
|
-
node.parameters.length === 1) {
|
|
515
|
-
const param = node.parameters[0].name;
|
|
516
|
-
if (ts.isIdentifier(param)) {
|
|
517
|
-
// Check if this looks like a reactive accessor
|
|
518
|
-
const parent = node.parent;
|
|
519
|
-
if (ts.isPropertyAssignment(parent)) {
|
|
520
|
-
const key = parent.name;
|
|
521
|
-
if (ts.isIdentifier(key) && !/^on[A-Z]/.test(key.text)) {
|
|
522
|
-
extractAccessPaths(node.body, param.text, paths);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
else if (ts.isCallExpression(parent) && parent.arguments[0] === node) {
|
|
526
|
-
extractAccessPaths(node.body, param.text, paths);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
ts.forEachChild(node, visit);
|
|
531
|
-
}
|
|
532
|
-
visit(sf);
|
|
533
|
-
return paths;
|
|
534
|
-
}
|
|
535
|
-
function extractAccessPaths(node, paramName, paths) {
|
|
536
|
-
if (ts.isPropertyAccessExpression(node)) {
|
|
537
|
-
const chain = resolveSimpleChain(node, paramName);
|
|
538
|
-
if (chain)
|
|
539
|
-
paths.add(chain);
|
|
540
|
-
}
|
|
541
|
-
ts.forEachChild(node, (child) => extractAccessPaths(child, paramName, paths));
|
|
542
|
-
}
|
|
543
|
-
function resolveSimpleChain(node, paramName) {
|
|
544
|
-
const parts = [];
|
|
545
|
-
let current = node;
|
|
546
|
-
while (ts.isPropertyAccessExpression(current)) {
|
|
547
|
-
parts.unshift(current.name.text);
|
|
548
|
-
current = current.expression;
|
|
549
|
-
}
|
|
550
|
-
if (!ts.isIdentifier(current) || current.text !== paramName)
|
|
551
|
-
return null;
|
|
552
|
-
if (parts.length > 2)
|
|
553
|
-
return parts.slice(0, 2).join('.');
|
|
554
|
-
return parts.join('.');
|
|
555
|
-
}
|
|
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.
|
|
556
646
|
function checkBitmaskOverflow(node, sf, diagnostics, paths) {
|
|
557
647
|
// Only emit once, on the component() call
|
|
558
648
|
if (!ts.isCallExpression(node))
|
|
@@ -585,12 +675,32 @@ function checkBitmaskOverflow(node, sf, diagnostics, paths) {
|
|
|
585
675
|
}
|
|
586
676
|
const breakdown = sorted.map(([field, n]) => `${field} (${n})`).join(', ');
|
|
587
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
|
+
: '';
|
|
588
695
|
diagnostics.push({
|
|
696
|
+
rule: 'bitmask-overflow',
|
|
589
697
|
message: `Component at line ${line} has ${pathCount} unique state access paths ` +
|
|
590
698
|
`(${overflow} past the 31-path limit). Paths 32..${pathCount} fall back to ` +
|
|
591
699
|
`FULL_MASK — their changes re-evaluate every binding in the component, ` +
|
|
592
700
|
`negating the bitmask optimization for those updates.\n\n` +
|
|
593
|
-
`Top-level fields by path count: ${breakdown}
|
|
701
|
+
`Top-level fields by path count: ${breakdown}.` +
|
|
702
|
+
cooccurrenceNote +
|
|
703
|
+
`\n\n` +
|
|
594
704
|
`Recommended fix: extract ${candidateList} into ${candidates.length === 1 ? 'a' : ''} ` +
|
|
595
705
|
`child component${candidates.length === 1 ? '' : 's'} via \`child()\` ` +
|
|
596
706
|
`(see /api/dom#child). Each child gets its own 31-path bitmask, so the ` +
|
|
@@ -601,4 +711,136 @@ function checkBitmaskOverflow(node, sf, diagnostics, paths) {
|
|
|
601
711
|
column,
|
|
602
712
|
});
|
|
603
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
|
+
}
|
|
604
846
|
//# sourceMappingURL=diagnostics.js.map
|