@aefree/pi-unity 0.13.0 → 0.15.0

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.
@@ -67,4 +67,4 @@ Normally call the typed tools, not raw CLI commands. If a typed tool is unavaila
67
67
 
68
68
  Use `unity_run_tests` with `execution: "isolated"` for a closed project, intentional isolation/CI, multiple selectors in one run, retries, sharding, coverage, or required NUnit/JUnit evidence. Multiple independent fixtures alone do not require closing a reachable Editor: use the serial recipe above. A single category is supported connected when Pipeline advertises the command; do not claim broader connected selector support. Do not use batchmode as an automatic fallback after an uncertain connected dispatch.
69
69
 
70
- Use the typed compile/test tools when their polling and terminal evidence fit the task. For an explicitly requested existing C# builder file, `unity_pipeline_run_script` uses Pipeline 0.6 ephemeral in-memory compilation with bounded JSON arguments or compile-only dry run; it never enables hotpatch and remains arbitrary code execution requiring explicit mutation authorization. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows; its `timeoutSeconds` range is 1–86,400 seconds, and a timeout remains uncertain without cancellation or retry. It is an assistance surface, not a forbidden fallback or a substitute for the typed tools' completion protocol. Eval compiles arbitrary C# with Roslyn on the Editor main thread, so ordinary properties and local-variable snippets are valid; it is not expression-only or reliably statically read-only. Prefer typed tools for their stronger evidence, but let user intent and project guidance govern mutations. Lifecycle, persistent-setting, destructive, asset, scene-save, package, build, and test mutations require explicit authorization.
70
+ Use the typed compile/test tools when their polling and terminal evidence fit the task. For an explicitly requested existing C# builder file, `unity_pipeline_run_script` uses Pipeline 0.6 ephemeral in-memory compilation with bounded JSON arguments or compile-only dry run; it never enables hotpatch and remains arbitrary code execution requiring explicit mutation authorization. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows. Its `timeoutSeconds` range is 1–86,400 seconds and bounds pi-unity/Unity CLI waits. Optional `handlerTimeoutMilliseconds` (1–86,400,000) is forwarded only when the exact reachable Pipeline advertises raw argv and the verified eval `code`/integer-`timeout` signature; it bounds Pipeline's dispatcher wait, while a shorter host wait can still win. A dispatcher expiry cannot cancel code already started on Unity's main thread, so the effect remains uncertain and no retry or fallback is allowed. It is an assistance surface, not a forbidden fallback or a substitute for the typed tools' completion protocol. Eval compiles arbitrary C# with Roslyn on the Editor main thread, so ordinary properties and local-variable snippets are valid; it is not expression-only or reliably statically read-only. Prefer typed tools for their stronger evidence, but let user intent and project guidance govern mutations. Lifecycle, persistent-setting, destructive, asset, scene-save, package, build, and test mutations require explicit authorization.
@@ -9,6 +9,16 @@ const strings = (value: unknown): value is string[] => Array.isArray(value) && v
9
9
  const relativeId = (value: unknown): value is string => typeof value === "string" && !!value.trim()
10
10
  && !isAbsolute(value) && !/^(?:[A-Za-z]:|[\\/])/.test(value) && !value.split(/[\\/]/).includes("..") && !/\0/.test(value);
11
11
 
12
+ /** Shared count/retained-record invariant for durable validation and Pipeline pass acceptance. */
13
+ export function hasConsistentUnityTestCounts(summary: Record<string, unknown>, tests: Array<{ status: string }>): boolean {
14
+ const total = summary.total; const passed = summary.passed; const failed = summary.failed;
15
+ const skipped = summary.skipped; const inconclusive = summary.inconclusive;
16
+ if (![total, passed, failed, skipped, inconclusive].every(value => value === undefined || count(value))) return false;
17
+ if (count(total) && [passed, failed, skipped, inconclusive].reduce<number>((sum, value) => sum + (count(value) ? value : 0), 0) > total) return false;
18
+ if (count(total) && tests.length > total) return false;
19
+ return [["passed", passed], ["failed", failed], ["skipped", skipped], ["inconclusive", inconclusive]].every(([status, limit]) => !count(limit) || tests.filter(test => status === "passed" ? /^(?:passed|success)$/i.test(test.status) : test.status.toLowerCase() === status).length <= limit);
20
+ }
21
+
12
22
  /** Read the durable schema, not a transport response. Missing optional counts remain unknown.
13
23
  * Test records may be bounded or absent: never require tests.length === summary.total.
14
24
  */
@@ -25,10 +35,7 @@ export function validateNormalizedUnityTestArtifact(value: unknown): NormalizedU
25
35
  for (const key of ["total", "passed", "failed", "skipped", "inconclusive"]) {
26
36
  if (summary[key] !== undefined && !count(summary[key])) invalid(`summary.${key} must be a non-negative integer`);
27
37
  }
28
- if (count(summary.total)) {
29
- const accounted = [summary.passed, summary.failed, summary.skipped, summary.inconclusive].reduce<number>((sum, item) => sum + (count(item) ? item : 0), 0);
30
- if (accounted > summary.total) invalid("summary counts exceed total");
31
- }
38
+ if (!hasConsistentUnityTestCounts(summary, [])) invalid("summary counts are inconsistent");
32
39
  if (!Array.isArray(result.tests)) invalid("tests must be an array");
33
40
  const tests = result.tests as unknown[];
34
41
  for (const test of tests) {
@@ -38,14 +45,11 @@ export function validateNormalizedUnityTestArtifact(value: unknown): NormalizedU
38
45
  if (item.durationSeconds !== undefined && !nonnegative(item.durationSeconds)) invalid("test durationSeconds must be non-negative");
39
46
  if (item.attempts !== undefined && (!count(item.attempts) || item.attempts < 1)) invalid("test attempts must be positive");
40
47
  }
41
- if (count(summary.total) && tests.length > summary.total) invalid("test records exceed total");
48
+ if (!hasConsistentUnityTestCounts(summary, tests as Array<{ status: string }>)) invalid("test records conflict with summary counts");
42
49
  const typed = result as unknown as NormalizedUnityTestResult;
43
- for (const [status, key] of [["passed", "passed"], ["failed", "failed"], ["skipped", "skipped"], ["inconclusive", "inconclusive"]] as const) {
44
- const observed = typed.tests.filter(test => test.status.toLowerCase() === status).length;
45
- if (count(summary[key]) && observed > summary[key]) invalid(`test records conflict with summary.${key}`);
46
- }
47
50
  if (result.projectRelativeId !== undefined && !relativeId(result.projectRelativeId)) invalid("projectRelativeId must be project-relative");
48
51
  if (result.backendArtifacts !== undefined && (!record(result.backendArtifacts) || !Object.values(result.backendArtifacts).every(relativeId))) invalid("backendArtifacts must contain project-relative paths");
52
+ if (result.diagnostics !== undefined && (!Array.isArray(result.diagnostics) || result.diagnostics.length > 8 || !result.diagnostics.every(item => typeof item === "string" && !!item.trim() && item.length <= 1_000))) invalid("diagnostics must contain at most eight bounded strings");
49
53
  for (const key of ["startedAt", "completedAt"]) if (result[key] !== undefined && (typeof result[key] !== "string" || !Number.isFinite(Date.parse(result[key] as string)))) invalid(`${key} must be a timestamp`);
50
54
  if (typed.startedAt && typed.completedAt && Date.parse(typed.completedAt) < Date.parse(typed.startedAt)) invalid("completion precedes start");
51
55
  if (result.durationSeconds !== undefined && !nonnegative(result.durationSeconds)) invalid("durationSeconds must be non-negative");
@@ -24,7 +24,7 @@ const OWNER = Object.freeze({
24
24
  * metadata remains project-owned and schema-open.
25
25
  */
26
26
  export function createUnityArtifactProfileV1(): ArtifactProfileV1 {
27
- return Object.freeze({
27
+ return Object.freeze<ArtifactProfileV1>({
28
28
  contractVersion: 1,
29
29
  id: UNITY_ARTIFACT_PROFILE_ID_V1,
30
30
  kind: "artifact-profile",
package/src/unity-cli.ts CHANGED
@@ -46,6 +46,13 @@ export type UnityCliPipelineInstance = {
46
46
 
47
47
  export type UnityCliDiscoveryState = "not_attempted" | "available" | "absent" | "timeout" | "unavailable";
48
48
 
49
+ export type UnityCliCommandParameter = {
50
+ name: string;
51
+ type: string;
52
+ required: boolean;
53
+ defaultValue?: unknown;
54
+ };
55
+
49
56
  export type UnityCliProjectCapabilities = {
50
57
  cliAvailable: boolean;
51
58
  cliVersion?: string;
@@ -56,6 +63,12 @@ export type UnityCliProjectCapabilities = {
56
63
  advertisedCommands: string[];
57
64
  advertisedCommandCount: number;
58
65
  advertisedCommandsTruncated: boolean;
66
+ /** Display-oriented command descriptors. They are never capability evidence. */
67
+ advertisedCommandParameters?: Record<string, readonly UnityCliCommandParameter[]>;
68
+ /** Complete, bounded, unambiguous descriptors eligible for exact capability gates. */
69
+ verifiedCommandParameters?: Record<string, readonly UnityCliCommandParameter[]>;
70
+ /** True only when the exact live Pipeline descriptor advertises raw argv support. */
71
+ pipelineSupportsExecArgv?: boolean;
59
72
  commandDiscoveryAttempted: boolean;
60
73
  commandDiscoverySucceeded: boolean;
61
74
  latestPipelineVersion?: string;
@@ -68,7 +81,7 @@ export type UnityCliProjectCapabilities = {
68
81
  export type UnityCliExecResult = {
69
82
  stdout: string;
70
83
  stderr: string;
71
- error?: Error & { code?: string | number; signal?: string | null };
84
+ error?: Error & { code?: string | number | null; signal?: string | null; killed?: boolean };
72
85
  };
73
86
 
74
87
  /** Injectable seam for deterministic capability and planning-dispatch tests. */
@@ -196,9 +209,9 @@ const execFileCollect: UnityCliExecutor = (command, args, options = {}) => {
196
209
  return new Promise((resolve) => {
197
210
  execFile(command, args, { timeout: options.timeout ?? UNITY_CLI_VERSION_TIMEOUT_MS, signal: options.signal, windowsHide: true }, (error, stdout, stderr) => {
198
211
  resolve({
199
- stdout: typeof stdout === "string" ? stdout : stdout.toString(),
200
- stderr: typeof stderr === "string" ? stderr : stderr.toString(),
201
- error: error as UnityCliExecResult["error"],
212
+ stdout,
213
+ stderr,
214
+ error: error ?? undefined,
202
215
  });
203
216
  });
204
217
  });
@@ -346,10 +359,16 @@ export function parseUnityCliPipelineListOutput(output: string, projectRoot: str
346
359
  type UnityCliCommandCatalog = {
347
360
  valid: boolean;
348
361
  commands: string[];
362
+ parametersByCommand: Record<string, readonly UnityCliCommandParameter[]>;
363
+ verifiedParametersByCommand: Record<string, readonly UnityCliCommandParameter[]>;
349
364
  total: number;
350
365
  truncated: boolean;
351
366
  };
352
367
 
368
+ function isBoundedDescriptorString(value: unknown): value is string {
369
+ return typeof value === "string" && value.length > 0 && value.length <= 120 && !/[\u0000-\u001f\u007f]/.test(value);
370
+ }
371
+
353
372
  function parseUnityCliCommandCatalog(output: string): UnityCliCommandCatalog {
354
373
  const payload = parseJsonObject(output);
355
374
  const data = getRecord(payload?.data);
@@ -366,21 +385,53 @@ function parseUnityCliCommandCatalog(output: string): UnityCliCommandCatalog {
366
385
  candidates.push(...value);
367
386
  }
368
387
  }
369
- const names = candidates.flatMap((entry): string[] => {
370
- const rawName = typeof entry === "string"
371
- ? entry
372
- : optionalString(getRecord(entry)?.name, getRecord(entry)?.command, getRecord(entry)?.id);
373
- if (!rawName) return [];
388
+
389
+ const parametersByCommand: Record<string, readonly UnityCliCommandParameter[]> = {};
390
+ const verifiedCandidates = new Map<string, Array<readonly UnityCliCommandParameter[] | undefined>>();
391
+ const names: string[] = [];
392
+ for (const entry of candidates) {
393
+ const record = getRecord(entry);
394
+ const rawName = typeof entry === "string" ? entry : optionalString(record?.name, record?.command, record?.id);
395
+ if (!isBoundedDescriptorString(rawName)) continue;
374
396
  const name = rawName.trim();
375
- if (!name || name.length > 120 || /[\u0000-\u001f\u007f]/.test(name)) return [];
376
- return [name];
377
- });
397
+ if (!isBoundedDescriptorString(name)) continue;
398
+ names.push(name);
399
+
400
+ // Preserve a best-effort descriptor for status display, but retain authoritative
401
+ // evidence only when every declared parameter is valid and the array was not cut.
402
+ const rawParameters = record?.parameters;
403
+ let parsed: UnityCliCommandParameter[] | undefined;
404
+ if (Array.isArray(rawParameters) && rawParameters.length <= 32) {
405
+ parsed = [];
406
+ for (const item of rawParameters) {
407
+ const parameter = getRecord(item);
408
+ const parameterName = parameter?.name;
409
+ const type = parameter?.type;
410
+ if (!isBoundedDescriptorString(parameterName) || !isBoundedDescriptorString(type) || typeof parameter?.required !== "boolean") {
411
+ parsed = undefined;
412
+ break;
413
+ }
414
+ parsed.push({ name: parameterName, type, required: parameter.required, ...(Object.prototype.hasOwnProperty.call(parameter, "defaultValue") ? { defaultValue: parameter.defaultValue } : {}) });
415
+ }
416
+ }
417
+ if (!(name in parametersByCommand) && parsed) parametersByCommand[name] = parsed;
418
+ const entries = verifiedCandidates.get(name) ?? [];
419
+ entries.push(parsed);
420
+ verifiedCandidates.set(name, entries);
421
+ }
422
+
378
423
  const unique = [...new Set(names)].sort((left, right) => left.localeCompare(right));
424
+ const commands = unique.slice(0, 256);
425
+ const verifiedParametersByCommand = Object.fromEntries([...verifiedCandidates].flatMap(([name, descriptors]) =>
426
+ descriptors.length === 1 && descriptors[0] ? [[name, descriptors[0]]] : [],
427
+ ));
379
428
  return {
380
429
  valid: Boolean(payload?.success === true && valid),
381
- commands: unique.slice(0, 256),
430
+ commands,
431
+ parametersByCommand: Object.fromEntries(commands.flatMap(name => parametersByCommand[name] ? [[name, parametersByCommand[name]]] : [])),
432
+ verifiedParametersByCommand,
382
433
  total: unique.length,
383
- truncated: unique.length > 256 || names.length < candidates.length,
434
+ truncated: unique.length > 256,
384
435
  };
385
436
  }
386
437
 
@@ -428,8 +479,16 @@ export async function readDeclaredUnityPipelineVersion(projectRoot: string): Pro
428
479
  return optionalString(manifestDependencies?.["com.unity.pipeline"]);
429
480
  }
430
481
 
482
+ /** Read only the public capability names; the descriptor's authentication token is never retained or surfaced. */
483
+ async function readPipelineDescriptorCapabilities(projectRoot: string): Promise<string[] | undefined> {
484
+ const descriptor = await readJsonFile(join(projectRoot, "Library", "Pipeline", ".unity-pipeline-port"));
485
+ if (!descriptor) return undefined;
486
+ const values = Array.isArray(descriptor.capabilities) ? descriptor.capabilities : [];
487
+ return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0 && value.length <= 120 && !/[\u0000-\u001f\u007f]/.test(value)))];
488
+ }
489
+
431
490
  export function isUnityCliTimeout(result: Pick<UnityCliExecResult, "error">): boolean {
432
- const error = result.error as (NodeJS.ErrnoException & { killed?: boolean }) | undefined;
491
+ const error = result.error;
433
492
  return error?.code === "ETIMEDOUT" || error?.killed === true || error?.signal === "SIGTERM";
434
493
  }
435
494
 
@@ -511,6 +570,8 @@ export async function inspectUnityCliProjectCapabilities(
511
570
  result.pipelineDiscovery = "available";
512
571
  const pipeline = parseUnityCliPipelineListOutput(pipelineResult.stdout, projectRoot);
513
572
  result.matchingInstances = pipeline.instances;
573
+ const descriptorCapabilities = await readPipelineDescriptorCapabilities(projectRoot);
574
+ result.pipelineSupportsExecArgv = descriptorCapabilities?.includes("exec.argv") === true;
514
575
  result.latestPipelineVersion = pipeline.latestVersion;
515
576
  if (pipeline.instances.length === 0) {
516
577
  result.pipelineDiscovery = "absent";
@@ -522,7 +583,9 @@ export async function inspectUnityCliProjectCapabilities(
522
583
  }
523
584
 
524
585
  result.commandDiscoveryAttempted = true;
525
- const listResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "list", "--project-path", projectRoot], { timeout: discoveryTimeout, signal: options.signal });
586
+ // `unity list` normalizes parameter types/defaults, while `unity command` with no command
587
+ // returns the live Pipeline catalog descriptor needed for exact capability-gated forwarding.
588
+ const listResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot, "--detail", "full"], { timeout: discoveryTimeout, signal: options.signal });
526
589
  const catalog = parseUnityCliCommandCatalog(listResult.stdout);
527
590
  const listPayload = parseJsonObject(listResult.stdout);
528
591
  const commandDiagnostics = cliEnvelopeDiagnostics(listPayload);
@@ -533,12 +596,16 @@ export async function inspectUnityCliProjectCapabilities(
533
596
  // Commands in a warning-bearing catalog are informational only, not advertised
534
597
  // capability evidence. Keep descriptors for status visibility without enabling dispatch.
535
598
  result.advertisedCommands = catalog.commands;
599
+ result.advertisedCommandParameters = catalog.parametersByCommand;
600
+ result.verifiedCommandParameters = catalog.verifiedParametersByCommand;
536
601
  result.advertisedCommandCount = catalog.total;
537
602
  result.advertisedCommandsTruncated = catalog.truncated;
538
603
  return result;
539
604
  }
540
605
  result.commandDiscovery = "available";
541
606
  result.advertisedCommands = catalog.commands;
607
+ result.advertisedCommandParameters = catalog.parametersByCommand;
608
+ result.verifiedCommandParameters = catalog.verifiedParametersByCommand;
542
609
  result.advertisedCommandCount = catalog.total;
543
610
  result.advertisedCommandsTruncated = catalog.truncated;
544
611
  result.commandDiscoverySucceeded = true;
@@ -569,6 +636,8 @@ export type UnityPlanningInspectionRequest = {
569
636
  args?: string[];
570
637
  /** A bounded C# snippet for advertised eval. Pipeline compiles it with Roslyn on the Editor main thread. */
571
638
  evalSnippet?: string;
639
+ /** Verified Pipeline eval dispatcher wait in milliseconds; distinct from the CLI/host wait. */
640
+ handlerTimeoutMilliseconds?: number;
572
641
  };
573
642
 
574
643
  export type UnityPlanningInspectionResult =
@@ -624,6 +693,20 @@ function runScriptCommandFailure(output: string): "malformed" | "failure" | unde
624
693
  return undefined;
625
694
  }
626
695
 
696
+ function hasVerifiedEvalTimeoutContract(capabilities: UnityCliProjectCapabilities): boolean {
697
+ const parameters = capabilities.verifiedCommandParameters?.eval;
698
+ return capabilities.pipelineSupportsExecArgv === true
699
+ && Array.isArray(parameters)
700
+ && parameters.length === 2
701
+ && parameters[0]?.name === "code"
702
+ && parameters[0]?.type === "String"
703
+ && parameters[0]?.required === true
704
+ && parameters[1]?.name === "timeout"
705
+ && parameters[1]?.type === "Int32"
706
+ && parameters[1]?.required === false
707
+ && parameters[1]?.defaultValue === 5000;
708
+ }
709
+
627
710
  function connectedCommandFailure(output: string, isEval: boolean): "malformed" | "failure" | undefined {
628
711
  const envelope = parseJsonObject(output);
629
712
  if (!envelope) return "malformed";
@@ -691,6 +774,12 @@ export async function dispatchUnityPlanningInspection(
691
774
  if (request.args?.length || !snippet || snippet.length > UNITY_PIPELINE_EVAL_MAX_CHARS || /[\u0000]/.test(snippet)) {
692
775
  return { outcome: "rejected", code: "planning_eval_invalid", message: "Eval requires one non-empty bounded C# snippet and no separate arguments." };
693
776
  }
777
+ if (request.handlerTimeoutMilliseconds !== undefined && (!Number.isInteger(request.handlerTimeoutMilliseconds) || request.handlerTimeoutMilliseconds < 1 || request.handlerTimeoutMilliseconds > 86_400_000)) {
778
+ return { outcome: "rejected", code: "planning_eval_invalid", message: "Eval handler timeout must be an integer from 1 to 86400000 milliseconds." };
779
+ }
780
+ if (request.handlerTimeoutMilliseconds !== undefined && !hasVerifiedEvalTimeoutContract(initial)) {
781
+ return { outcome: "rejected", code: "planning_eval_timeout_unavailable", message: "The exact Pipeline copy does not establish raw argv support and the documented eval timeout signature; eval was not dispatched." };
782
+ }
694
783
  } else if (!UNITY_PLANNING_READ_COMMANDS.includes(request.command as typeof UNITY_PLANNING_READ_COMMANDS[number]) || (request.evalSnippet?.trim() ?? "") !== "") {
695
784
  return { outcome: "rejected", code: "planning_command_invalid", message: "Only a package-owned purpose-built inspection command may be selected here." };
696
785
  }
@@ -706,13 +795,16 @@ export async function dispatchUnityPlanningInspection(
706
795
  if (!refreshed.advertisedCommands.includes(request.command)) {
707
796
  return { outcome: "rejected", code: "planning_command_unadvertised", message: "The refreshed exact Pipeline copy did not advertise the requested command." };
708
797
  }
798
+ if (isEval && request.handlerTimeoutMilliseconds !== undefined && !hasVerifiedEvalTimeoutContract(refreshed)) {
799
+ return { outcome: "rejected", code: "planning_eval_timeout_unavailable", message: "The exact Pipeline eval timeout capability changed before dispatch; eval was not dispatched." };
800
+ }
709
801
 
710
802
  const command = resolveUnityCliCommand({ cliCommand: options.cliCommand });
711
803
  const args = [
712
804
  "--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot,
713
805
  "--timeout", String(Math.max(1, Math.ceil((options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS) / 1000))),
714
806
  request.command,
715
- ...(isEval ? [request.evalSnippet!.trim()] : request.args ?? []),
807
+ ...(isEval ? [request.evalSnippet!.trim(), ...(request.handlerTimeoutMilliseconds === undefined ? [] : [String(request.handlerTimeoutMilliseconds)])] : request.args ?? []),
716
808
  ];
717
809
  const execution = await options.execute(command, args, { timeout: options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS, signal: options.signal });
718
810
  const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n");
@@ -14,7 +14,7 @@ export const UNITY_EXACT_GENERATED_ROOT_BYPASSED_CODE = "unity_exact_generated_r
14
14
  export const UNITY_GENERATED_DIRECTORIES = Object.freeze(["Library", "Temp", "Logs", "obj", "Build", "Builds", "UserSettings", ".vs"] as const);
15
15
 
16
16
  export function createUnityFileDiscoveryFilterV1(): FileDiscoveryFilterV1 {
17
- return Object.freeze({
17
+ return Object.freeze<FileDiscoveryFilterV1>({
18
18
  contractVersion: 1,
19
19
  id: UNITY_FILE_DISCOVERY_FILTER_ID_V1,
20
20
  kind: "file-discovery-filter",
@@ -1,5 +1,6 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import { projectPathsMatch } from "./unity-core";
3
+ import { hasConsistentUnityTestCounts } from "./unity-artifact-inspection";
3
4
  import { redactUnityPlanningOutput, resolveUnityCliCommand, summarizeUnityCliText, unityCapabilityDiagnosticSuffix, type UnityCliExecResult, type UnityCliExecutor, type UnityCliProjectCapabilities } from "./unity-cli";
4
5
 
5
6
  /** Public limits are deliberately small enough that connected work cannot create an unbounded agent wait loop. */
@@ -37,6 +38,11 @@ export type UnityPipelineOperationDetails = {
37
38
  export type UnityPipelineTestRecord = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string };
38
39
  /** testRecords are terminal evidence for the caller's durable artifact only; do not expose them in tool details. */
39
40
  export type UnityPipelineOperationResult = { text: string; details: UnityPipelineOperationDetails; testRecords?: UnityPipelineTestRecord[] };
41
+ export type UnityPipelineTerminalTestEvidence = { state: "completed" | "failed" | "cancelled"; outcome: "uncertain" | "tests_failed" | "run_error" | "cancelled"; reason: string; elapsedSeconds: number; selection: { platform: "EditMode" | "PlayMode"; filter?: string }; correlation: Record<string, string>; observations: string[]; testRecords: UnityPipelineTestRecord[]; warnings: string[] };
42
+ /** Terminal Pipeline evidence can be durable and inspectable without being passing evidence. */
43
+ export class UnityPipelineTerminalTestEvidenceError extends Error {
44
+ constructor(readonly evidence: UnityPipelineTerminalTestEvidence) { super(evidence.reason); this.name = "UnityPipelineTerminalTestEvidenceError"; }
45
+ }
40
46
 
41
47
  type RecordValue = Record<string, unknown>;
42
48
  type ParsedEnvelope = { result: RecordValue; outerSuccess: boolean; malformed?: string };
@@ -46,6 +52,8 @@ type NormalizedTest = {
46
52
  total?: number; passed?: number; failed?: number; inconclusive?: number; failures: string[];
47
53
  correlation: Record<string, string>;
48
54
  testRecords?: UnityPipelineTestRecord[];
55
+ testFailureEstablished: boolean;
56
+ runnerError: boolean;
49
57
  };
50
58
 
51
59
  type PipelineDependencies = {
@@ -199,7 +207,9 @@ export function normalizeUnityPipelineCompile(output: string): NormalizedCompile
199
207
  const parsed = parseUnityPipelineEnvelope(output);
200
208
  if (parsed.malformed) return { state: "uncertain", diagnostics: [], failed: false };
201
209
  const compilerDiagnostics = diagnostics(parsed.result);
202
- const failed = !parsed.outerSuccess || hasSemanticFailure(parsed.result) || compilerDiagnostics.length > 0;
210
+ let compilationFailed = false;
211
+ walk(parsed.result, item => { if (field(item, "compilationfailed") === true) compilationFailed = true; });
212
+ const failed = !parsed.outerSuccess || hasSemanticFailure(parsed.result) || compilationFailed || compilerDiagnostics.length > 0;
203
213
  const raw = statusOf(parsed.result);
204
214
  const state = failed || raw === "failed" || raw === "error" ? "failed" : raw === "up_to_date" || raw === "uptodate" ? "up_to_date"
205
215
  : raw === "triggered" ? "triggered" : raw === "compiling" || raw === "running" ? "compiling"
@@ -229,6 +239,9 @@ function testRecords(result: RecordValue): UnityPipelineTestRecord[] {
229
239
  });
230
240
  return values.slice(0, 2_000);
231
241
  }
242
+ function isRecognizedFailedTestStatus(status: string): boolean {
243
+ return status.trim().toLowerCase() === "failed";
244
+ }
232
245
  function testFailures(result: RecordValue): string[] {
233
246
  const values: string[] = [];
234
247
  walk(result, item => {
@@ -238,7 +251,7 @@ function testFailures(result: RecordValue): string[] {
238
251
  for (const entry of entries.slice(0, 200)) {
239
252
  const test = record(entry); if (!test) continue;
240
253
  const outcome = string(field(test, "result", "status", "outcome"))?.toLowerCase();
241
- if (!outcome || /pass|success/.test(outcome)) continue;
254
+ if (!outcome || /^(?:passed|success)$/i.test(outcome)) continue;
242
255
  const name = string(field(test, "name", "fullname", "testname")) ?? "Unnamed test";
243
256
  const message = string(field(test, "message", "error", "failuremessage"));
244
257
  const stack = string(field(test, "stacktrace", "stack", "trace"));
@@ -268,19 +281,23 @@ function correlation(result: RecordValue): Record<string, string> {
268
281
  }
269
282
  export function normalizeUnityPipelineTest(output: string): NormalizedTest {
270
283
  const parsed = parseUnityPipelineEnvelope(output);
271
- if (parsed.malformed) return { state: "uncertain", failures: [], correlation: {} };
284
+ if (parsed.malformed) return { state: "uncertain", failures: [], correlation: {}, testFailureEstablished: false, runnerError: false };
272
285
  const sum = summary(parsed.result);
273
286
  const total = number(field(sum ?? parsed.result, "total"));
274
287
  const passed = number(field(sum ?? parsed.result, "passed", "pass"));
275
288
  const failedCount = number(field(sum ?? parsed.result, "failed", "fail"));
276
289
  const inconclusive = number(field(sum ?? parsed.result, "inconclusive", "skipped"));
277
290
  const raw = statusOf(parsed.result);
278
- const semanticFailed = !parsed.outerSuccess || hasSemanticFailure(parsed.result) || (failedCount ?? 0) > 0;
279
- const state = semanticFailed || raw === "failed" || raw === "error" ? "failed" : raw === "cancelled" || raw === "canceled" ? "cancelled"
291
+ const records = testRecords(parsed.result);
292
+ const testFailureEstablished = (Number.isSafeInteger(failedCount) && (failedCount ?? 0) > 0) || records.some(test => isRecognizedFailedTestStatus(test.status));
293
+ const runnerError = !parsed.outerSuccess || raw === "failed" || raw === "error" || hasSemanticFailure(parsed.result);
294
+ // A reported active state remains active even when it carries partial records/counts.
295
+ const state = raw === "cancelled" || raw === "canceled" ? "cancelled"
280
296
  : raw === "no_tests" || raw === "idle" || raw === "not_started" || raw === "not_running" ? "inactive"
281
297
  : raw === "running" ? "running" : raw === "starting" || raw === "queued" ? "starting"
282
- : raw === "completed" || raw === "complete" || raw === "success" ? "completed" : "uncertain";
283
- return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result), testRecords: testRecords(parsed.result) };
298
+ : raw === "completed" || raw === "complete" || raw === "success" ? testFailureEstablished || runnerError ? "failed" : "completed"
299
+ : testFailureEstablished || runnerError ? "failed" : "uncertain";
300
+ return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result), testRecords: records, testFailureEstablished, runnerError };
284
301
  }
285
302
 
286
303
  function editorStopSucceeded(output: string): boolean {
@@ -465,10 +482,18 @@ function checkCorrelation(expected: Record<string, string>, actual: Record<strin
465
482
  return Object.entries(expected).every(([key, value]) => !actual[key] || actual[key] === value);
466
483
  }
467
484
  function passingCounts(state: NormalizedTest): { total: number; passed: number; failed: number; inconclusive?: number } | undefined {
485
+ const summary = { total: state.total, passed: state.passed, failed: state.failed, inconclusive: state.inconclusive };
486
+ if (!hasConsistentUnityTestCounts(summary, state.testRecords ?? [])) return undefined;
487
+ if (state.testRecords?.some(test => !/^(?:passed|success)$/i.test(test.status))) return undefined;
468
488
  if (state.total === undefined || state.total <= 0 || state.passed === undefined || state.failed !== 0 || (state.inconclusive ?? 0) > 0) return undefined;
469
489
  if (state.passed + state.failed + (state.inconclusive ?? 0) !== state.total) return undefined;
470
490
  return { total: state.total, passed: state.passed, failed: state.failed, inconclusive: state.inconclusive };
471
491
  }
492
+ function terminalEvidence(state: NormalizedTest, reason: string, request: UnityPipelineTestRequest, elapsedSeconds: number, warnings: string[]): UnityPipelineTerminalTestEvidenceError {
493
+ const outcome = state.state === "cancelled" ? "cancelled" : state.testFailureEstablished ? "tests_failed" : state.runnerError ? "run_error" : "uncertain";
494
+ const observations = [`terminal state=${state.state}`, `terminal outcome=${outcome}`, ...["total", "passed", "failed", "inconclusive"].flatMap(key => state[key as keyof Pick<NormalizedTest, "total" | "passed" | "failed" | "inconclusive">] === undefined ? [] : [`reported ${key}=${String(state[key as keyof Pick<NormalizedTest, "total" | "passed" | "failed" | "inconclusive">])}`]), ...Object.entries(state.correlation).map(([key, value]) => `correlation ${key}=${value}`)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
495
+ return new UnityPipelineTerminalTestEvidenceError({ state: state.state as "completed" | "failed" | "cancelled", outcome, reason, elapsedSeconds, selection: { platform: request.testPlatform, ...(request.testFilter ?? request.testCategory ? { filter: request.testFilter ?? request.testCategory } : {}) }, correlation: state.correlation, observations, testRecords: state.testRecords ?? [], warnings });
496
+ }
472
497
  function elapsed(start: number, now: () => number): number { return Math.max(0, (now() - start) / 1000); }
473
498
  function timeoutMessage(operation: string): Error { return new Error(`Unity Pipeline ${operation} timed out; result is uncertain and may still be running. No cancellation, retry, or route switch was performed.`); }
474
499
  function ensureBeforeDeadline(deadline: number, now: () => number, operation: string): void {
@@ -540,14 +565,14 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
540
565
  const dispatchWarnings = pipelineEnvelopeWarnings(dispatched.stdout);
541
566
  let state = normalizeUnityPipelineTest(dispatched.stdout);
542
567
  if (state.state === "uncertain" || state.state === "inactive") throw new Error("Unity Pipeline test dispatch returned inactive, malformed, or uncertain evidence; test run may not have started.");
543
- if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
544
568
  const requestedCorrelation = { mode: request.testPlatform, ...(request.testFilter ? { filter: request.testFilter } : {}) };
545
569
  if (!checkCorrelation(requestedCorrelation, state.correlation)) throw new Error("Unity Pipeline test dispatch reported a different mode or filter; operation state is uncertain.");
570
+ if (state.state === "failed" || state.state === "cancelled") throw terminalEvidence(state, `Unity ${request.testPlatform} tests ${state.state}: ${state.failures.join("; ") || state.state}.`, request, elapsed(start, now), dispatchWarnings);
546
571
  const expected = { ...requestedCorrelation, ...state.correlation };
547
572
  // Some Pipeline versions return a complete result directly from asynchronous dispatch.
548
573
  if (state.state === "completed") {
549
574
  const counts = passingCounts(state);
550
- if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
575
+ if (!counts) throw terminalEvidence(state, "Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).", request, elapsed(start, now), dispatchWarnings);
551
576
  return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}) }, testRecords: state.testRecords };
552
577
  }
553
578
  for (let poll = 0; now() < deadline; poll += 1) {
@@ -565,12 +590,12 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
565
590
  retainWarnings(dispatchWarnings, response.stdout);
566
591
  state = normalizeUnityPipelineTest(response.stdout);
567
592
  if (!checkCorrelation(expected, state.correlation)) throw new Error("Unity Pipeline test status was displaced by a different run; operation state is uncertain.");
568
- if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
593
+ if (state.state === "failed" || state.state === "cancelled") throw terminalEvidence(state, `Unity ${request.testPlatform} tests ${state.state}: ${state.failures.join("; ") || state.state}.`, request, elapsed(start, now), dispatchWarnings);
569
594
  if (state.state === "uncertain") throw new Error("Unity Pipeline test status is malformed or uncertain; operation may still be running.");
570
595
  if (state.state === "inactive") throw new Error("Unity Pipeline test status became inactive before a terminal result; operation state is uncertain.");
571
596
  if (state.state !== "completed") continue;
572
597
  const counts = passingCounts(state);
573
- if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
598
+ if (!counts) throw terminalEvidence(state, "Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).", request, elapsed(start, now), dispatchWarnings);
574
599
  return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}) }, testRecords: state.testRecords };
575
600
  }
576
601
  throw timeoutMessage("tests");
@@ -1,3 +1,4 @@
1
+ import type { Dirent } from "node:fs";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import { parseUnityVersionText, resolveAbsolutePath } from "./unity-core";
@@ -135,7 +136,7 @@ export async function discoverUnityProjects(
135
136
  continue;
136
137
  }
137
138
 
138
- let entries: fs.Dirent[] = [];
139
+ let entries: Dirent[] = [];
139
140
  try {
140
141
  entries = await fs.readdir(next.dir, { withFileTypes: true });
141
142
  } catch {