@aefree/pi-unity 0.11.0 → 0.12.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-unity",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.ts"
@@ -18,18 +18,18 @@
18
18
  ]
19
19
  },
20
20
  "scripts": {
21
- "test": "tsx tests/unity-core.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/unity-package-validation.test.ts",
21
+ "test": "tsx tests/unity-core.test.ts && tsx tests/unity-launch.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/unity-package-validation.test.ts",
22
22
  "eval:guidance-skill": "tsx evals/auditing-unity-agent-guidance/run-eval.ts"
23
23
  },
24
24
  "dependencies": {
25
25
  "typebox": "1.3.8"
26
26
  },
27
27
  "peerDependencies": {
28
+ "@aefree/pi-file-discovery": "^0.1.0",
29
+ "@aefree/pi-project-artifacts": "^0.1.0",
28
30
  "@earendil-works/pi-ai": "*",
29
31
  "@earendil-works/pi-coding-agent": "*",
30
- "@earendil-works/pi-tui": "*",
31
- "@aefree/pi-project-artifacts": "^0.1.0",
32
- "@aefree/pi-file-discovery": "^0.1.0"
32
+ "@earendil-works/pi-tui": "*"
33
33
  },
34
34
  "engines": {
35
35
  "node": ">=22.19.0"
@@ -48,11 +48,11 @@
48
48
  },
49
49
  "homepage": "https://github.com/aefreedman/pi-unity#readme",
50
50
  "devDependencies": {
51
- "@earendil-works/pi-ai": "0.83.0",
52
- "@earendil-works/pi-coding-agent": "0.83.0",
53
- "@earendil-works/pi-tui": "0.83.0",
54
- "@aefree/pi-project-artifacts": "^0.1.0",
55
51
  "@aefree/pi-file-discovery": "^0.1.0",
52
+ "@aefree/pi-project-artifacts": "^0.1.0",
53
+ "@earendil-works/pi-ai": "0.85.1",
54
+ "@earendil-works/pi-coding-agent": "0.85.1",
55
+ "@earendil-works/pi-tui": "0.85.1",
56
56
  "tsx": "^4.23.5"
57
57
  },
58
58
  "peerDependenciesMeta": {
@@ -50,9 +50,9 @@ Use the `pi-unity` tools first instead of forming raw Unity CLI commands on the
50
50
  `unity_launch_batchmode` remains the default for custom agent-run headless Unity work because it already:
51
51
  - resolves the Unity project from a direct project root, a coordination root, or another nearby folder
52
52
  - reads `ProjectSettings/ProjectVersion.txt`
53
- - prefers the installed `unity run` CLI when available, falling back to OS-aware direct editor launch
53
+ - treats installed `unity run` as authoritative when available: it receives the project path without `--editor-version` and reads the declared version itself, falling back only when Unity CLI is unavailable
54
54
  - uses OS-aware standard install probing
55
- - supports explicit `UNITY_EDITOR_PATH` / `unityEditorPath` overrides
55
+ - resolves only exact project-version standard installation candidates when direct Editor fallback is required
56
56
  - strips direct-Editor flags managed by `unity run` (`-batchmode`, `-projectPath`, `-quit`) before forwarding args in Unity CLI mode
57
57
  - supports `launcher: "editor-executable"` when Unity CLI argument forwarding differs from direct Editor executable behavior
58
58
  - checks Unity CLI status and running Unity processes before launch
@@ -68,15 +68,15 @@ If a launch is blocked by a native Unity lockfile, call `unity_project_status` b
68
68
 
69
69
  ### 2. Get the Project's Unity Version
70
70
 
71
- If you are in direct CLI fallback mode, read `ProjectSettings/ProjectVersion.txt` in the Unity project root:
71
+ Before direct Editor fallback, read `ProjectSettings/ProjectVersion.txt` in the Unity project root:
72
72
 
73
73
  ```
74
74
  m_EditorVersion: ####.#.#f#
75
75
  ```
76
76
 
77
- ### 3. Find the Unity Executable
77
+ ### 3. Resolve the exact-version Unity Editor
78
78
 
79
- If you are in direct CLI fallback mode, check these locations based on platform and version:
79
+ For direct Editor fallback, use only the standard locations constructed from the version declared in `ProjectVersion.txt`:
80
80
 
81
81
  **Windows:**
82
82
  ```
@@ -89,10 +89,9 @@ C:\UnityInstalls\<version>\Editor\Unity.exe
89
89
  ```
90
90
  /Applications/Unity/Hub/Editor/<version>/Unity.app/Contents/MacOS/Unity
91
91
  /Applications/Unity/<version>/Unity.app/Contents/MacOS/Unity
92
- /Applications/Unity*
93
92
  ```
94
93
 
95
- If not found, ask the user for their Unity install path.
94
+ If the exact declared version is not found, do not use an arbitrary Editor path or a nearby version. Ask the user to install the declared version through their normal Unity installation workflow, or use Unity CLI after it can resolve that exact version.
96
95
 
97
96
  ### 4. Choose the execution route, then run tests
98
97
 
@@ -124,12 +123,13 @@ After choosing the isolated route:
124
123
  - exclude graphics-required screenshot/visual-capture tests from no-graphics runs
125
124
  - prefer project test categories such as `RequiresGraphics` / `VisualCapture` when the project exposes them
126
125
 
127
- Direct CLI fallback templates:
126
+ Unity CLI project-authoritative template:
128
127
  ```
129
128
  unity test "<ProjectPath>" --mode <EditMode|PlayMode> --filter "<Full.Test.Name>" --output "<ResultsPath>" -- -logFile "<LogPath>"
130
- "<UnityEditorPath>" -batchmode -nographics -projectPath "<ProjectPath>" -runTests -testPlatform <EditMode|PlayMode> -testFilter "<Full.Test.Name>" -testResults "<ResultsPath>" -logFile "<LogPath>"
131
129
  ```
132
130
 
131
+ For direct Editor fallback, use `unity_launch_batchmode` with its project path and test parameters; it resolves only the declared project's exact-version standard candidates.
132
+
133
133
  Fallback parameters:
134
134
  - `-testPlatform`: `EditMode` or `PlayMode`
135
135
  - `-testFilter`: Full test name (e.g., `MyNamespace.MyTests.TestMethodName`)
@@ -41,12 +41,30 @@ Call `unity_run_tests` with:
41
41
 
42
42
  The tool treats `no_tests`, idle, and not-started statuses as safe inactivity, detects a pre-existing active connected test before dispatch, and stops rather than claiming or replacing active work. It captures returned mode/filter/run identity fields when available and stops as uncertain if status is clearly displaced by another run.
43
43
 
44
+ ### Two independent fixtures in an open Editor
45
+
46
+ Keep the Editor open. For an authorized request for two known non-overlapping fixtures, use the same explicit exact-copy `path` and platform, one selector per call. For example, replace `./SyntheticGame` with the selected project path and issue this `unity_run_tests` call:
47
+
48
+ ```json
49
+ {"path":"./SyntheticGame","testPlatform":"EditMode","execution":"connected","testFilters":["Synthetic.InventoryFixture"]}
50
+ ```
51
+
52
+ Await the completed tool result and inspect its outcome and exact normalized artifact path. Only after a passing result, issue the second call (not in parallel or pre-queued in the same turn):
53
+
54
+ ```json
55
+ {"path":"./SyntheticGame","testPlatform":"EditMode","execution":"connected","testFilters":["Synthetic.DialogueFixture"]}
56
+ ```
57
+
58
+ Stop the remaining sequence on failure, timeout, cancellation, malformed evidence, displaced identity, or any uncertainty; report the first result and the remaining fixture as not run. Do not retry, close the Editor, switch to isolated execution, or broaden to a parent suite/category or empty selectors. Empty/omitted selectors select all tests. Keep each result's artifact separately; do not treat one fixture's evidence as covering the other.
59
+
60
+ For a single category use `testCategories: ["SyntheticCategory"]` and omit `testFilters`. Never combine the two families or join selectors with semicolons. Multiple-selector rejection means no test was dispatched: use the serial recipe only for independent, non-overlapping selections without other isolated-only requirements. If overlap or intended filter/category intersection semantics are unclear, stop for clarification rather than split, duplicate, or broaden tests. Required XML, retries, coverage, or other isolated-only options still need a deliberate isolation decision, not argument stripping.
61
+
44
62
  ## Bounded raw CLI troubleshooting only
45
63
 
46
64
  Normally call the typed tools, not raw CLI commands. If a typed tool is unavailable in an older installed package and a user specifically authorizes troubleshooting, first use `unity_project_status` and require advertised `recompile_status` or `run_tests` and `test_status`. Use the documented asynchronous form with `--async_tests true`, one fixed deadline, and bounded backoff; parse object and stringified nested JSON. `Total: 0` with `result: running` is a valid nonterminal initiating response. Passing evidence reports successful completion, a known positive executed-test count, and zero failures; nested `success:false`, changed exact-copy identity, or polling timeout is non-passing uncertainty. Do not silently fall back to batchmode after uncertain connected dispatch. Connected work does not guarantee NUnit XML.
47
65
 
48
66
  ## When not to use connected tools
49
67
 
50
- Use `unity_run_tests` with `execution: "isolated"` for a closed project, intentional isolation/CI, multiple selectors, retries, sharding, coverage, or required NUnit/JUnit evidence. 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.
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.
51
69
 
52
70
  Use the typed compile/test tools when their polling and terminal evidence fit the task. 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.
@@ -0,0 +1,59 @@
1
+ import { isAbsolute } from "node:path";
2
+ import { determineUnityTestOutcome, type NormalizedUnityTestResult } from "./unity-tests";
3
+
4
+ const outcomes = ["passed", "passed_with_flakes", "tests_failed", "empty_selection", "run_error", "timed_out", "cancelled", "uncertain"];
5
+ const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
6
+ const nonnegative = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
7
+ const count = (value: unknown): value is number => nonnegative(value) && Number.isSafeInteger(value);
8
+ const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === "string" && !!item.trim() && !/[\0\r\n;]/.test(item));
9
+ const relativeId = (value: unknown): value is string => typeof value === "string" && !!value.trim()
10
+ && !isAbsolute(value) && !/^(?:[A-Za-z]:|[\\/])/.test(value) && !value.split(/[\\/]/).includes("..") && !/\0/.test(value);
11
+
12
+ /** Read the durable schema, not a transport response. Missing optional counts remain unknown.
13
+ * Test records may be bounded or absent: never require tests.length === summary.total.
14
+ */
15
+ export function validateNormalizedUnityTestArtifact(value: unknown): NormalizedUnityTestResult {
16
+ const invalid = (reason: string): never => { throw new Error(`Invalid normalized Unity test artifact: ${reason}.`); };
17
+ if (!record(value) || value.schemaVersion !== 1) invalid("expected schemaVersion 1 object");
18
+ const result = value as Record<string, unknown>;
19
+ if (!["pipeline", "unity-cli", "editor-executable"].includes(result.source as string)) invalid("unsupported source");
20
+ if (!["EditMode", "PlayMode"].includes(result.platform as string)) invalid("unsupported platform");
21
+ if (!outcomes.includes(result.outcome as string)) invalid("unsupported outcome");
22
+ if (!record(result.selection) || !strings(result.selection.testFilters) || !strings(result.selection.testCategories)) invalid("selection must contain testFilters and testCategories arrays");
23
+ if (!record(result.summary)) invalid("summary must be an object");
24
+ const summary = result.summary as Record<string, unknown>;
25
+ for (const key of ["total", "passed", "failed", "skipped", "inconclusive"]) {
26
+ if (summary[key] !== undefined && !count(summary[key])) invalid(`summary.${key} must be a non-negative integer`);
27
+ }
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
+ }
32
+ if (!Array.isArray(result.tests)) invalid("tests must be an array");
33
+ const tests = result.tests as unknown[];
34
+ for (const test of tests) {
35
+ if (!record(test) || typeof test.name !== "string" || !test.name.trim() || typeof test.status !== "string" || !test.status.trim()) invalid("test records require name and status");
36
+ const item = test as Record<string, unknown>;
37
+ for (const key of ["message", "stackTrace"]) if (item[key] !== undefined && typeof item[key] !== "string") invalid(`test ${key} must be a string`);
38
+ if (item.durationSeconds !== undefined && !nonnegative(item.durationSeconds)) invalid("test durationSeconds must be non-negative");
39
+ if (item.attempts !== undefined && (!count(item.attempts) || item.attempts < 1)) invalid("test attempts must be positive");
40
+ }
41
+ if (count(summary.total) && tests.length > summary.total) invalid("test records exceed total");
42
+ 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
+ if (result.projectRelativeId !== undefined && !relativeId(result.projectRelativeId)) invalid("projectRelativeId must be project-relative");
48
+ if (result.backendArtifacts !== undefined && (!record(result.backendArtifacts) || !Object.values(result.backendArtifacts).every(relativeId))) invalid("backendArtifacts must contain project-relative paths");
49
+ 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
+ if (typed.startedAt && typed.completedAt && Date.parse(typed.completedAt) < Date.parse(typed.startedAt)) invalid("completion precedes start");
51
+ if (result.durationSeconds !== undefined && !nonnegative(result.durationSeconds)) invalid("durationSeconds must be non-negative");
52
+ if (result.flakyTests !== undefined && (!Array.isArray(result.flakyTests) || !result.flakyTests.every(item => record(item) && typeof item.name === "string" && !!item.name.trim() && count(item.attempts) && item.attempts > 0))) invalid("invalid flakyTests");
53
+ if (result.outcome === "passed" || result.outcome === "passed_with_flakes") {
54
+ if (determineUnityTestOutcome(typed.summary) !== "passed" || typed.tests.some(test => !["passed", "success"].includes(test.status.toLowerCase()))) invalid("passing outcome lacks consistent positive passing counts/records");
55
+ if (result.outcome === "passed_with_flakes" && !typed.flakyTests?.length) invalid("passed_with_flakes requires flakyTests evidence");
56
+ }
57
+ if (result.outcome === "empty_selection" && ((typed.summary.total ?? 0) > 0 || tests.length > 0)) invalid("empty selection conflicts with executed tests");
58
+ return typed;
59
+ }
@@ -29,6 +29,8 @@ export type UnityParsedTestResults = {
29
29
  failedTests: UnityFailedTest[];
30
30
  /** Complete bounded per-test evidence for normalized artifacts, never routine tool output. */
31
31
  tests: UnityParsedTestCase[];
32
+ /** Observed XML record lower bounds, counted before record output is truncated. */
33
+ testRecordCounts?: { total: number; passed: number; failed: number; skipped: number; inconclusive: number; other: number };
32
34
  };
33
35
 
34
36
  export type UnityBatchmodeArtifacts = {
@@ -82,10 +84,10 @@ function decodeXmlText(value: string | undefined): string | undefined {
82
84
 
83
85
  function parseAttributes(tagSource: string): Record<string, string> {
84
86
  const attributes: Record<string, string> = {};
85
- const attributeRegex = /(\w[\w:-]*)\s*=\s*"([^"]*)"/g;
87
+ const attributeRegex = /(\w[\w:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
86
88
  for (const match of tagSource.matchAll(attributeRegex)) {
87
89
  const key = match[1];
88
- const value = match[2] ?? "";
90
+ const value = match[2] ?? match[3] ?? "";
89
91
  attributes[key] = value;
90
92
  }
91
93
  return attributes;
@@ -102,6 +104,12 @@ function parseOptionalNumber(value: string | undefined): number | undefined {
102
104
  return Number.isFinite(parsed) ? parsed : undefined;
103
105
  }
104
106
 
107
+ function parseTestCount(value: string | undefined): number | undefined {
108
+ // Only omission is unknown. Retain invalid supplied counters as NaN so the
109
+ // inspection validator rejects them instead of silently treating them as absent.
110
+ return value === undefined ? undefined : value.trim() ? Number(value) : Number.NaN;
111
+ }
112
+
105
113
  export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults | null {
106
114
  const testRunMatch = xml.match(/<test-run\b([^>]*)>/i);
107
115
  const testRunCloseIndex = xml.search(/<\/test-run\s*>/i);
@@ -112,33 +120,43 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
112
120
  const rootAttributes = parseAttributes(testRunMatch[1] ?? "");
113
121
  const failedTests: UnityFailedTest[] = [];
114
122
  const tests: UnityParsedTestCase[] = [];
123
+ const testRecordCounts = { total: 0, passed: 0, failed: 0, skipped: 0, inconclusive: 0, other: 0 };
115
124
 
116
- const testCaseRegex = /<test-case\b([^>]*)>([\s\S]*?)<\/test-case>/gi;
125
+ // Match the self-closing alternative first so it cannot consume the body of
126
+ // the next paired record. Both forms carry authoritative failure evidence.
127
+ const testCaseRegex = /<test-case\b([^>]*?)(?:\/\s*>|>([\s\S]*?)<\/test-case\s*>)/gi;
117
128
  for (const match of xml.matchAll(testCaseRegex)) {
118
129
  const attributes = parseAttributes(match[1] ?? "");
119
130
  const body = match[2] ?? "";
120
131
  const result = String(attributes.result ?? attributes.label ?? "").toLowerCase();
121
132
  const success = String(attributes.success ?? "").toLowerCase();
122
133
  const isFailure = result === "failed" || success === "false";
134
+ const status = isFailure ? "Failed" : attributes.result ?? attributes.label ?? "Unknown";
135
+ const statusKey = isFailure ? "failed" : result === "passed" || result === "success" ? "passed" : result === "skipped" ? "skipped" : result === "inconclusive" ? "inconclusive" : "other";
136
+ testRecordCounts.total++;
137
+ testRecordCounts[statusKey]++;
123
138
  const failureMessage = body.match(/<message[^>]*>([\s\S]*?)<\/message>/i);
124
139
  const stackTrace = body.match(/<stack-trace[^>]*>([\s\S]*?)<\/stack-trace>/i);
125
140
  const name = truncateEvidence(attributes.fullname ?? attributes.name ?? "(unknown test)", 1_000) ?? "(unknown test)";
126
- if (tests.length < 2_000) tests.push({ name, status: attributes.result ?? attributes.label ?? "Unknown", ...(parseOptionalNumber(attributes.duration) === undefined ? {} : { durationSeconds: parseOptionalNumber(attributes.duration) }), ...(truncateEvidence(decodeXmlText(failureMessage?.[1]), 4_000) ? { message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 4_000) } : {}), ...(truncateEvidence(decodeXmlText(stackTrace?.[1]), 8_000) ? { stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 8_000) } : {}) });
141
+ if (tests.length < 2_000) tests.push({ name, status, ...(parseOptionalNumber(attributes.duration) === undefined ? {} : { durationSeconds: parseOptionalNumber(attributes.duration) }), ...(truncateEvidence(decodeXmlText(failureMessage?.[1]), 4_000) ? { message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 4_000) } : {}), ...(truncateEvidence(decodeXmlText(stackTrace?.[1]), 8_000) ? { stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 8_000) } : {}) });
127
142
  if (!isFailure) continue;
128
143
  if (failedTests.length < 50) failedTests.push({ name, message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 1_000), stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 4_000) });
129
144
  }
130
145
 
131
- const skipped = parseOptionalNumber(rootAttributes.skipped) ?? parseOptionalNumber(rootAttributes.inconclusive);
146
+ // These are separate counters. Synthesizing skipped from inconclusive makes
147
+ // combined-count validation double-count one observed category.
148
+ const skipped = parseTestCount(rootAttributes.skipped);
132
149
 
133
150
  const parsed: UnityParsedTestResults = {
134
- total: parseOptionalNumber(rootAttributes.total) ?? parseOptionalNumber(rootAttributes.testcasecount),
135
- passed: parseOptionalNumber(rootAttributes.passed),
136
- failed: parseOptionalNumber(rootAttributes.failed),
151
+ total: parseTestCount(rootAttributes.total) ?? parseTestCount(rootAttributes.testcasecount),
152
+ passed: parseTestCount(rootAttributes.passed),
153
+ failed: parseTestCount(rootAttributes.failed),
137
154
  skipped,
138
- inconclusive: parseOptionalNumber(rootAttributes.inconclusive),
155
+ inconclusive: parseTestCount(rootAttributes.inconclusive),
139
156
  durationSeconds: parseOptionalNumber(rootAttributes.duration),
140
157
  failedTests,
141
158
  tests,
159
+ testRecordCounts,
142
160
  };
143
161
  if (parsed.total === undefined && parsed.passed === undefined && parsed.failed === undefined && parsed.failedTests.length === 0) {
144
162
  return null;
@@ -146,6 +164,23 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
146
164
  return parsed;
147
165
  }
148
166
 
167
+ /** Missing counters and omitted/bounded records are unknown, not zero executions.
168
+ * Supplied counters and observed record lower bounds must nevertheless agree.
169
+ * An optional linked summary can fill missing XML counters, not replace them.
170
+ */
171
+ export function hasConflictingUnityXmlTestEvidence(
172
+ results: UnityParsedTestResults,
173
+ linkedSummary?: Pick<UnityParsedTestResults, "total" | "passed" | "failed" | "skipped" | "inconclusive">,
174
+ ): boolean {
175
+ const keys = ["total", "passed", "failed", "skipped", "inconclusive"] as const;
176
+ const counts = Object.fromEntries(keys.map(key => [key, results[key] ?? linkedSummary?.[key]])) as Pick<UnityParsedTestResults, typeof keys[number]>;
177
+ if (keys.some(key => counts[key] !== undefined && (!Number.isSafeInteger(counts[key]) || counts[key]! < 0))) return true;
178
+ const observed = results.testRecordCounts;
179
+ if (keys.some(key => counts[key] !== undefined && (observed?.[key] ?? 0) > counts[key]!)) return true;
180
+ const accounted = keys.slice(1).reduce((sum, key) => sum + Math.max(counts[key] ?? 0, observed?.[key] ?? 0), 0);
181
+ return counts.total !== undefined && accounted > counts.total;
182
+ }
183
+
149
184
  function buildArtifactCandidates(cwd: string, projectRoot: string, rawPath: string): string[] {
150
185
  if (path.isAbsolute(rawPath)) {
151
186
  return [path.normalize(rawPath)];
package/src/unity-cli.ts CHANGED
@@ -13,8 +13,7 @@ export type UnityCliCommand = {
13
13
  };
14
14
 
15
15
  export type UnityCliLaunchOptions = {
16
- editorVersion?: string;
17
- editorPath?: string;
16
+ editorVersionOverride?: string;
18
17
  timeoutSeconds?: number;
19
18
  cliCommand?: string;
20
19
  useGraphics?: boolean;
@@ -84,11 +83,8 @@ function unityCliBaseArgs(): string[] {
84
83
  }
85
84
 
86
85
  function appendUnityCliEditorOptions(args: string[], options: UnityCliLaunchOptions): void {
87
- if (options.editorVersion?.trim()) {
88
- args.push("--editor-version", options.editorVersion.trim());
89
- }
90
- if (options.editorPath?.trim()) {
91
- args.push("--editor-path", options.editorPath.trim());
86
+ if (options.editorVersionOverride?.trim()) {
87
+ args.push("--editor-version", options.editorVersionOverride.trim());
92
88
  }
93
89
  }
94
90
 
@@ -651,11 +647,11 @@ export async function dispatchUnityPlanningInspection(
651
647
  ...(isEval ? [request.evalSnippet!.trim()] : request.args ?? []),
652
648
  ];
653
649
  const execution = await options.execute(command, args, { timeout: options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS, signal: options.signal });
650
+ const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n");
651
+ const output = summarizeUnityCliText(redactUnityPlanningOutput(raw), 4_000, 40);
654
652
  if (execution.error) {
655
- return { outcome: "rejected", code: isUnityCliTimeout(execution) ? "planning_command_timeout" : "planning_command_failed", message: "Connected command did not complete successfully; its effect may be uncertain." };
653
+ return { outcome: "rejected", code: isUnityCliTimeout(execution) ? "planning_command_timeout" : "planning_command_failed", message: `Connected command did not complete successfully; its effect may be uncertain.${output ? ` ${output}` : ""}` };
656
654
  }
657
- const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n");
658
- const output = redactUnityPlanningOutput(summarizeUnityCliText(raw, 4_000, 40));
659
655
  const reportedFailure = connectedCommandFailure(execution.stdout, isEval);
660
656
  if (reportedFailure) {
661
657
  return {
package/src/unity-core.ts CHANGED
@@ -27,14 +27,6 @@ export function parseUnityVersionText(contents: string): string | null {
27
27
  return match ? match[1] : null;
28
28
  }
29
29
 
30
- export function normalizeUnityEditorOverride(editorPath: string, platform: SupportedPlatform): string {
31
- const normalized = path.normalize(editorPath.trim());
32
- if (platform === "darwin" && normalized.toLowerCase().endsWith(".app")) {
33
- return path.join(normalized, "Contents", "MacOS", "Unity");
34
- }
35
- return normalized;
36
- }
37
-
38
30
  export function buildUnityEditorCandidates(
39
31
  version: string,
40
32
  platform: SupportedPlatform = process.platform,
@@ -112,8 +104,8 @@ export function normalizeForCommandSearch(value: string, platform: SupportedPlat
112
104
  return platform === "win32" ? normalized.toLowerCase() : normalized;
113
105
  }
114
106
 
115
- function realpathMatchesOnDarwin(candidatePath: string, projectRoot: string, platform: SupportedPlatform): boolean | null {
116
- if (platform !== "darwin" || process.platform !== "darwin") {
107
+ function realpathMatchesOnNativePlatform(candidatePath: string, projectRoot: string, platform: SupportedPlatform): boolean | null {
108
+ if ((platform !== "darwin" && platform !== "win32") || process.platform !== platform) {
117
109
  return null;
118
110
  }
119
111
 
@@ -121,7 +113,7 @@ function realpathMatchesOnDarwin(candidatePath: string, projectRoot: string, pla
121
113
  return realpathSync.native(candidatePath) === realpathSync.native(projectRoot);
122
114
  } catch {
123
115
  // Only use filesystem identity when both paths can be resolved. Falling back
124
- // to the case-sensitive textual comparison preserves case-sensitive APFS.
116
+ // to the platform-specific textual comparison preserves existing behavior.
125
117
  return null;
126
118
  }
127
119
  }
@@ -138,7 +130,7 @@ export function projectPathsMatch(candidatePath: string, projectRoot: string, pl
138
130
  return false;
139
131
  }
140
132
 
141
- const realpathMatch = realpathMatchesOnDarwin(trimmedCandidatePath, trimmedProjectRoot, comparisonPlatform);
133
+ const realpathMatch = realpathMatchesOnNativePlatform(trimmedCandidatePath, trimmedProjectRoot, comparisonPlatform);
142
134
  if (realpathMatch !== null) {
143
135
  return realpathMatch;
144
136
  }
@@ -0,0 +1,36 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import {
4
+ buildUnityBatchmodeArgs,
5
+ buildUnityEditorCandidates,
6
+ buildUnityOpenEditorArgs,
7
+ type SupportedPlatform,
8
+ type UnityOpenEditorArgsOptions,
9
+ } from "./unity-core";
10
+
11
+ /** Direct-executable compatibility fallback. Unity CLI routes must not import this module. */
12
+ export async function resolveUnityEditorPath(
13
+ unityVersion: string,
14
+ options: { platform?: SupportedPlatform; homeDir?: string; access?: (candidate: string) => Promise<void> } = {},
15
+ ): Promise<string> {
16
+ const autoCandidates = buildUnityEditorCandidates(unityVersion, options.platform ?? process.platform, options.homeDir);
17
+ const access = options.access ?? ((candidate: string) => fs.access(candidate, fs.constants.X_OK));
18
+ for (const candidate of autoCandidates) {
19
+ try { await access(candidate); return path.normalize(candidate); } catch { /* Try next exact-version candidate. */ }
20
+ }
21
+ throw new Error(`Could not find a Unity Editor executable for Unity ${unityVersion}. Install that exact Editor version through your normal Unity installation workflow, or install Unity CLI so it can resolve the project version.`);
22
+ }
23
+
24
+ export function launchUnityEditorDetached(editorPath: string, projectRoot: string, options: UnityOpenEditorArgsOptions = {}): { pid: number | undefined; args: string[]; command: string } {
25
+ const args = buildUnityOpenEditorArgs(projectRoot, options);
26
+ const child = (awaitableSpawn)(editorPath, args, { detached: true, stdio: "ignore", windowsHide: false });
27
+ child.unref();
28
+ return { pid: child.pid, args, command: editorPath };
29
+ }
30
+
31
+ // Kept local so this module owns direct process creation entirely.
32
+ import { spawn as awaitableSpawn } from "node:child_process";
33
+
34
+ export function createUnityBatchmodeCommand(editorPath: string, projectRoot: string, extraArgs: string[] = [], options: { useGraphics?: boolean } = {}): { command: string; args: string[] } {
35
+ return { command: editorPath, args: buildUnityBatchmodeArgs(projectRoot, extraArgs, options) };
36
+ }
@@ -1,90 +1,10 @@
1
- import * as fs from "node:fs/promises";
2
- import * as path from "node:path";
3
1
  import { spawn } from "node:child_process";
4
- import {
5
- buildUnityBatchmodeArgs,
6
- buildUnityEditorCandidates,
7
- buildUnityOpenEditorArgs,
8
- normalizeUnityEditorOverride,
9
- type SupportedPlatform,
10
- type UnityOpenEditorArgsOptions,
11
- } from "./unity-core";
12
2
  import { createUnityCliOpenCommand, type UnityCliLaunchOptions } from "./unity-cli";
13
3
 
14
- export async function resolveUnityEditorPath(
15
- unityVersion: string,
16
- options: {
17
- overridePath?: string;
18
- env?: NodeJS.ProcessEnv;
19
- platform?: SupportedPlatform;
20
- homeDir?: string;
21
- } = {},
22
- ): Promise<string> {
23
- const platform = options.platform ?? process.platform;
24
- const env = options.env ?? process.env;
25
- const homeDir = options.homeDir;
26
-
27
- const overrideCandidates = [options.overridePath, env.UNITY_EDITOR_PATH, env.UNITY_PATH]
28
- .map((value) => value?.trim())
29
- .filter((value): value is string => Boolean(value))
30
- .map((value) => normalizeUnityEditorOverride(value, platform));
31
-
32
- const autoCandidates = buildUnityEditorCandidates(unityVersion, platform, homeDir);
33
-
34
- for (const candidate of [...overrideCandidates, ...autoCandidates]) {
35
- try {
36
- await fs.access(candidate, fs.constants.X_OK);
37
- return path.normalize(candidate);
38
- } catch {
39
- // Try next candidate.
40
- }
41
- }
42
-
43
- throw new Error(
44
- [
45
- `Could not find a Unity Editor executable for Unity ${unityVersion}.`,
46
- "Set UNITY_EDITOR_PATH to an explicit executable path or pass unityEditorPath.",
47
- ].join(" "),
48
- );
49
- }
50
-
51
- export function launchUnityEditorDetached(
52
- editorPath: string,
53
- projectRoot: string,
54
- options: UnityOpenEditorArgsOptions = {},
55
- ): { pid: number | undefined; args: string[]; command: string } {
56
- const args = buildUnityOpenEditorArgs(projectRoot, options);
57
- const child = spawn(editorPath, args, {
58
- detached: true,
59
- stdio: "ignore",
60
- windowsHide: false,
61
- });
62
- child.unref();
63
- return { pid: child.pid, args, command: editorPath };
64
- }
65
-
66
- export function launchUnityCliOpenDetached(
67
- projectRoot: string,
68
- options: UnityCliLaunchOptions = {},
69
- ): { pid: number | undefined; args: string[]; command: string } {
4
+ /** Unity CLI GUI launch path. Direct executable fallback lives in unity-editor-fallback.ts. */
5
+ export function launchUnityCliOpenDetached(projectRoot: string, options: UnityCliLaunchOptions = {}): { pid: number | undefined; args: string[]; command: string } {
70
6
  const cli = createUnityCliOpenCommand(projectRoot, options);
71
- const child = spawn(cli.command, cli.args, {
72
- detached: true,
73
- stdio: "ignore",
74
- windowsHide: false,
75
- });
7
+ const child = spawn(cli.command, cli.args, { detached: true, stdio: "ignore", windowsHide: false });
76
8
  child.unref();
77
9
  return { pid: child.pid, args: cli.args, command: cli.command };
78
10
  }
79
-
80
- export function createUnityBatchmodeCommand(
81
- editorPath: string,
82
- projectRoot: string,
83
- extraArgs: string[] = [],
84
- options: { useGraphics?: boolean } = {},
85
- ): { command: string; args: string[] } {
86
- return {
87
- command: editorPath,
88
- args: buildUnityBatchmodeArgs(projectRoot, extraArgs, options),
89
- };
90
- }
@@ -5,7 +5,8 @@ import { parseUnityVersionText, resolveAbsolutePath } from "./unity-core";
5
5
  export type UnityProjectCandidate = {
6
6
  projectRoot: string;
7
7
  projectName: string;
8
- unityVersion: string;
8
+ /** Manual ProjectVersion.txt evidence, loaded only by direct/Pipeline routes. */
9
+ unityVersion?: string;
9
10
  };
10
11
 
11
12
  export type UnityProjectDiscoveryResult = {
@@ -81,7 +82,6 @@ export async function findAncestorUnityProject(startDir: string): Promise<UnityP
81
82
  return {
82
83
  projectRoot: current,
83
84
  projectName: path.basename(current),
84
- unityVersion: await readUnityVersion(current),
85
85
  };
86
86
  }
87
87
 
@@ -123,7 +123,6 @@ export async function discoverUnityProjects(
123
123
  candidates.push({
124
124
  projectRoot: next.dir,
125
125
  projectName: path.basename(next.dir),
126
- unityVersion: await readUnityVersion(next.dir),
127
126
  });
128
127
  if (candidates.length >= maxCandidates) {
129
128
  truncated = true;
@@ -63,6 +63,19 @@ export type NormalizedUnityTestResult = {
63
63
  };
64
64
 
65
65
  export type UnityTestRouteRequirements = { requiresIsolation: boolean; reasons: string[] };
66
+
67
+ export function resolveUnityCliBackendReportPaths(
68
+ paths: { nunit: string; junit: string; log: string },
69
+ formats: UnityTestReportFormat[],
70
+ ): { nunit: string; junit?: string; log: string } {
71
+ // Normalized JSON is derived from Unity CLI's native NUnit XML, so the
72
+ // backend report is required even when callers do not retain NUnit output.
73
+ return {
74
+ nunit: paths.nunit,
75
+ junit: formats.includes("junit") ? paths.junit : undefined,
76
+ log: paths.log,
77
+ };
78
+ }
66
79
  export type UnityTestOutcomeEvidence = {
67
80
  cancelled?: boolean;
68
81
  timedOut?: boolean;