@genex-ai/cli-demo 1.5.2-dev.398 → 1.6.0-dev.399

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.
@@ -0,0 +1,682 @@
1
+ // The headless gate runner - AG-859 §7, gates G1…G17.
2
+ //
3
+ // Serves the built dist/ on an ephemeral port, drives it with headless
4
+ // chromium, and measures the asset instead of trusting it. A gate failure
5
+ // blocks the stamp, which blocks the deploy.
6
+ //
7
+ // exit 0 every gate passed
8
+ // exit 1 a gate failed
9
+ // exit 2 the runner itself could not complete
10
+ //
11
+ // Copied verbatim into every asset project and hashed by gate G13. Edit it in
12
+ // packages/asset-viewer-template/template/tools.
13
+ //
14
+ // Usage: node tools/gates.mjs [--dist dist] [--out .gates/report.json]
15
+ // [--skip-paste-run] [--views 8]
16
+
17
+ import fs from "node:fs";
18
+ import fsp from "node:fs/promises";
19
+ import http from "node:http";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { spawn } from "node:child_process";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ import {
26
+ MANIFEST_FILENAME,
27
+ MANIFEST_MAX_BYTES,
28
+ PARITY_FILENAME,
29
+ findAssetSource,
30
+ hashSharedFiles,
31
+ parseImports,
32
+ readAssetConfig,
33
+ scanForbidden,
34
+ sha256,
35
+ usesBareMathRandom,
36
+ validateManifest,
37
+ } from "./emit-manifest.mjs";
38
+
39
+ const PROJECT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
40
+ const RUNNER_ID = "asset-gates@1";
41
+ const SILHOUETTE_VIEWS_DEFAULT = 8;
42
+ /** img2threejs DEFAULT_COLLAPSE_RATIO: below this a view is a collapsed silhouette. */
43
+ const COLLAPSE_RATIO_DEFAULT = 0.15;
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // tiny helpers
47
+ // ---------------------------------------------------------------------------
48
+
49
+ function parseArgs(argv) {
50
+ const args = { dist: "dist", out: path.join(".gates", "report.json"), views: SILHOUETTE_VIEWS_DEFAULT, pasteRun: true };
51
+ for (let i = 0; i < argv.length; i += 1) {
52
+ const a = argv[i];
53
+ if (a === "--dist") args.dist = argv[++i];
54
+ else if (a === "--out") args.out = argv[++i];
55
+ else if (a === "--views") args.views = Number(argv[++i]);
56
+ else if (a === "--skip-paste-run") args.pasteRun = false;
57
+ else if (a === "--help" || a === "-h") args.help = true;
58
+ else throw new Error(`unknown flag: ${a}`);
59
+ }
60
+ return args;
61
+ }
62
+
63
+ const MIME = {
64
+ ".html": "text/html; charset=utf-8",
65
+ ".js": "text/javascript; charset=utf-8",
66
+ ".mjs": "text/javascript; charset=utf-8",
67
+ ".css": "text/css; charset=utf-8",
68
+ ".json": "application/json; charset=utf-8",
69
+ ".png": "image/png",
70
+ ".jpg": "image/jpeg",
71
+ ".svg": "image/svg+xml",
72
+ ".wasm": "application/wasm",
73
+ ".ktx2": "image/ktx2",
74
+ ".glb": "model/gltf-binary",
75
+ };
76
+
77
+ function serveDir(root) {
78
+ const server = http.createServer(async (req, res) => {
79
+ try {
80
+ const url = new URL(req.url, "http://127.0.0.1");
81
+ let rel = decodeURIComponent(url.pathname);
82
+ if (rel.endsWith("/")) rel += "index.html";
83
+ const abs = path.join(root, path.normalize(rel).replace(/^([/\\])+/, ""));
84
+ if (!abs.startsWith(root)) {
85
+ res.writeHead(403).end("forbidden");
86
+ return;
87
+ }
88
+ const body = await fsp.readFile(abs);
89
+ res.writeHead(200, { "content-type": MIME[path.extname(abs)] ?? "application/octet-stream" }).end(body);
90
+ } catch {
91
+ res.writeHead(404).end("not found");
92
+ }
93
+ });
94
+ return new Promise((resolve, reject) => {
95
+ server.on("error", reject);
96
+ server.listen(0, "127.0.0.1", () => {
97
+ const { port } = server.address();
98
+ resolve({ server, origin: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(r)) });
99
+ });
100
+ });
101
+ }
102
+
103
+ async function loadChromium() {
104
+ const mod = await import("playwright-core");
105
+ return (mod.default ?? mod).chromium;
106
+ }
107
+
108
+ function launchOptions() {
109
+ return {
110
+ args: [
111
+ // Headless chromium has no GPU; SwiftShader is what gives it WebGL2.
112
+ "--enable-unsafe-swiftshader",
113
+ "--use-angle=swiftshader",
114
+ "--disable-lcd-text",
115
+ ],
116
+ };
117
+ }
118
+
119
+ function run(cmd, args, cwd) {
120
+ return new Promise((resolve) => {
121
+ const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
122
+ let out = "";
123
+ child.stdout.on("data", (d) => (out += d));
124
+ child.stderr.on("data", (d) => (out += d));
125
+ child.on("error", (err) => resolve({ code: -1, output: String(err) }));
126
+ child.on("close", (code) => resolve({ code, output: out }));
127
+ });
128
+ }
129
+
130
+ /**
131
+ * SwiftShader narrates its own performance characteristics ("GPU stall due to
132
+ * ReadPixels") whenever the runner reads a frame back. That is the harness
133
+ * talking about itself on a software GPU, not the asset misbehaving, so G7
134
+ * ignores driver PERFORMANCE messages only - every driver error and every
135
+ * three.js warning still fails the gate.
136
+ */
137
+ function isHarnessNoise(text) {
138
+ return /GL Driver Message \((?:OpenGL|OpenGL ES|Vulkan), Performance,/.test(text);
139
+ }
140
+
141
+ function pct(measured, stated) {
142
+ return stated === 0 ? 0 : ((measured - stated) / stated) * 100;
143
+ }
144
+
145
+ function round(value, places = 4) {
146
+ const f = 10 ** places;
147
+ return Math.round(value * f) / f;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // G8 - paste-and-run
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * The Copy-code button's promise, tested the way a stranger would exercise it:
156
+ * a scratch project whose only resolvable dependency is three, the manifest's
157
+ * source.code pasted verbatim, `tsc --noEmit --strict`, then a real headless
158
+ * render that has to put pixels on the screen.
159
+ */
160
+ async function pasteAndRun({ manifest, browser, projectDir }) {
161
+ const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), "genex-asset-g8-"));
162
+ const detail = { scratch };
163
+ try {
164
+ const filename = manifest.source.filename;
165
+ const base = filename.replace(/\.ts$/, "");
166
+ await fsp.mkdir(path.join(scratch, "src"), { recursive: true });
167
+ await fsp.mkdir(path.join(scratch, "node_modules", "@types"), { recursive: true });
168
+
169
+ // Only three is linked in. If the pasted module reaches for anything else,
170
+ // both the typecheck and the bundle fail - which is the point.
171
+ const link = async (from, to) => {
172
+ const src = path.join(projectDir, "node_modules", from);
173
+ if (!fs.existsSync(src)) throw new Error(`G8 needs ${from} installed in the asset project (npm install first)`);
174
+ await fsp.symlink(src, path.join(scratch, "node_modules", to), "dir");
175
+ };
176
+ await link("three", "three");
177
+ await link(path.join("@types", "three"), path.join("@types", "three"));
178
+
179
+ await fsp.writeFile(path.join(scratch, "src", filename), manifest.source.code, "utf8");
180
+ await fsp.writeFile(
181
+ path.join(scratch, "package.json"),
182
+ JSON.stringify({ name: "asset-paste-run", private: true, version: "0.0.0", type: "module" }, null, 2),
183
+ "utf8",
184
+ );
185
+ await fsp.writeFile(
186
+ path.join(scratch, "tsconfig.json"),
187
+ JSON.stringify(
188
+ {
189
+ compilerOptions: {
190
+ target: "ES2022",
191
+ module: "ESNext",
192
+ moduleResolution: "bundler",
193
+ // No node types: an asset module must compile in a plain browser or
194
+ // worker project, which is where most adopters will paste it.
195
+ types: [],
196
+ lib: ["ES2022", "DOM"],
197
+ strict: true,
198
+ noEmit: true,
199
+ skipLibCheck: true,
200
+ },
201
+ include: ["src"],
202
+ },
203
+ null,
204
+ 2,
205
+ ),
206
+ "utf8",
207
+ );
208
+ // A plain object, deliberately NOT `defineConfig` - importing 'vite' here
209
+ // would need vite resolvable from the scratch project, and the whole point
210
+ // of G8 is that three is the only thing that resolves.
211
+ await fsp.writeFile(
212
+ path.join(scratch, "vite.config.js"),
213
+ 'export default { base: "./", build: { outDir: "dist", target: "es2022" }, logLevel: "error" };\n',
214
+ "utf8",
215
+ );
216
+ await fsp.writeFile(
217
+ path.join(scratch, "index.html"),
218
+ '<!doctype html><html><body><script type="module" src="./src/main.ts"></script></body></html>\n',
219
+ "utf8",
220
+ );
221
+ await fsp.writeFile(
222
+ path.join(scratch, "src", "main.ts"),
223
+ [
224
+ "import * as THREE from 'three';",
225
+ `import { ${manifest.source.entry} } from './${base}';`,
226
+ "",
227
+ "const CLEAR = 0x101820;",
228
+ "const renderer = new THREE.WebGLRenderer({ antialias: false, preserveDrawingBuffer: true });",
229
+ "renderer.setSize(320, 320, false);",
230
+ "renderer.setClearColor(CLEAR, 1);",
231
+ "document.body.appendChild(renderer.domElement);",
232
+ "const scene = new THREE.Scene();",
233
+ "scene.add(new THREE.HemisphereLight(0xffffff, 0x333333, 2.0));",
234
+ `const model = ${manifest.source.entry}();`,
235
+ "scene.add(model);",
236
+ "const box = new THREE.Box3().setFromObject(model);",
237
+ "const centre = box.getCenter(new THREE.Vector3());",
238
+ "const radius = Math.max(box.getSize(new THREE.Vector3()).length() * 0.5, 0.05);",
239
+ "const camera = new THREE.PerspectiveCamera(40, 1, radius * 0.02, radius * 40);",
240
+ "camera.position.set(centre.x + radius * 2.2, centre.y + radius * 1.2, centre.z + radius * 2.2);",
241
+ "camera.lookAt(centre);",
242
+ "renderer.render(scene, camera);",
243
+ "const gl = renderer.getContext();",
244
+ "const pixels = new Uint8Array(320 * 320 * 4);",
245
+ "gl.readPixels(0, 0, 320, 320, gl.RGBA, gl.UNSIGNED_BYTE, pixels);",
246
+ "let lit = 0;",
247
+ "for (let i = 0; i < pixels.length; i += 4) {",
248
+ " const dr = Math.abs(pixels[i] - 0x10), dg = Math.abs(pixels[i + 1] - 0x18), db = Math.abs(pixels[i + 2] - 0x20);",
249
+ " if (dr + dg + db > 12) lit += 1;",
250
+ "}",
251
+ "(window as unknown as { __PASTE_RUN__: unknown }).__PASTE_RUN__ = { nonBackgroundPixels: lit, entry: model.name };",
252
+ "",
253
+ ].join("\n"),
254
+ "utf8",
255
+ );
256
+
257
+ const bin = (name) => path.join(projectDir, "node_modules", ".bin", name);
258
+ const tsc = await run(bin("tsc"), ["--noEmit", "--project", "tsconfig.json"], scratch);
259
+ detail.typecheck = { code: tsc.code, output: tsc.output.trim().slice(0, 4000) };
260
+ if (tsc.code !== 0) {
261
+ return { passed: false, measured: "tsc --noEmit --strict failed", detail };
262
+ }
263
+
264
+ const build = await run(bin("vite"), ["build"], scratch);
265
+ detail.build = { code: build.code, output: build.output.trim().slice(-4000) };
266
+ if (build.code !== 0) {
267
+ return { passed: false, measured: "vite build of the pasted module failed", detail };
268
+ }
269
+
270
+ const site = await serveDir(path.join(scratch, "dist"));
271
+ try {
272
+ const page = await browser.newPage({ viewport: { width: 360, height: 360 } });
273
+ const errors = [];
274
+ page.on("pageerror", (e) => errors.push(String(e)));
275
+ page.on("console", (m) => {
276
+ if (m.type() === "error" && !isHarnessNoise(m.text())) errors.push(m.text());
277
+ });
278
+ await page.goto(site.origin, { waitUntil: "load" });
279
+ await page.waitForFunction(() => Boolean(window.__PASTE_RUN__), null, { timeout: 30_000 });
280
+ const result = await page.evaluate(() => window.__PASTE_RUN__);
281
+ await page.close();
282
+ detail.render = result;
283
+ detail.errors = errors;
284
+ const passed = result.nonBackgroundPixels >= 1 && errors.length === 0;
285
+ return {
286
+ passed,
287
+ measured: `${result.nonBackgroundPixels} non-background px, ${errors.length} console errors`,
288
+ detail,
289
+ };
290
+ } finally {
291
+ await site.close();
292
+ }
293
+ } finally {
294
+ await fsp.rm(scratch, { recursive: true, force: true }).catch(() => {});
295
+ }
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // main
300
+ // ---------------------------------------------------------------------------
301
+
302
+ async function main() {
303
+ const args = parseArgs(process.argv.slice(2));
304
+ if (args.help) {
305
+ console.log("usage: node tools/gates.mjs [--dist dist] [--out .gates/report.json] [--views 8] [--skip-paste-run]");
306
+ return 0;
307
+ }
308
+
309
+ const distDir = path.resolve(PROJECT_DIR, args.dist);
310
+ const manifestPath = path.join(distDir, MANIFEST_FILENAME);
311
+ if (!fs.existsSync(manifestPath)) {
312
+ throw new Error(`no ${MANIFEST_FILENAME} in ${args.dist} - run \`npm run build\` first`);
313
+ }
314
+
315
+ const config = readAssetConfig(PROJECT_DIR);
316
+ const manifest = JSON.parse(await fsp.readFile(manifestPath, "utf8"));
317
+ const source = findAssetSource(PROJECT_DIR);
318
+
319
+ const results = [];
320
+ const add = (id, name, threshold, passed, measured, detail) =>
321
+ results.push({ id, name, threshold, measured, passed, ...(detail ? { detail } : {}) });
322
+
323
+ // --- G13, G11, G10a: pure filesystem, no browser needed -------------------
324
+ const parityPath = path.join(PROJECT_DIR, PARITY_FILENAME);
325
+ if (fs.existsSync(parityPath)) {
326
+ const recorded = JSON.parse(await fsp.readFile(parityPath, "utf8"));
327
+ const actual = hashSharedFiles(PROJECT_DIR);
328
+ const drifted = Object.keys(recorded.files ?? {}).filter((rel) => recorded.files[rel] !== actual[rel]);
329
+ add(
330
+ "G13",
331
+ "shared-viewer-parity",
332
+ "sha256 of the shared viewer files equals the template's record",
333
+ drifted.length === 0,
334
+ drifted.length === 0 ? "in sync" : `drifted: ${drifted.join(", ")}`,
335
+ { templateVersion: recorded.templateVersion ?? null, drifted },
336
+ );
337
+ } else {
338
+ add("G13", "shared-viewer-parity", "viewer-parity.json present", false, `${PARITY_FILENAME} is missing`);
339
+ }
340
+
341
+ const imports = parseImports(source.code);
342
+ const forbidden = scanForbidden(source.code);
343
+ const importsOk = imports.length === 1 && imports[0] === "three";
344
+ add(
345
+ "G11",
346
+ "self-containment",
347
+ 'imports === ["three"]; no document/window/fetch/TextureLoader/CanvasTexture/three-examples/http',
348
+ importsOk && forbidden.length === 0,
349
+ importsOk && forbidden.length === 0
350
+ ? "three only"
351
+ : `imports: [${imports.join(", ")}]${forbidden.length ? `; banned: ${forbidden.join(", ")}` : ""}`,
352
+ { imports, forbidden, note: "scanned with comments stripped, so the MIT header cannot trip a rule" },
353
+ );
354
+
355
+ // --- browser-measured gates ----------------------------------------------
356
+ const chromium = await loadChromium();
357
+ let browser;
358
+ try {
359
+ browser = await chromium.launch(launchOptions());
360
+ } catch (err) {
361
+ throw new Error(
362
+ `could not launch chromium: ${err.message}\nInstall the browser once with: npx playwright-core install chromium`,
363
+ );
364
+ }
365
+
366
+ const site = await serveDir(distDir);
367
+ let measured;
368
+ let secondVertexHash = null;
369
+ const consoleIssues = [];
370
+
371
+ try {
372
+ const page = await browser.newPage({ viewport: { width: 1024, height: 768 } });
373
+ page.on("pageerror", (e) => consoleIssues.push({ type: "pageerror", text: String(e) }));
374
+ page.on("console", (m) => {
375
+ const type = m.type();
376
+ if (type !== "error" && type !== "warning") return;
377
+ const text = m.text();
378
+ if (isHarnessNoise(text)) return;
379
+ consoleIssues.push({ type, text });
380
+ });
381
+
382
+ await page.goto(`${site.origin}/?gates=1`, { waitUntil: "load" });
383
+ await page.waitForFunction(() => Boolean(window.__ASSET_GATES__?.ready), null, { timeout: 60_000 });
384
+
385
+ measured = await page.evaluate(() => {
386
+ const g = window.__ASSET_GATES__;
387
+ return {
388
+ geometry: g.geometry,
389
+ maxTextureDimension: g.maxTextureDimension,
390
+ space: g.space,
391
+ numeric: g.numeric,
392
+ vertexHash: g.vertexHash,
393
+ userData: g.userData,
394
+ threeRevision: g.threeRevision,
395
+ };
396
+ });
397
+
398
+ const silhouettes = await page.evaluate((views) => window.__ASSET_GATES__.silhouettes(views), args.views);
399
+ const partVisibility = await page.evaluate(
400
+ (views) => (window.__ASSET_GATES__.partVisibility ? window.__ASSET_GATES__.partVisibility(views) : []),
401
+ args.views,
402
+ );
403
+ const winding = await page.evaluate(() =>
404
+ window.__ASSET_GATES__.winding ? window.__ASSET_GATES__.winding() : [],
405
+ );
406
+ const disposal = await page.evaluate(() => window.__ASSET_GATES__.disposal());
407
+ await page.evaluate((views) => window.__ASSET_GATES__.renderViews(views), args.views);
408
+ await page.close();
409
+
410
+ // G10b: a second, entirely fresh page evaluates the module again. Unseeded
411
+ // randomness shows up here and nowhere else.
412
+ const secondContext = await browser.newContext({ viewport: { width: 640, height: 480 } });
413
+ const secondPage = await secondContext.newPage();
414
+ await secondPage.goto(`${site.origin}/?gates=1`, { waitUntil: "load" });
415
+ await secondPage.waitForFunction(() => Boolean(window.__ASSET_GATES__?.ready), null, { timeout: 60_000 });
416
+ secondVertexHash = await secondPage.evaluate(() => window.__ASSET_GATES__.vertexHash);
417
+ await secondContext.close();
418
+
419
+ // --- G1 triangle band --------------------------------------------------
420
+ const [bandLow, bandHigh] = config.triBand;
421
+ add(
422
+ "G1",
423
+ "triangle-band",
424
+ `${bandLow}..${bandHigh}`,
425
+ measured.geometry.triangles >= bandLow && measured.geometry.triangles <= bandHigh,
426
+ measured.geometry.triangles,
427
+ );
428
+
429
+ // --- G2 draw calls -----------------------------------------------------
430
+ const drawCallMax = config.drawCallMax ?? 8;
431
+ add("G2", "draw-calls", `<= ${drawCallMax}`, measured.geometry.drawCalls <= drawCallMax, measured.geometry.drawCalls);
432
+
433
+ // --- G3 materials ------------------------------------------------------
434
+ const materialMax = config.materialMax ?? 4;
435
+ add("G3", "material-count", `<= ${materialMax}`, measured.geometry.materials <= materialMax, measured.geometry.materials);
436
+
437
+ // --- G4 stated dims ----------------------------------------------------
438
+ const stated = config.statedSizeMeters;
439
+ const deltas = measured.space.sizeMeters.map((v, i) => round(pct(v, stated[i]), 3));
440
+ add(
441
+ "G4",
442
+ "stated-dims",
443
+ "every axis within ±5% of asset.config.json",
444
+ deltas.every((d) => Math.abs(d) <= 5),
445
+ deltas.map((d) => `${d > 0 ? "+" : ""}${d}%`).join(" / "),
446
+ { statedSizeMeters: stated, sizeMeters: measured.space.sizeMeters.map((v) => round(v)) },
447
+ );
448
+
449
+ // --- G5 origin ---------------------------------------------------------
450
+ const minY = measured.space.boundingBox.min[1];
451
+ const [cx, , cz] = measured.space.centre;
452
+ const originOk = Math.abs(minY) <= 0.001 && Math.abs(cx) <= 0.01 && Math.abs(cz) <= 0.01;
453
+ add(
454
+ "G5",
455
+ "origin-base-centre",
456
+ "|bbox.min.y| <= 1 mm; |centre.x|, |centre.z| <= 10 mm",
457
+ originOk,
458
+ `min.y ${round(minY * 1000, 2)} mm · centre ${round(cx * 1000, 2)} / ${round(cz * 1000, 2)} mm`,
459
+ );
460
+
461
+ // --- G6 numeric sanity -------------------------------------------------
462
+ const numericOk = measured.numeric.nanAttributes.length === 0 && measured.numeric.badBoundingSpheres.length === 0;
463
+ add(
464
+ "G6",
465
+ "numeric-sanity",
466
+ "no NaN/Infinity in position/normal/uv; every boundingSphere.radius finite and > 0",
467
+ numericOk,
468
+ numericOk
469
+ ? "clean"
470
+ : `${measured.numeric.nanAttributes.length} bad attributes, ${measured.numeric.badBoundingSpheres.length} bad spheres`,
471
+ measured.numeric,
472
+ );
473
+
474
+ // --- G7 console clean --------------------------------------------------
475
+ add(
476
+ "G7",
477
+ "console-clean",
478
+ "zero console errors/warnings across load + 8-view render",
479
+ consoleIssues.length === 0,
480
+ consoleIssues.length === 0 ? "clean" : `${consoleIssues.length} message(s)`,
481
+ consoleIssues.slice(0, 20),
482
+ );
483
+
484
+ // --- G9 multi-angle coverage -------------------------------------------
485
+ const collapseRatio = config.silhouetteCollapseRatio ?? COLLAPSE_RATIO_DEFAULT;
486
+ const maxArea = Math.max(...silhouettes, 1);
487
+ const minRatio = Math.min(...silhouettes.map((a) => a / maxArea));
488
+ add(
489
+ "G9",
490
+ "multi-angle-coverage",
491
+ `every view >= ${Math.round(collapseRatio * 100)}% of the widest view`,
492
+ silhouettes.every((a) => a > 0) && minRatio >= collapseRatio,
493
+ `min ${round(minRatio * 100, 1)}% over ${silhouettes.length} views`,
494
+ { areas: silhouettes },
495
+ );
496
+
497
+ // --- G16 every named part is visible -----------------------------------
498
+ // The one defect class every count-based gate is structurally blind to: a part
499
+ // whose rings were lofted in reverse order has inward normals, is entirely
500
+ // back-face culled, and renders as NOTHING - while still contributing its
501
+ // meshes, triangles and draw calls. Measured on the steel drum, where both
502
+ // bungs were invisible for three build cycles with G1-G15 all green. G17 is what
503
+ // actually catches it; this gate only sees a part that renders nothing at all.
504
+ // The threshold is an ABSOLUTE pixel count, deliberately not a share of the
505
+ // frame. The camera fits each part's own bounding box, and for a spatially
506
+ // distributed repeat - 28 rivets across a ladder, 24 nails across a pallet - that
507
+ // box is nearly the whole asset while each instance is millimetres, so a
508
+ // share-based floor fails perfectly good parts. A long thin pivot rod fails the
509
+ // same way. This gate asks one question: does the part render AT ALL.
510
+ const PART_MIN_PIXELS = 16;
511
+ const dark = partVisibility.filter((p) => !p.skipped && (p.bestPixels ?? 0) < PART_MIN_PIXELS);
512
+ const litValues = partVisibility.filter((p) => !p.skipped).map((p) => p.bestPixels ?? 0);
513
+ add(
514
+ "G16",
515
+ "part-visibility",
516
+ `every named part lights >= ${PART_MIN_PIXELS} px when viewed alone`,
517
+ partVisibility.length > 0 && dark.length === 0,
518
+ partVisibility.length === 0
519
+ ? "no named parts to check"
520
+ : dark.length
521
+ ? `invisible: ${dark.map((p) => `${p.name} (${p.bestPixels ?? 0} px)`).join(", ")}`
522
+ : `${partVisibility.length} part(s), dimmest ${litValues.length ? Math.min(...litValues) : 0} px`,
523
+ { parts: partVisibility },
524
+ );
525
+
526
+ // --- G17 winding agrees with the supplied normals ----------------------
527
+ // The gate that actually catches a reversed ring stack. Pure geometry, no
528
+ // renderer: a triangle whose winding disagrees with its own shaded normal is
529
+ // drawn facing the opposite way from the way it is lit. G16 alone cannot see
530
+ // this on a closed surface, which is why both gates exist.
531
+ const WINDING_MAX_SHARE = 0.01;
532
+ const wrongWay = winding.filter((m) => m.share > WINDING_MAX_SHARE);
533
+ const worstWinding = winding.length ? Math.max(...winding.map((m) => m.share)) : 0;
534
+ add(
535
+ "G17",
536
+ "winding-vs-normals",
537
+ `every mesh has <= ${WINDING_MAX_SHARE * 100}% of triangles wound against their normal`,
538
+ winding.length > 0 && wrongWay.length === 0,
539
+ winding.length === 0
540
+ ? "no meshes with normals to check"
541
+ : wrongWay.length
542
+ ? `reversed: ${wrongWay.map((m) => `${m.name} ${m.flipped}/${m.triangles}`).join(", ")}`
543
+ : `${winding.length} mesh(es), worst ${round(worstWinding * 100, 2)}%`,
544
+ { meshes: winding },
545
+ );
546
+
547
+ // --- G10 determinism ---------------------------------------------------
548
+ const sourceOnDisk = sha256(source.code);
549
+ const bareRandom = usesBareMathRandom(source.code);
550
+ const deterministic = sourceOnDisk === manifest.source.sha256 && secondVertexHash === measured.vertexHash && !bareRandom;
551
+ add(
552
+ "G10",
553
+ "determinism",
554
+ "stable source sha256 + identical vertex hash across two fresh evaluations, no bare Math.random()",
555
+ deterministic,
556
+ deterministic
557
+ ? `vertexHash ${measured.vertexHash}`
558
+ : bareRandom
559
+ ? "source calls Math.random() directly"
560
+ : `hash drift: ${measured.vertexHash} vs ${secondVertexHash}`,
561
+ { sourceSha256: sourceOnDisk, vertexHash: measured.vertexHash, secondVertexHash, bareRandom },
562
+ );
563
+
564
+ // --- G12 disposal ------------------------------------------------------
565
+ add(
566
+ "G12",
567
+ "disposal",
568
+ "renderer.info.memory returns to baseline after root.userData.dispose()",
569
+ disposal.passed,
570
+ disposal.hasDispose
571
+ ? `geometries ${disposal.before.geometries} → ${disposal.peak.geometries} → ${disposal.after.geometries}; textures ${disposal.before.textures} → ${disposal.peak.textures} → ${disposal.after.textures}`
572
+ : "root.userData.dispose is not a function",
573
+ disposal,
574
+ );
575
+
576
+ // --- G15 texture budget ------------------------------------------------
577
+ const textureBytesMax = config.textureBytesMax ?? 4 * 1024 * 1024;
578
+ const maxDimension = config.maxTextureDimension ?? 1024;
579
+ const textureOk = measured.geometry.textureBytes <= textureBytesMax && measured.maxTextureDimension <= maxDimension;
580
+ add(
581
+ "G15",
582
+ "texture-budget",
583
+ `<= ${Math.round(textureBytesMax / 1024)} KB total, no dimension > ${maxDimension}`,
584
+ textureOk,
585
+ `${Math.round(measured.geometry.textureBytes / 1024)} KB across ${measured.geometry.textures} texture(s), max dimension ${measured.maxTextureDimension}`,
586
+ );
587
+
588
+ // --- G8 paste-and-run --------------------------------------------------
589
+ if (args.pasteRun) {
590
+ const g8 = await pasteAndRun({ manifest, browser, projectDir: PROJECT_DIR });
591
+ add(
592
+ "G8",
593
+ "paste-and-run",
594
+ "scratch project with only three: tsc --noEmit --strict passes and the render lights >= 1 pixel",
595
+ g8.passed,
596
+ g8.measured,
597
+ g8.detail,
598
+ );
599
+ } else {
600
+ add("G8", "paste-and-run", "skipped via --skip-paste-run", false, "skipped");
601
+ }
602
+ } finally {
603
+ await site.close();
604
+ await browser.close();
605
+ }
606
+
607
+ // --- G14: validate the manifest that will actually ship -------------------
608
+ const runAt = new Date().toISOString();
609
+ const projected = {
610
+ ...manifest,
611
+ geometry: measured.geometry,
612
+ space: {
613
+ ...manifest.space,
614
+ boundingBox: measured.space.boundingBox,
615
+ sizeMeters: measured.space.sizeMeters,
616
+ sizeDeltaPct: measured.space.sizeMeters.map((v, i) => round(pct(v, config.statedSizeMeters[i]), 3)),
617
+ },
618
+ gates: {
619
+ runAt,
620
+ runner: RUNNER_ID,
621
+ allPassed: results.every((r) => r.passed),
622
+ results: results.map(({ id, name, threshold, measured: m, passed }) => ({ id, name, threshold, measured: m, passed })),
623
+ },
624
+ };
625
+ const projectedBytes = Buffer.byteLength(JSON.stringify(projected), "utf8");
626
+ const shape = validateManifest(projected, { bytes: projectedBytes });
627
+ add(
628
+ "G14",
629
+ "manifest-validity",
630
+ `parses against the manifest schema, <= ${MANIFEST_MAX_BYTES / 1024} KB, source.code non-empty`,
631
+ shape.ok && projectedBytes <= MANIFEST_MAX_BYTES && (manifest.source?.code?.length ?? 0) > 0,
632
+ `${(projectedBytes / 1024).toFixed(1)} KB${shape.ok ? "" : `; ${shape.errors.length} schema error(s)`}`,
633
+ { bytes: projectedBytes, errors: shape.errors },
634
+ );
635
+
636
+ const allPassed = results.every((r) => r.passed);
637
+ const report = {
638
+ runner: RUNNER_ID,
639
+ runAt,
640
+ slug: config.slug,
641
+ name: config.name,
642
+ threeRevision: measured.threeRevision,
643
+ sourceSha256: manifest.source.sha256,
644
+ allPassed,
645
+ geometry: measured.geometry,
646
+ space: {
647
+ boundingBox: measured.space.boundingBox,
648
+ sizeMeters: measured.space.sizeMeters,
649
+ centre: measured.space.centre,
650
+ statedSizeMeters: config.statedSizeMeters,
651
+ sizeDeltaPct: projected.space.sizeDeltaPct,
652
+ },
653
+ userData: measured.userData,
654
+ results,
655
+ };
656
+
657
+ const outPath = path.resolve(PROJECT_DIR, args.out);
658
+ await fsp.mkdir(path.dirname(outPath), { recursive: true });
659
+ await fsp.writeFile(outPath, JSON.stringify(report, null, 2) + "\n", "utf8");
660
+
661
+ const width = Math.max(...results.map((r) => r.name.length));
662
+ console.log(`\n${config.name} - ${RUNNER_ID}\n`);
663
+ for (const r of results.slice().sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)))) {
664
+ console.log(
665
+ ` ${r.passed ? "pass" : "FAIL"} ${r.id.padEnd(3)} ${r.name.padEnd(width)} ${String(r.measured)}${r.passed ? "" : ` (want ${r.threshold})`}`,
666
+ );
667
+ }
668
+ console.log(
669
+ `\n ${allPassed ? `all ${results.length} gates passed` : `${results.filter((r) => !r.passed).length} of ${results.length} gates FAILED`}`,
670
+ );
671
+ console.log(` report: ${path.relative(PROJECT_DIR, outPath)}\n`);
672
+
673
+ return allPassed ? 0 : 1;
674
+ }
675
+
676
+ main().then(
677
+ (code) => process.exit(code),
678
+ (error) => {
679
+ console.error(`\n[gates] runner error: ${error?.stack ?? error}\n`);
680
+ process.exit(2);
681
+ },
682
+ );