@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.
@@ -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":"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","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"]}
@@ -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[];
@@ -1 +1 @@
1
- {"version":3,"file":"transform.d.ts","sourceRoot":"","sources":["../src/transform.ts"],"names":[],"mappings":"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"}
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
- const registryResult = registry.run(sourceFile, undefined, typeSources);
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
- if (ts.isIdentifier(node) && node.text === stateParam && (!parent || !ts.isParameter(parent))) {
1373
- readsState = true;
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
  }