@llui/compiler 0.5.1 → 0.5.3
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/accessor-resolver.d.ts +9 -1
- package/dist/accessor-resolver.d.ts.map +1 -1
- package/dist/accessor-resolver.js +68 -9
- package/dist/accessor-resolver.js.map +1 -1
- package/dist/collect-deps.d.ts +5 -1
- package/dist/collect-deps.d.ts.map +1 -1
- package/dist/collect-deps.js +117 -14
- package/dist/collect-deps.js.map +1 -1
- package/dist/cross-file-walker.d.ts +4 -1
- package/dist/cross-file-walker.d.ts.map +1 -1
- package/dist/cross-file-walker.js +72 -9
- package/dist/cross-file-walker.js.map +1 -1
- package/dist/lint-modules.d.ts.map +1 -1
- package/dist/lint-modules.js +2 -0
- package/dist/lint-modules.js.map +1 -1
- package/dist/module.d.ts +12 -1
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +2 -1
- package/dist/module.js.map +1 -1
- package/dist/modules/bitmask-overflow.js +1 -1
- package/dist/modules/bitmask-overflow.js.map +1 -1
- package/dist/modules/core-synthesis.d.ts +7 -0
- package/dist/modules/core-synthesis.d.ts.map +1 -1
- package/dist/modules/core-synthesis.js +24 -12
- package/dist/modules/core-synthesis.js.map +1 -1
- package/dist/modules/opaque-state-flow.d.ts +3 -0
- package/dist/modules/opaque-state-flow.d.ts.map +1 -0
- package/dist/modules/opaque-state-flow.js +240 -0
- package/dist/modules/opaque-state-flow.js.map +1 -0
- package/dist/transform.d.ts +1 -1
- package/dist/transform.d.ts.map +1 -1
- package/dist/transform.js +84 -6
- package/dist/transform.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// `opaque-state-flow` — errors when a reactive accessor's body flows
|
|
2
|
+
// the state identifier into an expression the walker can't statically
|
|
3
|
+
// trace. The compiler still produces a *correct* binding by forcing
|
|
4
|
+
// FULL_MASK and emitting a whole-state `(s) => s` sentinel into
|
|
5
|
+
// `__prefixes` (see `02 Compiler.md` § "Opaque-flow classifier"), but
|
|
6
|
+
// the binding then re-evaluates on every state change rather than
|
|
7
|
+
// only when its actual reads change. This rule surfaces the leak so
|
|
8
|
+
// authors can either:
|
|
9
|
+
//
|
|
10
|
+
// - Rewrite the accessor as direct property access (`s.foo`,
|
|
11
|
+
// `s.foo['literal']`), the form the walker can resolve.
|
|
12
|
+
// - Declare the reads explicitly via `track({ deps: (s) => [...] })`
|
|
13
|
+
// — the compile-time escape hatch the framework provides for cases
|
|
14
|
+
// where the read genuinely can't be expressed inline.
|
|
15
|
+
//
|
|
16
|
+
// Detected leak shapes (mirrors the classifier in
|
|
17
|
+
// `transform.ts:computeAccessorMask`):
|
|
18
|
+
// - `helper(s)` with an Identifier callee that can't be resolved to
|
|
19
|
+
// a local declaration (function parameter, import, destructured
|
|
20
|
+
// binding) — the callee may read any field of `s`.
|
|
21
|
+
//
|
|
22
|
+
// NOT flagged (intentional): `obj.helper(s)` / `lib.fn(s)` —
|
|
23
|
+
// PropertyAccessExpression callees. This is the documented headless-
|
|
24
|
+
// components idiom (`pr.valueText(s)` where `pr` comes from
|
|
25
|
+
// `progress.connect()`), and refactoring it defeats the API surface.
|
|
26
|
+
// The runtime sentinel keeps such bindings correct — the cost is a
|
|
27
|
+
// per-update re-evaluation, which is a property of the composition
|
|
28
|
+
// pattern rather than an author mistake worth blocking.
|
|
29
|
+
// - `new Wrapper(s)` — NewExpression with state as an argument.
|
|
30
|
+
// - `` tag`${s}` `` — TaggedTemplate with state in a span.
|
|
31
|
+
// - `{ ...s }` / `[...s]` — spread of state.
|
|
32
|
+
// - `const x = s` — const aliasing.
|
|
33
|
+
// - `cond ? s : other` — state in a conditional branch (state
|
|
34
|
+
// reaches the binding via a path the walker can't trace).
|
|
35
|
+
// - `s[expr]` — dynamic element access (literal keys are tracked).
|
|
36
|
+
// - State passed as `arg1+` to any call (the existing delegation
|
|
37
|
+
// branch only inspects `arg0`).
|
|
38
|
+
import ts from 'typescript';
|
|
39
|
+
import { rangeFromOffsets } from '../diagnostic.js';
|
|
40
|
+
import { isReactiveAccessor } from '../collect-deps.js';
|
|
41
|
+
import { resolveAccessorBody } from '../accessor-resolver.js';
|
|
42
|
+
// Mirrors the file-local list in collect-deps.ts. Calls to these
|
|
43
|
+
// framework primitives are visited as accessor positions in their
|
|
44
|
+
// own right, so we don't double-classify.
|
|
45
|
+
const NON_DELEGATION_HELPERS = new Set(['sample', 'item', 'memo', 'text', 'unsafeHtml']);
|
|
46
|
+
function findFirstLeakInAccessor(accessor, checker) {
|
|
47
|
+
if (accessor.parameters.length !== 1)
|
|
48
|
+
return null;
|
|
49
|
+
const param = accessor.parameters[0];
|
|
50
|
+
if (!ts.isIdentifier(param.name))
|
|
51
|
+
return null;
|
|
52
|
+
if (!accessor.body)
|
|
53
|
+
return null;
|
|
54
|
+
const stateParam = param.name.text;
|
|
55
|
+
let leak = null;
|
|
56
|
+
const visit = (node) => {
|
|
57
|
+
if (leak)
|
|
58
|
+
return;
|
|
59
|
+
if (ts.isIdentifier(node) && node.text === stateParam) {
|
|
60
|
+
const parent = node.parent;
|
|
61
|
+
if (!parent || ts.isParameter(parent)) {
|
|
62
|
+
ts.forEachChild(node, visit);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Tracked containers — the same set the mask classifier honors.
|
|
66
|
+
let tracked = false;
|
|
67
|
+
let shape = '';
|
|
68
|
+
let hint = '';
|
|
69
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
|
|
70
|
+
tracked = true;
|
|
71
|
+
}
|
|
72
|
+
else if (ts.isElementAccessExpression(parent) && parent.expression === node) {
|
|
73
|
+
if (ts.isStringLiteralLike(parent.argumentExpression) ||
|
|
74
|
+
ts.isNumericLiteral(parent.argumentExpression)) {
|
|
75
|
+
tracked = true;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
shape = `dynamic element access \`s[<expr>]\``;
|
|
79
|
+
hint =
|
|
80
|
+
'replace the dynamic key with a literal property (e.g. `s.foo`), or declare the read via `track({ deps: (s) => [s[key]] })`.';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else if (ts.isCallExpression(parent)) {
|
|
84
|
+
const argIndex = parent.arguments.indexOf(node);
|
|
85
|
+
if (argIndex > 0) {
|
|
86
|
+
// State passed as arg1+ to a call. The header documents this
|
|
87
|
+
// as NOT flagged (intentional): the existing delegation
|
|
88
|
+
// branch only attempts to trace arg0, and the mask classifier
|
|
89
|
+
// emits a whole-state sentinel into `__prefixes` so the
|
|
90
|
+
// binding stays correct. The cost is per-update re-evaluation
|
|
91
|
+
// — a property of the composition pattern, not an author
|
|
92
|
+
// mistake worth blocking. Without this branch we'd fall
|
|
93
|
+
// through to the default "outside a tracked container" leak.
|
|
94
|
+
tracked = true;
|
|
95
|
+
}
|
|
96
|
+
else if (argIndex === 0) {
|
|
97
|
+
if (ts.isIdentifier(parent.expression) &&
|
|
98
|
+
!NON_DELEGATION_HELPERS.has(parent.expression.text)) {
|
|
99
|
+
// Identifier-callee delegation. Recurse into the callee's
|
|
100
|
+
// body via the same resolver the mask walker uses. If it
|
|
101
|
+
// resolves to a local accessor, the helper's reads are
|
|
102
|
+
// walked transitively and the call is tracked. If the
|
|
103
|
+
// callee is a function parameter, import, destructured
|
|
104
|
+
// binding, or otherwise unresolvable, this IS the leak
|
|
105
|
+
// shape — flag it here so the diagnostic points at the
|
|
106
|
+
// call site rather than at some deeper unresolvable read.
|
|
107
|
+
const resolved = resolveAccessorBody(parent.expression, checker);
|
|
108
|
+
if (resolved) {
|
|
109
|
+
tracked = true;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const calleeSymbol = checker?.getSymbolAtLocation(parent.expression);
|
|
113
|
+
const isFunctionParam = !!calleeSymbol?.declarations?.some((d) => ts.isParameter(d));
|
|
114
|
+
shape = `call to an unresolvable callee \`${parent.expression.text}(s)\` (function parameter, import, or destructured binding)`;
|
|
115
|
+
if (isFunctionParam) {
|
|
116
|
+
hint =
|
|
117
|
+
'this callee is a function parameter — the closure passed at the call site is opaque to per-binding analysis. The framework expects per-row dynamic state to flow through `each` items (slot data on `item.*`) rather than through `(s) => ...` callback parameters; restructure the helper so its bindings read `item.*` and the call site builds the slot data once in `items: (s) => …`.';
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
hint =
|
|
121
|
+
'inline the read against `s` directly, refactor the callee into a same-module `const`/`function` declaration, or declare the dependencies via `track({ deps: (s) => [...] })`.';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (!ts.isIdentifier(parent.expression)) {
|
|
126
|
+
// Method-call / computed callee with state arg —
|
|
127
|
+
// `obj.helper(s)`, `lib.fn(s)`. This is the documented
|
|
128
|
+
// headless-components idiom (`pr.valueText(s)` where `pr`
|
|
129
|
+
// comes from `progress.connect()`); refactoring it would
|
|
130
|
+
// defeat the API surface. The runtime sentinel keeps the
|
|
131
|
+
// binding correct — just at the cost of re-evaluating on
|
|
132
|
+
// every update. Treat as tracked from the lint's POV so
|
|
133
|
+
// legitimate composition doesn't error the build; the
|
|
134
|
+
// perf cost is a property of the composition pattern, not
|
|
135
|
+
// an author mistake worth blocking.
|
|
136
|
+
tracked = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (ts.isNewExpression(parent)) {
|
|
141
|
+
shape = 'state passed as a constructor argument (`new X(s)`)';
|
|
142
|
+
hint =
|
|
143
|
+
'compute the derived value inline against direct state reads, or use `track({ deps: (s) => [...] })` to declare the reads.';
|
|
144
|
+
}
|
|
145
|
+
else if (ts.isSpreadElement(parent) || ts.isSpreadAssignment(parent)) {
|
|
146
|
+
shape = 'state spread (`{...s}` / `[...s]`)';
|
|
147
|
+
hint =
|
|
148
|
+
'spread only the fields you actually need (`{...s.user}`), or use `track({ deps: (s) => [...] })`.';
|
|
149
|
+
}
|
|
150
|
+
else if (ts.isVariableDeclaration(parent)) {
|
|
151
|
+
shape = 'const alias (`const x = s; … x.foo`)';
|
|
152
|
+
hint =
|
|
153
|
+
'inline the alias to `s.foo`, or split the deeper read into a separate single-assignment alias `const foo = s.foo`.';
|
|
154
|
+
}
|
|
155
|
+
else if (ts.isConditionalExpression(parent)) {
|
|
156
|
+
shape = 'state in a conditional branch (`cond ? s : other`)';
|
|
157
|
+
hint = 'move the conditional inside the property access: `cond ? s.foo : other.foo`.';
|
|
158
|
+
}
|
|
159
|
+
else if (ts.isAsExpression(parent) || ts.isTypeAssertionExpression(parent)) {
|
|
160
|
+
shape = 'type assertion wrapping state (`(s as T).foo`)';
|
|
161
|
+
hint = 'drop the assertion — the chain `s.foo` already carries the type.';
|
|
162
|
+
}
|
|
163
|
+
else if (ts.isParenthesizedExpression(parent)) {
|
|
164
|
+
// Walk up through parens transparently. Don't flag here; the
|
|
165
|
+
// outer parent classifies.
|
|
166
|
+
ts.forEachChild(node, visit);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
shape = `state used outside a tracked container (${describe(parent)})`;
|
|
171
|
+
hint =
|
|
172
|
+
'restructure the expression so `s` appears only as the root of a property/element-access chain, or declare the read via `track({ deps: (s) => [...] })`.';
|
|
173
|
+
}
|
|
174
|
+
if (!tracked) {
|
|
175
|
+
leak = { node, shape, hint };
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
ts.forEachChild(node, visit);
|
|
180
|
+
};
|
|
181
|
+
visit(accessor.body);
|
|
182
|
+
return leak;
|
|
183
|
+
}
|
|
184
|
+
function describe(node) {
|
|
185
|
+
if (ts.isIdentifier(node))
|
|
186
|
+
return node.text;
|
|
187
|
+
if (ts.isPropertyAccessExpression(node))
|
|
188
|
+
return `${describe(node.expression)}.${node.name.text}`;
|
|
189
|
+
return ts.SyntaxKind[node.kind];
|
|
190
|
+
}
|
|
191
|
+
export function opaqueStateFlowModule() {
|
|
192
|
+
return {
|
|
193
|
+
name: 'opaque-state-flow',
|
|
194
|
+
compilerVersion: '^0.3.0',
|
|
195
|
+
diagnostics: [
|
|
196
|
+
{
|
|
197
|
+
id: 'llui/opaque-state-flow',
|
|
198
|
+
description: "Reactive accessor flows state into an opaque expression the walker can't trace. The runtime stays correct via a FULL_MASK binding + whole-state sentinel in `__prefixes`, but the binding then re-evaluates on every state change.",
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
visitors: {
|
|
202
|
+
[ts.SyntaxKind.SourceFile]: (ctx, node) => {
|
|
203
|
+
// When the host adapter has built a Program, walk the checker's
|
|
204
|
+
// own SourceFile so symbol resolution (Alias → Symbol via
|
|
205
|
+
// `getSymbolAtLocation`) actually works. The reparsed file used
|
|
206
|
+
// in the AST-only fallback is not part of any Program, so the
|
|
207
|
+
// checker can't resolve identifiers in it. Fall back to a
|
|
208
|
+
// reparse for paths without a Program (test harness, lint
|
|
209
|
+
// adapters without cross-file resolution).
|
|
210
|
+
const visited = node;
|
|
211
|
+
const fromProgram = ctx.program?.getSourceFile(visited.fileName);
|
|
212
|
+
const checker = fromProgram ? ctx.checker : undefined;
|
|
213
|
+
const sf = fromProgram ??
|
|
214
|
+
ts.createSourceFile(visited.fileName, visited.text, ts.ScriptTarget.Latest, true);
|
|
215
|
+
const walk = (n) => {
|
|
216
|
+
if ((ts.isArrowFunction(n) || ts.isFunctionExpression(n)) && isReactiveAccessor(n)) {
|
|
217
|
+
const leak = findFirstLeakInAccessor(n, checker);
|
|
218
|
+
if (leak) {
|
|
219
|
+
ctx.reportDiagnostic({
|
|
220
|
+
id: 'llui/opaque-state-flow',
|
|
221
|
+
severity: 'error',
|
|
222
|
+
category: 'perf',
|
|
223
|
+
message: `Reactive accessor flows state opaquely — ${leak.shape}. ` +
|
|
224
|
+
`The compiler ships a correct binding (FULL_MASK + whole-state sentinel), ` +
|
|
225
|
+
`but it re-evaluates on every state change. ${leak.hint}`,
|
|
226
|
+
location: {
|
|
227
|
+
file: sf.fileName,
|
|
228
|
+
range: rangeFromOffsets(sf.text, leak.node.getStart(sf), leak.node.getEnd()),
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
ts.forEachChild(n, walk);
|
|
234
|
+
};
|
|
235
|
+
walk(sf);
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
//# sourceMappingURL=opaque-state-flow.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"opaque-state-flow.js","sourceRoot":"","sources":["../../src/modules/opaque-state-flow.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,sEAAsE;AACtE,oEAAoE;AACpE,gEAAgE;AAChE,sEAAsE;AACtE,kEAAkE;AAClE,oEAAoE;AACpE,sBAAsB;AACtB,EAAE;AACF,+DAA+D;AAC/D,4DAA4D;AAC5D,uEAAuE;AACvE,uEAAuE;AACvE,0DAA0D;AAC1D,EAAE;AACF,kDAAkD;AAClD,uCAAuC;AACvC,sEAAsE;AACtE,oEAAoE;AACpE,uDAAuD;AACvD,EAAE;AACF,6DAA6D;AAC7D,qEAAqE;AACrE,4DAA4D;AAC5D,qEAAqE;AACrE,mEAAmE;AACnE,mEAAmE;AACnE,wDAAwD;AACxD,kEAAkE;AAClE,6DAA6D;AAC7D,+CAA+C;AAC/C,sCAAsC;AACtC,gEAAgE;AAChE,8DAA8D;AAC9D,qEAAqE;AACrE,mEAAmE;AACnE,oCAAoC;AAEpC,OAAO,EAAE,MAAM,YAAY,CAAA;AAC3B,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAA;AAEnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AACvD,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAA;AAE7D,iEAAiE;AACjE,kEAAkE;AAClE,0CAA0C;AAC1C,MAAM,sBAAsB,GAAG,IAAI,GAAG,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,CAAC,CAAC,CAAA;AAQxF,SAAS,uBAAuB,CAC9B,QAA2E,EAC3E,OAAmC;IAEnC,IAAI,QAAQ,CAAC,UAAU,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IACjD,MAAM,KAAK,GAAG,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAE,CAAA;IACrC,IAAI,CAAC,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IAC7C,IAAI,CAAC,QAAQ,CAAC,IAAI;QAAE,OAAO,IAAI,CAAA;IAC/B,MAAM,UAAU,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAA;IAElC,IAAI,IAAI,GAAoB,IAAI,CAAA;IAChC,MAAM,KAAK,GAAG,CAAC,IAAa,EAAQ,EAAE;QACpC,IAAI,IAAI;YAAE,OAAM;QAChB,IAAI,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;YACtD,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAA;YAC1B,IAAI,CAAC,MAAM,IAAI,EAAE,CAAC,WAAW,CAAC,MAAM,CAAC,EAAE,CAAC;gBACtC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;gBAC5B,OAAM;YACR,CAAC;YACD,gEAAgE;YAChE,IAAI,OAAO,GAAG,KAAK,CAAA;YACnB,IAAI,KAAK,GAAG,EAAE,CAAA;YACd,IAAI,IAAI,GAAG,EAAE,CAAA;YACb,IAAI,EAAE,CAAC,0BAA0B,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC;gBACxE,OAAO,GAAG,IAAI,CAAA;YAChB,CAAC;iBAAM,IAAI,EAAE,CAAC,yBAAyB,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC;gBAC9E,IACE,EAAE,CAAC,mBAAmB,CAAC,MAAM,CAAC,kBAAkB,CAAC;oBACjD,EAAE,CAAC,gBAAgB,CAAC,MAAM,CAAC,kBAAkB,CAAC,EAC9C,CAAC;oBACD,OAAO,GAAG,IAAI,CAAA;gBAChB,CAAC;qBAAM,CAAC;oBACN,KAAK,GAAG,sCAAsC,CAAA;oBAC9C,IAAI;wBACF,6HAA6H,CAAA;gBACjI,CAAC;YACH,CAAC;iBAAM,IAAI,EAAE,CAAC,gBAAgB,CAAC,MAAM,CAAC,EAAE,CAAC;gBACvC,MAAM,QAAQ,GAAG,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,IAAqB,CAAC,CAAA;gBAChE,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;oBACjB,6DAA6D;oBAC7D,wDAAwD;oBACxD,8DAA8D;oBAC9D,wDAAwD;oBACxD,8DAA8D;oBAC9D,yDAAyD;oBACzD,wDAAwD;oBACxD,6DAA6D;oBAC7D,OAAO,GAAG,IAAI,CAAA;gBAChB,CAAC;qBAAM,IAAI,QAAQ,KAAK,CAAC,EAAE,CAAC;oBAC1B,IACE,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,UAAU,CAAC;wBAClC,CAAC,sBAAsB,CAAC,GAAG,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,EACnD,CAAC;wBACD,0DAA0D;wBAC1D,yDAAyD;wBACzD,uDAAuD;wBACvD,sDAAsD;wBACtD,uDAAuD;wBACvD,uDAAuD;wBACvD,uDAAuD;wBACvD,0DAA0D;wBAC1D,MAAM,QAAQ,GAAG,mBAAmB,CAAC,MAAM,CAAC,UAAU,EAAE,OAAO,CAAC,CAAA;wBAChE,IAAI,QAAQ,EAAE,CAAC;4BACb,OAAO,GAAG,IAAI,CAAA;wBAChB,CAAC;6BAAM,CAAC;4BACN,MAAM,YAAY,GAAG,OAAO,EAAE,mBAAmB,CAAC,MAAM,CAAC,UAAU,CAAC,CAAA;4BACpE,MAAM,eAAe,GAAG,CAAC,CAAC,YAAY,EAAE,YAAY,EAAE,IAAI,CAAC,CAAC,CAAiB,EAAE,EAAE,CAC/E,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC,CAClB,CAAA;4BACD,KAAK,GAAG,oCAAoC,MAAM,CAAC,UAAU,CAAC,IAAI,6DAA6D,CAAA;4BAC/H,IAAI,eAAe,EAAE,CAAC;gCACpB,IAAI;oCACF,4XAA4X,CAAA;4BAChY,CAAC;iCAAM,CAAC;gCACN,IAAI;oCACF,+KAA+K,CAAA;4BACnL,CAAC;wBACH,CAAC;oBACH,CAAC;yBAAM,IAAI,CAAC,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC;wBAC/C,iDAAiD;wBACjD,uDAAuD;wBACvD,0DAA0D;wBAC1D,yDAAyD;wBACzD,yDAAyD;wBACzD,yDAAyD;wBACzD,wDAAwD;wBACxD,sDAAsD;wBACtD,0DAA0D;wBAC1D,oCAAoC;wBACpC,OAAO,GAAG,IAAI,CAAA;oBAChB,CAAC;gBACH,CAAC;YACH,CAAC;iBAAM,IAAI,EAAE,CAAC,eAAe,CAAC,MAAM,CAAC,EAAE,CAAC;gBACtC,KAAK,GAAG,qDAAqD,CAAA;gBAC7D,IAAI;oBACF,2HAA2H,CAAA;YAC/H,CAAC;iBAAM,IAAI,EAAE,CAAC,eAAe,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,kBAAkB,CAAC,MAAM,CAAC,EAAE,CAAC;gBACvE,KAAK,GAAG,oCAAoC,CAAA;gBAC5C,IAAI;oBACF,mGAAmG,CAAA;YACvG,CAAC;iBAAM,IAAI,EAAE,CAAC,qBAAqB,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC5C,KAAK,GAAG,sCAAsC,CAAA;gBAC9C,IAAI;oBACF,oHAAoH,CAAA;YACxH,CAAC;iBAAM,IAAI,EAAE,CAAC,uBAAuB,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC9C,KAAK,GAAG,oDAAoD,CAAA;gBAC5D,IAAI,GAAG,8EAA8E,CAAA;YACvF,CAAC;iBAAM,IAAI,EAAE,CAAC,cAAc,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,yBAAyB,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC7E,KAAK,GAAG,gDAAgD,CAAA;gBACxD,IAAI,GAAG,kEAAkE,CAAA;YAC3E,CAAC;iBAAM,IAAI,EAAE,CAAC,yBAAyB,CAAC,MAAM,CAAC,EAAE,CAAC;gBAChD,6DAA6D;gBAC7D,2BAA2B;gBAC3B,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;gBAC5B,OAAM;YACR,CAAC;iBAAM,CAAC;gBACN,KAAK,GAAG,2CAA2C,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAA;gBACtE,IAAI;oBACF,yJAAyJ,CAAA;YAC7J,CAAC;YACD,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,IAAI,GAAG,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;gBAC5B,OAAM;YACR,CAAC;QACH,CAAC;QACD,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;IAC9B,CAAC,CAAA;IACD,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAA;IACpB,OAAO,IAAI,CAAA;AACb,CAAC;AAED,SAAS,QAAQ,CAAC,IAAa;IAC7B,IAAI,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC,IAAI,CAAA;IAC3C,IAAI,EAAE,CAAC,0BAA0B,CAAC,IAAI,CAAC;QAAE,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAA;IAChG,OAAO,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AACjC,CAAC;AAED,MAAM,UAAU,qBAAqB;IACnC,OAAO;QACL,IAAI,EAAE,mBAAmB;QACzB,eAAe,EAAE,QAAQ;QACzB,WAAW,EAAE;YACX;gBACE,EAAE,EAAE,wBAAwB;gBAC5B,WAAW,EACT,oOAAoO;aACvO;SACF;QACD,QAAQ,EAAE;YACR,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE;gBACxC,gEAAgE;gBAChE,0DAA0D;gBAC1D,gEAAgE;gBAChE,8DAA8D;gBAC9D,0DAA0D;gBAC1D,0DAA0D;gBAC1D,2CAA2C;gBAC3C,MAAM,OAAO,GAAG,IAAqB,CAAA;gBACrC,MAAM,WAAW,GAAG,GAAG,CAAC,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAA;gBAChE,MAAM,OAAO,GAAG,WAAW,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAA;gBACrD,MAAM,EAAE,GACN,WAAW;oBACX,EAAE,CAAC,gBAAgB,CAAC,OAAO,CAAC,QAAQ,EAAE,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,YAAY,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;gBAEnF,MAAM,IAAI,GAAG,CAAC,CAAU,EAAQ,EAAE;oBAChC,IAAI,CAAC,EAAE,CAAC,eAAe,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC,IAAI,kBAAkB,CAAC,CAAC,CAAC,EAAE,CAAC;wBACnF,MAAM,IAAI,GAAG,uBAAuB,CAAC,CAAC,EAAE,OAAO,CAAC,CAAA;wBAChD,IAAI,IAAI,EAAE,CAAC;4BACT,GAAG,CAAC,gBAAgB,CAAC;gCACnB,EAAE,EAAE,wBAAwB;gCAC5B,QAAQ,EAAE,OAAO;gCACjB,QAAQ,EAAE,MAAM;gCAChB,OAAO,EACL,4CAA4C,IAAI,CAAC,KAAK,IAAI;oCAC1D,2EAA2E;oCAC3E,8CAA8C,IAAI,CAAC,IAAI,EAAE;gCAC3D,QAAQ,EAAE;oCACR,IAAI,EAAE,EAAE,CAAC,QAAQ;oCACjB,KAAK,EAAE,gBAAgB,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;iCAC7E;6BACF,CAAC,CAAA;wBACJ,CAAC;oBACH,CAAC;oBACD,EAAE,CAAC,YAAY,CAAC,CAAC,EAAE,IAAI,CAAC,CAAA;gBAC1B,CAAC,CAAA;gBACD,IAAI,CAAC,EAAE,CAAC,CAAA;YACV,CAAC;SACF;KACF,CAAA;AACH,CAAC","sourcesContent":["// `opaque-state-flow` — errors when a reactive accessor's body flows\n// the state identifier into an expression the walker can't statically\n// trace. The compiler still produces a *correct* binding by forcing\n// FULL_MASK and emitting a whole-state `(s) => s` sentinel into\n// `__prefixes` (see `02 Compiler.md` § \"Opaque-flow classifier\"), but\n// the binding then re-evaluates on every state change rather than\n// only when its actual reads change. This rule surfaces the leak so\n// authors can either:\n//\n// - Rewrite the accessor as direct property access (`s.foo`,\n// `s.foo['literal']`), the form the walker can resolve.\n// - Declare the reads explicitly via `track({ deps: (s) => [...] })`\n// — the compile-time escape hatch the framework provides for cases\n// where the read genuinely can't be expressed inline.\n//\n// Detected leak shapes (mirrors the classifier in\n// `transform.ts:computeAccessorMask`):\n// - `helper(s)` with an Identifier callee that can't be resolved to\n// a local declaration (function parameter, import, destructured\n// binding) — the callee may read any field of `s`.\n//\n// NOT flagged (intentional): `obj.helper(s)` / `lib.fn(s)` —\n// PropertyAccessExpression callees. This is the documented headless-\n// components idiom (`pr.valueText(s)` where `pr` comes from\n// `progress.connect()`), and refactoring it defeats the API surface.\n// The runtime sentinel keeps such bindings correct — the cost is a\n// per-update re-evaluation, which is a property of the composition\n// pattern rather than an author mistake worth blocking.\n// - `new Wrapper(s)` — NewExpression with state as an argument.\n// - `` tag`${s}` `` — TaggedTemplate with state in a span.\n// - `{ ...s }` / `[...s]` — spread of state.\n// - `const x = s` — const aliasing.\n// - `cond ? s : other` — state in a conditional branch (state\n// reaches the binding via a path the walker can't trace).\n// - `s[expr]` — dynamic element access (literal keys are tracked).\n// - State passed as `arg1+` to any call (the existing delegation\n// branch only inspects `arg0`).\n\nimport ts from 'typescript'\nimport { rangeFromOffsets } from '../diagnostic.js'\nimport type { CompilerModule } from '../module.js'\nimport { isReactiveAccessor } from '../collect-deps.js'\nimport { resolveAccessorBody } from '../accessor-resolver.js'\n\n// Mirrors the file-local list in collect-deps.ts. Calls to these\n// framework primitives are visited as accessor positions in their\n// own right, so we don't double-classify.\nconst NON_DELEGATION_HELPERS = new Set(['sample', 'item', 'memo', 'text', 'unsafeHtml'])\n\ninterface LeakSite {\n node: ts.Node\n shape: string\n hint: string\n}\n\nfunction findFirstLeakInAccessor(\n accessor: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration,\n checker: ts.TypeChecker | undefined,\n): LeakSite | null {\n if (accessor.parameters.length !== 1) return null\n const param = accessor.parameters[0]!\n if (!ts.isIdentifier(param.name)) return null\n if (!accessor.body) return null\n const stateParam = param.name.text\n\n let leak: LeakSite | null = null\n const visit = (node: ts.Node): void => {\n if (leak) return\n if (ts.isIdentifier(node) && node.text === stateParam) {\n const parent = node.parent\n if (!parent || ts.isParameter(parent)) {\n ts.forEachChild(node, visit)\n return\n }\n // Tracked containers — the same set the mask classifier honors.\n let tracked = false\n let shape = ''\n let hint = ''\n if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {\n tracked = true\n } else if (ts.isElementAccessExpression(parent) && parent.expression === node) {\n if (\n ts.isStringLiteralLike(parent.argumentExpression) ||\n ts.isNumericLiteral(parent.argumentExpression)\n ) {\n tracked = true\n } else {\n shape = `dynamic element access \\`s[<expr>]\\``\n hint =\n 'replace the dynamic key with a literal property (e.g. `s.foo`), or declare the read via `track({ deps: (s) => [s[key]] })`.'\n }\n } else if (ts.isCallExpression(parent)) {\n const argIndex = parent.arguments.indexOf(node as ts.Expression)\n if (argIndex > 0) {\n // State passed as arg1+ to a call. The header documents this\n // as NOT flagged (intentional): the existing delegation\n // branch only attempts to trace arg0, and the mask classifier\n // emits a whole-state sentinel into `__prefixes` so the\n // binding stays correct. The cost is per-update re-evaluation\n // — a property of the composition pattern, not an author\n // mistake worth blocking. Without this branch we'd fall\n // through to the default \"outside a tracked container\" leak.\n tracked = true\n } else if (argIndex === 0) {\n if (\n ts.isIdentifier(parent.expression) &&\n !NON_DELEGATION_HELPERS.has(parent.expression.text)\n ) {\n // Identifier-callee delegation. Recurse into the callee's\n // body via the same resolver the mask walker uses. If it\n // resolves to a local accessor, the helper's reads are\n // walked transitively and the call is tracked. If the\n // callee is a function parameter, import, destructured\n // binding, or otherwise unresolvable, this IS the leak\n // shape — flag it here so the diagnostic points at the\n // call site rather than at some deeper unresolvable read.\n const resolved = resolveAccessorBody(parent.expression, checker)\n if (resolved) {\n tracked = true\n } else {\n const calleeSymbol = checker?.getSymbolAtLocation(parent.expression)\n const isFunctionParam = !!calleeSymbol?.declarations?.some((d: ts.Declaration) =>\n ts.isParameter(d),\n )\n shape = `call to an unresolvable callee \\`${parent.expression.text}(s)\\` (function parameter, import, or destructured binding)`\n if (isFunctionParam) {\n hint =\n 'this callee is a function parameter — the closure passed at the call site is opaque to per-binding analysis. The framework expects per-row dynamic state to flow through `each` items (slot data on `item.*`) rather than through `(s) => ...` callback parameters; restructure the helper so its bindings read `item.*` and the call site builds the slot data once in `items: (s) => …`.'\n } else {\n hint =\n 'inline the read against `s` directly, refactor the callee into a same-module `const`/`function` declaration, or declare the dependencies via `track({ deps: (s) => [...] })`.'\n }\n }\n } else if (!ts.isIdentifier(parent.expression)) {\n // Method-call / computed callee with state arg —\n // `obj.helper(s)`, `lib.fn(s)`. This is the documented\n // headless-components idiom (`pr.valueText(s)` where `pr`\n // comes from `progress.connect()`); refactoring it would\n // defeat the API surface. The runtime sentinel keeps the\n // binding correct — just at the cost of re-evaluating on\n // every update. Treat as tracked from the lint's POV so\n // legitimate composition doesn't error the build; the\n // perf cost is a property of the composition pattern, not\n // an author mistake worth blocking.\n tracked = true\n }\n }\n } else if (ts.isNewExpression(parent)) {\n shape = 'state passed as a constructor argument (`new X(s)`)'\n hint =\n 'compute the derived value inline against direct state reads, or use `track({ deps: (s) => [...] })` to declare the reads.'\n } else if (ts.isSpreadElement(parent) || ts.isSpreadAssignment(parent)) {\n shape = 'state spread (`{...s}` / `[...s]`)'\n hint =\n 'spread only the fields you actually need (`{...s.user}`), or use `track({ deps: (s) => [...] })`.'\n } else if (ts.isVariableDeclaration(parent)) {\n shape = 'const alias (`const x = s; … x.foo`)'\n hint =\n 'inline the alias to `s.foo`, or split the deeper read into a separate single-assignment alias `const foo = s.foo`.'\n } else if (ts.isConditionalExpression(parent)) {\n shape = 'state in a conditional branch (`cond ? s : other`)'\n hint = 'move the conditional inside the property access: `cond ? s.foo : other.foo`.'\n } else if (ts.isAsExpression(parent) || ts.isTypeAssertionExpression(parent)) {\n shape = 'type assertion wrapping state (`(s as T).foo`)'\n hint = 'drop the assertion — the chain `s.foo` already carries the type.'\n } else if (ts.isParenthesizedExpression(parent)) {\n // Walk up through parens transparently. Don't flag here; the\n // outer parent classifies.\n ts.forEachChild(node, visit)\n return\n } else {\n shape = `state used outside a tracked container (${describe(parent)})`\n hint =\n 'restructure the expression so `s` appears only as the root of a property/element-access chain, or declare the read via `track({ deps: (s) => [...] })`.'\n }\n if (!tracked) {\n leak = { node, shape, hint }\n return\n }\n }\n ts.forEachChild(node, visit)\n }\n visit(accessor.body)\n return leak\n}\n\nfunction describe(node: ts.Node): string {\n if (ts.isIdentifier(node)) return node.text\n if (ts.isPropertyAccessExpression(node)) return `${describe(node.expression)}.${node.name.text}`\n return ts.SyntaxKind[node.kind]\n}\n\nexport function opaqueStateFlowModule(): CompilerModule {\n return {\n name: 'opaque-state-flow',\n compilerVersion: '^0.3.0',\n diagnostics: [\n {\n id: 'llui/opaque-state-flow',\n description:\n \"Reactive accessor flows state into an opaque expression the walker can't trace. The runtime stays correct via a FULL_MASK binding + whole-state sentinel in `__prefixes`, but the binding then re-evaluates on every state change.\",\n },\n ],\n visitors: {\n [ts.SyntaxKind.SourceFile]: (ctx, node) => {\n // When the host adapter has built a Program, walk the checker's\n // own SourceFile so symbol resolution (Alias → Symbol via\n // `getSymbolAtLocation`) actually works. The reparsed file used\n // in the AST-only fallback is not part of any Program, so the\n // checker can't resolve identifiers in it. Fall back to a\n // reparse for paths without a Program (test harness, lint\n // adapters without cross-file resolution).\n const visited = node as ts.SourceFile\n const fromProgram = ctx.program?.getSourceFile(visited.fileName)\n const checker = fromProgram ? ctx.checker : undefined\n const sf =\n fromProgram ??\n ts.createSourceFile(visited.fileName, visited.text, ts.ScriptTarget.Latest, true)\n\n const walk = (n: ts.Node): void => {\n if ((ts.isArrowFunction(n) || ts.isFunctionExpression(n)) && isReactiveAccessor(n)) {\n const leak = findFirstLeakInAccessor(n, checker)\n if (leak) {\n ctx.reportDiagnostic({\n id: 'llui/opaque-state-flow',\n severity: 'error',\n category: 'perf',\n message:\n `Reactive accessor flows state opaquely — ${leak.shape}. ` +\n `The compiler ships a correct binding (FULL_MASK + whole-state sentinel), ` +\n `but it re-evaluates on every state change. ${leak.hint}`,\n location: {\n file: sf.fileName,\n range: rangeFromOffsets(sf.text, leak.node.getStart(sf), leak.node.getEnd()),\n },\n })\n }\n }\n ts.forEachChild(n, walk)\n }\n walk(sf)\n },\n },\n }\n}\n"]}
|
package/dist/transform.d.ts
CHANGED
|
@@ -56,7 +56,7 @@ export interface PreExtractedSchemas {
|
|
|
56
56
|
stateSchema?: ReturnType<typeof extractStateSchema>;
|
|
57
57
|
effectSchema?: ReturnType<typeof extractEffectSchema>;
|
|
58
58
|
}
|
|
59
|
-
export declare function transformLlui(source: string, _filename: string, devMode?: boolean, emitAgentMetadata?: boolean, mcpPort?: number | null, verbose?: boolean, typeSources?: ExternalTypeSources, preExtracted?: PreExtractedSchemas, crossFilePaths?: ReadonlySet<string
|
|
59
|
+
export declare function transformLlui(source: string, _filename: string, devMode?: boolean, emitAgentMetadata?: boolean, mcpPort?: number | null, verbose?: boolean, typeSources?: ExternalTypeSources, preExtracted?: PreExtractedSchemas, crossFilePaths?: ReadonlySet<string>, crossFileOpaque?: boolean, crossFileProgram?: ts.Program): {
|
|
60
60
|
output: string;
|
|
61
61
|
edits: TransformEdit[];
|
|
62
62
|
diagnostics: Diagnostic[];
|
package/dist/transform.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transform.d.ts","sourceRoot":"","sources":["../src/transform.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,YAAY,CAAA;AAG3B,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AACvE,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAA;AAC5D,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAOtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAiCjD,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,EAAE,CAAC,UAAU,CAQhF;AAwED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,WAAW,EAAE,MAAM,CAAA;CACpB;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,mBAAmB;IAClC,KAAK,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;IAC5C,GAAG,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;IAC1C,MAAM,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAC9C;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,mBAAmB;IAClC,SAAS,CAAC,EAAE,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAA;IAC/C,cAAc,CAAC,EAAE,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAA;IACzD,WAAW,CAAC,EAAE,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAA;IACnD,YAAY,CAAC,EAAE,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAA;CACtD;AAED,wBAAgB,aAAa,CAC3B,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,OAAO,UAAQ,EACf,iBAAiB,UAAQ,EACzB,OAAO,GAAE,MAAM,GAAG,IAAW,EAC7B,OAAO,UAAQ,EACf,WAAW,CAAC,EAAE,mBAAmB,EACjC,YAAY,CAAC,EAAE,mBAAmB,EAClC,cAAc,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"transform.d.ts","sourceRoot":"","sources":["../src/transform.ts"],"names":[],"mappings":"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"}
|
package/dist/transform.js
CHANGED
|
@@ -104,7 +104,7 @@ const ELEMENT_HELPERS = new Set([
|
|
|
104
104
|
'ul',
|
|
105
105
|
'video',
|
|
106
106
|
]);
|
|
107
|
-
export function transformLlui(source, _filename, devMode = false, emitAgentMetadata = false, mcpPort = 5200, verbose = false, typeSources, preExtracted, crossFilePaths) {
|
|
107
|
+
export function transformLlui(source, _filename, devMode = false, emitAgentMetadata = false, mcpPort = 5200, verbose = false, typeSources, preExtracted, crossFilePaths, crossFileOpaque = false, crossFileProgram) {
|
|
108
108
|
// Use the caller-provided filename so any module reading `sf.fileName`
|
|
109
109
|
// (e.g. `componentMetaModule` emitting `__componentMeta: { file }`)
|
|
110
110
|
// sees the real path instead of a placeholder. The monolith's inline
|
|
@@ -164,9 +164,13 @@ export function transformLlui(source, _filename, devMode = false, emitAgentMetad
|
|
|
164
164
|
// function is generated per-component, so bit assignments in other files
|
|
165
165
|
// won't match. Files without component() get FULL_MASK on all bindings.
|
|
166
166
|
const fileHasComponent = hasComponentDef(sourceFile, lluiImport);
|
|
167
|
-
const { lo: fieldBits, hi: fieldBitsHi } = fileHasComponent
|
|
167
|
+
const { lo: fieldBits, hi: fieldBitsHi, opaque: fileLocalOpaque, } = fileHasComponent
|
|
168
168
|
? collectDeps(source, crossFilePaths)
|
|
169
|
-
: { lo: new Map(), hi: new Map() };
|
|
169
|
+
: { lo: new Map(), hi: new Map(), opaque: false };
|
|
170
|
+
// Union the file-local opaque flag with the cross-file flag from the
|
|
171
|
+
// vite-plugin's walker — either can independently mandate a
|
|
172
|
+
// whole-state sentinel in `__prefixes`.
|
|
173
|
+
const hasOpaqueAccessor = fileLocalOpaque || crossFileOpaque;
|
|
170
174
|
if (verbose && fileHasComponent) {
|
|
171
175
|
const pairs = [...fieldBits.entries()]
|
|
172
176
|
.map(([path, bit]) => `${path}=${bit === -1 ? 'FULL' : bit}`)
|
|
@@ -357,6 +361,7 @@ export function transformLlui(source, _filename, devMode = false, emitAgentMetad
|
|
|
357
361
|
fieldBits,
|
|
358
362
|
fieldBitsHi,
|
|
359
363
|
lluiImport,
|
|
364
|
+
hasOpaqueAccessor,
|
|
360
365
|
}));
|
|
361
366
|
// structuralMaskModule injects `__mask` into each()/branch()/scope()/show()
|
|
362
367
|
// options. Activated when the file has any low-word reactive paths
|
|
@@ -386,7 +391,15 @@ export function transformLlui(source, _filename, devMode = false, emitAgentMetad
|
|
|
386
391
|
// `typeSources` flows through to lint modules that need cross-file
|
|
387
392
|
// visibility (e.g. agent-emits-drift's imported-Msg case). Same
|
|
388
393
|
// shape as `ModuleExternalTypes`.
|
|
389
|
-
|
|
394
|
+
// Cross-file Program (when the host adapter supplied one) flows through
|
|
395
|
+
// to lint modules that need to resolve identifiers across files — e.g.
|
|
396
|
+
// opaque-state-flow walking through an imported helper to decide
|
|
397
|
+
// whether `helper(s)` is a tracked delegation or an opaque leak. The
|
|
398
|
+
// checker derived here is the one bound to that Program; identifiers
|
|
399
|
+
// in `crossFileProgram.getSourceFile(_filename)` resolve through it,
|
|
400
|
+
// identifiers in the locally-reparsed `sourceFile` do not.
|
|
401
|
+
const crossFileChecker = crossFileProgram?.getTypeChecker();
|
|
402
|
+
const registryResult = registry.run(sourceFile, crossFileChecker, typeSources, crossFileProgram);
|
|
390
403
|
// The registry phases (preTransform v2c/decomp-7, transformCall
|
|
391
404
|
// v2c/decomp-11/12) may have mutated the source file — replace our
|
|
392
405
|
// local reference so all subsequent code (fieldBits, visitor,
|
|
@@ -1356,6 +1369,15 @@ export function computeAccessorMask(accessor, fieldBits, visited = new Set(), fi
|
|
|
1356
1369
|
let mask = 0;
|
|
1357
1370
|
let maskHi = 0;
|
|
1358
1371
|
let readsState = false;
|
|
1372
|
+
// The state value flows into an expression we can't statically trace
|
|
1373
|
+
// (function-arg / imported / destructured / method callee, or a
|
|
1374
|
+
// dynamic `s[expr]` lookup). The callee may read any field; the
|
|
1375
|
+
// dynamic key may index any field. Any non-empty precise mask we'd
|
|
1376
|
+
// compute from the visible direct reads alone is "clean but wrong":
|
|
1377
|
+
// a reducer that narrowly touches only the opaquely-read field
|
|
1378
|
+
// produces a dirty bit that `mask & dirty` zeroes, silently skipping
|
|
1379
|
+
// the binding. Conservative correctness: bail to FULL_MASK.
|
|
1380
|
+
let opaqueStateFlow = false;
|
|
1359
1381
|
// `inNestedFn` gates only the delegation-recursion. Property-access
|
|
1360
1382
|
// path extraction happens everywhere — inner-arrow callbacks like
|
|
1361
1383
|
// `s.items.filter((i) => i.includes(s.filter))` close over our
|
|
@@ -1369,8 +1391,48 @@ export function computeAccessorMask(accessor, fieldBits, visited = new Set(), fi
|
|
|
1369
1391
|
// like `text((_s) => \`$${item.x.toLocaleString()}\`)` was how
|
|
1370
1392
|
// this bug first surfaced in the persistent-layout example work.
|
|
1371
1393
|
const parent = node.parent;
|
|
1372
|
-
|
|
1373
|
-
|
|
1394
|
+
// Every appearance of the state identifier `s` is one of:
|
|
1395
|
+
// - the parameter binding itself — ignore
|
|
1396
|
+
// - the root of `s.x.y…` (PropertyAccessExpression) — tracked by the PAE walker
|
|
1397
|
+
// - the root of `s['literal']`/`s[0]` — tracked by element-access (literal key only)
|
|
1398
|
+
// - arg0 of `helper(s)` with an Identifier callee — handled by the delegation branch below
|
|
1399
|
+
// - anything else (spread, return, ternary branch, template span,
|
|
1400
|
+
// NewExpression arg, TaggedTemplate value, const alias, arg1+ of any call,
|
|
1401
|
+
// method-call arg `obj.f(s)`, dynamic key `s[expr]`, type assertion,
|
|
1402
|
+
// parenthesized, …) — opaque: state has leaked
|
|
1403
|
+
//
|
|
1404
|
+
// The leak cases can't be reasoned about statically — the receiver
|
|
1405
|
+
// may read any field of `s`. A "precise" mask built from sibling
|
|
1406
|
+
// direct reads alone hides every field reachable only through the
|
|
1407
|
+
// leak, and (mask & dirty) silently skips updates. Conservative
|
|
1408
|
+
// correctness: any opaque flow forces FULL_MASK.
|
|
1409
|
+
if (ts.isIdentifier(node) && node.text === stateParam) {
|
|
1410
|
+
const isBinding = !!parent && ts.isParameter(parent);
|
|
1411
|
+
if (!isBinding) {
|
|
1412
|
+
readsState = true;
|
|
1413
|
+
let isTracked = false;
|
|
1414
|
+
if (parent) {
|
|
1415
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
|
|
1416
|
+
isTracked = true;
|
|
1417
|
+
}
|
|
1418
|
+
else if (ts.isElementAccessExpression(parent) && parent.expression === node) {
|
|
1419
|
+
isTracked =
|
|
1420
|
+
ts.isStringLiteralLike(parent.argumentExpression) ||
|
|
1421
|
+
ts.isNumericLiteral(parent.argumentExpression);
|
|
1422
|
+
}
|
|
1423
|
+
else if (ts.isCallExpression(parent) &&
|
|
1424
|
+
ts.isIdentifier(parent.expression) &&
|
|
1425
|
+
parent.arguments[0] === node &&
|
|
1426
|
+
!NON_DELEGATION_HELPERS.has(parent.expression.text)) {
|
|
1427
|
+
// The delegation branch below either recurses into the
|
|
1428
|
+
// resolved body or sets opaqueStateFlow explicitly when
|
|
1429
|
+
// unresolvable — don't pre-empt that decision here.
|
|
1430
|
+
isTracked = true;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (!isTracked)
|
|
1434
|
+
opaqueStateFlow = true;
|
|
1435
|
+
}
|
|
1374
1436
|
}
|
|
1375
1437
|
if (ts.isPropertyAccessExpression(node)) {
|
|
1376
1438
|
// When there's no parent we can't tell if this is the top of a
|
|
@@ -1437,6 +1499,14 @@ export function computeAccessorMask(accessor, fieldBits, visited = new Set(), fi
|
|
|
1437
1499
|
if (inner.readsState)
|
|
1438
1500
|
readsState = true;
|
|
1439
1501
|
}
|
|
1502
|
+
else {
|
|
1503
|
+
// Callee is a function parameter, imported binding, or
|
|
1504
|
+
// destructured local — `resolveAccessorBody` couldn't pin
|
|
1505
|
+
// it to a local declaration. The body could read any
|
|
1506
|
+
// field of `s`, so a precise mask from sibling direct
|
|
1507
|
+
// reads alone is unsafe.
|
|
1508
|
+
opaqueStateFlow = true;
|
|
1509
|
+
}
|
|
1440
1510
|
}
|
|
1441
1511
|
}
|
|
1442
1512
|
}
|
|
@@ -1444,6 +1514,14 @@ export function computeAccessorMask(accessor, fieldBits, visited = new Set(), fi
|
|
|
1444
1514
|
ts.forEachChild(node, (child) => walk(child, inNestedFn || enteringNested));
|
|
1445
1515
|
}
|
|
1446
1516
|
walk(accessor.body, false);
|
|
1517
|
+
if (opaqueStateFlow) {
|
|
1518
|
+
// Both words FULL_MASK: the whole-state sentinel emitted by
|
|
1519
|
+
// `core-synthesis.ts:buildPrefixesProp` may land in either the
|
|
1520
|
+
// low or high word depending on the file's prefix count. The
|
|
1521
|
+
// runtime gate `(mask & dirty) | (maskHi & dirtyHi)` only catches
|
|
1522
|
+
// the sentinel bit when the binding's mask covers BOTH words.
|
|
1523
|
+
return { mask: 0xffffffff | 0, maskHi: 0xffffffff | 0, readsState: true };
|
|
1524
|
+
}
|
|
1447
1525
|
if (mask === 0 && maskHi === 0 && readsState) {
|
|
1448
1526
|
return { mask: 0xffffffff | 0, maskHi: 0, readsState: true };
|
|
1449
1527
|
}
|