@aefree/pi-plastic 0.4.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 +109 -0
- package/CONTRIBUTING.md +88 -0
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/SECURITY.md +31 -0
- package/evals/tool-loading/README.md +38 -0
- package/evals/tool-loading/RESULTS.md +57 -0
- package/evals/tool-loading/baseline.json +34 -0
- package/evals/tool-loading/cases.json +70 -0
- package/evals/tool-loading/config.json +19 -0
- package/evals/tool-loading/mutation-guard.ts +30 -0
- package/evals/tool-loading/provider-capture.ts +22 -0
- package/evals/tool-loading/results/.gitignore +3 -0
- package/evals/tool-loading/run-eval.ts +372 -0
- package/extensions/bash-cm-diff-guard.ts +49 -0
- package/extensions/bash-cm-merge-guard.ts +53 -0
- package/extensions/plastic-branch-status.ts +285 -0
- package/extensions/repository-search-provider.ts +66 -0
- package/extensions/shared/bash-command-guards.ts +67 -0
- package/index.ts +777 -0
- package/package.json +76 -0
- package/skills/_shared/references/conventional-commits.md +198 -0
- package/skills/using-plastic/SKILL.md +50 -0
- package/skills/using-plastic/references/branch-management.md +78 -0
- package/skills/using-plastic/references/changeset-operations.md +96 -0
- package/skills/using-plastic/references/code-review-creation.md +30 -0
- package/skills/using-plastic/references/integration.md +47 -0
- package/skills/using-plastic/references/quick-reference.md +32 -0
- package/skills/using-plastic/references/resources.md +7 -0
- package/skills/using-plastic/references/reviewing-changes.md +71 -0
- package/skills/using-plastic/references/shelving.md +25 -0
- package/skills/using-plastic/references/troubleshooting.md +115 -0
- package/skills/using-plastic/references/workspaces.md +33 -0
- package/src/pi-tool-compat.ts +56 -0
- package/src/plastic-core.ts +4204 -0
- package/src/plastic-tool-loading.ts +234 -0
- package/src/plastic-workspace.ts +176 -0
- package/src/repository-search-provider.ts +64 -0
- package/tests/bash-cm-diff-guard-validation.ts +55 -0
- package/tests/fixtures/plastic.selector +3 -0
- package/tests/fixtures/status-compact.txt +1 -0
- package/tests/fixtures/status-normal.txt +1 -0
- package/tests/packed-copy-owner-conflict.test.ts +28 -0
- package/tests/pi-tool-harness.ts +116 -0
- package/tests/plastic-branch-create-live.test.ts +78 -0
- package/tests/plastic-branch-query.test.ts +26 -0
- package/tests/plastic-branch-status.test.ts +241 -0
- package/tests/plastic-checkin-path-resolution.ts +219 -0
- package/tests/plastic-constrained-sampling.test.ts +77 -0
- package/tests/plastic-diff-workflow.test.ts +86 -0
- package/tests/plastic-dynamic-tool-loading.test.ts +224 -0
- package/tests/plastic-extension-registration.test.ts +34 -0
- package/tests/plastic-live-smoke.test.ts +37 -0
- package/tests/plastic-mutation-execution.test.ts +61 -0
- package/tests/plastic-patch-command.test.ts +118 -0
- package/tests/plastic-process-lifecycle.test.ts +148 -0
- package/tests/plastic-tool-rendering.test.ts +16 -0
- package/tests/plastic-tool-validation.ts +153 -0
- package/tests/plastic-workspace.test.ts +62 -0
- package/tests/repository-search-provider-registration.test.ts +37 -0
- package/tests/repository-search-provider.test.ts +27 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
function main(): void {
|
|
5
|
+
const indexText = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
|
|
6
|
+
const packageManifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
7
|
+
const branchStatusText = readFileSync(new URL("../extensions/plastic-branch-status.ts", import.meta.url), "utf8");
|
|
8
|
+
|
|
9
|
+
assert.match(indexText, /parameters:\s*buildParameters\(coreTool\.args\)/, "plastic tools should derive schemas from core args without approval-only parameters");
|
|
10
|
+
assert.match(indexText, /prepareArguments:\s*config\.prepareArguments/, "plastic tools should wire prepareArguments");
|
|
11
|
+
assert.match(indexText, /core\.runWithAbortSignal\(signal, async \(\) => coreTool\.execute\(normalizedParams\)\)/, "plastic tools should propagate abort signals into core execution");
|
|
12
|
+
assert.doesNotMatch(indexText, /authorizationToken|authorizationProvenance|ctx\.ui\.confirm/, "Plastic tool registration must not implement token or UI-confirmation approvals");
|
|
13
|
+
|
|
14
|
+
assert.match(indexText, /assignAlias\(input, "message", \["comment", "comments"\]\);/, "plastic_checkin should normalize comment aliases");
|
|
15
|
+
assert.match(indexText, /assignAlias\(input, "pendingChanges", \["pending_changes"\]\);/, "plastic_switchBranch should normalize pending_changes");
|
|
16
|
+
assert.match(indexText, /"patch",/, "plastic_patch should be included in registered exports");
|
|
17
|
+
assert.match(indexText, /"mergeToBranch",/, "plastic_mergeToBranch should be included in registered exports");
|
|
18
|
+
assert.doesNotMatch(indexText, /"workspaceCreate",/, "plastic_workspaceCreate should remain unregistered until paired cleanup is available");
|
|
19
|
+
assert.match(indexText, /assignAlias\(input, "toolPath", \["tool_path", "tool"\]\);/, "plastic_patch should normalize toolPath aliases");
|
|
20
|
+
assert.match(indexText, /assignAlias\(input, "output", \["output_file", "outputFile"\]\);/, "plastic_patch should normalize output aliases");
|
|
21
|
+
assert.match(indexText, /assignAlias\(input, "titleLike", \["title_like"\]\);/, "plastic_codeReviewFind should normalize title_like");
|
|
22
|
+
assert.match(indexText, /assignAlias\(input, "keepOnDisk", \["keep_on_disk", "keepOnDisk", "nodisk"\]\);/, "plastic_resolveDeleteChangeConflict should normalize keepOnDisk aliases");
|
|
23
|
+
assert.match(indexText, /assignAlias\(input, "source", \["sourceBranch", "source_branch", "branch"\]\);/, "plastic_mergeToBranch should normalize source branch aliases");
|
|
24
|
+
assert.match(indexText, /assignAlias\(input, "cardRef", \["card", "cardCode", "card_code", "codecksCard", "codecks_card"\]\);/, "plastic_mergeToBranch should normalize card aliases");
|
|
25
|
+
assert.match(indexText, /assignAlias\(input, "source", \["mergeSource", "merge_source"\]\);/, "plastic_finalizeMerge should normalize merge source aliases");
|
|
26
|
+
assert.match(indexText, /assignAlias\(input, "workdir", \["cwd", "workingDirectory", "working_directory"\]\);/, "plastic tools should normalize workdir aliases");
|
|
27
|
+
assert.match(indexText, /const enumSchema =/, "plastic tools should expose explicit enum schemas");
|
|
28
|
+
assert.ok(packageManifest.pi.extensions.includes("./extensions/plastic-branch-status.ts"), "package should register the Plastic branch status extension");
|
|
29
|
+
assert.doesNotMatch(branchStatusText, /\.setFooter\s*\(/, "Plastic branch status must compose through setStatus rather than replace the footer");
|
|
30
|
+
|
|
31
|
+
console.log("PASS: plastic extension registration test succeeded");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
main();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { invokeRegisteredTool } from "./pi-tool-harness.ts";
|
|
4
|
+
|
|
5
|
+
type ToolEnvelope = {
|
|
6
|
+
ok?: boolean;
|
|
7
|
+
data?: Record<string, unknown>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const parseToolEnvelope = (text: string): ToolEnvelope => {
|
|
11
|
+
const fencedJson = text.match(/```json\s*([\s\S]*?)\s*```/);
|
|
12
|
+
assert(fencedJson, "Expected a structured JSON tool result.");
|
|
13
|
+
return JSON.parse(fencedJson[1]) as ToolEnvelope;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const configuredWorkspace = process.env.PI_PLASTIC_TEST_WORKSPACE?.trim();
|
|
17
|
+
|
|
18
|
+
if (!configuredWorkspace) {
|
|
19
|
+
console.log("SKIP: set PI_PLASTIC_TEST_WORKSPACE to run opt-in Plastic live smoke tests");
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const workspace = resolve(configuredWorkspace);
|
|
24
|
+
const status = parseToolEnvelope(await invokeRegisteredTool("plastic_status", { short: true, format: "json" }, workspace));
|
|
25
|
+
assert.equal(status.ok, true, "Expected Plastic status to succeed.");
|
|
26
|
+
const summary = status.data?.summary as Record<string, unknown> | undefined;
|
|
27
|
+
const mergeState = status.data?.mergeState as Record<string, unknown> | undefined;
|
|
28
|
+
assert.equal(summary?.totalPending, 0, "Live smoke tests require a clean workspace.");
|
|
29
|
+
assert.equal(mergeState?.hasMergeInProgress, false, "Live smoke tests require no merge in progress.");
|
|
30
|
+
|
|
31
|
+
const currentBranch = parseToolEnvelope(await invokeRegisteredTool("plastic_currentBranch", { format: "json" }, workspace));
|
|
32
|
+
assert.equal(currentBranch.data?.branch, "/main", "Live sandbox must be on /main.");
|
|
33
|
+
|
|
34
|
+
const mainExists = await invokeRegisteredTool("plastic_branchExists", { branch: "/main" }, workspace);
|
|
35
|
+
assert.equal(mainExists.trim(), "true", "Expected plastic_branchExists to find the full /main path.");
|
|
36
|
+
|
|
37
|
+
console.log("PASS: read-only Plastic live smoke tests passed for the configured sandbox");
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { runWithAbortSignal, update } from "../src/plastic-core.ts";
|
|
5
|
+
import { loadRegisteredTools } from "./pi-tool-harness.ts";
|
|
6
|
+
|
|
7
|
+
const registeredTools = await loadRegisteredTools();
|
|
8
|
+
for (const [name, registeredTool] of registeredTools) {
|
|
9
|
+
if (!name.startsWith("plastic_") || name === "plastic_tool_search") continue;
|
|
10
|
+
assert.equal(
|
|
11
|
+
(registeredTool.parameters as any)?.properties?.authorizationToken,
|
|
12
|
+
undefined,
|
|
13
|
+
`${name} must not expose mutation authorization tokens`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type SpawnCall = {
|
|
18
|
+
command: string;
|
|
19
|
+
args: string[];
|
|
20
|
+
cwd: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function makeSpawn(calls: SpawnCall[], exitCode: number, stderr = "") {
|
|
24
|
+
return ((command: string, args: readonly string[], options: { cwd: string }) => {
|
|
25
|
+
calls.push({ command, args: [...args], cwd: options.cwd });
|
|
26
|
+
const child = new EventEmitter() as any;
|
|
27
|
+
child.stdout = Readable.from(exitCode === 0 ? ["Updated successfully"] : []);
|
|
28
|
+
child.stderr = Readable.from(stderr ? [stderr] : []);
|
|
29
|
+
child.stdin = undefined;
|
|
30
|
+
child.kill = () => true;
|
|
31
|
+
process.nextTick(() => child.emit("close", exitCode));
|
|
32
|
+
return child;
|
|
33
|
+
}) as any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const workdir = process.cwd();
|
|
37
|
+
const successfulCalls: SpawnCall[] = [];
|
|
38
|
+
const output = await runWithAbortSignal(
|
|
39
|
+
undefined,
|
|
40
|
+
() => update.execute({ workdir }),
|
|
41
|
+
{ spawn: makeSpawn(successfulCalls, 0) },
|
|
42
|
+
);
|
|
43
|
+
assert.equal(output, "Updated successfully");
|
|
44
|
+
assert.deepEqual(successfulCalls, [{
|
|
45
|
+
command: process.env.PI_PLASTIC_CM_EXECUTABLE?.trim() || "cm",
|
|
46
|
+
args: ["update", "--dontmerge", "--noinput"],
|
|
47
|
+
cwd: workdir,
|
|
48
|
+
}], "a directly invoked mutation tool must make one exact cm spawn without approval context");
|
|
49
|
+
|
|
50
|
+
const failedCalls: SpawnCall[] = [];
|
|
51
|
+
await assert.rejects(
|
|
52
|
+
runWithAbortSignal(
|
|
53
|
+
undefined,
|
|
54
|
+
() => update.execute({ workdir }),
|
|
55
|
+
{ spawn: makeSpawn(failedCalls, 1, "fixture mutation failure") },
|
|
56
|
+
),
|
|
57
|
+
/fixture mutation failure/,
|
|
58
|
+
);
|
|
59
|
+
assert.equal(failedCalls.length, 1, "a failed mutation process must not be retried implicitly");
|
|
60
|
+
|
|
61
|
+
console.log("PASS: Plastic direct mutation execution tests passed");
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { __plasticPatchInternals } from "../src/plastic-core.ts";
|
|
2
|
+
|
|
3
|
+
const assert = (condition: boolean, message: string): void =>
|
|
4
|
+
{
|
|
5
|
+
if (!condition)
|
|
6
|
+
{
|
|
7
|
+
throw new Error(message);
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const assertArgs = (actual: string[], expected: string[], message: string): void =>
|
|
12
|
+
{
|
|
13
|
+
assert(JSON.stringify(actual) === JSON.stringify(expected), `${message}. Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}.`);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const assertThrows = (fn: () => void, expectedMessage: string, message: string): void =>
|
|
17
|
+
{
|
|
18
|
+
try
|
|
19
|
+
{
|
|
20
|
+
fn();
|
|
21
|
+
}
|
|
22
|
+
catch (error)
|
|
23
|
+
{
|
|
24
|
+
assert(error instanceof Error, `${message}: expected an Error instance.`);
|
|
25
|
+
assert(error.message.includes(expectedMessage), `${message}: expected message to include '${expectedMessage}', got '${error.message}'.`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw new Error(`${message}: expected function to throw.`);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const main = (): void =>
|
|
33
|
+
{
|
|
34
|
+
const { buildPatchCommandArgs, resolvePatchToolPath } = __plasticPatchInternals;
|
|
35
|
+
|
|
36
|
+
assert(resolvePatchToolPath() === "diff", "Expected patch backend to share the portable diff PATH default.");
|
|
37
|
+
assert(resolvePatchToolPath(" C:\\gnu\\diff.exe ") === "C:\\gnu\\diff.exe", "Expected an explicit patch backend to override the default.");
|
|
38
|
+
|
|
39
|
+
assertArgs(
|
|
40
|
+
buildPatchCommandArgs({ source: "br:/main/task001" }),
|
|
41
|
+
["patch", "br:/main/task001"],
|
|
42
|
+
"Expected one-spec patch command",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
assertArgs(
|
|
46
|
+
buildPatchCommandArgs({ source: "br:/main/task001", clean: true }),
|
|
47
|
+
["patch", "br:/main/task001", "--clean"],
|
|
48
|
+
"Expected clean flag to be included",
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
assertArgs(
|
|
52
|
+
buildPatchCommandArgs({ source: "br:/main/task001", integration: true }),
|
|
53
|
+
["patch", "br:/main/task001", "--integration"],
|
|
54
|
+
"Expected integration flag to be included",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
assertArgs(
|
|
58
|
+
buildPatchCommandArgs({ source: "br:/main/task001", clean: true, integration: true }),
|
|
59
|
+
["patch", "br:/main/task001", "--clean", "--integration"],
|
|
60
|
+
"Expected clean and integration flags in stable order",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
assertArgs(
|
|
64
|
+
buildPatchCommandArgs({ source: "cs:2", destination: "cs:4", output: "review.patch" }),
|
|
65
|
+
["patch", "cs:2", "cs:4", "--output=review.patch"],
|
|
66
|
+
"Expected destination before output flag",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
assertArgs(
|
|
70
|
+
buildPatchCommandArgs({ source: "br:/main/task001", toolPath: "C:\\gnu\\diff.exe" }),
|
|
71
|
+
["patch", "br:/main/task001", "--tool=C:\\gnu\\diff.exe"],
|
|
72
|
+
"Expected custom diff tool path flag",
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
assertArgs(
|
|
76
|
+
buildPatchCommandArgs({ source: "br:/main/task001", clean: false, integration: false }),
|
|
77
|
+
["patch", "br:/main/task001"],
|
|
78
|
+
"Expected false booleans to be omitted",
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
assertThrows(
|
|
82
|
+
() => buildPatchCommandArgs({ source: "" }),
|
|
83
|
+
"source must be non-empty",
|
|
84
|
+
"Expected blank source to be rejected",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
assertThrows(
|
|
88
|
+
() => buildPatchCommandArgs({ source: "br:/main/task001", destination: " " }),
|
|
89
|
+
"destination must be non-empty",
|
|
90
|
+
"Expected blank destination to be rejected",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
assertThrows(
|
|
94
|
+
() => buildPatchCommandArgs({ source: "br:/main/task001", output: " " }),
|
|
95
|
+
"output must be non-empty",
|
|
96
|
+
"Expected blank output to be rejected",
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
assertThrows(
|
|
100
|
+
() => buildPatchCommandArgs({ source: "br:/main/task001", toolPath: " " }),
|
|
101
|
+
"toolPath must be non-empty",
|
|
102
|
+
"Expected blank toolPath to be rejected",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const allArgs = buildPatchCommandArgs({
|
|
106
|
+
source: "br:/main/task001",
|
|
107
|
+
destination: "br:/main",
|
|
108
|
+
output: "review.patch",
|
|
109
|
+
toolPath: "C:\\gnu\\diff.exe",
|
|
110
|
+
clean: true,
|
|
111
|
+
integration: true,
|
|
112
|
+
});
|
|
113
|
+
assert(!allArgs.some((arg) => arg === "--apply" || arg.startsWith("--apply=")), "Patch generation helper must not emit --apply.");
|
|
114
|
+
|
|
115
|
+
console.log("PASS: plastic patch command tests succeeded");
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
main();
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
import { __plasticProcessInternals } from "../src/plastic-core.ts";
|
|
4
|
+
|
|
5
|
+
const assert = (condition: boolean, message: string): void =>
|
|
6
|
+
{
|
|
7
|
+
if (!condition)
|
|
8
|
+
{
|
|
9
|
+
throw new Error(message);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
class FakeChildProcess extends EventEmitter
|
|
14
|
+
{
|
|
15
|
+
readonly stdout = new PassThrough();
|
|
16
|
+
readonly stderr = new PassThrough();
|
|
17
|
+
readonly stdin = new PassThrough();
|
|
18
|
+
readonly signals: string[] = [];
|
|
19
|
+
killed = false;
|
|
20
|
+
|
|
21
|
+
kill(signal?: NodeJS.Signals): boolean
|
|
22
|
+
{
|
|
23
|
+
this.killed = true;
|
|
24
|
+
this.signals.push(signal ?? "SIGTERM");
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
close(code = 0): void
|
|
29
|
+
{
|
|
30
|
+
this.stdout.end();
|
|
31
|
+
this.stderr.end();
|
|
32
|
+
this.emit("close", code, null);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const fakeSpawn = (proc: FakeChildProcess): typeof import("node:child_process").spawn =>
|
|
37
|
+
(() => proc as unknown as ReturnType<typeof import("node:child_process").spawn>) as typeof import("node:child_process").spawn;
|
|
38
|
+
|
|
39
|
+
const testExecutableResolutionAndDiagnostics = async (): Promise<void> =>
|
|
40
|
+
{
|
|
41
|
+
assert(__plasticProcessInternals.resolveCmExecutable({}) === "cm", "Expected cm to be the default Plastic executable.");
|
|
42
|
+
assert(__plasticProcessInternals.resolveCmExecutable({ PI_PLASTIC_CM_EXECUTABLE: " /tools/cm " }) === "/tools/cm", "Expected PI_PLASTIC_CM_EXECUTABLE to override cm.");
|
|
43
|
+
assert(__plasticProcessInternals.resolveGitExecutable({ PI_PLASTIC_GIT_EXECUTABLE: "C:/Tools/git.exe" }) === "C:/Tools/git.exe", "Expected PI_PLASTIC_GIT_EXECUTABLE to override git.");
|
|
44
|
+
|
|
45
|
+
const proc = new FakeChildProcess();
|
|
46
|
+
const missingCm = __plasticProcessInternals.spawnAndCollect("cm", ["status"], process.cwd(), undefined, undefined, {
|
|
47
|
+
spawn: fakeSpawn(proc),
|
|
48
|
+
});
|
|
49
|
+
const error = Object.assign(new Error("spawn cm ENOENT"), { code: "ENOENT" });
|
|
50
|
+
proc.emit("error", error);
|
|
51
|
+
|
|
52
|
+
await missingCm.then(
|
|
53
|
+
() => { throw new Error("Expected a missing executable to reject."); },
|
|
54
|
+
(reason: unknown) =>
|
|
55
|
+
{
|
|
56
|
+
assert(reason instanceof Error && reason.message.includes("PI_PLASTIC_CM_EXECUTABLE"), "Expected missing cm guidance to name PI_PLASTIC_CM_EXECUTABLE.");
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const gitProc = new FakeChildProcess();
|
|
61
|
+
const missingGit = __plasticProcessInternals.spawnAndCollect("git", ["--version"], process.cwd(), undefined, undefined, {
|
|
62
|
+
spawn: fakeSpawn(gitProc),
|
|
63
|
+
});
|
|
64
|
+
gitProc.emit("error", Object.assign(new Error("spawn git ENOENT"), { code: "ENOENT" }));
|
|
65
|
+
await missingGit.then(
|
|
66
|
+
() => { throw new Error("Expected a missing Git executable to reject."); },
|
|
67
|
+
(reason: unknown) =>
|
|
68
|
+
{
|
|
69
|
+
assert(reason instanceof Error && reason.message.includes("PI_PLASTIC_GIT_EXECUTABLE"), "Expected missing git guidance to name PI_PLASTIC_GIT_EXECUTABLE.");
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const testAbortEscalatesUntilTerminalSettlement = async (): Promise<void> =>
|
|
75
|
+
{
|
|
76
|
+
const proc = new FakeChildProcess();
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
let scheduledKill: (() => void) | undefined;
|
|
79
|
+
let clearedTimerCount = 0;
|
|
80
|
+
|
|
81
|
+
const resultPromise = __plasticProcessInternals.spawnAndCollect("fake", [], process.cwd(), undefined, controller.signal, {
|
|
82
|
+
spawn: fakeSpawn(proc),
|
|
83
|
+
setTimeout: (callback) =>
|
|
84
|
+
{
|
|
85
|
+
scheduledKill = callback;
|
|
86
|
+
return {} as NodeJS.Timeout;
|
|
87
|
+
},
|
|
88
|
+
clearTimeout: () =>
|
|
89
|
+
{
|
|
90
|
+
clearedTimerCount += 1;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
controller.abort();
|
|
95
|
+
assert(proc.killed, "Expected SIGTERM to mark the fake child as killed.");
|
|
96
|
+
assert(proc.signals.join(",") === "SIGTERM", "Expected abort to send SIGTERM first.");
|
|
97
|
+
assert(scheduledKill !== undefined, "Expected abort to schedule escalation.");
|
|
98
|
+
|
|
99
|
+
// ChildProcess.killed means only that SIGTERM was sent; a still-running child needs SIGKILL.
|
|
100
|
+
scheduledKill?.();
|
|
101
|
+
assert(proc.signals.join(",") === "SIGTERM,SIGKILL", "Expected a non-settled child to receive SIGKILL even after SIGTERM was sent.");
|
|
102
|
+
|
|
103
|
+
proc.close();
|
|
104
|
+
const result = await resultPromise;
|
|
105
|
+
assert(result.aborted, "Expected abort state to be reported after terminal close.");
|
|
106
|
+
assert(clearedTimerCount === 1, "Expected the escalation timer to be cleared during cleanup.");
|
|
107
|
+
|
|
108
|
+
controller.abort();
|
|
109
|
+
assert(proc.signals.join(",") === "SIGTERM,SIGKILL", "Expected abort listener cleanup after terminal settlement.");
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const testTerminalListenersExistBeforeStdinWrite = async (): Promise<void> =>
|
|
113
|
+
{
|
|
114
|
+
const proc = new FakeChildProcess();
|
|
115
|
+
proc.stdin.write = ((_chunk: unknown, _callback?: (error?: Error | null) => void): boolean =>
|
|
116
|
+
{
|
|
117
|
+
proc.stdout.end();
|
|
118
|
+
proc.stderr.end();
|
|
119
|
+
// Deliberately do not invoke the write callback: terminal error must still settle.
|
|
120
|
+
proc.emit("error", new Error("stdin-triggered process failure"));
|
|
121
|
+
return true;
|
|
122
|
+
}) as typeof proc.stdin.write;
|
|
123
|
+
|
|
124
|
+
let thrown: unknown;
|
|
125
|
+
try
|
|
126
|
+
{
|
|
127
|
+
await __plasticProcessInternals.spawnAndCollect("fake", [], process.cwd(), "input", undefined, {
|
|
128
|
+
spawn: fakeSpawn(proc),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (error)
|
|
132
|
+
{
|
|
133
|
+
thrown = error;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
assert(thrown instanceof Error && thrown.message === "stdin-triggered process failure", "Expected process error raised during stdin write to reject cleanly.");
|
|
137
|
+
assert(proc.listenerCount("error") === 0 && proc.listenerCount("close") === 0, "Expected terminal listeners to be removed after rejection.");
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const main = async (): Promise<void> =>
|
|
141
|
+
{
|
|
142
|
+
await testExecutableResolutionAndDiagnostics();
|
|
143
|
+
await testAbortEscalatesUntilTerminalSettlement();
|
|
144
|
+
await testTerminalListenersExistBeforeStdinWrite();
|
|
145
|
+
console.log("PASS: plastic process lifecycle tests passed");
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
void main();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const indexText = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
|
|
5
|
+
|
|
6
|
+
assert.match(indexText, /const COLLAPSED_RESULT_LINES = 12;/, "plastic tool results should have a compact collapsed preview limit");
|
|
7
|
+
assert.match(indexText, /function renderPlasticCall\(/, "plastic tools should provide custom call rendering");
|
|
8
|
+
assert.match(indexText, /function renderPlasticResult\(/, "plastic tools should provide custom result rendering");
|
|
9
|
+
assert.match(indexText, /renderCall\(args, theme\) \{/, "registered plastic tools should wire renderCall");
|
|
10
|
+
assert.match(indexText, /renderResult\(result, options, theme\) \{/, "registered plastic tools should wire renderResult");
|
|
11
|
+
assert.match(indexText, /options\?\.expanded \? lines\.length : COLLAPSED_RESULT_LINES/, "collapsed result rendering should limit visible output until expansion");
|
|
12
|
+
assert.match(indexText, /ctrl\+o to expand/, "collapsed result rendering should advertise the Ctrl+O expansion shortcut");
|
|
13
|
+
assert.match(indexText, /truncateAnsiLine/, "custom rendering should remain width-safe for long output lines");
|
|
14
|
+
assert.match(indexText, /themed\(theme, "toolOutput", line\)/, "tool output lines should use the toolOutput theme color");
|
|
15
|
+
|
|
16
|
+
console.log("PASS: plastic tool rendering test succeeded");
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
|
|
3
|
+
const pass = (msg: string): void => console.log(`PASS: ${msg}`);
|
|
4
|
+
const fail = (msg: string): void => console.log(`FAIL: ${msg}`);
|
|
5
|
+
|
|
6
|
+
const readText = (fileURL: URL): string => readFileSync(fileURL, "utf8");
|
|
7
|
+
|
|
8
|
+
const checkRequired = (contents: string, target: string, snippets: string[]): number => {
|
|
9
|
+
let failures = 0;
|
|
10
|
+
for (const snippet of snippets) {
|
|
11
|
+
if (contents.includes(snippet)) {
|
|
12
|
+
pass(`${target} contains: ${snippet}`);
|
|
13
|
+
} else {
|
|
14
|
+
fail(`${target} missing required snippet: ${snippet}`);
|
|
15
|
+
failures += 1;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return failures;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const checkForbidden = (contents: string, target: string, patterns: RegExp[]): number => {
|
|
22
|
+
let failures = 0;
|
|
23
|
+
for (const pattern of patterns) {
|
|
24
|
+
if (pattern.test(contents)) {
|
|
25
|
+
fail(`${target} matched forbidden pattern: ${pattern}`);
|
|
26
|
+
failures += 1;
|
|
27
|
+
} else {
|
|
28
|
+
pass(`${target} does not match: ${pattern}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return failures;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const main = (): void => {
|
|
35
|
+
let failures = 0;
|
|
36
|
+
|
|
37
|
+
const plasticPath = new URL("../src/plastic-core.ts", import.meta.url);
|
|
38
|
+
const skillPath = new URL("../skills/using-plastic/SKILL.md", import.meta.url);
|
|
39
|
+
const reviewingPath = new URL("../skills/using-plastic/references/reviewing-changes.md", import.meta.url);
|
|
40
|
+
const changesetPath = new URL("../skills/using-plastic/references/changeset-operations.md", import.meta.url);
|
|
41
|
+
const troubleshootingPath = new URL("../skills/using-plastic/references/troubleshooting.md", import.meta.url);
|
|
42
|
+
const integrationPath = new URL("../skills/using-plastic/references/integration.md", import.meta.url);
|
|
43
|
+
const bashGuardPath = new URL("../extensions/bash-cm-diff-guard.ts", import.meta.url);
|
|
44
|
+
const bashMergeGuardPath = new URL("../extensions/bash-cm-merge-guard.ts", import.meta.url);
|
|
45
|
+
|
|
46
|
+
const plasticText = readText(plasticPath);
|
|
47
|
+
failures += checkRequired(plasticText, "pi-plastic/src/plastic-core.ts", [
|
|
48
|
+
"const BLOCKED_CM_DIFF_MESSAGE",
|
|
49
|
+
"ensureCmCommandAllowed(args);",
|
|
50
|
+
'command === "diff"',
|
|
51
|
+
"export const merge = tool({",
|
|
52
|
+
"export const mergeToBranch = tool({",
|
|
53
|
+
"resolveBranchParentName",
|
|
54
|
+
"export const finalizeMerge = tool({",
|
|
55
|
+
"export const resolveDeleteChangeConflict = tool({",
|
|
56
|
+
"export const patch = tool({",
|
|
57
|
+
"__plasticPatchInternals",
|
|
58
|
+
"buildPatchCommandArgs",
|
|
59
|
+
"export const diffRevisions = tool({",
|
|
60
|
+
"plastic_diff is disabled",
|
|
61
|
+
"parseMachineReadablePendingItems",
|
|
62
|
+
"summarizePendingItems",
|
|
63
|
+
"selectPrivatePathsForAutoAdd",
|
|
64
|
+
"filterPendingItemsByScope",
|
|
65
|
+
"isNoChangesWorkspaceCheckinError",
|
|
66
|
+
"SENSITIVE_PRIVATE_PATH_PATTERNS",
|
|
67
|
+
"auto-add-private-retry-success",
|
|
68
|
+
"__plasticSwitchInternals",
|
|
69
|
+
"normalizeBranchSpecForComparison",
|
|
70
|
+
"isSwitchBringBlockedForUnattended",
|
|
71
|
+
"canSwitchDirectWithPrivateOnlyPending",
|
|
72
|
+
"direct-switch-private-only",
|
|
73
|
+
"resolveCheckinPaths",
|
|
74
|
+
"buildFallbackScopePaths",
|
|
75
|
+
"--nointeractiveresolution",
|
|
76
|
+
"--mergetype=try",
|
|
77
|
+
"--automaticresolution=all-",
|
|
78
|
+
"FILE_CONFLICT",
|
|
79
|
+
"isMergeInProgressCheckinError",
|
|
80
|
+
"buildMergeInProgressCheckinMessage",
|
|
81
|
+
"updateAfter is disabled for unattended safety",
|
|
82
|
+
'["update", "--dontmerge", "--noinput"]',
|
|
83
|
+
]);
|
|
84
|
+
failures += checkForbidden(plasticText, "pi-plastic/src/plastic-core.ts", [
|
|
85
|
+
/runCm\(\["diff"/,
|
|
86
|
+
/runCmRaw\(\["diff"/,
|
|
87
|
+
/runCm\(\["update"\]/,
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
const skillText = readText(skillPath);
|
|
91
|
+
failures += checkRequired(skillText, "pi-plastic/skills/using-plastic/SKILL.md", [
|
|
92
|
+
"Never run `cm diff` in Pi.",
|
|
93
|
+
"plastic_diffRevisions",
|
|
94
|
+
"plastic_patch",
|
|
95
|
+
"plastic_merge",
|
|
96
|
+
"plastic_mergeToBranch",
|
|
97
|
+
"source branch's Plastic parent branch",
|
|
98
|
+
]);
|
|
99
|
+
failures += checkForbidden(skillText, "pi-plastic/skills/using-plastic/SKILL.md", [
|
|
100
|
+
/^\s*-\s+plastic_diff\s*$/m,
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
const refs = [
|
|
104
|
+
["reviewing-changes.md", readText(reviewingPath)],
|
|
105
|
+
["changeset-operations.md", readText(changesetPath)],
|
|
106
|
+
["troubleshooting.md", readText(troubleshootingPath)],
|
|
107
|
+
["integration.md", readText(integrationPath)],
|
|
108
|
+
] as const;
|
|
109
|
+
|
|
110
|
+
for (const [name, text] of refs) {
|
|
111
|
+
failures += checkForbidden(text, `pi-plastic/skills/using-plastic/references/${name}`, [
|
|
112
|
+
/cm diff\s+br:/,
|
|
113
|
+
/cm diff\s+cs:/,
|
|
114
|
+
/cm diff\s+lb:/,
|
|
115
|
+
/cm diff\s+sh:/,
|
|
116
|
+
/cm diff\s+rev:/,
|
|
117
|
+
/plastic_diff\(source=/,
|
|
118
|
+
/list changed files/i,
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const bashGuardText = readText(bashGuardPath);
|
|
123
|
+
failures += checkRequired(bashGuardText, "pi-plastic/extensions/bash-cm-diff-guard.ts", [
|
|
124
|
+
'pi.on("tool_call"',
|
|
125
|
+
"isBashToolCall",
|
|
126
|
+
"commandRunsCmDiff",
|
|
127
|
+
"__bashCmDiffGuardInternals",
|
|
128
|
+
"block: true",
|
|
129
|
+
"reason: BLOCK_MESSAGE",
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
const bashMergeGuardText = readText(bashMergeGuardPath);
|
|
133
|
+
failures += checkRequired(bashMergeGuardText, "pi-plastic/extensions/bash-cm-merge-guard.ts", [
|
|
134
|
+
'pi.on("tool_call"',
|
|
135
|
+
"isBashToolCall",
|
|
136
|
+
"commandRunsUnsafeCmMerge",
|
|
137
|
+
"__bashCmMergeGuardInternals",
|
|
138
|
+
"plastic_resolveDeleteChangeConflict",
|
|
139
|
+
"--nointeractiveresolution",
|
|
140
|
+
"--mergetype=try",
|
|
141
|
+
"block: true",
|
|
142
|
+
"reason: BLOCK_MESSAGE",
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
if (failures > 0) {
|
|
146
|
+
console.log(`FAIL: plastic validation failed with ${failures} issue(s)`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log("PASS: plastic validation succeeded");
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
main();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import {
|
|
6
|
+
discoverPlasticWorkspace,
|
|
7
|
+
parsePlasticSelector,
|
|
8
|
+
parsePlasticStatusBranch,
|
|
9
|
+
sanitizePlasticBranch,
|
|
10
|
+
} from "../src/plastic-workspace.ts";
|
|
11
|
+
|
|
12
|
+
const fixture = async (name: string): Promise<string> => readFile(new URL(`./fixtures/${name}`, import.meta.url), "utf8");
|
|
13
|
+
|
|
14
|
+
const root = await mkdtemp(join(tmpdir(), "pi-plastic-workspace-"));
|
|
15
|
+
try {
|
|
16
|
+
const outer = join(root, "outer");
|
|
17
|
+
const inner = join(outer, "nested", "inner");
|
|
18
|
+
const cwd = join(inner, "src", "feature");
|
|
19
|
+
await mkdir(join(outer, ".plastic"), { recursive: true });
|
|
20
|
+
await writeFile(join(outer, ".plastic", "plastic.workspace"), "sanitized workspace marker\n");
|
|
21
|
+
await mkdir(join(inner, ".plastic"), { recursive: true });
|
|
22
|
+
await writeFile(join(inner, ".plastic", "plastic.workspace"), "nested sanitized marker\n");
|
|
23
|
+
await mkdir(cwd, { recursive: true });
|
|
24
|
+
|
|
25
|
+
const nested = await discoverPlasticWorkspace(cwd);
|
|
26
|
+
assert.equal(nested.kind, "found");
|
|
27
|
+
if (nested.kind === "found") assert.equal(nested.value.root, await realpath(inner));
|
|
28
|
+
|
|
29
|
+
await rm(join(inner, ".plastic", "plastic.workspace"));
|
|
30
|
+
const outerResult = await discoverPlasticWorkspace(cwd);
|
|
31
|
+
assert.equal(outerResult.kind, "found");
|
|
32
|
+
if (outerResult.kind === "found") assert.equal(outerResult.value.root, await realpath(outer));
|
|
33
|
+
|
|
34
|
+
const gitOnly = join(root, "git-only", ".git");
|
|
35
|
+
await mkdir(gitOnly, { recursive: true });
|
|
36
|
+
assert.equal((await discoverPlasticWorkspace(join(root, "git-only"))).kind, "not_found");
|
|
37
|
+
|
|
38
|
+
const selector = parsePlasticSelector(await fixture("plastic.selector"));
|
|
39
|
+
assert.deepEqual(selector, {
|
|
40
|
+
kind: "found",
|
|
41
|
+
value: { repository: "sanitized-repository@sanitized-server", branch: "/main/feature/footer" },
|
|
42
|
+
});
|
|
43
|
+
assert.equal(parsePlasticSelector("repository sample@server\n path /\n").kind, "not_found");
|
|
44
|
+
assert.equal(parsePlasticSelector("smartbranch \n").kind, "malformed");
|
|
45
|
+
assert.equal(parsePlasticSelector("smartbranch /main/bad\u0007branch\n").kind, "malformed");
|
|
46
|
+
|
|
47
|
+
const normal = parsePlasticStatusBranch(await fixture("status-normal.txt"));
|
|
48
|
+
const compact = parsePlasticStatusBranch(await fixture("status-compact.txt"));
|
|
49
|
+
assert.deepEqual(normal, { kind: "found", value: { branch: "/main/feature/footer" } });
|
|
50
|
+
assert.deepEqual(compact, normal, "normal and compact status forms should yield the same branch path");
|
|
51
|
+
assert.deepEqual(parsePlasticStatusBranch("status cs:731\n"), { kind: "found", value: { changesetId: "731" } });
|
|
52
|
+
assert.equal(parsePlasticStatusBranch("unexpected output\n").kind, "malformed");
|
|
53
|
+
assert.equal(parsePlasticStatusBranch("\n").kind, "not_found");
|
|
54
|
+
|
|
55
|
+
assert.equal(sanitizePlasticBranch(" /main/feature\u0007 footer "), "/main/feature footer");
|
|
56
|
+
assert.equal(sanitizePlasticBranch("x".repeat(81)), `${"x".repeat(79)}…`);
|
|
57
|
+
assert.equal(sanitizePlasticBranch("界".repeat(41)), `${"界".repeat(39)}…`);
|
|
58
|
+
} finally {
|
|
59
|
+
await rm(root, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log("PASS: Plastic workspace discovery and parser tests passed");
|