sceneview-mcp 3.0.1 → 3.0.2

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.
package/dist/samples.js CHANGED
@@ -3,6 +3,7 @@ export const SAMPLES = {
3
3
  id: "model-viewer",
4
4
  title: "3D Model Viewer",
5
5
  description: "Full-screen 3D scene with a GLB model, HDR environment, and orbit camera",
6
+ tags: ["3d", "model", "environment", "camera"],
6
7
  dependency: "io.github.sceneview:sceneview:3.0.0",
7
8
  prompt: "Create an Android Compose screen called `ModelViewerScreen` that loads a GLB file from assets/models/my_model.glb and displays it in a full-screen 3D scene with an orbit camera (drag to rotate, pinch to zoom). Add an HDR environment from assets/environments/sky_2k.hdr for realistic lighting. Use SceneView `io.github.sceneview:sceneview:3.0.0`.",
8
9
  code: `@Composable
@@ -36,6 +37,7 @@ fun ModelViewerScreen() {
36
37
  id: "geometry-scene",
37
38
  title: "3D Geometry Scene",
38
39
  description: "Procedural 3D scene using primitive geometry nodes (cube, sphere, plane) — no GLB required",
40
+ tags: ["3d", "geometry", "animation"],
39
41
  dependency: "io.github.sceneview:sceneview:3.0.0",
40
42
  prompt: "Create an Android Compose screen called `GeometrySceneScreen` that renders a full-screen 3D scene with a red rotating cube, a metallic blue sphere, and a green floor plane. No model files — use SceneView built-in geometry nodes. Orbit camera. Use SceneView `io.github.sceneview:sceneview:3.0.0`.",
41
43
  code: `@Composable
@@ -91,6 +93,7 @@ fun GeometrySceneScreen() {
91
93
  id: "ar-tap-to-place",
92
94
  title: "AR Tap-to-Place",
93
95
  description: "AR scene where each tap places a GLB model on a detected surface. Placed models are pinch-to-scale and drag-to-rotate.",
96
+ tags: ["ar", "model", "anchor", "plane-detection", "placement", "gestures"],
94
97
  dependency: "io.github.sceneview:arsceneview:3.0.0",
95
98
  prompt: "Create an Android Compose screen called `TapToPlaceScreen` that opens the camera in AR mode. Show a plane detection grid. When the user taps a detected surface, place a 3D GLB model from assets/models/chair.glb at that point. The user should be able to pinch-to-scale and drag-to-rotate after placing. Multiple taps = multiple objects. Use SceneView `io.github.sceneview:arsceneview:3.0.0`.",
96
99
  code: `@Composable
@@ -134,6 +137,7 @@ fun TapToPlaceScreen() {
134
137
  id: "ar-placement-cursor",
135
138
  title: "AR Placement Cursor",
136
139
  description: "AR scene with a reticle that follows the surface at screen center. Tap to confirm placement.",
140
+ tags: ["ar", "model", "anchor", "plane-detection", "placement", "camera"],
137
141
  dependency: "io.github.sceneview:arsceneview:3.0.0",
138
142
  prompt: "Create an Android Compose AR screen called `ARCursorScreen`. Show a small reticle that snaps to the nearest detected surface at the center of the screen as the user moves the camera. When the user taps, place a GLB model from assets/models/object.glb at that position and hide the reticle. Use SceneView `io.github.sceneview:arsceneview:3.0.0`.",
139
143
  code: `@Composable
@@ -180,6 +184,7 @@ fun ARCursorScreen() {
180
184
  id: "ar-augmented-image",
181
185
  title: "AR Augmented Image",
182
186
  description: "Detects a reference image in the camera feed and overlays a 3D model above it.",
187
+ tags: ["ar", "model", "anchor", "image-tracking"],
183
188
  dependency: "io.github.sceneview:arsceneview:3.0.0",
184
189
  prompt: "Create an Android Compose AR screen called `AugmentedImageScreen` that detects a printed reference image (from R.drawable.target_image, physical width 15 cm) and places a 3D GLB model from assets/models/overlay.glb above it, scaled to match the image width. The model should disappear when the image is lost. Use SceneView `io.github.sceneview:arsceneview:3.0.0`.",
185
190
  code: `@Composable
@@ -222,6 +227,7 @@ fun AugmentedImageScreen() {
222
227
  id: "ar-face-filter",
223
228
  title: "AR Face Filter",
224
229
  description: "Front-camera AR that detects faces and renders a 3D mesh material over them.",
230
+ tags: ["ar", "face-tracking", "camera"],
225
231
  dependency: "io.github.sceneview:arsceneview:3.0.0",
226
232
  prompt: "Create an Android Compose AR screen called `FaceFilterScreen` using the front camera. Detect all visible faces and apply a custom material from assets/materials/face_mask.filamat to the face mesh. Use SceneView `io.github.sceneview:arsceneview:3.0.0` with `Session.Feature.FRONT_CAMERA` and `AugmentedFaceMode.MESH3D`.",
227
233
  code: `@Composable
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { SAMPLES, SAMPLE_IDS, getSample } from "./samples.js";
3
+ describe("SAMPLE_IDS", () => {
4
+ it("contains all 6 expected scenarios", () => {
5
+ expect(SAMPLE_IDS).toContain("model-viewer");
6
+ expect(SAMPLE_IDS).toContain("geometry-scene");
7
+ expect(SAMPLE_IDS).toContain("ar-tap-to-place");
8
+ expect(SAMPLE_IDS).toContain("ar-placement-cursor");
9
+ expect(SAMPLE_IDS).toContain("ar-augmented-image");
10
+ expect(SAMPLE_IDS).toContain("ar-face-filter");
11
+ });
12
+ it("SAMPLE_IDS matches keys of SAMPLES", () => {
13
+ expect(SAMPLE_IDS.sort()).toEqual(Object.keys(SAMPLES).sort());
14
+ });
15
+ });
16
+ describe("every sample", () => {
17
+ for (const id of SAMPLE_IDS) {
18
+ const sample = SAMPLES[id];
19
+ it(`${id}: has all required fields`, () => {
20
+ expect(sample.id).toBe(id);
21
+ expect(sample.title).toBeTruthy();
22
+ expect(sample.description).toBeTruthy();
23
+ expect(sample.tags.length).toBeGreaterThan(0);
24
+ expect(sample.dependency).toMatch(/^io\.github\.sceneview:/);
25
+ expect(sample.prompt).toBeTruthy();
26
+ expect(sample.code).toBeTruthy();
27
+ });
28
+ it(`${id}: code is non-empty Kotlin`, () => {
29
+ expect(sample.code).toContain("@Composable");
30
+ expect(sample.code).toContain("fun ");
31
+ });
32
+ it(`${id}: dependency is a valid sceneview artifact`, () => {
33
+ expect(["io.github.sceneview:sceneview:3.0.0", "io.github.sceneview:arsceneview:3.0.0"]).toContain(sample.dependency);
34
+ });
35
+ }
36
+ });
37
+ describe("AR samples", () => {
38
+ const arIds = SAMPLE_IDS.filter((id) => SAMPLES[id].tags.includes("ar"));
39
+ it("all AR samples use arsceneview dependency", () => {
40
+ for (const id of arIds) {
41
+ expect(SAMPLES[id].dependency).toBe("io.github.sceneview:arsceneview:3.0.0");
42
+ }
43
+ });
44
+ it("all AR samples contain ARScene in code", () => {
45
+ for (const id of arIds) {
46
+ expect(SAMPLES[id].code).toContain("ARScene");
47
+ }
48
+ });
49
+ it("all AR samples have the 'ar' tag", () => {
50
+ for (const id of arIds) {
51
+ expect(SAMPLES[id].tags).toContain("ar");
52
+ }
53
+ });
54
+ });
55
+ describe("3D samples", () => {
56
+ const d3Ids = SAMPLE_IDS.filter((id) => SAMPLES[id].tags.includes("3d"));
57
+ it("all 3D samples use sceneview dependency", () => {
58
+ for (const id of d3Ids) {
59
+ expect(SAMPLES[id].dependency).toBe("io.github.sceneview:sceneview:3.0.0");
60
+ }
61
+ });
62
+ it("all 3D samples contain Scene in code", () => {
63
+ for (const id of d3Ids) {
64
+ expect(SAMPLES[id].code).toContain("Scene(");
65
+ }
66
+ });
67
+ });
68
+ describe("getSample", () => {
69
+ it("returns the correct sample by ID", () => {
70
+ const s = getSample("model-viewer");
71
+ expect(s).toBeDefined();
72
+ expect(s.id).toBe("model-viewer");
73
+ expect(s.title).toBe("3D Model Viewer");
74
+ });
75
+ it("returns undefined for an unknown ID", () => {
76
+ expect(getSample("nonexistent-scenario")).toBeUndefined();
77
+ });
78
+ it("returns all samples without undefined", () => {
79
+ for (const id of SAMPLE_IDS) {
80
+ expect(getSample(id)).toBeDefined();
81
+ }
82
+ });
83
+ });
84
+ describe("tag filtering (simulating list_samples tool)", () => {
85
+ const filterByTag = (tag) => Object.values(SAMPLES).filter((s) => s.tags.includes(tag));
86
+ it("tag 'ar' returns only AR samples", () => {
87
+ const results = filterByTag("ar");
88
+ expect(results.length).toBeGreaterThan(0);
89
+ results.forEach((s) => expect(s.tags).toContain("ar"));
90
+ });
91
+ it("tag '3d' returns only 3D samples", () => {
92
+ const results = filterByTag("3d");
93
+ expect(results.length).toBeGreaterThan(0);
94
+ results.forEach((s) => expect(s.tags).toContain("3d"));
95
+ });
96
+ it("tag 'face-tracking' returns only the face filter sample", () => {
97
+ const results = filterByTag("face-tracking");
98
+ expect(results).toHaveLength(1);
99
+ expect(results[0].id).toBe("ar-face-filter");
100
+ });
101
+ it("tag 'image-tracking' returns only augmented image sample", () => {
102
+ const results = filterByTag("image-tracking");
103
+ expect(results).toHaveLength(1);
104
+ expect(results[0].id).toBe("ar-augmented-image");
105
+ });
106
+ it("tag 'geometry' returns only geometry scene", () => {
107
+ const results = filterByTag("geometry");
108
+ expect(results).toHaveLength(1);
109
+ expect(results[0].id).toBe("geometry-scene");
110
+ });
111
+ it("tag 'anchor' returns AR samples that use anchors", () => {
112
+ const results = filterByTag("anchor");
113
+ expect(results.length).toBeGreaterThan(0);
114
+ results.forEach((s) => expect(s.tags).toContain("anchor"));
115
+ });
116
+ it("unknown tag returns empty array", () => {
117
+ expect(filterByTag("nonexistent-tag")).toHaveLength(0);
118
+ });
119
+ });
@@ -0,0 +1,251 @@
1
+ function findLines(lines, pattern) {
2
+ return lines
3
+ .map((l, i) => (pattern.test(l) ? i + 1 : -1))
4
+ .filter((n) => n !== -1);
5
+ }
6
+ const RULES = [
7
+ // ─── Threading ────────────────────────────────────────────────────────────
8
+ {
9
+ id: "threading/filament-off-main-thread",
10
+ severity: "error",
11
+ check(code, lines) {
12
+ const issues = [];
13
+ if (!/Dispatchers\.(IO|Default)/.test(code))
14
+ return issues;
15
+ const filamentCallPatterns = [
16
+ [/modelLoader\.createModel/, "modelLoader.createModel*"],
17
+ [/modelLoader\.loadModel/, "modelLoader.loadModel*"],
18
+ [/materialLoader\./, "materialLoader.*"],
19
+ [/Texture\.Builder/, "Texture.Builder"],
20
+ [/engine\.createTexture/, "engine.createTexture"],
21
+ ];
22
+ for (const [pat, name] of filamentCallPatterns) {
23
+ if (pat.test(code)) {
24
+ findLines(lines, pat).forEach((line) => issues.push({
25
+ severity: "error",
26
+ rule: "threading/filament-off-main-thread",
27
+ message: `\`${name}\` detected alongside a background dispatcher. Filament JNI calls must run on the **main thread**. Use \`rememberModelInstance\` in composables, or wrap imperative code in \`withContext(Dispatchers.Main)\`.`,
28
+ line,
29
+ }));
30
+ }
31
+ }
32
+ return issues;
33
+ },
34
+ },
35
+ // ─── AR: plain Node worldPosition instead of AnchorNode ──────────────────
36
+ {
37
+ id: "ar/node-not-anchor",
38
+ severity: "warning",
39
+ check(code, lines) {
40
+ const issues = [];
41
+ if (!code.includes("ARScene") && !code.includes("AnchorNode"))
42
+ return issues;
43
+ if (/\.worldPosition\s*=/.test(code)) {
44
+ findLines(lines, /\.worldPosition\s*=/).forEach((line) => issues.push({
45
+ severity: "warning",
46
+ rule: "ar/node-not-anchor",
47
+ message: "Manually setting `worldPosition` inside an AR scene causes drift — ARCore remaps coordinates during tracking and plain nodes don't compensate. Use `AnchorNode(anchor = hitResult.createAnchor())` instead.",
48
+ line,
49
+ }));
50
+ }
51
+ return issues;
52
+ },
53
+ },
54
+ // ─── Missing null-check on rememberModelInstance ──────────────────────────
55
+ {
56
+ id: "composable/model-instance-null-check",
57
+ severity: "error",
58
+ check(code, lines) {
59
+ const issues = [];
60
+ const assignMatch = code.match(/val\s+(\w+)\s*=\s*rememberModelInstance\(/);
61
+ if (!assignMatch)
62
+ return issues;
63
+ const varName = assignMatch[1];
64
+ // Flag ModelNode uses where the variable is passed without null-guard (no ?. !! or ?: )
65
+ const unsafeUse = new RegExp(`ModelNode\\s*\\([^)]*modelInstance\\s*=\\s*${varName}(?![?!])`);
66
+ if (unsafeUse.test(code)) {
67
+ findLines(lines, new RegExp(`modelInstance\\s*=\\s*${varName}(?![?!])`)).forEach((line) => issues.push({
68
+ severity: "error",
69
+ rule: "composable/model-instance-null-check",
70
+ message: `\`${varName}\` from \`rememberModelInstance\` is \`null\` while the asset loads. Guard it: \`${varName}?.let { ModelNode(modelInstance = it, ...) }\`.`,
71
+ line,
72
+ }));
73
+ }
74
+ return issues;
75
+ },
76
+ },
77
+ // ─── LightNode trailing lambda instead of named `apply =` ────────────────
78
+ {
79
+ id: "api/light-node-trailing-lambda",
80
+ severity: "error",
81
+ check(code, lines) {
82
+ const issues = [];
83
+ // Matches: LightNode(...) { — trailing lambda, not apply = { inside parens
84
+ if (/LightNode\s*\([^)]*\)\s*\{/.test(code)) {
85
+ findLines(lines, /LightNode\s*\([^)]*\)\s*\{/).forEach((line) => issues.push({
86
+ severity: "error",
87
+ rule: "api/light-node-trailing-lambda",
88
+ message: "`LightNode`'s configuration block is a **named parameter** `apply`, not a trailing lambda. Write `LightNode(engine = engine, type = ..., apply = { intensity(100_000f) })`. Without `apply =` the block is silently ignored and your light has default (zero) settings.",
89
+ line,
90
+ }));
91
+ }
92
+ return issues;
93
+ },
94
+ },
95
+ // ─── Engine created manually in composable ────────────────────────────────
96
+ {
97
+ id: "lifecycle/manual-engine-create",
98
+ severity: "error",
99
+ check(code, lines) {
100
+ const issues = [];
101
+ findLines(lines, /Engine\.create\(/).forEach((line) => issues.push({
102
+ severity: "error",
103
+ rule: "lifecycle/manual-engine-create",
104
+ message: "`Engine.create()` called directly. In composables use `rememberEngine()` — it ties the Engine to the composition lifecycle and destroys it automatically on disposal, preventing leaks and double-destroy SIGABRTs.",
105
+ line,
106
+ }));
107
+ return issues;
108
+ },
109
+ },
110
+ // ─── Manual engine.destroy() alongside rememberEngine ────────────────────
111
+ {
112
+ id: "lifecycle/manual-engine-destroy",
113
+ severity: "warning",
114
+ check(code, lines) {
115
+ const issues = [];
116
+ if (!code.includes("rememberEngine"))
117
+ return issues;
118
+ findLines(lines, /engine\.(safeD|d)estroy\(\)/).forEach((line) => issues.push({
119
+ severity: "warning",
120
+ rule: "lifecycle/manual-engine-destroy",
121
+ message: "`engine.destroy()` called manually alongside `rememberEngine()`. The engine is already destroyed on composition disposal — calling it again triggers a SIGABRT. Remove the manual call.",
122
+ line,
123
+ }));
124
+ return issues;
125
+ },
126
+ },
127
+ // ─── Texture destroy order (issue #630) ──────────────────────────────────
128
+ {
129
+ id: "lifecycle/texture-destroy-order",
130
+ severity: "error",
131
+ check(code, lines) {
132
+ const issues = [];
133
+ const texIdx = code.indexOf("safeDestroyTexture");
134
+ const matIdx = code.indexOf("destroyMaterialInstance");
135
+ if (texIdx !== -1 && matIdx !== -1 && texIdx < matIdx) {
136
+ findLines(lines, /safeDestroyTexture|engine\.destroyTexture/).forEach((line) => issues.push({
137
+ severity: "error",
138
+ rule: "lifecycle/texture-destroy-order",
139
+ message: "Texture destroyed **before** the MaterialInstance that references it → SIGABRT (\"Invalid texture still bound to MaterialInstance\"). Destroy the MaterialInstance first: `materialLoader.destroyMaterialInstance(instance)`, then `engine.safeDestroyTexture(texture)`.",
140
+ line,
141
+ }));
142
+ }
143
+ return issues;
144
+ },
145
+ },
146
+ // ─── createModelInstance in composable (should be rememberModelInstance) ──
147
+ {
148
+ id: "composable/prefer-remember-model-instance",
149
+ severity: "warning",
150
+ check(code, lines) {
151
+ const issues = [];
152
+ findLines(lines, /modelLoader\.createModelInstance\(/).forEach((line) => issues.push({
153
+ severity: "warning",
154
+ rule: "composable/prefer-remember-model-instance",
155
+ message: "`modelLoader.createModelInstance()` blocks the main thread. In composables, use `rememberModelInstance(modelLoader, path)` — it loads asynchronously, returns `null` while loading, and recomposes when ready.",
156
+ line,
157
+ }));
158
+ return issues;
159
+ },
160
+ },
161
+ // ─── Empty AnchorNode() ────────────────────────────────────────────────────
162
+ {
163
+ id: "ar/anchor-node-missing-anchor",
164
+ severity: "error",
165
+ check(code, lines) {
166
+ const issues = [];
167
+ findLines(lines, /AnchorNode\s*\(\s*\)/).forEach((line) => issues.push({
168
+ severity: "error",
169
+ rule: "ar/anchor-node-missing-anchor",
170
+ message: "`AnchorNode()` requires an `anchor` from a hit result. Use `AnchorNode(anchor = hitResult.createAnchor())` inside `onTouchEvent` or `onSessionUpdated`.",
171
+ line,
172
+ }));
173
+ return issues;
174
+ },
175
+ },
176
+ // ─── 2.x → 3.0 renamed/removed APIs ─────────────────────────────────────
177
+ {
178
+ id: "migration/old-api",
179
+ severity: "error",
180
+ check(code, lines) {
181
+ const issues = [];
182
+ const renames = [
183
+ [/\bSceneView\s*\(/, "`SceneView(…)` → renamed to `Scene(…)` in 3.0"],
184
+ [/\bArSceneView\s*\(/, "`ArSceneView(…)` → renamed to `ARScene(…)` in 3.0"],
185
+ [/\bPlacementNode\b/, "`PlacementNode` removed → use `AnchorNode` + `HitResultNode` in 3.0"],
186
+ [/\bTransformableNode\b/, "`TransformableNode` removed → set `isEditable = true` on `ModelNode` in 3.0"],
187
+ [/\bViewRenderable\b/, "`ViewRenderable` removed → use `ViewNode` with a `@Composable` content lambda in 3.0"],
188
+ [/\bmodelLoader\.loadModelAsync\b/, "`loadModelAsync` removed → use `rememberModelInstance` in composables (3.0)"],
189
+ [/\bmodelLoader\.loadModel\b(?!Instance)/, "`loadModel` → use `rememberModelInstance` or `loadModelInstanceAsync` (3.0)"],
190
+ ];
191
+ for (const [pat, msg] of renames) {
192
+ if (pat.test(code)) {
193
+ findLines(lines, pat).forEach((line) => issues.push({ severity: "error", rule: "migration/old-api", message: msg, line }));
194
+ }
195
+ }
196
+ return issues;
197
+ },
198
+ },
199
+ // ─── Scene missing engine param ───────────────────────────────────────────
200
+ {
201
+ id: "api/scene-missing-engine",
202
+ severity: "error",
203
+ check(code, lines) {
204
+ const issues = [];
205
+ // Scene( or ARScene( without engine = somewhere nearby
206
+ const sceneCallLines = findLines(lines, /\b(AR)?Scene\s*\(/);
207
+ sceneCallLines.forEach((line) => {
208
+ // Look at the next 10 lines for engine =
209
+ const block = lines.slice(line - 1, line + 10).join("\n");
210
+ if (!block.includes("engine")) {
211
+ issues.push({
212
+ severity: "error",
213
+ rule: "api/scene-missing-engine",
214
+ message: "`Scene` / `ARScene` requires an `engine` parameter. Create one with `val engine = rememberEngine()` and pass it: `Scene(engine = engine, …)`.",
215
+ line,
216
+ });
217
+ }
218
+ });
219
+ return issues;
220
+ },
221
+ },
222
+ ];
223
+ export function validateCode(kotlinCode) {
224
+ const lines = kotlinCode.split("\n");
225
+ return RULES.flatMap((rule) => rule.check(kotlinCode, lines));
226
+ }
227
+ export function formatValidationReport(issues) {
228
+ if (issues.length === 0) {
229
+ return "✅ No issues found. The snippet follows SceneView best practices.";
230
+ }
231
+ const errors = issues.filter((i) => i.severity === "error");
232
+ const warnings = issues.filter((i) => i.severity === "warning");
233
+ const infos = issues.filter((i) => i.severity === "info");
234
+ const icon = {
235
+ error: "🔴",
236
+ warning: "🟡",
237
+ info: "🔵",
238
+ };
239
+ const header = `Found **${issues.length} issue(s)**: ${errors.length} error(s), ${warnings.length} warning(s), ${infos.length} info(s).\n`;
240
+ const body = issues
241
+ .map((issue, i) => {
242
+ const loc = issue.line ? ` (line ${issue.line})` : "";
243
+ return [
244
+ `### ${i + 1}. ${icon[issue.severity]} ${issue.severity.toUpperCase()}${loc}`,
245
+ `**Rule:** \`${issue.rule}\``,
246
+ issue.message,
247
+ ].join("\n");
248
+ })
249
+ .join("\n\n");
250
+ return header + "\n" + body;
251
+ }
@@ -0,0 +1,246 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { validateCode, formatValidationReport } from "./validator.js";
3
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
4
+ function ruleIds(code) {
5
+ return validateCode(code).map((i) => i.rule);
6
+ }
7
+ function hasRule(code, rule) {
8
+ return ruleIds(code).includes(rule);
9
+ }
10
+ // ─── threading/filament-off-main-thread ──────────────────────────────────────
11
+ describe("threading/filament-off-main-thread", () => {
12
+ const RULE = "threading/filament-off-main-thread";
13
+ it("fires when modelLoader.createModel* used near Dispatchers.IO", () => {
14
+ const code = `
15
+ withContext(Dispatchers.IO) {
16
+ val model = modelLoader.createModelInstance("models/chair.glb")
17
+ }
18
+ `;
19
+ expect(hasRule(code, RULE)).toBe(true);
20
+ });
21
+ it("fires when Texture.Builder used near Dispatchers.Default", () => {
22
+ const code = `
23
+ launch(Dispatchers.Default) {
24
+ val texture = Texture.Builder().width(4).build(engine)
25
+ }
26
+ `;
27
+ expect(hasRule(code, RULE)).toBe(true);
28
+ });
29
+ it("does NOT fire without a background dispatcher", () => {
30
+ const code = `val model = modelLoader.createModelInstance("models/chair.glb")`;
31
+ expect(hasRule(code, RULE)).toBe(false);
32
+ });
33
+ it("does NOT fire when Dispatchers.Main is used", () => {
34
+ const code = `
35
+ withContext(Dispatchers.Main) {
36
+ val model = modelLoader.createModelInstance("models/chair.glb")
37
+ }
38
+ `;
39
+ expect(hasRule(code, RULE)).toBe(false);
40
+ });
41
+ });
42
+ // ─── ar/node-not-anchor ───────────────────────────────────────────────────────
43
+ describe("ar/node-not-anchor", () => {
44
+ const RULE = "ar/node-not-anchor";
45
+ it("fires when worldPosition is set inside an ARScene", () => {
46
+ const code = `
47
+ ARScene(engine = engine) {
48
+ node.worldPosition = Position(0f, 0f, -1f)
49
+ }
50
+ `;
51
+ expect(hasRule(code, RULE)).toBe(true);
52
+ });
53
+ it("does NOT fire for worldPosition outside ARScene", () => {
54
+ const code = `node.worldPosition = Position(0f, 0f, -1f)`;
55
+ expect(hasRule(code, RULE)).toBe(false);
56
+ });
57
+ it("does NOT fire when AnchorNode is used correctly", () => {
58
+ const code = `
59
+ ARScene(engine = engine) {
60
+ AnchorNode(anchor = hitResult.createAnchor()) { }
61
+ }
62
+ `;
63
+ expect(hasRule(code, RULE)).toBe(false);
64
+ });
65
+ });
66
+ // ─── composable/model-instance-null-check ────────────────────────────────────
67
+ describe("composable/model-instance-null-check", () => {
68
+ const RULE = "composable/model-instance-null-check";
69
+ it("fires when rememberModelInstance result used without null guard", () => {
70
+ const code = `
71
+ val instance = rememberModelInstance(modelLoader, "models/chair.glb")
72
+ ModelNode(modelInstance = instance, scaleToUnits = 1f)
73
+ `;
74
+ expect(hasRule(code, RULE)).toBe(true);
75
+ });
76
+ it("does NOT fire when guarded with ?.", () => {
77
+ const code = `
78
+ val instance = rememberModelInstance(modelLoader, "models/chair.glb")
79
+ instance?.let { ModelNode(modelInstance = it) }
80
+ `;
81
+ expect(hasRule(code, RULE)).toBe(false);
82
+ });
83
+ it("does NOT fire when guarded with !!", () => {
84
+ const code = `
85
+ val instance = rememberModelInstance(modelLoader, "models/chair.glb")
86
+ ModelNode(modelInstance = instance!!, scaleToUnits = 1f)
87
+ `;
88
+ expect(hasRule(code, RULE)).toBe(false);
89
+ });
90
+ it("does NOT fire when rememberModelInstance is not assigned to a var", () => {
91
+ const code = `ModelNode(modelInstance = someOtherInstance, scaleToUnits = 1f)`;
92
+ expect(hasRule(code, RULE)).toBe(false);
93
+ });
94
+ });
95
+ // ─── api/light-node-trailing-lambda ──────────────────────────────────────────
96
+ describe("api/light-node-trailing-lambda", () => {
97
+ const RULE = "api/light-node-trailing-lambda";
98
+ it("fires on trailing lambda syntax", () => {
99
+ const code = `LightNode(engine = engine, type = LightManager.Type.SUN) { intensity(100_000f) }`;
100
+ expect(hasRule(code, RULE)).toBe(true);
101
+ });
102
+ it("does NOT fire when apply = { } is used correctly", () => {
103
+ const code = `LightNode(engine = engine, type = LightManager.Type.SUN, apply = { intensity(100_000f) })`;
104
+ expect(hasRule(code, RULE)).toBe(false);
105
+ });
106
+ });
107
+ // ─── lifecycle/manual-engine-create ──────────────────────────────────────────
108
+ describe("lifecycle/manual-engine-create", () => {
109
+ const RULE = "lifecycle/manual-engine-create";
110
+ it("fires on Engine.create()", () => {
111
+ const code = `val engine = Engine.create(eglContext)`;
112
+ expect(hasRule(code, RULE)).toBe(true);
113
+ });
114
+ it("does NOT fire when rememberEngine() is used", () => {
115
+ const code = `val engine = rememberEngine()`;
116
+ expect(hasRule(code, RULE)).toBe(false);
117
+ });
118
+ });
119
+ // ─── lifecycle/manual-engine-destroy ─────────────────────────────────────────
120
+ describe("lifecycle/manual-engine-destroy", () => {
121
+ const RULE = "lifecycle/manual-engine-destroy";
122
+ it("fires when engine.destroy() called alongside rememberEngine", () => {
123
+ const code = `
124
+ val engine = rememberEngine()
125
+ DisposableEffect(Unit) { onDispose { engine.destroy() } }
126
+ `;
127
+ expect(hasRule(code, RULE)).toBe(true);
128
+ });
129
+ it("does NOT fire when rememberEngine is absent (imperative code)", () => {
130
+ const code = `engine.destroy()`;
131
+ expect(hasRule(code, RULE)).toBe(false);
132
+ });
133
+ });
134
+ // ─── lifecycle/texture-destroy-order ─────────────────────────────────────────
135
+ describe("lifecycle/texture-destroy-order", () => {
136
+ const RULE = "lifecycle/texture-destroy-order";
137
+ it("fires when texture is destroyed before material instance", () => {
138
+ const code = `
139
+ engine.safeDestroyTexture(texture)
140
+ materialLoader.destroyMaterialInstance(instance)
141
+ `;
142
+ expect(hasRule(code, RULE)).toBe(true);
143
+ });
144
+ it("does NOT fire when material instance destroyed first", () => {
145
+ const code = `
146
+ materialLoader.destroyMaterialInstance(instance)
147
+ engine.safeDestroyTexture(texture)
148
+ `;
149
+ expect(hasRule(code, RULE)).toBe(false);
150
+ });
151
+ it("does NOT fire when only one of the two is present", () => {
152
+ expect(hasRule(`engine.safeDestroyTexture(texture)`, RULE)).toBe(false);
153
+ expect(hasRule(`materialLoader.destroyMaterialInstance(instance)`, RULE)).toBe(false);
154
+ });
155
+ });
156
+ // ─── composable/prefer-remember-model-instance ────────────────────────────────
157
+ describe("composable/prefer-remember-model-instance", () => {
158
+ const RULE = "composable/prefer-remember-model-instance";
159
+ it("fires when createModelInstance is used directly", () => {
160
+ const code = `val m = modelLoader.createModelInstance("models/chair.glb")`;
161
+ expect(hasRule(code, RULE)).toBe(true);
162
+ });
163
+ it("does NOT fire for rememberModelInstance", () => {
164
+ const code = `val m = rememberModelInstance(modelLoader, "models/chair.glb")`;
165
+ expect(hasRule(code, RULE)).toBe(false);
166
+ });
167
+ });
168
+ // ─── ar/anchor-node-missing-anchor ───────────────────────────────────────────
169
+ describe("ar/anchor-node-missing-anchor", () => {
170
+ const RULE = "ar/anchor-node-missing-anchor";
171
+ it("fires on empty AnchorNode()", () => {
172
+ const code = `val node = AnchorNode()`;
173
+ expect(hasRule(code, RULE)).toBe(true);
174
+ });
175
+ it("does NOT fire when anchor param is provided", () => {
176
+ const code = `AnchorNode(anchor = hitResult.createAnchor())`;
177
+ expect(hasRule(code, RULE)).toBe(false);
178
+ });
179
+ });
180
+ // ─── migration/old-api ────────────────────────────────────────────────────────
181
+ describe("migration/old-api", () => {
182
+ const RULE = "migration/old-api";
183
+ it("fires on SceneView composable", () => {
184
+ expect(hasRule(`SceneView(modifier = Modifier.fillMaxSize())`, RULE)).toBe(true);
185
+ });
186
+ it("fires on ArSceneView composable", () => {
187
+ expect(hasRule(`ArSceneView(modifier = Modifier.fillMaxSize())`, RULE)).toBe(true);
188
+ });
189
+ it("fires on TransformableNode", () => {
190
+ expect(hasRule(`val node = TransformableNode(system)`, RULE)).toBe(true);
191
+ });
192
+ it("fires on PlacementNode", () => {
193
+ expect(hasRule(`val node = PlacementNode()`, RULE)).toBe(true);
194
+ });
195
+ it("fires on ViewRenderable", () => {
196
+ expect(hasRule(`ViewRenderable.builder()`, RULE)).toBe(true);
197
+ });
198
+ it("fires on loadModelAsync", () => {
199
+ expect(hasRule(`modelLoader.loadModelAsync("models/x.glb")`, RULE)).toBe(true);
200
+ });
201
+ it("does NOT fire on modern 3.0 APIs", () => {
202
+ const code = `
203
+ val engine = rememberEngine()
204
+ Scene(engine = engine) {
205
+ rememberModelInstance(modelLoader, "models/x.glb")?.let { ModelNode(modelInstance = it) }
206
+ }
207
+ `;
208
+ expect(hasRule(code, RULE)).toBe(false);
209
+ });
210
+ });
211
+ // ─── api/scene-missing-engine ─────────────────────────────────────────────────
212
+ describe("api/scene-missing-engine", () => {
213
+ const RULE = "api/scene-missing-engine";
214
+ it("fires when Scene() has no engine nearby", () => {
215
+ const code = `Scene(modifier = Modifier.fillMaxSize()) { }`;
216
+ expect(hasRule(code, RULE)).toBe(true);
217
+ });
218
+ it("does NOT fire when engine is provided", () => {
219
+ const code = `
220
+ val engine = rememberEngine()
221
+ Scene(modifier = Modifier.fillMaxSize(), engine = engine) { }
222
+ `;
223
+ expect(hasRule(code, RULE)).toBe(false);
224
+ });
225
+ });
226
+ // ─── formatValidationReport ───────────────────────────────────────────────────
227
+ describe("formatValidationReport", () => {
228
+ it("returns success message when no issues", () => {
229
+ expect(formatValidationReport([])).toContain("No issues found");
230
+ });
231
+ it("includes severity counts in the header", () => {
232
+ const issues = validateCode(`
233
+ val instance = rememberModelInstance(modelLoader, "x.glb")
234
+ ModelNode(modelInstance = instance, scaleToUnits = 1f)
235
+ LightNode(engine = engine) { intensity(1f) }
236
+ `);
237
+ const report = formatValidationReport(issues);
238
+ expect(report).toMatch(/error/);
239
+ expect(report).toContain("🔴");
240
+ });
241
+ it("includes line numbers when available", () => {
242
+ const code = `\nval instance = rememberModelInstance(modelLoader, "x.glb")\nModelNode(modelInstance = instance, scaleToUnits = 1f)`;
243
+ const report = formatValidationReport(validateCode(code));
244
+ expect(report).toMatch(/line \d+/);
245
+ });
246
+ });