@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,1844 @@
1
+ /**
2
+ * Vite plugin: live OID instrumentation + source-write endpoints (C1/C2/C7/C8).
3
+ *
4
+ * The visual-edit "trick" made live for hand-authored React UI components: at dev
5
+ * time it stamps `data-oid` on every JSX element in files matched by `include` (a
6
+ * predicate, so the rest of the editor build is untouched), builds an OID→source
7
+ * index, and serves:
8
+ * GET /__ui-source/index → the whole OID index
9
+ * POST /__ui-source/write {oid,prop,value} → surgical inline-style edit to source
10
+ * POST /__ui-source/struct {oid,op} → structural source edit (delete)
11
+ * Edits run the SAME `writer.ts` used in unit tests, server-side, then Vite HMR
12
+ * reloads. `data-oid` exists only in transform output, never on disk.
13
+ *
14
+ * `include` widening (T6.2 slice 2): the original
15
+ * scope was ONLY `ui-editor/editable-components/*.tsx` (the visual-edit fixture dir,
16
+ * for `UIAuthoringAdapter`/`SourceEditPanel`). A react WORLD's entry graph is a real
17
+ * project's `.tsx` source — the mounted template project (`packages/editor/template/
18
+ * src/**`) or an externally-scaffolded project directory reached via the SAME
19
+ * dev-server `fs.allow` pipeline `server/dev.ts` already builds for project scripts
20
+ * (T3.3) — so `ReactRootAuthoringAdapter` needs those files instrumented too.
21
+ * `defaultProjectScopeInclude` widens to PROJECT scope, not repo-global: any `.tsx`
22
+ * file outside `node_modules`, EXCEPT the vgai tooling/engine source trees this repo
23
+ * itself is built from (`packages/engine/src`, `packages/create-vgai-project`,
24
+ * `packages/vgai-cli`, `packages/editor-sdk`, and `packages/editor/src` generally —
25
+ * carving OUT `ui-editor/editable-components` so the existing UI-edit-mode surface
26
+ * keeps working unchanged). This does NOT depend on which project happens to be
27
+ * open — `fs.allow` already bounds what Vite can even reach, and this predicate
28
+ * additionally keeps the vgai app's OWN react source un-instrumented.
29
+ *
30
+ * Vendored trees: a vendored GAME's source is STAMPED; every other `/vendor/`
31
+ * tree is not. `data-oid` stamping is what makes a `.tsx` file addressable —
32
+ * and, through `/__ui-source/write`, writable — so this predicate is where the
33
+ * question "may a vendored game be authored?" is actually decided.
34
+ *
35
+ * It used to answer no, for every path under a `/vendor/` segment, under the
36
+ * never-modify-vendored-source rule. The owner's code-or-data ruling
37
+ * superseded that rule (ARCHITECTURE-CORE §Editor "Every edit edits the game's
38
+ * own CODE or DATA", §Rules "there is no unwritable base"): ownership decides
39
+ * the RECORDER, not whether the edit may happen. A user's folder records in
40
+ * their git; a repo-vendored copy records in its own `UPSTREAM.lock`, written
41
+ * in the SAME gesture as the file by `server/vendored-lock-recorder.ts` and
42
+ * routed through {@link writeEditableSource} below. `verify-unaltered.mjs`'s
43
+ * bar was never zero diff — it is zero UNRECORDED diff — so it stays green
44
+ * with an authored edit present, as a patch that reverse-applies to upstream.
45
+ *
46
+ * What stays excluded is everything under `/vendor/` that is a DEPENDENCY
47
+ * rather than a game: `packages/threejs-runtime/vendor/realism-effects` (a vendored
48
+ * library) and the import analyzers' `vendor/` API dumps. See
49
+ * {@link VENDORED_GAME_SRC_RE} for the measured characterization behind that
50
+ * split.
51
+ *
52
+ * D-Y4 (wave 2, slice S3) —
53
+ * the write-back ban becomes IDENTITY-scoped, not just location-scoped: the
54
+ * `/vendor/` regex above only protects the repo's OWN vendored trees, but
55
+ * wave 2 lets an `ingest-react` world live in ANY external, user-owned
56
+ * folder (`vgai edit <folder>`) — an ingested-as-is foreign game there would
57
+ * otherwise be silently stamped/writable, reopening the never-modify-game-
58
+ * source rule by location rather than by identity. `nearestManifestExcludesIngestReact`
59
+ * walks up from a candidate `.tsx`'s own directory to the NEAREST
60
+ * `vgai.project.json`; if that manifest declares an `{ ingest }` react world,
61
+ * the whole manifest folder's subtree is excluded — exact, not coarse:
62
+ * `ingest-react` is single-world by construction (`binding-resolver.ts`'s
63
+ * `MULTI_WORLD_ALLOWED_IDENTITIES` exclusion), so "the manifest folder" IS
64
+ * the foreign game's whole tree, and D-N8 ships no authoring for it anyway —
65
+ * excluding it from stamping loses nothing. Cached per directory (hit AND
66
+ * miss, R-Y3) so a project with no manifest anywhere up the tree pays the
67
+ * walk cost once, ever. Malformed JSON at the manifest path is treated as
68
+ * "no exclusion" (never crash the transform hook over a broken file); a
69
+ * genuine fs error OTHER than "not found" (e.g. a permission error) is not
70
+ * swallowed — it propagates, same honesty bar the rest of this file holds.
71
+ */
72
+
73
+ import { createHash } from 'node:crypto';
74
+ import {
75
+ existsSync,
76
+ mkdirSync,
77
+ readdirSync,
78
+ readFileSync,
79
+ realpathSync,
80
+ } from 'node:fs';
81
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
82
+ import { Worker } from 'node:worker_threads';
83
+ import type { Plugin, ViteDevServer } from 'vite';
84
+ import { resolveManifestPath } from '@volter/editor-project/manifest/locate';
85
+ import {
86
+ type ProjectServingServices,
87
+ SOURCE_WRITE_ROUTES_PLUGIN,
88
+ } from '@volter/editor-sdk/session/project-serving';
89
+ import { cssTextForStyleValue } from '@volter/editor-sdk/css-numeric-style';
90
+ import {
91
+ type ComponentPropSpec,
92
+ lineColToOffset,
93
+ type OidEntry,
94
+ OidStore,
95
+ oidAttributeForSurface,
96
+ type R3fComponentContract,
97
+ transformSource,
98
+ } from '../src/source/oid-transform';
99
+ import { planDeleteStory, planRenameStory, planSaveStory } from '../src/source/plan-csf-story';
100
+ import { planComponentExtraction } from '../src/source/plan-extract-component';
101
+ import { planComponentFork } from '../src/source/plan-fork-component';
102
+ import { planCreateClassRule } from '../src/source/plan-named-style';
103
+ import {
104
+ applyStyleWriteRequest,
105
+ planSourceEdit,
106
+ sourceEditFiles,
107
+ } from '../src/source/plan-source-edit';
108
+ import {
109
+ type EntryDiagnosticSelector,
110
+ fileDiagnosticJoin,
111
+ } from '../src/source/r3f-diagnostic-index';
112
+ import {
113
+ importedR3fContracts,
114
+ r3fAuthoringDiagnostics,
115
+ visibleR3fContracts,
116
+ } from '../src/source/r3f-project-contracts';
117
+ import type { SourceEditRequest } from '../src/source/source-edit-request';
118
+ import {
119
+ detectUtilityClassSupport,
120
+ isUtilityClassEvidenceFile,
121
+ UTILITY_CLASS_EVIDENCE_FILES,
122
+ type UtilityClassSupport,
123
+ } from '../src/source/utility-class-support';
124
+ import {
125
+ addClassNameToken,
126
+ collectLiteralInlineStyles,
127
+ cssCamelToKebab,
128
+ deleteElements,
129
+ editTextContent,
130
+ findTagEnd,
131
+ getEditableText,
132
+ groupSiblingElements,
133
+ removeClassNameToken,
134
+ removeInlineStyle,
135
+ removePropAttribute,
136
+ surgicalCssEdit,
137
+ surgicalCssEditInMedia,
138
+ writePropChange,
139
+ } from '../src/source/writer';
140
+
141
+ // `applyStyleWriteRequest` and `oidAttributeForSurface` live in browser-safe
142
+ // modules (`plan-source-edit.ts` / `oid-transform.ts`), because a PAGE reaches
143
+ // the same transform; re-exported here so this file stays the stable import
144
+ // site for both.
145
+ export { applyStyleWriteRequest, oidAttributeForSurface };
146
+
147
+ const NODE_MODULES_RE = /\/node_modules\//;
148
+ const TOOLING_SRC_RE = /\/packages\/(engine\/src|create-vgai-project|vgai-cli|editor-sdk)\//;
149
+ const EDITOR_SRC_RE = /\/packages\/editor\/src\//;
150
+ const EDITABLE_COMPONENTS_RE = /\/ui-editor\/editable-components\/.*\.tsx$/;
151
+ /** Track N, D-N4 item 2 — see the module doc comment's "Vendored-tree exclusion". */
152
+ const VENDOR_RE = /\/vendor\//;
153
+ /**
154
+ * A vendored GAME's own source (`…/vendor/games/<id>/…`) — the ONE tree under
155
+ * a `/vendor/` segment that is a game rather than a dependency.
156
+ *
157
+ * The distinction is what {@link defaultProjectScopeInclude} needs and the
158
+ * blanket `VENDOR_RE` could not express. Characterized before it was carved,
159
+ * because "what else does this regex protect?" is the question a scope change
160
+ * has to answer: under every `/vendor/` root in this repo, the only `.tsx` at
161
+ * all is `vendor/games/racing-game`'s 35 files. The rest is
162
+ * `packages/threejs-runtime/vendor/realism-effects` (a vendored LIBRARY — built `dist`
163
+ * JS and shaders, a dependency we happen to ship in-tree) and the two import
164
+ * analyzers' `vendor/` API dumps (`packages/gd-analyze`,
165
+ * `packages/rbx-analyze` — JSON only). None of those is a game, none is
166
+ * authorable, and all of them stay excluded here.
167
+ *
168
+ * The Godot lane is ARCHIVED off main — `git fetch origin archive/godot-lane`, tag `archive/godot-lane-2026-09-19`.
169
+ */
170
+ const VENDORED_GAME_SRC_RE = /\/vendor\/games\/[^/]+\//;
171
+
172
+ /** True if a parsed `vgai.project.json` body declares at least one
173
+ * React root with an `{ ingest }` adapter — the `{ ingest }` shape alone is
174
+ * the answer, because every ingest-react root is D-N8 no-authoring.
175
+ * Defensive against
176
+ * any shape (never trusts the manifest is even an object) — this is a
177
+ * best-effort identity probe for a WRITE-BACK BAN, not manifest validation
178
+ * (the real Zod schema/loader is `@vgai/project/manifest/load`, not reachable
179
+ * from this vite-config-time, dependency-light file by design). */
180
+ function manifestDeclaresIngestReactWorld(parsed: unknown): boolean {
181
+ if (!parsed || typeof parsed !== 'object') return false;
182
+ const roots = (parsed as { roots?: unknown }).roots;
183
+ if (!Array.isArray(roots)) return false;
184
+ return roots.some((entry) => {
185
+ if (!entry || typeof entry !== 'object') return false;
186
+ const root = entry as { adapter?: unknown };
187
+ const { adapter } = root;
188
+ return (
189
+ !!adapter &&
190
+ typeof adapter === 'object' &&
191
+ (adapter as { surface?: unknown }).surface === 'dom' &&
192
+ 'ingest' in adapter
193
+ );
194
+ });
195
+ }
196
+
197
+ /** Cache: absolute directory -> whether ITS OWN ancestor walk (starting AT
198
+ * this directory) hit an ingest-react-declaring manifest. Cached both ways
199
+ * (hit and miss, R-Y3) — see the module doc comment's D-Y4 section. */
200
+ const manifestAncestorExclusionCache = new Map<string, boolean>();
201
+
202
+ function nearestManifestExcludesIngestReact(dir: string): boolean {
203
+ const cached = manifestAncestorExclusionCache.get(dir);
204
+ if (cached !== undefined) return cached;
205
+
206
+ const manifestPath = resolveManifestPath(dir);
207
+ let raw: string | undefined;
208
+ try {
209
+ raw = readFileSync(manifestPath, 'utf8');
210
+ } catch (err) {
211
+ // ENOENT (no manifest at this level) -> keep walking up. Any OTHER fs
212
+ // error (e.g. EACCES) is a real environment problem, not "no manifest
213
+ // here" — never silently swallowed.
214
+ if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err;
215
+ }
216
+
217
+ let result: boolean;
218
+ if (raw === undefined) {
219
+ const parent = dirname(dir);
220
+ result = parent === dir ? false : nearestManifestExcludesIngestReact(parent);
221
+ } else {
222
+ try {
223
+ result = manifestDeclaresIngestReactWorld(JSON.parse(raw));
224
+ } catch {
225
+ // Malformed JSON: honest "no exclusion" (stamping stays on) — never
226
+ // crash the transform hook over a broken manifest file.
227
+ result = false;
228
+ }
229
+ }
230
+ manifestAncestorExclusionCache.set(dir, result);
231
+ return result;
232
+ }
233
+
234
+ /**
235
+ * Cap 2 (React visual-edit parity): the scope guard for the `/__ui-source/css` endpoint.
236
+ * A CSS file path (from a stylesheet's `data-vite-dev-id`) is editable iff it is a
237
+ * first-party PROJECT `.css` file — same carve-outs as `defaultProjectScopeInclude`
238
+ * (never node_modules, vendored trees, the vgai tooling/engine source, or the editor's
239
+ * own source). Paths are normalized to forward slashes so a Windows dev-id matches. This
240
+ * is the CSS analogue of the `.tsx` stamping scope: an out-of-scope path must not become
241
+ * writable through a client-supplied `file`.
242
+ */
243
+ export function isEditableCssFile(id: string): boolean {
244
+ const clean = (id.split('?')[0] ?? id).replace(/\\/g, '/');
245
+ if (!clean.endsWith('.css')) return false;
246
+ if (NODE_MODULES_RE.test(clean)) return false;
247
+ if (VENDOR_RE.test(clean)) return false;
248
+ if (TOOLING_SRC_RE.test(clean)) return false;
249
+ if (EDITOR_SRC_RE.test(clean)) return false;
250
+ return true;
251
+ }
252
+
253
+ /** D-1 (Phase D, spec27 §2): sha256 hex digest — the checksum guard the
254
+ * `/__ui-source/restore` endpoint (below) uses to refuse a stale write. Only
255
+ * ever computed SERVER-SIDE: the client (`ReactRootAuthoringAdapter`) never
256
+ * hashes anything itself, it only echoes back a digest this server already
257
+ * handed it in a prior `writeStruct`/`restoreSource` response. */
258
+ function sha256(text: string): string {
259
+ return createHash('sha256').update(text, 'utf8').digest('hex');
260
+ }
261
+
262
+ /** Stable, registry-safe identity for a source file. Project files retain their
263
+ * human-readable relative path. An explicitly allowed file outside the Vite
264
+ * root gets a non-leaking synthetic path instead of an invalid `../…` key. */
265
+ function sourceResourcePath(file: string, projectRoot: string): string {
266
+ const path = relative(projectRoot, file).replace(/\\/g, '/');
267
+ if (path && path !== '..' && !path.startsWith('../') && !isAbsolute(path)) return path;
268
+ const identity = createHash('sha256').update(file).digest('hex').slice(0, 16);
269
+ return `.vgai/external-source/${identity}/${basename(file)}`;
270
+ }
271
+
272
+ /**
273
+ * Utility-class support is a property of the PACKAGE that owns the edited file,
274
+ * not of the editor session — an ingested game's HUD lives in the game's own
275
+ * tree, with its own `package.json` and its own build, and it is THAT build
276
+ * that would have to compile a `bg-[#059669]`. So the probe walks up from the
277
+ * file to the nearest enclosing `package.json` (falling back to the served
278
+ * project root) and reads the evidence `utility-class-support.ts` names.
279
+ *
280
+ * Cached per resolved package directory, like `propResolvers` above: the walk
281
+ * and the reads happen once per package per dev-server lifetime.
282
+ */
283
+ const utilityClassSupport = new Map<string, UtilityClassSupport>();
284
+
285
+ /** The nearest ancestor of `file` holding a `package.json`, else `fallback`. */
286
+ function owningPackageDir(file: string, fallback: string): string {
287
+ let dir = dirname(isAbsolute(file) ? file : resolve(fallback, file));
288
+ for (let depth = 0; depth < 24; depth++) {
289
+ if (existsSync(join(dir, 'package.json'))) return dir;
290
+ const parent = dirname(dir);
291
+ if (parent === dir) break;
292
+ dir = parent;
293
+ }
294
+ return fallback;
295
+ }
296
+
297
+ /** Evidence files under `dir` — the named configs plus a bounded css/html scan. */
298
+ function utilityClassEvidence(dir: string): Map<string, string> {
299
+ const files = new Map<string, string>();
300
+ const read = (absolute: string, relativePath: string): void => {
301
+ try {
302
+ files.set(relativePath, readFileSync(absolute, 'utf8'));
303
+ } catch {
304
+ // Unreadable evidence is simply absent evidence — never a crash, and
305
+ // never an upgrade to "supported".
306
+ }
307
+ };
308
+ for (const name of UTILITY_CLASS_EVIDENCE_FILES) {
309
+ const absolute = join(dir, name);
310
+ if (existsSync(absolute)) read(absolute, name);
311
+ }
312
+ // Bounded walk for the css/html entry markers (`@tailwind`, a CDN script):
313
+ // shallow, skipping the directories that never hold a project's own entry.
314
+ const skip = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.vgai', '.claude']);
315
+ let budget = 80;
316
+ const walk = (current: string, prefix: string, depth: number): void => {
317
+ if (depth > 3 || budget <= 0) return;
318
+ let entries: import('node:fs').Dirent[];
319
+ try {
320
+ entries = readdirSync(current, { withFileTypes: true });
321
+ } catch {
322
+ return;
323
+ }
324
+ for (const entry of entries) {
325
+ if (budget <= 0) return;
326
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
327
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
328
+ if (entry.isDirectory()) {
329
+ if (!skip.has(entry.name)) walk(join(current, entry.name), relativePath, depth + 1);
330
+ continue;
331
+ }
332
+ if (files.has(relativePath) || !isUtilityClassEvidenceFile(relativePath)) continue;
333
+ budget--;
334
+ read(join(current, entry.name), relativePath);
335
+ }
336
+ };
337
+ walk(dir, '', 0);
338
+ return files;
339
+ }
340
+
341
+ function utilityClassSupportFor(projectRoot: string, file: string): UtilityClassSupport {
342
+ const dir = owningPackageDir(file, projectRoot);
343
+ const cached = utilityClassSupport.get(dir);
344
+ if (cached) return cached;
345
+ const support = detectUtilityClassSupport(utilityClassEvidence(dir));
346
+ utilityClassSupport.set(dir, support);
347
+ return support;
348
+ }
349
+
350
+ /** Declared props are a property of the (file, tag) PAIR, not of each
351
+ * occurrence — six `<Enemy>` tags resolve the component once per index read. */
352
+ function declaredPropsOf(
353
+ propsByTag: ReadonlyMap<string, ComponentPropSpec[]>,
354
+ file: string,
355
+ tag: string,
356
+ ): ComponentPropSpec[] | undefined {
357
+ const key = `${file}::${tag}`;
358
+ return propsByTag.get(key);
359
+ }
360
+
361
+ /** Everything one project file contributes to an index read, resolved from its
362
+ * CURRENT bytes exactly once per read. */
363
+ interface IndexFileAnalysis {
364
+ /** Component contracts visible in this module — `null` for a file this
365
+ * server does not stamp as R3F (or could not read). */
366
+ readonly contracts: ReadonlyMap<string, R3fComponentContract> | null;
367
+ /** H5 — which of this file's authorability diagnostics pertain to a given
368
+ * entry of this file. Inert for a non-R3F/unreadable file. */
369
+ readonly selectDiagnostics: EntryDiagnosticSelector;
370
+ }
371
+
372
+ const INERT_FILE_ANALYSIS: IndexFileAnalysis = {
373
+ contracts: null,
374
+ selectDiagnostics: () => undefined,
375
+ };
376
+
377
+ /** The index grouped by file, each bucket keyed by oid — the shape
378
+ * `fileDiagnosticJoin` needs so an entry's `parentOid` resolves. */
379
+ function indexEntriesByFile(store: OidStore): Map<string, Map<string, OidEntry>> {
380
+ const byFile = new Map<string, Map<string, OidEntry>>();
381
+ for (const [oid, entry] of store.index) {
382
+ const bucket = byFile.get(entry.file);
383
+ if (bucket) bucket.set(oid, entry);
384
+ else byFile.set(entry.file, new Map([[oid, entry]]));
385
+ }
386
+ return byFile;
387
+ }
388
+
389
+ /**
390
+ * Read one file's current bytes and derive everything an index read wants from
391
+ * it. Unreadable (deleted/renamed since it was transformed) or not stamped as
392
+ * R3F ⇒ the inert analysis: its entries ship exactly as recorded, which is a
393
+ * degradation, never a failure of the whole index read.
394
+ *
395
+ * COST, and why it went DOWN rather than up when H5 landed: this runs at most
396
+ * once per file per index read (the caller memoizes), whereas the previous
397
+ * shape re-read and re-surface-resolved a file for EVERY capitalized-tag entry
398
+ * whose surface turned out not to be `userData-oid`. H5 adds one
399
+ * `r3fAuthoringDiagnostics` pass per R3F file — the same order of work as the
400
+ * `visibleR3fContracts` pass beside it, and it early-returns for any file
401
+ * without an R3F reconciler import.
402
+ */
403
+ function analyzeIndexFile(
404
+ file: string,
405
+ fileEntries: ReadonlyMap<string, OidEntry>,
406
+ moduleGraph: unknown,
407
+ ): IndexFileAnalysis {
408
+ let source: string;
409
+ try {
410
+ source = readFileSync(file, 'utf8');
411
+ } catch {
412
+ return INERT_FILE_ANALYSIS;
413
+ }
414
+ // The SAME decision the transform hook stamped with (task #45) — not a
415
+ // second derivation that could disagree with it.
416
+ if (serving().surfaceOf(file, source, moduleGraph).attribute !== 'userData-oid') {
417
+ return INERT_FILE_ANALYSIS;
418
+ }
419
+ return {
420
+ contracts: visibleR3fContracts(source, file),
421
+ // The surface check above already established this file renders through
422
+ // the R3F reconciler, which is a stronger answer than the source-text
423
+ // sniff the analyzer falls back on — and the one a project that imports
424
+ // its prop shape from a shared module can actually pass.
425
+ selectDiagnostics: fileDiagnosticJoin(
426
+ fileEntries,
427
+ r3fAuthoringDiagnostics(source, file, { knownThreeSurface: true }),
428
+ ),
429
+ };
430
+ }
431
+
432
+ /**
433
+ * One recorded entry plus everything the current bytes add to it: its
434
+ * component's authoring contract, its declared props, and (H5) the
435
+ * authorability diagnostics that pertain to it. Returns the entry ITSELF when
436
+ * there is nothing to add, so an unenriched index read allocates nothing.
437
+ *
438
+ * Note that the diagnostic lookup runs for EVERY entry, including a lowercase
439
+ * host tag: R3F002/3/5 are ABOUT a component definition, and a definition's
440
+ * root is usually an ordinary `<group>`/`<mesh>` — that entry is precisely the
441
+ * one an instance row reaches through its boundary object's own `userData.oid`.
442
+ */
443
+ function enrichIndexEntry(
444
+ entry: OidEntry,
445
+ analysis: IndexFileAnalysis,
446
+ propsByTag: ReadonlyMap<string, ComponentPropSpec[]>,
447
+ ): OidEntry {
448
+ const diagnostics = analysis.selectDiagnostics(entry);
449
+ const custom = analysis.contracts !== null && /^[A-Z]/.test(entry.tag);
450
+ const contract = custom ? analysis.contracts?.get(entry.tag) : undefined;
451
+ const props = custom ? declaredPropsOf(propsByTag, entry.file, entry.tag) : undefined;
452
+ // `props !== undefined` means the resolver ANSWERED for this tag — an empty
453
+ // answer included. The flag is what lets the inspector's fallback tell a
454
+ // pending cold resolver from a genuine absence (see OidEntry.propsResolved).
455
+ const propsResolved = props !== undefined;
456
+ if (!contract && !props?.length && !diagnostics && !propsResolved) return entry;
457
+ return {
458
+ ...entry,
459
+ ...(contract ? { r3fAuthoring: contract } : {}),
460
+ ...(props?.length ? { props } : {}),
461
+ ...(propsResolved ? { propsResolved: true as const } : {}),
462
+ ...(diagnostics ? { diagnostics } : {}),
463
+ };
464
+ }
465
+
466
+ function snapshotRawOidIndex(store: OidStore): Record<string, unknown> {
467
+ const current: Record<string, unknown> = {};
468
+ for (const [oid, entry] of store.index) current[oid] = entry;
469
+ return current;
470
+ }
471
+
472
+ function buildEnrichedOidIndex(
473
+ store: OidStore,
474
+ devServer?: ViteDevServer,
475
+ propsByTag: ReadonlyMap<string, ComponentPropSpec[]> = new Map(),
476
+ ): Record<string, unknown> {
477
+ const moduleGraph = devServer?.moduleGraph;
478
+ const entriesByFile = indexEntriesByFile(store);
479
+
480
+ const analyses = new Map<string, IndexFileAnalysis>();
481
+ const analysisFor = (file: string): IndexFileAnalysis => {
482
+ const cached = analyses.get(file);
483
+ if (cached) return cached;
484
+ const analysis = analyzeIndexFile(file, entriesByFile.get(file) ?? new Map(), moduleGraph);
485
+ analyses.set(file, analysis);
486
+ return analysis;
487
+ };
488
+
489
+ const current: Record<string, unknown> = {};
490
+ for (const [oid, entry] of store.index) {
491
+ current[oid] = enrichIndexEntry(entry, analysisFor(entry.file), propsByTag);
492
+ }
493
+ return current;
494
+ }
495
+
496
+ /**
497
+ * Props/tag/diagnostic enrich is the measured 2.6 s cold cost (repeat 391 ms)
498
+ * of first design attach: `ComponentPropResolver` builds a `ts.Program`. The Scene
499
+ * can render without it — oids, file/line, and tags are already on the raw
500
+ * stamp. Blocking GET /__ui-source/index on enrich froze the Vite thread
501
+ * (and every module load) for those seconds. Serve enrichment only for an
502
+ * unchanged source index; otherwise serve current stamps while it refreshes.
503
+ */
504
+ let lastEnrichedIndex: {
505
+ root: string | undefined;
506
+ sourceIndex: ReadonlyMap<string, OidEntry>;
507
+ index: Record<string, unknown>;
508
+ } | null = null;
509
+ let enrichQueued = false;
510
+ let propWorker: Worker | null = null;
511
+ let propRequestId = 0;
512
+ const pendingPropRequests = new Map<
513
+ number,
514
+ {
515
+ resolve: (props: ReadonlyMap<string, ComponentPropSpec[]>) => void;
516
+ reject: (error: Error) => void;
517
+ }
518
+ >();
519
+
520
+ function componentPropWorkerUrl(): URL {
521
+ // The serving module runs built (`dist-node/serving.mjs`), beside the worker built with it.
522
+ return new URL('./component-prop-worker.mjs', import.meta.url);
523
+ }
524
+
525
+ function rejectPendingPropRequests(error: Error): void {
526
+ for (const pending of pendingPropRequests.values()) pending.reject(error);
527
+ pendingPropRequests.clear();
528
+ }
529
+
530
+ function declaredPropWorker(): Worker {
531
+ if (propWorker) return propWorker;
532
+ const worker = new Worker(componentPropWorkerUrl(), { name: 'vgai-component-props' });
533
+ worker.on(
534
+ 'message',
535
+ (message: { id?: unknown; props?: Record<string, ComponentPropSpec[]>; error?: unknown }) => {
536
+ if (typeof message.id !== 'number') return;
537
+ const pending = pendingPropRequests.get(message.id);
538
+ if (!pending) return;
539
+ pendingPropRequests.delete(message.id);
540
+ if (typeof message.error === 'string') {
541
+ pending.reject(new Error(message.error));
542
+ return;
543
+ }
544
+ pending.resolve(new Map(Object.entries(message.props ?? {})));
545
+ },
546
+ );
547
+ worker.on('error', (error: unknown) => {
548
+ if (propWorker === worker) propWorker = null;
549
+ rejectPendingPropRequests(error instanceof Error ? error : new Error(String(error)));
550
+ });
551
+ worker.on('exit', (code) => {
552
+ if (propWorker === worker) propWorker = null;
553
+ if (code !== 0) {
554
+ rejectPendingPropRequests(
555
+ new Error(`Declared-prop worker exited before answering (status ${code}).`),
556
+ );
557
+ }
558
+ });
559
+ propWorker = worker;
560
+ return worker;
561
+ }
562
+
563
+ function propRequestsFor(store: OidStore): Array<{ key: string; file: string; tag: string }> {
564
+ const requests = new Map<string, { key: string; file: string; tag: string }>();
565
+ for (const entry of store.index.values()) {
566
+ if (!/^[A-Z]/.test(entry.tag)) continue;
567
+ const key = `${entry.file}::${entry.tag}`;
568
+ requests.set(key, { key, file: entry.file, tag: entry.tag });
569
+ }
570
+ return [...requests.values()];
571
+ }
572
+
573
+ function resolveDeclaredProps(
574
+ projectRoot: string | undefined,
575
+ requests: Array<{ key: string; file: string; tag: string; definition?: true }>,
576
+ ): Promise<ReadonlyMap<string, ComponentPropSpec[]>> {
577
+ if (!projectRoot || requests.length === 0) {
578
+ return new Promise((resolveRequest) => setImmediate(() => resolveRequest(new Map())));
579
+ }
580
+ const id = ++propRequestId;
581
+ return new Promise((resolveRequest, rejectRequest) => {
582
+ pendingPropRequests.set(id, { resolve: resolveRequest, reject: rejectRequest });
583
+ declaredPropWorker().postMessage({ id, projectRoot, requests });
584
+ });
585
+ }
586
+
587
+ function scheduleOidIndexEnrich(
588
+ store: OidStore,
589
+ projectRoot?: string,
590
+ devServer?: ViteDevServer,
591
+ ): void {
592
+ if (enrichQueued) return;
593
+ enrichQueued = true;
594
+ const sourceIndex = new Map(store.index);
595
+ const requests = propRequestsFor(store);
596
+ void resolveDeclaredProps(projectRoot, requests)
597
+ .then((propsByTag) => {
598
+ lastEnrichedIndex = {
599
+ root: projectRoot,
600
+ sourceIndex,
601
+ index: buildEnrichedOidIndex(store, devServer, propsByTag),
602
+ };
603
+ })
604
+ .catch((error) => {
605
+ // A failed semantic resolver never removes source authoring. Contracts
606
+ // and diagnostics still enrich on the server thread; declared props
607
+ // degrade to the attributes physically present in source.
608
+ process.stderr.write(
609
+ `[ui-oid] Declared-prop analysis unavailable; using source attributes: ${String(error)}\n`,
610
+ );
611
+ lastEnrichedIndex = {
612
+ root: projectRoot,
613
+ sourceIndex,
614
+ index: buildEnrichedOidIndex(store, devServer),
615
+ };
616
+ })
617
+ .finally(() => {
618
+ enrichQueued = false;
619
+ });
620
+ }
621
+
622
+ function currentOidIndex(
623
+ store: OidStore,
624
+ projectRoot?: string,
625
+ devServer?: ViteDevServer,
626
+ ): Record<string, unknown> {
627
+ scheduleOidIndexEnrich(store, projectRoot, devServer);
628
+ const cached = lastEnrichedIndex;
629
+ // Transforms replace entries even when an OID stays stable. Old locations
630
+ // must never be paired with the newly edited source while enrichment runs.
631
+ if (
632
+ cached &&
633
+ cached.root === projectRoot &&
634
+ cached.sourceIndex.size === store.index.size &&
635
+ [...store.index].every(([oid, entry]) => cached.sourceIndex.get(oid) === entry)
636
+ ) {
637
+ return cached.index;
638
+ }
639
+ return snapshotRawOidIndex(store);
640
+ }
641
+
642
+ /** Test-only: wait for the in-flight background enrich, if any. */
643
+ export function __flushOidIndexEnrichForTest(): Promise<void> {
644
+ return new Promise((resolve) => {
645
+ const finish = (): void => {
646
+ if (!enrichQueued) {
647
+ resolve();
648
+ return;
649
+ }
650
+ setImmediate(finish);
651
+ };
652
+ setImmediate(finish);
653
+ });
654
+ }
655
+
656
+ /** Test-only reset. */
657
+ export function __resetOidIndexEnrichForTest(): void {
658
+ lastEnrichedIndex = null;
659
+ enrichQueued = false;
660
+ if (propWorker) {
661
+ void propWorker.terminate();
662
+ propWorker = null;
663
+ }
664
+ rejectPendingPropRequests(new Error('Declared-prop worker reset.'));
665
+ }
666
+
667
+ /** Test-only: whether the last GET returned without waiting for enrich. */
668
+ export function __oidIndexEnrichStateForTest(): { queued: boolean; hasEnriched: boolean } {
669
+ return { queued: enrichQueued, hasEnriched: lastEnrichedIndex !== null };
670
+ }
671
+
672
+ /**
673
+ * THE write point for every `/__ui-source/*` handler — and the reason a
674
+ * vendored game's source can be stamped without reopening silent drift.
675
+ *
676
+ * A source write lands in one of two recorders, decided by WHERE the file is,
677
+ * never by what the handler was doing:
678
+ *
679
+ * - inside a repo-vendored game → `writeRecordedVendoredFile`, which moves the
680
+ * file AND its `UPSTREAM.lock` entry as ONE journalled operation, so
681
+ * `verify-unaltered.mjs` is green with the edit present (the edit becomes a
682
+ * recorded patch that reverse-applies to upstream). Undo needs no special
683
+ * case: it comes back through here with the previous bytes, the lock
684
+ * reconciles from the bytes alone, and the edit that restores upstream's own
685
+ * content deletes the patch entry again.
686
+ * - anywhere else → the plain `writeFileSync` this file always did, because a
687
+ * user's own project records in the user's own git.
688
+ *
689
+ * Centralised deliberately. Nine handlers in this file write source; routing
690
+ * per-handler would mean nine chances to add a tenth that forgets, and a
691
+ * forgotten one is not a visible bug — it is a vendored tree that quietly
692
+ * stops matching its lock.
693
+ */
694
+ /** The kit's server capabilities this plugin was constructed with
695
+ * (`@volter/editor-sdk/session/project-serving`). */
696
+ let servingServices: ProjectServingServices | null = null;
697
+
698
+ function serving(): ProjectServingServices {
699
+ if (!servingServices) throw new Error('The React source-authoring plugin was used before the session constructed it.');
700
+ return servingServices;
701
+ }
702
+
703
+ /**
704
+ * THE read point for every `/__ui-source/*` handler: a crashed vendored write is rolled forward
705
+ * before its file is read, so no handler plans against bytes the recorder is about to replace.
706
+ * Welded to the read, the way the recorder is welded to the write, so no handler can forget it.
707
+ */
708
+ function readSettledSource(file: string): string {
709
+ serving().settleSource(file);
710
+ return readFileSync(file, 'utf8');
711
+ }
712
+
713
+ /**
714
+ * THE write point for every `/__ui-source/*` handler: the kit's own write (a vendored file is
715
+ * recorded against its lock, and the served modules it replaces are invalidated, so the next
716
+ * mount reads the new bytes rather than a transform the dev server still holds).
717
+ */
718
+ function writeEditableSource(file: string, code: string): void {
719
+ serving().writeSource(file, code);
720
+ }
721
+
722
+ /** Default `include` predicate (see the module doc comment above for the reasoning). */
723
+ export function defaultProjectScopeInclude(id: string): boolean {
724
+ const clean = (id.split('?')[0] ?? id).replace(/\\/g, '/');
725
+ if (!clean.endsWith('.tsx')) return false;
726
+ if (NODE_MODULES_RE.test(clean)) return false;
727
+ // A vendored GAME's source IS stamped; every other `/vendor/` tree is not.
728
+ // The old blanket exclusion was written under the never-modify-vendored-
729
+ // source rule, which the owner's code-or-data ruling superseded
730
+ // (ARCHITECTURE-CORE §Editor "Every edit edits the game's own CODE or DATA",
731
+ // §Rules "there is no unwritable base"): ownership no longer decides WHETHER
732
+ // an edit may be written, only WHO RECORDS it. For a repo-vendored copy the
733
+ // recorder is its own `UPSTREAM.lock`, written in the same gesture as the
734
+ // file (`server/vendored-lock-recorder.ts`), so the zero-UNRECORDED-diff bar
735
+ // holds WITH the edit present rather than by forbidding the edit. Stamping is
736
+ // what makes the game's own TSX literals addressable by the gizmo and the
737
+ // inspector; refusing it here is what used to make a vendored game
738
+ // permanently unauthorable.
739
+ if (VENDOR_RE.test(clean) && !VENDORED_GAME_SRC_RE.test(clean)) return false;
740
+ if (TOOLING_SRC_RE.test(clean)) return false;
741
+ if (EDITOR_SRC_RE.test(clean) && !EDITABLE_COMPONENTS_RE.test(clean)) return false;
742
+ if (nearestManifestExcludesIngestReact(dirname(clean))) return false;
743
+ return true;
744
+ }
745
+
746
+ function resolveEditableSourceFile(id: string, projectRoot: string): string | null {
747
+ const clean = (id.split('?')[0] ?? id).replace(/\\/g, '/');
748
+ const candidate = isAbsolute(clean) ? clean : resolve(projectRoot, clean);
749
+ if (defaultProjectScopeInclude(candidate) || isEditableCssFile(candidate)) return candidate;
750
+
751
+ // Whole-file transactions also serve project-owned model source. This is
752
+ // deliberately narrower than a general project-file API: TypeScript below
753
+ // this project's src/ tree, never vendored or an ingest-react identity.
754
+ const projectPath = relative(projectRoot, candidate).replace(/\\/g, '/');
755
+ if (
756
+ projectPath.startsWith('src/') &&
757
+ (projectPath.endsWith('.ts') || projectPath.endsWith('.tsx')) &&
758
+ !VENDOR_RE.test(candidate) &&
759
+ !nearestManifestExcludesIngestReact(dirname(candidate))
760
+ ) {
761
+ return candidate;
762
+ }
763
+ return null;
764
+ }
765
+
766
+ function isCanonicalProjectSourcePath(projectRoot: string, file: string): boolean {
767
+ const absolute = isAbsolute(file) ? resolve(file) : resolve(projectRoot, file);
768
+ let ancestor = absolute;
769
+ while (!existsSync(ancestor)) {
770
+ const next = dirname(ancestor);
771
+ if (next === ancestor) return false;
772
+ ancestor = next;
773
+ }
774
+ try {
775
+ const root = realpathSync(projectRoot);
776
+ const candidate = realpathSync(ancestor);
777
+ return candidate === root || candidate.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
778
+ } catch {
779
+ return false;
780
+ }
781
+ }
782
+
783
+ /** The JSON response a source-endpoint handler produces (status defaults to 200). */
784
+ export interface HandlerResult {
785
+ status?: number;
786
+ body: unknown;
787
+ }
788
+
789
+ function preparedSource(
790
+ file: string,
791
+ prevSource: string,
792
+ newSource: string,
793
+ result: Record<string, unknown>,
794
+ projectRoot: string,
795
+ ): HandlerResult {
796
+ const changed = prevSource !== newSource && result['changed'] !== false;
797
+ return {
798
+ body: {
799
+ changed,
800
+ file,
801
+ resourcePath: sourceResourcePath(file, projectRoot),
802
+ ...(changed
803
+ ? {
804
+ prevSource,
805
+ newSource,
806
+ prevSha: sha256(prevSource),
807
+ newSha: sha256(newSource),
808
+ }
809
+ : {}),
810
+ result: { ...result, changed, file },
811
+ },
812
+ };
813
+ }
814
+
815
+ /**
816
+ * Pure source edit preparation: computes exact bytes and hashes, never writes.
817
+ *
818
+ * The edit itself is planned by the tier-agnostic `planSourceEdit`
819
+ * (`src/ui-source/plan-source-edit.ts`) — this handler only supplies what is
820
+ * genuinely server-side: the `node:fs` reads, the css scope guard, sha256, and
821
+ * the project-relative resource path. The hosted browser backend supplies its
822
+ * own equivalents around the SAME planner, which is what keeps the two tiers
823
+ * from drifting into different edit semantics.
824
+ */
825
+ export function handlePrepare(
826
+ store: OidStore,
827
+ body: Record<string, unknown>,
828
+ projectRoot = process.cwd(),
829
+ ): HandlerResult {
830
+ const request = body as unknown as SourceEditRequest;
831
+ if (request.kind === 'css' && (!request.file || !isEditableCssFile(request.file))) {
832
+ return {
833
+ status: 403,
834
+ body: { changed: false, result: { error: 'css file out of editable scope' } },
835
+ };
836
+ }
837
+ const resolveOid = (oid: string) => store.index.get(oid);
838
+ const sources = new Map<string, string>();
839
+ const files = sourceEditFiles(request, resolveOid);
840
+ for (const file of files) {
841
+ sources.set(file, readSettledSource(file));
842
+ }
843
+ // The class-vs-inline gate is a question about the EDITED FILE's own project,
844
+ // so it is answered here (the tier that has `node:fs`) and handed to the pure
845
+ // planner — never inferred from the element's shape. Only a style request can
846
+ // route to a class, so nothing else pays for the probe.
847
+ const support =
848
+ request.kind === 'style' && files[0]
849
+ ? utilityClassSupportFor(projectRoot, files[0])
850
+ : undefined;
851
+ const plan = planSourceEdit(request, sources, resolveOid, support);
852
+ if (plan.file === null) return { body: { changed: false, result: plan.result } };
853
+ return preparedSource(plan.file, plan.prevSource, plan.newSource, plan.result, projectRoot);
854
+ }
855
+
856
+ export function handleSourceRead(
857
+ body: Record<string, unknown>,
858
+ projectRoot = process.cwd(),
859
+ ): HandlerResult {
860
+ const file = body['file'];
861
+ const resolved = typeof file === 'string' ? resolveEditableSourceFile(file, projectRoot) : null;
862
+ if (!resolved) {
863
+ return { status: 403, body: { error: 'file out of editable scope' } };
864
+ }
865
+ const source = readSettledSource(resolved);
866
+ return {
867
+ body: {
868
+ source,
869
+ sha: sha256(source),
870
+ resourcePath: sourceResourcePath(resolved, projectRoot),
871
+ },
872
+ };
873
+ }
874
+
875
+ export function handleSourceApply(
876
+ body: Record<string, unknown>,
877
+ projectRoot = process.cwd(),
878
+ ): HandlerResult {
879
+ const restored = handleRestore(body, projectRoot);
880
+ const value = restored.body as { restored: boolean; sha?: string; error?: string };
881
+ return {
882
+ ...(restored.status ? { status: restored.status } : {}),
883
+ body: { applied: value.restored, sha: value.sha, error: value.error },
884
+ };
885
+ }
886
+
887
+ /** POST `/__ui-source/write` — surgical inline-style/className edit (F5 routing).
888
+ * D-A3 (wave 13): a `value: null` body is the REMOVAL sentinel — routed via
889
+ * `applyStyleWriteRequest` to `removeInlineStyle` (append-aware undo); `appended`
890
+ * is forwarded so the client's undo can remove vs. restore. */
891
+ function handleWrite(
892
+ store: OidStore,
893
+ body: Record<string, unknown>,
894
+ projectRoot = process.cwd(),
895
+ ): HandlerResult {
896
+ // A numeric `value` stays a NUMBER all the way to the writer — the JSX then
897
+ // carries a bare `12` (React px-ifies it) instead of `'12'`, which React
898
+ // passes through and the CSSOM rejects, keeping the previous value.
899
+ const { oid, prop, value } = body as {
900
+ oid: string;
901
+ prop: string;
902
+ value: string | number | null;
903
+ };
904
+ const entry = store.index.get(oid);
905
+ if (!entry) return { body: { changed: false, error: 'unknown oid' } };
906
+ const src = readSettledSource(entry.file);
907
+ const off = lineColToOffset(src, entry.line, entry.col);
908
+ const result = applyStyleWriteRequest(
909
+ src,
910
+ off,
911
+ prop,
912
+ value,
913
+ utilityClassSupportFor(projectRoot, entry.file),
914
+ );
915
+ if (result.changed) writeEditableSource(entry.file, result.code);
916
+ return {
917
+ body: {
918
+ changed: result.changed,
919
+ dynamic: result.dynamic,
920
+ route: result.route,
921
+ appended: result.appended,
922
+ ...(result.tokenRef ? { tokenRef: result.tokenRef } : {}),
923
+ ...(result.error ? { error: result.error } : {}),
924
+ file: entry.file,
925
+ },
926
+ };
927
+ }
928
+
929
+ /**
930
+ * POST `/__ui-source/css` — Cap 2 surgical CSS-FILE edit. The client (adapter) resolves
931
+ * the target rule from live matched-rule data (`inspect.ts` getMatchedCssRules) and sends
932
+ * the rule's source file + selector; this edits that file in place. The file path is
933
+ * client-supplied, so it MUST pass the first-party CSS scope guard before any read/write
934
+ * — an out-of-scope path is rejected, not touched. A selector not present in source is
935
+ * generated CSS (`generated: true`); the client re-routes to class/inline.
936
+ *
937
+ * Exported (unlike its sibling handlers above, which close over the live
938
+ * `OidStore`) so it's directly unit-testable without a dev server — it's pure
939
+ * body-in/HandlerResult-out plus real fs I/O on a client-supplied, scope-
940
+ * guarded path, no store/session state involved.
941
+ */
942
+ export function handleCss(
943
+ body: Record<string, unknown>,
944
+ ): HandlerResult {
945
+ const { file, selector, prop, value, media } = body as {
946
+ file: string;
947
+ selector: string;
948
+ prop: string;
949
+ value: string;
950
+ media?: string;
951
+ };
952
+ if (!file || !isEditableCssFile(file)) {
953
+ return { body: { changed: false, error: 'css file out of editable scope' } };
954
+ }
955
+ const cleanFile = file.split('?')[0] ?? file;
956
+ const src = readSettledSource(cleanFile);
957
+ // `media` scopes the edit to a breakpoint: the rule inside `@media <media>`
958
+ // (block and rule both created on demand — a breakpoint's first edit IS
959
+ // what mints it). The base-path `generated` probe does not apply: the
960
+ // media write can always land.
961
+ const edited =
962
+ typeof media === 'string' && media.trim().length > 0
963
+ ? surgicalCssEditInMedia(src, media, selector, prop, value)
964
+ : surgicalCssEdit(src, selector, prop, value);
965
+ if (edited === null) return { body: { changed: false, generated: true, file: cleanFile } };
966
+ if (edited !== src) writeEditableSource(cleanFile, edited);
967
+ return { body: { changed: edited !== src, file: cleanFile } };
968
+ }
969
+
970
+ /**
971
+ * POST `/__ui-source/text` — Cap 3 text-content edit. Replaces a leaf element's pure-text
972
+ * body in place (guarded: refuses a body with `{expression}` or child elements). Returns
973
+ * `prevText` (the editable text before the edit) for the client's undo inverse.
974
+ */
975
+ function handleText(
976
+ store: OidStore,
977
+ body: Record<string, unknown>,
978
+ ): HandlerResult {
979
+ const { oid, text } = body as { oid: string; text: string };
980
+ const entry = store.index.get(oid);
981
+ if (!entry) return { body: { changed: false, error: 'unknown oid' } };
982
+ const src = readSettledSource(entry.file);
983
+ const off = lineColToOffset(src, entry.line, entry.col);
984
+ const prevText = getEditableText(src, off);
985
+ const result = editTextContent(src, off, text);
986
+ if (result.changed) writeEditableSource(entry.file, result.code);
987
+ return {
988
+ body: { changed: result.changed, dynamic: result.dynamic, prevText, file: entry.file },
989
+ };
990
+ }
991
+
992
+ /**
993
+ * POST `/__ui-source/prop` — Cap 4 component prop / attribute edit at the call site. The
994
+ * client sends the CALL-SITE oid (resolved from the fiber, `inspect.ts` getCallSiteOid)
995
+ * whose index entry points at the `<Component …>` tag. Guards a dynamic (non-literal) prop
996
+ * and the event-handler / structural props.
997
+ */
998
+ function handleProp(
999
+ store: OidStore,
1000
+ body: Record<string, unknown>,
1001
+ ): HandlerResult {
1002
+ const { oid, prop, value } = body as { oid: string; prop: string; value: string | null };
1003
+ const entry = store.index.get(oid);
1004
+ if (!entry) return { body: { changed: false, error: 'unknown oid' } };
1005
+ const src = readSettledSource(entry.file);
1006
+ const off = lineColToOffset(src, entry.line, entry.col);
1007
+ // `value: null` is the REMOVAL sentinel, the same shape `/__ui-source/write`
1008
+ // uses for `removeStyle` (D-A3). Removing a prop attribute is how the
1009
+ // inspector reverts a prop to the component's declared default.
1010
+ if (value === null) {
1011
+ const removed = removePropAttribute(src, off, prop);
1012
+ if (removed.changed) writeEditableSource(entry.file, removed.code);
1013
+ return { body: { changed: removed.changed, dynamic: removed.dynamic, file: entry.file } };
1014
+ }
1015
+ const result = writePropChange(src, off, prop, value, {
1016
+ addIfMissing: body['addIfMissing'] === true,
1017
+ // R2: opt-in scalar→tuple shape
1018
+ // upgrade — see writePropChange's own doc comment. Absent/false on every
1019
+ // pre-existing caller, so react-dom prop writes are byte-identical.
1020
+ allowShapeUpgrade: body['allowShapeUpgrade'] === true,
1021
+ });
1022
+ if (result.changed) writeEditableSource(entry.file, result.code);
1023
+ return { body: { changed: result.changed, dynamic: result.dynamic, file: entry.file } };
1024
+ }
1025
+
1026
+ /**
1027
+ * POST `/__ui-source/struct` — Cap 5 structural source edits. Ops: `delete`, `duplicate`,
1028
+ * `wrap` (+wrapperTag), `unwrap`, `create` (insert a `<tag/>` child of `oid`),
1029
+ * `create-sibling` (insert the `snippet` immediately AFTER `oid` — how a top-level element
1030
+ * is added to a world whose root JSX is an OID-less fragment), `reorder`
1031
+ * (move `oid` before `targetOid`, or to the end of `parentOid`), and `reparent` (move `oid`
1032
+ * into `parentOid`). An insert may carry `ensureImport` when its snippet names a component.
1033
+ * Multi-oid ops resolve every oid to an offset in the SAME file (a
1034
+ * cross-file move is refused) — OID-resolved, immune to index races.
1035
+ *
1036
+ * D-1 (Phase D, spec27 §2 "known limitation to schedule, not hide"): when the op actually
1037
+ * changes the file, the response also carries the WHOLE file's `prevSource`/`newSource`
1038
+ * (before/after this write) and their `prevSha`/`newSha` (sha256 digests, computed here —
1039
+ * the client never hashes anything itself). `ReactRootAuthoringAdapter.structOp` uses
1040
+ * these to push a checksum-guarded whole-file-snapshot undo/redo entry — the sound inverse
1041
+ * for a structural op (a per-OID inverse is unsound: OIDs are content-signature keyed,
1042
+ * reorder/delete reassign occurrence indices, and delete has no inverse payload). Exported
1043
+ * (like `handleCss`) so it's directly unit-testable without a running dev server.
1044
+ *
1045
+ * The op switch itself lives in `planSourceEdit` and NOWHERE else. It used to be written
1046
+ * out a second time here, and the two copies drifted the moment `reparent` grew rules of
1047
+ * its own (R1–R4): one caller routed through the planner and got them, this
1048
+ * one did not. Everything that remains below is what is genuinely server-side — the
1049
+ * `node:fs` read/write, sha256, and the response envelope.
1050
+ */
1051
+ export function handleStruct(
1052
+ store: OidStore,
1053
+ body: Record<string, unknown>,
1054
+ ): HandlerResult {
1055
+ const { oid, op } = body as { oid?: unknown; op?: unknown };
1056
+ if (typeof oid !== 'string' || typeof op !== 'string') {
1057
+ return { body: { changed: false, error: 'struct requests need an oid and an op' } };
1058
+ }
1059
+ const request = { ...body, kind: 'struct', oid, op } as unknown as SourceEditRequest;
1060
+ const resolveOid = (id: string): OidEntry | undefined => store.index.get(id);
1061
+ const sources = new Map<string, string>();
1062
+ for (const file of sourceEditFiles(request, resolveOid)) {
1063
+ sources.set(file, readSettledSource(file));
1064
+ }
1065
+ const plan = planSourceEdit(request, sources, resolveOid);
1066
+ if (plan.file === null) return { body: { changed: false, ...plan.result } };
1067
+ const { changed: _planned, ...detail } = plan.result;
1068
+ if (!plan.changed) return { body: { changed: false, file: plan.file, ...detail } };
1069
+ writeEditableSource(plan.file, plan.newSource);
1070
+ return {
1071
+ body: {
1072
+ changed: true,
1073
+ file: plan.file,
1074
+ ...detail,
1075
+ prevSource: plan.prevSource,
1076
+ newSource: plan.newSource,
1077
+ prevSha: sha256(plan.prevSource),
1078
+ newSha: sha256(plan.newSource),
1079
+ },
1080
+ };
1081
+ }
1082
+
1083
+ /**
1084
+ * POST `/__ui-source/struct-many` — one source snapshot and one history boundary
1085
+ * for structural operations involving several OIDs. `group` wraps source siblings
1086
+ * beneath an ordinary R3F group. `delete` retains the delete-order-residual fix
1087
+ * (50f90a6d): it resolves EVERY `oid` in `oids` to an offset against ONE shared
1088
+ * `readFileSync` snapshot, then applies them via `deleteElements` (`writer.ts`) —
1089
+ * highest offset first, so
1090
+ * no still-queued target's offset is ever shifted out from under it, REGARDLESS of
1091
+ * what order the caller listed `oids` in. This is what makes the batch immune to the
1092
+ * delete-order-residual: `deleteSelection` (`editor-hotkeys.ts`)'s per-id fallback
1093
+ * loop derives its delete order from the CALLER's own hierarchy walk (DOM order for
1094
+ * `ReactRootAuthoringAdapter` — see `walkOidTree`), which can diverge from true
1095
+ * SOURCE order when a component's live DOM child order is a runtime permutation of
1096
+ * its JSX (e.g. `{[...els].reverse()}`); this endpoint never consults that walk order
1097
+ * at all, only each oid's own registered `{line, col}` and ONE shared file read, so
1098
+ * caller order is irrelevant to correctness. Explicitly NOT a re-scan/re-transform of
1099
+ * the file per oid (see `deleteElements`'s doc comment for why that would be UNSOUND
1100
+ * here — a live re-transform mid-batch reassigns occurrence-index-based oid identity
1101
+ * out from under the very oids this batch is still trying to resolve). One write, one
1102
+ * `prevSource`/`newSource` pair — one undo entry for the whole batch (a bonus over
1103
+ * the N-entry per-id fallback, not the point of this fix, but free from reusing
1104
+ * `pushStructUndo`'s existing whole-file-snapshot inverse unchanged).
1105
+ */
1106
+ export function handleStructMany(
1107
+ store: OidStore,
1108
+ body: Record<string, unknown>,
1109
+ ): HandlerResult {
1110
+ const { oids, op, wrapperTag } = body as {
1111
+ oids?: unknown;
1112
+ op?: string;
1113
+ wrapperTag?: string;
1114
+ };
1115
+ if (op !== 'delete' && op !== 'group') {
1116
+ return { body: { changed: false, error: `unsupported batch op: ${op}` } };
1117
+ }
1118
+ if (!Array.isArray(oids) || oids.length === 0) {
1119
+ return { body: { changed: false, error: 'oids must be a non-empty array' } };
1120
+ }
1121
+ const uniqueOids = [...new Set(oids as string[])];
1122
+ const entries = uniqueOids.map((oid) => ({ oid, entry: store.index.get(oid) }));
1123
+ const missing = entries.find((e) => !e.entry);
1124
+ if (missing) return { body: { changed: false, error: `unknown oid: ${missing.oid}` } };
1125
+ const file = entries[0]!.entry!.file;
1126
+ const crossFile = entries.find((e) => e.entry!.file !== file);
1127
+ if (crossFile) return { body: { changed: false, error: 'cannot batch-edit across files' } };
1128
+
1129
+ const src = readSettledSource(file); // ONE snapshot every offset below resolves against
1130
+ const offsets = entries.map((e) => lineColToOffset(src, e.entry!.line, e.entry!.col));
1131
+ const result =
1132
+ op === 'group' ? groupSiblingElements(src, offsets, wrapperTag) : deleteElements(src, offsets);
1133
+ if (!result.changed) return { body: { changed: false, file } };
1134
+ const prevSource = src;
1135
+ const newSource = result.code;
1136
+ writeEditableSource(file, newSource);
1137
+ return {
1138
+ body: {
1139
+ changed: true,
1140
+ file,
1141
+ prevSource,
1142
+ newSource,
1143
+ prevSha: sha256(prevSource),
1144
+ newSha: sha256(newSource),
1145
+ },
1146
+ };
1147
+ }
1148
+
1149
+ /**
1150
+ * POST `/__ui-source/fork-component` — H7 "Fork Component…" (wave 6).
1151
+ *
1152
+ * The ONE thing this endpoint does that the client cannot: create a file. Every
1153
+ * other `/__ui-source/*` write targets a file the OID index already knows, and
1154
+ * the whole-file `apply`/`restore` pair is checksum-guarded against a file that
1155
+ * exists — there is no create path through the client write seam at all. So the
1156
+ * new module is written HERE with `node:fs`, and the CALLSITE edit is handed
1157
+ * back to the client unwritten, so it can go through project history like every
1158
+ * other source write (the fork is undoable at the callsite; the new file, being
1159
+ * outside history, survives an undo as an inert module nothing imports).
1160
+ *
1161
+ * ORDER AND ATOMICITY. `planComponentFork` computes BOTH edits from both files'
1162
+ * current bytes before anything is written, and refuses — with a sentence — on
1163
+ * every failure it can see (definition not found, tag no longer matches, name
1164
+ * or file collision). Only then is the new file written, and only if nothing is
1165
+ * at that path already: `existsSync` is checked here as well as in the planner,
1166
+ * because the planner reads a directory LISTING the caller supplied and this is
1167
+ * the last moment before the write.
1168
+ */
1169
+ export function handleForkComponent(
1170
+ store: OidStore,
1171
+ body: Record<string, unknown>,
1172
+ projectRoot = process.cwd(),
1173
+ ): HandlerResult {
1174
+ const { oid, definitionOid, name } = body as {
1175
+ oid?: string;
1176
+ definitionOid?: string;
1177
+ name?: string;
1178
+ };
1179
+ const callsite = typeof oid === 'string' ? store.index.get(oid) : undefined;
1180
+ if (!callsite) return { body: { ok: false, error: 'unknown oid' } };
1181
+ const definition = typeof definitionOid === 'string' ? store.index.get(definitionOid) : undefined;
1182
+ if (!definition) return { body: { ok: false, error: 'unknown definition oid' } };
1183
+
1184
+ const definitionDir = dirname(definition.file);
1185
+ let siblingFiles: string[] = [];
1186
+ try {
1187
+ siblingFiles = readdirSync(definitionDir);
1188
+ } catch {
1189
+ // An unreadable directory is not a reason to refuse: the collision checks
1190
+ // below (and `existsSync`) still hold, they are just less informed.
1191
+ }
1192
+ const planned = planComponentFork({
1193
+ callsiteFile: callsite.file,
1194
+ callsiteSource: readSettledSource(callsite.file),
1195
+ callsiteLine: callsite.line,
1196
+ callsiteCol: callsite.col,
1197
+ definitionFile: definition.file,
1198
+ definitionSource: readSettledSource(definition.file),
1199
+ tag: callsite.tag,
1200
+ nameSeed: typeof name === 'string' ? name : undefined,
1201
+ siblingFiles,
1202
+ });
1203
+ if (!planned.ok) return { body: { ok: false, error: planned.reason } };
1204
+
1205
+ const plan = planned.plan;
1206
+ const newFile = join(definitionDir, plan.newFileName);
1207
+ if (existsSync(newFile)) {
1208
+ return {
1209
+ body: {
1210
+ ok: false,
1211
+ error: `${sourceResourcePath(newFile, projectRoot)} already exists — fork will not overwrite a file.`,
1212
+ },
1213
+ };
1214
+ }
1215
+ writeEditableSource(newFile, plan.newFileSource);
1216
+ return {
1217
+ body: {
1218
+ ok: true,
1219
+ newName: plan.newName,
1220
+ tag: plan.tag,
1221
+ newFile,
1222
+ newResourcePath: sourceResourcePath(newFile, projectRoot),
1223
+ importSpecifier: plan.importSpecifier,
1224
+ label: plan.historyLabel,
1225
+ callsiteFile: plan.callsiteFile,
1226
+ callsiteResourcePath: sourceResourcePath(plan.callsiteFile, projectRoot),
1227
+ callsitePrevSource: plan.callsitePrevSource,
1228
+ callsiteNewSource: plan.callsiteNewSource,
1229
+ callsitePrevSha: sha256(plan.callsitePrevSource),
1230
+ callsiteNewSha: sha256(plan.callsiteNewSource),
1231
+ },
1232
+ };
1233
+ }
1234
+
1235
+ /**
1236
+ * POST `/__ui-source/extract-component` — P1 "Extract Component…".
1237
+ *
1238
+ * The fork route's sibling, and the SECOND file-creating seam on this
1239
+ * middleware (the header on `/__ui-source/fork-component` records why file
1240
+ * creation lives server-side at all). `planComponentExtraction` computes the
1241
+ * two new modules (component + portable CSF story under `src/prefabs/`) and
1242
+ * the callsite edit from current bytes before anything is written, refusing
1243
+ * with a sentence on every failure it can see (subtree gone, lexical capture,
1244
+ * module-local reference, name/file collision). Only then are the new files
1245
+ * written — `existsSync`-guarded here as well as in the planner, because the
1246
+ * planner reads a directory LISTING and this is the last moment before the
1247
+ * write — and the CALLSITE edit is handed back unwritten so the client pushes
1248
+ * it through project history: undo restores the callsite and leaves the two
1249
+ * new files behind as an inert module + story nothing imports.
1250
+ */
1251
+ export function handleExtractComponent(
1252
+ store: OidStore,
1253
+ body: Record<string, unknown>,
1254
+ projectRoot = process.cwd(),
1255
+ ): HandlerResult {
1256
+ const { oid, name } = body as { oid?: string; name?: string };
1257
+ const entry = typeof oid === 'string' ? store.index.get(oid) : undefined;
1258
+ if (!entry) return { body: { ok: false, error: 'unknown oid' } };
1259
+
1260
+ const prefabDir = join(projectRoot, 'src', 'prefabs');
1261
+ let siblingFiles: string[] = [];
1262
+ try {
1263
+ siblingFiles = readdirSync(prefabDir);
1264
+ } catch {
1265
+ // No prefab folder yet — the write below creates it.
1266
+ }
1267
+ const planned = planComponentExtraction({
1268
+ sourceFile: entry.file,
1269
+ source: readSettledSource(entry.file),
1270
+ line: entry.line,
1271
+ col: entry.col,
1272
+ nameSeed: typeof name === 'string' ? name : undefined,
1273
+ prefabDir,
1274
+ siblingFiles,
1275
+ });
1276
+ if (!planned.ok) return { body: { ok: false, error: planned.reason } };
1277
+
1278
+ const plan = planned.plan;
1279
+ for (const file of [plan.componentFile, plan.storyFile]) {
1280
+ if (existsSync(file)) {
1281
+ return {
1282
+ body: {
1283
+ ok: false,
1284
+ error: `${sourceResourcePath(file, projectRoot)} already exists — extraction will not overwrite a file.`,
1285
+ },
1286
+ };
1287
+ }
1288
+ }
1289
+ mkdirSync(prefabDir, { recursive: true });
1290
+ writeEditableSource(plan.componentFile, plan.componentSource);
1291
+ writeEditableSource(plan.storyFile, plan.storySource);
1292
+ return {
1293
+ body: {
1294
+ ok: true,
1295
+ newName: plan.newName,
1296
+ tag: plan.tag,
1297
+ componentFile: plan.componentFile,
1298
+ componentResourcePath: sourceResourcePath(plan.componentFile, projectRoot),
1299
+ storyFile: plan.storyFile,
1300
+ storyResourcePath: sourceResourcePath(plan.storyFile, projectRoot),
1301
+ importSpecifier: plan.importSpecifier,
1302
+ label: plan.historyLabel,
1303
+ callsiteFile: plan.callsiteFile,
1304
+ callsiteResourcePath: sourceResourcePath(plan.callsiteFile, projectRoot),
1305
+ callsitePrevSource: plan.callsitePrevSource,
1306
+ callsiteNewSource: plan.callsiteNewSource,
1307
+ callsitePrevSha: sha256(plan.callsitePrevSource),
1308
+ callsiteNewSha: sha256(plan.callsiteNewSource),
1309
+ },
1310
+ };
1311
+ }
1312
+
1313
+ /**
1314
+ * POST `/__ui-source/csf-story` — the CSF write half (design ledger:
1315
+ * stories composed and framed everywhere, written nowhere): save an arg
1316
+ * set as a new story export, rename one, or delete one. The grammar and
1317
+ * every refusal live in the pure planner (`ui-source/plan-csf-story.ts`);
1318
+ * this handler is scope-guard + read + plan + write.
1319
+ */
1320
+ export function handleCsfStory(
1321
+ body: Record<string, unknown>,
1322
+ projectRoot = process.cwd(),
1323
+ ): HandlerResult {
1324
+ const { op, file, name, newName, args } = body as {
1325
+ op?: 'save' | 'rename' | 'delete';
1326
+ file?: string;
1327
+ name?: string;
1328
+ newName?: string;
1329
+ args?: Record<string, unknown>;
1330
+ };
1331
+ if (typeof file !== 'string' || typeof name !== 'string' || !op) {
1332
+ return { status: 400, body: { changed: false, error: 'csf-story requires {op, file, name}.' } };
1333
+ }
1334
+ const resolved = resolveEditableSourceFile(file, projectRoot);
1335
+ if (!resolved) {
1336
+ return { status: 403, body: { changed: false, error: 'file out of editable scope' } };
1337
+ }
1338
+ const source = readSettledSource(resolved);
1339
+ const plan =
1340
+ op === 'save'
1341
+ ? planSaveStory(source, name, args ?? {})
1342
+ : op === 'rename'
1343
+ ? planRenameStory(source, name, typeof newName === 'string' ? newName : '')
1344
+ : planDeleteStory(source, name);
1345
+ if (!plan.ok) return { body: { changed: false, error: plan.reason } };
1346
+ writeEditableSource(resolved, plan.nextSource);
1347
+ return { body: { changed: true, summary: plan.summary, file } };
1348
+ }
1349
+
1350
+ /** A JSX literal style value as CSS TEXT (`'8px'` → `8px`, `8` → `8px` per the
1351
+ * react px-ifying rule in `css-numeric-style.ts`), or null for a literal that
1352
+ * has no CSS reading (a boolean) — such a member stays inline, unmoved. */
1353
+ function cssValueFromJsxLiteral(name: string, raw: string): string | null {
1354
+ const t = raw.trim();
1355
+ if (/^['"`]/.test(t)) return t.slice(1, -1);
1356
+ const num = Number(t);
1357
+ if (Number.isFinite(num)) return cssTextForStyleValue(name, num);
1358
+ return null;
1359
+ }
1360
+
1361
+ /**
1362
+ * POST `/__ui-source/named-style` — the design ledger's NAMED STYLES order
1363
+ * (Webflow prior art: a reusable style is a CSS CLASS in the project's own
1364
+ * stylesheet). Three ops on one door:
1365
+ *
1366
+ * - `create` — mint `.className` in the given first-party stylesheet
1367
+ * (`file`, guarded by {@link isEditableCssFile}), MOVING the element's
1368
+ * literal inline declarations into it (dynamic members stay inline — they
1369
+ * are not the writer's to move), and adding the class token to the
1370
+ * element. Both files are planned before either is written, so a refusal
1371
+ * (duplicate class, dynamic className) leaves nothing half-done.
1372
+ * - `apply` / `remove` — add/remove the bare class token on the element.
1373
+ *
1374
+ * Every refusal names its gate; a `className={expr}` is never rewritten.
1375
+ */
1376
+ export function handleNamedStyle(
1377
+ store: OidStore,
1378
+ body: Record<string, unknown>,
1379
+ ): HandlerResult {
1380
+ const { op, oid, className, file } = body as {
1381
+ op?: 'create' | 'apply' | 'remove';
1382
+ oid?: string;
1383
+ className?: string;
1384
+ file?: string;
1385
+ };
1386
+ if (
1387
+ (op !== 'create' && op !== 'apply' && op !== 'remove') ||
1388
+ typeof oid !== 'string' ||
1389
+ typeof className !== 'string'
1390
+ ) {
1391
+ return {
1392
+ status: 400,
1393
+ body: { changed: false, error: 'named-style requires {op, oid, className}.' },
1394
+ };
1395
+ }
1396
+ const entry = store.index.get(oid);
1397
+ if (!entry) return { body: { changed: false, error: 'unknown oid' } };
1398
+ const src = readSettledSource(entry.file);
1399
+ const off = lineColToOffset(src, entry.line, entry.col);
1400
+
1401
+ if (op === 'apply' || op === 'remove') {
1402
+ const r = (op === 'apply' ? addClassNameToken : removeClassNameToken)(src, off, className);
1403
+ if (r.dynamic) {
1404
+ return {
1405
+ body: {
1406
+ changed: false,
1407
+ error: "the element's className is a dynamic expression — refusing to rewrite it.",
1408
+ },
1409
+ };
1410
+ }
1411
+ if (r.changed) writeEditableSource(entry.file, r.code);
1412
+ return { body: { changed: r.changed, file: entry.file } };
1413
+ }
1414
+
1415
+ // No stylesheet named ⇒ MINT one beside the element's own module (and make
1416
+ // the module import it — an unimported stylesheet never paints, and a bare
1417
+ // `import './styles.css'` is exactly the ecosystem-native form Vite and the
1418
+ // game's own build both honor). No example or fresh scaffold ships project
1419
+ // CSS today, so without this the whole feature would open on a refusal.
1420
+ const cleanCss =
1421
+ typeof file === 'string'
1422
+ ? (file.split('?')[0] ?? file)
1423
+ : join(dirname(entry.file), 'styles.css');
1424
+ if (!isEditableCssFile(cleanCss)) {
1425
+ return { body: { changed: false, error: 'css file out of editable scope' } };
1426
+ }
1427
+ const cssSrc = existsSync(cleanCss) ? readSettledSource(cleanCss) : '';
1428
+ const decls: { prop: string; value: string }[] = [];
1429
+ const moved: string[] = [];
1430
+ for (const member of collectLiteralInlineStyles(src, off)) {
1431
+ const value = cssValueFromJsxLiteral(member.name, member.raw);
1432
+ if (value === null) continue;
1433
+ decls.push({ prop: cssCamelToKebab(member.name), value });
1434
+ moved.push(member.name);
1435
+ }
1436
+ const plan = planCreateClassRule(cssSrc, className, decls);
1437
+ if (!plan.ok) return { body: { changed: false, error: plan.reason } };
1438
+ let code = src;
1439
+ for (const name of moved) code = removeInlineStyle(code, off, name).code;
1440
+ const applied = addClassNameToken(code, off, className);
1441
+ if (applied.dynamic) {
1442
+ return {
1443
+ body: {
1444
+ changed: false,
1445
+ error: "the element's className is a dynamic expression — refusing to rewrite it.",
1446
+ },
1447
+ };
1448
+ }
1449
+ let elementCode = applied.code;
1450
+ // Bulk removal leaves each moved member's line as bare indentation — collapse
1451
+ // the blank lines inside the opening tag so the authored source stays clean.
1452
+ const tagEnd = findTagEnd(elementCode, off);
1453
+ if (tagEnd > 0) {
1454
+ const seg = elementCode.slice(off, tagEnd);
1455
+ const collapsed = seg.replace(/\n(?:[ \t]*\n)+/g, '\n');
1456
+ elementCode = elementCode.slice(0, off) + collapsed + elementCode.slice(off + seg.length);
1457
+ }
1458
+ const specifier = `./${basename(cleanCss)}`;
1459
+ if (
1460
+ dirname(cleanCss) === dirname(entry.file) &&
1461
+ !new RegExp(`import\\s+['"]\\.?/?${basename(cleanCss).replace('.', '\\.')}['"]`).test(
1462
+ elementCode,
1463
+ )
1464
+ ) {
1465
+ elementCode = `import '${specifier}';\n${elementCode}`;
1466
+ }
1467
+ writeEditableSource(cleanCss, plan.nextSource);
1468
+ if (elementCode !== src) writeEditableSource(entry.file, elementCode);
1469
+ return {
1470
+ body: { changed: true, file: cleanCss, elementFile: entry.file, moved, summary: plan.summary },
1471
+ };
1472
+ }
1473
+
1474
+ /**
1475
+ * POST `/__ui-source/restore` — D-1 (Phase D, spec27 §2): the checksum-guarded whole-file
1476
+ * restore that makes Cap-5 structural edits (delete/duplicate/wrap/unwrap/reorder/reparent/
1477
+ * create) undoable. `ReactRootAuthoringAdapter.structOp` captures `{prevSource, newSource,
1478
+ * prevSha, newSha}` from a struct write's response (`handleStruct`, above) and, on undo/
1479
+ * redo, posts the INVERSE `{file, source, ifMatchSha}` here: undo sends `source: prevSource,
1480
+ * ifMatchSha: newSha` (the hash of what SHOULD be on disk right now); redo sends the mirror
1481
+ * image. Refuses — WITHOUT writing — when the file's CURRENT sha256 doesn't match
1482
+ * `ifMatchSha` (an out-of-band change since: a hand-edit, a since-diverged reload, …);
1483
+ * `restored: false` is the caller's cue to surface a loud refusal instead of silently
1484
+ * corrupting the file. Scope-guarded the same way `handleCss`'s client-supplied `file` is
1485
+ * (`defaultProjectScopeInclude` — restore targets the SAME `.tsx` project files struct ops
1486
+ * do). Exported (like `handleCss`) so it's directly unit-testable without a dev server.
1487
+ */
1488
+ export function handleRestore(
1489
+ body: Record<string, unknown>,
1490
+ projectRoot = process.cwd(),
1491
+ ): HandlerResult {
1492
+ const { file, source, ifMatchSha } = body as {
1493
+ file: string;
1494
+ source: string;
1495
+ ifMatchSha: string;
1496
+ };
1497
+ const resolved = file ? resolveEditableSourceFile(file, projectRoot) : null;
1498
+ if (!resolved) {
1499
+ return { status: 403, body: { restored: false, error: 'file out of editable scope' } };
1500
+ }
1501
+ let current: string;
1502
+ try {
1503
+ current = readSettledSource(resolved);
1504
+ } catch (e) {
1505
+ return { status: 404, body: { restored: false, error: String(e) } };
1506
+ }
1507
+ const currentSha = sha256(current);
1508
+ if (currentSha !== ifMatchSha) {
1509
+ return {
1510
+ status: 409,
1511
+ body: {
1512
+ restored: false,
1513
+ error: 'stale: the file changed since this undo/redo entry was recorded',
1514
+ sha: currentSha,
1515
+ },
1516
+ };
1517
+ }
1518
+ writeEditableSource(resolved, source);
1519
+ return { body: { restored: true, sha: sha256(source) } };
1520
+ }
1521
+
1522
+ export function uiOidPlugin(
1523
+ services: ProjectServingServices,
1524
+ include: RegExp | ((id: string) => boolean) = defaultProjectScopeInclude,
1525
+ ): Plugin {
1526
+ servingServices = services;
1527
+ const projectRoot = services.currentProjectRoot;
1528
+ const test = typeof include === 'function' ? include : (id: string) => include.test(id);
1529
+ const store = new OidStore();
1530
+ // Captured by `configureServer` below, read by `transform` — the dev
1531
+ // server's own `ModuleGraph` is what lets `declaredSurfaceForProjectFileViaGraph`
1532
+ // reach a root entry's surface through a NON-entry file's importer chain
1533
+ // (a child component with no direct fiber/world3d-react import of its
1534
+ // own). `undefined` in a build/SSR context without `configureServer`
1535
+ // (e.g. a one-off transform in a test) degrades to the direct-entry-only
1536
+ // answer, same as before this existed.
1537
+ let devServer: ViteDevServer | undefined;
1538
+ return {
1539
+ name: SOURCE_WRITE_ROUTES_PLUGIN,
1540
+ enforce: 'pre',
1541
+ transform(code, id) {
1542
+ // A VIRTUAL MODULE IS NOT A FILE, and everything below this line treats
1543
+ // `clean` as one — `resolveOidSurface` walks up from it looking for the
1544
+ // owning `vgai.project.json`, and `transformSource` records it as the
1545
+ // stamped file's path. Vite's convention is that a `\0`-prefixed id was
1546
+ // synthesized by a plugin and has no path behind it, so a path plugin
1547
+ // skips it.
1548
+ //
1549
+ // The `test(clean)` below happens to reject today's virtual ids for the
1550
+ // wrong reason (they carry no extension); a synthesized id may legally
1551
+ // end in `.tsx`, and then the extension test passes and a NUL byte
1552
+ // reaches `readFileSync`. That exact throw was measured one layer down
1553
+ // on 2026-09-21 — see `importerFiles` in `server/project-root-surface.ts`
1554
+ // for the failure and what it looked like from the editor.
1555
+ if (id.startsWith('\0')) return null;
1556
+ const clean = id.split('?')[0] ?? id;
1557
+ if (!test(clean)) return null;
1558
+ // W1 — R3F-dialect files get ` userData-oid` (fiber pierces it into
1559
+ // `object.userData.oid`); react-dom files keep ` data-oid` unchanged.
1560
+ // WHICH REGION owns this exact file decides: a declaration the project
1561
+ // made (an `include` glob or a `mounts` entry in `vgai.adapter.ts`), the
1562
+ // manifest root whose `entry` it is, or reach from one through the live
1563
+ // import graph. Nothing is inferred from the file's own bytes; a file
1564
+ // nothing places gets the documented `data-oid` default, said out loud.
1565
+ // The project root is threaded because an INGEST root's sources live
1566
+ // outside its manifest's directory (see `manifestOwning`), so the walk
1567
+ // alone cannot find the project that declares them.
1568
+ const decision = services.surfaceOf(clean, code, devServer?.moduleGraph, projectRoot());
1569
+ const { code: out, entries } = transformSource(code, clean, store, {
1570
+ attribute: decision.attribute,
1571
+ canvasComponents: decision.surface === 'canvas',
1572
+ importedR3fContracts: importedR3fContracts(code, clean),
1573
+ });
1574
+ // Task #45: a wrong stamp must not be silent. Reported only when this
1575
+ // file actually GOT stamps — a module with no JSX has no selection to
1576
+ // break, so its (irrelevant) attribute is not worth a terminal line.
1577
+ if (entries.length > 0) decision.report();
1578
+ return { code: out, map: null };
1579
+ },
1580
+ configureServer(server) {
1581
+ devServer = server;
1582
+ const activeProjectRoot = (): string => projectRoot() ?? server.config.root;
1583
+ // The editor's own writes invalidate the modules they replace; the kit binds the graph.
1584
+ services.bindModuleGraph(server.moduleGraph, activeProjectRoot);
1585
+ // The Express host may apply express.json() app-wide, consuming the body and
1586
+ // leaving the parsed object on req.body. Prefer that; else read the raw stream.
1587
+ const readJson = async (
1588
+ req: import('node:http').IncomingMessage,
1589
+ ): Promise<Record<string, unknown>> => {
1590
+ const pre = (req as unknown as { body?: unknown }).body;
1591
+ if (pre && typeof pre === 'object') return pre as Record<string, unknown>;
1592
+ const chunks: Buffer[] = [];
1593
+ for await (const chunk of req) chunks.push(chunk as Buffer);
1594
+ const raw = Buffer.concat(chunks).toString('utf8');
1595
+ return raw ? JSON.parse(raw) : {};
1596
+ };
1597
+ const json = (res: import('node:http').ServerResponse, obj: unknown, code = 200): void => {
1598
+ res.statusCode = code;
1599
+ res.setHeader('Content-Type', 'application/json');
1600
+ res.end(JSON.stringify(obj));
1601
+ };
1602
+ const mutationFiles = (body: Record<string, unknown>, result?: HandlerResult): string[] => {
1603
+ const files = new Set<string>();
1604
+ const suppliedFile = body['file'];
1605
+ if (typeof suppliedFile === 'string') files.add(suppliedFile.split('?')[0] ?? suppliedFile);
1606
+ const oid = body['oid'];
1607
+ if (typeof oid === 'string') {
1608
+ const entry = store.index.get(oid);
1609
+ if (entry) files.add(entry.file);
1610
+ }
1611
+ const oids = body['oids'];
1612
+ if (Array.isArray(oids)) {
1613
+ for (const candidate of oids) {
1614
+ if (typeof candidate !== 'string') continue;
1615
+ const entry = store.index.get(candidate);
1616
+ if (entry) files.add(entry.file);
1617
+ }
1618
+ }
1619
+ const response = result?.body as Record<string, unknown> | undefined;
1620
+ for (const key of ['file', 'newFile', 'callsiteFile']) {
1621
+ const file = response?.[key];
1622
+ if (typeof file === 'string') files.add(file);
1623
+ }
1624
+ return [...files];
1625
+ };
1626
+ const resourcePath = (file: string) =>
1627
+ sourceResourcePath(
1628
+ isAbsolute(file) ? file : resolve(activeProjectRoot(), file),
1629
+ activeProjectRoot(),
1630
+ );
1631
+ /**
1632
+ * Record this write with the collaboration session and REPORT THE
1633
+ * REVISION IT PRODUCED.
1634
+ *
1635
+ * The revision is what the next mutation's optimistic-concurrency guard
1636
+ * (`assertSourceMutation`, below) compares against, and the client only
1637
+ * learned it from the collaboration SSE stream — one event loop later.
1638
+ * A gesture that writes the same file TWICE therefore sent a revision
1639
+ * that its own first write had already superseded, and the second write
1640
+ * was rejected: measured on a canvas transform drag, whose `position`
1641
+ * channel is two props (`x` and `y`) by construction, so EVERY drag lost
1642
+ * its whole gesture to the compensation path. An R3F pivot-rotate
1643
+ * (position + rotation) has the same shape.
1644
+ *
1645
+ * Returning it here closes the window without a second channel: the
1646
+ * writer already awaits this response, so adopting the number it carries
1647
+ * is exactly as fresh as the write itself (`source-write-backend.ts`'s
1648
+ * `postJson`).
1649
+ */
1650
+ const recordEditorMutation = (
1651
+ participantId: string,
1652
+ files: readonly string[],
1653
+ ): number | undefined => {
1654
+ const resources = files.map((file) => {
1655
+ const absolute = isAbsolute(file) ? file : resolve(activeProjectRoot(), file);
1656
+ return {
1657
+ path: resourcePath(absolute),
1658
+ sha: existsSync(absolute) ? sha256(readFileSync(absolute, 'utf8')).slice(0, 16) : null,
1659
+ };
1660
+ });
1661
+ if (resources.length === 0) return undefined;
1662
+ const session = services.collaboration(activeProjectRoot());
1663
+ // `null` means the bytes were already recorded (a filesystem observer
1664
+ // won the race) — the session's CURRENT revision is still the honest
1665
+ // answer to "what must the next write expect".
1666
+ return (
1667
+ session.recordSourceMutation({ authorId: participantId, source: 'editor', resources })
1668
+ ?.revision ?? session.revision()
1669
+ );
1670
+ };
1671
+ // Single middleware switching on the URL — avoids connect prefix-matching subtleties.
1672
+ // Each POST endpoint delegates to a small module-level handler (below) so this
1673
+ // dispatcher stays flat; the try/catch + JSON plumbing lives here once.
1674
+ const post = async (
1675
+ req: import('node:http').IncomingMessage,
1676
+ res: import('node:http').ServerResponse,
1677
+ url: string,
1678
+ handler: (body: Record<string, unknown>) => HandlerResult,
1679
+ ): Promise<void> => {
1680
+ try {
1681
+ const body = await readJson(req);
1682
+ const claimedParticipantId = body['participantId'];
1683
+ const trustedParticipantId = req.headers['x-vgai-share-participant-id'];
1684
+ const trustedRole = req.headers['x-vgai-share-role'];
1685
+ if (
1686
+ typeof trustedParticipantId === 'string' &&
1687
+ typeof claimedParticipantId === 'string' &&
1688
+ trustedParticipantId !== claimedParticipantId
1689
+ ) {
1690
+ throw new Error(
1691
+ 'The authenticated share participant does not match the source mutation author.',
1692
+ );
1693
+ }
1694
+ const participantId =
1695
+ typeof trustedParticipantId === 'string' ? trustedParticipantId : claimedParticipantId;
1696
+ const expectedRevision = body['expectedRevision'];
1697
+ const readOnlySourceRequest =
1698
+ url === '/__ui-source/prepare' || url === '/__ui-source/read';
1699
+ const beforeFiles = mutationFiles(body);
1700
+ if (
1701
+ typeof trustedParticipantId === 'string' &&
1702
+ beforeFiles.some((file) => !isCanonicalProjectSourcePath(activeProjectRoot(), file))
1703
+ ) {
1704
+ throw new Error('Shared source path leaves the project root.');
1705
+ }
1706
+ if (!readOnlySourceRequest && beforeFiles.length > 0) {
1707
+ if (typeof participantId !== 'string' || !Number.isInteger(expectedRevision)) {
1708
+ throw new Error('Source mutations require participantId and expectedRevision.');
1709
+ }
1710
+ if (
1711
+ typeof trustedRole === 'string' &&
1712
+ trustedRole !== 'editor' &&
1713
+ trustedRole !== 'terminal' &&
1714
+ trustedRole !== 'maintainer'
1715
+ ) {
1716
+ throw new Error(`The ${trustedRole} share role cannot edit source.`);
1717
+ }
1718
+ services.collaboration(activeProjectRoot()).assertSourceMutation(
1719
+ participantId,
1720
+ expectedRevision as number,
1721
+ beforeFiles.map(resourcePath),
1722
+ );
1723
+ }
1724
+ const result = handler(body);
1725
+ const response = result.body as Record<string, unknown>;
1726
+ const changed =
1727
+ response['changed'] === true ||
1728
+ response['applied'] === true ||
1729
+ response['restored'] === true ||
1730
+ ((url === '/__ui-source/fork-component' || url === '/__ui-source/extract-component') &&
1731
+ response['ok'] === true) ||
1732
+ (url === '/__ui-source/csf-story' && response['changed'] === true);
1733
+ const revision =
1734
+ !readOnlySourceRequest && changed && typeof participantId === 'string'
1735
+ ? recordEditorMutation(participantId, mutationFiles(body, result))
1736
+ : undefined;
1737
+ json(
1738
+ res,
1739
+ revision === undefined ? result.body : { ...response, revision },
1740
+ result.status ?? 200,
1741
+ );
1742
+ } catch (e) {
1743
+ json(
1744
+ res,
1745
+ { changed: false, error: e instanceof Error ? e.message : String(e) },
1746
+ services.isCollaborationConflict(e) ? 409 : 400,
1747
+ );
1748
+ }
1749
+ };
1750
+ // POST route table — keeps the dispatcher flat (one lookup, no per-endpoint branch).
1751
+ const postRoutes: Record<string, (body: Record<string, unknown>) => HandlerResult> = {
1752
+ '/__ui-source/prepare': (b) => handlePrepare(store, b, activeProjectRoot()),
1753
+ '/__ui-source/read': (b) => handleSourceRead(b, activeProjectRoot()),
1754
+ '/__ui-source/apply': (b) => handleSourceApply(b, activeProjectRoot()),
1755
+ '/__ui-source/write': (b) => handleWrite(store, b, activeProjectRoot()),
1756
+ '/__ui-source/css': (b) => handleCss(b),
1757
+ '/__ui-source/text': (b) => handleText(store, b),
1758
+ '/__ui-source/prop': (b) => handleProp(store, b),
1759
+ '/__ui-source/struct': (b) => handleStruct(store, b),
1760
+ '/__ui-source/struct-many': (b) => handleStructMany(store, b),
1761
+ '/__ui-source/csf-story': (b) => handleCsfStory(b, activeProjectRoot()),
1762
+ '/__ui-source/named-style': (b) => handleNamedStyle(store, b),
1763
+ '/__ui-source/fork-component': (b) =>
1764
+ handleForkComponent(store, b, activeProjectRoot()),
1765
+ '/__ui-source/extract-component': (b) =>
1766
+ handleExtractComponent(store, b, activeProjectRoot()),
1767
+ };
1768
+ server.middlewares.use(async (req, res, next) => {
1769
+ const url = (req.url ?? '').split('?')[0] ?? '';
1770
+ if (url === '/__ui-source/index') {
1771
+ // Re-resolve contracts from current project bytes on every index
1772
+ // read — but OFF the request: a cold enrich is a 2.6 s ts.Program
1773
+ // and must not freeze first paint. The handler returns the last
1774
+ // enriched map (or the raw stamp) immediately; see currentOidIndex.
1775
+ //
1776
+ // H5 (wave 4): the R3F
1777
+ // authorability DIAGNOSTICS ride this same response, per entry.
1778
+ // Deliberately not a channel of their own — attaching them to the
1779
+ // payload the R3F adapter's `refreshSourceState()` already fetches is
1780
+ // what makes their freshness structurally identical to the index's,
1781
+ // with no second fetch, no polling loop and no cache to invalidate.
1782
+
1783
+ const indexStarted = Date.now();
1784
+ const index = currentOidIndex(store, activeProjectRoot(), server);
1785
+ res.setHeader('Server-Timing', `index;dur=${Date.now() - indexStarted}`);
1786
+ res.setHeader(
1787
+ 'X-Vgai-Oid-Index-Enriched',
1788
+ index === lastEnrichedIndex?.index ? '1' : '0',
1789
+ );
1790
+ if (typeof req.headers['x-vgai-share-participant-id'] !== 'string') {
1791
+ return json(res, index);
1792
+ }
1793
+ return json(
1794
+ res,
1795
+ Object.fromEntries(
1796
+ Object.entries(index).filter(([, value]) => {
1797
+ const file = (value as { file?: unknown } | null)?.file;
1798
+ return (
1799
+ typeof file === 'string' &&
1800
+ isCanonicalProjectSourcePath(activeProjectRoot(), file)
1801
+ );
1802
+ }),
1803
+ ),
1804
+ );
1805
+ }
1806
+ if (url === '/__ui-source/component-props' && req.method === 'GET') {
1807
+ // THE PER-DEFINITION DOOR: the props a prefab DECLARES, asked before
1808
+ // any callsite exists. The index above only knows tags already used
1809
+ // in a file; a drop needs the declaration itself so a REQUIRED enum
1810
+ // prop can be written with its first option instead of nothing
1811
+ // (`<WeaponPickup>` rendered NOTHING until `kind` was set — runhuman
1812
+ // pass 123). Same worker, same checker, one request.
1813
+ const params = new URL(req.url ?? '', 'http://localhost').searchParams;
1814
+ const file = params.get('file') ?? '';
1815
+ const name = params.get('name') ?? '';
1816
+ const root = activeProjectRoot();
1817
+ if (!root || !file || !name) return json(res, { props: [] });
1818
+ const absolute = isAbsolute(file) ? resolve(file) : resolve(root, file);
1819
+ if (
1820
+ !isCanonicalProjectSourcePath(root, absolute) ||
1821
+ resolveEditableSourceFile(absolute, root) === null
1822
+ ) {
1823
+ return json(res, { props: [] });
1824
+ }
1825
+ const key = `${absolute}::${name}::definition`;
1826
+ try {
1827
+ const props = await resolveDeclaredProps(root, [
1828
+ { key, file: absolute, tag: name, definition: true },
1829
+ ]);
1830
+ return json(res, { props: props.get(key) ?? [] });
1831
+ } catch (error) {
1832
+ return json(res, {
1833
+ props: [],
1834
+ error: error instanceof Error ? error.message : String(error),
1835
+ });
1836
+ }
1837
+ }
1838
+ const handler = req.method === 'POST' ? postRoutes[url] : undefined;
1839
+ if (handler) return post(req, res, url, handler);
1840
+ next();
1841
+ });
1842
+ },
1843
+ };
1844
+ }