@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.
- package/CHANGELOG.md +23 -0
- package/README.md +15 -3
- package/index.ts +87 -217
- package/package.json +8 -3
- package/skills/unity-pipeline-workflows/SKILL.md +1 -1
- package/src/unity-artifact-inspection.ts +13 -9
- package/src/unity-artifact-profile.ts +1 -1
- package/src/unity-cli.ts +109 -17
- package/src/unity-file-discovery-filter.ts +1 -1
- package/src/unity-pipeline.ts +36 -11
- package/src/unity-projects.ts +2 -1
- package/src/unity-renderers.ts +174 -0
- package/src/unity-tests.ts +3 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { highlightCode, keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { UnityToolDetails } from "../index";
|
|
4
|
+
import type { UnityGuidanceAuditResult } from "./unity-guidance-audit";
|
|
5
|
+
|
|
6
|
+
type Args = { path?: string; args?: unknown[]; code?: string; command?: string; file?: string; entry?: string; dryRun?: boolean; testPlatform?: string; testFilter?: string; testFilters?: string[]; testCategories?: string[]; execution?: string };
|
|
7
|
+
type Context = { lastComponent?: unknown; args?: Args; isError?: boolean; expanded?: boolean };
|
|
8
|
+
type Result = { content?: Array<{ type: string; text?: string }>; details?: unknown };
|
|
9
|
+
type Options = { expanded: boolean; isPartial?: boolean };
|
|
10
|
+
|
|
11
|
+
function redact(value: string): string {
|
|
12
|
+
return value.replace(/\b(token|secret|password|api[_-]?key)\s*([:=])\s*((?:\$@?|@\$?)?"(?:""|\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;)}\]]+)/gi, "$1$2[redacted]")
|
|
13
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
14
|
+
}
|
|
15
|
+
export function compactUnityRendererValue(value: unknown, limit = 160): string {
|
|
16
|
+
const text = redact(String(value ?? "")).replace(/\s+/g, " ").trim();
|
|
17
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
18
|
+
}
|
|
19
|
+
function reuse(context: Context | undefined, value: string): Text {
|
|
20
|
+
const text = context?.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
21
|
+
text.setText(value);
|
|
22
|
+
return text;
|
|
23
|
+
}
|
|
24
|
+
function projectName(path?: string): string {
|
|
25
|
+
return compactUnityRendererValue(path?.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "auto-resolve", 70);
|
|
26
|
+
}
|
|
27
|
+
const titles: Record<string, string> = {
|
|
28
|
+
unity_project_status: "Project status", unity_run_tests: "Tests", unity_pipeline_run_tests: "Tests",
|
|
29
|
+
unity_pipeline_recompile: "Recompile", unity_pipeline_eval: "Eval", unity_pipeline_inspect: "Inspect",
|
|
30
|
+
unity_pipeline_run_script: "Run script", unity_inspect_artifacts: "Artifacts", unity_open_editor: "Open Editor",
|
|
31
|
+
unity_launch_batchmode: "Batchmode", unity_guidance_audit: "Guidance audit",
|
|
32
|
+
};
|
|
33
|
+
export function renderUnityToolCall(name: string, args: Args, theme: Pick<Theme, "fg" | "bold">, _mode?: string, _emphasis?: string, context?: Context): Text {
|
|
34
|
+
const title = args.testPlatform ? `${args.testPlatform} tests` : titles[name] ?? name;
|
|
35
|
+
let text = theme.fg("toolTitle", theme.bold(`Unity · ${title}`)) + theme.fg("dim", ` ${projectName(args.path)}`);
|
|
36
|
+
let subtitle = "";
|
|
37
|
+
if (args.code !== undefined) subtitle = compactUnityRendererValue(args.code, 140);
|
|
38
|
+
else if (args.file) subtitle = `${compactUnityRendererValue(args.file, 120)}${args.entry ? ` · ${compactUnityRendererValue(args.entry, 60)}` : ""}${args.dryRun ? " · compile only" : ""}`;
|
|
39
|
+
else if (args.command) subtitle = compactUnityRendererValue(args.command.replace(/_/g, " "), 100);
|
|
40
|
+
else if (args.testPlatform) {
|
|
41
|
+
subtitle = [...(args.testFilters ?? (args.testFilter ? [args.testFilter] : [])), ...(args.testCategories ?? []).map(value => `category: ${value}`)].map(value => compactUnityRendererValue(value, 100)).join(" · ") || "All tests";
|
|
42
|
+
subtitle = compactUnityRendererValue(subtitle, 180);
|
|
43
|
+
} else if (name === "unity_launch_batchmode") subtitle = _emphasis ?? "";
|
|
44
|
+
if (subtitle && !(args.code && context?.expanded)) text += `\n${theme.fg("muted", subtitle)}`;
|
|
45
|
+
return reuse(context, text);
|
|
46
|
+
}
|
|
47
|
+
export function renderUnityPipelineCall(name: string, args: Args, theme: Pick<Theme, "fg" | "bold">, context: Context): Text {
|
|
48
|
+
return renderUnityToolCall(name, args, theme, undefined, undefined, context);
|
|
49
|
+
}
|
|
50
|
+
function content(result: Result): string {
|
|
51
|
+
return redact((result.content ?? []).filter(entry => entry.type === "text").map(entry => entry.text ?? "").join("\n"));
|
|
52
|
+
}
|
|
53
|
+
function counts(summary?: { passed?: number; failed?: number; skipped?: number }): string {
|
|
54
|
+
return summary ? (["passed", "failed", "skipped"] as const).filter(key => summary[key] !== undefined).map(key => `${summary[key]} ${key}`).join(" · ") : "";
|
|
55
|
+
}
|
|
56
|
+
function prettyOutput(output: string): string {
|
|
57
|
+
const safe = redact(output);
|
|
58
|
+
try { JSON.parse(safe); }
|
|
59
|
+
catch (error) { if (error instanceof SyntaxError) return safe; throw error; }
|
|
60
|
+
|
|
61
|
+
// Validate with JSON.parse, but format tokens directly so JSON number and string lexemes stay exact.
|
|
62
|
+
let formatted = "";
|
|
63
|
+
let depth = 0;
|
|
64
|
+
const indent = () => " ".repeat(depth);
|
|
65
|
+
for (let index = 0; index < safe.length; index++) {
|
|
66
|
+
const character = safe[index];
|
|
67
|
+
if (/\s/.test(character)) continue;
|
|
68
|
+
if (character === '"') {
|
|
69
|
+
const start = index++;
|
|
70
|
+
while (index < safe.length) {
|
|
71
|
+
if (safe[index] === "\\") index++;
|
|
72
|
+
else if (safe[index] === '"') break;
|
|
73
|
+
index++;
|
|
74
|
+
}
|
|
75
|
+
formatted += safe.slice(start, index + 1);
|
|
76
|
+
} else if (character === "{" || character === "[") {
|
|
77
|
+
let next = index + 1;
|
|
78
|
+
while (/\s/.test(safe[next] ?? "")) next++;
|
|
79
|
+
if (safe[next] === (character === "{" ? "}" : "]")) {
|
|
80
|
+
formatted += character + safe[next];
|
|
81
|
+
index = next;
|
|
82
|
+
} else {
|
|
83
|
+
formatted += `${character}\n`;
|
|
84
|
+
depth++;
|
|
85
|
+
formatted += indent();
|
|
86
|
+
}
|
|
87
|
+
} else if (character === "}" || character === "]") {
|
|
88
|
+
depth--;
|
|
89
|
+
formatted += `\n${indent()}${character}`;
|
|
90
|
+
} else if (character === ",") formatted += `,\n${indent()}`;
|
|
91
|
+
else if (character === ":") formatted += ": ";
|
|
92
|
+
else formatted += character;
|
|
93
|
+
}
|
|
94
|
+
return formatted;
|
|
95
|
+
}
|
|
96
|
+
export function renderUnityPipelineResult(result: Result, options: Options, theme: Pick<Theme, "fg" | "bold">, context: Context): Text {
|
|
97
|
+
return renderUnityToolResult(result, options.expanded, theme, context, options.isPartial);
|
|
98
|
+
}
|
|
99
|
+
export function renderUnityToolResult(result: Result, expanded: boolean, theme: Pick<Theme, "fg" | "bold">, context?: Context, isPartial = false): Text {
|
|
100
|
+
const primary = content(result);
|
|
101
|
+
const details = result.details as UnityToolDetails | undefined;
|
|
102
|
+
if (isPartial) return reuse(context, theme.fg("warning", `… ${compactUnityRendererValue(primary || "Waiting for Unity…", 200)}`));
|
|
103
|
+
if (!details?.mode) {
|
|
104
|
+
const text = expanded ? primary : compactUnityRendererValue(primary || "No output", 280);
|
|
105
|
+
return reuse(context, theme.fg(context?.isError ? "error" : "toolOutput", text) + (!expanded && primary ? `\n${theme.fg("dim", keyHint("app.tools.expand", "details"))}` : ""));
|
|
106
|
+
}
|
|
107
|
+
const pipeline = details.pipeline;
|
|
108
|
+
const tests = details.testResult;
|
|
109
|
+
const output = details.pipelineEval ?? details.pipelineInspection ?? details.pipelineRunScript;
|
|
110
|
+
const outcome = tests?.outcome ?? details.testOutcome;
|
|
111
|
+
const uncertain = outcome && ["uncertain", "empty_selection", "timed_out", "cancelled", "passed_with_flakes"].includes(outcome);
|
|
112
|
+
const failed = context?.isError || details.status === "failed" || outcome === "tests_failed" || outcome === "run_error" || output?.outcome === "rejected";
|
|
113
|
+
const tone = uncertain || details.status === "killed" || details.projectState?.processVerificationIncomplete || details.projectState?.staleLockSuspected ? "warning" : failed ? "error" : details.status === "passed" || details.mode === "gui" ? "success" : "warning";
|
|
114
|
+
let summary: string = details.status ?? "Completed";
|
|
115
|
+
if (details.mode === "status") {
|
|
116
|
+
const capabilities = details.cliCapabilities;
|
|
117
|
+
const reachable = capabilities?.matchingInstances.some(instance => instance.reachable === true);
|
|
118
|
+
const state = details.projectState;
|
|
119
|
+
summary = reachable ? "Editor open · Pipeline reachable"
|
|
120
|
+
: capabilities?.matchingInstances.length || state?.runningProcessCount ? "Editor detected · Pipeline reachability unconfirmed"
|
|
121
|
+
: state?.processVerificationIncomplete ? "Process state uncertain"
|
|
122
|
+
: state?.staleLockSuspected ? "No Editor detected · lock may be stale"
|
|
123
|
+
: state ? `No Editor detected · lock ${state.nativeLockfileExists ? "present" : "absent"}`
|
|
124
|
+
: "Project inspected · expand for process and lock state";
|
|
125
|
+
if (details.unityVersion) summary += ` · Unity ${details.unityVersion}`;
|
|
126
|
+
} else if (tests || details.mode === "artifacts") {
|
|
127
|
+
summary = `${details.mode === "artifacts" ? `Inspection ${details.status ?? "unknown"} · Tests: ` : ""}${(outcome ?? "not established").replace(/_/g, " ")}`;
|
|
128
|
+
const testCounts = counts(tests?.summary ?? details.normalizedResult?.summary ?? details.parsedTestResults ?? undefined);
|
|
129
|
+
if (testCounts) summary = details.mode === "tests" && outcome === "passed" ? testCounts : `${summary} · ${testCounts}`;
|
|
130
|
+
if (tests?.durationSeconds !== undefined) summary += ` · ${tests.durationSeconds.toFixed(1)}s`;
|
|
131
|
+
if (details.route) summary += ` · ${details.route}`;
|
|
132
|
+
} else if (pipeline) {
|
|
133
|
+
summary = `${pipeline.operation === "recompile" ? "Recompile" : "Tests"} ${pipeline.terminalState} · ${pipeline.elapsedSeconds.toFixed(1)}s`;
|
|
134
|
+
const testCounts = counts(pipeline.counts);
|
|
135
|
+
if (testCounts) summary += ` · ${testCounts}`;
|
|
136
|
+
} else if (output) {
|
|
137
|
+
summary = output.outcome === "rejected" ? compactUnityRendererValue(output.message, 240) : expanded ? "Completed" : compactUnityRendererValue(output.output || "No output returned", 240);
|
|
138
|
+
if (output.outcome === "dispatched" && output.truncated && !expanded) summary += " · output truncated";
|
|
139
|
+
}
|
|
140
|
+
else if (details.mode === "gui") summary = `Editor launched${details.pid ? ` · PID ${details.pid}` : ""}`;
|
|
141
|
+
else if (details.mode === "batchmode") summary = `${details.status ?? "unknown"} · exit ${details.exitCode ?? "unknown"}${counts(details.parsedTestResults ?? undefined) ? ` · ${counts(details.parsedTestResults ?? undefined)}` : ""}`;
|
|
142
|
+
let text = theme.fg(tone, `${tone === "success" ? "✓" : tone === "error" ? "✗" : "!"} ${summary}`);
|
|
143
|
+
const notices = [details.warning, ...(pipeline?.warnings ?? []), ...(details.evidenceWarnings ?? []), ...(tests?.diagnostics ?? [])].filter((value): value is string => Boolean(value));
|
|
144
|
+
if (pipeline?.playModeHandling && pipeline.playModeHandling !== "not_playing") notices.push(pipeline.playModeHandling === "agent_exited" ? "Play Mode exited by pi-unity" : `Play Mode: ${pipeline.playModeHandling.replace(/_/g, " ")}`);
|
|
145
|
+
if (notices.length) text += `\n${theme.fg("warning", compactUnityRendererValue(notices.join(" · "), expanded ? 1000 : 240))}`;
|
|
146
|
+
const nonPassing = tests?.tests?.filter(test => !["passed", "success"].includes(test.status.toLowerCase())) ?? [];
|
|
147
|
+
const failures = nonPassing.filter(test => ["failed", "error"].includes(test.status.toLowerCase()));
|
|
148
|
+
const diagnostics = nonPassing.filter(test => !["failed", "error"].includes(test.status.toLowerCase()));
|
|
149
|
+
for (const test of [...failures, ...diagnostics].slice(0, expanded ? 8 : 1)) text += `\n${theme.fg("warning", compactUnityRendererValue(`${test.name}: ${test.message || test.status}`, expanded ? 1000 : 200))}`;
|
|
150
|
+
if (expanded) {
|
|
151
|
+
const section = (title: string, body: string) => { if (body) text += `\n\n${theme.fg("toolTitle", theme.bold(title))}\n${body}`; };
|
|
152
|
+
section("Project", theme.fg("muted", redact(details.projectRoot || context?.args?.path || "Unknown project")));
|
|
153
|
+
if (context?.args?.code) section("C#", highlightCode(redact(context.args.code), "csharp").join("\n"));
|
|
154
|
+
if (context?.args?.file) section("Script", theme.fg("toolOutput", redact(`${context.args.file}${context.args.entry ? `\nEntry: ${context.args.entry}` : ""}${context.args.dryRun ? "\nCompile only" : ""}`)));
|
|
155
|
+
if (output?.outcome === "dispatched") section(`Result${output.truncated ? " (truncated)" : ""}`, theme.fg("toolOutput", prettyOutput(output.output)));
|
|
156
|
+
section("Evidence", theme.fg("toolOutput", primary));
|
|
157
|
+
const paths = [...new Set([details.artifactPath, details.normalizedResultPath, details.artifacts?.testResultsPath, details.artifacts?.logFilePath, ...Object.values(tests?.backendArtifacts ?? {})].filter((value): value is string => Boolean(value)))];
|
|
158
|
+
section("Artifacts", theme.fg("muted", redact(paths.join("\n"))));
|
|
159
|
+
} else text += `\n${theme.fg("dim", keyHint("app.tools.expand", "details"))}`;
|
|
160
|
+
return reuse(context, text);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function renderUnityGuidanceResult(result: Result, options: Options, theme: Pick<Theme, "fg" | "bold">, context: Context): Text {
|
|
164
|
+
const details = result.details as UnityGuidanceAuditResult | undefined;
|
|
165
|
+
if (!details?.summary) return renderUnityToolResult(result, options.expanded, theme, context, options.isPartial);
|
|
166
|
+
const { filesScanned, errors, warnings, infos } = details.summary;
|
|
167
|
+
const ancestors = details.ancestorCandidates.length;
|
|
168
|
+
const tone = errors ? "error" : warnings || ancestors ? "warning" : "success";
|
|
169
|
+
let text = theme.fg(tone, `${tone === "success" ? "✓" : "!"} ${filesScanned} files · ${errors} errors · ${warnings} warnings · ${infos} info`);
|
|
170
|
+
if (ancestors) text += `\n${theme.fg("warning", `${ancestors} ancestor files excluded`)}`;
|
|
171
|
+
if (options.expanded) text += `\n\n${theme.fg("toolTitle", theme.bold("Findings"))}\n${theme.fg("toolOutput", content(result))}`;
|
|
172
|
+
else text += `\n${theme.fg("dim", keyHint("app.tools.expand", "details"))}`;
|
|
173
|
+
return reuse(context, text);
|
|
174
|
+
}
|
package/src/unity-tests.ts
CHANGED
|
@@ -60,6 +60,8 @@ export type NormalizedUnityTestResult = {
|
|
|
60
60
|
tests: NormalizedUnityTest[];
|
|
61
61
|
flakyTests?: Array<{ name: string; attempts: number }>;
|
|
62
62
|
backendArtifacts?: Record<string, string>;
|
|
63
|
+
/** Bounded non-authoritative observations retained when terminal Pipeline evidence cannot establish a result. */
|
|
64
|
+
diagnostics?: string[];
|
|
63
65
|
};
|
|
64
66
|
|
|
65
67
|
export type UnityTestRouteRequirements = { requiresIsolation: boolean; reasons: string[] };
|
|
@@ -250,6 +252,7 @@ export function normalizeUnityTestResult(result: NormalizedUnityTestResult): Nor
|
|
|
250
252
|
summary: Object.fromEntries(Object.entries(result.summary).flatMap(([key, value]) => numberOrUndefined(value) === undefined ? [] : [[key, numberOrUndefined(value)!]])),
|
|
251
253
|
tests, ...(result.flakyTests ? { flakyTests: result.flakyTests.slice(0, UNITY_TEST_MAX_TESTS).map(item => ({ name: bound(item.name, 1_000) || "Unnamed test", attempts: Math.max(1, Math.floor(item.attempts)) })) } : {}),
|
|
252
254
|
...(Object.keys(artifacts).length ? { backendArtifacts: artifacts } : {}),
|
|
255
|
+
...(result.diagnostics ? { diagnostics: result.diagnostics.slice(0, 8).flatMap(value => typeof value === "string" ? [bound(value, 1_000) || ""] : []).filter(Boolean) } : {}),
|
|
253
256
|
};
|
|
254
257
|
}
|
|
255
258
|
|