@volter/editor-react 0.5.66

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.
Files changed (33) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +4 -0
  3. package/contributions/component-contracts.service.ts +18 -0
  4. package/dist-node/component-prop-worker.mjs +335 -0
  5. package/dist-node/serving.mjs +5842 -0
  6. package/package.json +51 -0
  7. package/serving/component-prop-worker.ts +72 -0
  8. package/serving/index.ts +21 -0
  9. package/serving/ui-oid-plugin.ts +1844 -0
  10. package/src/source/component-prop-types.ts +517 -0
  11. package/src/source/ensure-import.ts +132 -0
  12. package/src/source/oid-transform.ts +1667 -0
  13. package/src/source/plan-csf-story.ts +196 -0
  14. package/src/source/plan-extract-component.ts +421 -0
  15. package/src/source/plan-fork-component.ts +621 -0
  16. package/src/source/plan-named-style.ts +95 -0
  17. package/src/source/plan-source-edit.ts +353 -0
  18. package/src/source/r3f-contract-resolver.ts +327 -0
  19. package/src/source/r3f-diagnostic-index.ts +165 -0
  20. package/src/source/r3f-environment-binding.ts +106 -0
  21. package/src/source/r3f-joint-binding.ts +228 -0
  22. package/src/source/r3f-lod-binding.ts +81 -0
  23. package/src/source/r3f-particle-binding.ts +213 -0
  24. package/src/source/r3f-physics-binding.ts +454 -0
  25. package/src/source/r3f-project-contracts.ts +444 -0
  26. package/src/source/relative-import-specifier.ts +34 -0
  27. package/src/source/reparent-guard.ts +531 -0
  28. package/src/source/source-edit-request.ts +22 -0
  29. package/src/source/syntactic-prop-specs.ts +296 -0
  30. package/src/source/ts-ast.ts +171 -0
  31. package/src/source/utility-class-support.ts +169 -0
  32. package/src/source/write-component-default.ts +133 -0
  33. package/src/source/writer.ts +1982 -0
@@ -0,0 +1,1667 @@
1
+ /**
2
+ * OID instrumentation (C1/C2) — the visual-edit "stamp every JSX element with a
3
+ * stable source id" trick, ported to vgai using the TypeScript compiler API
4
+ * (vgai has `typescript`; it does NOT have @babel/*). Hand-authored React UI
5
+ * component source is transformed at build/dev time to carry a `data-oid` on every
6
+ * JSX element, and an OID -> {file,line,col,component,tag} index is built so the
7
+ * editor can map a clicked element back to its exact source location.
8
+ *
9
+ * Stability (C1): unlike visual-edit's line:col key (which shifts every id below an
10
+ * edit), we key by a CONTENT signature `component:tag:nthOccurrence`, so an edit on
11
+ * an unrelated line keeps existing ids stable. The `data-oid` lives ONLY in the
12
+ * transformed output, never on disk (the writer edits the original source instead).
13
+ */
14
+ import ts from 'typescript';
15
+ import {
16
+ environmentBindingsByElement,
17
+ type R3fEnvironmentBinding,
18
+ } from './r3f-environment-binding';
19
+ import { jointBindingsByElement, type R3fJointBinding } from './r3f-joint-binding';
20
+ import { lodBindingsByElement, type R3fLodBinding } from './r3f-lod-binding';
21
+ import { particleBindingsByElement, type R3fParticleBinding } from './r3f-particle-binding';
22
+ import { physicsBindingsByElement, type R3fPhysicsBinding } from './r3f-physics-binding';
23
+ import { parseAuthoringTsx, refIdentifier } from './ts-ast';
24
+
25
+ import type { DeclaredRootSurface, OidEntry, R3fComponentContract, R3fTransformProp, SourceDialectEvidence } from '@volter/editor-sdk/source-authoring';
26
+ export type { ComponentPropSpec, DeclaredRootSurface, OidEntry, R3fAuthoringDiagnostic, R3fComponentContract, R3fTransformProp, SourceDialectEvidence } from '@volter/editor-sdk/source-authoring';
27
+
28
+ export type {
29
+ R3fEnvironmentBinding,
30
+ R3fEnvironmentNumberBinding,
31
+ R3fEnvironmentStringBinding,
32
+ } from './r3f-environment-binding';
33
+ export type {
34
+ R3fJointBinding,
35
+ R3fJointHook,
36
+ R3fJointLiteral,
37
+ R3fJointLiteralRange,
38
+ } from './r3f-joint-binding';
39
+ export type { R3fLodBinding, R3fLodNumberBinding } from './r3f-lod-binding';
40
+ export type {
41
+ QuarksEmitterShape,
42
+ R3fParticleBinding,
43
+ R3fParticleNumberBinding,
44
+ } from './r3f-particle-binding';
45
+ export type { PhysicsChannel, R3fPhysicsBinding } from './r3f-physics-binding';
46
+
47
+ function literalJsxExpression(expression: ts.Expression | undefined): boolean {
48
+ if (!expression) return false;
49
+ if (
50
+ ts.isStringLiteral(expression) ||
51
+ ts.isNumericLiteral(expression) ||
52
+ expression.kind === ts.SyntaxKind.TrueKeyword ||
53
+ expression.kind === ts.SyntaxKind.FalseKeyword
54
+ ) {
55
+ return true;
56
+ }
57
+ if (!ts.isArrayLiteralExpression(expression)) return false;
58
+ return expression.elements.every(
59
+ (element) =>
60
+ ts.isNumericLiteral(element) ||
61
+ (ts.isPrefixUnaryExpression(element) &&
62
+ element.operator === ts.SyntaxKind.MinusToken &&
63
+ ts.isNumericLiteral(element.operand)),
64
+ );
65
+ }
66
+
67
+ function authoredPropsOf(
68
+ node: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
69
+ sourceFile: ts.SourceFile,
70
+ ): NonNullable<OidEntry['authoredProps']> {
71
+ return node.attributes.properties.flatMap((property) => {
72
+ if (!ts.isJsxAttribute(property)) return [];
73
+ const name = property.name.getText(sourceFile);
74
+ const initializer = property.initializer;
75
+ if (!initializer) return [{ name, valueText: 'true', literal: true }];
76
+ if (ts.isStringLiteral(initializer)) {
77
+ return [{ name, valueText: initializer.getText(sourceFile), literal: true }];
78
+ }
79
+ if (!ts.isJsxExpression(initializer)) return [];
80
+ return [
81
+ {
82
+ name,
83
+ valueText: initializer.expression?.getText(sourceFile) ?? '',
84
+ literal: literalJsxExpression(initializer.expression),
85
+ },
86
+ ];
87
+ });
88
+ }
89
+
90
+ /** Persistent oid store: signature -> oid (kept stable across re-transforms). */
91
+ export class OidStore {
92
+ private bySig = new Map<string, string>();
93
+ private counter = 0;
94
+ readonly index = new Map<string, OidEntry>();
95
+
96
+ getOrCreate(sig: string): string {
97
+ let oid = this.bySig.get(sig);
98
+ if (!oid) {
99
+ this.counter += 1;
100
+ oid = `o${this.counter.toString(36)}${hash(sig).toString(36)}`;
101
+ this.bySig.set(sig, oid);
102
+ }
103
+ return oid;
104
+ }
105
+
106
+ /** Clear index entries for a file before re-transforming it. */
107
+ clearFile(file: string): void {
108
+ for (const [oid, e] of this.index) if (e.file === file) this.index.delete(oid);
109
+ }
110
+ }
111
+
112
+ function hash(s: string): number {
113
+ let h = 0;
114
+ for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
115
+ return Math.abs(h);
116
+ }
117
+
118
+ interface SourceEdit {
119
+ pos: number;
120
+ end?: number;
121
+ text: string;
122
+ }
123
+
124
+ const R3F_TRANSFORM_PROPS = ['position', 'rotation', 'scale'] as const;
125
+
126
+ /**
127
+ * Contracts for components vgai does NOT own, keyed by package specifier and
128
+ * export name. The project resolver reads a local component's contract out of
129
+ * its own source; an installed package has no source to walk from the browser
130
+ * tier and no reason to expect one, so the few whose native root is part of the
131
+ * package's documented public API are recorded here instead.
132
+ *
133
+ * Deliberately tiny and hand-audited. Everything absent from this table stays
134
+ * conservative (`root: 'unknown'`), because guessing at a dependency's scene
135
+ * ownership is how an editor writes a prop the library silently ignores.
136
+ *
137
+ * `@react-three/rapier`'s `<RigidBody>` renders one host `object3D` and passes
138
+ * `position`/`rotation`/`scale` straight to it — the body's initial transform is
139
+ * read back off that object, and rapier scales the colliders it owns by the
140
+ * object's world scale — so a transform written at the callsite lands exactly
141
+ * where the editor claims it does.
142
+ *
143
+ * `rootTag` is `group`, not `object3D`, because the tag is proved against the
144
+ * CALLER's props type: `typeAllowsStandardRootProps` looks for the literal text
145
+ * `ThreeElements['<rootTag>']`, and a component forwarding to a RigidBody types
146
+ * its props as `ThreeElements['group'] & …` — the ordinary R3F spelling for "a
147
+ * spatial container". `object3D` would match no real caller.
148
+ */
149
+ const BUILTIN_R3F_CONTRACTS: Readonly<
150
+ Record<string, Readonly<Record<string, R3fComponentContract>>>
151
+ > = {
152
+ '@react-three/rapier': {
153
+ RigidBody: { root: 'single', rootTag: 'group', transformProps: [...R3F_TRANSFORM_PROPS] },
154
+ },
155
+ };
156
+
157
+ /**
158
+ * Package prop types that are themselves a proved native-root prop surface.
159
+ * Kept beside the component contract because both facts come from the same
160
+ * hand-audited public API. This lets ordinary wrappers preserve the package's
161
+ * own type (`Omit<RigidBodyProps, ...>`) without pretending every imported
162
+ * type is spatial.
163
+ */
164
+ const BUILTIN_R3F_ROOT_PROP_TYPES: Readonly<Record<string, Readonly<Record<string, string>>>> = {
165
+ '@react-three/rapier': { RigidBodyProps: 'group' },
166
+ };
167
+
168
+ /** Local imported type binding → native root tag it proves. */
169
+ function builtinRootPropTypes(sf: ts.SourceFile): Map<string, string> {
170
+ const bound = new Map<string, string>();
171
+ for (const statement of sf.statements) {
172
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
173
+ continue;
174
+ }
175
+ const known = BUILTIN_R3F_ROOT_PROP_TYPES[statement.moduleSpecifier.text];
176
+ const bindings = statement.importClause?.namedBindings;
177
+ if (!known || !bindings || !ts.isNamedImports(bindings)) continue;
178
+ for (const element of bindings.elements) {
179
+ const importedName = element.propertyName?.text ?? element.name.text;
180
+ const rootTag = known[importedName];
181
+ if (rootTag) bound.set(element.name.text, rootTag);
182
+ }
183
+ }
184
+ return bound;
185
+ }
186
+
187
+ /** True for a contract that came from the table above rather than from source.
188
+ * Identity, not shape: a project-local definition that SHADOWS an imported
189
+ * binding produces its own freshly analysed object, so it is never mistaken
190
+ * for the package's. */
191
+ export function isBuiltinR3fContract(contract: R3fComponentContract | undefined): boolean {
192
+ if (!contract) return false;
193
+ return Object.values(BUILTIN_R3F_CONTRACTS).some((exports) =>
194
+ Object.values(exports).includes(contract),
195
+ );
196
+ }
197
+
198
+ /** The built-in contract for one named export of an external package, if any. */
199
+ export function builtinR3fContract(
200
+ specifier: string,
201
+ exportName: string,
202
+ ): R3fComponentContract | undefined {
203
+ return BUILTIN_R3F_CONTRACTS[specifier]?.[exportName];
204
+ }
205
+
206
+ /** The local bindings ONE import declaration takes from the built-in table.
207
+ * Keyed by the LOCAL name, so `import { RigidBody as RB }` registers `RB`. */
208
+ export function builtinR3fContractsOfImport(
209
+ declaration: ts.ImportDeclaration,
210
+ ): Map<string, R3fComponentContract> {
211
+ const bound = new Map<string, R3fComponentContract>();
212
+ if (!ts.isStringLiteral(declaration.moduleSpecifier)) return bound;
213
+ const bindings = declaration.importClause?.namedBindings;
214
+ if (!bindings || !ts.isNamedImports(bindings)) return bound;
215
+ for (const element of bindings.elements) {
216
+ const contract = builtinR3fContract(
217
+ declaration.moduleSpecifier.text,
218
+ element.propertyName?.text ?? element.name.text,
219
+ );
220
+ if (contract) bound.set(element.name.text, contract);
221
+ }
222
+ return bound;
223
+ }
224
+
225
+ /** Every built-in contract one module's imports bring into scope. The browser
226
+ * tier has no filesystem to resolve project-local imports from, so this is the
227
+ * whole of the contract knowledge available to it. */
228
+ export function builtinR3fContractsForSource(
229
+ code: string,
230
+ file: string,
231
+ ): Map<string, R3fComponentContract> {
232
+ const sf = parseAuthoringTsx(file, code);
233
+ const contracts = new Map<string, R3fComponentContract>();
234
+ for (const statement of sf.statements) {
235
+ if (!ts.isImportDeclaration(statement)) continue;
236
+ for (const [name, contract] of builtinR3fContractsOfImport(statement)) {
237
+ contracts.set(name, contract);
238
+ }
239
+ }
240
+ return contracts;
241
+ }
242
+
243
+ function unwrapExpression(expression: ts.Expression): ts.Expression {
244
+ let current = expression;
245
+ while (
246
+ ts.isParenthesizedExpression(current) ||
247
+ ts.isAsExpression(current) ||
248
+ ts.isSatisfiesExpression(current)
249
+ ) {
250
+ current = current.expression;
251
+ }
252
+ return current;
253
+ }
254
+
255
+ function returnedExpression(body: ts.ConciseBody): ts.Expression | undefined {
256
+ if (!ts.isBlock(body)) return unwrapExpression(body);
257
+ const returns = body.statements.filter(ts.isReturnStatement);
258
+ if (returns.length !== 1 || !returns[0]?.expression) return undefined;
259
+ return unwrapExpression(returns[0].expression);
260
+ }
261
+
262
+ /**
263
+ * A type alias and the file it was written in.
264
+ *
265
+ * These travel together because `ts.Node.getText(sf)` reads the node's
266
+ * position range out of the SOURCE TEXT it is handed: give it another file's
267
+ * `SourceFile` and it returns whatever characters happen to sit at those
268
+ * offsets. An alias resolved across a module boundary is exactly that case.
269
+ */
270
+ export interface R3fTypeAlias {
271
+ readonly type: ts.TypeNode;
272
+ readonly sf: ts.SourceFile;
273
+ }
274
+
275
+ /** The `export type X = …` declarations of one module, for importers. */
276
+ export function exportedR3fTypeAliases(sf: ts.SourceFile): Map<string, R3fTypeAlias> {
277
+ const aliases = new Map<string, R3fTypeAlias>();
278
+ for (const statement of sf.statements) {
279
+ if (!ts.isTypeAliasDeclaration(statement)) continue;
280
+ if (!ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue;
281
+ aliases.set(statement.name.text, { type: statement.type, sf });
282
+ }
283
+ return aliases;
284
+ }
285
+
286
+ function textIncludesBuiltinRootProps(
287
+ text: string,
288
+ rootTag: string,
289
+ builtinPropTypes: ReadonlyMap<string, string>,
290
+ ): boolean {
291
+ for (const [name, knownRootTag] of builtinPropTypes) {
292
+ if (knownRootTag === rootTag && new RegExp(`\\b${name}\\b`).test(text)) return true;
293
+ }
294
+ return false;
295
+ }
296
+
297
+ function typeAllowsStandardRootProps(
298
+ type: ts.TypeNode | undefined,
299
+ rootTag: string,
300
+ sf: ts.SourceFile,
301
+ typeAliases: ReadonlyMap<string, R3fTypeAlias>,
302
+ builtinPropTypes: ReadonlyMap<string, string>,
303
+ seen = new Set<string>(),
304
+ ): boolean {
305
+ if (!type) return false;
306
+ const text = type.getText(sf);
307
+ const escapedRootTag = rootTag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
308
+ if (new RegExp(`\\bThreeElements\\s*\\[\\s*['"]${escapedRootTag}['"]\\s*\\]`).test(text))
309
+ return true;
310
+ if (textIncludesBuiltinRootProps(text, rootTag, builtinPropTypes)) return true;
311
+ if (ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName)) {
312
+ const name = type.typeName.text;
313
+ if (seen.has(name)) return false;
314
+ const alias = typeAliases.get(name);
315
+ if (!alias) return false;
316
+ seen.add(name);
317
+ return typeAllowsStandardRootProps(
318
+ alias.type,
319
+ rootTag,
320
+ alias.sf,
321
+ typeAliases,
322
+ builtinPropTypes,
323
+ seen,
324
+ );
325
+ }
326
+ for (const [name, alias] of typeAliases) {
327
+ if (seen.has(name) || !new RegExp(`\\b${name}\\b`).test(text)) continue;
328
+ const nextSeen = new Set(seen).add(name);
329
+ if (
330
+ typeAllowsStandardRootProps(
331
+ alias.type,
332
+ rootTag,
333
+ alias.sf,
334
+ typeAliases,
335
+ builtinPropTypes,
336
+ nextSeen,
337
+ )
338
+ )
339
+ return true;
340
+ }
341
+ return false;
342
+ }
343
+
344
+ function spatialRootOpening(expression: ts.Expression): ts.JsxOpeningLikeElement | undefined {
345
+ if (ts.isJsxSelfClosingElement(expression)) return expression;
346
+ if (!ts.isJsxElement(expression)) return undefined;
347
+ return expression.openingElement;
348
+ }
349
+
350
+ type FragmentRootCardinality = 0 | 1 | 'multiple' | 'unknown';
351
+
352
+ function combineFragmentRootCardinality(
353
+ left: FragmentRootCardinality,
354
+ right: FragmentRootCardinality,
355
+ ): FragmentRootCardinality {
356
+ if (left === 'multiple' || right === 'multiple') return 'multiple';
357
+ if (left === 'unknown' || right === 'unknown') return 'unknown';
358
+ return left + right >= 2 ? 'multiple' : ((left + right) as 0 | 1);
359
+ }
360
+
361
+ function jsxRootCardinality(
362
+ element: ts.JsxElement | ts.JsxSelfClosingElement,
363
+ sf: ts.SourceFile,
364
+ knownContracts: ReadonlyMap<string, R3fComponentContract>,
365
+ ): FragmentRootCardinality {
366
+ const opening = ts.isJsxElement(element) ? element.openingElement : element;
367
+ const tag = opening.tagName.getText(sf);
368
+ if (!/^[A-Z]/.test(tag)) return isNonSpatialNativeTag(tag) ? 0 : 1;
369
+ const delegated = knownContracts.get(tag);
370
+ if (!delegated || delegated.root === 'unknown') return 'unknown';
371
+ if (delegated.root === 'non-spatial') return 0;
372
+ return delegated.root === 'multiple' ? 'multiple' : 1;
373
+ }
374
+
375
+ function mappedRootCardinality(
376
+ expression: ts.CallExpression,
377
+ sf: ts.SourceFile,
378
+ knownContracts: ReadonlyMap<string, R3fComponentContract>,
379
+ ): FragmentRootCardinality | undefined {
380
+ if (
381
+ !ts.isPropertyAccessExpression(expression.expression) ||
382
+ expression.expression.name.text !== 'map'
383
+ ) {
384
+ return undefined;
385
+ }
386
+ const callback = expression.arguments[0];
387
+ if (!callback || (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback))) {
388
+ return 'unknown';
389
+ }
390
+ const returned = returnedExpression(callback.body);
391
+ if (!returned) return 'unknown';
392
+ const item = expressionRootCardinality(returned, sf, knownContracts);
393
+ // A map can emit any number of its item. It remains non-spatial only when
394
+ // the item is proved non-spatial; every spatial item makes a plural result
395
+ // possible, which is precisely the non-integral component shape R3F003
396
+ // reports.
397
+ return item === 0 ? 0 : item === 'unknown' ? 'unknown' : 'multiple';
398
+ }
399
+
400
+ function expressionRootCardinality(
401
+ expression: ts.Expression,
402
+ sf: ts.SourceFile,
403
+ knownContracts: ReadonlyMap<string, R3fComponentContract>,
404
+ ): FragmentRootCardinality {
405
+ const unwrapped = unwrapExpression(expression);
406
+ if (unwrapped.kind === ts.SyntaxKind.NullKeyword) return 0;
407
+ if (unwrapped.kind === ts.SyntaxKind.FalseKeyword) return 0;
408
+ if (ts.isJsxFragment(unwrapped)) {
409
+ return fragmentRootCardinality(unwrapped, sf, knownContracts);
410
+ }
411
+ if (ts.isJsxElement(unwrapped) || ts.isJsxSelfClosingElement(unwrapped)) {
412
+ return jsxRootCardinality(unwrapped, sf, knownContracts);
413
+ }
414
+ if (ts.isCallExpression(unwrapped)) {
415
+ return mappedRootCardinality(unwrapped, sf, knownContracts) ?? 'unknown';
416
+ }
417
+ return 'unknown';
418
+ }
419
+
420
+ function jsxChildRootCardinality(
421
+ child: ts.JsxChild,
422
+ sf: ts.SourceFile,
423
+ knownContracts: ReadonlyMap<string, R3fComponentContract>,
424
+ ): FragmentRootCardinality {
425
+ if (ts.isJsxText(child)) return child.text.trim() === '' ? 0 : 'unknown';
426
+ if (ts.isJsxExpression(child)) {
427
+ return child.expression ? expressionRootCardinality(child.expression, sf, knownContracts) : 0;
428
+ }
429
+ if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
430
+ return jsxRootCardinality(child, sf, knownContracts);
431
+ }
432
+ if (ts.isJsxFragment(child)) return fragmentRootCardinality(child, sf, knownContracts);
433
+ return 'unknown';
434
+ }
435
+
436
+ function fragmentRootCardinality(
437
+ fragment: ts.JsxFragment,
438
+ sf: ts.SourceFile,
439
+ knownContracts: ReadonlyMap<string, R3fComponentContract>,
440
+ ): FragmentRootCardinality {
441
+ let result: FragmentRootCardinality = 0;
442
+ for (const child of fragment.children) {
443
+ result = combineFragmentRootCardinality(
444
+ result,
445
+ jsxChildRootCardinality(child, sf, knownContracts),
446
+ );
447
+ }
448
+ return result;
449
+ }
450
+
451
+ function fragmentComponentContract(
452
+ fragment: ts.JsxFragment,
453
+ sf: ts.SourceFile,
454
+ knownContracts: ReadonlyMap<string, R3fComponentContract>,
455
+ ): R3fComponentContract {
456
+ const cardinality = fragmentRootCardinality(fragment, sf, knownContracts);
457
+ if (cardinality === 0) return { root: 'non-spatial', transformProps: [] };
458
+ if (cardinality === 'multiple') return { root: 'multiple', transformProps: [] };
459
+ // A fragment with one proved spatial child, or an unresolved child, has no
460
+ // fragment object of its own through which transform props can be proved to
461
+ // flow. Keep it conservative without falsely claiming several roots.
462
+ return { root: 'unknown', transformProps: [] };
463
+ }
464
+
465
+ /**
466
+ * Native R3F tags that are NOT `Object3D`s.
467
+ *
468
+ * R3F's host namespace is all of three's constructors, not just the scene
469
+ * graph: materials, geometries and buffer attributes are elements too, and
470
+ * they mount by `attach` onto a parent rather than by taking a place in the
471
+ * hierarchy. They have no `position`, no `rotation`, no `scale`, and they are
472
+ * never a hierarchy row.
473
+ *
474
+ * A component whose single root is one of these is a perfectly ordinary R3F
475
+ * idiom — a shared material helper returning `<meshStandardMaterial …/>` is
476
+ * about as common as R3F code gets — and it must not be read as an
477
+ * unauthorable spatial instance. R3F002 would demand it forward transform
478
+ * channels the element does not have (there is no `position` prop on a
479
+ * material to accept), and R3F004 would ask for a `name` "so its instance is
480
+ * recognizable in the hierarchy" when nothing about it ever appears there.
481
+ * Both are unactionable by construction, which is precisely the failure the
482
+ * `simulationOwnedTransform` and keyed-list-item carve-outs already exist to
483
+ * prevent elsewhere in these rules.
484
+ *
485
+ * Recognized by suffix because three's naming is regular here and the set is
486
+ * open — every release adds materials and geometries, and an exhaustive list
487
+ * would silently rot into false positives again. The three exact names are the
488
+ * scene-level attachables that carry no suffix.
489
+ */
490
+ function isNonSpatialNativeTag(tag: string): boolean {
491
+ if (/^[A-Z]/.test(tag)) return false;
492
+ return (
493
+ /(Material|Geometry|Attribute)$/.test(tag) ||
494
+ tag === 'fog' ||
495
+ tag === 'fogExp2' ||
496
+ tag === 'color'
497
+ );
498
+ }
499
+
500
+ interface R3fPropForwarding {
501
+ standardRootProps: boolean;
502
+ wholePropsName?: string;
503
+ restPropsName?: string;
504
+ localBindingByProp: Map<R3fObjectProp, string>;
505
+ }
506
+
507
+ type R3fObjectProp = R3fTransformProp | 'visible';
508
+
509
+ function isR3fTransformProp(value: string): value is R3fTransformProp {
510
+ return (R3F_TRANSFORM_PROPS as readonly string[]).includes(value);
511
+ }
512
+
513
+ function isR3fObjectProp(value: string): value is R3fObjectProp {
514
+ return isR3fTransformProp(value) || value === 'visible';
515
+ }
516
+
517
+ function propForwarding(
518
+ first: ts.ParameterDeclaration | undefined,
519
+ rootTag: string,
520
+ sf: ts.SourceFile,
521
+ typeAliases: ReadonlyMap<string, R3fTypeAlias>,
522
+ builtinPropTypes: ReadonlyMap<string, string>,
523
+ ): R3fPropForwarding {
524
+ const result: R3fPropForwarding = {
525
+ standardRootProps: typeAllowsStandardRootProps(
526
+ first?.type,
527
+ rootTag,
528
+ sf,
529
+ typeAliases,
530
+ builtinPropTypes,
531
+ ),
532
+ localBindingByProp: new Map(),
533
+ };
534
+ if (!first) return result;
535
+ if (ts.isIdentifier(first.name)) {
536
+ result.wholePropsName = first.name.text;
537
+ return result;
538
+ }
539
+ if (!ts.isObjectBindingPattern(first.name)) return result;
540
+ for (const element of first.name.elements) {
541
+ if (element.dotDotDotToken && ts.isIdentifier(element.name)) {
542
+ result.restPropsName = element.name.text;
543
+ continue;
544
+ }
545
+ if (!ts.isIdentifier(element.name)) continue;
546
+ const authoredName = element.propertyName?.getText(sf) ?? element.name.text;
547
+ if (isR3fObjectProp(authoredName)) {
548
+ result.localBindingByProp.set(authoredName, element.name.text);
549
+ }
550
+ }
551
+ return result;
552
+ }
553
+
554
+ /**
555
+ * Is this parameter's type spelled AS another component's props — the guard
556
+ * wrapper's `Parameters<typeof Body>[0]` / `ComponentProps<typeof Body>`?
557
+ *
558
+ * Deliberately a spelling test and not a type check: this analyzer holds no
559
+ * checker, and the one thing it needs to know is that the wrapper's props ARE
560
+ * the delegate's, which the spelling states outright.
561
+ */
562
+ function typeIsDelegateProps(
563
+ type: ts.TypeNode | undefined,
564
+ delegateTag: string,
565
+ sf: ts.SourceFile,
566
+ ): boolean {
567
+ if (!type) return false;
568
+ const text = type.getText(sf).replace(/\s+/g, '');
569
+ const tag = delegateTag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
570
+ return new RegExp(
571
+ `^(React\\.)?(Parameters<typeof${tag}>\\[0\\]|ComponentProps(WithoutRef|WithRef)?<typeof${tag}>)`,
572
+ ).test(text);
573
+ }
574
+
575
+ function spreadForwardsAll(
576
+ property: ts.JsxSpreadAttribute,
577
+ forwarding: R3fPropForwarding,
578
+ ): boolean {
579
+ if (!forwarding.standardRootProps || !ts.isIdentifier(property.expression)) return false;
580
+ return (
581
+ property.expression.text === forwarding.wholePropsName ||
582
+ property.expression.text === forwarding.restPropsName
583
+ );
584
+ }
585
+
586
+ function attributeForwardsObjectProp(
587
+ property: ts.JsxAttribute,
588
+ forwarding: R3fPropForwarding,
589
+ sf: ts.SourceFile,
590
+ ): R3fObjectProp | undefined {
591
+ const propName = property.name.getText(sf);
592
+ if (!isR3fObjectProp(propName)) return undefined;
593
+ if (!property.initializer || !ts.isJsxExpression(property.initializer)) return undefined;
594
+ const value = property.initializer.expression;
595
+ if (!value) return undefined;
596
+ const localName = forwarding.localBindingByProp.get(propName);
597
+ if (localName && ts.isIdentifier(value) && value.text === localName) return propName;
598
+ if (
599
+ forwarding.wholePropsName &&
600
+ ts.isPropertyAccessExpression(value) &&
601
+ ts.isIdentifier(value.expression) &&
602
+ value.expression.text === forwarding.wholePropsName &&
603
+ value.name.text === propName
604
+ ) {
605
+ return propName;
606
+ }
607
+ return undefined;
608
+ }
609
+
610
+ function forwardedObjectProps(
611
+ opening: ts.JsxOpeningLikeElement,
612
+ forwarding: R3fPropForwarding,
613
+ sf: ts.SourceFile,
614
+ ): R3fObjectProp[] {
615
+ const forwarded = new Set<R3fObjectProp>();
616
+ for (const property of opening.attributes.properties) {
617
+ if (ts.isJsxSpreadAttribute(property)) {
618
+ if (spreadForwardsAll(property, forwarding)) {
619
+ for (const channel of R3F_TRANSFORM_PROPS) forwarded.add(channel);
620
+ forwarded.add('visible');
621
+ }
622
+ continue;
623
+ }
624
+ const prop = attributeForwardsObjectProp(property, forwarding, sf);
625
+ if (prop) forwarded.add(prop);
626
+ }
627
+ const ordered: readonly R3fObjectProp[] = [...R3F_TRANSFORM_PROPS, 'visible'];
628
+ return ordered.filter((prop) => forwarded.has(prop));
629
+ }
630
+
631
+ /**
632
+ * A ref whose `.current` transform is written inside a `useFrame` body — the
633
+ * source-visible mark of runtime-driven motion.
634
+ *
635
+ * Optional chaining is part of the pattern, not an edge case: `useRef<
636
+ * THREE.Group>(null)` types `.current` as nullable, so `body.current?.position
637
+ * .copy(…)` is the ordinary spelling and `body.current.position.copy(…)` the
638
+ * non-null-asserted one. Both must read the same.
639
+ */
640
+ const RUNTIME_MOTION_MUTATION_RE =
641
+ /\b([A-Za-z_$][\w$]*)\.current\??\.(?:position|rotation|quaternion|scale)(?:\??\.[xyzw]+)?\s*(?:[+\-*/]?=|\??\.(?:set|setScalar|copy|add|sub|multiply|lerp|slerp|fromArray)\s*\()/g;
642
+
643
+ /**
644
+ * The refs any `useFrame` callback in `scope` drives every frame.
645
+ *
646
+ * Deliberately a regex over the callback's text rather than a resolved
647
+ * dataflow analysis — the same honesty budget the rest of this module's source
648
+ * evidence holds to, and the single derivation BOTH readers use (the contract
649
+ * below, and R3F005 in `r3f-project-contracts.ts`) so the two can never
650
+ * disagree about whether one component drives its own root.
651
+ */
652
+ export function runtimeMotionRefs(scope: ts.Node, sf: ts.SourceFile): Set<string> {
653
+ const refs = new Set<string>();
654
+ const visit = (node: ts.Node): void => {
655
+ if (ts.isCallExpression(node) && /(^|\.)useFrame$/.test(node.expression.getText(sf))) {
656
+ for (const match of node.getText(sf).matchAll(RUNTIME_MOTION_MUTATION_RE)) {
657
+ refs.add(match[1]!);
658
+ }
659
+ }
660
+ ts.forEachChild(node, visit);
661
+ };
662
+ visit(scope);
663
+ return refs;
664
+ }
665
+
666
+ /** `{ simulationOwnedTransform: true }` when this root's transform is written
667
+ * by the runtime and by nothing else — see the field's own doc. Only when
668
+ * NOTHING is forwarded: a component that forwards a channel and then animates
669
+ * it is the genuine conflict R3F005 exists for, not a simulation-owned
670
+ * instance. */
671
+ function simulationOwnership(
672
+ opening: ts.JsxOpeningLikeElement,
673
+ transformProps: readonly R3fTransformProp[],
674
+ motionRefs: ReadonlySet<string>,
675
+ ): { simulationOwnedTransform?: true } {
676
+ if (transformProps.length > 0) return {};
677
+ const rootRef = refIdentifier(opening);
678
+ return rootRef && motionRefs.has(rootRef) ? { simulationOwnedTransform: true } : {};
679
+ }
680
+
681
+ function componentContract(
682
+ parameters: ts.NodeArray<ts.ParameterDeclaration>,
683
+ body: ts.ConciseBody,
684
+ sf: ts.SourceFile,
685
+ typeAliases: ReadonlyMap<string, R3fTypeAlias>,
686
+ builtinPropTypes: ReadonlyMap<string, string>,
687
+ knownContracts: ReadonlyMap<string, R3fComponentContract>,
688
+ motionRefs: ReadonlySet<string>,
689
+ physicsBindings: ReadonlyMap<ts.JsxOpeningLikeElement, R3fPhysicsBinding>,
690
+ ): R3fComponentContract {
691
+ const expression = returnedExpression(body);
692
+ if (!expression) return { root: 'unknown', transformProps: [] };
693
+ if (expression.kind === ts.SyntaxKind.NullKeyword)
694
+ return { root: 'non-spatial', transformProps: [] };
695
+ if (ts.isJsxFragment(expression))
696
+ return fragmentComponentContract(expression, sf, knownContracts);
697
+ if (!ts.isJsxElement(expression) && !ts.isJsxSelfClosingElement(expression)) {
698
+ return { root: 'unknown', transformProps: [] };
699
+ }
700
+
701
+ const opening = spatialRootOpening(expression);
702
+ if (!opening) return { root: 'unknown', transformProps: [] };
703
+ const rootTag = opening.tagName.getText(sf);
704
+ // A material/geometry/attribute root is not a spatial instance at all — see
705
+ // `isNonSpatialNativeTag`. Classified here rather than suppressed at each
706
+ // rule, so every rule that keys off `root === 'single'` gets it at once.
707
+ if (isNonSpatialNativeTag(rootTag)) return { root: 'non-spatial', transformProps: [] };
708
+ const delegated = /^[A-Z]/.test(rootTag) ? knownContracts.get(rootTag) : undefined;
709
+ if (/^[A-Z]/.test(rootTag) && !delegated) return { root: 'unknown', transformProps: [] };
710
+ if (delegated && delegated.root !== 'single') return delegated;
711
+ const nativeRootTag = delegated?.rootTag ?? rootTag;
712
+ // A channel this component hands to the PHYSICS BINDING that places its root
713
+ // is forwarded exactly as truly as one written onto the root's own tag — the
714
+ // body is what puts the object in the world, so the callsite literal the hook
715
+ // reads IS this instance's placement (`r3f-physics-binding.ts`). Without this
716
+ // the contract said `Ramp` "does not forward position to its native root"
717
+ // and refused `<Ramp position={[2, -1, 168.55]} />`, the one literal in the
718
+ // whole chain that actually places the ramp.
719
+ const rootBinding = physicsBindings.get(opening);
720
+ // A channel the component's OWN `useFrame` drives through the body's api is
721
+ // NOT forwarded in any sense a write can rely on: the hook may read the
722
+ // callsite literal at spawn, but the per-frame driver has the last word and
723
+ // the authored value is dead on the next tick (racing-game's `Train` — a
724
+ // Kinematic body re-posed from its animated group every frame; the doctor's
725
+ // settle leg measured the authored spawn discarded, digit for digit). Those
726
+ // channels leave the forwarded set here, and the component is classified
727
+ // simulation-owned below, so a callsite write refuses with the sentence
728
+ // that names the real owner instead of persisting a literal that lies.
729
+ const apiDriven = (rootBinding?.apiDriven ?? []) as readonly string[];
730
+ const toBinding = ((rootBinding?.forwarded ?? []) as readonly string[]).filter(
731
+ (channel) => !apiDriven.includes(channel),
732
+ );
733
+ const declared = propForwarding(parameters[0], nativeRootTag, sf, typeAliases, builtinPropTypes);
734
+ // A GUARD WRAPPER spells its props AS the component it delegates to —
735
+ // `props: Parameters<typeof WeaponModelBody>[0]` — and spreads them whole.
736
+ // `typeAllowsStandardRootProps` reads type TEXT and aliases, so that spelling
737
+ // reads as "accepts no root props" and the wrapper was reported as forwarding
738
+ // nothing: first-person's `WeaponModel` (the hook-free discriminator check in
739
+ // front of `WeaponModelBody` that its own header documents, bought by runhuman
740
+ // pass 97) drew a permanent R3F002 telling the author to forward
741
+ // position/rotation/scale it already forwards, all three, through `{...props}`.
742
+ // The delegate's contract is resolved just above, so a wrapper whose props are
743
+ // SPELLED as the delegate's accepts exactly what the delegate accepts.
744
+ const forwarding =
745
+ !declared.standardRootProps &&
746
+ delegated &&
747
+ typeIsDelegateProps(parameters[0]?.type, rootTag, sf)
748
+ ? { ...declared, standardRootProps: true }
749
+ : declared;
750
+ const toTag = forwardedObjectProps(opening, forwarding, sf);
751
+ const ownForwarding = R3F_TRANSFORM_PROPS.filter(
752
+ (channel) => toTag.includes(channel) || toBinding.includes(channel),
753
+ );
754
+ const transformProps = delegated
755
+ ? ownForwarding.filter((channel) => delegated.transformProps.includes(channel))
756
+ : ownForwarding;
757
+ const forwardsVisible =
758
+ toTag.includes('visible') && (!delegated || delegated.visibleProp === true);
759
+
760
+ // A built-in package contract may only ADD authorability. A component that
761
+ // returns an external component while forwarding no transform channel to it
762
+ // is exactly as opaque as it was before the table existed — reading the
763
+ // package's root as its own would claim a native root the component never
764
+ // routes anything to, and turn a silent unknown into a "does not forward"
765
+ // warning about source the author cannot act on.
766
+ if (isBuiltinR3fContract(delegated) && transformProps.length === 0) {
767
+ return { root: 'unknown', transformProps: [] };
768
+ }
769
+
770
+ // WHICH of the forwarded channels a BODY reads, kept because the answer
771
+ // changes what an authored write has to survive (see `bodyForwarded`).
772
+ const bodyForwarded = transformProps.filter((channel) => toBinding.includes(channel));
773
+
774
+ return {
775
+ root: 'single',
776
+ rootTag: nativeRootTag,
777
+ transformProps,
778
+ ...(bodyForwarded.length > 0 ? { bodyForwarded } : {}),
779
+ ...(forwardsVisible ? { visibleProp: true as const } : {}),
780
+ ...simulationOwnership(opening, transformProps, motionRefs),
781
+ // The api-driven case (see `apiDriven` above): the component drives its
782
+ // body every frame, so its instances are placed by the simulation exactly
783
+ // as `runtimeMotionRefs`'s direct-ref case is.
784
+ ...(apiDriven.length > 0 ? { simulationOwnedTransform: true as const } : {}),
785
+ };
786
+ }
787
+
788
+ interface R3fComponentDefinition {
789
+ name: string;
790
+ parameters: ts.NodeArray<ts.ParameterDeclaration>;
791
+ body: ts.ConciseBody;
792
+ }
793
+
794
+ function componentDefinitions(statement: ts.Statement): R3fComponentDefinition[] {
795
+ if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) {
796
+ return /^[A-Z]/.test(statement.name.text)
797
+ ? [{ name: statement.name.text, parameters: statement.parameters, body: statement.body }]
798
+ : [];
799
+ }
800
+ if (!ts.isVariableStatement(statement)) return [];
801
+ return statement.declarationList.declarations.flatMap((declaration) => {
802
+ if (
803
+ !ts.isIdentifier(declaration.name) ||
804
+ !/^[A-Z]/.test(declaration.name.text) ||
805
+ !declaration.initializer ||
806
+ (!ts.isArrowFunction(declaration.initializer) &&
807
+ !ts.isFunctionExpression(declaration.initializer))
808
+ ) {
809
+ return [];
810
+ }
811
+ return [
812
+ {
813
+ name: declaration.name.text,
814
+ parameters: declaration.initializer.parameters,
815
+ body: declaration.initializer.body,
816
+ },
817
+ ];
818
+ });
819
+ }
820
+
821
+ function collectR3fComponentContracts(
822
+ sf: ts.SourceFile,
823
+ importedContracts: ReadonlyMap<string, R3fComponentContract> = new Map(),
824
+ importedTypeAliases: ReadonlyMap<string, R3fTypeAlias> = new Map(),
825
+ ): Map<string, R3fComponentContract> {
826
+ // Imported first, so a local declaration of the same name shadows it exactly
827
+ // as JavaScript lexical scope does — the same rule `importedContracts`
828
+ // already follows for components.
829
+ const aliases = new Map<string, R3fTypeAlias>(importedTypeAliases);
830
+ for (const statement of sf.statements) {
831
+ if (ts.isTypeAliasDeclaration(statement))
832
+ aliases.set(statement.name.text, { type: statement.type, sf });
833
+ }
834
+ const builtins = builtinRootPropTypes(sf);
835
+
836
+ const definitions = sf.statements.flatMap(componentDefinitions);
837
+ const motionRefs = runtimeMotionRefs(sf, sf);
838
+ const physicsBindings = physicsBindingsByElement(sf);
839
+ const contracts = new Map(importedContracts);
840
+ // Components commonly delegate their native root to another local component.
841
+ // Resolve that ordinary React composition to a fixed point rather than
842
+ // requiring definition order or an authoring-specific wrapper.
843
+ for (let pass = 0; pass <= definitions.length; pass += 1) {
844
+ let changed = false;
845
+ for (const definition of definitions) {
846
+ const next = componentContract(
847
+ definition.parameters,
848
+ definition.body,
849
+ sf,
850
+ aliases,
851
+ builtins,
852
+ contracts,
853
+ motionRefs,
854
+ physicsBindings,
855
+ );
856
+ const previous = contracts.get(definition.name);
857
+ if (JSON.stringify(previous) !== JSON.stringify(next)) {
858
+ contracts.set(definition.name, next);
859
+ changed = true;
860
+ }
861
+ }
862
+ if (!changed) break;
863
+ }
864
+ return contracts;
865
+ }
866
+
867
+ /** Analyze the component bindings visible in one R3F module. Imported
868
+ * bindings are supplied by the project resolver; local definitions override
869
+ * them exactly as JavaScript lexical scope does. */
870
+ export function analyzeR3fComponentContracts(
871
+ code: string,
872
+ file: string,
873
+ importedContracts: ReadonlyMap<string, R3fComponentContract> = new Map(),
874
+ importedTypeAliases: ReadonlyMap<string, R3fTypeAlias> = new Map(),
875
+ ): Map<string, R3fComponentContract> {
876
+ const sf = parseAuthoringTsx(file, code);
877
+ return collectR3fComponentContracts(sf, importedContracts, importedTypeAliases);
878
+ }
879
+
880
+ /**
881
+ * Intrinsic (lowercase-initial) JSX tags that exist ONLY in react-three-fiber's
882
+ * reconciler — there is no HTML or SVG element with any of these names.
883
+ *
884
+ * DIAGNOSTIC EVIDENCE ONLY. This does not classify a file's dialect and must
885
+ * not be made to: which region a file belongs to is decided by declaration and
886
+ * import reach (`file-region-resolver.ts`), because that is a fact the game's
887
+ * author states or the host EXECUTES, while a tag list is a guess about a file
888
+ * whose imports it cannot see. What the scan is still for is telling an author
889
+ * what the editor OBSERVED: the `OID003`/`OID004` messages in
890
+ * `server/project-root-surface.ts` name the tags a file renders, and `OID004` —
891
+ * a file rendering both dialects' intrinsics, which one attribute cannot serve
892
+ * — is nothing BUT this observation.
893
+ *
894
+ * Deliberately CONSERVATIVE: every entry is checked against the HTML and SVG
895
+ * element sets, so nothing here can be a DOM tag. `line`, `points`, `sprite`,
896
+ * `audio`, `color` and `fog` are all real R3F intrinsics but are EXCLUDED from
897
+ * this list where SVG/HTML also spells them (`<line>`, `<audio>`) — the list is
898
+ * evidence, not a catalogue, and a wrong entry would flip a genuine DOM file.
899
+ * `[a-z][A-Za-z]*(Geometry|Material)` covers the whole geometry/material family
900
+ * (`boxGeometry`, `meshStandardMaterial`, `shaderMaterial`, …) without naming
901
+ * each one; the leading lowercase class is what keeps it from matching a
902
+ * CUSTOM component (`<MyMaterial>`), which is a callsite, not an intrinsic.
903
+ */
904
+ const R3F_ONLY_INTRINSIC_RE =
905
+ /<(mesh|instancedMesh|batchedMesh|skinnedMesh|group|primitive|lineSegments|bufferGeometry|bufferAttribute|instancedBufferAttribute|[a-z][A-Za-z]*Geometry|[a-z][A-Za-z]*Material|ambientLight|directionalLight|pointLight|spotLight|hemisphereLight|rectAreaLight|perspectiveCamera|orthographicCamera|axesHelper|gridHelper|boxHelper)[\s/>]/g;
906
+
907
+ /**
908
+ * The mirror image: intrinsic tags that exist ONLY in the react-dom
909
+ * reconciler.
910
+ *
911
+ * DIAGNOSTIC EVIDENCE ONLY, on the same terms as the R3F list above: it
912
+ * classifies no file. What a file renders is reported (`OID003`/`OID004` in
913
+ * `server/project-root-surface.ts`); which region a file BELONGS to is a
914
+ * declaration or a reach, never a property of its bytes. Excludes every name R3F also spells as an intrinsic
915
+ * (`line`, `points`, `sprite`, `audio`, `color`, `fog`, `path`, `circle`), so
916
+ * a hit here is unambiguous.
917
+ */
918
+ const DOM_ONLY_INTRINSIC_RE =
919
+ /<(div|span|p|button|section|header|footer|nav|main|aside|form|input|textarea|select|option|label|table|thead|tbody|tr|td|th|img|iframe|br|hr|strong|em|pre|code|canvas|ul|ol|li|h[1-6])[\s/>]/g;
920
+
921
+ function distinctTags(code: string, pattern: RegExp): string[] {
922
+ const found = new Set<string>();
923
+ for (const match of code.matchAll(pattern)) if (match[1]) found.add(match[1]);
924
+ return [...found];
925
+ }
926
+
927
+ /**
928
+ * Drop line and block comments before the dialect scan, copying string
929
+ * literals through untouched. The shipped case that bought this: the starter
930
+ * dev-tools JSDoc names its command shape `cheat.<group>.<label>` — `group`
931
+ * is an R3F-only intrinsic and `label` a DOM-only one, so ONE comment line
932
+ * made a fresh scaffold boot with a dialect-mixing diagnostic about a file
933
+ * with no JSX in it. A diagnostic channel agents are told to trust cannot
934
+ * cry wolf over prose.
935
+ *
936
+ * Single pass, no AST — the transform-hook perf bar stands. Known residual
937
+ * imprecision, accepted: a regex literal containing `//` or `/*` is misread
938
+ * as a comment and drops the rest of itself — which degrades toward "no
939
+ * evidence" (the safe default), never toward a fabricated tag.
940
+ */
941
+ function stripCommentsForScan(code: string): string {
942
+ const kept: string[] = [];
943
+ let i = 0;
944
+ while (i < code.length) {
945
+ const ch = code[i]!;
946
+ const next = code[i + 1];
947
+ if (ch === '/' && (next === '/' || next === '*')) {
948
+ // A space, not nothing: gluing a block comment's neighbors could mint
949
+ // a token that was never in the source.
950
+ if (next === '*') kept.push(' ');
951
+ i = commentEnd(code, i, next);
952
+ } else if (ch === "'" || ch === '"' || ch === '`') {
953
+ const end = stringLiteralEnd(code, i);
954
+ kept.push(code.slice(i, end));
955
+ i = end;
956
+ } else {
957
+ kept.push(ch);
958
+ i += 1;
959
+ }
960
+ }
961
+ return kept.join('');
962
+ }
963
+
964
+ /** End index (exclusive) of the comment opening at `start` — a `//` comment
965
+ * stops BEFORE its newline (the newline stays in the scanned text). */
966
+ function commentEnd(code: string, start: number, kind: '/' | '*'): number {
967
+ if (kind === '/') {
968
+ const newline = code.indexOf('\n', start + 2);
969
+ return newline === -1 ? code.length : newline;
970
+ }
971
+ const close = code.indexOf('*/', start + 2);
972
+ return close === -1 ? code.length : close + 2;
973
+ }
974
+
975
+ /** End index (exclusive) of the string literal opening at `start`, honoring
976
+ * backslash escapes; an unterminated literal runs to the end of the file. */
977
+ function stringLiteralEnd(code: string, start: number): number {
978
+ const quote = code[start]!;
979
+ let i = start + 1;
980
+ while (i < code.length) {
981
+ const ch = code[i]!;
982
+ if (ch === '\\') i += 2;
983
+ else if (ch === quote) return i + 1;
984
+ else i += 1;
985
+ }
986
+ return code.length;
987
+ }
988
+
989
+ /** Scan one file's source for self-contained evidence of its JSX dialect. */
990
+ export function sourceDialectEvidence(code: string): SourceDialectEvidence {
991
+ const scanned = stripCommentsForScan(code);
992
+ return {
993
+ reconcilerImport:
994
+ scanned.includes('@react-three/fiber') ||
995
+ scanned.includes('@vgai/game-runtime/world3d-react'),
996
+ r3fOnlyTags: distinctTags(scanned, R3F_ONLY_INTRINSIC_RE),
997
+ domOnlyTags: distinctTags(scanned, DOM_ONLY_INTRINSIC_RE),
998
+ };
999
+ }
1000
+
1001
+ /** True when a file's own bytes prove it authors for the R3F reconciler. */
1002
+ export function sourceProvesR3f(evidence: SourceDialectEvidence): boolean {
1003
+ return evidence.reconcilerImport || evidence.r3fOnlyTags.length > 0;
1004
+ }
1005
+
1006
+ /**
1007
+ * THE SOURCE-ID ATTRIBUTE for one file, decided by its REGION and nothing else.
1008
+ *
1009
+ * 1. **The region (authoritative).** `surface` is the answer
1010
+ * `file-region-resolver.ts` gave for THIS file — a declaration the game
1011
+ * made (a region `include` glob, a region `mounts` entry, or the manifest
1012
+ * root whose `entry` this file is) or the region whose import closure
1013
+ * reaches it. `'three'` stamps `userData-oid`; `'canvas'`/`'dom'` stamp
1014
+ * `data-oid`. It is resolved from OUTSIDE the file, exactly where the fact
1015
+ * lives, which is what makes it right for a world whose entry
1016
+ * default-exports a component and imports NEITHER `@react-three/fiber` nor
1017
+ * `@vgai/game-runtime/world3d-react` (legal: R3F's global JSX-intrinsics augmentation,
1018
+ * or an entry composed entirely of already-typed child components, needs no
1019
+ * import in THIS file). Every call site threads it from a context that
1020
+ * resolved it per file: `binding-resolver.ts`'s per-surface resolvers, the
1021
+ * browser bundle's own per-file resolution
1022
+ * (`browser-transpile.ts`), and the dev server's
1023
+ * `server/project-root-surface.ts` — shared with `classifyProjectHotUpdate`,
1024
+ * the HMR half of this same decision, so the two cannot drift apart by hand.
1025
+ *
1026
+ * 2. **The DEFAULT.** When `surface` is `undefined` — no region placed the
1027
+ * file (an ad-hoc file with no `vgai.project.json` above it, or a module no
1028
+ * region's import closure reaches and none declares) — the answer is
1029
+ * `data-oid`, the documented default, and the CALLER says so out loud
1030
+ * (`server/project-root-surface.ts`'s `OID001`/`OID002`). There is
1031
+ * deliberately no source-text rung: a probe over a file's own bytes answered
1032
+ * for a file it could not place, silently, and being wrong that way is
1033
+ * indistinguishable from being right. Zero inference (ARCHITECTURE-CORE
1034
+ * §The editor protocol) makes the fix a declaration — an `include` glob or
1035
+ * a `mounts` entry in `vgai.adapter.ts` — not a better heuristic.
1036
+ *
1037
+ * Nothing about a file's own bytes outranks the region — not even a file that
1038
+ * renders THREE host elements while its region says `dom`. That disagreement is
1039
+ * FATAL rather than noisy (`data-oid` on a THREE object pierces inside fiber,
1040
+ * writes `object.data`, and throws on the next apply), which is exactly why it
1041
+ * is reported at full volume as declared-vs-measured DRIFT
1042
+ * (`server/project-root-surface.ts`'s `OID003`) and repaired by fixing the
1043
+ * declaration. A stamp derived from the bytes instead would make the
1044
+ * declaration untestable, which is how a wrong one stays invisible.
1045
+ *
1046
+ * Lives HERE, beside the transform it parameterizes, rather than in
1047
+ * `vite-plugin-ui-oid.ts` (which re-exports it for its existing callers):
1048
+ * a caller in a page reaches the same `transformSource`, and that path cannot
1049
+ * import a `node:fs`-bearing module.
1050
+ */
1051
+ export function oidAttributeForSurface(
1052
+ declaredSurface?: DeclaredRootSurface,
1053
+ ): 'data-oid' | 'userData-oid' {
1054
+ return declaredSurface === 'three' ? 'userData-oid' : 'data-oid';
1055
+ }
1056
+
1057
+ /**
1058
+ * R3F host tags whose three class constructs WITHOUT a `userData` object, so the
1059
+ * editor's `userData-*` stamps have no parent to pierce through unless one is
1060
+ * seeded. Every Object3D, material, geometry and texture has its own, and a
1061
+ * custom `extend` host is taken to be one of those.
1062
+ */
1063
+ const SEEDED_USER_DATA_TAGS = new Set<string>([
1064
+ 'bufferAttribute',
1065
+ 'float16BufferAttribute',
1066
+ 'float32BufferAttribute',
1067
+ 'int8BufferAttribute',
1068
+ 'int16BufferAttribute',
1069
+ 'int32BufferAttribute',
1070
+ 'uint8BufferAttribute',
1071
+ 'uint8ClampedBufferAttribute',
1072
+ 'uint16BufferAttribute',
1073
+ 'uint32BufferAttribute',
1074
+ 'instancedBufferAttribute',
1075
+ 'interleavedBuffer',
1076
+ 'interleavedBufferAttribute',
1077
+ 'instancedInterleavedBuffer',
1078
+ 'color',
1079
+ 'vector2',
1080
+ 'vector3',
1081
+ 'vector4',
1082
+ 'euler',
1083
+ 'quaternion',
1084
+ 'matrix3',
1085
+ 'matrix4',
1086
+ 'fog',
1087
+ 'fogExp2',
1088
+ 'box2',
1089
+ 'box3',
1090
+ 'sphere',
1091
+ 'plane',
1092
+ 'ray',
1093
+ 'line3',
1094
+ 'triangle',
1095
+ 'spherical',
1096
+ 'cylindrical',
1097
+ 'layers',
1098
+ ]);
1099
+
1100
+ /** Options for {@link transformSource}. */
1101
+ export interface TransformSourceOptions {
1102
+ /**
1103
+ * The attribute name stamped on every JSX element (default `data-oid`).
1104
+ * R3F-dialect files (spike W1) pass `userData-oid` instead:
1105
+ * react-three-fiber's dashed-prop piercing writes `object.userData.oid`
1106
+ * on the constructed Object3D at reconcile time — zero runtime cost, and
1107
+ * the editor's R3F authoring adapter reads it back off the live scene
1108
+ * graph. The OID→{file,line,col} index is attribute-agnostic (keyed by
1109
+ * oid only), so every `/__ui-source/*` endpoint works unchanged for
1110
+ * either dialect.
1111
+ */
1112
+ attribute?: string;
1113
+ /** Contracts for ordinary project-local imported component bindings. */
1114
+ importedR3fContracts?: ReadonlyMap<string, R3fComponentContract>;
1115
+ /** The prop-shape type aliases those same imports bring into scope. A
1116
+ * project that writes its canonical prop shape once and imports it is
1117
+ * otherwise read as forwarding nothing. */
1118
+ importedR3fTypeAliases?: ReadonlyMap<string, R3fTypeAlias>;
1119
+ /** The file is mounted by @pixi/react. Local component callsites propagate
1120
+ * their OID onto the component's first native Pixi host, exactly as R3F
1121
+ * does with userData, but through ordinary data-* constructor fields. */
1122
+ canvasComponents?: boolean;
1123
+ }
1124
+
1125
+ /**
1126
+ * The local names bound to drei's `<Html>` in this file — the ONE seam where a
1127
+ * three-surface file renders through **react-dom** instead of the R3F
1128
+ * reconciler. `<Html>` creates a real `<div>`, mounts a `createRoot` on it and
1129
+ * renders its children there, so every host element inside it is a DOM element
1130
+ * and the R3F-pierced identity props this transform stamps on host elements
1131
+ * (`userData-oid`, `userData-authoringInstance`, `userData-authoringLabel`,
1132
+ * and the `userData={{}}` seed) have nowhere to land.
1133
+ *
1134
+ * Measured: the starter template's own `BotHud` — screen-pinned dev chrome
1135
+ * built on `<Html>`, the documented way to put DOM in a Three world — logged
1136
+ * FOUR `React does not recognize the \`userData-…\` prop on a DOM element`
1137
+ * errors on every bot run, one per stamped prop. Inside an `<Html>` subtree we
1138
+ * therefore stamp `data-oid`, byte-for-byte what a `dom` root gets.
1139
+ *
1140
+ * Lexical by design: this reads the subtree written in THIS file, which is the
1141
+ * whole extent the transform can see. A component defined elsewhere and
1142
+ * rendered inside `<Html>` is transformed by its own pass against its own
1143
+ * declared surface.
1144
+ */
1145
+ function dreiHtmlLocalNames(sf: ts.SourceFile): Set<string> {
1146
+ const names = new Set<string>();
1147
+ for (const statement of sf.statements) {
1148
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
1149
+ continue;
1150
+ }
1151
+ const specifier = statement.moduleSpecifier.text;
1152
+ if (specifier !== '@react-three/drei' && !specifier.startsWith('@react-three/drei/')) continue;
1153
+ const bindings = statement.importClause?.namedBindings;
1154
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
1155
+ for (const element of bindings.elements) {
1156
+ if ((element.propertyName?.text ?? element.name.text) === 'Html')
1157
+ names.add(element.name.text);
1158
+ }
1159
+ }
1160
+ return names;
1161
+ }
1162
+
1163
+ /**
1164
+ * Local JSX names bound by an import from an INSTALLED PACKAGE — every
1165
+ * specifier that is not relative (`./x`, `../x`), which is exactly the line
1166
+ * `r3f-project-contracts.ts`'s `resolveRelativeModule` already draws between
1167
+ * "source this editor walks" and "a dependency it never guesses about".
1168
+ *
1169
+ * Why the callsite stamp must skip them: `__vgaiOid`/`__vgaiLabel` are a
1170
+ * CONTRACT BETWEEN TWO HALVES OF THIS TRANSFORM. The callsite half writes
1171
+ * them; the only thing that ever reads them is the callee half —
1172
+ * `componentInstanceExpressions` destructuring them out of the component's own
1173
+ * parameters and re-emitting them as `userData-authoringInstance` on that
1174
+ * component's host elements. Nothing anywhere reads them off a React instance
1175
+ * or a DOM node. A package module is never served through this transform, so
1176
+ * the callee half never runs for it: the props have no reader by construction,
1177
+ * and the only thing they can still do is escape.
1178
+ *
1179
+ * And they do. Measured on the vendored `racing-game` ingest: `App.tsx` is a
1180
+ * MIXED file (an R3F `Scene` and a DOM `Hud` in one module, so the file's one
1181
+ * dialect is `userData-oid`), and its `<Canvas>` — `@react-three/fiber`'s own,
1182
+ * which spreads its rest props onto the `<div>` it wraps the canvas in — put
1183
+ * `__vgaiOid` and `__vgaiLabel` straight into the DOM, for two permanent
1184
+ * `React does not recognize the \`__vgaiOid\` prop on a DOM element` errors on
1185
+ * every mount of a real game. Nothing was lost by not stamping it, because
1186
+ * nothing was reading it.
1187
+ *
1188
+ * The one package component that DOES carry editor identity earns it through
1189
+ * the hand-audited contract table instead (`BUILTIN_R3F_CONTRACTS`), which
1190
+ * describes the native root the package forwards to — a claim about a specific
1191
+ * package's public API, never a guess made at a callsite.
1192
+ */
1193
+ function packageImportedLocalNames(sf: ts.SourceFile): Set<string> {
1194
+ const names = new Set<string>();
1195
+ for (const statement of sf.statements) {
1196
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
1197
+ continue;
1198
+ }
1199
+ if (statement.moduleSpecifier.text.startsWith('.')) continue;
1200
+ const clause = statement.importClause;
1201
+ if (!clause) continue;
1202
+ if (clause.name) names.add(clause.name.text);
1203
+ const bindings = clause.namedBindings;
1204
+ if (!bindings) continue;
1205
+ if (ts.isNamespaceImport(bindings)) {
1206
+ names.add(bindings.name.text);
1207
+ continue;
1208
+ }
1209
+ for (const element of bindings.elements) names.add(element.name.text);
1210
+ }
1211
+ return names;
1212
+ }
1213
+
1214
+ /** Drei cameras forward these props to their native Three camera. */
1215
+ function dreiCameraLocalNames(sf: ts.SourceFile): Set<string> {
1216
+ const names = new Set<string>();
1217
+ const cameras = ['PerspectiveCamera', 'OrthographicCamera'];
1218
+ for (const statement of sf.statements) {
1219
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
1220
+ continue;
1221
+ const specifier = statement.moduleSpecifier.text;
1222
+ if (
1223
+ specifier !== '@react-three/drei' &&
1224
+ !cameras.some((name) => specifier === `@react-three/drei/core/${name}`)
1225
+ )
1226
+ continue;
1227
+ const bindings = statement.importClause?.namedBindings;
1228
+ if (bindings && ts.isNamespaceImport(bindings)) {
1229
+ for (const camera of cameras) names.add(`${bindings.name.text}.${camera}`);
1230
+ } else if (bindings && ts.isNamedImports(bindings)) {
1231
+ for (const element of bindings.elements) {
1232
+ if (cameras.includes(element.propertyName?.text ?? element.name.text))
1233
+ names.add(element.name.text);
1234
+ }
1235
+ }
1236
+ }
1237
+ return names;
1238
+ }
1239
+
1240
+ /**
1241
+ * Component-shaped JSX names bound by a function PARAMETER — the second way a
1242
+ * tag's callee is unknowable at transform time, and the same hazard as
1243
+ * {@link packageImportedLocalNames} for exactly the same reason.
1244
+ *
1245
+ * `__vgaiOid`/`__vgaiLabel` are a contract between two halves of this
1246
+ * transform: the callsite half writes them, the callee half destructures them
1247
+ * out of the component's own parameters. A tag bound to a parameter names
1248
+ * WHATEVER the caller passed, so this module cannot know whether the callee
1249
+ * half ever ran for it — and where it did not, the props' only remaining
1250
+ * behaviour is to escape into the DOM through any rest-prop spread.
1251
+ *
1252
+ * MEASURED on the vendored `racing-game` ingest, against the SERVED modules:
1253
+ * `useToggle.tsx`'s `<ToggledComponent {...props} />` (its `ToggledComponent`
1254
+ * is a parameter — drei's `Stats`/`OrbitControls`, cannon's `Debug`, or a
1255
+ * project component, depending on the call) and every R3F `*.stories.tsx`
1256
+ * decorator's `<Story />`. Two standing
1257
+ * `React does not recognize the \`__vgaiOid\` prop on a DOM element` errors on
1258
+ * every run of the acceptance portfolio.
1259
+ *
1260
+ * Nothing is lost by not stamping, and identity actually IMPROVES: a forwarder
1261
+ * spreads the caller's own props first, so the oid the REAL callsite wrote
1262
+ * (`<ToggledMap/>` in `App.tsx`) now reaches the wrapped component instead of
1263
+ * being overwritten by a literal naming the forwarder's own line.
1264
+ *
1265
+ * Scope is the whole file rather than lexical scope on purpose: a name that is
1266
+ * a parameter ANYWHERE in a module is not a stable component identity to stamp
1267
+ * against, and a per-scope walk would buy precision this decision does not use.
1268
+ */
1269
+ function parameterBoundLocalNames(sf: ts.SourceFile): Set<string> {
1270
+ const names = new Set<string>();
1271
+ const addBinding = (name: ts.BindingName): void => {
1272
+ if (ts.isIdentifier(name)) {
1273
+ if (/^[A-Z]/.test(name.text)) names.add(name.text);
1274
+ return;
1275
+ }
1276
+ for (const element of name.elements) {
1277
+ if (ts.isBindingElement(element)) addBinding(element.name);
1278
+ }
1279
+ };
1280
+ const visit = (node: ts.Node): void => {
1281
+ if (ts.isFunctionLike(node)) {
1282
+ for (const parameter of node.parameters) addBinding(parameter.name);
1283
+ }
1284
+ ts.forEachChild(node, visit);
1285
+ };
1286
+ visit(sf);
1287
+ return names;
1288
+ }
1289
+
1290
+ /** JSX spellings that resolve to React's transparent Fragment. A Fragment is
1291
+ * composition, not a host node: it accepts only key/children, has no runtime
1292
+ * element to select, and must never receive an OID prop. */
1293
+ function reactFragmentNames(sf: ts.SourceFile): Set<string> {
1294
+ const names = new Set(['Fragment', 'React.Fragment']);
1295
+ for (const statement of sf.statements) {
1296
+ if (
1297
+ !ts.isImportDeclaration(statement) ||
1298
+ !ts.isStringLiteral(statement.moduleSpecifier) ||
1299
+ statement.moduleSpecifier.text !== 'react'
1300
+ ) {
1301
+ continue;
1302
+ }
1303
+ const clause = statement.importClause;
1304
+ if (clause?.name) names.add(`${clause.name.text}.Fragment`);
1305
+ const bindings = clause?.namedBindings;
1306
+ if (bindings && ts.isNamespaceImport(bindings)) {
1307
+ names.add(`${bindings.name.text}.Fragment`);
1308
+ } else if (bindings && ts.isNamedImports(bindings)) {
1309
+ for (const element of bindings.elements) {
1310
+ if ((element.propertyName?.text ?? element.name.text) === 'Fragment') {
1311
+ names.add(element.name.text);
1312
+ }
1313
+ }
1314
+ }
1315
+ }
1316
+ return names;
1317
+ }
1318
+
1319
+ /**
1320
+ * Transform source: insert `data-oid="…"` (or `opts.attribute="…"`) on every
1321
+ * JSX element and register the index. In the R3F dialect, a custom component
1322
+ * callsite receives editor-only props whose values are propagated onto the
1323
+ * component's existing host Object3Ds. Every transformed project component,
1324
+ * on every surface, consumes those props at its parameter boundary so a
1325
+ * cross-surface component or ordinary `{...props}` spread cannot forward them
1326
+ * into game DOM. That preserves per-instance source identity without inserting
1327
+ * a wrapper into the native tree. Returns the transformed code + index entries.
1328
+ */
1329
+ export function transformSource(
1330
+ code: string,
1331
+ file: string,
1332
+ store: OidStore,
1333
+ opts?: TransformSourceOptions,
1334
+ ): { code: string; entries: OidEntry[] } {
1335
+ const sf = parseAuthoringTsx(file, code);
1336
+ store.clearFile(file);
1337
+ const attribute = opts?.attribute ?? 'data-oid';
1338
+ const projectsComponentInstances =
1339
+ attribute === 'userData-oid' || opts?.canvasComponents === true;
1340
+ const sourceEdits: SourceEdit[] = [];
1341
+ const entries: OidEntry[] = [];
1342
+ const r3fComponentContracts =
1343
+ attribute === 'userData-oid'
1344
+ ? collectR3fComponentContracts(sf, opts?.importedR3fContracts, opts?.importedR3fTypeAliases)
1345
+ : new Map();
1346
+ const physicsBindings =
1347
+ attribute === 'userData-oid'
1348
+ ? physicsBindingsByElement(sf)
1349
+ : new Map<ts.JsxOpeningLikeElement, R3fPhysicsBinding>();
1350
+ const jointBindings =
1351
+ attribute === 'userData-oid'
1352
+ ? jointBindingsByElement(sf)
1353
+ : new Map<ts.JsxOpeningLikeElement, readonly R3fJointBinding[]>();
1354
+ const particleBindings =
1355
+ attribute === 'userData-oid'
1356
+ ? particleBindingsByElement(sf)
1357
+ : new Map<ts.JsxOpeningLikeElement, R3fParticleBinding>();
1358
+ const lodBindings =
1359
+ attribute === 'userData-oid'
1360
+ ? lodBindingsByElement(sf)
1361
+ : new Map<ts.JsxOpeningLikeElement, R3fLodBinding>();
1362
+ const environmentBindings =
1363
+ attribute === 'userData-oid'
1364
+ ? environmentBindingsByElement(sf)
1365
+ : new Map<ts.JsxOpeningLikeElement, R3fEnvironmentBinding>();
1366
+ const componentStack: Array<{
1367
+ name: string;
1368
+ instanceExpression: string;
1369
+ labelExpression: string;
1370
+ }> = [];
1371
+ // per (component:tag) occurrence counters for stable signatures
1372
+ const occ = new Map<string, number>();
1373
+
1374
+ const componentInstanceExpressions = (
1375
+ parameters: ts.NodeArray<ts.ParameterDeclaration>,
1376
+ ): { instanceExpression: string; labelExpression: string } => {
1377
+ const first = parameters[0];
1378
+ if (!first) {
1379
+ // Default the whole binding. Isolation/preview/bake call a stamped
1380
+ // export as a factory (`MainScene()` / `WOOD()`) with no args; a
1381
+ // required destructure of `undefined` is `Cannot destructure property
1382
+ // '__vgaiOid'`. React still passes `{}` at a real callsite.
1383
+ sourceEdits.push({ pos: parameters.pos, text: '{ __vgaiOid, __vgaiLabel } = {}' });
1384
+ return { instanceExpression: '__vgaiOid', labelExpression: '__vgaiLabel' };
1385
+ }
1386
+ if (ts.isObjectBindingPattern(first.name)) {
1387
+ sourceEdits.push({
1388
+ pos: first.name.getStart(sf) + 1,
1389
+ text: '__vgaiOid, __vgaiLabel, ',
1390
+ });
1391
+ // After the WHOLE parameter, including a type annotation:
1392
+ // `({ name, ...props }: T)` must become `({ __vgaiOid, …, name, ...props }: T = {})`.
1393
+ // Inserting at the binding's end produced `} = {}: T`, which is not
1394
+ // TypeScript and broke every typed scene export the look verb imports.
1395
+ if (!first.initializer) {
1396
+ sourceEdits.push({ pos: first.getEnd(), text: ' = {}' });
1397
+ }
1398
+ return { instanceExpression: '__vgaiOid', labelExpression: '__vgaiLabel' };
1399
+ }
1400
+ if (ts.isIdentifier(first.name) && !first.dotDotDotToken) {
1401
+ // Preserve the authored identifier as the REST binding. Code below the
1402
+ // parameter continues to read `props`, but it now sees only game props:
1403
+ // `function Card(props: Props)` becomes
1404
+ // `function Card({ __vgaiOid, __vgaiLabel, ...props }: Props)`.
1405
+ // Remove an optional marker while replacing the identifier; a binding
1406
+ // pattern cannot itself be optional, so its equivalent is a `{}` default.
1407
+ sourceEdits.push({
1408
+ pos: first.name.getStart(sf),
1409
+ end: first.questionToken?.end ?? first.name.getEnd(),
1410
+ text: `{ __vgaiOid, __vgaiLabel, ...${first.name.text} }`,
1411
+ });
1412
+ if (!first.initializer) {
1413
+ sourceEdits.push({ pos: first.getEnd(), text: ' = {}' });
1414
+ }
1415
+ return { instanceExpression: '__vgaiOid', labelExpression: '__vgaiLabel' };
1416
+ }
1417
+ return { instanceExpression: '__vgaiOid', labelExpression: '__vgaiLabel' };
1418
+ };
1419
+
1420
+ const pushComponent = (
1421
+ name: string | undefined,
1422
+ parameters: ts.NodeArray<ts.ParameterDeclaration> | undefined,
1423
+ ): boolean => {
1424
+ if (!name || !/^[A-Z]/.test(name) || !parameters) return false;
1425
+ // Component membership participates in the stable OID signature on every
1426
+ // JSX dialect. Three/Canvas callsites inject the transport props, while
1427
+ // every project component consumes them: a component declared on a DOM
1428
+ // mount can therefore be called safely from a Three-region module without
1429
+ // leaking editor metadata through its own rest props.
1430
+ componentStack.push({ name, ...componentInstanceExpressions(parameters) });
1431
+ return true;
1432
+ };
1433
+
1434
+ /** Local names of drei's `<Html>`, and how deep inside one we currently are —
1435
+ * see {@link dreiHtmlLocalNames}. Only meaningful in the R3F dialect; a
1436
+ * `data-oid` file already stamps what a DOM subtree wants. */
1437
+ const dreiHtmlNames = attribute === 'userData-oid' ? dreiHtmlLocalNames(sf) : new Set<string>();
1438
+ const dreiCameraNames =
1439
+ attribute === 'userData-oid' ? dreiCameraLocalNames(sf) : new Set<string>();
1440
+ /** Tags bound to an installed package — see {@link packageImportedLocalNames}.
1441
+ * Only meaningful in the R3F dialect: `data-oid` is a legal DOM attribute
1442
+ * and a package that spreads it renders it harmlessly. */
1443
+ const packageNames = projectsComponentInstances
1444
+ ? packageImportedLocalNames(sf)
1445
+ : new Set<string>();
1446
+ /** Tags bound to a function parameter — see {@link parameterBoundLocalNames}.
1447
+ * Same dialect gating and same reason as `packageNames`: only the transport
1448
+ * props can escape, and `data-oid` is a legal DOM attribute. */
1449
+ const parameterNames = projectsComponentInstances
1450
+ ? parameterBoundLocalNames(sf)
1451
+ : new Set<string>();
1452
+ const fragmentNames = reactFragmentNames(sf);
1453
+ let domSubtreeDepth = 0;
1454
+
1455
+ /** Oids of the JSX elements lexically enclosing the node being visited. */
1456
+ const jsxStack: string[] = [];
1457
+ /** Opening tag -> its oid, so the enclosing `JsxElement` can push it before
1458
+ * descending into the children that tag wraps. */
1459
+ const openedOid = new Map<ts.JsxOpeningElement, string>();
1460
+
1461
+ const visit = (node: ts.Node): void => {
1462
+ let pushed = false;
1463
+ if (ts.isFunctionDeclaration(node) && node.name)
1464
+ pushed = pushComponent(node.name.text, node.parameters);
1465
+ else if (
1466
+ ts.isVariableDeclaration(node) &&
1467
+ ts.isIdentifier(node.name) &&
1468
+ node.initializer &&
1469
+ (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))
1470
+ ) {
1471
+ pushed = pushComponent(node.name.text, node.initializer.parameters);
1472
+ }
1473
+
1474
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
1475
+ const tag = node.tagName.getText(sf);
1476
+ if (fragmentNames.has(tag)) {
1477
+ ts.forEachChild(node, visit);
1478
+ if (pushed) componentStack.pop();
1479
+ return;
1480
+ }
1481
+ const componentContext = componentStack[componentStack.length - 1];
1482
+ const component = componentContext?.name ?? null;
1483
+ const sigBase = `${file}:${component}:${tag}`;
1484
+ const n = occ.get(sigBase) ?? 0;
1485
+ occ.set(sigBase, n + 1);
1486
+ const sig = `${sigBase}:${n}`;
1487
+ const oid = store.getOrCreate(sig);
1488
+ // Inside a drei `<Html>` the reconciler is react-dom, not fiber — see
1489
+ // `dreiHtmlLocalNames`. Everything below reads this, not `attribute`.
1490
+ const elementAttribute = domSubtreeDepth > 0 ? 'data-oid' : attribute;
1491
+ const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
1492
+ const localContract =
1493
+ elementAttribute === 'userData-oid' && /^[A-Z]/.test(tag)
1494
+ ? r3fComponentContracts.get(tag)
1495
+ : undefined;
1496
+ const entry: OidEntry = {
1497
+ file,
1498
+ line: line + 1,
1499
+ col: character,
1500
+ component,
1501
+ tag,
1502
+ authoredProps: authoredPropsOf(node, sf),
1503
+ ...(localContract ? { r3fAuthoring: localContract } : {}),
1504
+ ...(physicsBindings.has(node) ? { physicsBinding: physicsBindings.get(node)! } : {}),
1505
+ ...(jointBindings.has(node) ? { jointBindings: jointBindings.get(node)! } : {}),
1506
+ ...(particleBindings.has(node) ? { particleBinding: particleBindings.get(node)! } : {}),
1507
+ ...(lodBindings.has(node) ? { lodBinding: lodBindings.get(node)! } : {}),
1508
+ ...(environmentBindings.has(node)
1509
+ ? { environmentBinding: environmentBindings.get(node)! }
1510
+ : {}),
1511
+ ...(jsxStack.length > 0 ? { parentOid: jsxStack[jsxStack.length - 1]! } : {}),
1512
+ };
1513
+ store.index.set(oid, entry);
1514
+ entries.push(entry);
1515
+ if (ts.isJsxOpeningElement(node)) openedOid.set(node, oid);
1516
+ const isProjectCustomComponent =
1517
+ (elementAttribute === 'userData-oid' || opts?.canvasComponents === true) &&
1518
+ /^[A-Z]/.test(tag);
1519
+ if (isProjectCustomComponent) {
1520
+ const element = ts.isJsxOpeningElement(node) ? node.parent : node;
1521
+ if (ts.isJsxElement(element) || ts.isJsxSelfClosingElement(element)) {
1522
+ // An installed package's module never passes through this transform,
1523
+ // so the callee half of the `__vgaiOid` contract never runs for it and
1524
+ // the props can only escape (into the DOM, via any package component
1525
+ // that spreads its rest props onto a host element). The callsite is
1526
+ // still INDEXED above — the source entry, its parent and its contract
1527
+ // are unchanged; only the unreadable runtime prop is withheld. See
1528
+ // {@link packageImportedLocalNames}.
1529
+ if (packageNames.has(tag.split('.')[0] ?? tag)) {
1530
+ // Detailed and the two Drei cameras spread their remaining props
1531
+ // onto a native Three host, where the ordinary R3F OID belongs.
1532
+ if (lodBindings.has(node) || dreiCameraNames.has(tag)) {
1533
+ sourceEdits.push({ pos: node.attributes.end, text: ` userData-oid="${oid}"` });
1534
+ }
1535
+ ts.forEachChild(node, visit);
1536
+ if (pushed) componentStack.pop();
1537
+ return;
1538
+ }
1539
+ // A tag bound to a PARAMETER names whatever the caller passed, so the
1540
+ // callee half may never have run for it either — same withholding,
1541
+ // same reason, and the callsite stays indexed. See
1542
+ // {@link parameterBoundLocalNames}.
1543
+ if (parameterNames.has(tag.split('.')[0] ?? tag)) {
1544
+ ts.forEachChild(node, visit);
1545
+ if (pushed) componentStack.pop();
1546
+ return;
1547
+ }
1548
+ const authoredName = node.attributes.properties.find(
1549
+ (property): property is ts.JsxAttribute =>
1550
+ ts.isJsxAttribute(property) && property.name.getText(sf) === 'name',
1551
+ );
1552
+ const label =
1553
+ authoredName?.initializer && ts.isStringLiteral(authoredName.initializer)
1554
+ ? authoredName.initializer.text
1555
+ : tag;
1556
+ const labelAttribute =
1557
+ authoredName?.initializer && ts.isJsxExpression(authoredName.initializer)
1558
+ ? `__vgaiLabel={${authoredName.initializer.expression?.getText(sf) ?? JSON.stringify(tag)}}`
1559
+ : `__vgaiLabel=${JSON.stringify(label)}`;
1560
+ sourceEdits.push({
1561
+ pos: node.attributes.end,
1562
+ text: ` __vgaiOid="${oid}" ${labelAttribute}`,
1563
+ });
1564
+ }
1565
+ } else {
1566
+ // Host elements carry their identity directly.
1567
+ // Append after authored attributes. In R3F, an authored `userData={{…}}`
1568
+ // replaces the whole object at its position in prop order; putting the
1569
+ // pierced editor keys last prevents that prop from erasing identity.
1570
+ sourceEdits.push({ pos: node.attributes.end, text: ` ${elementAttribute}="${oid}"` });
1571
+ if (elementAttribute === 'userData-oid' && componentContext) {
1572
+ sourceEdits.push({
1573
+ pos: node.attributes.end,
1574
+ text:
1575
+ ` userData-authoringInstance={${componentContext.instanceExpression}}` +
1576
+ ` userData-authoringLabel={${componentContext.labelExpression}}` +
1577
+ ` userData-authoringComponent=${JSON.stringify(componentContext.name)}`,
1578
+ });
1579
+ } else if (opts?.canvasComponents === true && componentContext) {
1580
+ sourceEdits.push({
1581
+ pos: node.attributes.end,
1582
+ text:
1583
+ ` data-authoring-instance={${componentContext.instanceExpression}}` +
1584
+ ` data-authoring-label={${componentContext.labelExpression}}` +
1585
+ ` data-authoring-component=${JSON.stringify(componentContext.name)}`,
1586
+ });
1587
+ }
1588
+ if (
1589
+ elementAttribute === 'userData-oid' &&
1590
+ // NEVER on `<primitive>`. Its object is the GAME's, constructed before
1591
+ // React ever sees it, and `userData={{}}` REPLACES that object's own
1592
+ // `userData` wholesale — measured: a root carrying
1593
+ // `markComponentRoot(node, 'Stage')` from its constructor came out of
1594
+ // mount with nothing but the three editor keys, so every hierarchy
1595
+ // mark a class-owned root sets was destroyed by the editor's own
1596
+ // serve-time transform. An adopted object always HAS a `userData`
1597
+ // (three sets one on every Object3D/geometry/material), which is
1598
+ // precisely why the initializer below is unnecessary here and
1599
+ // destructive. That initializer's real subjects are the resources R3F
1600
+ // itself constructs.
1601
+ tag !== 'primitive' &&
1602
+ // ONLY where the constructed object has no `userData` of its own. The
1603
+ // seed is a new `{}` on every render, and Fiber REPLACES `userData`
1604
+ // whenever that prop changes: seeded on an Object3D, a material or a
1605
+ // custom host, it wiped what the game kept there on each rerender
1606
+ // (measured: a mesh's `userData.probe`, set once on mount, gone after
1607
+ // 13 renders, leaving only the editor's keys).
1608
+ SEEDED_USER_DATA_TAGS.has(tag) &&
1609
+ !node.attributes.properties.some(
1610
+ (property) => ts.isJsxAttribute(property) && property.name.getText(sf) === 'userData',
1611
+ )
1612
+ ) {
1613
+ // Some valid R3F host resources (for example BufferAttribute) do not
1614
+ // start with a `userData` object. Current Fiber deliberately refuses
1615
+ // to pierce `userData-*` through an undefined parent, so initialize
1616
+ // it before the editor metadata props are applied.
1617
+ sourceEdits.push({ pos: node.attributes.end, text: ' userData={{}}' });
1618
+ }
1619
+ }
1620
+ }
1621
+
1622
+ // A `<Tag>…</Tag>` pair encloses its children, but in the AST the opening
1623
+ // tag is their SIBLING — so walk it FIRST (which mints its oid), then push
1624
+ // that oid while descending into the children it wraps. Doing this in the
1625
+ // generic `forEachChild` tail would read the oid before it exists.
1626
+ if (ts.isJsxElement(node)) {
1627
+ visit(node.openingElement);
1628
+ const enclosingOid = openedOid.get(node.openingElement);
1629
+ if (enclosingOid !== undefined) jsxStack.push(enclosingOid);
1630
+ // `<Html>` ITSELF stays an R3F callsite (drei mounts a `<group>` for it);
1631
+ // only what it wraps is DOM.
1632
+ const opensDomSubtree = dreiHtmlNames.has(node.openingElement.tagName.getText(sf));
1633
+ if (opensDomSubtree) domSubtreeDepth++;
1634
+ for (const child of node.children) visit(child);
1635
+ if (opensDomSubtree) domSubtreeDepth--;
1636
+ if (enclosingOid !== undefined) jsxStack.pop();
1637
+ visit(node.closingElement);
1638
+ if (pushed) componentStack.pop();
1639
+ return;
1640
+ }
1641
+
1642
+ ts.forEachChild(node, visit);
1643
+ if (pushed) componentStack.pop();
1644
+ };
1645
+ visit(sf);
1646
+
1647
+ // Apply edits back-to-front so every offset stays relative to source bytes.
1648
+ sourceEdits.sort((a, b) => b.pos - a.pos);
1649
+ let out = code;
1650
+ for (const edit of sourceEdits) {
1651
+ out = out.slice(0, edit.pos) + edit.text + out.slice(edit.end ?? edit.pos);
1652
+ }
1653
+ return { code: out, entries };
1654
+ }
1655
+
1656
+ /** 1-based line + 0-based col -> absolute char offset in `code`. */
1657
+ export function lineColToOffset(code: string, line: number, col: number): number {
1658
+ let offset = 0;
1659
+ let cur = 1;
1660
+ while (cur < line) {
1661
+ const nl = code.indexOf('\n', offset);
1662
+ if (nl === -1) return code.length;
1663
+ offset = nl + 1;
1664
+ cur += 1;
1665
+ }
1666
+ return offset + col;
1667
+ }