@aefree/pi-unity 0.12.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/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project follows semantic versioning for public package releases.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.12.1 - 2026-09-10
11
+
12
+ ### Security
13
+
14
+ - Update the development/test Pi baseline to 0.85.1 and refresh the lockfile to resolve patched `brace-expansion` 5.0.9 and `undici` 8.9.0. Consumer-managed Pi hosts must be updated separately; peer compatibility ranges are unchanged.
15
+
16
+ ### Fixed
17
+
18
+ - Compare existing Windows project aliases by canonical filesystem path, preventing connected project discovery from treating short paths or junction aliases as a different closed project.
19
+ - Preserve bounded, redacted CLI stdout/stderr on failed Pipeline inspections and evals, including actionable package-version errors; retain native failure, timeout uncertainty and single-dispatch behavior.
20
+ - Clarify connected single-selector arguments and pre-dispatch rejection recipes; document serial independent-fixture calls with stop-on-uncertainty boundaries, backed by offline registered-tool contract tests (not model-planning or live-Editor evidence).
21
+ - Validate normalized JSON test artifacts as standalone inspection evidence, separate inspection success from test outcome, and reject missing explicit paths or conflicting artifacts without substituting unrelated latest logs.
22
+ - Mark rejected Pipeline eval and inspection as native Pi tool failures while retaining structured diagnostics and uncertain post-dispatch effects, without retry or fallback.
23
+ - Reject contradictory combined XML counters and test-record evidence during artifact inspection, including self-closing failures and records beyond the display limit; keep skipped and inconclusive counters distinct and preserve valid partial records.
24
+
10
25
  ## 0.12.0 - 2026-08-27
11
26
 
12
27
  ### Changed
package/README.md CHANGED
@@ -45,7 +45,9 @@ A timeout is uncertain: work may still be running. The tools do not silently can
45
45
 
46
46
  - `unity_open_editor` — open the Unity Editor GUI. Pass `automated: true` to add the Unity Editor `-automated` flag; this is distinct from the Unity CLI's own `--non-interactive` option.
47
47
  - `unity_launch_batchmode` — run a bounded batchmode command through Unity CLI or the direct Editor executable.
48
- - `unity_inspect_artifacts` — summarize existing Unity Test Framework XML and Unity logs without launching Unity.
48
+ - `unity_inspect_artifacts` — validate existing normalized JSON test artifacts, Unity Test Framework XML and Unity logs without launching Unity. `details.status` describes inspection; `details.testOutcome` describes the tests, including failures and uncertainty. A valid failed-test artifact is a successful inspection, not a passing run.
49
+
50
+ Pass `normalizedResultPath` for standalone JSON evidence. Any explicit artifact path disables implicit latest-file selection; missing requested files fail inspection. With all paths omitted, `latestFromLogs` discovers recent files but cannot establish current-run identity. Mixed JSON/XML evidence must agree; without an explicit native artifact link, matching counts alone leave correlation uncertain.
49
51
 
50
52
  Use `unity_run_tests` for all ordinary Unity Test Framework work. It writes a durable normalized JSON result under `Logs/`; isolated `unity test` runs retain requested native reports. Connected Pipeline is selected only for compatible requests. A reachable Editor is never closed automatically to obtain isolated-only features.
51
53
 
@@ -95,7 +97,7 @@ The connected compile and test tools:
95
97
  - poll internally with fixed deadlines and bounded backoff;
96
98
  - reject malformed or semantically failing nested results;
97
99
  - require a known positive test count and zero failures before reporting a pass;
98
- - discard passing-test records while retaining bounded failure diagnostics;
100
+ - keep routine tool output compact while preserving connected test records in durable normalized JSON evidence;
99
101
  - detect pre-existing or clearly displaced test runs when available correlation fields permit it.
100
102
 
101
103
  Another connected client is not a project lock. When Pipeline returns stable correlation fields, conflicting status is reported as displaced and uncertain. If Pipeline omits stable run identity, a competing same-mode, same-filter run may be indistinguishable from the requested run; the tool cannot prove exclusive ownership from shared Editor status alone.
@@ -111,6 +113,8 @@ Another connected client is not a project lock. When Pipeline returns stable cor
111
113
 
112
114
  Use `unity_pipeline_inspect` when a purpose-built structured command fits. Use eval for bounded project-specific work that matches the user's intent. Prefer typed tools when they provide stronger lifecycle, polling, validation, or recovery semantics.
113
115
 
116
+ Rejected eval and inspection results are native Pi tool failures (`isError: true`) via the documented `tool_result` middleware, with structured rejection codes and bounded diagnostics retained. Pre-dispatch rejection does not execute the command; a timeout or dispatch failure can leave effects uncertain and never triggers retry or fallback.
117
+
114
118
  ## Launch and process safeguards
115
119
 
116
120
  `unity_open_editor` and batchmode tools treat Unity CLI as the authoritative launcher: `unity open`, `unity run`, and `unity test` receive only the selected project and let Unity CLI read `ProjectVersion.txt`. The direct Editor executable is an exceptional compatibility fallback used only when Unity CLI is unavailable. Set `launcher` to `auto`, `unity-cli`, or `editor-executable` when explicit routing is needed.
package/index.ts CHANGED
@@ -7,9 +7,9 @@ import { setTimeout as delay } from "node:timers/promises";
7
7
  import { getKeybindings, Text, truncateToWidth } from "@earendil-works/pi-tui";
8
8
  import {
9
9
  buildUnityBatchmodeAgentText,
10
- deriveUnityArtifactInspectionStatus,
11
10
  deriveUnityBatchmodeStatus,
12
11
  hasKnownPositiveExecutedTestCount,
12
+ hasConflictingUnityXmlTestEvidence,
13
13
  loadUnityBatchmodeArtifacts,
14
14
  parseUnityBatchmodeInvocation,
15
15
  parseUnityTestResultsXml,
@@ -29,6 +29,8 @@ import { assertUnityProjectNotBusy, evaluateUnityLaunchSafety, getUnityNativeLoc
29
29
  import { readUnityVersion, resolveUnityProjectCandidates, type UnityProjectCandidate } from "./src/unity-projects";
30
30
  import { createUnityTestBatchPlan, type UnityTestBatchPlan, type UnityTestPlatform } from "./src/unity-test-batch";
31
31
  import { applyUnityCliRetrySummary, compactUnityTestSummary, defaultUnityTestReportFormats, deriveUnityCliEffectiveReportPath, determineUnityTestOutcome, getUnityTestRouteRequirements, normalizeUnityRunTestsRequest, parseUnityCliRetrySummary, resolveUnityCliBackendReportPaths, writeNormalizedUnityTestArtifact, type NormalizedUnityTestResult, type UnityRunTestsRequest } from "./src/unity-tests";
32
+ import { validateNormalizedUnityTestArtifact } from "./src/unity-artifact-inspection";
33
+ import { UNITY_TEST_MAX_ARTIFACT_BYTES } from "./src/unity-tests";
32
34
  import { auditUnityGuidance, type UnityGuidanceAuditResult } from "./src/unity-guidance-audit";
33
35
  import { runUnityPipelineRecompile, runUnityPipelineTests, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
34
36
  import {
@@ -83,6 +85,10 @@ type UnityToolDetails = {
83
85
  invocation?: UnityBatchmodeInvocation;
84
86
  artifacts?: UnityBatchmodeArtifacts;
85
87
  parsedTestResults?: UnityParsedTestResults | null;
88
+ testOutcome?: NormalizedUnityTestResult["outcome"];
89
+ normalizedResultPath?: string;
90
+ normalizedResult?: Pick<NormalizedUnityTestResult, "source" | "platform" | "outcome" | "summary"> & { testRecordCount: number };
91
+ evidenceWarnings?: string[];
86
92
  status?: "passed" | "failed" | "killed";
87
93
  launcher?: "unity-cli" | "editor-executable";
88
94
  cliArgs?: string[];
@@ -118,11 +124,13 @@ const LAUNCH_BATCHMODE_PARAMS = Type.Object({
118
124
  closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false, description: "When true, pi-unity may close a running Unity process for the resolved project before launch, but only if piUnity.allowCloseRunningUnityProcess is enabled in Pi settings. The process is selected by project matching, not by model-supplied PID." })),
119
125
  }, { additionalProperties: false });
120
126
 
127
+ const CONNECTED_TEST_SELECTOR_GUIDANCE = 'unity_run_tests connected execution supports one testFilters entry OR one testCategories entry per call. For independent, non-overlapping selections, use separate execution: "connected" calls with the same explicit path and platform; await and inspect each passing result before issuing the next. Stop the remaining sequence on failure or uncertainty; never retry, broaden the selection, close the Editor, or switch routes automatically. Do not split a mixed filter/category intersection into separate runs.';
128
+
121
129
  const RUN_TESTS_PARAMS = Type.Object({
122
130
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
123
131
  testPlatform: StringEnum(["EditMode", "PlayMode"] as const),
124
- testFilters: Type.Optional(Type.Array(Type.String(), { maxItems: 50 })),
125
- testCategories: Type.Optional(Type.Array(Type.String(), { maxItems: 50 })),
132
+ testFilters: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "Connected: one test-name selector only; omit testCategories. For two independent fixtures, issue serial single-selector calls, inspecting each result first. No semicolon lists. Omitted/empty selectors select all tests, not a repair for rejected filters." })),
133
+ testCategories: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "Connected: one category only; omit testFilters. Multiple categories require separate non-overlapping selections or deliberate isolated execution; never split a filter/category intersection into separate runs." })),
126
134
  execution: Type.Optional(StringEnum(["auto", "connected", "isolated"] as const, { default: "auto" })),
127
135
  isolatedLauncher: Type.Optional(StringEnum(["auto", "unity-cli", "editor-executable"] as const, { default: "auto" })),
128
136
  retries: Type.Optional(Type.Integer({ minimum: 0, maximum: 20, default: 0 })),
@@ -178,7 +186,7 @@ const INSPECT_ARTIFACTS_PARAMS = Type.Object({
178
186
  testResultsPath: Type.Optional(Type.String({ description: "Unity Test Framework XML results path. Relative paths are resolved against cwd and the Unity project root." })),
179
187
  normalizedResultPath: Type.Optional(Type.String({ description: "pi-unity normalized JSON test artifact path." })),
180
188
  logFilePath: Type.Optional(Type.String({ description: "Unity log file path. Relative paths are resolved against cwd and the Unity project root." })),
181
- latestFromLogs: Type.Optional(Type.Boolean({ default: true, description: "When paths are omitted, inspect the newest .xml and .log files under the project's Logs folder." })),
189
+ latestFromLogs: Type.Optional(Type.Boolean({ default: true, description: "Only when all artifact paths are omitted, inspect the newest .json, .xml and .log files under Logs. Latest files are not proof of a shared run." })),
182
190
  maxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, default: 60, description: "Maximum log/output lines to include." })),
183
191
  maxChars: Type.Optional(Type.Integer({ minimum: 500, maximum: 20000, default: 6000, description: "Maximum log/output characters to include." })),
184
192
  });
@@ -705,7 +713,8 @@ async function buildArtifactInspectionReport(
705
713
  candidate: UnityProjectCandidate,
706
714
  params: { testResultsPath?: string; normalizedResultPath?: string; logFilePath?: string; latestFromLogs?: boolean; maxLines?: number; maxChars?: number },
707
715
  ): Promise<{ text: string; details: UnityToolDetails }> {
708
- const useLatest = params.latestFromLogs !== false;
716
+ // An exact artifact request must not silently recruit unrelated latest evidence.
717
+ const useLatest = params.latestFromLogs !== false && ![params.testResultsPath, params.logFilePath, params.normalizedResultPath].some(value => value?.trim());
709
718
  const logsRoot = join(candidate.projectRoot, "Logs");
710
719
  const testResultsPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.testResultsPath)
711
720
  ?? (useLatest ? await findNewestFile(logsRoot, [".xml"]) : undefined);
@@ -713,12 +722,16 @@ async function buildArtifactInspectionReport(
713
722
  ?? (useLatest ? await findNewestFile(logsRoot, [".log", ".txt"]) : undefined);
714
723
  const normalizedResultPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.normalizedResultPath)
715
724
  ?? (useLatest ? await findNewestFile(logsRoot, [".json"]) : undefined);
716
- let normalizedSummary: string | undefined;
725
+ let normalized: NormalizedUnityTestResult | undefined;
726
+ const evidenceErrors: string[] = [];
727
+ const evidenceWarnings: string[] = [];
717
728
  if (normalizedResultPath) {
718
729
  try {
719
- const normalized = JSON.parse(await readFile(normalizedResultPath, "utf8")) as Partial<NormalizedUnityTestResult>;
720
- if (normalized.schemaVersion === 1 && typeof normalized.outcome === "string") normalizedSummary = `Normalized test result: ${normalized.platform ?? "Unity"} ${normalized.outcome}; ${normalized.summary?.total ?? "unknown"} total.`;
721
- } catch { normalizedSummary = `Normalized test result JSON could not be parsed: ${normalizedResultPath}`; }
730
+ if ((await stat(normalizedResultPath)).size > UNITY_TEST_MAX_ARTIFACT_BYTES) throw new Error("Normalized artifact exceeds its size limit.");
731
+ normalized = validateNormalizedUnityTestArtifact(JSON.parse(await readFile(normalizedResultPath, "utf8")));
732
+ } catch (error) {
733
+ evidenceErrors.push(`Normalized test result could not be loaded/validated: ${normalizedResultPath}: ${error instanceof Error ? error.message : String(error)}`);
734
+ }
722
735
  }
723
736
  const invocation: UnityBatchmodeInvocation = {
724
737
  isTestRun: Boolean(testResultsPath),
@@ -731,16 +744,51 @@ async function buildArtifactInspectionReport(
731
744
  if (testResultsPath && artifacts.testResultsXml && !parsedTestResults) {
732
745
  artifacts.warnings.push(`Unity test results XML could not be parsed: ${artifacts.testResultsPath ?? testResultsPath}`);
733
746
  }
734
- const hasLoadedArtifacts = Boolean(artifacts.testResultsPath || artifacts.logFilePath);
735
- const status = deriveUnityArtifactInspectionStatus(hasLoadedArtifacts, invocation, parsedTestResults);
747
+ if (testResultsPath && !parsedTestResults) evidenceErrors.push(`Requested XML evidence is missing or malformed: ${testResultsPath}`);
748
+ if (parsedTestResults && hasConflictingUnityXmlTestEvidence(parsedTestResults)) {
749
+ evidenceErrors.push("Conflicting XML evidence: invalid or inconsistent counts/records.");
750
+ }
751
+ if (logFilePath && artifacts.logText === undefined) evidenceErrors.push(`Requested log evidence is missing: ${logFilePath}`);
752
+ const hasLoadedArtifacts = Boolean(normalized || parsedTestResults || artifacts.logText !== undefined);
753
+ if (!hasLoadedArtifacts) evidenceErrors.push("No valid Unity artifacts were loaded.");
754
+ let testOutcome = normalized?.outcome ?? (parsedTestResults ? determineUnityTestOutcome({ ...parsedTestResults, failed: parsedTestResults.failedTests.length > 0 ? Math.max(1, parsedTestResults.failed ?? 0) : parsedTestResults.failed }) : undefined);
755
+ if (normalized && parsedTestResults) {
756
+ if (hasConflictingUnityXmlTestEvidence(parsedTestResults, normalized.summary)) evidenceErrors.push("Conflicting normalized/XML evidence: combined counts/records disagree.");
757
+ for (const key of ["total", "passed", "failed", "skipped", "inconclusive"] as const) {
758
+ if (normalized.summary[key] !== undefined && parsedTestResults[key] !== undefined && normalized.summary[key] !== parsedTestResults[key]) evidenceErrors.push(`Conflicting normalized/XML evidence: ${key} differs.`);
759
+ }
760
+ if ((normalized.outcome === "passed" || normalized.outcome === "passed_with_flakes") && parsedTestResults.failedTests.length > 0) evidenceErrors.push("Conflicting normalized/XML evidence: XML contains failed tests.");
761
+ const linkedXml = normalized.backendArtifacts?.nunit;
762
+ if (linkedXml && resolve(candidate.projectRoot, linkedXml) !== resolve(testResultsPath!)) evidenceErrors.push("Conflicting artifact identity: selected XML is not the normalized artifact's nunit path.");
763
+ if (!linkedXml) {
764
+ evidenceWarnings.push("Normalized JSON and XML have no shared run identity; matching counts alone do not correlate these files.");
765
+ testOutcome = "uncertain";
766
+ }
767
+ }
768
+ if (parsedTestResults?.testRecordCounts?.other && (testOutcome === "passed" || testOutcome === "passed_with_flakes")) {
769
+ evidenceWarnings.push("XML contains test records with unknown outcomes; these are not passing evidence.");
770
+ testOutcome = "uncertain";
771
+ }
772
+ if (useLatest) evidenceWarnings.push("Latest artifact selection does not establish current-run identity; use exact paths for a particular run.");
773
+ if (evidenceErrors.length) testOutcome = "uncertain";
774
+ // Inspection succeeded even when valid evidence reports test failure or uncertainty.
775
+ const status = evidenceErrors.length ? "failed" : "passed";
736
776
  const lines = [
737
777
  `Unity artifacts inspected for ${formatPathForUser(ctx.cwd, candidate.projectRoot)}; Unity CLI selects the project's declared Editor version when launching.`,
738
778
  testResultsPath ? `Requested test results: ${testResultsPath}` : "Requested test results: (none found)",
739
779
  logFilePath ? `Requested log file: ${logFilePath}` : "Requested log file: (none found)",
740
780
  normalizedResultPath ? `Requested normalized result: ${normalizedResultPath}` : "Requested normalized result: (none found)",
741
- ...(normalizedSummary ? [normalizedSummary] : []),
781
+ `Inspection: ${status}. Test outcome: ${testOutcome ?? "not established (log only)"}.`,
782
+ ...(normalized ? [compactUnityTestSummary(normalized), `Normalized source: ${normalized.source}; selection (bounded): ${summarizeTextForAgent(JSON.stringify(normalized.selection), 1, 2000)}; ${normalized.tests.length} retained test record(s).`] : []),
783
+ ...evidenceErrors,
784
+ ...evidenceWarnings,
742
785
  ];
743
786
 
787
+ if (normalized) {
788
+ for (const test of normalized.tests.filter(test => !["passed", "success"].includes(test.status.toLowerCase())).slice(0, 8)) {
789
+ lines.push(`- ${test.name.slice(0, 1000)}: ${test.status.slice(0, 100)}${test.message ? ` — ${test.message.slice(0, 1000)}` : ""}`);
790
+ }
791
+ }
744
792
  if (parsedTestResults) {
745
793
  lines.push(...formatParsedTestResultsForAgent(parsedTestResults));
746
794
  }
@@ -767,6 +815,10 @@ async function buildArtifactInspectionReport(
767
815
  artifacts: compactUnityArtifacts(artifacts),
768
816
  parsedTestResults,
769
817
  status,
818
+ testOutcome,
819
+ normalizedResultPath,
820
+ normalizedResult: normalized ? { source: normalized.source, platform: normalized.platform, outcome: normalized.outcome, summary: { total: normalized.summary.total, passed: normalized.summary.passed, failed: normalized.summary.failed, skipped: normalized.summary.skipped, inconclusive: normalized.summary.inconclusive }, testRecordCount: normalized.tests.length } : undefined,
821
+ evidenceWarnings,
770
822
  },
771
823
  };
772
824
  }
@@ -1152,14 +1204,14 @@ async function runUnifiedUnityTests(
1152
1204
  const busy = (await listBlockingUnityProcesses(candidate.projectRoot)).processes.length > 0 || capabilities.matchingInstances.length > 0;
1153
1205
  let route: "connected" | "isolated";
1154
1206
  if (request.execution === "connected") {
1155
- if (requirements.requiresIsolation) throw new Error(`Connected execution cannot honor this request: ${requirements.reasons.join("; ")}.`);
1207
+ if (requirements.requiresIsolation) throw new Error(`Connected execution cannot honor this request: ${requirements.reasons.join("; ")}. No tests were dispatched. ${CONNECTED_TEST_SELECTOR_GUIDANCE} Other isolated-only options still require a deliberate isolated-execution decision.`);
1156
1208
  if (!reachable) throw new Error("Connected execution requires an already-open exact-copy reachable Pipeline Editor; no Unity was launched.");
1157
1209
  route = "connected";
1158
1210
  } else if (request.execution === "isolated") {
1159
1211
  if (reachable && !request.closeBlockingUnityProcess) throw new Error("Isolated execution will not close a reachable Pipeline Editor automatically. Close it first or use the explicitly guarded close option.");
1160
1212
  route = "isolated";
1161
1213
  } else if (reachable) {
1162
- if (requirements.requiresIsolation) throw new Error(`This request requires isolated execution (${requirements.reasons.join("; ")}), but the exact project copy is open in reachable Pipeline. pi-unity will not close it automatically.`);
1214
+ if (requirements.requiresIsolation) throw new Error(`This request requires isolated execution (${requirements.reasons.join("; ")}), but the exact project copy is open in reachable Pipeline. pi-unity will not close it automatically. No tests were dispatched. ${CONNECTED_TEST_SELECTOR_GUIDANCE} Other isolated-only options still require a deliberate isolated-execution decision.`);
1163
1215
  route = "connected";
1164
1216
  } else {
1165
1217
  if (capabilities.pipelineDiscovery === "timeout" || (capabilities.projectSupportsPipeline && capabilities.pipelineDiscovery !== "absent" && capabilities.pipelineDiscovery !== "available")) throw new Error("Pipeline discovery is uncertain for this exact project copy; refusing to start an isolated Editor until project state is known.");
@@ -1333,6 +1385,17 @@ function formatUnityGuidanceAudit(result: UnityGuidanceAuditResult): string {
1333
1385
  }
1334
1386
 
1335
1387
  export default function freeUnityPi(pi: ExtensionAPI) {
1388
+ // Pi's documented tool_result patch marks native failure without discarding the
1389
+ // structured rejection details (throwing from execute retains only error text).
1390
+ // A top-level isError property returned from execute is NOT a native error.
1391
+ pi.on("tool_result", (event) => {
1392
+ const details = event.details as UnityToolDetails | undefined;
1393
+ if ((event.toolName === "unity_pipeline_eval" && details?.mode === "pipeline_eval" && details.pipelineEval?.outcome === "rejected")
1394
+ || (event.toolName === "unity_pipeline_inspect" && details?.mode === "pipeline_inspection" && details.pipelineInspection?.outcome === "rejected")) {
1395
+ return { isError: true };
1396
+ }
1397
+ });
1398
+
1336
1399
  type ScopeRegistrations = Readonly<{
1337
1400
  artifactProfile?: Readonly<{ registry: ScopedRegistryV1; token: RegistrationToken }>;
1338
1401
  fileDiscoveryFilter?: Readonly<{ registry: ScopedRegistryV1; token: RegistrationToken }>;
@@ -1546,6 +1609,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1546
1609
  promptSnippet: "Run Unity EditMode or PlayMode tests through one safe routed workflow with durable normalized evidence.",
1547
1610
  promptGuidelines: [
1548
1611
  "Use unity_run_tests for ordinary Unity Test Framework runs. It selects connected Pipeline only for compatible requests and isolated unity test only when the exact project copy is closed.",
1612
+ CONNECTED_TEST_SELECTOR_GUIDANCE,
1549
1613
  "Do not use unity_launch_batchmode for ordinary tests; raw test flags there are an unsupported escape hatch.",
1550
1614
  "A reachable Editor is never closed merely to obtain isolated-only options. Requests needing retries, sharding, reruns, coverage, multiple selectors, or XML reports are rejected before dispatch when it is open.",
1551
1615
  "Timeout, malformed evidence, cancellation, or missing artifacts never cause a backend fallback or relaunch.",
@@ -1689,13 +1753,13 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1689
1753
  pi.registerTool({
1690
1754
  name: "unity_inspect_artifacts",
1691
1755
  label: "Unity Inspect Artifacts",
1692
- description: "Summarize existing Unity log files and Unity Test Framework XML results without launching Unity.",
1693
- promptSnippet: "Inspect existing Unity logs or test result XML files without launching Unity.",
1756
+ description: "Inspect existing Unity normalized JSON, Test Framework XML and logs without launching Unity. Inspection success is separate from test outcome.",
1757
+ promptSnippet: "Inspect existing Unity normalized test evidence, XML or logs without launching Unity.",
1694
1758
  promptGuidelines: [
1695
1759
  "Use unity_inspect_artifacts after Unity failures when existing -testResults or -logFile artifacts need concise parsing without another Unity launch.",
1696
1760
  "Prefer unity_inspect_artifacts over ad hoc bash parsing of Unity XML/log files when paths are known or Logs/ contains recent artifacts.",
1697
1761
  "unity_inspect_artifacts does not launch Unity and is safe to use even when the Unity project is busy.",
1698
- "Treat selected test XML as passing evidence only when it is well formed, reports a known positive executed-test count, and reports no failures.",
1762
+ "Inspect details.testOutcome, not inspection status, for test success. Passing evidence needs consistent positive passing counts; missing explicit paths and conflicting artifacts fail inspection. Latest files are not current-run identity.",
1699
1763
  ],
1700
1764
  parameters: INSPECT_ARTIFACTS_PARAMS,
1701
1765
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-unity",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.ts"
@@ -25,11 +25,11 @@
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": {
@@ -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
@@ -647,11 +647,11 @@ export async function dispatchUnityPlanningInspection(
647
647
  ...(isEval ? [request.evalSnippet!.trim()] : request.args ?? []),
648
648
  ];
649
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);
650
652
  if (execution.error) {
651
- 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}` : ""}` };
652
654
  }
653
- const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n");
654
- const output = redactUnityPlanningOutput(summarizeUnityCliText(raw, 4_000, 40));
655
655
  const reportedFailure = connectedCommandFailure(execution.stdout, isEval);
656
656
  if (reportedFailure) {
657
657
  return {
package/src/unity-core.ts CHANGED
@@ -104,8 +104,8 @@ export function normalizeForCommandSearch(value: string, platform: SupportedPlat
104
104
  return platform === "win32" ? normalized.toLowerCase() : normalized;
105
105
  }
106
106
 
107
- function realpathMatchesOnDarwin(candidatePath: string, projectRoot: string, platform: SupportedPlatform): boolean | null {
108
- 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) {
109
109
  return null;
110
110
  }
111
111
 
@@ -113,7 +113,7 @@ function realpathMatchesOnDarwin(candidatePath: string, projectRoot: string, pla
113
113
  return realpathSync.native(candidatePath) === realpathSync.native(projectRoot);
114
114
  } catch {
115
115
  // Only use filesystem identity when both paths can be resolved. Falling back
116
- // to the case-sensitive textual comparison preserves case-sensitive APFS.
116
+ // to the platform-specific textual comparison preserves existing behavior.
117
117
  return null;
118
118
  }
119
119
  }
@@ -130,7 +130,7 @@ export function projectPathsMatch(candidatePath: string, projectRoot: string, pl
130
130
  return false;
131
131
  }
132
132
 
133
- const realpathMatch = realpathMatchesOnDarwin(trimmedCandidatePath, trimmedProjectRoot, comparisonPlatform);
133
+ const realpathMatch = realpathMatchesOnNativePlatform(trimmedCandidatePath, trimmedProjectRoot, comparisonPlatform);
134
134
  if (realpathMatch !== null) {
135
135
  return realpathMatch;
136
136
  }