@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,424 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile, readdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type UnityGuidanceHarness = "agents" | "claude" | "copilot" | "cursor";
|
|
6
|
+
export type UnityGuidanceProfile = "pi-native" | "portable" | "mixed";
|
|
7
|
+
export type UnityGuidanceLevel = "error" | "warning" | "info";
|
|
8
|
+
|
|
9
|
+
export type UnityGuidanceFinding = {
|
|
10
|
+
ruleId: string;
|
|
11
|
+
category: string;
|
|
12
|
+
level: UnityGuidanceLevel;
|
|
13
|
+
confidence: "high" | "medium" | "low";
|
|
14
|
+
path: string;
|
|
15
|
+
line: number;
|
|
16
|
+
evidence: string;
|
|
17
|
+
consequence: string;
|
|
18
|
+
replacementPolicyId: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type UnityGuidanceAuditResult = {
|
|
22
|
+
schemaVersion: 1;
|
|
23
|
+
scope: {
|
|
24
|
+
root: string;
|
|
25
|
+
profile: UnityGuidanceProfile;
|
|
26
|
+
harnesses: UnityGuidanceHarness[];
|
|
27
|
+
includeAncestors: boolean;
|
|
28
|
+
};
|
|
29
|
+
files: Array<{ path: string; harness: UnityGuidanceHarness; sha256: string; bytes: number }>;
|
|
30
|
+
ancestorCandidates: Array<{ path: string; harness: UnityGuidanceHarness }>;
|
|
31
|
+
findings: UnityGuidanceFinding[];
|
|
32
|
+
skipped: Array<{ path: string; reason: string }>;
|
|
33
|
+
summary: { filesScanned: number; errors: number; warnings: number; infos: number };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type UnityGuidanceAuditOptions = {
|
|
37
|
+
path: string;
|
|
38
|
+
files?: string[];
|
|
39
|
+
harnesses?: UnityGuidanceHarness[];
|
|
40
|
+
includeAncestors?: boolean;
|
|
41
|
+
profile?: UnityGuidanceProfile;
|
|
42
|
+
maxFiles?: number;
|
|
43
|
+
maxBytesPerFile?: number;
|
|
44
|
+
maxTotalBytes?: number;
|
|
45
|
+
maxFindings?: number;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const DEFAULT_HARNESSES: UnityGuidanceHarness[] = ["agents", "claude", "copilot", "cursor"];
|
|
50
|
+
const ROOT_FILES: Array<[string, UnityGuidanceHarness]> = [
|
|
51
|
+
["AGENTS.md", "agents"],
|
|
52
|
+
["CLAUDE.md", "claude"],
|
|
53
|
+
[".github/copilot-instructions.md", "copilot"],
|
|
54
|
+
[".cursorrules", "cursor"],
|
|
55
|
+
];
|
|
56
|
+
const DISCOVERY_DIRS: Array<[string, UnityGuidanceHarness]> = [
|
|
57
|
+
[".claude/rules", "claude"],
|
|
58
|
+
[".github/instructions", "copilot"],
|
|
59
|
+
[".cursor/rules", "cursor"],
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function withinRoot(root: string, candidate: string): boolean {
|
|
63
|
+
const rel = relative(root, candidate);
|
|
64
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeEvidence(line: string): string {
|
|
68
|
+
const safe = line.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, "�");
|
|
69
|
+
const compact = safe.trim().replace(/\s+/g, " ");
|
|
70
|
+
return compact.length > 240 ? `${compact.slice(0, 240)}…` : compact;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function harnessForPath(path: string): UnityGuidanceHarness | null {
|
|
74
|
+
const normalized = path.replace(/\\/g, "/").toLowerCase();
|
|
75
|
+
const name = basename(normalized);
|
|
76
|
+
if (name === "agents.md") return "agents";
|
|
77
|
+
if (name === "claude.md" || normalized.includes("/.claude/rules/")) return "claude";
|
|
78
|
+
if (name === "copilot-instructions.md" || normalized.includes("/.github/instructions/")) return "copilot";
|
|
79
|
+
if (name === ".cursorrules" || normalized.includes("/.cursor/rules/")) return "cursor";
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function collectMarkdownFiles(root: string, relativeDir: string, maxFiles: number, signal?: AbortSignal): Promise<string[]> {
|
|
84
|
+
const start = resolve(root, relativeDir);
|
|
85
|
+
const files: string[] = [];
|
|
86
|
+
const queue = [start];
|
|
87
|
+
let visitedDirectories = 0;
|
|
88
|
+
let visitedEntries = 0;
|
|
89
|
+
while (queue.length > 0 && files.length < maxFiles && visitedDirectories < 200 && visitedEntries < 5000) {
|
|
90
|
+
signal?.throwIfAborted();
|
|
91
|
+
const current = queue.shift();
|
|
92
|
+
if (!current) break;
|
|
93
|
+
visitedDirectories += 1;
|
|
94
|
+
let entries;
|
|
95
|
+
try {
|
|
96
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
signal?.throwIfAborted();
|
|
102
|
+
visitedEntries += 1;
|
|
103
|
+
if (visitedEntries > 5000) break;
|
|
104
|
+
const fullPath = resolve(current, entry.name);
|
|
105
|
+
if (!withinRoot(root, fullPath)) continue;
|
|
106
|
+
if (entry.isDirectory()) queue.push(fullPath);
|
|
107
|
+
else if (entry.isFile() && [".md", ".mdc"].includes(extname(entry.name).toLowerCase())) files.push(fullPath);
|
|
108
|
+
if (files.length >= maxFiles) break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return files;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function discoverFiles(root: string, harnesses: Set<UnityGuidanceHarness>, includeAncestors: boolean, maxFiles: number, signal?: AbortSignal): Promise<string[]> {
|
|
115
|
+
const found = new Set<string>();
|
|
116
|
+
const roots = [root];
|
|
117
|
+
if (includeAncestors) {
|
|
118
|
+
let current = dirname(root);
|
|
119
|
+
for (let index = 0; index < 3 && current !== dirname(current); index += 1) {
|
|
120
|
+
roots.push(current);
|
|
121
|
+
current = dirname(current);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const candidateRoot of roots) {
|
|
125
|
+
signal?.throwIfAborted();
|
|
126
|
+
for (const [relativePath, harness] of ROOT_FILES) {
|
|
127
|
+
if (harnesses.has(harness)) found.add(resolve(candidateRoot, relativePath));
|
|
128
|
+
}
|
|
129
|
+
for (const [relativeDir, harness] of DISCOVERY_DIRS) {
|
|
130
|
+
if (!harnesses.has(harness)) continue;
|
|
131
|
+
for (const path of await collectMarkdownFiles(candidateRoot, relativeDir, Math.max(0, maxFiles - found.size), signal)) found.add(path);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...found].slice(0, maxFiles);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function nearbyMentionsFallback(lines: string[], index: number): boolean {
|
|
138
|
+
const context = lines.slice(Math.max(0, index - 3), Math.min(lines.length, index + 4)).join(" ").toLowerCase();
|
|
139
|
+
return /\b(?:fallback|legacy|cold|isolated|ci)\b|\b(?:if|when)\b .*\bunavailable\b/.test(context);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isProhibitedMatch(line: string, pattern: RegExp): boolean {
|
|
143
|
+
const plain = line.replace(/[*_`~]/g, "");
|
|
144
|
+
const match = pattern.exec(plain);
|
|
145
|
+
if (!match || match.index === undefined) return false;
|
|
146
|
+
const clauseStart = Math.max(plain.lastIndexOf(".", match.index), plain.lastIndexOf(";", match.index), plain.lastIndexOf(":", match.index)) + 1;
|
|
147
|
+
const prefix = plain.slice(clauseStart, match.index);
|
|
148
|
+
return /\b(?:do not|don't|never|must not|should not|avoid|forbid|without)\b/i.test(prefix);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function addFinding(findings: UnityGuidanceFinding[], finding: UnityGuidanceFinding): void {
|
|
152
|
+
findings.push(finding);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function auditUnityGuidanceText(path: string, text: string): UnityGuidanceFinding[] {
|
|
156
|
+
const findings: UnityGuidanceFinding[] = [];
|
|
157
|
+
const lines = text.split(/\r?\n/);
|
|
158
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
159
|
+
const line = lines[index];
|
|
160
|
+
const lower = line.toLowerCase();
|
|
161
|
+
const evidence = normalizeEvidence(line);
|
|
162
|
+
const common = { path, line: index + 1, evidence };
|
|
163
|
+
const fallback = nearbyMentionsFallback(lines, index);
|
|
164
|
+
|
|
165
|
+
if (/-(?:runTests|runtests)\b/.test(line)
|
|
166
|
+
&& /-quit\b/i.test(line)
|
|
167
|
+
&& !isProhibitedMatch(line, /-(?:runTests|runtests)\b/i)
|
|
168
|
+
&& !isProhibitedMatch(line, /-quit\b/i)) {
|
|
169
|
+
addFinding(findings, {
|
|
170
|
+
...common,
|
|
171
|
+
ruleId: "tests.quit-with-run-tests",
|
|
172
|
+
category: "test-artifact-correctness",
|
|
173
|
+
level: "error",
|
|
174
|
+
confidence: "high",
|
|
175
|
+
consequence: "Raw Unity Test Framework runs may exit before producing results.",
|
|
176
|
+
replacementPolicyId: "tests.report-producing-route",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (/\bunity\s+-batchmode\b/i.test(line) && !isProhibitedMatch(line, /\bunity\s+-batchmode\b/i)) {
|
|
181
|
+
addFinding(findings, {
|
|
182
|
+
...common,
|
|
183
|
+
ruleId: "commands.ambiguous-bare-unity",
|
|
184
|
+
category: "command-surface",
|
|
185
|
+
level: "warning",
|
|
186
|
+
confidence: "high",
|
|
187
|
+
consequence: "The standalone Unity CLI uses `unity run <project> -- ...`; bare `unity -batchmode` is ambiguous and non-portable.",
|
|
188
|
+
replacementPolicyId: "fallback.direct-editor-explicit",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (/(unity\.exe|\/unity\.app\/contents\/macos\/unity).*-batchmode/i.test(line) && !fallback && !isProhibitedMatch(line, /(unity\.exe|\/unity\.app\/contents\/macos\/unity).*-batchmode/i)) {
|
|
193
|
+
addFinding(findings, {
|
|
194
|
+
...common,
|
|
195
|
+
ruleId: "commands.direct-batchmode-primary",
|
|
196
|
+
category: "routing-policy",
|
|
197
|
+
level: "warning",
|
|
198
|
+
confidence: "medium",
|
|
199
|
+
consequence: "Direct Editor batchmode is still valid as a fallback, but should not be the unconditional primary route when Unity CLI or a reachable Pipeline Editor is available.",
|
|
200
|
+
replacementPolicyId: "routing.connected-then-isolated",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if ((/use headless unity .*compile|headless unity open\/compile/.test(lower) || (/compile/.test(lower) && /-batchmode/.test(lower))) && !fallback && !isProhibitedMatch(line, /(?:headless unity|compile|-batchmode)/i)) {
|
|
205
|
+
addFinding(findings, {
|
|
206
|
+
...common,
|
|
207
|
+
ruleId: "compile.headless-only",
|
|
208
|
+
category: "compile-workflow",
|
|
209
|
+
level: "warning",
|
|
210
|
+
confidence: "high",
|
|
211
|
+
consequence: "A reachable exact-copy Pipeline Editor can compile through `recompile` without closing the active Editor or launching a second process.",
|
|
212
|
+
replacementPolicyId: "compile.connected-recompile",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (/\bunity\s+command\b/i.test(line) && !/--project-path\b/i.test(line) && !isProhibitedMatch(line, /\bunity\s+command\b/i)) {
|
|
217
|
+
addFinding(findings, {
|
|
218
|
+
...common,
|
|
219
|
+
ruleId: "pipeline.missing-exact-project",
|
|
220
|
+
category: "project-copy-routing",
|
|
221
|
+
level: "error",
|
|
222
|
+
confidence: "high",
|
|
223
|
+
consequence: "Connected commands can target the wrong Editor when multiple project copies are open.",
|
|
224
|
+
replacementPolicyId: "routing.exact-project-path",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (/(?:\bdelete\b|\bremove\b|\bunlink\b|\brm\b|\bdel\b).*(?:temp[\\/]unitylockfile|unitylockfile)/i.test(line) && !isProhibitedMatch(line, /(?:\bdelete\b|\bremove\b|\bunlink\b|\brm\b|\bdel\b)/i)) {
|
|
229
|
+
addFinding(findings, {
|
|
230
|
+
...common,
|
|
231
|
+
ruleId: "lifecycle.unconditional-lockfile-deletion",
|
|
232
|
+
category: "project-lifecycle",
|
|
233
|
+
level: "error",
|
|
234
|
+
confidence: "high",
|
|
235
|
+
consequence: "Deleting a Unity lockfile without verifying the exact project process can permit competing Editors and corrupt project state.",
|
|
236
|
+
replacementPolicyId: "lifecycle.inspect-before-lockfile-action",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const arbitraryPidPattern = /\b(?:taskkill(?:\.exe)?|stop-process|kill(?:\.exe)?|process\.kill)\b/i;
|
|
241
|
+
if (arbitraryPidPattern.test(line)
|
|
242
|
+
&& /(?:\/pid\b|-id\b|\bpid\b|\$[a-z_]*pid\b)/i.test(line)
|
|
243
|
+
&& !/(?:exact|matching|verified).{0,30}project|project.{0,30}(?:exact|matching|verified)/i.test(line)
|
|
244
|
+
&& !isProhibitedMatch(line, arbitraryPidPattern)) {
|
|
245
|
+
addFinding(findings, {
|
|
246
|
+
...common,
|
|
247
|
+
ruleId: "lifecycle.arbitrary-pid-termination",
|
|
248
|
+
category: "project-lifecycle",
|
|
249
|
+
level: "error",
|
|
250
|
+
confidence: "high",
|
|
251
|
+
consequence: "PID-only termination can close an unrelated Unity project copy or another process after PID reuse.",
|
|
252
|
+
replacementPolicyId: "lifecycle.exact-project-process-verification",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const pipelineCommandPattern = /(?:unity\s+command|pipeline\s+command|run\s+(?:recompile|run_tests|build))/i;
|
|
257
|
+
const pipelineAssumptionContext = lines.slice(Math.max(0, index - 1), Math.min(lines.length, index + 2)).join(" ");
|
|
258
|
+
if (pipelineCommandPattern.test(line)
|
|
259
|
+
&& /(?:manifest(?:\.json)?|com\.unity\.pipeline)/i.test(pipelineAssumptionContext)
|
|
260
|
+
&& !/(?:reachable|advertis(?:e|es|ed)|live|capabilit|project_status|editor_status)/i.test(pipelineAssumptionContext)
|
|
261
|
+
&& !isProhibitedMatch(line, pipelineCommandPattern)) {
|
|
262
|
+
addFinding(findings, {
|
|
263
|
+
...common,
|
|
264
|
+
ruleId: "pipeline.manifest-implies-reachability",
|
|
265
|
+
category: "capability-discovery",
|
|
266
|
+
level: "warning",
|
|
267
|
+
confidence: "medium",
|
|
268
|
+
consequence: "A manifest dependency does not prove that the exact project Editor is running, reachable, or advertising a command.",
|
|
269
|
+
replacementPolicyId: "pipeline.live-capability-discovery",
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (/(?:\bunity\s+list\b|\blist_tests\b|\blist\s+(?:all\s+)?(?:pipeline\s+)?commands\b)/i.test(line)
|
|
274
|
+
&& !/(?:filter|limit|bound|specific|narrow|head)/i.test(line)
|
|
275
|
+
&& !isProhibitedMatch(line, /(?:\bunity\s+list\b|\blist_tests\b|\blist\s+(?:all\s+)?(?:pipeline\s+)?commands\b)/i)) {
|
|
276
|
+
addFinding(findings, {
|
|
277
|
+
...common,
|
|
278
|
+
ruleId: "discovery.unbounded-command-or-test-listing",
|
|
279
|
+
category: "context-efficiency",
|
|
280
|
+
level: "info",
|
|
281
|
+
confidence: "medium",
|
|
282
|
+
consequence: "Unbounded command or test discovery can flood agent context in large projects.",
|
|
283
|
+
replacementPolicyId: "discovery.bounded-filtered-output",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (/unity version/i.test(line) && /\b(?:20\d{2}|6000)\.\d+\.\d+[abfp]\d+\b/i.test(line) && !isProhibitedMatch(line, /unity version/i)) {
|
|
288
|
+
addFinding(findings, {
|
|
289
|
+
...common,
|
|
290
|
+
ruleId: "version.hard-coded-project-version",
|
|
291
|
+
category: "version-resolution",
|
|
292
|
+
level: "info",
|
|
293
|
+
confidence: "high",
|
|
294
|
+
consequence: "Project instructions drift when the Editor version changes; resolve ProjectVersion.txt instead.",
|
|
295
|
+
replacementPolicyId: "version.project-version-file",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (/pipeline\s+install/i.test(line) && !isProhibitedMatch(line, /pipeline\s+install/i) && !/explicit|approve|confirm|review|optional/i.test(lines.slice(Math.max(0, index - 2), index + 3).join(" "))) {
|
|
300
|
+
addFinding(findings, {
|
|
301
|
+
...common,
|
|
302
|
+
ruleId: "pipeline.install-undisclosed",
|
|
303
|
+
category: "project-mutation",
|
|
304
|
+
level: "warning",
|
|
305
|
+
confidence: "medium",
|
|
306
|
+
consequence: "Pipeline installation and startup can modify manifest.json, packages-lock.json, and ProjectSettings.asset.",
|
|
307
|
+
replacementPolicyId: "pipeline.explicit-installation",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return findings;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function auditUnityGuidance(options: UnityGuidanceAuditOptions): Promise<UnityGuidanceAuditResult> {
|
|
315
|
+
const maxFiles = options.maxFiles ?? 100;
|
|
316
|
+
const maxBytesPerFile = options.maxBytesPerFile ?? 256 * 1024;
|
|
317
|
+
const maxTotalBytes = options.maxTotalBytes ?? 4 * 1024 * 1024;
|
|
318
|
+
const maxFindings = options.maxFindings ?? 1000;
|
|
319
|
+
const requestedRoot = resolve(options.path);
|
|
320
|
+
const rootStat = await lstat(requestedRoot);
|
|
321
|
+
const root = rootStat.isFile() ? dirname(await realpath(requestedRoot)) : await realpath(requestedRoot);
|
|
322
|
+
const harnesses = new Set(options.harnesses?.length ? options.harnesses : DEFAULT_HARNESSES);
|
|
323
|
+
let candidates: string[];
|
|
324
|
+
if (options.files?.length) {
|
|
325
|
+
candidates = options.files.map((path) => resolve(root, path));
|
|
326
|
+
const outside = candidates.find((path) => !withinRoot(root, path));
|
|
327
|
+
if (outside) throw new Error(`Explicit guidance file is outside the audit root: ${outside}`);
|
|
328
|
+
} else if (rootStat.isFile()) {
|
|
329
|
+
candidates = [requestedRoot];
|
|
330
|
+
} else {
|
|
331
|
+
candidates = await discoverFiles(root, harnesses, Boolean(options.includeAncestors), maxFiles, options.signal);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const ancestorCandidates: UnityGuidanceAuditResult["ancestorCandidates"] = [];
|
|
335
|
+
if (!options.includeAncestors && !options.files?.length && !rootStat.isFile()) {
|
|
336
|
+
const inheritedCandidates = await discoverFiles(root, harnesses, true, maxFiles, options.signal);
|
|
337
|
+
for (const candidate of inheritedCandidates) {
|
|
338
|
+
options.signal?.throwIfAborted();
|
|
339
|
+
if (withinRoot(root, candidate)) continue;
|
|
340
|
+
try {
|
|
341
|
+
const candidateStats = await lstat(candidate);
|
|
342
|
+
if (!candidateStats.isFile() || candidateStats.isSymbolicLink()) continue;
|
|
343
|
+
const canonical = await realpath(candidate);
|
|
344
|
+
const harness = harnessForPath(canonical);
|
|
345
|
+
if (harness && harnesses.has(harness)) ancestorCandidates.push({ path: canonical, harness });
|
|
346
|
+
} catch {
|
|
347
|
+
// Missing known instruction paths are expected during discovery.
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const files: UnityGuidanceAuditResult["files"] = [];
|
|
353
|
+
const findings: UnityGuidanceFinding[] = [];
|
|
354
|
+
const skipped: UnityGuidanceAuditResult["skipped"] = [];
|
|
355
|
+
let totalBytes = 0;
|
|
356
|
+
for (const candidate of candidates.slice(0, maxFiles)) {
|
|
357
|
+
options.signal?.throwIfAborted();
|
|
358
|
+
let canonical: string;
|
|
359
|
+
let stats;
|
|
360
|
+
try {
|
|
361
|
+
const candidateStats = await lstat(candidate);
|
|
362
|
+
if (candidateStats.isSymbolicLink()) {
|
|
363
|
+
skipped.push({ path: candidate, reason: "symbolic links are not followed" });
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
canonical = await realpath(candidate);
|
|
367
|
+
stats = await lstat(canonical);
|
|
368
|
+
} catch {
|
|
369
|
+
if (options.files?.length || rootStat.isFile()) skipped.push({ path: candidate, reason: "not found" });
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (!withinRoot(root, canonical) && !options.includeAncestors) {
|
|
373
|
+
skipped.push({ path: candidate, reason: "outside audit root" });
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const harness = harnessForPath(canonical);
|
|
377
|
+
if (!harness || !harnesses.has(harness)) continue;
|
|
378
|
+
if (!stats.isFile()) continue;
|
|
379
|
+
if (stats.size > maxBytesPerFile) {
|
|
380
|
+
skipped.push({ path: canonical, reason: `file exceeds ${maxBytesPerFile} bytes` });
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (totalBytes + stats.size > maxTotalBytes) {
|
|
384
|
+
skipped.push({ path: canonical, reason: `aggregate audit input exceeds ${maxTotalBytes} bytes` });
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
const text = await readFile(canonical, "utf8");
|
|
388
|
+
totalBytes += stats.size;
|
|
389
|
+
files.push({
|
|
390
|
+
path: canonical,
|
|
391
|
+
harness,
|
|
392
|
+
sha256: createHash("sha256").update(text).digest("hex"),
|
|
393
|
+
bytes: Buffer.byteLength(text, "utf8"),
|
|
394
|
+
});
|
|
395
|
+
const remainingFindings = Math.max(0, maxFindings - findings.length);
|
|
396
|
+
const fileFindings = auditUnityGuidanceText(canonical, text);
|
|
397
|
+
findings.push(...fileFindings.slice(0, remainingFindings));
|
|
398
|
+
if (fileFindings.length > remainingFindings) {
|
|
399
|
+
skipped.push({ path: canonical, reason: `finding limit ${maxFindings} reached` });
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
findings.sort((left, right) => left.path.localeCompare(right.path) || left.line - right.line || left.ruleId.localeCompare(right.ruleId));
|
|
405
|
+
return {
|
|
406
|
+
schemaVersion: 1,
|
|
407
|
+
scope: {
|
|
408
|
+
root,
|
|
409
|
+
profile: options.profile ?? "mixed",
|
|
410
|
+
harnesses: [...harnesses],
|
|
411
|
+
includeAncestors: Boolean(options.includeAncestors),
|
|
412
|
+
},
|
|
413
|
+
files,
|
|
414
|
+
ancestorCandidates,
|
|
415
|
+
findings,
|
|
416
|
+
skipped,
|
|
417
|
+
summary: {
|
|
418
|
+
filesScanned: files.length,
|
|
419
|
+
errors: findings.filter((finding) => finding.level === "error").length,
|
|
420
|
+
warnings: findings.filter((finding) => finding.level === "warning").length,
|
|
421
|
+
infos: findings.filter((finding) => finding.level === "info").length,
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import {
|
|
5
|
+
buildUnityBatchmodeArgs,
|
|
6
|
+
buildUnityEditorCandidates,
|
|
7
|
+
buildUnityOpenEditorArgs,
|
|
8
|
+
normalizeUnityEditorOverride,
|
|
9
|
+
type SupportedPlatform,
|
|
10
|
+
} from "./unity-core";
|
|
11
|
+
import { createUnityCliOpenCommand, type UnityCliLaunchOptions } from "./unity-cli";
|
|
12
|
+
|
|
13
|
+
export async function resolveUnityEditorPath(
|
|
14
|
+
unityVersion: string,
|
|
15
|
+
options: {
|
|
16
|
+
overridePath?: string;
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
platform?: SupportedPlatform;
|
|
19
|
+
homeDir?: string;
|
|
20
|
+
} = {},
|
|
21
|
+
): Promise<string> {
|
|
22
|
+
const platform = options.platform ?? process.platform;
|
|
23
|
+
const env = options.env ?? process.env;
|
|
24
|
+
const homeDir = options.homeDir;
|
|
25
|
+
|
|
26
|
+
const overrideCandidates = [options.overridePath, env.UNITY_EDITOR_PATH, env.UNITY_PATH]
|
|
27
|
+
.map((value) => value?.trim())
|
|
28
|
+
.filter((value): value is string => Boolean(value))
|
|
29
|
+
.map((value) => normalizeUnityEditorOverride(value, platform));
|
|
30
|
+
|
|
31
|
+
const autoCandidates = buildUnityEditorCandidates(unityVersion, platform, homeDir);
|
|
32
|
+
|
|
33
|
+
for (const candidate of [...overrideCandidates, ...autoCandidates]) {
|
|
34
|
+
try {
|
|
35
|
+
await fs.access(candidate, fs.constants.X_OK);
|
|
36
|
+
return path.normalize(candidate);
|
|
37
|
+
} catch {
|
|
38
|
+
// Try next candidate.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw new Error(
|
|
43
|
+
[
|
|
44
|
+
`Could not find a Unity Editor executable for Unity ${unityVersion}.`,
|
|
45
|
+
"Set UNITY_EDITOR_PATH to an explicit executable path or pass unityEditorPath.",
|
|
46
|
+
].join(" "),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function launchUnityEditorDetached(editorPath: string, projectRoot: string): { pid: number | undefined; args: string[]; command: string } {
|
|
51
|
+
const args = buildUnityOpenEditorArgs(projectRoot);
|
|
52
|
+
const child = spawn(editorPath, args, {
|
|
53
|
+
detached: true,
|
|
54
|
+
stdio: "ignore",
|
|
55
|
+
windowsHide: false,
|
|
56
|
+
});
|
|
57
|
+
child.unref();
|
|
58
|
+
return { pid: child.pid, args, command: editorPath };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function launchUnityCliOpenDetached(
|
|
62
|
+
projectRoot: string,
|
|
63
|
+
options: UnityCliLaunchOptions = {},
|
|
64
|
+
): { pid: number | undefined; args: string[]; command: string } {
|
|
65
|
+
const cli = createUnityCliOpenCommand(projectRoot, options);
|
|
66
|
+
const child = spawn(cli.command, cli.args, {
|
|
67
|
+
detached: true,
|
|
68
|
+
stdio: "ignore",
|
|
69
|
+
windowsHide: false,
|
|
70
|
+
});
|
|
71
|
+
child.unref();
|
|
72
|
+
return { pid: child.pid, args: cli.args, command: cli.command };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createUnityBatchmodeCommand(
|
|
76
|
+
editorPath: string,
|
|
77
|
+
projectRoot: string,
|
|
78
|
+
extraArgs: string[] = [],
|
|
79
|
+
options: { useGraphics?: boolean } = {},
|
|
80
|
+
): { command: string; args: string[] } {
|
|
81
|
+
return {
|
|
82
|
+
command: editorPath,
|
|
83
|
+
args: buildUnityBatchmodeArgs(projectRoot, extraArgs, options),
|
|
84
|
+
};
|
|
85
|
+
}
|