@realsee/vr-cli 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/NOTICE.md +7 -0
  2. package/README.md +216 -0
  3. package/TERMS.txt +54 -0
  4. package/agent-assets/AGENTS.md +113 -0
  5. package/agent-assets/adapters/.cursor/rules/realsee-vr.mdc +7 -0
  6. package/agent-assets/adapters/.github/copilot-instructions.md +1 -0
  7. package/agent-assets/adapters/.kiro/steering/realsee-vr.md +6 -0
  8. package/agent-assets/adapters/CLAUDE.md +3 -0
  9. package/bin/vr-cli.mjs +3 -0
  10. package/dist/cli.mjs +24 -0
  11. package/package.json +38 -0
  12. package/skills/realsee-image-resources/SKILL.md +82 -0
  13. package/skills/realsee-image-resources/references/cors-and-three-textures.md +50 -0
  14. package/skills/realsee-image-resources/references/data-wanxiang.md +57 -0
  15. package/skills/realsee-image-resources/references/five-image-url-transform.md +81 -0
  16. package/skills/realsee-image-resources/references/source-and-domain-policy.md +63 -0
  17. package/skills/realsee-vr-app/SKILL.md +157 -0
  18. package/skills/realsee-vr-app/references/continuous-improvement.md +24 -0
  19. package/skills/realsee-vr-app/references/dnalogel-api-capabilities.md +241 -0
  20. package/skills/realsee-vr-app/references/dnalogel-openapi-recipes.md +818 -0
  21. package/skills/realsee-vr-app/references/five-api-capabilities.md +119 -0
  22. package/skills/realsee-vr-app/references/five-runtime.md +83 -0
  23. package/skills/realsee-vr-app/references/official-docs.md +61 -0
  24. package/skills/realsee-vr-app/references/plugin-lifecycle.md +65 -0
  25. package/skills/realsee-vr-app/references/ui-and-validation.md +57 -0
  26. package/skills/realsee-vr-app/references/vapor-dom-layer.md +186 -0
  27. package/skills/realsee-vr-app/scripts/verify-project.mjs +1566 -0
  28. package/skills/threejs-r117-five/SKILL.md +71 -0
  29. package/skills/threejs-r117-five/references/five-ownership.md +70 -0
  30. package/skills/threejs-r117-five/references/r117-capabilities.md +82 -0
  31. package/skills/threejs-r117-five/references/r117-version-guards.md +60 -0
  32. package/templates/base/.editorconfig +12 -0
  33. package/templates/base/.env.example +12 -0
  34. package/templates/base/.gitignore.template +6 -0
  35. package/templates/base/.node-version +1 -0
  36. package/templates/base/.npmrc.template +3 -0
  37. package/templates/base/.nvmrc +1 -0
  38. package/templates/base/.prettierignore +12 -0
  39. package/templates/base/README.md +160 -0
  40. package/templates/base/eslint.config.mjs +75 -0
  41. package/templates/base/index.html +13 -0
  42. package/templates/base/package-lock.json +6444 -0
  43. package/templates/base/package.json +55 -0
  44. package/templates/base/prettier.config.mjs +5 -0
  45. package/templates/base/server/app.ts +54 -0
  46. package/templates/base/server/check-config.ts +4 -0
  47. package/templates/base/server/config.ts +54 -0
  48. package/templates/base/server/index.ts +31 -0
  49. package/templates/base/server/realsee-open-api.ts +316 -0
  50. package/templates/base/server/types.ts +64 -0
  51. package/templates/base/server/work-route.ts +80 -0
  52. package/templates/base/server/work-service.ts +54 -0
  53. package/templates/base/shared/model-floorplan.ts +242 -0
  54. package/templates/base/src/main.tsx +11 -0
  55. package/templates/base/src/shared/useElementSize.ts +28 -0
  56. package/templates/base/src/styles.css +91 -0
  57. package/templates/base/src/vr/five/SpatialRuntimeProvider.tsx +234 -0
  58. package/templates/base/src/vr/five/api.ts +10 -0
  59. package/templates/base/src/vr/five/fiveConfig.ts +42 -0
  60. package/templates/base/src/vr/five/initialState.ts +48 -0
  61. package/templates/base/src/vr/plugins/SpatialPluginRegistry.ts +104 -0
  62. package/templates/base/src/vr/plugins/dnalogel.ts +2 -0
  63. package/templates/base/src/vr/plugins/examples/FeatureOwnedMeshPlugin.ts +73 -0
  64. package/templates/base/src/vr/work/WorkSource.ts +247 -0
  65. package/templates/base/tsconfig.json +27 -0
  66. package/templates/base/tsconfig.server.json +25 -0
  67. package/templates/base/vite.config.ts +21 -0
  68. package/templates/viewer/src/app/App.tsx +182 -0
  69. package/templates/viewer/src/features/floorplan/ModelFloorplanFeature.ts +141 -0
  70. package/templates/viewer/src/features/navigation/ModeSwitcher.tsx +111 -0
  71. package/templates/viewer/src/features/tags/PanoTagFeature.ts +110 -0
  72. package/templates/viewer/src/ui/ViewerOverlay.tsx +141 -0
@@ -0,0 +1,1566 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFile } from 'node:child_process'
4
+ import { readFile, readdir, stat } from 'node:fs/promises'
5
+ import path from 'node:path'
6
+ import { promisify } from 'node:util'
7
+
8
+ const execFileAsync = promisify(execFile)
9
+
10
+ const root = path.resolve(process.argv[2] || process.cwd())
11
+ const nodeVersion = '22.23.2'
12
+ const minimumNodeVersion = '22.18.0'
13
+ const nodeEngine = '>=22.18.0'
14
+ const npmVersion = '11.19.0'
15
+ const npmEngine = '>=11.19.0'
16
+ const packageManager = 'npm@11.19.0'
17
+ const expected = {
18
+ '@fastify/static': '10.1.3',
19
+ '@realsee/dnalogel': '3.81.2',
20
+ '@realsee/five': '6.8.9',
21
+ '@realsee/vapor': '0.2.12',
22
+ fastify: '5.12.1',
23
+ react: '18.3.1',
24
+ 'react-dom': '18.3.1',
25
+ three: '0.117.1',
26
+ }
27
+ const expectedDev = {
28
+ '@eslint/js': '10.0.1',
29
+ '@types/node': '24.13.3',
30
+ '@types/react': '18.3.12',
31
+ '@types/react-dom': '18.3.1',
32
+ '@vitejs/plugin-react': '4.3.1',
33
+ concurrently: '10.0.5',
34
+ eslint: '10.9.1',
35
+ 'eslint-plugin-react-hooks': '7.1.1',
36
+ globals: '17.11.0',
37
+ prettier: '3.9.6',
38
+ typescript: '5.9.3',
39
+ 'typescript-eslint': '8.68.0',
40
+ vite: '5.4.14',
41
+ }
42
+ const requiredEnvKeys = [
43
+ 'APP_ID',
44
+ 'REALSEE_GATEWAY_ENDPOINT',
45
+ 'REALSEE_GATEWAY_APP_KEY',
46
+ 'REALSEE_GATEWAY_APP_SECRET',
47
+ 'REALSEE_SOURCE',
48
+ ]
49
+ const credentialEnvKeys = [
50
+ 'REALSEE_GATEWAY_APP_KEY',
51
+ 'REALSEE_GATEWAY_APP_SECRET',
52
+ ]
53
+ const secretEnvKeys = ['REALSEE_GATEWAY_APP_SECRET']
54
+ const requiredFiles = [
55
+ 'package.json',
56
+ 'package-lock.json',
57
+ 'README.md',
58
+ 'index.html',
59
+ 'tsconfig.json',
60
+ 'tsconfig.server.json',
61
+ 'vite.config.ts',
62
+ '.editorconfig',
63
+ '.nvmrc',
64
+ '.node-version',
65
+ '.npmrc',
66
+ '.prettierignore',
67
+ '.vr-cli.json',
68
+ 'eslint.config.mjs',
69
+ 'prettier.config.mjs',
70
+ 'server/types.ts',
71
+ 'server/config.ts',
72
+ 'server/check-config.ts',
73
+ 'server/realsee-open-api.ts',
74
+ 'server/work-service.ts',
75
+ 'server/work-route.ts',
76
+ 'server/app.ts',
77
+ 'server/index.ts',
78
+ 'shared/model-floorplan.ts',
79
+ 'src/main.tsx',
80
+ 'src/app/App.tsx',
81
+ 'src/shared/useElementSize.ts',
82
+ 'src/vr/five/api.ts',
83
+ 'src/vr/five/fiveConfig.ts',
84
+ 'src/vr/five/SpatialRuntimeProvider.tsx',
85
+ 'src/vr/five/initialState.ts',
86
+ 'src/vr/plugins/dnalogel.ts',
87
+ 'src/vr/plugins/SpatialPluginRegistry.ts',
88
+ 'src/vr/plugins/examples/FeatureOwnedMeshPlugin.ts',
89
+ 'src/vr/work/WorkSource.ts',
90
+ 'src/features/floorplan/ModelFloorplanFeature.ts',
91
+ 'src/features/navigation/ModeSwitcher.tsx',
92
+ 'src/features/tags/PanoTagFeature.ts',
93
+ 'src/ui/ViewerOverlay.tsx',
94
+ 'src/styles.css',
95
+ 'AGENTS.md',
96
+ '.agents/skills/realsee-vr-app/SKILL.md',
97
+ '.agents/skills/realsee-vr-app/references/dnalogel-api-capabilities.md',
98
+ '.agents/skills/realsee-vr-app/references/five-api-capabilities.md',
99
+ '.agents/skills/realsee-vr-app/references/five-runtime.md',
100
+ '.agents/skills/realsee-vr-app/references/official-docs.md',
101
+ '.agents/skills/realsee-vr-app/references/plugin-lifecycle.md',
102
+ '.agents/skills/realsee-vr-app/references/ui-and-validation.md',
103
+ '.agents/skills/realsee-vr-app/references/vapor-dom-layer.md',
104
+ '.agents/skills/realsee-vr-app/scripts/verify-project.mjs',
105
+ '.agents/skills/realsee-image-resources/SKILL.md',
106
+ '.agents/skills/realsee-image-resources/references/cors-and-three-textures.md',
107
+ '.agents/skills/realsee-image-resources/references/data-wanxiang.md',
108
+ '.agents/skills/realsee-image-resources/references/five-image-url-transform.md',
109
+ '.agents/skills/realsee-image-resources/references/source-and-domain-policy.md',
110
+ '.agents/skills/threejs-r117-five/SKILL.md',
111
+ '.agents/skills/threejs-r117-five/references/r117-capabilities.md',
112
+ '.agents/skills/threejs-r117-five/references/r117-version-guards.md',
113
+ '.agents/skills/threejs-r117-five/references/five-ownership.md',
114
+ '.env.example',
115
+ '.gitignore',
116
+ ]
117
+ const fiveEntryPoints = [
118
+ '@realsee/five',
119
+ '@realsee/five/plugins',
120
+ '@realsee/five/line',
121
+ '@realsee/five/sticker',
122
+ '@realsee/five/vfx',
123
+ '@realsee/five/gltf-loader',
124
+ '@realsee/five/shader-lib',
125
+ '@realsee/five/work-downloader',
126
+ ]
127
+ // Three is the only hard-locked dependency: Five 6.8.9 targets r117.
128
+ // Every other dependency uses an open minimum range so it can move to any newer release.
129
+ const lockedDependencies = { three: expected.three }
130
+ const dependencySpec = (name, version) =>
131
+ name in lockedDependencies ? version : `>=${version}`
132
+ const specMinimumVersion = (spec) => {
133
+ if (typeof spec !== 'string') return null
134
+ const match = /^>=(\d+\.\d+\.\d+(?:[-+][\w.]+)?)$/.exec(spec.trim())
135
+ return match ? match[1] : null
136
+ }
137
+ const failures = []
138
+ const warnings = []
139
+ const isSensitiveKey = (key) =>
140
+ /(?:^|_)(?:AK|SK|SECRET|TOKEN|PASSWORD|CREDENTIALS?)(?:_|$)/i.test(key)
141
+ // Target the actual server-only configuration keys. A blanket `REALSEE_[A-Z0-9_]+`
142
+ // also matched harmless browser constants such as the documented
143
+ // `REALSEE_IMAGE_URL_MAPPINGS`, which the image-resource guidance itself recommends.
144
+ const serverOnlyBrowserPatterns = [
145
+ /\bAPP_ID\b/,
146
+ /\bREALSEE_(?:GATEWAY_[A-Z0-9_]+|SOURCE|ACCESS_TOKEN)\b/,
147
+ /\b(?:VITE_)?(?:AK|SK|ACCESS_TOKEN)\b/,
148
+ ]
149
+ const findServerOnlyBrowserMarker = (source) => {
150
+ for (const pattern of serverOnlyBrowserPatterns) {
151
+ const match = pattern.exec(source)
152
+ if (match) return match[0]
153
+ }
154
+ return null
155
+ }
156
+ const stripSourceComments = (source) =>
157
+ source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
158
+ // Representative high-signal regressions only; declarations, typecheck, build and browser validation remain authoritative.
159
+ const threeR117Regressions = [
160
+ [/three\/addons\//, 'r117 extensions must import from three/examples/jsm'],
161
+ [/three\/tsl\b/, 'Three r117 does not provide the modern TSL entry'],
162
+ [
163
+ /\b(?:SRGBColorSpace|LinearSRGBColorSpace|NoColorSpace|CapsuleGeometry|WebGPURenderer)\b/,
164
+ 'source uses a symbol unavailable in Three r117',
165
+ ],
166
+ [
167
+ /\b(?:renderer|[\w$]*Renderer)\.outputColorSpace\b/,
168
+ 'Three r117 uses renderer.outputEncoding',
169
+ ],
170
+ [
171
+ /\b(?:texture|[\w$]*(?:map|Map|texture|Texture))\.colorSpace\s*=/,
172
+ 'Three r117 textures use encoding instead of colorSpace',
173
+ ],
174
+ ]
175
+ const customPluginRequiredMarkers = [
176
+ "from '@realsee/five'",
177
+ "from 'three'",
178
+ 'FEATURE_OWNED_MESH_PLUGIN_NAME',
179
+ 'example.featureOwnedMesh',
180
+ 'new Group(',
181
+ 'new BoxBufferGeometry(',
182
+ 'new MeshBasicMaterial(',
183
+ 'new Mesh(',
184
+ 'five.scene.add(root)',
185
+ 'root.parent?.remove(root)',
186
+ 'five.needsRender = true',
187
+ 'geometry.dispose()',
188
+ 'material.dispose()',
189
+ 'plugins.register(FEATURE_OWNED_MESH_PLUGIN_NAME, plugin)',
190
+ 'plugin.dispose()',
191
+ ]
192
+ const customPluginForbiddenMarkers = [
193
+ 'new Five(',
194
+ 'new WebGLRenderer(',
195
+ 'renderer.render(',
196
+ 'requestAnimationFrame(',
197
+ 'five.dispose(',
198
+ 'three/addons/',
199
+ ]
200
+ const imageResourceGuidanceMarkers = [
201
+ 'Image resources use approved Realsee CDN domains only; never use `ljcdn.com`, `lianjia.com` or their subdomains.',
202
+ 'Prefer Five `defaultImageURLTransform` over hand-built 数据万象 query strings when its installed contract satisfies the request.',
203
+ 'URL transformation does not grant CORS access.',
204
+ "Ordinary images use the deployment's approved image-4 host.",
205
+ 'Feature media from OpenAPI or Work data is displayed through a derived sized variant, never the original: pass ordinary images through the reviewed `defaultImageURLTransform` adapter with a bounded `size`, and do not hand a full-resolution URL to a tag, tour, card or DOM thumbnail.',
206
+ ]
207
+ const vaporGuidanceMarkers = [
208
+ 'When the user explicitly requests Vapor components, use matching named exports from the installed `@realsee/vapor` package for every applicable DOM control; importing `Vapor` or `vapor.css` alone does not satisfy the request.',
209
+ 'Do not replace an available Vapor component with raw HTML or a locally styled replica; use raw DOM only for semantics or capabilities absent from the installed Vapor declarations, and record that gap.',
210
+ 'Before implementing Vapor UI, read `node_modules/@realsee/vapor/AI_CONTEXT.md`, `llms.txt`, `vapor.d.ts`, and the selected component declarations; never invent component names or props.',
211
+ "Use the installed `ai_guides` only for component discovery and composition recipes; the target project's root `vapor.d.ts` and selected component declarations are the API contract, and conflicts resolve in favor of installed declarations.",
212
+ 'Keep the Five canvas in its dedicated sibling host outside `Vapor`; Vapor layout components own DOM chrome only, even when an external recipe nests a placeholder VR component inside a stack.',
213
+ ]
214
+ const vaporReferenceMarkers = [
215
+ 'The current generated baseline is `@realsee/vapor@0.2.12`.',
216
+ 'The 0.2.12 baseline requires exactly one executable `@realsee/vapor/vapor.css` import, not `vapor.scss`',
217
+ 'Choose by product semantics before styling:',
218
+ '`HStack` arranges DOM horizontally, `VStack` vertically and `ZStack` overlays DOM children.',
219
+ 'The 0.2.12 match union is exact:',
220
+ 'if both represent the same physical inset, de-duplicate rather than adding it twice.',
221
+ ]
222
+ const vaporAcceptanceMarkers = [
223
+ 'For an explicit Vapor request, executable source must import and render the matching non-provider Vapor component; the provider and stylesheet alone are insufficient.',
224
+ 'exactly one executable `@realsee/vapor/vapor.css` import and no `vapor.scss` or copied internal stylesheet;',
225
+ 'the `Vapor` provider rectangle matches the DOM overlay host',
226
+ 'safe-area behavior uses the declared source/merge rule without double-counting',
227
+ 'correct portal order, focus entry/return, Escape/backdrop behavior and close reasons;',
228
+ 'no application stylesheet overrides `.vapor-*` internals',
229
+ 'typecheck validates every component prop, compound child and symbol',
230
+ ]
231
+ const vaporCssImportPattern =
232
+ /\bimport\s*['"]@realsee\/vapor\/vapor\.css['"]\s*;?/
233
+ const countVaporCssImports = (source) =>
234
+ source.match(new RegExp(vaporCssImportPattern.source, 'g'))?.length ?? 0
235
+ const hasVaporNamedImport = (source, symbol) =>
236
+ new RegExp(
237
+ `\\bimport\\s*\\{[^}]*\\b${symbol}\\b[^}]*\\}\\s*from\\s*['"]@realsee/vapor['"]`,
238
+ ).test(source)
239
+ const hasJsxElement = (source, symbol) =>
240
+ new RegExp(`<${symbol}(?:\\s|/?>)`).test(source)
241
+ const spatialTagGuidanceMarkers = [
242
+ 'Spatial capability resolution order: installed @realsee/dnalogel preset -> native Five -> Three.js.',
243
+ 'Tags, hotspots, and anchors default to PanoTagPlugin with GET /open/v3/hot/list.',
244
+ 'Normalize optional OpenAPI configuration fields such as `version`, `globalConfig` and `contentTypeConfig`: treat `null` as absent before validating the installed SDK contract.',
245
+ 'Load ordinary PanoTag point/plane tags after `five.load` without waiting for `five.model.loaded`; gate only model-dependent tag content or anchors on model readiness.',
246
+ 'Tours use Five State snapshots with GET /open/v3/observerpicture/search.',
247
+ 'Model annotations default to ModelMakerPlugin with GET /open/v3/annotation/search.',
248
+ 'Routes use GuideLinePlugin for rendering and shape-selected CruisePlugin or MovePlugin playback with GET /open/v3/route/search.',
249
+ 'Tour adapters read `data.list`, map each validated record to `Partial<State>` with the active `workCode`, and never cast an observer-picture record directly to `State`.',
250
+ 'Model annotation adapters read `data.list`, validate `object_data` for `box`, `triangles` or `prism`, and pass `{ list }` to ModelMaker only for a model-capable Work.',
251
+ 'Feature API, schema, or plugin failures disable only the affected feature; keep the base Viewer usable and never runtime-fallback to raw data or a custom Three implementation.',
252
+ 'Scope tour, ModelMaker, GuideLine, Cruise and Move work to an active `workCode`/generation token; abort fetches, ignore stale continuations, pause the owned playback controller, remove hooks, clear GuideLine data, wait for pending loads, then let identity-safe unregister dispose each registered controller exactly once before `five.dispose()`.',
253
+ 'SDK-shaped custom data requires an explicit developer opt-out from Open Platform data.',
254
+ 'VR spatial tag (spatial annotation) is the umbrella concept',
255
+ 'Classify content, renderer, anchor, and lifecycle/data ownership',
256
+ 'Never infer PanoTag from',
257
+ 'Never directly edit `work.json`, downloaded Work, or customer Work',
258
+ ]
259
+ const routeGuidanceMarkers = [
260
+ 'dnalogel 3.81.2 route playback is shape-selected: a validated pano group uses `CruisePlugin` `{ panoIndexList }`, while a validated model-space curve uses `MovePlugin` `{ path }`; `GuideLinePlugin` renders route geometry and is not playback input.',
261
+ '`CruisePlugin` accepts exactly one of `panoIndexList` or `keyframes`; its installed union has no type discriminator.',
262
+ 'OpenAPI route records are never passed raw: rename validated style config only for GuideLine `lines`, and never pass GuideLine `PathItem` arrays or style objects to `CruisePlugin` or `MovePlugin`.',
263
+ 'If a source record contains both `pano_group` and `path`, the route adapter must choose product semantics explicitly and pass only the selected geometry branch to GuideLine.',
264
+ '`GuideLinePlugin.load` waits for model readiness; `CruisePlugin` waits only for Work and observers; `MovePlugin` enters Model and awaits Five readiness when playback starts.',
265
+ 'Reject empty routes, unknown pano indexes, unsupported curves, non-finite coordinates, timing or speed, and ambiguous multi-segment Move paths before plugin load.',
266
+ 'Route cleanup pauses the owned playback controller before hook removal and disposal; `clear` belongs to GuideLine, is not a public Cruise operation, and GuideLine disposal does not own an external or embedded Cruise controller automatically.',
267
+ 'Do not invent `RoutePlugin`, `PanoRove`, `stop()`, or a universal route controller; use only the exports and methods declared by the installed package.',
268
+ ]
269
+ const floorplanGuidanceMarkers = [
270
+ 'Floorplans default to ModelFloorplanPlugin with GET /open/v3/floorplan/plugins.',
271
+ '`data.format_floorplan_url`, not `format_model_url`, resolves to the preferred `{ version, data: FloorplanServerData }` plugin input.',
272
+ 'Expose `capabilities.modelFloorplan` only after validating the complete wrapper and nested floorplan shape; missing, empty, malformed or failed floorplan data must leave the base Viewer usable.',
273
+ 'The default tabs are `漫游` for `Panorama` and `三维模型` for `Mapview`; do not expose `Topview` as `地图`.',
274
+ 'Add the `平面图` tab only after the capability validates and `ModelFloorplanPlugin.load` succeeds.',
275
+ 'Configure `ModelFloorplanPlugin` with `autoShowEnable: false`; the application tab owns explicit `show()`/`hide()`, uses `modelOpacity: 0` while the floorplan is visible, and restores the Five model surface on hide, failure, and disposal.',
276
+ 'Resolve the Five canvas parent and call `ModelFloorplanPlugin.appendTo(wrapper)` before `load`; `five.addExtraElement` registers input handling but does not mount the plugin DOM.',
277
+ 'For multi-floor Work, ModelFloorplanPlugin and Five pano/model floor synchronization are the floor source of truth; do not invent a React-only `floorIndex`.',
278
+ 'Floorplan show/hide, hooks, pending load and identity-safe disposal must finish before `five.dispose()`.',
279
+ ]
280
+ // One authoritative contract; always-on files keep the routing summary and the other
281
+ // references link to it instead of restating nine rules apiece.
282
+ const floorplanSummaryMarkers = floorplanGuidanceMarkers.slice(0, 3)
283
+ const floorplanPointerMarker = 'The complete model floorplan contract lives in'
284
+ const routePointerMarker = 'The complete route playback contract lives in'
285
+ const routeSummaryMarkers = [
286
+ routeGuidanceMarkers[0],
287
+ routeGuidanceMarkers[7],
288
+ routePointerMarker,
289
+ ]
290
+
291
+ const viewerOverlayComponents = [
292
+ 'Vapor',
293
+ 'Text',
294
+ 'TitleBar',
295
+ 'ZStack',
296
+ 'VStack',
297
+ 'Layer',
298
+ ]
299
+ const viewerModeSwitcherComponents = ['Button', 'Font', 'HStack', 'VStack']
300
+ // Project invariants only. `VaporConfig` defaults width/height to 100vw/100vh, so a
301
+ // provider inside the sized overlay host must override both; the status region must
302
+ // stay an accessible live region; safe-area insets must reach CSS. Exact copy, class
303
+ // names, spacing and decorative props are generator template fidelity instead.
304
+ const viewerOverlayInvariantMarkers = [
305
+ 'width="100%"',
306
+ 'height="100%"',
307
+ 'useSaveAreaInsets()',
308
+ 'useDimension()',
309
+ "role={error ? 'alert' : 'status'}",
310
+ "aria-live={error ? 'assertive' : 'polite'}",
311
+ 'env(safe-area-inset-${side}, 0px)',
312
+ ]
313
+ // Project invariants only: an accessible group and declaration-driven responsive
314
+ // branching. Exact sizes, weights and class names belong to generator template
315
+ // fidelity and are asserted by the generator's own check, not in a user project.
316
+ const viewerModeSwitcherAccessibilityMarkers = [
317
+ 'role="group"',
318
+ 'useDimension()',
319
+ ]
320
+ const viewerForbiddenOverlayMarkers = [
321
+ 'viewer-glass',
322
+ 'className="viewer-topbar"',
323
+ '<h1',
324
+ ]
325
+ const viewerForbiddenModeSwitcherMarkers = [
326
+ 'Segmented',
327
+ 'viewer-segmented',
328
+ 'viewer-glass',
329
+ 'appearance="horizontal"',
330
+ 'maxWidth="100%"',
331
+ ]
332
+ const viewerForbiddenStyleMarkers = [
333
+ '--viewer-glass-',
334
+ '--viewer-control-size',
335
+ '.vapor-segmented',
336
+ 'backdrop-filter',
337
+ ]
338
+
339
+ function versionAtLeast(actual, required) {
340
+ const parse = (version) => {
341
+ if (typeof version !== 'string') return null
342
+ const match =
343
+ /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
344
+ version.trim(),
345
+ )
346
+ return match
347
+ ? {
348
+ core: match.slice(1, 4).map(Number),
349
+ prerelease: match[4] ?? null,
350
+ }
351
+ : null
352
+ }
353
+ const actualVersion = parse(actual)
354
+ const requiredVersion = parse(required)
355
+ if (!actualVersion || !requiredVersion) return false
356
+ for (let index = 0; index < 3; index += 1) {
357
+ const difference = actualVersion.core[index] - requiredVersion.core[index]
358
+ if (difference > 0) return true
359
+ if (difference < 0) return false
360
+ }
361
+ if (actualVersion.prerelease === requiredVersion.prerelease) return true
362
+ if (actualVersion.prerelease === null) return true
363
+ if (requiredVersion.prerelease === null) return false
364
+ return actualVersion.prerelease.localeCompare(requiredVersion.prerelease, 'en', {
365
+ numeric: true,
366
+ }) >= 0
367
+ }
368
+
369
+ async function currentNpmVersion() {
370
+ const command = process.platform === 'win32' ? 'npm.cmd' : 'npm'
371
+ const { stdout } = await execFileAsync(command, ['--version'], {
372
+ encoding: 'utf8',
373
+ })
374
+ return stdout.trim()
375
+ }
376
+
377
+ async function exists(file) {
378
+ try {
379
+ await stat(path.join(root, file))
380
+ return true
381
+ } catch {
382
+ return false
383
+ }
384
+ }
385
+
386
+ async function collectSourceFiles(directory) {
387
+ const files = []
388
+ if (!(await exists(directory))) return files
389
+ for (const entry of await readdir(path.join(root, directory), {
390
+ withFileTypes: true,
391
+ })) {
392
+ const relative = path.join(directory, entry.name)
393
+ if (entry.isDirectory()) files.push(...(await collectSourceFiles(relative)))
394
+ else if (entry.isFile() && /\.(?:[cm]?[jt]sx?)$/.test(entry.name))
395
+ files.push(relative)
396
+ }
397
+ return files
398
+ }
399
+
400
+ function parseEnv(content) {
401
+ const values = new Map()
402
+ for (const line of content.split(/\r?\n/)) {
403
+ const trimmed = line.trim()
404
+ if (!trimmed || trimmed.startsWith('#')) continue
405
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed)
406
+ if (match) values.set(match[1], match[2].trim())
407
+ }
408
+ return values
409
+ }
410
+
411
+ if (!versionAtLeast(process.versions.node, minimumNodeVersion)) {
412
+ failures.push(
413
+ `Node must be >=${minimumNodeVersion}; current ${process.versions.node}`,
414
+ )
415
+ }
416
+ try {
417
+ const actualNpmVersion = await currentNpmVersion()
418
+ if (!versionAtLeast(actualNpmVersion, npmVersion)) {
419
+ failures.push(`npm must be >=${npmVersion}; current ${actualNpmVersion}`)
420
+ }
421
+ } catch (error) {
422
+ failures.push(`npm --version failed: ${String(error)}`)
423
+ }
424
+
425
+ try {
426
+ const pkg = JSON.parse(
427
+ await readFile(path.join(root, 'package.json'), 'utf8'),
428
+ )
429
+ if (pkg.private !== true) failures.push('package.json private must be true')
430
+ if (pkg.type !== 'module')
431
+ failures.push('package.json type must equal module')
432
+ if (pkg.packageManager !== packageManager)
433
+ failures.push(`packageManager must equal ${packageManager}`)
434
+ if (pkg.engines?.node !== nodeEngine)
435
+ failures.push(`engines.node must equal ${nodeEngine}`)
436
+ if (pkg.engines?.npm !== npmEngine)
437
+ failures.push(`engines.npm must equal ${npmEngine}`)
438
+ for (const [declared, baseline] of [
439
+ [pkg.dependencies, expected],
440
+ [pkg.devDependencies, expectedDev],
441
+ ]) {
442
+ for (const [name, version] of Object.entries(baseline)) {
443
+ const actual = declared?.[name]
444
+ if (actual === undefined) {
445
+ failures.push(
446
+ `${name} must be declared; generator baseline is ${version}`,
447
+ )
448
+ continue
449
+ }
450
+ if (name in lockedDependencies) {
451
+ if (actual !== version) {
452
+ failures.push(
453
+ `${name} must stay locked at ${version} because Five 6.8.9 targets that Three release; found ${actual}`,
454
+ )
455
+ }
456
+ continue
457
+ }
458
+ const minimum = specMinimumVersion(actual)
459
+ if (!minimum) {
460
+ failures.push(
461
+ `${name} must use an open minimum range >=x.y.z; generator baseline is >=${version}, found ${actual}`,
462
+ )
463
+ continue
464
+ }
465
+ if (!versionAtLeast(minimum, version)) {
466
+ failures.push(
467
+ `${name} declares minimum ${minimum}, below generator baseline ${version}`,
468
+ )
469
+ continue
470
+ }
471
+ if (actual !== dependencySpec(name, version)) {
472
+ warnings.push(
473
+ `${name} raised its minimum from generator baseline ${version} to ${minimum}; re-run npm run check and npm run build, and re-verify versioned fixtures for SDK upgrades`,
474
+ )
475
+ }
476
+ }
477
+ }
478
+ for (const [name, markers] of [
479
+ ['dev', ['check:env', 'concurrently']],
480
+ ['dev:bff', ['--env-file-if-exists=.env.development', 'server/index.ts']],
481
+ ['dev:web', ['vite']],
482
+ [
483
+ 'check:env',
484
+ ['--env-file-if-exists=.env.development', 'server/check-config.ts'],
485
+ ],
486
+ ['typecheck', ['npm run typecheck:web', 'npm run typecheck:bff']],
487
+ ['typecheck:web', ['tsc -p tsconfig.json']],
488
+ ['typecheck:bff', ['tsc -p tsconfig.server.json']],
489
+ ['lint', ['eslint . --max-warnings=0']],
490
+ ['format', ['prettier --write .']],
491
+ ['format:check', ['prettier --check .']],
492
+ ['check', ['npm run typecheck', 'npm run lint', 'npm run format:check']],
493
+ ['check:bff', ['typecheck:bff']],
494
+ ['deps:relock', ['npm install --package-lock-only']],
495
+ ['deps:install', ['npm ci']],
496
+ [
497
+ 'start',
498
+ [
499
+ '--env-file-if-exists=.env.development',
500
+ 'server/index.ts',
501
+ '--serve-frontend',
502
+ ],
503
+ ],
504
+ ]) {
505
+ for (const marker of markers) {
506
+ if (!pkg.scripts?.[name]?.includes(marker))
507
+ failures.push(`scripts.${name} must include ${marker}`)
508
+ }
509
+ }
510
+ if (await exists('package-lock.json')) {
511
+ const lockfile = JSON.parse(
512
+ await readFile(path.join(root, 'package-lock.json'), 'utf8'),
513
+ )
514
+ const lockRoot = lockfile.packages?.['']
515
+ if (lockfile.lockfileVersion !== 3 || !lockRoot)
516
+ failures.push(
517
+ 'package-lock.json must use lockfileVersion 3 with a root package',
518
+ )
519
+ else {
520
+ if (
521
+ lockfile.name !== pkg.name ||
522
+ lockRoot.name !== pkg.name ||
523
+ lockRoot.version !== pkg.version
524
+ ) {
525
+ failures.push(
526
+ 'package-lock.json root name/version must match package.json',
527
+ )
528
+ }
529
+ for (const [lockDeclared, declared, baseline, label] of [
530
+ [
531
+ lockRoot.dependencies,
532
+ pkg.dependencies,
533
+ expected,
534
+ 'dependencies',
535
+ ],
536
+ [
537
+ lockRoot.devDependencies,
538
+ pkg.devDependencies,
539
+ expectedDev,
540
+ 'devDependencies',
541
+ ],
542
+ ]) {
543
+ for (const [name, declaredSpec] of Object.entries(declared ?? {})) {
544
+ if (lockDeclared?.[name] !== declaredSpec) {
545
+ failures.push(
546
+ `package-lock.json ${label} must match package.json for ${name}@${declaredSpec}; run npm run deps:relock`,
547
+ )
548
+ }
549
+ const baselineVersion = baseline[name]
550
+ if (!baselineVersion) continue
551
+ const resolvedVersion =
552
+ lockfile.packages?.[`node_modules/${name}`]?.version
553
+ if (!resolvedVersion) {
554
+ failures.push(
555
+ `package-lock.json must contain a resolved direct version for ${name}`,
556
+ )
557
+ continue
558
+ }
559
+ if (name in lockedDependencies) {
560
+ if (resolvedVersion !== baselineVersion) {
561
+ failures.push(
562
+ `package-lock.json must resolve ${name} to ${baselineVersion}; found ${resolvedVersion}`,
563
+ )
564
+ }
565
+ continue
566
+ }
567
+ const declaredMinimum = specMinimumVersion(declaredSpec)
568
+ if (
569
+ declaredMinimum &&
570
+ (!versionAtLeast(resolvedVersion, baselineVersion) ||
571
+ !versionAtLeast(resolvedVersion, declaredMinimum))
572
+ ) {
573
+ failures.push(
574
+ `package-lock.json resolves ${name} to ${resolvedVersion}; it must satisfy generator baseline >=${baselineVersion} and declaration ${declaredSpec}`,
575
+ )
576
+ }
577
+ }
578
+ }
579
+ }
580
+ }
581
+ } catch (error) {
582
+ failures.push(`package metadata: ${String(error)}`)
583
+ }
584
+
585
+ for (const file of requiredFiles) {
586
+ if (!(await exists(file))) failures.push(`missing ${file}`)
587
+ }
588
+
589
+ if ((await exists('.vr-cli.json')) && (await exists('package.json'))) {
590
+ try {
591
+ const manifest = JSON.parse(
592
+ await readFile(path.join(root, '.vr-cli.json'), 'utf8'),
593
+ )
594
+ const pkg = JSON.parse(
595
+ await readFile(path.join(root, 'package.json'), 'utf8'),
596
+ )
597
+ if (manifest.schemaVersion !== 1)
598
+ failures.push('manifest schemaVersion must equal 1')
599
+ if (typeof manifest.toolVersion !== 'string' || !manifest.toolVersion)
600
+ failures.push('manifest toolVersion must be a non-empty string')
601
+ if (manifest.preset !== 'viewer')
602
+ failures.push('manifest preset must equal viewer')
603
+ if (manifest.packageName !== pkg.name)
604
+ failures.push('manifest packageName must match package.json name')
605
+ if (!['portable', 'all', 'none'].includes(manifest.agentAssets))
606
+ failures.push('manifest agentAssets must be portable, all or none')
607
+ if (Number.isNaN(Date.parse(manifest.generatedAt)))
608
+ failures.push('manifest generatedAt must be an ISO-compatible date')
609
+ if (Object.prototype.hasOwnProperty.call(manifest, 'files'))
610
+ failures.push('manifest must not contain a files inventory')
611
+ } catch (error) {
612
+ failures.push(`.vr-cli.json: ${String(error)}`)
613
+ }
614
+ }
615
+
616
+ for (const relativePath of ['.nvmrc', '.node-version']) {
617
+ if (await exists(relativePath)) {
618
+ const actual = (
619
+ await readFile(path.join(root, relativePath), 'utf8')
620
+ ).trim()
621
+ if (actual !== nodeVersion)
622
+ failures.push(`${relativePath} must equal ${nodeVersion}`)
623
+ }
624
+ }
625
+ if (await exists('.npmrc')) {
626
+ const lines = new Set(
627
+ (await readFile(path.join(root, '.npmrc'), 'utf8'))
628
+ .split(/\r?\n/)
629
+ .map((line) => line.trim())
630
+ .filter(Boolean),
631
+ )
632
+ for (const marker of [
633
+ 'engine-strict=true',
634
+ 'package-lock=true',
635
+ 'save-prefix=>=',
636
+ ]) {
637
+ if (!lines.has(marker)) failures.push(`.npmrc must contain ${marker}`)
638
+ }
639
+ }
640
+ for (const [relativePath, markers] of [
641
+ [
642
+ 'eslint.config.mjs',
643
+ [
644
+ "from '@eslint/js'",
645
+ "from 'typescript-eslint'",
646
+ "from 'eslint-plugin-react-hooks'",
647
+ 'recommendedTypeChecked',
648
+ 'tsconfig.server.json',
649
+ 'globals.browser',
650
+ 'globals.node',
651
+ "'react-hooks/rules-of-hooks'",
652
+ "'react-hooks/exhaustive-deps'",
653
+ 'no-floating-promises',
654
+ 'disableTypeChecked',
655
+ ],
656
+ ],
657
+ [
658
+ 'prettier.config.mjs',
659
+ ['semi: false', 'singleQuote: true', "trailingComma: 'all'"],
660
+ ],
661
+ [
662
+ 'tsconfig.json',
663
+ [
664
+ '"strict": true',
665
+ '"noUncheckedIndexedAccess": true',
666
+ '"noEmit": true',
667
+ '"include": ["src", "shared", "vite.config.ts"]',
668
+ ],
669
+ ],
670
+ [
671
+ 'tsconfig.server.json',
672
+ [
673
+ '"strict": true',
674
+ '"erasableSyntaxOnly": true',
675
+ '"verbatimModuleSyntax": true',
676
+ '"exactOptionalPropertyTypes": true',
677
+ '"noUncheckedIndexedAccess": true',
678
+ '"types": ["node"]',
679
+ '"include": ["server", "shared"]',
680
+ ],
681
+ ],
682
+ ]) {
683
+ if (!(await exists(relativePath))) continue
684
+ const source = await readFile(path.join(root, relativePath), 'utf8')
685
+ for (const marker of markers) {
686
+ if (!source.includes(marker))
687
+ failures.push(`${relativePath} missing tooling marker: ${marker}`)
688
+ }
689
+ }
690
+
691
+ if (await exists('src/main.tsx')) {
692
+ const main = await readFile(path.join(root, 'src/main.tsx'), 'utf8')
693
+ const mainSource = stripSourceComments(main)
694
+ if (
695
+ countVaporCssImports(mainSource) !== 1 ||
696
+ mainSource.includes('@realsee/vapor/vapor.scss')
697
+ )
698
+ failures.push(
699
+ 'Vapor CSS must use exactly one executable vapor.css side-effect import and must not use vapor.scss',
700
+ )
701
+ if (!mainSource.includes('./app/App'))
702
+ failures.push('main.tsx must compose the app layer')
703
+ }
704
+
705
+ if (await exists('src/vr/five/api.ts')) {
706
+ const api = stripSourceComments(
707
+ await readFile(path.join(root, 'src/vr/five/api.ts'), 'utf8'),
708
+ )
709
+ for (const entryPoint of fiveEntryPoints) {
710
+ if (!api.includes(`'${entryPoint}'`))
711
+ failures.push(`Five capability entry missing: ${entryPoint}`)
712
+ }
713
+ }
714
+
715
+ if (await exists('src/vr/plugins/dnalogel.ts')) {
716
+ const api = stripSourceComments(
717
+ await readFile(path.join(root, 'src/vr/plugins/dnalogel.ts'), 'utf8'),
718
+ )
719
+ if (!api.includes("from '@realsee/dnalogel'"))
720
+ failures.push('dnalogel preset root capability surface is missing')
721
+ }
722
+
723
+ if (await exists('src/vr/work/WorkSource.ts')) {
724
+ const source = stripSourceComments(
725
+ await readFile(path.join(root, 'src/vr/work/WorkSource.ts'), 'utf8'),
726
+ )
727
+ for (const marker of [
728
+ 'resourceCodeFromPathname',
729
+ 'window.location.pathname',
730
+ 'segments.length !== 1',
731
+ '`/api/work/${encodeURIComponent(resourceCode)}`',
732
+ 'asExplicitPanoTagList',
733
+ 'hasCanonicalPanoTagData(value.contentType, value.data)',
734
+ 'hasCanonicalPanoTagAnchor(value)',
735
+ 'asRecord(envelope.presetData)',
736
+ 'presetData: { panoTagList }',
737
+ 'displayName: string',
738
+ 'displayName: displayNameFrom(work.name) ?? workCode',
739
+ 'modelFloorplan?: ModelFloorplanServerData',
740
+ 'asModelFloorplanServerData(',
741
+ 'asRecord(envelope.capabilities).modelFloorplan',
742
+ 'modelFloorplan ? { capabilities: { modelFloorplan } } : {}',
743
+ 'new RouteWorkSource()',
744
+ ]) {
745
+ if (!source.includes(marker))
746
+ failures.push(
747
+ `Route WorkSource or explicit preset marker missing: ${marker}`,
748
+ )
749
+ }
750
+ for (const marker of [
751
+ 'VITE_WORK_URL',
752
+ '/work.json',
753
+ 'envelope.tags',
754
+ 'envelope.tagList',
755
+ 'envelope.tag_list',
756
+ 'payload.tags',
757
+ ]) {
758
+ if (source.includes(marker))
759
+ failures.push(
760
+ `WorkSource must not use a legacy URL/local Work or infer PanoTag from generic tags: ${marker}`,
761
+ )
762
+ }
763
+ }
764
+
765
+ for (const [relativePath, markers] of [
766
+ [
767
+ 'shared/model-floorplan.ts',
768
+ [
769
+ 'export function asModelFloorplanServerData(',
770
+ '!isInteger(value.version)',
771
+ 'value.version < 0',
772
+ '!isFloorplanServerData(value.data)',
773
+ 'value.outlines.length > 0',
774
+ 'value.floor_datas.length > 0',
775
+ 'isArrayOf(value.rooms, isRoom)',
776
+ 'isRules(value.rules)',
777
+ 'value.entrance === null || isEntrance(value.entrance)',
778
+ 'isBounding(value.bounding)',
779
+ 'isArrayOf(value.observers, isObserver)',
780
+ 'isArrayOf(value.doors, isDoor)',
781
+ ],
782
+ ],
783
+ [
784
+ 'server/types.ts',
785
+ [
786
+ 'WorkCapabilities',
787
+ 'modelFloorplan?: ModelFloorplanServerData',
788
+ 'ModelFloorplanClient',
789
+ 'RealseeSpatialClient',
790
+ 'extends WorkDetailClient, ModelFloorplanClient',
791
+ ],
792
+ ],
793
+ [
794
+ 'server/realsee-open-api.ts',
795
+ [
796
+ 'implements RealseeSpatialClient',
797
+ "FLOORPLAN_PLUGINS_PATH = '/open/v3/floorplan/plugins'",
798
+ 'asRecord(descriptor.data).format_floorplan_url',
799
+ "floorplanUrl.protocol !== 'https:'",
800
+ 'floorplanUrl.username',
801
+ 'floorplanUrl.password',
802
+ "redirect: 'error'",
803
+ 'asModelFloorplanServerData(payload)',
804
+ ],
805
+ ],
806
+ [
807
+ 'server/work-service.ts',
808
+ [
809
+ '.getModelFloorplan(resourceCode, { signal })',
810
+ '.catch((error: unknown) =>',
811
+ 'modelFloorplan ? { capabilities: { modelFloorplan } } : {}',
812
+ ],
813
+ ],
814
+ [
815
+ 'src/features/floorplan/ModelFloorplanFeature.ts',
816
+ [
817
+ '@realsee/dnalogel/libs/floorplan/ModelFloorplanPlugin',
818
+ 'dnalogel.modelFloorplan',
819
+ 'ModelFloorplanPlugin(five, { autoShowEnable: false })',
820
+ 'plugins.register(PLUGIN_NAME, candidate)',
821
+ 'candidate.dispose()',
822
+ 'const modelSurface = five.getElement()',
823
+ 'const wrapper = modelSurface?.parentElement',
824
+ 'plugin.appendTo(wrapper)',
825
+ 'plugin.load(serverData)',
826
+ 'modelOpacity: 0',
827
+ 'plugin.hide({ userAction: true })',
828
+ '.finally(restoreModelSurface)',
829
+ 'if (!state.visible) restoreModelSurface()',
830
+ 'plugins.unregister(PLUGIN_NAME, plugin)',
831
+ 'stopStateChange()',
832
+ 'if (disposeRequested) finalize()',
833
+ 'if (!loading) finalize()',
834
+ ],
835
+ ],
836
+ [
837
+ 'src/app/App.tsx',
838
+ [
839
+ 'payload.capabilities?.modelFloorplan',
840
+ 'if (modelFloorplan)',
841
+ 'createModelFloorplanFeature(five, plugins)',
842
+ 'await currentFloorplan.load(modelFloorplan)',
843
+ 'currentFloorplan.dispose()',
844
+ 'setFloorplanFeature(floorplan)',
845
+ 'floorplan?.dispose()',
846
+ ],
847
+ ],
848
+ [
849
+ 'src/features/navigation/ModeSwitcher.tsx',
850
+ [
851
+ 'floorplan?.subscribeVisibility',
852
+ 'await floorplan.show()',
853
+ 'if (floorplan?.visible) await floorplan.hide()',
854
+ "floorplan?.visible ? 'floorplan' : tabFromFiveMode(five.currentMode)",
855
+ 'Five.Mode.Panorama',
856
+ 'Five.Mode.Mapview',
857
+ ],
858
+ ],
859
+ ]) {
860
+ if (!(await exists(relativePath))) continue
861
+ const source = stripSourceComments(
862
+ await readFile(path.join(root, relativePath), 'utf8'),
863
+ )
864
+ for (const marker of markers) {
865
+ if (!source.includes(marker))
866
+ failures.push(
867
+ `${relativePath} missing floorplan validation, capability or lifecycle marker: ${marker}`,
868
+ )
869
+ }
870
+ }
871
+
872
+ if (await exists('server/realsee-open-api.ts')) {
873
+ const source = stripSourceComments(
874
+ await readFile(path.join(root, 'server/realsee-open-api.ts'), 'utf8'),
875
+ )
876
+ if (source.includes('format_model_url'))
877
+ failures.push(
878
+ 'floorplan descriptor must use format_floorplan_url, not format_model_url',
879
+ )
880
+ }
881
+
882
+ if (await exists('src/features/floorplan/ModelFloorplanFeature.ts')) {
883
+ const source = stripSourceComments(
884
+ await readFile(
885
+ path.join(root, 'src/features/floorplan/ModelFloorplanFeature.ts'),
886
+ 'utf8',
887
+ ),
888
+ )
889
+ for (const [pattern, label] of [
890
+ [/plugin\.hooks\.on\(\s*['"]show['"]/, 'show hook'],
891
+ [/plugin\.hooks\.on\(\s*['"]hide['"]/, 'hide hook'],
892
+ [/plugin\.hooks\.on\(\s*['"]stateChange['"]/, 'stateChange hook'],
893
+ ]) {
894
+ if (!pattern.test(source))
895
+ failures.push(`ModelFloorplanFeature missing ${label}`)
896
+ }
897
+ if (/autoShowEnable\s*:\s*true/.test(source))
898
+ failures.push('ModelFloorplanFeature must keep autoShowEnable: false')
899
+ const appendTo = source.indexOf('plugin.appendTo(wrapper)')
900
+ const pluginLoad = source.indexOf('plugin.load(serverData)')
901
+ if (appendTo < 0 || pluginLoad < appendTo) {
902
+ failures.push(
903
+ 'ModelFloorplanFeature must append plugin DOM to the Five host before loading format_floorplan_url data',
904
+ )
905
+ }
906
+ const stopShow = source.indexOf('stopShow()')
907
+ const stopHide = source.indexOf('stopHide()')
908
+ const stopStateChange = source.indexOf('stopStateChange()')
909
+ const pendingFinalize = source.indexOf('if (!loading) finalize()')
910
+ const finalizeStart = source.indexOf('const finalize =')
911
+ const finalizeRestore = source.indexOf('restoreModelSurface()', finalizeStart)
912
+ const unregister = source.indexOf(
913
+ 'plugins.unregister(PLUGIN_NAME, plugin)',
914
+ finalizeStart,
915
+ )
916
+ if (
917
+ stopShow < 0 ||
918
+ stopHide < stopShow ||
919
+ stopStateChange < stopHide ||
920
+ pendingFinalize < stopStateChange ||
921
+ finalizeStart < 0 ||
922
+ finalizeRestore < finalizeStart ||
923
+ unregister < finalizeRestore
924
+ ) {
925
+ failures.push(
926
+ 'ModelFloorplanFeature must restore the Five model surface on hide, failure and disposal, remove all hooks, then perform pending-load identity-safe cleanup',
927
+ )
928
+ }
929
+ }
930
+
931
+ if (await exists('src/features/navigation/ModeSwitcher.tsx')) {
932
+ const source = stripSourceComments(
933
+ await readFile(
934
+ path.join(root, 'src/features/navigation/ModeSwitcher.tsx'),
935
+ 'utf8',
936
+ ),
937
+ )
938
+ for (const [pattern, label] of [
939
+ [
940
+ /type\s+ViewerTab\s*=\s*['"]tour['"]\s*\|\s*['"]model['"]\s*\|\s*['"]floorplan['"]/,
941
+ 'three ViewerTab variants',
942
+ ],
943
+ [
944
+ /value\s*:\s*['"]tour['"]\s*,\s*text\s*:\s*['"]漫游['"]/,
945
+ 'Panorama 漫游 tab',
946
+ ],
947
+ [
948
+ /value\s*:\s*['"]model['"]\s*,\s*text\s*:\s*['"]三维模型['"]/,
949
+ 'Mapview 三维模型 tab',
950
+ ],
951
+ [
952
+ /\.\.\.\(\s*floorplan\s*\?\s*\[\{\s*value\s*:\s*['"]floorplan['"](?:\s+as\s+const)?\s*,\s*text\s*:\s*['"]平面图['"]\s*\}\]\s*:\s*\[\]\s*\)/,
953
+ 'conditional 平面图 tab',
954
+ ],
955
+ ]) {
956
+ if (!pattern.test(source)) failures.push(`ModeSwitcher missing ${label}`)
957
+ }
958
+ const floorplanTabSyncCount =
959
+ source.match(
960
+ /floorplan\?\.visible\s*\?\s*['"]floorplan['"]\s*:\s*tabFromFiveMode\(five\.currentMode\)/g,
961
+ )?.length ?? 0
962
+ if (floorplanTabSyncCount < 2) {
963
+ failures.push(
964
+ 'ModeSwitcher must sync its initial and failed-transition tabs from actual floorplan visibility',
965
+ )
966
+ }
967
+ if (
968
+ source.includes('Five.Mode.Topview') ||
969
+ /text\s*:\s*['"]地图['"]/.test(source)
970
+ ) {
971
+ failures.push('ModeSwitcher must not expose Topview as a 地图 tab')
972
+ }
973
+ }
974
+
975
+ if (await exists('src/features/tags/PanoTagFeature.ts')) {
976
+ const source = stripSourceComments(
977
+ await readFile(
978
+ path.join(root, 'src/features/tags/PanoTagFeature.ts'),
979
+ 'utf8',
980
+ ),
981
+ )
982
+ for (const marker of [
983
+ "from '@realsee/dnalogel/libs/PanoTagPlugin'",
984
+ "const PLUGIN_NAME = 'dnalogel.panoTag'",
985
+ 'const candidate = PanoTagPlugin(',
986
+ 'plugins.register(PLUGIN_NAME, candidate)',
987
+ 'candidate.dispose()',
988
+ "async load(panoTagList: Tags['tagList'])",
989
+ 'plugin.load({ tagList: panoTagList })',
990
+ 'const disposeError = plugins.unregister(PLUGIN_NAME, plugin)',
991
+ ]) {
992
+ if (!source.includes(marker))
993
+ failures.push(
994
+ `PanoTag compatibility, explicit type or ownership marker missing: ${marker}`,
995
+ )
996
+ }
997
+ for (const marker of [
998
+ 'model_file',
999
+ "as Tags['tagList']",
1000
+ "from '../../vr/work/WorkSource'",
1001
+ ]) {
1002
+ if (source.includes(marker))
1003
+ failures.push(
1004
+ `PanoTag must not use a non-canonical type or unchecked Work conversion: ${marker}`,
1005
+ )
1006
+ }
1007
+ }
1008
+
1009
+ if (await exists('src/vr/plugins/examples/FeatureOwnedMeshPlugin.ts')) {
1010
+ const source = stripSourceComments(
1011
+ await readFile(
1012
+ path.join(root, 'src/vr/plugins/examples/FeatureOwnedMeshPlugin.ts'),
1013
+ 'utf8',
1014
+ ),
1015
+ )
1016
+ for (const marker of customPluginRequiredMarkers) {
1017
+ if (!source.includes(marker))
1018
+ failures.push(
1019
+ `FeatureOwnedMeshPlugin ownership marker missing: ${marker}`,
1020
+ )
1021
+ }
1022
+ for (const marker of customPluginForbiddenMarkers) {
1023
+ if (source.includes(marker))
1024
+ failures.push(
1025
+ `FeatureOwnedMeshPlugin must not own Five runtime/render loop: ${marker}`,
1026
+ )
1027
+ }
1028
+ }
1029
+
1030
+ for (const sourceFile of await collectSourceFiles('src')) {
1031
+ const source = stripSourceComments(
1032
+ await readFile(path.join(root, sourceFile), 'utf8'),
1033
+ )
1034
+ if (source.includes('@realsee/five/react')) {
1035
+ failures.push(
1036
+ `${sourceFile} must use the native Five API instead of @realsee/five/react`,
1037
+ )
1038
+ }
1039
+ const serverOnlyMarker = findServerOnlyBrowserMarker(source)
1040
+ if (serverOnlyMarker) {
1041
+ failures.push(
1042
+ `${sourceFile} must not read server-only config or credentials: ${serverOnlyMarker}`,
1043
+ )
1044
+ }
1045
+ for (const [pattern, message] of threeR117Regressions) {
1046
+ if (pattern.test(source)) failures.push(`${sourceFile}: ${message}`)
1047
+ }
1048
+ }
1049
+
1050
+ if (await exists('vite.config.ts')) {
1051
+ const config = stripSourceComments(
1052
+ await readFile(path.join(root, 'vite.config.ts'), 'utf8'),
1053
+ )
1054
+ const serverOnlyMarker = findServerOnlyBrowserMarker(config)
1055
+ if (serverOnlyMarker || /envPrefix\s*:\s*[^\n]*REALSEE_/.test(config)) {
1056
+ failures.push(
1057
+ `vite.config.ts must not expose server-only config, AK/SK/token or REALSEE_*: ${serverOnlyMarker ?? 'envPrefix'}`,
1058
+ )
1059
+ }
1060
+ const hostCount =
1061
+ config.match(/\bhost\s*:\s*['"]127\.0\.0\.1['"]/g)?.length ?? 0
1062
+ const portCount = config.match(/\bport\s*:\s*8087\b/g)?.length ?? 0
1063
+ const strictPortCount =
1064
+ config.match(/\bstrictPort\s*:\s*true\b/g)?.length ?? 0
1065
+ const hasBffProxy = /target\s*:\s*['"]http:\/\/127\.0\.0\.1:8088['"]/.test(
1066
+ config,
1067
+ )
1068
+ if (hostCount < 2 || portCount < 2 || strictPortCount < 2 || !hasBffProxy) {
1069
+ failures.push(
1070
+ 'Vite must use strict IPv4 127.0.0.1:8087 and proxy /api to Fastify at 127.0.0.1:8088',
1071
+ )
1072
+ }
1073
+ }
1074
+
1075
+ if (await exists('src/vr/five/SpatialRuntimeProvider.tsx')) {
1076
+ const runtime = await readFile(
1077
+ path.join(root, 'src/vr/five/SpatialRuntimeProvider.tsx'),
1078
+ 'utf8',
1079
+ )
1080
+ const runtimeSource = stripSourceComments(runtime)
1081
+ if (
1082
+ runtimeSource.includes('unsafe__useFiveInstance') ||
1083
+ runtimeSource.includes('provider?.five')
1084
+ ) {
1085
+ failures.push('Spatial runtime must not obtain Five from a React wrapper')
1086
+ }
1087
+ if (
1088
+ !/import\s*\{[^}]*\bFive\b[^}]*\}\s*from\s*['"]@realsee\/five['"]/.test(
1089
+ runtimeSource,
1090
+ )
1091
+ ) {
1092
+ failures.push(
1093
+ 'SpatialRuntimeProvider must import native Five from @realsee/five',
1094
+ )
1095
+ }
1096
+ for (const marker of [
1097
+ 'new Five(',
1098
+ '.appendTo(',
1099
+ '.refresh(',
1100
+ '.getElement()',
1101
+ '.dispose()',
1102
+ 'new ResizeObserver',
1103
+ 'new SpatialRuntimeLifetime',
1104
+ 'runExclusive<T>(',
1105
+ 'ownedLifetime?.disposeFeatures()',
1106
+ 'ownedLifetime.afterPending(finalize)',
1107
+ 'width <= 0 || height <= 0',
1108
+ 'className="five-surface"',
1109
+ ]) {
1110
+ if (!runtimeSource.includes(marker))
1111
+ failures.push(`Native Five lifecycle missing: ${marker}`)
1112
+ }
1113
+ const featureCleanup = runtimeSource.indexOf(
1114
+ 'ownedLifetime?.disposeFeatures()',
1115
+ )
1116
+ const deferredFinalize = runtimeSource.indexOf(
1117
+ 'ownedLifetime.afterPending(finalize)',
1118
+ )
1119
+ if (featureCleanup < 0 || deferredFinalize < featureCleanup) {
1120
+ failures.push(
1121
+ 'Features must stop before pending native work, plugins, and Five are finalized',
1122
+ )
1123
+ }
1124
+ }
1125
+
1126
+ if (await exists('src/app/App.tsx')) {
1127
+ const app = await readFile(path.join(root, 'src/app/App.tsx'), 'utf8')
1128
+ const appSource = stripSourceComments(app)
1129
+ for (const marker of [
1130
+ 'lifetime.own(',
1131
+ 'lifetime.runExclusive(',
1132
+ 'await five.load(',
1133
+ 'await waitForActiveModel(',
1134
+ 'const panoTagList = payload.presetData?.panoTagList',
1135
+ 'if (panoTagList?.length)',
1136
+ 'await currentTags.load(panoTagList)',
1137
+ 'setWorkName(payload.displayName)',
1138
+ 'title={workName}',
1139
+ ]) {
1140
+ if (!appSource.includes(marker))
1141
+ failures.push(
1142
+ `App native async lifetime or explicit PanoTag condition missing: ${marker}`,
1143
+ )
1144
+ }
1145
+ if (appSource.includes('payload.tags'))
1146
+ failures.push('App must not load generic payload.tags as PanoTag')
1147
+ }
1148
+
1149
+ if (await exists('src/ui/ViewerOverlay.tsx')) {
1150
+ const overlay = stripSourceComments(
1151
+ await readFile(path.join(root, 'src/ui/ViewerOverlay.tsx'), 'utf8'),
1152
+ )
1153
+ const missingOverlayComponents = viewerOverlayComponents.filter(
1154
+ (component) =>
1155
+ !hasVaporNamedImport(overlay, component) ||
1156
+ !hasJsxElement(overlay, component),
1157
+ )
1158
+ if (missingOverlayComponents.length > 0) {
1159
+ failures.push(
1160
+ `ViewerOverlay must import and render the official Vapor demo components: ${missingOverlayComponents.join(', ')}`,
1161
+ )
1162
+ }
1163
+ // Exact copy, class names and pixel placement are generator template fidelity,
1164
+ // not project invariants. The generator asserts those against a freshly created
1165
+ // project; enforcing them here would fail an owner who legitimately restyles
1166
+ // their own overlay. Component fidelity and the invariants below stay.
1167
+ for (const marker of viewerOverlayInvariantMarkers) {
1168
+ if (!overlay.includes(marker))
1169
+ failures.push(
1170
+ `ViewerOverlay must keep the provider sizing, safe-area and accessible status invariants: ${marker}`,
1171
+ )
1172
+ }
1173
+ for (const marker of viewerForbiddenOverlayMarkers) {
1174
+ if (overlay.includes(marker))
1175
+ failures.push(
1176
+ `ViewerOverlay must not replace Vapor components with a raw title or custom glass styling: ${marker}`,
1177
+ )
1178
+ }
1179
+ }
1180
+
1181
+ if (await exists('src/features/navigation/ModeSwitcher.tsx')) {
1182
+ const modeSwitcher = stripSourceComments(
1183
+ await readFile(
1184
+ path.join(root, 'src/features/navigation/ModeSwitcher.tsx'),
1185
+ 'utf8',
1186
+ ),
1187
+ )
1188
+ const missingModeSwitcherComponents = viewerModeSwitcherComponents.filter(
1189
+ (component) =>
1190
+ !hasVaporNamedImport(modeSwitcher, component) ||
1191
+ !hasJsxElement(modeSwitcher, component),
1192
+ )
1193
+ if (missingModeSwitcherComponents.length > 0) {
1194
+ failures.push(
1195
+ `ModeSwitcher must import and render the official Vapor demo components: ${missingModeSwitcherComponents.join(', ')}`,
1196
+ )
1197
+ }
1198
+ for (const marker of viewerModeSwitcherAccessibilityMarkers) {
1199
+ if (!modeSwitcher.includes(marker))
1200
+ failures.push(
1201
+ `ModeSwitcher must keep its accessible grouping and responsive branching: ${marker}`,
1202
+ )
1203
+ }
1204
+ for (const marker of viewerForbiddenModeSwitcherMarkers) {
1205
+ if (modeSwitcher.includes(marker))
1206
+ failures.push(
1207
+ `ModeSwitcher must not regress to Segmented or old stretched/glass styling: ${marker}`,
1208
+ )
1209
+ }
1210
+ }
1211
+
1212
+ for (const uiFile of await collectSourceFiles('src/ui')) {
1213
+ const source = stripSourceComments(
1214
+ await readFile(path.join(root, uiFile), 'utf8'),
1215
+ )
1216
+ const importsVrRuntime = /(?:from|import)\s*(?:\(\s*)?['"][^'"]*\/vr\//.test(
1217
+ source,
1218
+ )
1219
+ if (
1220
+ source.includes('@realsee/five') ||
1221
+ source.includes('@realsee/dnalogel') ||
1222
+ importsVrRuntime
1223
+ ) {
1224
+ failures.push(
1225
+ `${uiFile} must not import src/vr, Five, or dnalogel spatial runtime`,
1226
+ )
1227
+ }
1228
+ }
1229
+
1230
+ if (await exists('src/styles.css')) {
1231
+ const styles = await readFile(path.join(root, 'src/styles.css'), 'utf8')
1232
+ if (
1233
+ !styles.includes('.five-surface') ||
1234
+ !styles.includes('.dom-overlay') ||
1235
+ !styles.includes('pointer-events: none')
1236
+ ) {
1237
+ failures.push(
1238
+ 'Native Five surface and DOM overlay must preserve independent layering and pointer-event isolation',
1239
+ )
1240
+ }
1241
+ if (!styles.includes('pointer-events: auto')) {
1242
+ failures.push(
1243
+ 'Visible overlay controls must re-enable pointer events with `pointer-events: auto`',
1244
+ )
1245
+ }
1246
+ for (const marker of viewerForbiddenStyleMarkers) {
1247
+ if (styles.includes(marker))
1248
+ failures.push(
1249
+ `Viewer styles must not override or redraw Vapor internals: ${marker}`,
1250
+ )
1251
+ }
1252
+ }
1253
+
1254
+ if (await exists('.env.example')) {
1255
+ const values = parseEnv(
1256
+ await readFile(path.join(root, '.env.example'), 'utf8'),
1257
+ )
1258
+ for (const key of requiredEnvKeys) {
1259
+ if (!values.has(key)) failures.push(`.env.example missing ${key}`)
1260
+ }
1261
+ for (const [key, value] of values) {
1262
+ if (key.startsWith('VITE_WORK_'))
1263
+ failures.push(
1264
+ `${key} is obsolete; Work identity comes from /{resourceCode}`,
1265
+ )
1266
+ if (key.startsWith('VITE_') && isSensitiveKey(key))
1267
+ failures.push(`${key} must not be browser-visible`)
1268
+ if (
1269
+ isSensitiveKey(key) &&
1270
+ !secretEnvKeys.includes(key) &&
1271
+ value &&
1272
+ value !== '*'
1273
+ ) {
1274
+ failures.push(`${key} must be empty or use the * example placeholder`)
1275
+ }
1276
+ }
1277
+ for (const key of credentialEnvKeys) {
1278
+ const value = values.get(key)
1279
+ if (value !== undefined && value !== '' && value !== '*') {
1280
+ failures.push(`${key} must be empty or use the * example placeholder`)
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ if (await exists('.env.server.example')) {
1286
+ failures.push(
1287
+ 'legacy .env.server.example must not exist; use the unified .env.example',
1288
+ )
1289
+ }
1290
+
1291
+ if (await exists('.gitignore')) {
1292
+ const patterns = new Set(
1293
+ (await readFile(path.join(root, '.gitignore'), 'utf8'))
1294
+ .split(/\r?\n/)
1295
+ .map((line) => line.trim())
1296
+ .filter(Boolean),
1297
+ )
1298
+ for (const pattern of ['.env*', '!.env.example']) {
1299
+ if (!patterns.has(pattern))
1300
+ failures.push(`.gitignore must contain ${pattern}`)
1301
+ }
1302
+ }
1303
+
1304
+ for (const [relativePath, markers] of [
1305
+ [
1306
+ 'server/types.ts',
1307
+ ['ServerConfig', 'WorkEnvelope', 'WorkDetailClient', 'WorkService'],
1308
+ ],
1309
+ [
1310
+ 'server/config.ts',
1311
+ [
1312
+ 'loadServerConfig',
1313
+ 'REALSEE_GATEWAY_APP_KEY',
1314
+ 'REALSEE_GATEWAY_APP_SECRET',
1315
+ "gatewayEndpoint.protocol !== 'https:'",
1316
+ '): ServerConfig {',
1317
+ ],
1318
+ ],
1319
+ [
1320
+ 'server/check-config.ts',
1321
+ ['loadServerConfig()', 'BFF server configuration is valid.'],
1322
+ ],
1323
+ [
1324
+ 'server/realsee-open-api.ts',
1325
+ [
1326
+ 'RealseeOpenApiClient',
1327
+ 'implements RealseeSpatialClient',
1328
+ "ACCESS_TOKEN_PATH = '/auth/access_token'",
1329
+ "'application/x-www-form-urlencoded'",
1330
+ "WORK_INFO_PATH = '/open/v3/vr/info'",
1331
+ 'Authorization: accessToken',
1332
+ 'resource_code: resourceCode',
1333
+ 'UNAUTHORIZED_UPSTREAM_CODES',
1334
+ 'TOKEN_REFRESH_SKEW_MS',
1335
+ ],
1336
+ ],
1337
+ [
1338
+ 'server/work-service.ts',
1339
+ [
1340
+ 'createWorkService',
1341
+ '): WorkService {',
1342
+ 'getWorkByResourceCode',
1343
+ 'openApiClient.getWorkDetail(resourceCode, { signal })',
1344
+ ],
1345
+ ],
1346
+ [
1347
+ 'server/work-route.ts',
1348
+ [
1349
+ "'/api/work/:resourceCode'",
1350
+ 'getWorkByResourceCode(resourceCode, {',
1351
+ 'signal: requestAbort.signal',
1352
+ 'WORK_UPSTREAM_FAILED',
1353
+ ],
1354
+ ],
1355
+ [
1356
+ 'server/app.ts',
1357
+ [
1358
+ 'Fastify({ logger })',
1359
+ "'/api/health'",
1360
+ 'fastifyStatic',
1361
+ 'serveFrontend',
1362
+ "reply.sendFile('index.html')",
1363
+ 'Promise<FastifyInstance>',
1364
+ ],
1365
+ ],
1366
+ [
1367
+ 'server/index.ts',
1368
+ [
1369
+ 'loadServerConfig',
1370
+ 'RealseeOpenApiClient',
1371
+ 'createWorkService',
1372
+ 'createApp',
1373
+ 'app.listen({ host: config.host, port: config.port })',
1374
+ ],
1375
+ ],
1376
+ ]) {
1377
+ if (!(await exists(relativePath))) continue
1378
+ const source = stripSourceComments(
1379
+ await readFile(path.join(root, relativePath), 'utf8'),
1380
+ )
1381
+ for (const marker of markers) {
1382
+ if (!source.includes(marker))
1383
+ failures.push(
1384
+ `${relativePath} missing Fastify BFF or Realsee OpenAPI contract: ${marker}`,
1385
+ )
1386
+ }
1387
+ }
1388
+
1389
+ for (const relativePath of [
1390
+ 'AGENTS.md',
1391
+ '.agents/skills/realsee-vr-app/SKILL.md',
1392
+ '.agents/skills/realsee-image-resources/SKILL.md',
1393
+ ]) {
1394
+ if (!(await exists(relativePath))) continue
1395
+ const source = await readFile(path.join(root, relativePath), 'utf8')
1396
+ for (const marker of imageResourceGuidanceMarkers) {
1397
+ if (!source.includes(marker))
1398
+ failures.push(
1399
+ `${relativePath} missing image resource domain, Five URL transform, 数据万象 or CORS rule: ${marker}`,
1400
+ )
1401
+ }
1402
+ }
1403
+
1404
+ for (const [relativePath, routeMarkers] of [
1405
+ // AGENTS.md is always-on context, so it keeps the full route contract. SKILL.md is a
1406
+ // router and carries the two decisive rules plus a pointer to the contract.
1407
+ ['AGENTS.md', routeGuidanceMarkers],
1408
+ ['.agents/skills/realsee-vr-app/SKILL.md', routeSummaryMarkers],
1409
+ ]) {
1410
+ if (!(await exists(relativePath))) continue
1411
+ const source = await readFile(path.join(root, relativePath), 'utf8')
1412
+ for (const marker of spatialTagGuidanceMarkers) {
1413
+ if (!source.includes(marker))
1414
+ failures.push(
1415
+ `${relativePath} missing spatial-tag decision or read-only rule: ${marker}`,
1416
+ )
1417
+ }
1418
+ for (const marker of routeMarkers) {
1419
+ if (!source.includes(marker))
1420
+ failures.push(
1421
+ `${relativePath} missing point/custom route selection, validation or lifecycle rule: ${marker}`,
1422
+ )
1423
+ }
1424
+ }
1425
+
1426
+ for (const relativePath of [
1427
+ 'AGENTS.md',
1428
+ '.agents/skills/realsee-vr-app/SKILL.md',
1429
+ '.agents/skills/realsee-vr-app/references/vapor-dom-layer.md',
1430
+ ]) {
1431
+ if (!(await exists(relativePath))) continue
1432
+ const source = await readFile(path.join(root, relativePath), 'utf8')
1433
+ for (const marker of vaporGuidanceMarkers) {
1434
+ if (!source.includes(marker))
1435
+ failures.push(
1436
+ `${relativePath} missing Vapor component fidelity rule: ${marker}`,
1437
+ )
1438
+ }
1439
+ }
1440
+
1441
+ for (const [relativePath, markers, label] of [
1442
+ [
1443
+ '.agents/skills/realsee-vr-app/references/vapor-dom-layer.md',
1444
+ vaporReferenceMarkers,
1445
+ 'versioned Vapor reference',
1446
+ ],
1447
+ [
1448
+ '.agents/skills/realsee-vr-app/references/ui-and-validation.md',
1449
+ vaporAcceptanceMarkers,
1450
+ 'Vapor runtime acceptance',
1451
+ ],
1452
+ ]) {
1453
+ if (!(await exists(relativePath))) continue
1454
+ const source = await readFile(path.join(root, relativePath), 'utf8')
1455
+ for (const marker of markers) {
1456
+ if (!source.includes(marker))
1457
+ failures.push(`${relativePath} missing ${label} rule: ${marker}`)
1458
+ }
1459
+ }
1460
+
1461
+ if (
1462
+ await exists(
1463
+ '.agents/skills/realsee-vr-app/references/dnalogel-api-capabilities.md',
1464
+ )
1465
+ ) {
1466
+ const source = await readFile(
1467
+ path.join(
1468
+ root,
1469
+ '.agents/skills/realsee-vr-app/references/dnalogel-api-capabilities.md',
1470
+ ),
1471
+ 'utf8',
1472
+ )
1473
+ for (const marker of [
1474
+ 'Spatial capability resolution order: installed @realsee/dnalogel preset -> native Five -> Three.js.',
1475
+ 'Tags, hotspots, and anchors default to PanoTagPlugin with GET /open/v3/hot/list.',
1476
+ 'Normalize optional OpenAPI configuration fields such as `version`, `globalConfig` and `contentTypeConfig`: treat `null` as absent before validating the installed SDK contract.',
1477
+ 'Load ordinary PanoTag point/plane tags after `five.load` without waiting for `five.model.loaded`; gate only model-dependent tag content or anchors on model readiness.',
1478
+ 'Tours use Five State snapshots with GET /open/v3/observerpicture/search.',
1479
+ 'Model annotations default to ModelMakerPlugin with GET /open/v3/annotation/search.',
1480
+ 'Routes use GuideLinePlugin for rendering and shape-selected CruisePlugin or MovePlugin playback with GET /open/v3/route/search.',
1481
+ 'Tour adapters read `data.list`, map each validated record to `Partial<State>` with the active `workCode`, and never cast an observer-picture record directly to `State`.',
1482
+ 'Model annotation adapters read `data.list`, validate `object_data` for `box`, `triangles` or `prism`, and pass `{ list }` to ModelMaker only for a model-capable Work.',
1483
+ 'Feature API, schema, or plugin failures disable only the affected feature; keep the base Viewer usable and never runtime-fallback to raw data or a custom Three implementation.',
1484
+ 'Scope tour, ModelMaker, GuideLine, Cruise and Move work to an active `workCode`/generation token; abort fetches, ignore stale continuations, pause the owned playback controller, remove hooks, clear GuideLine data, wait for pending loads, then let identity-safe unregister dispose each registered controller exactly once before `five.dispose()`.',
1485
+ 'SDK-shaped custom data requires an explicit developer opt-out from Open Platform data.',
1486
+ '`model_file` is not canonical',
1487
+ '`ModelTVVideoPlugin`',
1488
+ '`CSS3DRenderPlugin.create3DDomContainer`',
1489
+ '`PanoVideoPlugin`',
1490
+ 'generic `tags`, `tagList` and `tag_list`',
1491
+ '`ServerData` with `box`, `triangles` or `prism` items',
1492
+ ...routeGuidanceMarkers,
1493
+ ]) {
1494
+ if (!source.includes(marker))
1495
+ failures.push(
1496
+ `dnalogel capability map missing capability order, data source or plugin route: ${marker}`,
1497
+ )
1498
+ }
1499
+ }
1500
+
1501
+ for (const [relativePath, markers] of [
1502
+ [
1503
+ '.agents/skills/realsee-vr-app/references/five-runtime.md',
1504
+ routeGuidanceMarkers.slice(4, 6),
1505
+ ],
1506
+ [
1507
+ '.agents/skills/realsee-vr-app/references/plugin-lifecycle.md',
1508
+ routeGuidanceMarkers.slice(6, 8),
1509
+ ],
1510
+ [
1511
+ '.agents/skills/realsee-vr-app/references/official-docs.md',
1512
+ routeGuidanceMarkers.slice(2, 4),
1513
+ ],
1514
+ ]) {
1515
+ if (!(await exists(relativePath))) continue
1516
+ const source = await readFile(path.join(root, relativePath), 'utf8')
1517
+ for (const marker of markers) {
1518
+ if (!source.includes(marker))
1519
+ failures.push(`${relativePath} missing route contract marker: ${marker}`)
1520
+ }
1521
+ }
1522
+
1523
+ for (const [relativePath, markers] of [
1524
+ [
1525
+ '.agents/skills/realsee-vr-app/references/dnalogel-api-capabilities.md',
1526
+ floorplanGuidanceMarkers,
1527
+ ],
1528
+ ['AGENTS.md', floorplanSummaryMarkers],
1529
+ ['.agents/skills/realsee-vr-app/SKILL.md', floorplanSummaryMarkers],
1530
+ [
1531
+ '.agents/skills/realsee-vr-app/references/five-runtime.md',
1532
+ [floorplanPointerMarker],
1533
+ ],
1534
+ [
1535
+ '.agents/skills/realsee-vr-app/references/official-docs.md',
1536
+ [floorplanPointerMarker],
1537
+ ],
1538
+ [
1539
+ '.agents/skills/realsee-vr-app/references/plugin-lifecycle.md',
1540
+ [floorplanPointerMarker],
1541
+ ],
1542
+ [
1543
+ '.agents/skills/realsee-vr-app/references/ui-and-validation.md',
1544
+ [floorplanPointerMarker],
1545
+ ],
1546
+ ]) {
1547
+ if (!(await exists(relativePath))) continue
1548
+ const source = await readFile(path.join(root, relativePath), 'utf8')
1549
+ for (const marker of markers) {
1550
+ if (!source.includes(marker))
1551
+ failures.push(
1552
+ `${relativePath} missing floorplan endpoint, validation, tab, multi-floor or cleanup rule: ${marker}`,
1553
+ )
1554
+ }
1555
+ }
1556
+
1557
+ if (warnings.length) {
1558
+ console.warn(warnings.map((warning) => `! ${warning}`).join('\n'))
1559
+ }
1560
+
1561
+ if (failures.length) {
1562
+ console.error(failures.map((failure) => `- ${failure}`).join('\n'))
1563
+ process.exitCode = 1
1564
+ } else {
1565
+ console.log(`Realsee spatial project verification passed: ${root}`)
1566
+ }