@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
|
@@ -67,4 +67,4 @@ Normally call the typed tools, not raw CLI commands. If a typed tool is unavaila
|
|
|
67
67
|
|
|
68
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.
|
|
69
69
|
|
|
70
|
-
Use the typed compile/test tools when their polling and terminal evidence fit the task. For an explicitly requested existing C# builder file, `unity_pipeline_run_script` uses Pipeline 0.6 ephemeral in-memory compilation with bounded JSON arguments or compile-only dry run; it never enables hotpatch and remains arbitrary code execution requiring explicit mutation authorization. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows
|
|
70
|
+
Use the typed compile/test tools when their polling and terminal evidence fit the task. For an explicitly requested existing C# builder file, `unity_pipeline_run_script` uses Pipeline 0.6 ephemeral in-memory compilation with bounded JSON arguments or compile-only dry run; it never enables hotpatch and remains arbitrary code execution requiring explicit mutation authorization. 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 bounds pi-unity/Unity CLI waits. Optional `handlerTimeoutMilliseconds` (1–86,400,000) is forwarded only when the exact reachable Pipeline advertises raw argv and the verified eval `code`/integer-`timeout` signature; it bounds Pipeline's dispatcher wait, while a shorter host wait can still win. A dispatcher expiry cannot cancel code already started on Unity's main thread, so the effect remains uncertain and no retry or fallback is allowed. 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.
|
|
@@ -9,6 +9,16 @@ const strings = (value: unknown): value is string[] => Array.isArray(value) && v
|
|
|
9
9
|
const relativeId = (value: unknown): value is string => typeof value === "string" && !!value.trim()
|
|
10
10
|
&& !isAbsolute(value) && !/^(?:[A-Za-z]:|[\\/])/.test(value) && !value.split(/[\\/]/).includes("..") && !/\0/.test(value);
|
|
11
11
|
|
|
12
|
+
/** Shared count/retained-record invariant for durable validation and Pipeline pass acceptance. */
|
|
13
|
+
export function hasConsistentUnityTestCounts(summary: Record<string, unknown>, tests: Array<{ status: string }>): boolean {
|
|
14
|
+
const total = summary.total; const passed = summary.passed; const failed = summary.failed;
|
|
15
|
+
const skipped = summary.skipped; const inconclusive = summary.inconclusive;
|
|
16
|
+
if (![total, passed, failed, skipped, inconclusive].every(value => value === undefined || count(value))) return false;
|
|
17
|
+
if (count(total) && [passed, failed, skipped, inconclusive].reduce<number>((sum, value) => sum + (count(value) ? value : 0), 0) > total) return false;
|
|
18
|
+
if (count(total) && tests.length > total) return false;
|
|
19
|
+
return [["passed", passed], ["failed", failed], ["skipped", skipped], ["inconclusive", inconclusive]].every(([status, limit]) => !count(limit) || tests.filter(test => status === "passed" ? /^(?:passed|success)$/i.test(test.status) : test.status.toLowerCase() === status).length <= limit);
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
/** Read the durable schema, not a transport response. Missing optional counts remain unknown.
|
|
13
23
|
* Test records may be bounded or absent: never require tests.length === summary.total.
|
|
14
24
|
*/
|
|
@@ -25,10 +35,7 @@ export function validateNormalizedUnityTestArtifact(value: unknown): NormalizedU
|
|
|
25
35
|
for (const key of ["total", "passed", "failed", "skipped", "inconclusive"]) {
|
|
26
36
|
if (summary[key] !== undefined && !count(summary[key])) invalid(`summary.${key} must be a non-negative integer`);
|
|
27
37
|
}
|
|
28
|
-
if (
|
|
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
|
-
}
|
|
38
|
+
if (!hasConsistentUnityTestCounts(summary, [])) invalid("summary counts are inconsistent");
|
|
32
39
|
if (!Array.isArray(result.tests)) invalid("tests must be an array");
|
|
33
40
|
const tests = result.tests as unknown[];
|
|
34
41
|
for (const test of tests) {
|
|
@@ -38,14 +45,11 @@ export function validateNormalizedUnityTestArtifact(value: unknown): NormalizedU
|
|
|
38
45
|
if (item.durationSeconds !== undefined && !nonnegative(item.durationSeconds)) invalid("test durationSeconds must be non-negative");
|
|
39
46
|
if (item.attempts !== undefined && (!count(item.attempts) || item.attempts < 1)) invalid("test attempts must be positive");
|
|
40
47
|
}
|
|
41
|
-
if (
|
|
48
|
+
if (!hasConsistentUnityTestCounts(summary, tests as Array<{ status: string }>)) invalid("test records conflict with summary counts");
|
|
42
49
|
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
50
|
if (result.projectRelativeId !== undefined && !relativeId(result.projectRelativeId)) invalid("projectRelativeId must be project-relative");
|
|
48
51
|
if (result.backendArtifacts !== undefined && (!record(result.backendArtifacts) || !Object.values(result.backendArtifacts).every(relativeId))) invalid("backendArtifacts must contain project-relative paths");
|
|
52
|
+
if (result.diagnostics !== undefined && (!Array.isArray(result.diagnostics) || result.diagnostics.length > 8 || !result.diagnostics.every(item => typeof item === "string" && !!item.trim() && item.length <= 1_000))) invalid("diagnostics must contain at most eight bounded strings");
|
|
49
53
|
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
54
|
if (typed.startedAt && typed.completedAt && Date.parse(typed.completedAt) < Date.parse(typed.startedAt)) invalid("completion precedes start");
|
|
51
55
|
if (result.durationSeconds !== undefined && !nonnegative(result.durationSeconds)) invalid("durationSeconds must be non-negative");
|
|
@@ -24,7 +24,7 @@ const OWNER = Object.freeze({
|
|
|
24
24
|
* metadata remains project-owned and schema-open.
|
|
25
25
|
*/
|
|
26
26
|
export function createUnityArtifactProfileV1(): ArtifactProfileV1 {
|
|
27
|
-
return Object.freeze({
|
|
27
|
+
return Object.freeze<ArtifactProfileV1>({
|
|
28
28
|
contractVersion: 1,
|
|
29
29
|
id: UNITY_ARTIFACT_PROFILE_ID_V1,
|
|
30
30
|
kind: "artifact-profile",
|
package/src/unity-cli.ts
CHANGED
|
@@ -46,6 +46,13 @@ export type UnityCliPipelineInstance = {
|
|
|
46
46
|
|
|
47
47
|
export type UnityCliDiscoveryState = "not_attempted" | "available" | "absent" | "timeout" | "unavailable";
|
|
48
48
|
|
|
49
|
+
export type UnityCliCommandParameter = {
|
|
50
|
+
name: string;
|
|
51
|
+
type: string;
|
|
52
|
+
required: boolean;
|
|
53
|
+
defaultValue?: unknown;
|
|
54
|
+
};
|
|
55
|
+
|
|
49
56
|
export type UnityCliProjectCapabilities = {
|
|
50
57
|
cliAvailable: boolean;
|
|
51
58
|
cliVersion?: string;
|
|
@@ -56,6 +63,12 @@ export type UnityCliProjectCapabilities = {
|
|
|
56
63
|
advertisedCommands: string[];
|
|
57
64
|
advertisedCommandCount: number;
|
|
58
65
|
advertisedCommandsTruncated: boolean;
|
|
66
|
+
/** Display-oriented command descriptors. They are never capability evidence. */
|
|
67
|
+
advertisedCommandParameters?: Record<string, readonly UnityCliCommandParameter[]>;
|
|
68
|
+
/** Complete, bounded, unambiguous descriptors eligible for exact capability gates. */
|
|
69
|
+
verifiedCommandParameters?: Record<string, readonly UnityCliCommandParameter[]>;
|
|
70
|
+
/** True only when the exact live Pipeline descriptor advertises raw argv support. */
|
|
71
|
+
pipelineSupportsExecArgv?: boolean;
|
|
59
72
|
commandDiscoveryAttempted: boolean;
|
|
60
73
|
commandDiscoverySucceeded: boolean;
|
|
61
74
|
latestPipelineVersion?: string;
|
|
@@ -68,7 +81,7 @@ export type UnityCliProjectCapabilities = {
|
|
|
68
81
|
export type UnityCliExecResult = {
|
|
69
82
|
stdout: string;
|
|
70
83
|
stderr: string;
|
|
71
|
-
error?: Error & { code?: string | number; signal?: string | null };
|
|
84
|
+
error?: Error & { code?: string | number | null; signal?: string | null; killed?: boolean };
|
|
72
85
|
};
|
|
73
86
|
|
|
74
87
|
/** Injectable seam for deterministic capability and planning-dispatch tests. */
|
|
@@ -196,9 +209,9 @@ const execFileCollect: UnityCliExecutor = (command, args, options = {}) => {
|
|
|
196
209
|
return new Promise((resolve) => {
|
|
197
210
|
execFile(command, args, { timeout: options.timeout ?? UNITY_CLI_VERSION_TIMEOUT_MS, signal: options.signal, windowsHide: true }, (error, stdout, stderr) => {
|
|
198
211
|
resolve({
|
|
199
|
-
stdout
|
|
200
|
-
stderr
|
|
201
|
-
error: error
|
|
212
|
+
stdout,
|
|
213
|
+
stderr,
|
|
214
|
+
error: error ?? undefined,
|
|
202
215
|
});
|
|
203
216
|
});
|
|
204
217
|
});
|
|
@@ -346,10 +359,16 @@ export function parseUnityCliPipelineListOutput(output: string, projectRoot: str
|
|
|
346
359
|
type UnityCliCommandCatalog = {
|
|
347
360
|
valid: boolean;
|
|
348
361
|
commands: string[];
|
|
362
|
+
parametersByCommand: Record<string, readonly UnityCliCommandParameter[]>;
|
|
363
|
+
verifiedParametersByCommand: Record<string, readonly UnityCliCommandParameter[]>;
|
|
349
364
|
total: number;
|
|
350
365
|
truncated: boolean;
|
|
351
366
|
};
|
|
352
367
|
|
|
368
|
+
function isBoundedDescriptorString(value: unknown): value is string {
|
|
369
|
+
return typeof value === "string" && value.length > 0 && value.length <= 120 && !/[\u0000-\u001f\u007f]/.test(value);
|
|
370
|
+
}
|
|
371
|
+
|
|
353
372
|
function parseUnityCliCommandCatalog(output: string): UnityCliCommandCatalog {
|
|
354
373
|
const payload = parseJsonObject(output);
|
|
355
374
|
const data = getRecord(payload?.data);
|
|
@@ -366,21 +385,53 @@ function parseUnityCliCommandCatalog(output: string): UnityCliCommandCatalog {
|
|
|
366
385
|
candidates.push(...value);
|
|
367
386
|
}
|
|
368
387
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
388
|
+
|
|
389
|
+
const parametersByCommand: Record<string, readonly UnityCliCommandParameter[]> = {};
|
|
390
|
+
const verifiedCandidates = new Map<string, Array<readonly UnityCliCommandParameter[] | undefined>>();
|
|
391
|
+
const names: string[] = [];
|
|
392
|
+
for (const entry of candidates) {
|
|
393
|
+
const record = getRecord(entry);
|
|
394
|
+
const rawName = typeof entry === "string" ? entry : optionalString(record?.name, record?.command, record?.id);
|
|
395
|
+
if (!isBoundedDescriptorString(rawName)) continue;
|
|
374
396
|
const name = rawName.trim();
|
|
375
|
-
if (!
|
|
376
|
-
|
|
377
|
-
|
|
397
|
+
if (!isBoundedDescriptorString(name)) continue;
|
|
398
|
+
names.push(name);
|
|
399
|
+
|
|
400
|
+
// Preserve a best-effort descriptor for status display, but retain authoritative
|
|
401
|
+
// evidence only when every declared parameter is valid and the array was not cut.
|
|
402
|
+
const rawParameters = record?.parameters;
|
|
403
|
+
let parsed: UnityCliCommandParameter[] | undefined;
|
|
404
|
+
if (Array.isArray(rawParameters) && rawParameters.length <= 32) {
|
|
405
|
+
parsed = [];
|
|
406
|
+
for (const item of rawParameters) {
|
|
407
|
+
const parameter = getRecord(item);
|
|
408
|
+
const parameterName = parameter?.name;
|
|
409
|
+
const type = parameter?.type;
|
|
410
|
+
if (!isBoundedDescriptorString(parameterName) || !isBoundedDescriptorString(type) || typeof parameter?.required !== "boolean") {
|
|
411
|
+
parsed = undefined;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
parsed.push({ name: parameterName, type, required: parameter.required, ...(Object.prototype.hasOwnProperty.call(parameter, "defaultValue") ? { defaultValue: parameter.defaultValue } : {}) });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!(name in parametersByCommand) && parsed) parametersByCommand[name] = parsed;
|
|
418
|
+
const entries = verifiedCandidates.get(name) ?? [];
|
|
419
|
+
entries.push(parsed);
|
|
420
|
+
verifiedCandidates.set(name, entries);
|
|
421
|
+
}
|
|
422
|
+
|
|
378
423
|
const unique = [...new Set(names)].sort((left, right) => left.localeCompare(right));
|
|
424
|
+
const commands = unique.slice(0, 256);
|
|
425
|
+
const verifiedParametersByCommand = Object.fromEntries([...verifiedCandidates].flatMap(([name, descriptors]) =>
|
|
426
|
+
descriptors.length === 1 && descriptors[0] ? [[name, descriptors[0]]] : [],
|
|
427
|
+
));
|
|
379
428
|
return {
|
|
380
429
|
valid: Boolean(payload?.success === true && valid),
|
|
381
|
-
commands
|
|
430
|
+
commands,
|
|
431
|
+
parametersByCommand: Object.fromEntries(commands.flatMap(name => parametersByCommand[name] ? [[name, parametersByCommand[name]]] : [])),
|
|
432
|
+
verifiedParametersByCommand,
|
|
382
433
|
total: unique.length,
|
|
383
|
-
truncated: unique.length > 256
|
|
434
|
+
truncated: unique.length > 256,
|
|
384
435
|
};
|
|
385
436
|
}
|
|
386
437
|
|
|
@@ -428,8 +479,16 @@ export async function readDeclaredUnityPipelineVersion(projectRoot: string): Pro
|
|
|
428
479
|
return optionalString(manifestDependencies?.["com.unity.pipeline"]);
|
|
429
480
|
}
|
|
430
481
|
|
|
482
|
+
/** Read only the public capability names; the descriptor's authentication token is never retained or surfaced. */
|
|
483
|
+
async function readPipelineDescriptorCapabilities(projectRoot: string): Promise<string[] | undefined> {
|
|
484
|
+
const descriptor = await readJsonFile(join(projectRoot, "Library", "Pipeline", ".unity-pipeline-port"));
|
|
485
|
+
if (!descriptor) return undefined;
|
|
486
|
+
const values = Array.isArray(descriptor.capabilities) ? descriptor.capabilities : [];
|
|
487
|
+
return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0 && value.length <= 120 && !/[\u0000-\u001f\u007f]/.test(value)))];
|
|
488
|
+
}
|
|
489
|
+
|
|
431
490
|
export function isUnityCliTimeout(result: Pick<UnityCliExecResult, "error">): boolean {
|
|
432
|
-
const error = result.error
|
|
491
|
+
const error = result.error;
|
|
433
492
|
return error?.code === "ETIMEDOUT" || error?.killed === true || error?.signal === "SIGTERM";
|
|
434
493
|
}
|
|
435
494
|
|
|
@@ -511,6 +570,8 @@ export async function inspectUnityCliProjectCapabilities(
|
|
|
511
570
|
result.pipelineDiscovery = "available";
|
|
512
571
|
const pipeline = parseUnityCliPipelineListOutput(pipelineResult.stdout, projectRoot);
|
|
513
572
|
result.matchingInstances = pipeline.instances;
|
|
573
|
+
const descriptorCapabilities = await readPipelineDescriptorCapabilities(projectRoot);
|
|
574
|
+
result.pipelineSupportsExecArgv = descriptorCapabilities?.includes("exec.argv") === true;
|
|
514
575
|
result.latestPipelineVersion = pipeline.latestVersion;
|
|
515
576
|
if (pipeline.instances.length === 0) {
|
|
516
577
|
result.pipelineDiscovery = "absent";
|
|
@@ -522,7 +583,9 @@ export async function inspectUnityCliProjectCapabilities(
|
|
|
522
583
|
}
|
|
523
584
|
|
|
524
585
|
result.commandDiscoveryAttempted = true;
|
|
525
|
-
|
|
586
|
+
// `unity list` normalizes parameter types/defaults, while `unity command` with no command
|
|
587
|
+
// returns the live Pipeline catalog descriptor needed for exact capability-gated forwarding.
|
|
588
|
+
const listResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot, "--detail", "full"], { timeout: discoveryTimeout, signal: options.signal });
|
|
526
589
|
const catalog = parseUnityCliCommandCatalog(listResult.stdout);
|
|
527
590
|
const listPayload = parseJsonObject(listResult.stdout);
|
|
528
591
|
const commandDiagnostics = cliEnvelopeDiagnostics(listPayload);
|
|
@@ -533,12 +596,16 @@ export async function inspectUnityCliProjectCapabilities(
|
|
|
533
596
|
// Commands in a warning-bearing catalog are informational only, not advertised
|
|
534
597
|
// capability evidence. Keep descriptors for status visibility without enabling dispatch.
|
|
535
598
|
result.advertisedCommands = catalog.commands;
|
|
599
|
+
result.advertisedCommandParameters = catalog.parametersByCommand;
|
|
600
|
+
result.verifiedCommandParameters = catalog.verifiedParametersByCommand;
|
|
536
601
|
result.advertisedCommandCount = catalog.total;
|
|
537
602
|
result.advertisedCommandsTruncated = catalog.truncated;
|
|
538
603
|
return result;
|
|
539
604
|
}
|
|
540
605
|
result.commandDiscovery = "available";
|
|
541
606
|
result.advertisedCommands = catalog.commands;
|
|
607
|
+
result.advertisedCommandParameters = catalog.parametersByCommand;
|
|
608
|
+
result.verifiedCommandParameters = catalog.verifiedParametersByCommand;
|
|
542
609
|
result.advertisedCommandCount = catalog.total;
|
|
543
610
|
result.advertisedCommandsTruncated = catalog.truncated;
|
|
544
611
|
result.commandDiscoverySucceeded = true;
|
|
@@ -569,6 +636,8 @@ export type UnityPlanningInspectionRequest = {
|
|
|
569
636
|
args?: string[];
|
|
570
637
|
/** A bounded C# snippet for advertised eval. Pipeline compiles it with Roslyn on the Editor main thread. */
|
|
571
638
|
evalSnippet?: string;
|
|
639
|
+
/** Verified Pipeline eval dispatcher wait in milliseconds; distinct from the CLI/host wait. */
|
|
640
|
+
handlerTimeoutMilliseconds?: number;
|
|
572
641
|
};
|
|
573
642
|
|
|
574
643
|
export type UnityPlanningInspectionResult =
|
|
@@ -624,6 +693,20 @@ function runScriptCommandFailure(output: string): "malformed" | "failure" | unde
|
|
|
624
693
|
return undefined;
|
|
625
694
|
}
|
|
626
695
|
|
|
696
|
+
function hasVerifiedEvalTimeoutContract(capabilities: UnityCliProjectCapabilities): boolean {
|
|
697
|
+
const parameters = capabilities.verifiedCommandParameters?.eval;
|
|
698
|
+
return capabilities.pipelineSupportsExecArgv === true
|
|
699
|
+
&& Array.isArray(parameters)
|
|
700
|
+
&& parameters.length === 2
|
|
701
|
+
&& parameters[0]?.name === "code"
|
|
702
|
+
&& parameters[0]?.type === "String"
|
|
703
|
+
&& parameters[0]?.required === true
|
|
704
|
+
&& parameters[1]?.name === "timeout"
|
|
705
|
+
&& parameters[1]?.type === "Int32"
|
|
706
|
+
&& parameters[1]?.required === false
|
|
707
|
+
&& parameters[1]?.defaultValue === 5000;
|
|
708
|
+
}
|
|
709
|
+
|
|
627
710
|
function connectedCommandFailure(output: string, isEval: boolean): "malformed" | "failure" | undefined {
|
|
628
711
|
const envelope = parseJsonObject(output);
|
|
629
712
|
if (!envelope) return "malformed";
|
|
@@ -691,6 +774,12 @@ export async function dispatchUnityPlanningInspection(
|
|
|
691
774
|
if (request.args?.length || !snippet || snippet.length > UNITY_PIPELINE_EVAL_MAX_CHARS || /[\u0000]/.test(snippet)) {
|
|
692
775
|
return { outcome: "rejected", code: "planning_eval_invalid", message: "Eval requires one non-empty bounded C# snippet and no separate arguments." };
|
|
693
776
|
}
|
|
777
|
+
if (request.handlerTimeoutMilliseconds !== undefined && (!Number.isInteger(request.handlerTimeoutMilliseconds) || request.handlerTimeoutMilliseconds < 1 || request.handlerTimeoutMilliseconds > 86_400_000)) {
|
|
778
|
+
return { outcome: "rejected", code: "planning_eval_invalid", message: "Eval handler timeout must be an integer from 1 to 86400000 milliseconds." };
|
|
779
|
+
}
|
|
780
|
+
if (request.handlerTimeoutMilliseconds !== undefined && !hasVerifiedEvalTimeoutContract(initial)) {
|
|
781
|
+
return { outcome: "rejected", code: "planning_eval_timeout_unavailable", message: "The exact Pipeline copy does not establish raw argv support and the documented eval timeout signature; eval was not dispatched." };
|
|
782
|
+
}
|
|
694
783
|
} else if (!UNITY_PLANNING_READ_COMMANDS.includes(request.command as typeof UNITY_PLANNING_READ_COMMANDS[number]) || (request.evalSnippet?.trim() ?? "") !== "") {
|
|
695
784
|
return { outcome: "rejected", code: "planning_command_invalid", message: "Only a package-owned purpose-built inspection command may be selected here." };
|
|
696
785
|
}
|
|
@@ -706,13 +795,16 @@ export async function dispatchUnityPlanningInspection(
|
|
|
706
795
|
if (!refreshed.advertisedCommands.includes(request.command)) {
|
|
707
796
|
return { outcome: "rejected", code: "planning_command_unadvertised", message: "The refreshed exact Pipeline copy did not advertise the requested command." };
|
|
708
797
|
}
|
|
798
|
+
if (isEval && request.handlerTimeoutMilliseconds !== undefined && !hasVerifiedEvalTimeoutContract(refreshed)) {
|
|
799
|
+
return { outcome: "rejected", code: "planning_eval_timeout_unavailable", message: "The exact Pipeline eval timeout capability changed before dispatch; eval was not dispatched." };
|
|
800
|
+
}
|
|
709
801
|
|
|
710
802
|
const command = resolveUnityCliCommand({ cliCommand: options.cliCommand });
|
|
711
803
|
const args = [
|
|
712
804
|
"--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot,
|
|
713
805
|
"--timeout", String(Math.max(1, Math.ceil((options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS) / 1000))),
|
|
714
806
|
request.command,
|
|
715
|
-
...(isEval ? [request.evalSnippet!.trim()] : request.args ?? []),
|
|
807
|
+
...(isEval ? [request.evalSnippet!.trim(), ...(request.handlerTimeoutMilliseconds === undefined ? [] : [String(request.handlerTimeoutMilliseconds)])] : request.args ?? []),
|
|
716
808
|
];
|
|
717
809
|
const execution = await options.execute(command, args, { timeout: options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS, signal: options.signal });
|
|
718
810
|
const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n");
|
|
@@ -14,7 +14,7 @@ export const UNITY_EXACT_GENERATED_ROOT_BYPASSED_CODE = "unity_exact_generated_r
|
|
|
14
14
|
export const UNITY_GENERATED_DIRECTORIES = Object.freeze(["Library", "Temp", "Logs", "obj", "Build", "Builds", "UserSettings", ".vs"] as const);
|
|
15
15
|
|
|
16
16
|
export function createUnityFileDiscoveryFilterV1(): FileDiscoveryFilterV1 {
|
|
17
|
-
return Object.freeze({
|
|
17
|
+
return Object.freeze<FileDiscoveryFilterV1>({
|
|
18
18
|
contractVersion: 1,
|
|
19
19
|
id: UNITY_FILE_DISCOVERY_FILTER_ID_V1,
|
|
20
20
|
kind: "file-discovery-filter",
|
package/src/unity-pipeline.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
2
|
import { projectPathsMatch } from "./unity-core";
|
|
3
|
+
import { hasConsistentUnityTestCounts } from "./unity-artifact-inspection";
|
|
3
4
|
import { redactUnityPlanningOutput, resolveUnityCliCommand, summarizeUnityCliText, unityCapabilityDiagnosticSuffix, type UnityCliExecResult, type UnityCliExecutor, type UnityCliProjectCapabilities } from "./unity-cli";
|
|
4
5
|
|
|
5
6
|
/** Public limits are deliberately small enough that connected work cannot create an unbounded agent wait loop. */
|
|
@@ -37,6 +38,11 @@ export type UnityPipelineOperationDetails = {
|
|
|
37
38
|
export type UnityPipelineTestRecord = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string };
|
|
38
39
|
/** testRecords are terminal evidence for the caller's durable artifact only; do not expose them in tool details. */
|
|
39
40
|
export type UnityPipelineOperationResult = { text: string; details: UnityPipelineOperationDetails; testRecords?: UnityPipelineTestRecord[] };
|
|
41
|
+
export type UnityPipelineTerminalTestEvidence = { state: "completed" | "failed" | "cancelled"; outcome: "uncertain" | "tests_failed" | "run_error" | "cancelled"; reason: string; elapsedSeconds: number; selection: { platform: "EditMode" | "PlayMode"; filter?: string }; correlation: Record<string, string>; observations: string[]; testRecords: UnityPipelineTestRecord[]; warnings: string[] };
|
|
42
|
+
/** Terminal Pipeline evidence can be durable and inspectable without being passing evidence. */
|
|
43
|
+
export class UnityPipelineTerminalTestEvidenceError extends Error {
|
|
44
|
+
constructor(readonly evidence: UnityPipelineTerminalTestEvidence) { super(evidence.reason); this.name = "UnityPipelineTerminalTestEvidenceError"; }
|
|
45
|
+
}
|
|
40
46
|
|
|
41
47
|
type RecordValue = Record<string, unknown>;
|
|
42
48
|
type ParsedEnvelope = { result: RecordValue; outerSuccess: boolean; malformed?: string };
|
|
@@ -46,6 +52,8 @@ type NormalizedTest = {
|
|
|
46
52
|
total?: number; passed?: number; failed?: number; inconclusive?: number; failures: string[];
|
|
47
53
|
correlation: Record<string, string>;
|
|
48
54
|
testRecords?: UnityPipelineTestRecord[];
|
|
55
|
+
testFailureEstablished: boolean;
|
|
56
|
+
runnerError: boolean;
|
|
49
57
|
};
|
|
50
58
|
|
|
51
59
|
type PipelineDependencies = {
|
|
@@ -199,7 +207,9 @@ export function normalizeUnityPipelineCompile(output: string): NormalizedCompile
|
|
|
199
207
|
const parsed = parseUnityPipelineEnvelope(output);
|
|
200
208
|
if (parsed.malformed) return { state: "uncertain", diagnostics: [], failed: false };
|
|
201
209
|
const compilerDiagnostics = diagnostics(parsed.result);
|
|
202
|
-
|
|
210
|
+
let compilationFailed = false;
|
|
211
|
+
walk(parsed.result, item => { if (field(item, "compilationfailed") === true) compilationFailed = true; });
|
|
212
|
+
const failed = !parsed.outerSuccess || hasSemanticFailure(parsed.result) || compilationFailed || compilerDiagnostics.length > 0;
|
|
203
213
|
const raw = statusOf(parsed.result);
|
|
204
214
|
const state = failed || raw === "failed" || raw === "error" ? "failed" : raw === "up_to_date" || raw === "uptodate" ? "up_to_date"
|
|
205
215
|
: raw === "triggered" ? "triggered" : raw === "compiling" || raw === "running" ? "compiling"
|
|
@@ -229,6 +239,9 @@ function testRecords(result: RecordValue): UnityPipelineTestRecord[] {
|
|
|
229
239
|
});
|
|
230
240
|
return values.slice(0, 2_000);
|
|
231
241
|
}
|
|
242
|
+
function isRecognizedFailedTestStatus(status: string): boolean {
|
|
243
|
+
return status.trim().toLowerCase() === "failed";
|
|
244
|
+
}
|
|
232
245
|
function testFailures(result: RecordValue): string[] {
|
|
233
246
|
const values: string[] = [];
|
|
234
247
|
walk(result, item => {
|
|
@@ -238,7 +251,7 @@ function testFailures(result: RecordValue): string[] {
|
|
|
238
251
|
for (const entry of entries.slice(0, 200)) {
|
|
239
252
|
const test = record(entry); if (!test) continue;
|
|
240
253
|
const outcome = string(field(test, "result", "status", "outcome"))?.toLowerCase();
|
|
241
|
-
if (!outcome ||
|
|
254
|
+
if (!outcome || /^(?:passed|success)$/i.test(outcome)) continue;
|
|
242
255
|
const name = string(field(test, "name", "fullname", "testname")) ?? "Unnamed test";
|
|
243
256
|
const message = string(field(test, "message", "error", "failuremessage"));
|
|
244
257
|
const stack = string(field(test, "stacktrace", "stack", "trace"));
|
|
@@ -268,19 +281,23 @@ function correlation(result: RecordValue): Record<string, string> {
|
|
|
268
281
|
}
|
|
269
282
|
export function normalizeUnityPipelineTest(output: string): NormalizedTest {
|
|
270
283
|
const parsed = parseUnityPipelineEnvelope(output);
|
|
271
|
-
if (parsed.malformed) return { state: "uncertain", failures: [], correlation: {} };
|
|
284
|
+
if (parsed.malformed) return { state: "uncertain", failures: [], correlation: {}, testFailureEstablished: false, runnerError: false };
|
|
272
285
|
const sum = summary(parsed.result);
|
|
273
286
|
const total = number(field(sum ?? parsed.result, "total"));
|
|
274
287
|
const passed = number(field(sum ?? parsed.result, "passed", "pass"));
|
|
275
288
|
const failedCount = number(field(sum ?? parsed.result, "failed", "fail"));
|
|
276
289
|
const inconclusive = number(field(sum ?? parsed.result, "inconclusive", "skipped"));
|
|
277
290
|
const raw = statusOf(parsed.result);
|
|
278
|
-
const
|
|
279
|
-
const
|
|
291
|
+
const records = testRecords(parsed.result);
|
|
292
|
+
const testFailureEstablished = (Number.isSafeInteger(failedCount) && (failedCount ?? 0) > 0) || records.some(test => isRecognizedFailedTestStatus(test.status));
|
|
293
|
+
const runnerError = !parsed.outerSuccess || raw === "failed" || raw === "error" || hasSemanticFailure(parsed.result);
|
|
294
|
+
// A reported active state remains active even when it carries partial records/counts.
|
|
295
|
+
const state = raw === "cancelled" || raw === "canceled" ? "cancelled"
|
|
280
296
|
: raw === "no_tests" || raw === "idle" || raw === "not_started" || raw === "not_running" ? "inactive"
|
|
281
297
|
: raw === "running" ? "running" : raw === "starting" || raw === "queued" ? "starting"
|
|
282
|
-
: raw === "completed" || raw === "complete" || raw === "success" ? "
|
|
283
|
-
|
|
298
|
+
: raw === "completed" || raw === "complete" || raw === "success" ? testFailureEstablished || runnerError ? "failed" : "completed"
|
|
299
|
+
: testFailureEstablished || runnerError ? "failed" : "uncertain";
|
|
300
|
+
return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result), testRecords: records, testFailureEstablished, runnerError };
|
|
284
301
|
}
|
|
285
302
|
|
|
286
303
|
function editorStopSucceeded(output: string): boolean {
|
|
@@ -465,10 +482,18 @@ function checkCorrelation(expected: Record<string, string>, actual: Record<strin
|
|
|
465
482
|
return Object.entries(expected).every(([key, value]) => !actual[key] || actual[key] === value);
|
|
466
483
|
}
|
|
467
484
|
function passingCounts(state: NormalizedTest): { total: number; passed: number; failed: number; inconclusive?: number } | undefined {
|
|
485
|
+
const summary = { total: state.total, passed: state.passed, failed: state.failed, inconclusive: state.inconclusive };
|
|
486
|
+
if (!hasConsistentUnityTestCounts(summary, state.testRecords ?? [])) return undefined;
|
|
487
|
+
if (state.testRecords?.some(test => !/^(?:passed|success)$/i.test(test.status))) return undefined;
|
|
468
488
|
if (state.total === undefined || state.total <= 0 || state.passed === undefined || state.failed !== 0 || (state.inconclusive ?? 0) > 0) return undefined;
|
|
469
489
|
if (state.passed + state.failed + (state.inconclusive ?? 0) !== state.total) return undefined;
|
|
470
490
|
return { total: state.total, passed: state.passed, failed: state.failed, inconclusive: state.inconclusive };
|
|
471
491
|
}
|
|
492
|
+
function terminalEvidence(state: NormalizedTest, reason: string, request: UnityPipelineTestRequest, elapsedSeconds: number, warnings: string[]): UnityPipelineTerminalTestEvidenceError {
|
|
493
|
+
const outcome = state.state === "cancelled" ? "cancelled" : state.testFailureEstablished ? "tests_failed" : state.runnerError ? "run_error" : "uncertain";
|
|
494
|
+
const observations = [`terminal state=${state.state}`, `terminal outcome=${outcome}`, ...["total", "passed", "failed", "inconclusive"].flatMap(key => state[key as keyof Pick<NormalizedTest, "total" | "passed" | "failed" | "inconclusive">] === undefined ? [] : [`reported ${key}=${String(state[key as keyof Pick<NormalizedTest, "total" | "passed" | "failed" | "inconclusive">])}`]), ...Object.entries(state.correlation).map(([key, value]) => `correlation ${key}=${value}`)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
|
|
495
|
+
return new UnityPipelineTerminalTestEvidenceError({ state: state.state as "completed" | "failed" | "cancelled", outcome, reason, elapsedSeconds, selection: { platform: request.testPlatform, ...(request.testFilter ?? request.testCategory ? { filter: request.testFilter ?? request.testCategory } : {}) }, correlation: state.correlation, observations, testRecords: state.testRecords ?? [], warnings });
|
|
496
|
+
}
|
|
472
497
|
function elapsed(start: number, now: () => number): number { return Math.max(0, (now() - start) / 1000); }
|
|
473
498
|
function timeoutMessage(operation: string): Error { return new Error(`Unity Pipeline ${operation} timed out; result is uncertain and may still be running. No cancellation, retry, or route switch was performed.`); }
|
|
474
499
|
function ensureBeforeDeadline(deadline: number, now: () => number, operation: string): void {
|
|
@@ -540,14 +565,14 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
540
565
|
const dispatchWarnings = pipelineEnvelopeWarnings(dispatched.stdout);
|
|
541
566
|
let state = normalizeUnityPipelineTest(dispatched.stdout);
|
|
542
567
|
if (state.state === "uncertain" || state.state === "inactive") throw new Error("Unity Pipeline test dispatch returned inactive, malformed, or uncertain evidence; test run may not have started.");
|
|
543
|
-
if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
|
|
544
568
|
const requestedCorrelation = { mode: request.testPlatform, ...(request.testFilter ? { filter: request.testFilter } : {}) };
|
|
545
569
|
if (!checkCorrelation(requestedCorrelation, state.correlation)) throw new Error("Unity Pipeline test dispatch reported a different mode or filter; operation state is uncertain.");
|
|
570
|
+
if (state.state === "failed" || state.state === "cancelled") throw terminalEvidence(state, `Unity ${request.testPlatform} tests ${state.state}: ${state.failures.join("; ") || state.state}.`, request, elapsed(start, now), dispatchWarnings);
|
|
546
571
|
const expected = { ...requestedCorrelation, ...state.correlation };
|
|
547
572
|
// Some Pipeline versions return a complete result directly from asynchronous dispatch.
|
|
548
573
|
if (state.state === "completed") {
|
|
549
574
|
const counts = passingCounts(state);
|
|
550
|
-
if (!counts) throw
|
|
575
|
+
if (!counts) throw terminalEvidence(state, "Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).", request, elapsed(start, now), dispatchWarnings);
|
|
551
576
|
return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}) }, testRecords: state.testRecords };
|
|
552
577
|
}
|
|
553
578
|
for (let poll = 0; now() < deadline; poll += 1) {
|
|
@@ -565,12 +590,12 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
565
590
|
retainWarnings(dispatchWarnings, response.stdout);
|
|
566
591
|
state = normalizeUnityPipelineTest(response.stdout);
|
|
567
592
|
if (!checkCorrelation(expected, state.correlation)) throw new Error("Unity Pipeline test status was displaced by a different run; operation state is uncertain.");
|
|
568
|
-
if (state.state === "failed" || state.state === "cancelled") throw
|
|
593
|
+
if (state.state === "failed" || state.state === "cancelled") throw terminalEvidence(state, `Unity ${request.testPlatform} tests ${state.state}: ${state.failures.join("; ") || state.state}.`, request, elapsed(start, now), dispatchWarnings);
|
|
569
594
|
if (state.state === "uncertain") throw new Error("Unity Pipeline test status is malformed or uncertain; operation may still be running.");
|
|
570
595
|
if (state.state === "inactive") throw new Error("Unity Pipeline test status became inactive before a terminal result; operation state is uncertain.");
|
|
571
596
|
if (state.state !== "completed") continue;
|
|
572
597
|
const counts = passingCounts(state);
|
|
573
|
-
if (!counts) throw
|
|
598
|
+
if (!counts) throw terminalEvidence(state, "Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).", request, elapsed(start, now), dispatchWarnings);
|
|
574
599
|
return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}) }, testRecords: state.testRecords };
|
|
575
600
|
}
|
|
576
601
|
throw timeoutMessage("tests");
|
package/src/unity-projects.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
1
2
|
import * as fs from "node:fs/promises";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { parseUnityVersionText, resolveAbsolutePath } from "./unity-core";
|
|
@@ -135,7 +136,7 @@ export async function discoverUnityProjects(
|
|
|
135
136
|
continue;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
let entries:
|
|
139
|
+
let entries: Dirent[] = [];
|
|
139
140
|
try {
|
|
140
141
|
entries = await fs.readdir(next.dir, { withFileTypes: true });
|
|
141
142
|
} catch {
|