@aefree/pi-unity 0.9.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 +173 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/index.ts +1724 -0
- package/package.json +75 -0
- package/skills/auditing-unity-agent-guidance/SKILL.md +41 -0
- package/skills/auditing-unity-agent-guidance/assets/mixed-workflow-template.md +30 -0
- package/skills/auditing-unity-agent-guidance/references/detection-catalog.md +28 -0
- package/skills/auditing-unity-agent-guidance/references/migration-policy.md +48 -0
- package/skills/unity-batchmode-tests/SKILL.md +145 -0
- package/skills/unity-debugging/SKILL.md +35 -0
- package/skills/unity-interactive-playmode-authoring/SKILL.md +91 -0
- package/skills/unity-pipeline-workflows/SKILL.md +52 -0
- package/src/optional-integration-rendezvous.ts +124 -0
- package/src/pi-unity-settings.ts +88 -0
- package/src/unity-artifact-profile.ts +110 -0
- package/src/unity-batchmode.ts +355 -0
- package/src/unity-cli.ts +635 -0
- package/src/unity-core.ts +218 -0
- package/src/unity-file-discovery-filter.ts +89 -0
- package/src/unity-guidance-audit.ts +424 -0
- package/src/unity-launch.ts +85 -0
- package/src/unity-pipeline.ts +487 -0
- package/src/unity-processes.ts +260 -0
- package/src/unity-project-lock.ts +381 -0
- package/src/unity-projects.ts +174 -0
- package/src/unity-test-batch.ts +82 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { parseUnityVersionText, resolveAbsolutePath } from "./unity-core";
|
|
4
|
+
|
|
5
|
+
export type UnityProjectCandidate = {
|
|
6
|
+
projectRoot: string;
|
|
7
|
+
projectName: string;
|
|
8
|
+
unityVersion: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type UnityProjectDiscoveryResult = {
|
|
12
|
+
candidates: UnityProjectCandidate[];
|
|
13
|
+
visitedDirectories: number;
|
|
14
|
+
truncated: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DiscoverUnityProjectsOptions = {
|
|
18
|
+
maxDepth?: number;
|
|
19
|
+
maxDirectories?: number;
|
|
20
|
+
maxCandidates?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const DEFAULT_DISCOVERY_OPTIONS: Required<DiscoverUnityProjectsOptions> = {
|
|
24
|
+
maxDepth: 4,
|
|
25
|
+
maxDirectories: 400,
|
|
26
|
+
maxCandidates: 20,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const SKIPPED_DIRECTORY_NAMES = new Set([
|
|
30
|
+
".git",
|
|
31
|
+
".hg",
|
|
32
|
+
".svn",
|
|
33
|
+
".vs",
|
|
34
|
+
".idea",
|
|
35
|
+
".pi",
|
|
36
|
+
"Library",
|
|
37
|
+
"Temp",
|
|
38
|
+
"Logs",
|
|
39
|
+
"obj",
|
|
40
|
+
"node_modules",
|
|
41
|
+
"Build",
|
|
42
|
+
"Builds",
|
|
43
|
+
"bin",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
47
|
+
try {
|
|
48
|
+
await fs.access(filePath);
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readUnityVersion(projectRoot: string): Promise<string> {
|
|
56
|
+
const versionFile = path.join(projectRoot, "ProjectSettings", "ProjectVersion.txt");
|
|
57
|
+
const contents = await fs.readFile(versionFile, "utf8");
|
|
58
|
+
const version = parseUnityVersionText(contents);
|
|
59
|
+
if (!version) {
|
|
60
|
+
throw new Error(`Could not parse Unity version from ${versionFile}`);
|
|
61
|
+
}
|
|
62
|
+
return version;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function isUnityProjectRoot(dirPath: string): Promise<boolean> {
|
|
66
|
+
const versionFile = path.join(dirPath, "ProjectSettings", "ProjectVersion.txt");
|
|
67
|
+
if (!(await pathExists(versionFile))) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const assetsDir = path.join(dirPath, "Assets");
|
|
72
|
+
const manifestFile = path.join(dirPath, "Packages", "manifest.json");
|
|
73
|
+
return (await pathExists(assetsDir)) || (await pathExists(manifestFile));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function findAncestorUnityProject(startDir: string): Promise<UnityProjectCandidate | null> {
|
|
77
|
+
let current = path.resolve(startDir);
|
|
78
|
+
|
|
79
|
+
while (true) {
|
|
80
|
+
if (await isUnityProjectRoot(current)) {
|
|
81
|
+
return {
|
|
82
|
+
projectRoot: current,
|
|
83
|
+
projectName: path.basename(current),
|
|
84
|
+
unityVersion: await readUnityVersion(current),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const parent = path.dirname(current);
|
|
89
|
+
if (parent === current) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
current = parent;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function discoverUnityProjects(
|
|
97
|
+
startDir: string,
|
|
98
|
+
options: DiscoverUnityProjectsOptions = {},
|
|
99
|
+
): Promise<UnityProjectDiscoveryResult> {
|
|
100
|
+
const { maxDepth, maxDirectories, maxCandidates } = { ...DEFAULT_DISCOVERY_OPTIONS, ...options };
|
|
101
|
+
const root = path.resolve(startDir);
|
|
102
|
+
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }];
|
|
103
|
+
const visited = new Set<string>();
|
|
104
|
+
const candidates: UnityProjectCandidate[] = [];
|
|
105
|
+
let visitedDirectories = 0;
|
|
106
|
+
let truncated = false;
|
|
107
|
+
|
|
108
|
+
while (queue.length > 0) {
|
|
109
|
+
const next = queue.shift();
|
|
110
|
+
if (!next) break;
|
|
111
|
+
|
|
112
|
+
const normalized = path.normalize(next.dir);
|
|
113
|
+
if (visited.has(normalized)) continue;
|
|
114
|
+
visited.add(normalized);
|
|
115
|
+
visitedDirectories += 1;
|
|
116
|
+
|
|
117
|
+
if (visitedDirectories > maxDirectories) {
|
|
118
|
+
truncated = true;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (await isUnityProjectRoot(next.dir)) {
|
|
123
|
+
candidates.push({
|
|
124
|
+
projectRoot: next.dir,
|
|
125
|
+
projectName: path.basename(next.dir),
|
|
126
|
+
unityVersion: await readUnityVersion(next.dir),
|
|
127
|
+
});
|
|
128
|
+
if (candidates.length >= maxCandidates) {
|
|
129
|
+
truncated = true;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (next.depth >= maxDepth) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let entries: fs.Dirent[] = [];
|
|
140
|
+
try {
|
|
141
|
+
entries = await fs.readdir(next.dir, { withFileTypes: true });
|
|
142
|
+
} catch {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
if (!entry.isDirectory()) continue;
|
|
148
|
+
if (SKIPPED_DIRECTORY_NAMES.has(entry.name)) continue;
|
|
149
|
+
queue.push({ dir: path.join(next.dir, entry.name), depth: next.depth + 1 });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
candidates.sort((left, right) => left.projectRoot.localeCompare(right.projectRoot));
|
|
154
|
+
return { candidates, visitedDirectories, truncated };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function resolveUnityProjectCandidates(cwd: string, requestedPath?: string): Promise<UnityProjectDiscoveryResult> {
|
|
158
|
+
if (requestedPath?.trim()) {
|
|
159
|
+
const absolutePath = resolveAbsolutePath(cwd, requestedPath);
|
|
160
|
+
const directProject = await findAncestorUnityProject(absolutePath);
|
|
161
|
+
if (directProject) {
|
|
162
|
+
return { candidates: [directProject], visitedDirectories: 0, truncated: false };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return discoverUnityProjects(absolutePath);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ancestorProject = await findAncestorUnityProject(cwd);
|
|
169
|
+
if (ancestorProject) {
|
|
170
|
+
return { candidates: [ancestorProject], visitedDirectories: 0, truncated: false };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return discoverUnityProjects(cwd);
|
|
174
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export type UnityTestPlatform = "EditMode" | "PlayMode";
|
|
5
|
+
|
|
6
|
+
export type UnityTestBatchPlanInput = {
|
|
7
|
+
projectRoot: string;
|
|
8
|
+
testPlatform: UnityTestPlatform;
|
|
9
|
+
testFilters?: string[];
|
|
10
|
+
testCategories?: string[];
|
|
11
|
+
now?: Date;
|
|
12
|
+
token?: string;
|
|
13
|
+
pathApi?: Pick<typeof path, "resolve" | "join" | "isAbsolute" | "relative" | "sep">;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type UnityTestBatchPlan = {
|
|
17
|
+
testPlatform: UnityTestPlatform;
|
|
18
|
+
testFilters: string[];
|
|
19
|
+
testCategories: string[];
|
|
20
|
+
testResultsPath: string;
|
|
21
|
+
logFilePath: string;
|
|
22
|
+
args: string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function normalizeSelectors(values: string[] | undefined, label: string): string[] {
|
|
26
|
+
const normalized: string[] = [];
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
for (const [index, raw] of (values ?? []).entries()) {
|
|
29
|
+
const value = raw.trim();
|
|
30
|
+
if (!value) throw new Error(`${label}[${index}] must not be empty or whitespace-only.`);
|
|
31
|
+
if (/[\0\r\n;]/.test(value)) {
|
|
32
|
+
throw new Error(`${label}[${index}] must not contain NUL, newlines, or semicolons; pass separate selectors as separate array entries.`);
|
|
33
|
+
}
|
|
34
|
+
if (!seen.has(value)) {
|
|
35
|
+
seen.add(value);
|
|
36
|
+
normalized.push(value);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function safeTimestamp(date: Date): string {
|
|
43
|
+
return date.toISOString().replace(/[-:.]/g, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function safeToken(value: string): string {
|
|
47
|
+
const token = value.replace(/[^A-Za-z0-9]/g, "").slice(0, 16);
|
|
48
|
+
if (!token) throw new Error("Unity test batch token must contain at least one ASCII letter or digit.");
|
|
49
|
+
return token;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createUnityTestBatchPlan(input: UnityTestBatchPlanInput): UnityTestBatchPlan {
|
|
53
|
+
const pathApi = input.pathApi ?? path;
|
|
54
|
+
const projectRoot = pathApi.resolve(input.projectRoot);
|
|
55
|
+
const logsRoot = pathApi.join(projectRoot, "Logs");
|
|
56
|
+
const testFilters = normalizeSelectors(input.testFilters, "testFilters");
|
|
57
|
+
const testCategories = normalizeSelectors(input.testCategories, "testCategories");
|
|
58
|
+
const token = safeToken(input.token ?? randomUUID());
|
|
59
|
+
const platformSlug = input.testPlatform.toLowerCase();
|
|
60
|
+
const basename = `unity-tests-${platformSlug}-${safeTimestamp(input.now ?? new Date())}-${token}`;
|
|
61
|
+
const testResultsPath = pathApi.join(logsRoot, `${basename}.xml`);
|
|
62
|
+
const logFilePath = pathApi.join(logsRoot, `${basename}.log`);
|
|
63
|
+
const args = ["-runTests", "-testPlatform", input.testPlatform];
|
|
64
|
+
if (testFilters.length > 0) args.push("-testFilter", testFilters.join(";"));
|
|
65
|
+
if (testCategories.length > 0) args.push("-testCategory", testCategories.join(";"));
|
|
66
|
+
args.push("-testResults", testResultsPath, "-logFile", logFilePath);
|
|
67
|
+
|
|
68
|
+
if (!pathApi.isAbsolute(testResultsPath) || !pathApi.isAbsolute(logFilePath)) {
|
|
69
|
+
throw new Error("Unity test batch artifact paths must be absolute.");
|
|
70
|
+
}
|
|
71
|
+
for (const artifactPath of [testResultsPath, logFilePath]) {
|
|
72
|
+
const relative = pathApi.relative(logsRoot, artifactPath);
|
|
73
|
+
if (relative === ".." || relative.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(relative)) {
|
|
74
|
+
throw new Error("Unity test batch artifact path escaped the project Logs directory.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (args.some((arg) => arg.toLowerCase() === "-quit" || arg.toLowerCase().startsWith("-quit="))) {
|
|
78
|
+
throw new Error("Unity test batch arguments must not contain -quit.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { testPlatform: input.testPlatform, testFilters, testCategories, testResultsPath, logFilePath, args };
|
|
82
|
+
}
|