@auraindustry/aurajs 0.0.2 → 0.0.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.
@@ -3,6 +3,12 @@ import { resolve } from 'node:path';
3
3
  import vm from 'node:vm';
4
4
 
5
5
  import { bundleProject } from './bundler.mjs';
6
+ import {
7
+ createGameStateRuntimeHooks,
8
+ diffCanonicalGameState,
9
+ applyCanonicalStatePatch,
10
+ DEFAULT_STATE_MUTATION_GUARDRAILS,
11
+ } from './game-state-runtime.mjs';
6
12
 
7
13
  export class HeadlessTestError extends Error {
8
14
  constructor(message, details = {}) {
@@ -12,6 +18,16 @@ export class HeadlessTestError extends Error {
12
18
  }
13
19
  }
14
20
 
21
+ function createHeadlessTestState() {
22
+ return {
23
+ failures: [],
24
+ passes: 0,
25
+ drawCalls: 0,
26
+ audioCalls: 0,
27
+ logs: [],
28
+ };
29
+ }
30
+
15
31
  export function parseTestArgs(args) {
16
32
  const parsed = {
17
33
  file: 'src/test.js',
@@ -71,13 +87,7 @@ export async function runHeadlessTest(options = {}) {
71
87
  outFile: resolve(projectRoot, '.aura/test/test.bundle.js'),
72
88
  });
73
89
 
74
- const testState = {
75
- failures: [],
76
- passes: 0,
77
- drawCalls: 0,
78
- audioCalls: 0,
79
- logs: [],
80
- };
90
+ const testState = createHeadlessTestState();
81
91
 
82
92
  const aura = createHeadlessAura({ width, height, testState });
83
93
  const context = vm.createContext(createRuntimeContext(aura, testState));
@@ -116,6 +126,482 @@ export async function runHeadlessTest(options = {}) {
116
126
  };
117
127
  }
118
128
 
129
+ export async function runHeadlessStateExport(options = {}) {
130
+ const projectRoot = resolve(options.projectRoot || process.cwd());
131
+ const entryFile = resolve(projectRoot, options.file || 'src/main.js');
132
+ const width = options.width ?? 1280;
133
+ const height = options.height ?? 720;
134
+ const frames = options.frames ?? 1;
135
+
136
+ if (!existsSync(entryFile)) {
137
+ throw new HeadlessTestError(`State export entry file not found: ${entryFile}`);
138
+ }
139
+
140
+ const bundle = bundleProject({
141
+ projectRoot,
142
+ mode: 'test',
143
+ entryFile,
144
+ outFile: resolve(projectRoot, '.aura/test/state-export.bundle.js'),
145
+ });
146
+
147
+ const testState = createHeadlessTestState();
148
+ const aura = createHeadlessAura({ width, height, testState });
149
+ const context = vm.createContext(createRuntimeContext(aura, testState));
150
+
151
+ const source = readFileSync(bundle.outFile, 'utf8');
152
+ const script = new vm.Script(source, {
153
+ filename: bundle.outFile,
154
+ displayErrors: true,
155
+ });
156
+
157
+ try {
158
+ script.runInContext(context, { timeout: 5000 });
159
+ await executeLifecycle(aura, frames);
160
+ } catch (error) {
161
+ throw new HeadlessTestError(`Headless state export execution failed: ${error.message}`, {
162
+ stack: error.stack,
163
+ });
164
+ }
165
+
166
+ if (!aura.state || typeof aura.state !== 'object' || typeof aura.state.export !== 'function') {
167
+ throw new HeadlessTestError('Headless state export failed: aura.state.export hook is unavailable.');
168
+ }
169
+
170
+ let exportResult;
171
+ try {
172
+ exportResult = aura.state.export({
173
+ mode: options.mode,
174
+ seed: options.seed,
175
+ frameIndex: options.frameIndex ?? frames,
176
+ elapsedSeconds: options.elapsedSeconds,
177
+ capturedAt: options.capturedAt ?? null,
178
+ });
179
+ } catch (error) {
180
+ throw new HeadlessTestError(`Headless state export failed: ${error.message}`, {
181
+ stack: error.stack,
182
+ });
183
+ }
184
+
185
+ if (!exportResult || typeof exportResult !== 'object') {
186
+ throw new HeadlessTestError('Headless state export failed: runtime returned an invalid export result.');
187
+ }
188
+
189
+ return {
190
+ ok: true,
191
+ entryFile,
192
+ bundle,
193
+ frames,
194
+ width,
195
+ height,
196
+ exportResult,
197
+ passes: testState.passes,
198
+ drawCalls: testState.drawCalls,
199
+ audioCalls: testState.audioCalls,
200
+ };
201
+ }
202
+
203
+ export async function runHeadlessStateApply(options = {}) {
204
+ const projectRoot = resolve(options.projectRoot || process.cwd());
205
+ const entryFile = resolve(projectRoot, options.file || 'src/main.js');
206
+ const width = options.width ?? 1280;
207
+ const height = options.height ?? 720;
208
+ const frames = options.frames ?? 1;
209
+ const targetPayload = options.payload;
210
+ const dryRun = options.dryRun === true;
211
+ const verify = options.verify === true;
212
+ const rollbackOnFail = options.rollbackOnFail === true;
213
+ const guardrails = normalizeStateMutationGuardrails(options.guardrails);
214
+ const startedAtMs = Date.now();
215
+ const isTimedOut = () => (Date.now() - startedAtMs) >= guardrails.maxRuntimeMs;
216
+
217
+ if (!existsSync(entryFile)) {
218
+ throw new HeadlessTestError(`State apply entry file not found: ${entryFile}`);
219
+ }
220
+ if (!targetPayload || typeof targetPayload !== 'object' || Array.isArray(targetPayload)) {
221
+ throw new HeadlessTestError('Headless state apply failed: payload must be an object.');
222
+ }
223
+ const payloadBytes = jsonByteSize(targetPayload);
224
+ if (payloadBytes > guardrails.maxPayloadBytes) {
225
+ return {
226
+ ok: false,
227
+ reasonCode: 'state_payload_too_large',
228
+ detail: `payload bytes exceed maxPayloadBytes=${guardrails.maxPayloadBytes}`,
229
+ dryRun,
230
+ verify,
231
+ rollbackOnFail,
232
+ payloadBytes,
233
+ maxPayloadBytes: guardrails.maxPayloadBytes,
234
+ };
235
+ }
236
+
237
+ const bundle = bundleProject({
238
+ projectRoot,
239
+ mode: 'test',
240
+ entryFile,
241
+ outFile: resolve(projectRoot, '.aura/test/state-apply.bundle.js'),
242
+ });
243
+
244
+ const testState = createHeadlessTestState();
245
+ const aura = createHeadlessAura({ width, height, testState });
246
+ const context = vm.createContext(createRuntimeContext(aura, testState));
247
+
248
+ const source = readFileSync(bundle.outFile, 'utf8');
249
+ const script = new vm.Script(source, {
250
+ filename: bundle.outFile,
251
+ displayErrors: true,
252
+ });
253
+
254
+ try {
255
+ script.runInContext(context, { timeout: 5000 });
256
+ await executeLifecycle(aura, frames);
257
+ } catch (error) {
258
+ throw new HeadlessTestError(`Headless state apply execution failed: ${error.message}`, {
259
+ stack: error.stack,
260
+ });
261
+ }
262
+ if (isTimedOut()) {
263
+ return {
264
+ ok: false,
265
+ reasonCode: 'state_apply_timeout',
266
+ detail: `state apply exceeded maxRuntimeMs=${guardrails.maxRuntimeMs}`,
267
+ dryRun,
268
+ verify,
269
+ rollbackOnFail,
270
+ maxRuntimeMs: guardrails.maxRuntimeMs,
271
+ };
272
+ }
273
+
274
+ if (!aura.state || typeof aura.state !== 'object') {
275
+ throw new HeadlessTestError('Headless state apply failed: aura.state surface is unavailable.');
276
+ }
277
+ if (typeof aura.state.export !== 'function') {
278
+ throw new HeadlessTestError('Headless state apply failed: aura.state.export hook is unavailable.');
279
+ }
280
+ if (typeof aura.state.apply !== 'function') {
281
+ throw new HeadlessTestError('Headless state apply failed: aura.state.apply hook is unavailable.');
282
+ }
283
+
284
+ const captureExport = () => {
285
+ const result = aura.state.export({
286
+ mode: options.mode,
287
+ seed: options.seed,
288
+ frameIndex: options.frameIndex ?? frames,
289
+ elapsedSeconds: options.elapsedSeconds,
290
+ capturedAt: options.capturedAt ?? null,
291
+ });
292
+ if (!result || typeof result !== 'object' || result.ok !== true || !result.payload) {
293
+ throw new HeadlessTestError('Headless state apply failed: aura.state.export returned invalid result.');
294
+ }
295
+ return result.payload;
296
+ };
297
+
298
+ const baselinePayload = captureExport();
299
+ const baselineFingerprint = baselinePayload.export?.fingerprint || null;
300
+ const preview = diffCanonicalGameState(baselinePayload, targetPayload);
301
+ if (!preview.ok) {
302
+ return {
303
+ ok: false,
304
+ reasonCode: preview.reasonCode || (dryRun ? 'state_dry_run_failed' : 'state_apply_failed'),
305
+ detail: preview.detail || null,
306
+ dryRun,
307
+ verify,
308
+ rollbackOnFail,
309
+ baselinePayload,
310
+ preview,
311
+ baselineFingerprint,
312
+ };
313
+ }
314
+ if (preview.patch.mutations.length > guardrails.maxMutations) {
315
+ return {
316
+ ok: false,
317
+ reasonCode: 'mutation_budget_exceeded',
318
+ detail: `mutation count ${preview.patch.mutations.length} exceeds maxMutations=${guardrails.maxMutations}`,
319
+ dryRun,
320
+ verify,
321
+ rollbackOnFail,
322
+ mutationCount: preview.patch.mutations.length,
323
+ maxMutations: guardrails.maxMutations,
324
+ baselineFingerprint,
325
+ };
326
+ }
327
+ const guardPreviewApply = applyCanonicalStatePatch(baselinePayload, preview.patch, {
328
+ maxMutations: guardrails.maxMutations,
329
+ maxPayloadBytes: guardrails.maxPayloadBytes,
330
+ maxRuntimeMs: guardrails.maxRuntimeMs,
331
+ allowlistPrefixes: guardrails.allowlistPrefixes,
332
+ });
333
+ if (!guardPreviewApply.ok) {
334
+ return {
335
+ ok: false,
336
+ reasonCode: guardPreviewApply.reasonCode || 'state_apply_failed',
337
+ detail: guardPreviewApply.detail || null,
338
+ dryRun,
339
+ verify,
340
+ rollbackOnFail,
341
+ mutationCount: preview.patch.mutations.length,
342
+ baselineFingerprint,
343
+ };
344
+ }
345
+ if (isTimedOut()) {
346
+ return {
347
+ ok: false,
348
+ reasonCode: 'state_apply_timeout',
349
+ detail: `state apply exceeded maxRuntimeMs=${guardrails.maxRuntimeMs}`,
350
+ dryRun,
351
+ verify,
352
+ rollbackOnFail,
353
+ mutationCount: preview.patch.mutations.length,
354
+ maxRuntimeMs: guardrails.maxRuntimeMs,
355
+ baselineFingerprint,
356
+ };
357
+ }
358
+
359
+ if (dryRun) {
360
+ const afterDryRunPayload = captureExport();
361
+ const unchanged = diffCanonicalGameState(baselinePayload, afterDryRunPayload);
362
+ if (!unchanged.ok || unchanged.patch?.mutations?.length !== 0) {
363
+ return {
364
+ ok: false,
365
+ reasonCode: 'state_dry_run_mutated',
366
+ detail: 'Dry-run preview mutated runtime state.',
367
+ dryRun: true,
368
+ verify,
369
+ rollbackOnFail,
370
+ baselinePayload,
371
+ preview,
372
+ };
373
+ }
374
+
375
+ return {
376
+ ok: true,
377
+ reasonCode: 'state_dry_run_ok',
378
+ dryRun: true,
379
+ verify,
380
+ rollbackOnFail,
381
+ applied: false,
382
+ verified: false,
383
+ rolledBack: false,
384
+ mutationCount: preview.patch.mutations.length,
385
+ previewPatch: preview.patch,
386
+ baselineFingerprint,
387
+ finalFingerprint: afterDryRunPayload.export?.fingerprint || null,
388
+ payloadBytes,
389
+ };
390
+ }
391
+
392
+ if (isTimedOut()) {
393
+ return {
394
+ ok: false,
395
+ reasonCode: 'state_apply_timeout',
396
+ detail: `state apply exceeded maxRuntimeMs=${guardrails.maxRuntimeMs}`,
397
+ dryRun: false,
398
+ verify,
399
+ rollbackOnFail,
400
+ mutationCount: preview.patch.mutations.length,
401
+ maxRuntimeMs: guardrails.maxRuntimeMs,
402
+ baselineFingerprint,
403
+ };
404
+ }
405
+
406
+ const applyResult = aura.state.apply(targetPayload, {
407
+ dryRun,
408
+ verify,
409
+ rollbackOnFail,
410
+ });
411
+ if (!applyResult || typeof applyResult !== 'object') {
412
+ return {
413
+ ok: false,
414
+ reasonCode: 'state_apply_runtime_failed',
415
+ detail: 'aura.state.apply returned invalid result',
416
+ dryRun: false,
417
+ verify,
418
+ rollbackOnFail,
419
+ baselineFingerprint,
420
+ mutationCount: preview.patch.mutations.length,
421
+ payloadBytes,
422
+ };
423
+ }
424
+
425
+ const rollbackBaseline = () => {
426
+ try {
427
+ const rollback = aura.state.apply(baselinePayload, {
428
+ dryRun: false,
429
+ verify: false,
430
+ rollbackOnFail: false,
431
+ });
432
+ return rollback && typeof rollback === 'object' ? rollback : { ok: false, reasonCode: 'rollback_failed' };
433
+ } catch (error) {
434
+ return {
435
+ ok: false,
436
+ reasonCode: 'rollback_failed',
437
+ detail: error instanceof Error ? error.message : String(error),
438
+ };
439
+ }
440
+ };
441
+
442
+ if (applyResult.ok !== true) {
443
+ if (!rollbackOnFail) {
444
+ return {
445
+ ok: false,
446
+ reasonCode: applyResult.reasonCode || 'state_apply_failed',
447
+ detail: applyResult.detail || null,
448
+ dryRun: false,
449
+ verify,
450
+ rollbackOnFail,
451
+ applied: false,
452
+ verified: false,
453
+ rolledBack: false,
454
+ baselineFingerprint,
455
+ mutationCount: preview.patch.mutations.length,
456
+ payloadBytes,
457
+ };
458
+ }
459
+
460
+ const rollback = rollbackBaseline();
461
+ return {
462
+ ok: false,
463
+ reasonCode: rollback.ok === true
464
+ ? (applyResult.reasonCode || 'state_apply_failed')
465
+ : (rollback.reasonCode || 'rollback_failed'),
466
+ detail: applyResult.detail || rollback.detail || null,
467
+ dryRun: false,
468
+ verify,
469
+ rollbackOnFail,
470
+ applied: false,
471
+ verified: false,
472
+ rolledBack: rollback.ok === true,
473
+ rollbackReasonCode: rollback.reasonCode || null,
474
+ baselineFingerprint,
475
+ mutationCount: preview.patch.mutations.length,
476
+ payloadBytes,
477
+ };
478
+ }
479
+
480
+ const afterApplyPayload = captureExport();
481
+ if (isTimedOut()) {
482
+ return {
483
+ ok: false,
484
+ reasonCode: 'state_apply_timeout',
485
+ detail: `state apply exceeded maxRuntimeMs=${guardrails.maxRuntimeMs}`,
486
+ dryRun: false,
487
+ verify,
488
+ rollbackOnFail,
489
+ applied: true,
490
+ verified: false,
491
+ rolledBack: false,
492
+ mutationCount: preview.patch.mutations.length,
493
+ maxRuntimeMs: guardrails.maxRuntimeMs,
494
+ baselineFingerprint,
495
+ finalFingerprint: afterApplyPayload.export?.fingerprint || null,
496
+ payloadBytes,
497
+ };
498
+ }
499
+ let verified = false;
500
+ if (verify) {
501
+ const verifyDiff = diffCanonicalGameState(targetPayload, afterApplyPayload);
502
+ verified = verifyDiff.ok === true && verifyDiff.patch?.mutations?.length === 0;
503
+ if (!verified) {
504
+ if (!rollbackOnFail) {
505
+ return {
506
+ ok: false,
507
+ reasonCode: 'verify_failed',
508
+ detail: 'Applied state does not match requested payload.',
509
+ dryRun: false,
510
+ verify,
511
+ rollbackOnFail,
512
+ applied: true,
513
+ verified: false,
514
+ rolledBack: false,
515
+ baselineFingerprint,
516
+ finalFingerprint: afterApplyPayload.export?.fingerprint || null,
517
+ mutationCount: preview.patch.mutations.length,
518
+ payloadBytes,
519
+ };
520
+ }
521
+
522
+ const rollback = rollbackBaseline();
523
+ return {
524
+ ok: false,
525
+ reasonCode: rollback.ok === true ? 'verify_failed' : (rollback.reasonCode || 'rollback_failed'),
526
+ detail: 'Applied state does not match requested payload.',
527
+ dryRun: false,
528
+ verify,
529
+ rollbackOnFail,
530
+ applied: true,
531
+ verified: false,
532
+ rolledBack: rollback.ok === true,
533
+ rollbackReasonCode: rollback.reasonCode || null,
534
+ baselineFingerprint,
535
+ finalFingerprint: afterApplyPayload.export?.fingerprint || null,
536
+ mutationCount: preview.patch.mutations.length,
537
+ payloadBytes,
538
+ };
539
+ }
540
+ }
541
+
542
+ return {
543
+ ok: true,
544
+ reasonCode: 'state_apply_ok',
545
+ dryRun: false,
546
+ verify,
547
+ rollbackOnFail,
548
+ applied: true,
549
+ verified,
550
+ rolledBack: false,
551
+ mutationCount: preview.patch.mutations.length,
552
+ baselineFingerprint,
553
+ finalFingerprint: afterApplyPayload.export?.fingerprint || null,
554
+ payloadBytes,
555
+ applyResult,
556
+ };
557
+ }
558
+
559
+ function normalizeStateMutationGuardrails(value) {
560
+ const source = value && typeof value === 'object' ? value : {};
561
+ const maxMutations = normalizeNonNegativeInteger(
562
+ source.maxMutations,
563
+ DEFAULT_STATE_MUTATION_GUARDRAILS.maxMutations,
564
+ );
565
+ const maxPayloadBytes = normalizeNonNegativeInteger(
566
+ source.maxPayloadBytes,
567
+ DEFAULT_STATE_MUTATION_GUARDRAILS.maxPayloadBytes,
568
+ );
569
+ const maxRuntimeMs = normalizeNonNegativeInteger(
570
+ source.maxRuntimeMs,
571
+ DEFAULT_STATE_MUTATION_GUARDRAILS.maxRuntimeMs,
572
+ );
573
+ const allowlistPrefixes = Array.isArray(source.allowlistPrefixes) && source.allowlistPrefixes.length > 0
574
+ ? source.allowlistPrefixes
575
+ .filter((entry) => typeof entry === 'string' && entry.trim().startsWith('/'))
576
+ .map((entry) => entry.trim())
577
+ : [...DEFAULT_STATE_MUTATION_GUARDRAILS.allowlistPrefixes];
578
+
579
+ return {
580
+ maxMutations,
581
+ maxPayloadBytes,
582
+ maxRuntimeMs,
583
+ allowlistPrefixes: [...new Set(allowlistPrefixes)].sort((a, b) => a.localeCompare(b)),
584
+ };
585
+ }
586
+
587
+ function normalizeNonNegativeInteger(value, fallback) {
588
+ const parsed = Number(value);
589
+ if (!Number.isInteger(parsed) || parsed < 0) {
590
+ return fallback;
591
+ }
592
+ return parsed;
593
+ }
594
+
595
+ function jsonByteSize(value) {
596
+ let text = '';
597
+ try {
598
+ text = JSON.stringify(value);
599
+ } catch {
600
+ text = '';
601
+ }
602
+ return Buffer.byteLength(text || '', 'utf8');
603
+ }
604
+
119
605
  function createRuntimeContext(aura, testState) {
120
606
  const consoleShim = {
121
607
  log: (...args) => {
@@ -149,6 +635,7 @@ function createHeadlessAura({ width, height, testState }) {
149
635
  const drawNoop = () => {
150
636
  testState.drawCalls += 1;
151
637
  };
638
+ const stateNoop = () => {};
152
639
 
153
640
  const makeVec2 = (x = 0, y = 0) => ({ x: Number(x), y: Number(y) });
154
641
  const vec2 = function vec2(x = 0, y = 0) {
@@ -217,6 +704,14 @@ function createHeadlessAura({ width, height, testState }) {
217
704
  ? value
218
705
  : null
219
706
  );
707
+ const parseRectFromComponents = (x, y, w, h) => (
708
+ isFiniteNumber(x)
709
+ && isFiniteNumber(y)
710
+ && isFiniteNumber(w)
711
+ && isFiniteNumber(h)
712
+ ? { x, y, w, h }
713
+ : null
714
+ );
220
715
  const parsePoint = (value) => (
221
716
  value
222
717
  && isFiniteNumber(value.x)
@@ -249,9 +744,16 @@ function createHeadlessAura({ width, height, testState }) {
249
744
  };
250
745
 
251
746
  const collision = {
252
- rectRect: (a, b) => {
253
- const lhs = parseRect(a);
254
- const rhs = parseRect(b);
747
+ rectRect: (...args) => {
748
+ let lhs = null;
749
+ let rhs = null;
750
+ if (args.length >= 2 && typeof args[0] === 'object' && typeof args[1] === 'object') {
751
+ lhs = parseRect(args[0]);
752
+ rhs = parseRect(args[1]);
753
+ } else if (args.length >= 8) {
754
+ lhs = parseRectFromComponents(args[0], args[1], args[2], args[3]);
755
+ rhs = parseRectFromComponents(args[4], args[5], args[6], args[7]);
756
+ }
255
757
  return !!lhs && !!rhs && collisionRectRect(lhs, rhs);
256
758
  },
257
759
  rectPoint: (rect, point) => {
@@ -3818,7 +4320,14 @@ function createHeadlessAura({ width, height, testState }) {
3818
4320
  },
3819
4321
 
3820
4322
  draw3d: {
4323
+ drawMesh: drawNoop,
4324
+ clear3d: drawNoop,
4325
+ drawSkybox: drawNoop,
4326
+ billboard: drawNoop,
3821
4327
  mesh: drawNoop,
4328
+ setEnvironmentMap: stateNoop,
4329
+ setFog: stateNoop,
4330
+ clearFog: stateNoop,
3822
4331
  },
3823
4332
 
3824
4333
  material: {
@@ -4269,6 +4778,12 @@ function createHeadlessAura({ width, height, testState }) {
4269
4778
  vec3,
4270
4779
  };
4271
4780
 
4781
+ const gameStateHooks = createGameStateRuntimeHooks({ aura, mode: 'headless' });
4782
+ aura.state = {
4783
+ export: (options = {}) => gameStateHooks.exportState(options),
4784
+ apply: (payload, options = {}) => gameStateHooks.applyState(payload, options),
4785
+ };
4786
+
4272
4787
  return aura;
4273
4788
  }
4274
4789
 
@@ -314,8 +314,16 @@ function findInstalledPackageRoot(packageName, { requireResolve, readPackageJson
314
314
 
315
315
  function resolveLocalHostBinaryCandidates(searchRoot, binaryName) {
316
316
  return [
317
- resolve(searchRoot, 'src', 'rust-host', 'target', 'debug', binaryName),
318
- resolve(searchRoot, 'src', 'rust-host', 'target', 'release', binaryName),
317
+ {
318
+ kind: 'release',
319
+ path: resolve(searchRoot, 'src', 'rust-host', 'target', 'release', binaryName),
320
+ reasonCode: 'host_binary_local_release_selected',
321
+ },
322
+ {
323
+ kind: 'debug',
324
+ path: resolve(searchRoot, 'src', 'rust-host', 'target', 'debug', binaryName),
325
+ reasonCode: 'host_binary_local_debug_fallback',
326
+ },
319
327
  ];
320
328
  }
321
329
 
@@ -356,23 +364,35 @@ export function resolveGateHostBinary({
356
364
  const binaryName = basename(spec.binaryRelativePath);
357
365
  const repoRoot = findRepoRootWithRustHost(searchFrom);
358
366
  const localCandidates = repoRoot ? resolveLocalHostBinaryCandidates(repoRoot, binaryName) : [];
367
+ const checkedCandidates = localCandidates.map((candidate) => candidate.path);
359
368
 
360
369
  for (const candidate of localCandidates) {
361
- if (!existsSync(candidate)) continue;
362
- if (!canExecuteFile(candidate)) {
363
- diagnostics.push(`Detected local host binary but it is not executable: ${candidate}`);
370
+ if (!existsSync(candidate.path)) continue;
371
+ if (!canExecuteFile(candidate.path)) {
372
+ diagnostics.push(`[host_binary_local_non_executable] Detected local ${candidate.kind} host binary but it is not executable: ${candidate.path}`);
364
373
  continue;
365
374
  }
375
+ if (candidate.kind === 'debug') {
376
+ diagnostics.push(
377
+ `[host_binary_local_debug_fallback] Selected local debug host because release host is unavailable or not executable: ${candidate.path}`,
378
+ );
379
+ }
366
380
 
367
381
  return {
368
382
  ...spec,
369
- binaryPath: candidate,
370
- packageRoot: dirname(candidate),
383
+ binaryPath: candidate.path,
384
+ packageRoot: dirname(candidate.path),
371
385
  source: 'local-build',
386
+ reasonCode: candidate.reasonCode,
372
387
  diagnostics,
373
388
  localBuild: {
374
389
  repoRoot,
375
- checked: localCandidates,
390
+ checked: checkedCandidates,
391
+ selected: {
392
+ kind: candidate.kind,
393
+ path: candidate.path,
394
+ reasonCode: candidate.reasonCode,
395
+ },
376
396
  },
377
397
  resolvedAt: new Date().toISOString(),
378
398
  };
@@ -385,13 +405,15 @@ export function resolveGateHostBinary({
385
405
 
386
406
  try {
387
407
  const cached = resolveCachedBinary({ platform, arch, ...(cacheRoot ? { cacheRoot } : {}) });
408
+ const cachedReasonCode = cached.override ? 'host_binary_override_selected' : 'host_binary_cache_selected';
388
409
  return {
389
410
  ...cached,
390
411
  source: cached.override ? 'override' : 'cache',
412
+ reasonCode: cachedReasonCode,
391
413
  diagnostics: [...diagnostics, ...(cached.diagnostics || [])],
392
414
  localBuild: {
393
415
  repoRoot,
394
- checked: localCandidates,
416
+ checked: checkedCandidates,
395
417
  },
396
418
  resolvedAt: new Date().toISOString(),
397
419
  };
@@ -403,10 +425,11 @@ export function resolveGateHostBinary({
403
425
  return {
404
426
  ...packaged,
405
427
  source: 'package',
428
+ reasonCode: 'host_binary_package_selected',
406
429
  diagnostics,
407
430
  localBuild: {
408
431
  repoRoot,
409
- checked: localCandidates,
432
+ checked: checkedCandidates,
410
433
  },
411
434
  resolvedAt: new Date().toISOString(),
412
435
  };