@volter/editor-react 0.5.210 → 0.5.212

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,26 +1,14 @@
1
- /**
2
- * The project-contract RESOLVER, host-neutral: how one module's relative
3
- * imports are followed to the component contracts and prop-shape type aliases
4
- * they bring into scope. Pure TypeScript AST work over a {@link ContractHost}
5
- * that supplies the three things a file system used to — read a file, test
6
- * for one, join a relative specifier — so the walk itself is independent of
7
- * WHERE the sources are read from. The dev server supplies them with
8
- * `node:fs` and a stamp-checked cache (`r3f-project-contracts.ts`); any other
9
- * reader supplies its own, and gets the same contracts rather than a second
10
- * answer ("HeroBox does not expose a scale prop" while the dev server wrote
11
- * it — one editor, two answers — is what this split exists to prevent).
12
- */
1
+ /** Resolve relative project imports to visible and exported R3F contracts and
2
+ * prop-shape aliases. The server supplies file reads and a stamp-checked cache. */
13
3
 
14
4
  import ts from 'typescript';
15
5
  import {
16
6
  analyzeR3fComponentContracts,
17
7
  builtinR3fContractsOfImport,
18
- type ComponentPropSpec,
19
8
  exportedR3fTypeAliases,
20
9
  type R3fComponentContract,
21
10
  type R3fTypeAlias,
22
11
  } from './oid-transform';
23
- import { localComponentPropSpecs } from './syntactic-prop-specs';
24
12
  import { hasModifier, parseAuthoringTsx } from './ts-ast';
25
13
 
26
14
  export interface ModuleAnalysis {
@@ -28,14 +16,9 @@ export interface ModuleAnalysis {
28
16
  exported: Map<string, R3fComponentContract>;
29
17
  /** This module's own `export type X = …` declarations, for its importers. */
30
18
  exportedTypeAliases: Map<string, R3fTypeAlias>;
31
- /** Syntactic declared-prop specs per exported component
32
- * (`syntactic-prop-specs.ts`) — the checker-free surface attached to OID
33
- * entries so override detection and Apply-to-Component gate on facts
34
- * instead of an absent `ts.Program`. */
35
- exportedProps: Map<string, ComponentPropSpec[]>;
36
19
  }
37
20
 
38
- export const SOURCE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js'] as const;
21
+ const SOURCE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js'] as const;
39
22
 
40
23
  /** What a resolver needs from its host. Paths are whatever vocabulary the host
41
24
  * speaks (absolute on the server, project-relative in storage); the resolver
@@ -49,10 +32,8 @@ export interface ContractHost {
49
32
  extname(file: string): string;
50
33
  /** `<base>/<name>` — the `index.*` candidate under a directory specifier. */
51
34
  join(base: string, name: string): string;
52
- /** Optional shared cache around one file's analysis (the server's
53
- * stamp-checked map). Called with a thunk so the host decides whether to
54
- * run it; a host without one analyzes every time within one resolver. */
55
- analyzeFileCached?(file: string, analyze: () => ModuleAnalysis): ModuleAnalysis;
35
+ /** Shared stamp-checked cache for one file's analysis. */
36
+ analyzeFileCached(file: string, analyze: () => ModuleAnalysis): ModuleAnalysis;
56
37
  }
57
38
 
58
39
  function resolveRelativeModule(
@@ -93,52 +74,6 @@ export class ProjectContractResolver {
93
74
 
94
75
  constructor(private readonly host: ContractHost) {}
95
76
 
96
- importedForSource(code: string, file: string): Map<string, R3fComponentContract> {
97
- return this.importedBindings(parseAuthoringTsx(file, code), file);
98
- }
99
-
100
- typeAliasesForSource(code: string, file: string): Map<string, R3fTypeAlias> {
101
- return this.importedTypeAliases(parseAuthoringTsx(file, code), file);
102
- }
103
-
104
- /**
105
- * Declared-prop specs for every component a file's JSX can name — its own
106
- * top-level components plus its relative imports (a local declaration
107
- * shadows an import, the language's own rule). This is what the hosted
108
- * stamping hook attaches to OID entries in place of the dev server's
109
- * checker-resolved specs.
110
- */
111
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one bounded import-declaration pass, the same dispatch importedBindings carries.
112
- propSpecsForSource(code: string, file: string): Map<string, ComponentPropSpec[]> {
113
- const sf = parseAuthoringTsx(file, code);
114
- const specs = new Map<string, ComponentPropSpec[]>();
115
- for (const statement of sf.statements) {
116
- if (
117
- !ts.isImportDeclaration(statement) ||
118
- !ts.isStringLiteral(statement.moduleSpecifier) ||
119
- !statement.importClause
120
- ) {
121
- continue;
122
- }
123
- const target = resolveRelativeModule(this.host, file, statement.moduleSpecifier.text);
124
- if (!target) continue;
125
- const exported = this.analyzeFile(target).exportedProps;
126
- const defaultName = statement.importClause.name?.text;
127
- const defaultSpecs = exported.get('default');
128
- if (defaultName && defaultSpecs) specs.set(defaultName, defaultSpecs);
129
- const bindings = statement.importClause.namedBindings;
130
- if (!bindings || !ts.isNamedImports(bindings)) continue;
131
- for (const element of bindings.elements) {
132
- const imported = exported.get(element.propertyName?.text ?? element.name.text);
133
- if (imported) specs.set(element.name.text, imported);
134
- }
135
- }
136
- for (const [name, local] of localComponentPropSpecs(sf, this.importedTypeAliases(sf, file))) {
137
- specs.set(name, local);
138
- }
139
- return specs;
140
- }
141
-
142
77
  analyzeSource(code: string, file: string): ModuleAnalysis {
143
78
  const sf = parseAuthoringTsx(file, code);
144
79
  const imported = this.importedBindings(sf, file);
@@ -151,17 +86,10 @@ export class ProjectContractResolver {
151
86
  imported,
152
87
  this.importedTypeAliases(sf, file),
153
88
  );
154
- const props = localComponentPropSpecs(sf, this.importedTypeAliases(sf, file));
155
89
  return {
156
90
  visible,
157
91
  exported: this.exportsOf(sf, file, visible, (target) => this.analyzeFile(target).exported),
158
92
  exportedTypeAliases: exportedR3fTypeAliases(sf),
159
- exportedProps: this.exportsOf(
160
- sf,
161
- file,
162
- props,
163
- (target) => this.analyzeFile(target).exportedProps,
164
- ),
165
93
  };
166
94
  }
167
95
 
@@ -173,7 +101,6 @@ export class ProjectContractResolver {
173
101
  visible: new Map(),
174
102
  exported: new Map(),
175
103
  exportedTypeAliases: new Map(),
176
- exportedProps: new Map(),
177
104
  };
178
105
  }
179
106
  this.visiting.add(file);
@@ -185,13 +112,10 @@ export class ProjectContractResolver {
185
112
  visible: new Map(),
186
113
  exported: new Map(),
187
114
  exportedTypeAliases: new Map(),
188
- exportedProps: new Map(),
189
115
  }
190
116
  : this.analyzeSource(source, file);
191
117
  };
192
- const analysis = this.host.analyzeFileCached
193
- ? this.host.analyzeFileCached(file, analyze)
194
- : analyze();
118
+ const analysis = this.host.analyzeFileCached(file, analyze);
195
119
  this.cache.set(file, analysis);
196
120
  return analysis;
197
121
  } finally {
@@ -199,21 +123,8 @@ export class ProjectContractResolver {
199
123
  }
200
124
  }
201
125
 
202
- /**
203
- * The prop-shape type aliases one module imports, under their local names.
204
- *
205
- * The component-contract walk above already follows relative imports; this
206
- * is the same walk for the OTHER thing a shared module exports. Without it,
207
- * `typeAllowsStandardRootProps` stopped at the file boundary, so a project
208
- * that declares its canonical prop shape once — the thing the authoring
209
- * convention is asking for — read as forwarding nothing, and every prefab
210
- * annotated with it drew a permanent R3F002 telling it to do what it was
211
- * already doing.
212
- *
213
- * Only relative specifiers resolve. An installed package's `.d.ts` is not
214
- * source this walk can trust, and the built-in prop-type table is how a
215
- * package surface earns recognition instead.
216
- */
126
+ /** Resolve imported prop-shape aliases under their local names. Only
127
+ * relative project sources are followed; package surfaces use builtin types. */
217
128
  private importedTypeAliases(sf: ts.SourceFile, file: string): Map<string, R3fTypeAlias> {
218
129
  const aliases = new Map<string, R3fTypeAlias>();
219
130
  for (const statement of sf.statements) {
@@ -301,12 +212,7 @@ export class ProjectContractResolver {
301
212
  statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
302
213
  ? resolveRelativeModule(this.host, file, statement.moduleSpecifier.text)
303
214
  : null;
304
- // A BARREL (`export * from './Ramp'`) re-exports every name, and dropping
305
- // it dropped the whole contract: racing-game reaches every one of its
306
- // components through `src/models/index.ts`, so `<Ramp/>`, `<Train/>` and
307
- // `<Vehicle/>` all read as "definition not in this file" and refused
308
- // every transform write at their callsites. `export *` skips `default` by
309
- // the language's own rule.
215
+ // A barrel's `export *` carries every named contract except `default`.
310
216
  if (!statement.exportClause) {
311
217
  if (!target) continue;
312
218
  for (const [name, contract] of moduleExports(target)) {
@@ -1,144 +1,20 @@
1
- /**
2
- * WHERE A PHYSICS-OWNED NODE IS ACTUALLY PLACED — the source fact that says an
3
- * element's transform is written by a body rather than by the element's own
4
- * JSX, and which artifact owns that body's SPAWN.
5
- *
6
- * ## The measured defect
7
- *
8
- * `@react-three/cannon` drives a node by writing its MATRIX:
9
- * `object.matrixAutoUpdate = false; object.matrix.copy(compose(p, q, ONE))`
10
- * (`node_modules/@react-three/cannon/dist/index.js`'s `apply`). Two consequences,
11
- * both of which the editor got wrong before this module existed:
12
- *
13
- * - The node's own `position`/`rotation`/`scale` JSX props are DEAD. Writing
14
- * one produces a file that says the object is somewhere it will never be:
15
- * the body re-spawns from the hook's argument and overwrites the matrix on
16
- * the next frame. Measured on the racing-game ingest — a moved chassis wrote
17
- * its leaf element and the remount re-spawned at the binding's
18
- * `[-110, 0.75, 220]`, digit for digit.
19
- * - `scale` goes with them: `apply` composes with a fixed `(1,1,1)`, so a
20
- * cannon body erases its node's scale as surely as its position.
21
- *
22
- * The transform is therefore owned by the BINDING — the `useBox(…)` /
23
- * `useCompoundBody(…)` call that attached the body to this element's ref — and
24
- * an honest editor writes where the binding reads, or refuses by name.
25
- *
26
- * ## Where the binding reads, measured rather than assumed
27
- *
28
- * Over the whole vendored cannon corpus (`vendor/games/racing-game`: `Chassis`,
29
- * `Wheel`, `Ramp`, `Train`, `Goal`, `Heightmap`, `BoundingBox`) **not one hook
30
- * argument carries an inline position literal.** Every binding takes its spawn
31
- * from the component's OWN PROPS — `useBox(() => ({ …props }))`, or the
32
- * shorthand `useBox(() => ({ args, position, rotation }))` over a destructured
33
- * prop — so the literal that places the body is the one at the component's
34
- * CALLSITE (`<Ramp position={[2, -1, 168.55]} />`, `App.tsx:57`). That is the
35
- * prop literal the write routes to, and the ordinary JSX prop writer already
36
- * reaches it; what was missing was a contract that admits the forwarding is
37
- * real, which is `forwarded` below.
38
- *
39
- * `literal` is the other shape — a spawn spelled at the hook itself. It is
40
- * recorded so a refusal can NAME it (`position is owned by useBox at
41
- * Ramp.tsx:8`) rather than pretending the element's own prop is writable. No
42
- * writer for it ships: a hook argument is not a JSX element, it has no oid, and
43
- * building an address for a shape with zero occurrences in the corpus would be
44
- * a writer with no caller.
45
- *
46
- * ## Why this is a SOURCE analysis and not the runtime flag
47
- *
48
- * `matrixAutoUpdate === false` is a true runtime tell for cannon, and a useless
49
- * one for routing: it says a driver owns the node, never WHERE the driver reads
50
- * its spawn. It is also not general — `@react-three/rapier` writes
51
- * `object.position.copy(…)`/`object.quaternion.copy(…)` and leaves
52
- * `matrixAutoUpdate` alone, so the flag misses rapier entirely while the source
53
- * says plainly that `<RigidBody position={…}>` is the spawn (which is why the
54
- * built-in rapier contract in `oid-transform.ts` already routes it correctly).
55
- * The binding is a fact about the file; the write anchor has to be too.
56
- *
57
- * Nothing here is a general "physics abstraction": it recognizes ONE package's
58
- * body hooks by import specifier, the same hand-audited way
59
- * `BUILTIN_R3F_CONTRACTS` recognizes `<RigidBody>`, because guessing at an
60
- * unknown library's ownership is how an editor writes props a library ignores.
61
- */
1
+ /** Analyze Cannon body hooks that own a JSX element's transform. Forwarded
2
+ * spawn props contribute to the component contract; channels driven through
3
+ * the body API each frame remain simulation owned. Bind refs within their
4
+ * enclosing scopes rather than guessing from runtime matrix flags. */
62
5
 
63
6
  import ts from 'typescript';
64
7
  import { enclosingScope, refIdentifier } from './ts-ast';
65
8
 
66
- import type { PhysicsChannel, R3fPhysicsBinding } from '@volter/sdk/source-authoring';
67
- export type { PhysicsChannel, R3fPhysicsBinding } from '@volter/sdk/source-authoring';
68
-
69
- /** What an element carrying a binding looks like to a writer. Structural rather
70
- * than the whole `OidEntry`, so this stays the browser-safe half. */
71
- export interface PhysicsBoundElement {
72
- readonly file: string;
73
- readonly physicsBinding?: R3fPhysicsBinding | undefined;
74
- /** The callsite half of the same fact: the component this element
75
- * instantiates hands `channel` to a body binding inside itself, so the
76
- * literal written HERE is the spawn the simulation reads
77
- * (`oid-transform.ts`'s `bodyForwarded`). */
78
- readonly r3fAuthoring?: { readonly bodyForwarded?: readonly string[] | undefined } | undefined;
79
- }
80
-
81
- /**
82
- * Is this element's `channel` PLACED BY A SIMULATED BODY — the fact that makes
83
- * an edit here a `physics-binding` write rather than an ordinary `source-prop`
84
- * one, in `WriteAnchorKind`'s vocabulary (`@volter/project/adapter`'s `authoring.ts`).
85
- *
86
- * Two shapes, and both are the same question ("does a body read this value?")
87
- * asked of the two places a body can be attached, which is why they answer from
88
- * one function rather than one per lane:
89
- *
90
- * - the element carries the binding itself (`<mesh ref={ref}>` whose ref a
91
- * `useBox` bound) — its own props are dead on arrival, so the write is
92
- * refused, and the KIND still says which lane owns the value;
93
- * - the element is a CALLSITE whose component hands the channel to a binding
94
- * inside it (`<Ramp position={…}/>`, `<CitadelTower position={…}/>`) — the
95
- * literal a body actually spawns from, and the WRITABLE case. This is the
96
- * one whose authored value has to survive the re-settle, which is the whole
97
- * reason `physics-binding` is a kind of its own.
98
- *
99
- * Deliberately beside {@link physicsRefusal}: the refusal names who owns the
100
- * placement and this names the lane, and two derivations of "who owns this
101
- * placement" living in different files is exactly how a subject came to be
102
- * classified in one lane while its edit travelled another.
103
- */
104
- export function bodyPlacedChannel(
105
- element: PhysicsBoundElement | undefined,
106
- channel: string,
107
- ): boolean {
108
- if (channel !== 'position' && channel !== 'rotation' && channel !== 'scale') return false;
109
- if (element?.physicsBinding) return true;
110
- return element?.r3fAuthoring?.bodyForwarded?.includes(channel) === true;
111
- }
112
-
113
- /**
114
- * WHY a write to this element's own `channel` would be lost — `null` when no
115
- * body owns it, which is the ordinary case.
116
- *
117
- * ONE derivation, read by both writers (the first-party R3F source lane's
118
- * `transformEditability` and the ingest lane's `oid-source-persistence`), so
119
- * the two can never disagree about who owns a placement. Each sentence names
120
- * the binding and where to go instead; "does not forward position" would send
121
- * an author to add a prop the body would ignore.
122
- */
123
- export function physicsRefusal(
124
- element: PhysicsBoundElement | undefined,
125
- channel: string,
126
- ): string | null {
127
- const binding = element?.physicsBinding;
128
- if (!binding) return null;
129
- if (channel !== 'position' && channel !== 'rotation' && channel !== 'scale') return null;
130
- const at = `${element!.file.split('/').pop() ?? element!.file}:${binding.line}`;
131
- const owned = `${channel} is owned by ${binding.hook} at ${at}`;
132
- if ((binding.forwarded as readonly string[]).includes(channel)) {
133
- return `${owned}, which reads this component's own ${channel} prop — write it where the instance is placed, not on this element`;
134
- }
135
- if ((binding.literal as readonly string[]).includes(channel)) {
136
- return `${owned} — the body spawns from that hook's own argument, and a hook argument has no write anchor`;
137
- }
138
- // Including `scale`, always: the body re-composes the node's matrix with a
139
- // fixed unit scale (`@react-three/cannon`'s `apply`), so a scale written here
140
- // is erased on the next frame exactly like a position.
141
- return `${owned} — this node is placed by the simulation, so a ${channel} written here is overwritten on the next frame`;
9
+ type PhysicsChannel = 'position' | 'rotation' | 'scale';
10
+ /** Transform channels owned by an element's Cannon body hook. */
11
+ export interface R3fPhysicsBinding {
12
+ /** Channels whose spawn the hook takes from the component's own props — the
13
+ * write belongs at this component's CALLSITE. */
14
+ forwarded: PhysicsChannel[];
15
+ /** Transform channels written through the body API inside useFrame.
16
+ * Omit the field when no channel is driven. */
17
+ apiDriven?: PhysicsChannel[];
142
18
  }
143
19
 
144
20
  /**
@@ -164,10 +40,9 @@ const CANNON_BODY_HOOKS = new Set([
164
40
 
165
41
  const PHYSICS_PACKAGES = new Set(['@react-three/cannon']);
166
42
 
167
- /** A cannon body hook's LOCAL name (an `import { useBox as box }` keeps working)
168
- * → the imported hook name. */
169
- function bodyHookNames(sf: ts.SourceFile): Map<string, string> {
170
- const names = new Map<string, string>();
43
+ /** Local names imported as Cannon body hooks, including aliases. */
44
+ function bodyHookNames(sf: ts.SourceFile): Set<string> {
45
+ const names = new Set<string>();
171
46
  for (const statement of sf.statements) {
172
47
  if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
173
48
  continue;
@@ -177,7 +52,7 @@ function bodyHookNames(sf: ts.SourceFile): Map<string, string> {
177
52
  if (!bindings || !ts.isNamedImports(bindings)) continue;
178
53
  for (const element of bindings.elements) {
179
54
  const imported = element.propertyName?.text ?? element.name.text;
180
- if (CANNON_BODY_HOOKS.has(imported)) names.set(element.name.text, imported);
55
+ if (CANNON_BODY_HOOKS.has(imported)) names.add(element.name.text);
181
56
  }
182
57
  }
183
58
  return names;
@@ -238,14 +113,6 @@ function bodyArgumentObject(call: ts.CallExpression): ts.ObjectLiteralExpression
238
113
  return undefined;
239
114
  }
240
115
 
241
- function isVec3Literal(expression: ts.Expression): boolean {
242
- if (!ts.isArrayLiteralExpression(expression) || expression.elements.length !== 3) return false;
243
- return expression.elements.every((element) => {
244
- const inner = ts.isPrefixUnaryExpression(element) ? element.operand : element;
245
- return ts.isNumericLiteral(inner);
246
- });
247
- }
248
-
249
116
  /** Does this expression read one of the enclosing component's own props? */
250
117
  function readsProp(expression: ts.Expression, scope: PropScope, channel: PhysicsChannel): boolean {
251
118
  if (ts.isIdentifier(expression)) return scope.named.has(expression.text);
@@ -257,14 +124,8 @@ function readsProp(expression: ts.Expression, scope: PropScope, channel: Physics
257
124
  );
258
125
  }
259
126
 
260
- /**
261
- * How each channel's spawn is spelled in one hook argument.
262
- *
263
- * LAST WINS, the way an object literal actually evaluates: `{ position: […],
264
- * ...props }` is placed by `props`, and `{ ...props, position: […] }` by the
265
- * literal. Getting that backwards would name the wrong owner in a refusal.
266
- */
267
- type ChannelOwner = 'forwarded' | 'literal' | 'expression';
127
+ /** The last assignment to each channel determines whether it forwards props. */
128
+ type ChannelOwner = 'forwarded' | 'expression';
268
129
 
269
130
  /**
270
131
  * What ONE property of a hook argument says about placement.
@@ -296,29 +157,20 @@ function propertyOwnership(
296
157
  if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name)) return [];
297
158
  const name = property.name.text;
298
159
  if (!isPhysicsChannel(name)) return [];
299
- if (isVec3Literal(property.initializer)) return [[name, 'literal']];
300
160
  return [[name, readsProp(property.initializer, scope, name) ? 'forwarded' : 'expression']];
301
161
  }
302
162
 
303
163
  function channelOwnership(
304
164
  literalObject: ts.ObjectLiteralExpression,
305
165
  scope: PropScope,
306
- ): { forwarded: PhysicsChannel[]; literal: PhysicsChannel[] } {
166
+ ): PhysicsChannel[] {
307
167
  const owner = new Map<PhysicsChannel, ChannelOwner>();
308
168
  for (const property of literalObject.properties) {
309
169
  for (const [channel, kind] of propertyOwnership(property, scope)) owner.set(channel, kind);
310
170
  }
311
- const pick = (kind: ChannelOwner): PhysicsChannel[] =>
312
- [...owner.entries()].filter(([, value]) => value === kind).map(([channel]) => channel);
313
- return { forwarded: pick('forwarded'), literal: pick('literal') };
171
+ return [...owner.entries()].filter(([, value]) => value === 'forwarded').map(([channel]) => channel);
314
172
  }
315
173
 
316
- /**
317
- * The ref identifier a body hook binds — either the ref it was HANDED
318
- * (`useBox(fn, ref)`, the forwardRef spelling) or the one it HANDS BACK
319
- * (`const [ref] = useBox(fn)`, the plain spelling). Both occur in the vendored
320
- * corpus and they are the same binding.
321
- */
322
174
  /** The API identifier a body hook's tuple hands back (`const [, api] = useBox(…)`),
323
175
  * or null when the call does not destructure one. */
324
176
  function boundApiName(call: ts.CallExpression): string | null {
@@ -363,6 +215,7 @@ function apiDrivenChannels(
363
215
  return [...found];
364
216
  }
365
217
 
218
+ /** Resolve a hook's returned ref or its explicitly supplied ref argument. */
366
219
  function boundRefName(call: ts.CallExpression): string | null {
367
220
  const declaration = call.parent;
368
221
  if (ts.isVariableDeclaration(declaration) && ts.isArrayBindingPattern(declaration.name)) {
@@ -377,14 +230,7 @@ function boundRefName(call: ts.CallExpression): string | null {
377
230
  return null;
378
231
  }
379
232
 
380
- /**
381
- * Every JSX element in `sf` whose ref a physics body binds, mapped to the
382
- * binding that owns its transform.
383
- *
384
- * Keyed by the OPENING element so both readers join on the same node: the
385
- * component-contract analysis (which asks about a component's root) and the
386
- * stamping transform (which asks about each element it stamps).
387
- */
233
+ /** Join scoped body-hook refs to JSX elements for component-contract analysis. */
388
234
  export function physicsBindingsByElement(
389
235
  sf: ts.SourceFile,
390
236
  ): Map<ts.JsxOpeningLikeElement, R3fPhysicsBinding> {
@@ -396,22 +242,19 @@ export function physicsBindingsByElement(
396
242
  * bound it: two components may each call their ref `ref`. */
397
243
  const byScope = new Map<ts.Node | null, Map<string, R3fPhysicsBinding>>();
398
244
 
399
- const record = (call: ts.CallExpression, hook: string): void => {
245
+ const record = (call: ts.CallExpression): void => {
400
246
  const refName = boundRefName(call);
401
247
  if (!refName) return;
402
248
  const scope = enclosingScope(call);
403
249
  const argument = bodyArgumentObject(call);
404
- const ownership = argument
250
+ const forwarded = argument
405
251
  ? channelOwnership(argument, propScopeOf(scope?.parameters))
406
- : { forwarded: [], literal: [] };
407
- const { line } = sf.getLineAndCharacterOfPosition(call.getStart(sf));
252
+ : [];
408
253
  const api = boundApiName(call);
409
254
  const driven = api ? apiDrivenChannels(scope, sf, api) : [];
410
255
  const scoped = byScope.get(scope) ?? new Map<string, R3fPhysicsBinding>();
411
256
  scoped.set(refName, {
412
- hook,
413
- line: line + 1,
414
- ...ownership,
257
+ forwarded,
415
258
  ...(driven.length > 0 ? { apiDriven: driven } : {}),
416
259
  });
417
260
  byScope.set(scope, scoped);
@@ -419,8 +262,7 @@ export function physicsBindingsByElement(
419
262
 
420
263
  const collect = (node: ts.Node): void => {
421
264
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
422
- const hook = hooks.get(node.expression.text);
423
- if (hook) record(node, hook);
265
+ if (hooks.has(node.expression.text)) record(node);
424
266
  }
425
267
  ts.forEachChild(node, collect);
426
268
  };
@@ -15,7 +15,6 @@ import {
15
15
  isBuiltinR3fContract,
16
16
  type R3fAuthoringDiagnostic,
17
17
  type R3fComponentContract,
18
- type R3fTypeAlias,
19
18
  runtimeMotionRefs,
20
19
  } from './oid-transform';
21
20
  import {
@@ -25,41 +24,9 @@ import {
25
24
  } from './r3f-contract-resolver';
26
25
  import { parseAuthoringTsx } from './ts-ast';
27
26
 
28
- /** Re-exported from its declaration site. The type moved to `oid-transform.ts`
29
- * (browser-safe, no `node:fs`) when H5 made a diagnostic travel on an
30
- * `OidEntry`; this module still PRODUCES them, and remains the import site
31
- * every existing caller already uses. */
32
- export type { R3fAuthoringDiagnostic };
33
-
34
- /**
35
- * THE ON-DISK HALF OF THE WALK, CACHED ACROSS CALLS — stamped by the file's
36
- * own bytes, so it still "naturally tracks the latest bytes on disk without a
37
- * cache invalidation protocol" (the property the fresh-resolver-per-call rule
38
- * below exists to keep), while no longer re-deriving the same unchanged module
39
- * once per importer.
40
- *
41
- * WHY IT HAD TO EXIST. `uiOidPlugin`'s `transform` builds one resolver per
42
- * MODULE TRANSFORM (`vite-plugin-ui-oid.ts`, `importedR3fContracts(code,
43
- * clean)`), and each resolver walks that module's whole transitive
44
- * RELATIVE-import closure with `ts.createSourceFile`. The per-resolver cache
45
- * therefore only ever helped inside one file's walk and was thrown away
46
- * immediately after, so a project's shared modules were re-parsed once per
47
- * importer — quadratic in the shared-component fan-in, and invisible on a
48
- * small project. Measured on an imported 3D FPS project port (528 source files,
49
- * ~200 project modules served during one editor boot), V8 CPU profile of the
50
- * dev server across the whole ready -> page-bound window: **46.9s of 72.7s
51
- * sampled (64.5%) inside `typescript.js`**, entered from here and from
52
- * `oid-transform.ts`. The server's event loop was blocked ~80-90% of every
53
- * 5s bucket for two minutes, which is why a static `editor-mark.svg` took
54
- * 38s to serve and the browser's own module waterfall could not start.
55
- *
56
- * THE STAMP IS TAKEN TWICE, AROUND THE READ. A single stat cannot tell a
57
- * concurrent save from a quiet file: stat-then-read caches new bytes under an
58
- * old stamp, read-then-stat caches old bytes under a new one, and either way
59
- * the entry is stale FOREVER because its stamp looks current. Only a
60
- * before/after pair that AGREES proves nothing was written across the read;
61
- * a disagreeing pair is simply not cached (the analysis is still returned).
62
- */
27
+ /** Cache disk analysis by file stamp. Matching stats before and after the
28
+ * read establish that the source stayed unchanged; concurrent saves produce
29
+ * uncached analysis. A fresh resolver joins the current import closure. */
63
30
  const analysisByFile = new Map<string, { stamp: string; analysis: ModuleAnalysis }>();
64
31
  /** Bound: a dev session may open many projects, and nothing else evicts. */
65
32
  const MAX_CACHED_ANALYSES = 4096;
@@ -105,37 +72,11 @@ function newResolver(): ProjectContractResolver {
105
72
  return new ProjectContractResolver(nodeContractHost);
106
73
  }
107
74
 
108
- /** Resolve the contracts of component identifiers imported into one source
109
- * module. A fresh resolver per call makes save/HMR behavior naturally track
110
- * the latest bytes on disk without a cache invalidation protocol; the walk's
111
- * on-disk half reuses {@link analysisByFile}, which keeps that property by
112
- * stamping every entry with the bytes it was derived from. */
113
- export function importedR3fContracts(
114
- code: string,
115
- file: string,
116
- ): Map<string, R3fComponentContract> {
117
- return newResolver().importedForSource(code, file);
118
- }
119
-
120
- /** The prop-shape type aliases one module's relative imports bring into
121
- * scope, under their local names — the alias half of `importedR3fContracts`,
122
- * and the other input `transformSource` needs to read a shared prop shape. */
123
- export function importedR3fTypeAliases(code: string, file: string): Map<string, R3fTypeAlias> {
124
- return newResolver().typeAliasesForSource(code, file);
125
- }
126
-
127
- /** All local and imported component bindings visible to one module. Used by
128
- * the live index endpoint to refresh metadata after a shared component HMRs. */
129
- export function visibleR3fContracts(code: string, file: string): Map<string, R3fComponentContract> {
130
- return newResolver().analyzeSource(code, file).visible;
131
- }
132
-
133
75
  /**
134
76
  * The component a `default` export names, when the export is
135
77
  * `export default World` or `export default function World() {…}`.
136
78
  *
137
- * A three root's entry module default-exports its component (D26,
138
- * the editor's `roots/r3f-root.tsx`) — the same contract dom roots use. That component IS the
79
+ * A three root's entry module default-exports its component — the same contract dom roots use. That component IS the
139
80
  * adapter root, so the single-spatial-root rule must not fire on it: a world
140
81
  * legitimately returns a fragment of lights, scenery and players.
141
82
  */
@@ -190,17 +131,8 @@ function returnedRootComponent(element: ts.JsxElement | ts.JsxSelfClosingElement
190
131
  return null;
191
132
  }
192
133
 
193
- /**
194
- * R3F002's subject: a component with one native root that does not route every
195
- * authored transform channel to it.
196
- *
197
- * A simulation-owned root is NOT that (FX-9/PD-11). The component's own source
198
- * already says who writes the transform — it takes no transform prop and drives
199
- * its root ref every frame — so asking it to "keep instances movable" asks for
200
- * an authored placement the next tick overwrites. The warning was permanent and
201
- * unactionable on exactly the components (prey, projectiles, anything mapped
202
- * over simulation state) it fired on most.
203
- */
134
+ /** Warn when a single native root omits authored transform forwarding.
135
+ * Simulation-owned roots accept no authored transform and are excluded. */
204
136
  function needsForwardedTransforms(
205
137
  contract: R3fComponentContract | undefined,
206
138
  ): contract is R3fComponentContract {
@@ -245,32 +177,10 @@ function isKeyedListItem(
245
177
  }
246
178
 
247
179
  export interface R3fAuthoringDiagnosticsOptions {
248
- /**
249
- * The caller has RESOLVED this file's region and it is a `three` one —
250
- * `server/project-root-surface.ts`'s `resolveOidSurface`, which reads the
251
- * region's declared `include` globs, the manifest's root entries, and live
252
- * import reach. Absent or `false` means the caller resolved something else,
253
- * or resolved nothing, and this analyzer stays silent.
254
- *
255
- * It USED to default to a source-text sniff — does this file's text name
256
- * `@react-three/fiber`? — and that sniff is the reason a project could not
257
- * centralize its R3F imports: a terminal whose 138 components take their
258
- * prop shape from one shared `prefab.ts` and otherwise render nothing but
259
- * intrinsic `<group>`/`<mesh>` tags had 12 files pass it and 126 SILENTLY
260
- * SKIPPED, reported as no findings. Silence that cannot be distinguished
261
- * from a clean bill of health is the worst thing an analyzer can produce,
262
- * and reach answers all 138 because reach does not care where a project
263
- * keeps its imports.
264
- *
265
- * Refusing to answer without a resolved surface is not a smaller analyzer,
266
- * it is an honest one: a project's DOM regions run through here too
267
- * (`project-validation.ts` validates every source file, whatever its
268
- * adapter), and every rule below would fire on a `<div>`-rooted component —
269
- * R3F002 would tell it to forward `position`. A caller with no region answer
270
- * has an AMBIGUOUS file, which `resolveOidSurface` already reports as
271
- * `OID001`/`OID002` with the declaration that fixes it.
272
- */
273
- knownThreeSurface?: boolean;
180
+ /** Whether the caller resolved this file to a stage region through declared
181
+ * includes, manifest roots and import reach. False suppresses R3F-specific
182
+ * diagnostics for DOM or unresolved regions. */
183
+ knownStageSurface: boolean;
274
184
  }
275
185
 
276
186
  /** Convention diagnostics for ordinary R3F source. These are warnings, not a
@@ -279,16 +189,16 @@ export interface R3fAuthoringDiagnosticsOptions {
279
189
  export function r3fAuthoringDiagnostics(
280
190
  code: string,
281
191
  file: string,
282
- opts?: R3fAuthoringDiagnosticsOptions,
192
+ opts: R3fAuthoringDiagnosticsOptions,
283
193
  ): R3fAuthoringDiagnostic[] {
284
- if (!opts?.knownThreeSurface) return [];
194
+ if (!opts.knownStageSurface) return [];
285
195
  const analysis = newResolver().analyzeSource(code, file);
286
196
  const sf = parseAuthoringTsx(file, code);
287
197
  const diagnostics: R3fAuthoringDiagnostic[] = [];
288
198
  const diagnosedDefinitions = new Set<string>();
289
199
  const diagnosedMultiRoots = new Set<string>();
290
200
  const diagnosedMotionRoots = new Set<string>();
291
- // `export default World` IS the adapter root (D26 — the one entry shape).
201
+ // The default-exported component is the adapter root.
292
202
  const adapterRootComponents = new Set<string>();
293
203
  const defaultExport = defaultExportedComponent(sf);
294
204
  if (defaultExport) adapterRootComponents.add(defaultExport);