sceneview-mcp 3.0.0 → 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/README.md +64 -107
- package/dist/index.js +113 -8
- package/dist/issues.js +72 -0
- package/dist/issues.test.js +114 -0
- package/dist/migration.js +248 -0
- package/dist/migration.test.js +50 -0
- package/dist/samples.js +61 -0
- package/dist/samples.test.js +119 -0
- package/dist/validator.js +251 -0
- package/dist/validator.test.js +246 -0
- package/package.json +32 -5
|
@@ -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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sceneview-mcp",
|
|
3
|
-
"version": "3.0.
|
|
4
|
-
"description": "MCP server for SceneView — 3D and AR with Jetpack Compose for Android",
|
|
5
|
-
"keywords": [
|
|
3
|
+
"version": "3.0.2",
|
|
4
|
+
"description": "MCP server for SceneView — 3D and AR with Jetpack Compose for Android. Give Claude the full SceneView SDK so it writes correct, compilable Kotlin.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"sceneview",
|
|
9
|
+
"android",
|
|
10
|
+
"ar",
|
|
11
|
+
"arcore",
|
|
12
|
+
"3d",
|
|
13
|
+
"filament",
|
|
14
|
+
"jetpack-compose",
|
|
15
|
+
"claude",
|
|
16
|
+
"ai"
|
|
17
|
+
],
|
|
6
18
|
"license": "MIT",
|
|
19
|
+
"author": "SceneView (https://github.com/SceneView)",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/SceneView/sceneview-android.git",
|
|
23
|
+
"directory": "mcp"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/SceneView/sceneview-android/tree/main/mcp#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/SceneView/sceneview-android/issues"
|
|
28
|
+
},
|
|
7
29
|
"type": "module",
|
|
8
30
|
"bin": {
|
|
9
31
|
"sceneview-mcp": "dist/index.js"
|
|
@@ -12,11 +34,15 @@
|
|
|
12
34
|
"dist",
|
|
13
35
|
"llms.txt"
|
|
14
36
|
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
15
40
|
"scripts": {
|
|
16
41
|
"build": "tsc",
|
|
17
42
|
"prepare": "cp ../llms.txt ./llms.txt && tsc",
|
|
18
43
|
"start": "node dist/index.js",
|
|
19
|
-
"dev": "tsx src/index.ts"
|
|
44
|
+
"dev": "tsx src/index.ts",
|
|
45
|
+
"test": "vitest run"
|
|
20
46
|
},
|
|
21
47
|
"dependencies": {
|
|
22
48
|
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
@@ -24,6 +50,7 @@
|
|
|
24
50
|
"devDependencies": {
|
|
25
51
|
"@types/node": "^22.0.0",
|
|
26
52
|
"tsx": "^4.0.0",
|
|
27
|
-
"typescript": "^5.8.0"
|
|
53
|
+
"typescript": "^5.8.0",
|
|
54
|
+
"vitest": "^4.1.0"
|
|
28
55
|
}
|
|
29
56
|
}
|