@aeonsparx/pi-unreal 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +202 -2
- package/docs/internals.md +184 -0
- package/extensions/unreal/index.ts +682 -0
- package/extensions/unreal/lib/cache.ts +190 -0
- package/extensions/unreal/lib/compiledb.ts +491 -0
- package/extensions/unreal/lib/descriptors.ts +631 -0
- package/extensions/unreal/lib/engine-resolver.ts +443 -0
- package/extensions/unreal/lib/pi-tools.ts +402 -0
- package/extensions/unreal/lib/plugin-resolver.ts +305 -0
- package/extensions/unreal/lib/query.ts +272 -0
- package/extensions/unreal/lib/registry.ts +208 -0
- package/extensions/unreal/lib/scan.ts +510 -0
- package/extensions/unreal/lib/store.ts +900 -0
- package/extensions/unreal/lib/tools.ts +614 -0
- package/extensions/unreal/lib/ubt.ts +373 -0
- package/extensions/unreal/lib/util.ts +210 -0
- package/package.json +59 -4
- package/prompts/unreal-context.md +26 -0
- package/skills/ue-engine-resolution/SKILL.md +96 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UnrealBuildTool as the source of build facts, in a shell, with no IDE and no compiler.
|
|
3
|
+
*
|
|
4
|
+
* UE 5.8 ships `Engine/Source/Programs/UnrealBuildTool/Modes/QueryMode.cs`, which answers questions
|
|
5
|
+
* an agent keeps re-deriving by grepping:
|
|
6
|
+
*
|
|
7
|
+
* TargetDetails dirToModule (2,159 directories on a Lp/5.8 install) + per-module
|
|
8
|
+
* includePaths / defines / standard / forcedIncludes / compilerPath /
|
|
9
|
+
* compilerArgs / compilerAdditionalArgs / windowsSdkVersion (1,079 modules)
|
|
10
|
+
* TargetModuleReferences per-module public / private / publicInclude / privateInclude /
|
|
11
|
+
* dynamicallyLoaded / circular dependency lists
|
|
12
|
+
*
|
|
13
|
+
* Measured on this box (Lp, LpEditor/Win64/Development): TargetDetails 6.0 s (40.4 MB of JSON),
|
|
14
|
+
* TargetModuleReferences 2.1 s (994 KB). They run in UBT's own `UnrealIntermediateEnvironment.Query`
|
|
15
|
+
* environment, so they do not collide with a normal build's makefiles.
|
|
16
|
+
*
|
|
17
|
+
* Two measured traps:
|
|
18
|
+
* - `-NoIntellisense` makes TargetDetails return `dirToModule: null` and
|
|
19
|
+
* `moduleToCompileSettings: null` — the intellisense pass is what computes per-module compile
|
|
20
|
+
* settings, so the 2.6 s variant is a fast empty file. Never pass it here.
|
|
21
|
+
* - `defines` is `[]` for every module in TargetDetails. The real defines live in the
|
|
22
|
+
* `Definitions*.h` listed under `forcedIncludes` (e.g.
|
|
23
|
+
* `Engine/Intermediate/Build/Win64/x64/UnrealEditor/Development/Engine/Definitions.h`), so
|
|
24
|
+
* `unreal_context` reads that file instead of trusting the JSON field.
|
|
25
|
+
*
|
|
26
|
+
* Why not the IDE databases a UE developer already has: Visual Studio stores IntelliSense facts in
|
|
27
|
+
* `Browse.VC.db`, which is SQLCipher-encrypted with no public API, and Rider resolves symbols
|
|
28
|
+
* in-process over RiderLink with no CLI. Both derive from these same UBT target rules, so reading UBT
|
|
29
|
+
* is the compiler-agnostic route — an MSVC-only box gets the facts a clang database would give,
|
|
30
|
+
* including the real `cl.exe` path and `/std:` flag.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { spawn } from "node:child_process";
|
|
34
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
35
|
+
import { tmpdir } from "node:os";
|
|
36
|
+
import { joinPath, normalizePath, realFs, shortHash, type FsAdapter } from "./util.ts";
|
|
37
|
+
|
|
38
|
+
export interface UbtModuleSettings {
|
|
39
|
+
includePaths: string[];
|
|
40
|
+
/** Always empty in TargetDetails; the real defines are in `definesFile`. */
|
|
41
|
+
defines: string[];
|
|
42
|
+
standard: string;
|
|
43
|
+
forcedIncludes: string[];
|
|
44
|
+
compilerPath: string;
|
|
45
|
+
compilerArgs: string[];
|
|
46
|
+
compilerAdditionalArgs: { c: string[]; cpp: string[] };
|
|
47
|
+
windowsSdkVersion: string;
|
|
48
|
+
/** The `Definitions*.h` forced include that actually carries the module's defines. */
|
|
49
|
+
definesFile: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UbtModuleRefs {
|
|
53
|
+
shortName: string;
|
|
54
|
+
moduleDirectory: string;
|
|
55
|
+
intermediateDirectory: string;
|
|
56
|
+
public: string[];
|
|
57
|
+
private: string[];
|
|
58
|
+
publicInclude: string[];
|
|
59
|
+
privateInclude: string[];
|
|
60
|
+
dynamicallyLoaded: string[];
|
|
61
|
+
circular: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface UbtTargetIdentity {
|
|
65
|
+
target: string;
|
|
66
|
+
platform: string;
|
|
67
|
+
configuration: string;
|
|
68
|
+
projectPath?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface TargetRef {
|
|
72
|
+
name: string;
|
|
73
|
+
kind: "project" | "engine";
|
|
74
|
+
path: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface UbtBinary {
|
|
78
|
+
path: string;
|
|
79
|
+
kind: "exe" | "bat" | "sh";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type QueryName = "TargetDetails" | "TargetModuleReferences";
|
|
83
|
+
|
|
84
|
+
export interface QueryOptions extends UbtTargetIdentity {
|
|
85
|
+
query: QueryName;
|
|
86
|
+
engineRoot: string;
|
|
87
|
+
outputPath?: string;
|
|
88
|
+
timeoutMs?: number;
|
|
89
|
+
fs?: FsAdapter;
|
|
90
|
+
spawnImpl?: typeof spawn;
|
|
91
|
+
onStdout?: (chunk: string) => void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface QueryResult {
|
|
95
|
+
json: unknown;
|
|
96
|
+
/** Where the JSON came from (UBT's own `Writing <path>` line wins over the requested path). */
|
|
97
|
+
outputPath: string;
|
|
98
|
+
stdout: string;
|
|
99
|
+
exitCode: number;
|
|
100
|
+
durationMs: number;
|
|
101
|
+
ubtPath: string;
|
|
102
|
+
args: string[];
|
|
103
|
+
notes: string[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface TargetDetails {
|
|
107
|
+
dirModules: { prefix: string; module: string }[];
|
|
108
|
+
settings: Map<string, UbtModuleSettings>;
|
|
109
|
+
launch: { description?: string; binaryPath?: string; arguments: string[] };
|
|
110
|
+
isTestTarget: boolean;
|
|
111
|
+
/** Present when `-NoIntellisense` was used, or the query ran with no intellisense pass. */
|
|
112
|
+
notes: string[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
116
|
+
|
|
117
|
+
/* ──────────────────────────────────────────────────────────────────────────── discovery */
|
|
118
|
+
|
|
119
|
+
export function hostPlatform(platform = process.platform): string {
|
|
120
|
+
if (platform === "win32") return "Win64";
|
|
121
|
+
if (platform === "darwin") return "Mac";
|
|
122
|
+
if (platform === "linux" || platform.startsWith("freebsd")) return "Linux";
|
|
123
|
+
return "Win64";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Locate a runnable UBT. Installed builds ship the precompiled .exe; source builds ship the batch
|
|
128
|
+
* scripts (which compile UBT itself on first use, so they are the fallback, not the default).
|
|
129
|
+
*/
|
|
130
|
+
export function locateUbt(engineRoot: string, fs: FsAdapter = realFs): UbtBinary | undefined {
|
|
131
|
+
const root = normalizePath(engineRoot);
|
|
132
|
+
const candidates: UbtBinary[] = [
|
|
133
|
+
{ path: joinPath(root, "Engine/Binaries/DotNET/UnrealBuildTool/UnrealBuildTool.exe"), kind: "exe" },
|
|
134
|
+
{ path: joinPath(root, "Engine/Build/BatchFiles/Build.bat"), kind: "bat" },
|
|
135
|
+
{ path: joinPath(root, "Engine/Build/BatchFiles/Build.sh"), kind: "sh" },
|
|
136
|
+
];
|
|
137
|
+
return candidates.find((candidate) => fs.exists(candidate.path));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Targets available near a project: `*.Target.cs` under the project's `Source/` (project targets,
|
|
142
|
+
* e.g. `LpEditor`) and under `Engine/Source/Programs/*` (engine targets, e.g. `UnrealEditor`). The
|
|
143
|
+
* file name minus `.Target.cs` is the target name, which is UBT's own rule.
|
|
144
|
+
*/
|
|
145
|
+
/**
|
|
146
|
+
* Target inventory.
|
|
147
|
+
*
|
|
148
|
+
* `projectDepth` controls how deep the project scan goes: UE layouts put `*.Target.cs` next to
|
|
149
|
+
* `Source/<Module>/`, one level under `Source`, so depth 1 is the norm. Engine `Programs/*`
|
|
150
|
+
* targets are always one level deep.
|
|
151
|
+
*/
|
|
152
|
+
export function discoverTargets(input: { projectDir?: string; engineRoot?: string; projectDepth?: number; fs?: FsAdapter }): TargetRef[] {
|
|
153
|
+
const fs = input.fs ?? realFs;
|
|
154
|
+
const out: TargetRef[] = [];
|
|
155
|
+
const skip = new Set(["Intermediate", "Binaries", "Saved", "ThirdParty"]);
|
|
156
|
+
const push = (dir: string, kind: TargetRef["kind"], maxDepth: number) => {
|
|
157
|
+
if (!dir) return;
|
|
158
|
+
// Depth is tracked per directory: a single constant depth lets the walk reach leaves and
|
|
159
|
+
// report nothing, which is how a project's `Source/<Target>/<Target>.Target.cs` gets missed.
|
|
160
|
+
const queue: { dir: string; depth: number }[] = [{ dir, depth: 0 }];
|
|
161
|
+
while (queue.length) {
|
|
162
|
+
const current = queue.shift()!;
|
|
163
|
+
for (const name of fs.listDir(current.dir)) {
|
|
164
|
+
const abs = normalizePath(joinPath(current.dir, name));
|
|
165
|
+
if (fs.isDirectory(abs)) {
|
|
166
|
+
if (current.depth < maxDepth && !skip.has(name)) queue.push({ dir: abs, depth: current.depth + 1 });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const match = /^(.+)\.target\.cs$/i.exec(name);
|
|
170
|
+
const targetName = match?.[1];
|
|
171
|
+
if (targetName) out.push({ name: targetName, kind, path: abs });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
if (input.projectDir) push(joinPath(normalizePath(input.projectDir), "Source"), "project", input.projectDepth ?? 1);
|
|
176
|
+
if (input.engineRoot) push(joinPath(normalizePath(input.engineRoot), "Engine/Source/Programs"), "engine", 1);
|
|
177
|
+
return out.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "project" ? -1 : 1));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Prefer the project's editor target (it compiles the editor modules an agent works on). */
|
|
181
|
+
export function pickDefaultTarget(targets: TargetRef[], projectName?: string): TargetRef | undefined {
|
|
182
|
+
const byName = new Map(targets.map((target) => [target.name.toLowerCase(), target]));
|
|
183
|
+
for (const name of projectName ? [`${projectName.toLowerCase()}editor`, projectName.toLowerCase()] : []) {
|
|
184
|
+
const found = byName.get(name);
|
|
185
|
+
if (found) return found;
|
|
186
|
+
}
|
|
187
|
+
return byName.get("unrealeditor") ?? targets.find((target) => target.kind === "project");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** `-Mode=Query` argument list, in the order a human would type it. */
|
|
191
|
+
export function buildQueryArgs(input: QueryOptions & { outputPath: string }): string[] {
|
|
192
|
+
const args = ["-Mode=Query", `-Query=${input.query}`, `-Target=${input.target}`, `-Platform=${input.platform}`, `-Configuration=${input.configuration}`];
|
|
193
|
+
if (input.projectPath) args.push(`-Project=${input.projectPath}`);
|
|
194
|
+
args.push(`-OutputPath=${input.outputPath}`);
|
|
195
|
+
// Deliberately no -NoIntellisense: it nulls the payload (see the module header).
|
|
196
|
+
return args;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Stable output path per (query, target identity), so a rerun overwrites instead of accumulating. */
|
|
200
|
+
export function queryOutputPath(query: QueryName, identity: UbtTargetIdentity, base = tmpdir()): string {
|
|
201
|
+
const key = shortHash(`${identity.target}|${identity.platform}|${identity.configuration}|${identity.projectPath ?? "engine"}`);
|
|
202
|
+
const dir = normalizePath(joinPath(base, "pi-unreal-ubt"));
|
|
203
|
+
try {
|
|
204
|
+
mkdirSync(dir, { recursive: true });
|
|
205
|
+
} catch {
|
|
206
|
+
/* best-effort: a failure surfaces as the missing-output note in runQuery */
|
|
207
|
+
}
|
|
208
|
+
return joinPath(dir, `${query}-${key}.json`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ──────────────────────────────────────────────────────────────────────────── run */
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Run one UBT query and parse the JSON it writes. Failure is data, not an exception: `json` stays
|
|
215
|
+
* `undefined` and `notes` carries the exit code plus UBT's last output lines, so the caller can keep
|
|
216
|
+
* going with a heuristic index and report exactly why.
|
|
217
|
+
*/
|
|
218
|
+
export async function runQuery(input: QueryOptions): Promise<QueryResult> {
|
|
219
|
+
const started = Date.now();
|
|
220
|
+
const notes: string[] = [];
|
|
221
|
+
const binary = locateUbt(input.engineRoot, input.fs ?? realFs);
|
|
222
|
+
if (!binary) {
|
|
223
|
+
return {
|
|
224
|
+
json: undefined,
|
|
225
|
+
outputPath: "",
|
|
226
|
+
stdout: "",
|
|
227
|
+
exitCode: -1,
|
|
228
|
+
durationMs: 0,
|
|
229
|
+
ubtPath: "",
|
|
230
|
+
args: [],
|
|
231
|
+
notes: [`no UnrealBuildTool under ${normalizePath(input.engineRoot)} (expected Engine/Binaries/DotNET/UnrealBuildTool/UnrealBuildTool.exe, or Engine/Build/BatchFiles/Build.bat|.sh)`],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const outputPath = input.outputPath ?? queryOutputPath(input.query, input);
|
|
236
|
+
const args = buildQueryArgs({ ...input, outputPath });
|
|
237
|
+
const spawnImpl = input.spawnImpl ?? spawn;
|
|
238
|
+
// The .exe is spawned without a shell, so paths containing spaces ("Program Files") need no quoting.
|
|
239
|
+
const child =
|
|
240
|
+
binary.kind === "exe"
|
|
241
|
+
? spawnImpl(binary.path, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] })
|
|
242
|
+
: spawnImpl(`"${binary.path}"`, args.map(shellQuote), { windowsHide: true, shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
243
|
+
|
|
244
|
+
const { stdout, exitCode } = await new Promise<{ stdout: string; exitCode: number }>((resolve) => {
|
|
245
|
+
let text = "";
|
|
246
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
247
|
+
const slice = chunk.toString("utf8");
|
|
248
|
+
text += slice;
|
|
249
|
+
input.onStdout?.(slice);
|
|
250
|
+
});
|
|
251
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
252
|
+
text += chunk.toString("utf8");
|
|
253
|
+
});
|
|
254
|
+
const timer = setTimeout(() => child.kill(), input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
255
|
+
child.on("error", (error) => {
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
resolve({ stdout: `${text}\nspawn error: ${error.message}`, exitCode: -1 });
|
|
258
|
+
});
|
|
259
|
+
child.on("close", (code) => {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
resolve({ stdout: text, exitCode: code ?? -1 });
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// UBT prints `Writing <path>...` for the file it produced; that is the authoritative location.
|
|
266
|
+
const written = writtenPathFrom(stdout) ?? normalizePath(outputPath);
|
|
267
|
+
let json: unknown;
|
|
268
|
+
try {
|
|
269
|
+
json = JSON.parse(readFileSync(written, "utf8"));
|
|
270
|
+
} catch (error) {
|
|
271
|
+
notes.push(`no parseable JSON at ${written}: ${(error as Error).message.split("\n")[0]}`);
|
|
272
|
+
if (exitCode !== 0) notes.push(`UnrealBuildTool exit code ${exitCode}`);
|
|
273
|
+
const tail = stdout.trimEnd().split(/\r?\n/).slice(-10).join("\n ");
|
|
274
|
+
if (tail) notes.push(`ubt output:\n ${tail}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { json, outputPath: written, stdout, exitCode: json === undefined ? -1 : 0, durationMs: Date.now() - started, ubtPath: binary.path, args, notes };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const shellQuote = (arg: string) => (/^[A-Za-z0-9:./=_,+-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '\\"')}"`);
|
|
281
|
+
|
|
282
|
+
/** UBT prints `Writing <absolute path>...` for each query output file. */
|
|
283
|
+
export function writtenPathFrom(stdout: string): string | undefined {
|
|
284
|
+
const lines = stdout.split(/\r?\n/);
|
|
285
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
286
|
+
const written = /^Writing\s+(.+?)\.{3}\s*$/.exec(lines[i]?.trim() ?? "");
|
|
287
|
+
const writtenPath = written?.[1];
|
|
288
|
+
if (writtenPath) return normalizePath(writtenPath);
|
|
289
|
+
}
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ──────────────────────────────────────────────────────────────────────────── parse */
|
|
294
|
+
|
|
295
|
+
const asArray = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === "string").map(normalizePath) : []);
|
|
296
|
+
const asStrings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []);
|
|
297
|
+
const isDefinitionsHeader = (path: string) => /\/definitions(\.[^/]*)?\.h$/i.test(path);
|
|
298
|
+
|
|
299
|
+
export function parseTargetDetails(json: unknown): TargetDetails {
|
|
300
|
+
const root = (json ?? {}) as Record<string, unknown>;
|
|
301
|
+
const dirToModule = (root.dirToModule ?? {}) as Record<string, string>;
|
|
302
|
+
const settingsIn = (root.moduleToCompileSettings ?? {}) as Record<string, Record<string, unknown>>;
|
|
303
|
+
const notes: string[] = [];
|
|
304
|
+
if (root.dirToModule === null) notes.push("dirToModule is null — this Query JSON was produced with -NoIntellisense, which disables the pass that computes it");
|
|
305
|
+
if (root.moduleToCompileSettings === null) notes.push("moduleToCompileSettings is null — this Query JSON was produced with -NoIntellisense");
|
|
306
|
+
|
|
307
|
+
const dirModules: { prefix: string; module: string }[] = [];
|
|
308
|
+
for (const [dir, module] of Object.entries(dirToModule)) {
|
|
309
|
+
if (typeof module !== "string" || !dir) continue;
|
|
310
|
+
dirModules.push({ prefix: normalizePath(dir), module });
|
|
311
|
+
}
|
|
312
|
+
dirModules.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
313
|
+
|
|
314
|
+
const settings = new Map<string, UbtModuleSettings>();
|
|
315
|
+
for (const [module, value] of Object.entries(settingsIn)) {
|
|
316
|
+
const forcedIncludes = asArray(value.forcedIncludes);
|
|
317
|
+
settings.set(module, {
|
|
318
|
+
includePaths: asArray(value.includePaths),
|
|
319
|
+
defines: asStrings(value.defines),
|
|
320
|
+
standard: typeof value.standard === "string" ? value.standard : "",
|
|
321
|
+
forcedIncludes,
|
|
322
|
+
compilerPath: typeof value.compilerPath === "string" ? normalizePath(value.compilerPath) : "",
|
|
323
|
+
compilerArgs: asStrings(value.compilerArgs),
|
|
324
|
+
compilerAdditionalArgs: {
|
|
325
|
+
c: asStrings((value.compilerAdditionalArgs as Record<string, unknown> | undefined)?.c),
|
|
326
|
+
cpp: asStrings((value.compilerAdditionalArgs as Record<string, unknown> | undefined)?.cpp),
|
|
327
|
+
},
|
|
328
|
+
windowsSdkVersion: typeof value.windowsSdkVersion === "string" ? value.windowsSdkVersion : "",
|
|
329
|
+
definesFile: forcedIncludes.find(isDefinitionsHeader) ?? "",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const launch = (root.launchSettings ?? {}) as Record<string, unknown>;
|
|
334
|
+
return {
|
|
335
|
+
dirModules,
|
|
336
|
+
settings,
|
|
337
|
+
launch: { description: typeof launch.description === "string" ? launch.description : undefined, binaryPath: typeof launch.binaryPath === "string" ? normalizePath(launch.binaryPath) : undefined, arguments: asArray(launch.arguments) },
|
|
338
|
+
isTestTarget: root.isTestTarget === true,
|
|
339
|
+
notes,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function parseModuleReferences(json: unknown): Map<string, UbtModuleRefs> {
|
|
344
|
+
const modules = ((json as Record<string, unknown> | undefined)?.modules ?? {}) as Record<string, Record<string, unknown>>;
|
|
345
|
+
const out = new Map<string, UbtModuleRefs>();
|
|
346
|
+
for (const [name, value] of Object.entries(modules)) {
|
|
347
|
+
out.set(name, {
|
|
348
|
+
shortName: typeof value.shortName === "string" ? value.shortName : "",
|
|
349
|
+
moduleDirectory: normalizePath((value.moduleDirectory as string) ?? ""),
|
|
350
|
+
intermediateDirectory: normalizePath((value.intermediateDirectory as string) ?? ""),
|
|
351
|
+
public: asStrings(value.public),
|
|
352
|
+
private: asStrings(value.private),
|
|
353
|
+
publicInclude: asStrings(value.publicInclude),
|
|
354
|
+
privateInclude: asStrings(value.privateInclude),
|
|
355
|
+
dynamicallyLoaded: asStrings(value.dynamicallyLoaded),
|
|
356
|
+
circular: asStrings(value.circular),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Read a Query JSON file already on disk (fixture tests, and `unreal_context` without rebuilding). */
|
|
363
|
+
export function readQueryJson(path: string): unknown {
|
|
364
|
+
return JSON.parse(readFileSync(normalizePath(path), "utf8"));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function hasQueryJson(path: string): boolean {
|
|
368
|
+
try {
|
|
369
|
+
return existsSync(normalizePath(path));
|
|
370
|
+
} catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small filesystem / text helpers shared by every lib module.
|
|
3
|
+
*
|
|
4
|
+
* All FS access goes through `FsAdapter` so the resolver, descriptor parsers, and plugin
|
|
5
|
+
* resolver run against synthetic fixture trees in tests without touching the real disk.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
10
|
+
import { parse as parseJsonc } from "jsonc-parser";
|
|
11
|
+
|
|
12
|
+
/** Filesystem surface used by this extension. Everything is read-only by construction. */
|
|
13
|
+
export interface FsAdapter {
|
|
14
|
+
exists(path: string): boolean;
|
|
15
|
+
isDirectory(path: string): boolean;
|
|
16
|
+
readText(path: string): string | undefined;
|
|
17
|
+
listDir(path: string): string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
21
|
+
|
|
22
|
+
/** Replace `\\` with `/`, drop `\\?\` and `\\.\` prefixes, collapse repeats, drop trailing `/`. */
|
|
23
|
+
export function normalizePath(path: string): string {
|
|
24
|
+
let out = path.trim().replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
|
25
|
+
out = out.replace(/^\/\/[?.]\//, "/");
|
|
26
|
+
while (out.length > 1 && out.endsWith("/")) out = out.slice(0, -1);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Absolute on this platform (Windows drive letter, UNC, POSIX `/`). */
|
|
31
|
+
export function isAbsolutePath(path: string): boolean {
|
|
32
|
+
return /^[a-z]:\//i.test(path) || path.startsWith("//") || path.startsWith("/");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function joinPath(base: string, ...parts: string[]): string {
|
|
36
|
+
let out = normalizePath(base);
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
if (!part) continue;
|
|
39
|
+
if (isAbsolutePath(part)) {
|
|
40
|
+
out = normalizePath(part);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
out = normalizePath(`${out}/${part.replace(/^\/+/, "")}`);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parentDir(path: string): string | undefined {
|
|
49
|
+
const normalized = normalizePath(path);
|
|
50
|
+
const idx = normalized.lastIndexOf("/");
|
|
51
|
+
if (idx <= 0) return undefined;
|
|
52
|
+
return normalized.slice(0, idx);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function baseName(path: string): string {
|
|
56
|
+
const normalized = normalizePath(path);
|
|
57
|
+
const idx = normalized.lastIndexOf("/");
|
|
58
|
+
return idx < 0 ? normalized : normalized.slice(idx + 1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function extName(path: string): string {
|
|
62
|
+
const name = baseName(path);
|
|
63
|
+
const idx = name.lastIndexOf(".");
|
|
64
|
+
return idx <= 0 ? "" : name.slice(idx).toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function stripBom(text: string): string {
|
|
68
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Default adapter: real filesystem, read-only operations only. */
|
|
72
|
+
export const realFs: FsAdapter = {
|
|
73
|
+
exists(path) {
|
|
74
|
+
try {
|
|
75
|
+
return existsSync(path);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
isDirectory(path) {
|
|
81
|
+
try {
|
|
82
|
+
return statSync(path).isDirectory();
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
readText(path) {
|
|
88
|
+
try {
|
|
89
|
+
return readFileSync(path, "utf8");
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
listDir(path) {
|
|
95
|
+
try {
|
|
96
|
+
return readdirSync(path);
|
|
97
|
+
} catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export function readFileSafe(path: string, fs: FsAdapter = realFs): string | undefined {
|
|
104
|
+
if (!fs.exists(path) || fs.isDirectory(path)) return undefined;
|
|
105
|
+
return fs.readText(path);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `.uproject` and `.uplugin` files are hand-edited in practice: UTF-8 BOM, trailing commas,
|
|
110
|
+
* and `//` comments all appear in the wild, so strict `JSON.parse` is the wrong tool.
|
|
111
|
+
*/
|
|
112
|
+
export interface JsonParseResult<T> {
|
|
113
|
+
value?: T;
|
|
114
|
+
/** Human-readable jsonc diagnostics, e.g. `UnexpectedEndOfComment`. */
|
|
115
|
+
errors: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `jsonc-parser.ParseErrorCode` is a const enum, which `verbatimModuleSyntax` forbids accessing,
|
|
120
|
+
* so the codes are transcribed from jsonc-parser's `syntax-tree.d.ts`.
|
|
121
|
+
*/
|
|
122
|
+
const PARSE_ERROR_NAMES = new Map<number, string>([
|
|
123
|
+
[0, "None"],
|
|
124
|
+
[1, "Empty"],
|
|
125
|
+
[2, "UnexpectedEndOfComment"],
|
|
126
|
+
[3, "UnexpectedEndOfString"],
|
|
127
|
+
[4, "UnexpectedEndOfNumber"],
|
|
128
|
+
[5, "InvalidUnicode"],
|
|
129
|
+
[6, "InvalidEscapeCharacter"],
|
|
130
|
+
[7, "InvalidCharacter"],
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
export function parseJsoncLenient<T = JsonValue>(text: string): JsonParseResult<T> {
|
|
134
|
+
const errors: ParseErrorLike[] = [];
|
|
135
|
+
let value: T | undefined;
|
|
136
|
+
/* `jsonc-parser`'s `parse` THROWS on malformed input (verified against real engine `.uplugin`
|
|
137
|
+
files, which include malformed numbers and unquoted keys). Descriptor parsing must degrade
|
|
138
|
+
to a diagnostic, never crash an inspection tool. */
|
|
139
|
+
try {
|
|
140
|
+
value = parseJsonc(stripBom(text), errors as never, {
|
|
141
|
+
disallowComments: false,
|
|
142
|
+
allowTrailingComma: true,
|
|
143
|
+
}) as T;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return {
|
|
146
|
+
value: undefined as T,
|
|
147
|
+
errors: [`parse threw: ${error instanceof Error ? error.message : String(error)}`],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
value: value as T,
|
|
152
|
+
errors: errors.map((code) => PARSE_ERROR_NAMES.get(code) ?? `ParseErrorCode(${code})`),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type ParseErrorLike = number;
|
|
157
|
+
|
|
158
|
+
/** Walk up from `start`, calling `visit(dir)` for each ancestor including `start`. Stop when visit returns true. */
|
|
159
|
+
export function walkUp(start: string, visit: (dir: string, depth: number) => boolean | void, maxDepth = 12): void {
|
|
160
|
+
let current = normalizePath(start);
|
|
161
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
162
|
+
if (visit(current, depth) === true) return;
|
|
163
|
+
const next = parentDir(current);
|
|
164
|
+
if (!next || next === current) return;
|
|
165
|
+
current = next;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Ancestor list from `start` upward, `start` first. */
|
|
170
|
+
export function ancestors(start: string, maxDepth = 12): string[] {
|
|
171
|
+
const out: string[] = [];
|
|
172
|
+
walkUp(start, (dir) => {
|
|
173
|
+
out.push(dir);
|
|
174
|
+
});
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function truncateLines(text: string, maxLines: number): { text: string; omitted: number } {
|
|
179
|
+
const lines = text.split("\n");
|
|
180
|
+
if (lines.length <= maxLines) return { text, omitted: 0 };
|
|
181
|
+
return { text: lines.slice(0, maxLines).join("\n"), omitted: lines.length - maxLines };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Deterministic short id for cache keys and candidate ids. */
|
|
185
|
+
export function shortHash(input: string): string {
|
|
186
|
+
return createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function uniqueBy<T>(items: T[], key: (item: T) => string): T[] {
|
|
190
|
+
const seen = new Set<string>();
|
|
191
|
+
const out: T[] = [];
|
|
192
|
+
for (const item of items) {
|
|
193
|
+
const k = key(item);
|
|
194
|
+
if (seen.has(k)) continue;
|
|
195
|
+
seen.add(k);
|
|
196
|
+
out.push(item);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** `5.7` < `5.7.4` < `5.8`; missing sorts lowest. */
|
|
202
|
+
export function compareVersionish(a: string | undefined, b: string | undefined): number {
|
|
203
|
+
const pa = (a ?? "0").split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
204
|
+
const pb = (b ?? "0").split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
205
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
206
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
207
|
+
if (diff !== 0) return diff;
|
|
208
|
+
}
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aeonsparx/pi-unreal",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Read-only Pi extension that resolves Unreal Engine associations and project/plugin descriptors from engine-authored schemas instead of guesswork.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Aeonsparx-Interactive/pi-unreal.git"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22.14.0"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"extensions/unreal",
|
|
19
|
+
"skills",
|
|
20
|
+
"prompts",
|
|
21
|
+
"docs",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"pi-package",
|
|
27
|
+
"unreal-engine",
|
|
28
|
+
"pi-extension",
|
|
29
|
+
"uproject",
|
|
30
|
+
"uplugin"
|
|
31
|
+
],
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./extensions/unreal/index.ts"
|
|
35
|
+
],
|
|
36
|
+
"skills": [
|
|
37
|
+
"./skills"
|
|
38
|
+
],
|
|
39
|
+
"prompts": [
|
|
40
|
+
"./prompts"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"fast-glob": "^3.3.3",
|
|
45
|
+
"jsonc-parser": "^3.3.1"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@earendil-works/pi-coding-agent": "^1.4.0",
|
|
49
|
+
"@types/bun": "^1.4.3",
|
|
50
|
+
"@types/node": "^24",
|
|
51
|
+
"typescript": "^7.0.2"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
55
|
+
"typebox": "*"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"test": "bun test",
|
|
59
|
+
"typecheck": "tsc --noEmit"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Ground the task in verified Unreal Engine facts before touching anything
|
|
3
|
+
argument-hint: "[project path] [task]"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Task: ${@:-inspect the project}
|
|
7
|
+
|
|
8
|
+
Before any edit, build, or engine-version claim, establish the facts with the Unreal tools
|
|
9
|
+
(do not infer from directory names, `UNREAL_ENGINE`, or memory):
|
|
10
|
+
|
|
11
|
+
1. `unreal_resolve_engine` on ${1:-the project in this workspace} — engine root, `Build.version`
|
|
12
|
+
and its path, installed-vs-source, association kind. If the status is `ambiguous` or
|
|
13
|
+
`unresolved`, report every candidate and stop; do not pick a root silently.
|
|
14
|
+
2. `unreal_project_inspect` — modules with `Type`/`LoadingPhase`, plugin references and where
|
|
15
|
+
each one resolves (Engine / Project / Mod / External), JSONC diagnostics, `TargetPlatforms`,
|
|
16
|
+
`AdditionalPluginDirectories`.
|
|
17
|
+
3. For a plugin-centric task, `unreal_plugin_inspect` on the plugin name or `.uplugin` path.
|
|
18
|
+
|
|
19
|
+
Then state the constraints that follow from those facts:
|
|
20
|
+
|
|
21
|
+
- the editor must be closed for C++ edits, and the build/verify step is
|
|
22
|
+
`UnrealEngine-BuildProjectEditor`;
|
|
23
|
+
- `.uasset` / `.umap` are authored in the editor, never hand-edited;
|
|
24
|
+
- `clang-format` must never touch `*.cs` (UBT C#); C++ is formatted with `Script/Dev/Format-Cpp.ps1`.
|
|
25
|
+
|
|
26
|
+
${2:-Proceed with the task.}
|