@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,219 @@
|
|
|
1
|
+
import { __plasticCheckinInternals, __plasticSwitchInternals } 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 normalizePath = (pathValue: string): string => pathValue.replace(/\\/g, "/").replace(/^[A-Za-z]:/, "");
|
|
12
|
+
const pathMatches = (actualPath: string, expectedPath: string): boolean =>
|
|
13
|
+
{
|
|
14
|
+
const actual = normalizePath(actualPath);
|
|
15
|
+
const expected = normalizePath(expectedPath);
|
|
16
|
+
return actual === expected || actual.endsWith(`/${expected}`);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const main = (): void =>
|
|
20
|
+
{
|
|
21
|
+
const cwd = "/repo/ws";
|
|
22
|
+
const machineOutput = [
|
|
23
|
+
"STATUS 123 Normandie/Repo server",
|
|
24
|
+
"CH /repo/ws/Assets/Gameplay/Movement.cs False NO_MERGES",
|
|
25
|
+
"LD /repo/ws/Assets/Gameplay/FakeModifierService.cs False NO_MERGES",
|
|
26
|
+
"MV /repo/ws/Assets/Gameplay/MockModifierService.cs False NO_MERGES",
|
|
27
|
+
"CH /repo/ws/ProjectSettings/ProjectSettings.asset False NO_MERGES",
|
|
28
|
+
].join("\n");
|
|
29
|
+
|
|
30
|
+
const pendingItems = __plasticCheckinInternals.parseMachineReadablePendingItems(machineOutput, cwd);
|
|
31
|
+
assert(pendingItems.length === 4, "Expected parser to return four pending items.");
|
|
32
|
+
assert(pendingItems.some((item) => item.kind === "deleted"), "Expected at least one deleted pending item.");
|
|
33
|
+
assert(pendingItems.some((item) => item.kind === "moved"), "Expected at least one moved pending item.");
|
|
34
|
+
assert(__plasticCheckinInternals.inferPendingItemKind("CO+RP") === "changed", "Expected CO+RP to classify as changed.");
|
|
35
|
+
|
|
36
|
+
const pendingSummary = __plasticCheckinInternals.summarizePendingItems(pendingItems, cwd);
|
|
37
|
+
assert(pendingSummary.totalPending === 4, "Expected pending summary total count to match parsed items.");
|
|
38
|
+
assert(pendingSummary.tracked === 4, "Expected tracked count to equal total when no private items exist.");
|
|
39
|
+
assert(pendingSummary.private === 0, "Expected no private items in initial sample summary.");
|
|
40
|
+
|
|
41
|
+
const changedOnly = __plasticCheckinInternals.resolveCheckinPaths([
|
|
42
|
+
"Assets/Gameplay/Movement.cs",
|
|
43
|
+
], pendingItems, cwd);
|
|
44
|
+
assert(changedOnly.includedPaths.length === 1, "Expected one included path for changed file scope.");
|
|
45
|
+
assert(pathMatches(changedOnly.includedPaths[0], "Assets/Gameplay/Movement.cs"), "Expected changed file path to be preserved.");
|
|
46
|
+
assert(changedOnly.shouldApplyChanged === false, "Expected changed-only scope to avoid auto --applychanged.");
|
|
47
|
+
|
|
48
|
+
const deletedOnly = __plasticCheckinInternals.resolveCheckinPaths([
|
|
49
|
+
"Assets/Gameplay/FakeModifierService.cs",
|
|
50
|
+
], pendingItems, cwd);
|
|
51
|
+
assert(deletedOnly.includedPaths.length === 1, "Expected one included path for deleted file scope.");
|
|
52
|
+
assert(pathMatches(deletedOnly.includedPaths[0], "Assets/Gameplay/FakeModifierService.cs"), "Expected deleted-file scope to preserve the requested path before fallback retry.");
|
|
53
|
+
assert(deletedOnly.shouldApplyChanged, "Expected deleted-file scope to enable --applychanged.");
|
|
54
|
+
assert(deletedOnly.fallbackPaths.length === 1 && pathMatches(deletedOnly.fallbackPaths[0], "Assets/Gameplay"),
|
|
55
|
+
"Expected deleted-file fallback scope to target the parent directory.");
|
|
56
|
+
|
|
57
|
+
const directoryScope = __plasticCheckinInternals.resolveCheckinPaths([
|
|
58
|
+
"Assets/Gameplay",
|
|
59
|
+
], pendingItems, cwd);
|
|
60
|
+
assert(directoryScope.includedPaths.length === 1, "Expected one included path for directory scope.");
|
|
61
|
+
assert(pathMatches(directoryScope.includedPaths[0], "Assets/Gameplay"), "Expected directory scope to remain stable.");
|
|
62
|
+
assert(directoryScope.shouldApplyChanged, "Expected directory scope with deleted/moved items to enable --applychanged.");
|
|
63
|
+
|
|
64
|
+
const noMatch = __plasticCheckinInternals.resolveCheckinPaths([
|
|
65
|
+
"Assets/UI/Nope.cs",
|
|
66
|
+
], pendingItems, cwd);
|
|
67
|
+
assert(noMatch.includedPaths.length === 0, "Expected no included paths for unmatched scope.");
|
|
68
|
+
assert(noMatch.excludedPaths.length === 1, "Expected one excluded path for unmatched scope.");
|
|
69
|
+
assert(noMatch.excludedPaths[0].reason === "no_pending_changes", "Expected unmatched scope reason to be no_pending_changes.");
|
|
70
|
+
|
|
71
|
+
const windowsCwd = "C:/Workspaces/GameProject/ws3";
|
|
72
|
+
const windowsMachineOutput = [
|
|
73
|
+
"STATUS 1147 ExampleRepo/game-unity example@server",
|
|
74
|
+
"CH c:/Workspaces/GameProject/ws3/game-unity/Assets/Game/code/Runtime/Paths.cs False NO_MERGES",
|
|
75
|
+
"CH c:/Workspaces/GameProject/ws3/game-unity/Assets/Game/code/Editor/Validation/MapGraphValidationBatchTools.cs False NO_MERGES",
|
|
76
|
+
].join("\n");
|
|
77
|
+
const windowsPendingItems = __plasticCheckinInternals.parseMachineReadablePendingItems(windowsMachineOutput, windowsCwd);
|
|
78
|
+
assert(windowsPendingItems.length === 2, "Expected Windows parser sample to return two pending items.");
|
|
79
|
+
|
|
80
|
+
const windowsRelativeScope = __plasticCheckinInternals.resolveCheckinPaths([
|
|
81
|
+
"game-unity/Assets/Game/code/Runtime/Paths.cs",
|
|
82
|
+
], windowsPendingItems, windowsCwd);
|
|
83
|
+
assert(windowsRelativeScope.includedPaths.length === 1, "Expected Windows relative scope to match pending file despite drive-letter case mismatch.");
|
|
84
|
+
assert(pathMatches(windowsRelativeScope.includedPaths[0], "game-unity/Assets/Game/code/Runtime/Paths.cs"), "Expected Windows relative scope to keep workspace-relative command path.");
|
|
85
|
+
|
|
86
|
+
const windowsAbsoluteScope = __plasticCheckinInternals.resolveCheckinPaths([
|
|
87
|
+
"C:/Workspaces/GameProject/ws3/game-unity/Assets/Game/code/Runtime/Paths.cs",
|
|
88
|
+
], windowsPendingItems, windowsCwd);
|
|
89
|
+
assert(windowsAbsoluteScope.includedPaths.length === 1, "Expected Windows absolute scope to match pending file despite drive-letter case mismatch.");
|
|
90
|
+
assert(pathMatches(windowsAbsoluteScope.includedPaths[0], "game-unity/Assets/Game/code/Runtime/Paths.cs"), "Expected Windows absolute scope to collapse back to workspace-relative command path.");
|
|
91
|
+
|
|
92
|
+
const windowsDirectoryScope = __plasticCheckinInternals.resolveCheckinPaths([
|
|
93
|
+
"GAME-UNITY/Assets/Game/code/Editor",
|
|
94
|
+
], windowsPendingItems, windowsCwd);
|
|
95
|
+
assert(windowsDirectoryScope.includedPaths.length === 1, "Expected Windows directory scope to match pending children despite path case differences.");
|
|
96
|
+
assert(pathMatches(windowsDirectoryScope.includedPaths[0], "GAME-UNITY/Assets/Game/code/Editor"), "Expected Windows directory scope to preserve the requested command path casing.");
|
|
97
|
+
|
|
98
|
+
const windowsDuplicateScope = __plasticCheckinInternals.resolveCheckinPaths([
|
|
99
|
+
"GAME-UNITY/Assets/Game/code/Runtime/Paths.cs",
|
|
100
|
+
"game-unity/Assets/Game/code/Runtime/Paths.cs",
|
|
101
|
+
], windowsPendingItems, windowsCwd);
|
|
102
|
+
assert(windowsDuplicateScope.includedPaths.length === 1, "Expected Windows duplicate scopes that differ only by case to dedupe to one command path.");
|
|
103
|
+
|
|
104
|
+
assert(__plasticCheckinInternals.isSameFilesystemDevice(100, 100), "Expected Darwin case probing to remain on the target filesystem device.");
|
|
105
|
+
assert(!__plasticCheckinInternals.isSameFilesystemDevice(100, 200), "Expected Darwin case probing to stop before crossing a mount-device boundary.");
|
|
106
|
+
|
|
107
|
+
const comparisonPath = "/Volumes/Workspace/Assets/Player.cs";
|
|
108
|
+
const darwinInsensitiveKey = __plasticCheckinInternals.toPathComparisonKeyFromAbsolutePath(comparisonPath, {
|
|
109
|
+
platform: "darwin",
|
|
110
|
+
isDarwinCaseInsensitiveVolume: () => true,
|
|
111
|
+
});
|
|
112
|
+
const darwinSensitiveKey = __plasticCheckinInternals.toPathComparisonKeyFromAbsolutePath(comparisonPath, {
|
|
113
|
+
platform: "darwin",
|
|
114
|
+
isDarwinCaseInsensitiveVolume: () => false,
|
|
115
|
+
});
|
|
116
|
+
assert(darwinInsensitiveKey === comparisonPath.toLowerCase(), "Expected a detected case-insensitive Darwin volume to normalize comparison keys.");
|
|
117
|
+
assert(darwinSensitiveKey === comparisonPath, "Expected a case-sensitive APFS volume to preserve comparison-key casing.");
|
|
118
|
+
assert(__plasticCheckinInternals.toPathComparisonKeyFromAbsolutePath("C:/Workspace/Assets/Player.cs", {
|
|
119
|
+
platform: "darwin",
|
|
120
|
+
isDarwinCaseInsensitiveVolume: () => false,
|
|
121
|
+
}) === "c:/workspace/assets/player.cs", "Expected Windows drive paths to remain case-insensitive on every host platform.");
|
|
122
|
+
|
|
123
|
+
const gameplayScope = pendingItems.find((item) => item.workspacePath.endsWith("Movement.cs"))?.normalizedPath.replace(/\/Movement\.cs$/, "") ?? "";
|
|
124
|
+
const scopedPendingItems = __plasticCheckinInternals.filterPendingItemsByScope(
|
|
125
|
+
pendingItems,
|
|
126
|
+
gameplayScope.length > 0 ? [gameplayScope] : [],
|
|
127
|
+
);
|
|
128
|
+
assert(scopedPendingItems.length === 3, "Expected scope-filter helper to return only pending items under the requested path.");
|
|
129
|
+
|
|
130
|
+
const fallbackPaths = __plasticCheckinInternals.buildFallbackScopePaths([
|
|
131
|
+
"/repo/ws/Assets/Gameplay/Movement.cs",
|
|
132
|
+
"/repo/ws/Assets/Gameplay/FakeModifierService.cs",
|
|
133
|
+
], cwd);
|
|
134
|
+
assert(fallbackPaths.length === 1 && pathMatches(fallbackPaths[0], "Assets/Gameplay"), "Expected fallback paths to collapse to the common parent scope.");
|
|
135
|
+
|
|
136
|
+
const normalizedDiffRevision = __plasticCheckinInternals.normalizeDiffFileRevisionSpec(
|
|
137
|
+
"Assets/Gameplay/Movement.cs",
|
|
138
|
+
"cs:947",
|
|
139
|
+
);
|
|
140
|
+
assert(normalizedDiffRevision === "Assets/Gameplay/Movement.cs#cs:947", "Expected diff-file revision shorthand to be expanded with path scope.");
|
|
141
|
+
|
|
142
|
+
const numericDiffRevision = __plasticCheckinInternals.normalizeDiffFileRevisionSpec(
|
|
143
|
+
"Assets/Gameplay/Movement.cs",
|
|
144
|
+
"947",
|
|
145
|
+
);
|
|
146
|
+
assert(numericDiffRevision === "Assets/Gameplay/Movement.cs#cs:947", "Expected numeric diff-file revision shorthand to normalize to cs: selector.");
|
|
147
|
+
|
|
148
|
+
const scopedRevision = __plasticCheckinInternals.normalizeDiffFileRevisionSpec(
|
|
149
|
+
"Assets/Gameplay/Movement.cs",
|
|
150
|
+
"Assets/Gameplay/Movement.cs#cs:947",
|
|
151
|
+
);
|
|
152
|
+
assert(scopedRevision === "Assets/Gameplay/Movement.cs#cs:947", "Expected already scoped revision to remain unchanged.");
|
|
153
|
+
|
|
154
|
+
assert(__plasticCheckinInternals.isUnscopedDiffRevisionSpec("cs:947"), "Expected cs selector without file scope to be detected as unscoped.");
|
|
155
|
+
assert(__plasticCheckinInternals.isUnscopedDiffRevisionSpec("947"), "Expected numeric selector without file scope to be detected as unscoped.");
|
|
156
|
+
assert(!__plasticCheckinInternals.isUnscopedDiffRevisionSpec("Assets/Gameplay/Movement.cs#cs:947"), "Expected file-qualified selector not to be treated as unscoped.");
|
|
157
|
+
|
|
158
|
+
assert(__plasticCheckinInternals.isGitUnknownOptionLabelError("error: unknown option `label'"), "Expected backtick-style git unknown-option error to be detected.");
|
|
159
|
+
assert(__plasticCheckinInternals.isGitUnknownOptionLabelError("error: unknown option 'label'"), "Expected single-quote-style git unknown-option error to be detected.");
|
|
160
|
+
assert(!__plasticCheckinInternals.isGitUnknownOptionLabelError("fatal: unrelated error"), "Expected unrelated git errors not to match label-option detector.");
|
|
161
|
+
|
|
162
|
+
assert(__plasticCheckinInternals.isRevisionNotFoundError("The specified revision was not found foo#cs:1"), "Expected revision-not-found detector to match Plastic cat errors.");
|
|
163
|
+
assert(!__plasticCheckinInternals.isRevisionNotFoundError("fatal: unrelated error"), "Expected unrelated errors not to match revision-not-found detector.");
|
|
164
|
+
|
|
165
|
+
const branchSelectorFromRevision = __plasticCheckinInternals.extractBranchSelectorFromRevision("Assets/Gameplay/Movement.cs#br:/dev/task-123");
|
|
166
|
+
assert(branchSelectorFromRevision === "br:/dev/task-123", "Expected branch selector extraction from file-qualified revision.");
|
|
167
|
+
assert(__plasticCheckinInternals.extractBranchSelectorFromRevision("Assets/Gameplay/Movement.cs#cs:947") === null, "Expected no branch selector extraction for non-branch revisions.");
|
|
168
|
+
|
|
169
|
+
const branchName = __plasticCheckinInternals.extractBranchNameFromSelector("br:/dev/task-123@Repo@server");
|
|
170
|
+
assert(branchName === "/dev/task-123", "Expected branch selector repository suffix to be removed.");
|
|
171
|
+
assert(__plasticCheckinInternals.extractBranchNameFromSelector("br:") === null, "Expected invalid branch selector to return null branch name.");
|
|
172
|
+
|
|
173
|
+
assert(__plasticSwitchInternals.normalizeBranchSpecForComparison("br:/dev/task-123@Repo@server") === "/dev/task-123", "Expected switch branch normalization to remove br: prefix and repository suffix.");
|
|
174
|
+
assert(__plasticSwitchInternals.isSameBranchSpec("/dev/task-123", "br:/dev/task-123@Repo@server"), "Expected branch comparison helper to treat equivalent branch specs as equal.");
|
|
175
|
+
assert(!__plasticSwitchInternals.isSameBranchSpec("/dev/task-123", "/dev/task-124"), "Expected branch comparison helper to detect different branches.");
|
|
176
|
+
|
|
177
|
+
const machineOutputWithPrivate = [
|
|
178
|
+
"STATUS 123 Normandie/Repo server",
|
|
179
|
+
"PR /repo/ws/todos/059-ready.md False NO_MERGES",
|
|
180
|
+
"PR /repo/ws/.env.local False NO_MERGES",
|
|
181
|
+
"PR /repo/ws/src/id_rsa False NO_MERGES",
|
|
182
|
+
].join("\n");
|
|
183
|
+
const pendingItemsWithPrivate = __plasticCheckinInternals.parseMachineReadablePendingItems(machineOutputWithPrivate, cwd);
|
|
184
|
+
const privateSummary = __plasticCheckinInternals.summarizePendingItems(pendingItemsWithPrivate, cwd);
|
|
185
|
+
assert(privateSummary.private === 3, "Expected private summary to count private pending items.");
|
|
186
|
+
assert(privateSummary.tracked === 0, "Expected tracked count to be zero for private-only sample.");
|
|
187
|
+
assert(privateSummary.privatePaths.some((path) => pathMatches(path, "todos/059-ready.md")), "Expected summary private paths to include the expected markdown file.");
|
|
188
|
+
|
|
189
|
+
const privateOnlyProfile = __plasticSwitchInternals.buildSwitchPendingProfile(privateSummary);
|
|
190
|
+
assert(privateOnlyProfile.hasPrivateOnlyPendingChanges, "Expected switch pending profile to classify private-only pending state.");
|
|
191
|
+
assert(__plasticSwitchInternals.canSwitchDirectWithPrivateOnlyPending("bring", false, privateOnlyProfile), "Expected private-only bring policy to allow direct unattended switch.");
|
|
192
|
+
assert(__plasticSwitchInternals.canSwitchDirectWithPrivateOnlyPending("shelve", false, privateOnlyProfile), "Expected private-only shelve policy to allow direct unattended switch.");
|
|
193
|
+
assert(__plasticSwitchInternals.canSwitchDirectWithPrivateOnlyPending("cancel", true, privateOnlyProfile), "Expected defaulted cancel policy to allow direct private-only switch.");
|
|
194
|
+
assert(!__plasticSwitchInternals.canSwitchDirectWithPrivateOnlyPending("cancel", false, privateOnlyProfile), "Expected explicit cancel policy to keep private-only switch blocked.");
|
|
195
|
+
|
|
196
|
+
assert(__plasticCheckinInternals.getSensitivePrivatePathReason(".env.local") === "sensitive_path:dotenv", "Expected dotenv path to be blocked as sensitive.");
|
|
197
|
+
assert(__plasticCheckinInternals.getSensitivePrivatePathReason("src/id_rsa") === "sensitive_path:ssh-key", "Expected SSH key path to be blocked as sensitive.");
|
|
198
|
+
assert(__plasticCheckinInternals.getSensitivePrivatePathReason("todos/059-ready.md") === null, "Expected normal markdown path not to be blocked as sensitive.");
|
|
199
|
+
|
|
200
|
+
const autoAddSelection = __plasticCheckinInternals.selectPrivatePathsForAutoAdd(pendingItemsWithPrivate, [], cwd);
|
|
201
|
+
assert(autoAddSelection.candidatePaths.length === 1, "Expected one safe private auto-add candidate.");
|
|
202
|
+
assert(pathMatches(autoAddSelection.candidatePaths[0], "todos/059-ready.md"), "Expected private auto-add candidate to keep relative file path.");
|
|
203
|
+
assert(autoAddSelection.blockedPaths.length === 2, "Expected two sensitive private paths to be blocked from auto-add.");
|
|
204
|
+
|
|
205
|
+
const trackedPendingProfile = __plasticSwitchInternals.buildSwitchPendingProfile(pendingSummary);
|
|
206
|
+
assert(__plasticSwitchInternals.isSwitchBringBlockedForUnattended("bring", trackedPendingProfile), "Expected bring policy to be blocked when tracked pending changes exist.");
|
|
207
|
+
assert(!__plasticSwitchInternals.isSwitchBringBlockedForUnattended("bring", privateOnlyProfile), "Expected bring policy not to be blocked for private-only pending changes.");
|
|
208
|
+
|
|
209
|
+
const legacyPendingSummary = __plasticSwitchInternals.toLegacyPendingSummary(privateSummary);
|
|
210
|
+
assert(legacyPendingSummary.totalPending === privateSummary.totalPending, "Expected legacy pending summary total count to match detailed summary.");
|
|
211
|
+
assert(legacyPendingSummary.other >= privateSummary.private, "Expected legacy pending summary to account for private items in other count.");
|
|
212
|
+
|
|
213
|
+
assert(__plasticCheckinInternals.isNoChangesWorkspaceCheckinError("Error: There are no changes in the workspace c:/repo/ws"), "Expected no-changes detector to match workspace no-changes errors.");
|
|
214
|
+
assert(!__plasticCheckinInternals.isNoChangesWorkspaceCheckinError("Error: path is not changed in current workspace"), "Expected no-changes detector not to match path-scope errors.");
|
|
215
|
+
|
|
216
|
+
console.log("PASS: plastic checkin path resolution tests passed");
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
main();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { loadRegisteredTools } from "./pi-tool-harness.ts";
|
|
3
|
+
|
|
4
|
+
type JsonSchema = {
|
|
5
|
+
type?: string;
|
|
6
|
+
properties?: Record<string, JsonSchema>;
|
|
7
|
+
required?: string[];
|
|
8
|
+
additionalProperties?: boolean | JsonSchema;
|
|
9
|
+
items?: JsonSchema;
|
|
10
|
+
anyOf?: JsonSchema[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const collectOpenAiStrictIssues = (schema: JsonSchema, path = "$", issues: string[] = []): string[] => {
|
|
14
|
+
if (schema.type === "object") {
|
|
15
|
+
if (schema.additionalProperties !== false) {
|
|
16
|
+
issues.push(`${path} must set additionalProperties to false`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const propertyNames = Object.keys(schema.properties ?? {});
|
|
20
|
+
const required = new Set(schema.required ?? []);
|
|
21
|
+
for (const propertyName of propertyNames) {
|
|
22
|
+
if (!required.has(propertyName)) {
|
|
23
|
+
issues.push(`${path}.${propertyName} is optional`);
|
|
24
|
+
}
|
|
25
|
+
collectOpenAiStrictIssues(schema.properties![propertyName], `${path}.${propertyName}`, issues);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (schema.items) {
|
|
30
|
+
collectOpenAiStrictIssues(schema.items, `${path}[]`, issues);
|
|
31
|
+
}
|
|
32
|
+
for (const [index, variant] of (schema.anyOf ?? []).entries()) {
|
|
33
|
+
collectOpenAiStrictIssues(variant, `${path}.anyOf[${index}]`, issues);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return issues;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const main = async (): Promise<void> => {
|
|
40
|
+
const tools = await loadRegisteredTools();
|
|
41
|
+
const report = [...tools.values()].map((tool) => {
|
|
42
|
+
const schema = tool.parameters as JsonSchema;
|
|
43
|
+
return {
|
|
44
|
+
name: tool.name,
|
|
45
|
+
issues: collectOpenAiStrictIssues(schema),
|
|
46
|
+
constrainedSampling: tool.constrainedSampling,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
assert(report.length > 0, "Expected registered Plastic tools to audit.");
|
|
51
|
+
|
|
52
|
+
const optedIn = report.filter((entry) => entry.constrainedSampling !== undefined && entry.constrainedSampling !== false);
|
|
53
|
+
for (const entry of optedIn) {
|
|
54
|
+
if (entry.constrainedSampling && entry.constrainedSampling.type === "json_schema") {
|
|
55
|
+
assert.deepEqual(entry.issues, [], `${entry.name} must be OpenAI strict-schema compatible before opting in`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Pi 0.82 forwards a strict tool's schema without normalizing it. Every current
|
|
60
|
+
// Plastic schema has optional fields (at minimum workdir), and TypeBox objects
|
|
61
|
+
// currently allow unspecified additional properties. Enabling strict sampling
|
|
62
|
+
// would therefore make direct OpenAI requests fail or require a breaking public
|
|
63
|
+
// argument-schema redesign.
|
|
64
|
+
assert.equal(optedIn.length, 0, "No Plastic tool should opt in until its ordinary public schema is strict-compatible.");
|
|
65
|
+
assert.equal(report.filter((entry) => entry.issues.length === 0).length, 0, "The current catalog should be classified as blocked, not strict-ready.");
|
|
66
|
+
|
|
67
|
+
for (const pilotName of ["plastic_status", "plastic_branchExists", "plastic_branchCreate"]) {
|
|
68
|
+
const pilot = report.find((entry) => entry.name === pilotName);
|
|
69
|
+
assert(pilot, `Missing pilot tool ${pilotName}`);
|
|
70
|
+
assert(pilot.issues.some((issue) => issue.includes("additionalProperties")), `${pilotName} should report its closed-object blocker`);
|
|
71
|
+
assert(pilot.issues.some((issue) => issue.endsWith("workdir is optional")), `${pilotName} should report its optional workdir blocker`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(`PASS: audited ${report.length} Plastic schemas; 0 are currently OpenAI strict-ready`);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
void main();
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { __plasticDiffInternals, __plasticProcessInternals } from "../src/plastic-core.ts";
|
|
6
|
+
|
|
7
|
+
const root = await mkdtemp(join(tmpdir(), "pi-plastic-diff-"));
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const { resolveDiffFileRevision, isUnscopedDiffRevisionSpec, runPortableTextDiff, DIFF_OUTPUT_MAX_CHARS, safeTempExtension } = __plasticDiffInternals;
|
|
11
|
+
|
|
12
|
+
assert.deepEqual(
|
|
13
|
+
resolveDiffFileRevision("Assets/Scene.unity"),
|
|
14
|
+
{ supplied: null, kind: "workspace-base", resolved: "Assets/Scene.unity" },
|
|
15
|
+
"omitting revision should select the workspace-base path",
|
|
16
|
+
);
|
|
17
|
+
assert.deepEqual(
|
|
18
|
+
resolveDiffFileRevision("Assets/Scene.unity", "42"),
|
|
19
|
+
{ supplied: "42", kind: "changeset", resolved: "Assets/Scene.unity#cs:42" },
|
|
20
|
+
"a number must be a changeset selector, not an item revision",
|
|
21
|
+
);
|
|
22
|
+
assert.equal(resolveDiffFileRevision("Assets/Scene.unity", "br:/dev/diff").kind, "branch");
|
|
23
|
+
assert.equal(resolveDiffFileRevision("Assets/Scene.unity", "revid:123").kind, "global-revision");
|
|
24
|
+
assert.throws(() => resolveDiffFileRevision("Assets/Scene.unity", "base"), /Unsupported revision 'base'/);
|
|
25
|
+
assert.throws(() => resolveDiffFileRevision("Assets/Scene.unity", "cs:head"), /Unsupported revision 'cs:head'/);
|
|
26
|
+
assert.throws(() => resolveDiffFileRevision("Assets/Scene.unity", "cs:br:\/main"), /Unsupported revision/);
|
|
27
|
+
assert.equal(isUnscopedDiffRevisionSpec("base"), true);
|
|
28
|
+
assert.equal(isUnscopedDiffRevisionSpec("head"), true);
|
|
29
|
+
assert.equal(isUnscopedDiffRevisionSpec("cs:42"), true);
|
|
30
|
+
assert.equal(isUnscopedDiffRevisionSpec("42"), true);
|
|
31
|
+
assert.equal(isUnscopedDiffRevisionSpec("rev:"), true);
|
|
32
|
+
assert.equal(isUnscopedDiffRevisionSpec("arbitrary"), true);
|
|
33
|
+
assert.equal(isUnscopedDiffRevisionSpec("Assets/Scene.unity#cs:42"), false);
|
|
34
|
+
assert.equal(isUnscopedDiffRevisionSpec("Assets/Scene.unity#br:/main"), false);
|
|
35
|
+
assert.equal(isUnscopedDiffRevisionSpec("revid:123"), false);
|
|
36
|
+
assert.equal(safeTempExtension("Assets/My Scene.unity"), ".unity");
|
|
37
|
+
assert.equal(safeTempExtension("revid:99"), ".tmp");
|
|
38
|
+
assert.equal(__plasticProcessInternals.resolveDiffExecutable({ PI_PLASTIC_DIFF_EXECUTABLE: "D:/tools/diff.exe" }), "D:/tools/diff.exe");
|
|
39
|
+
|
|
40
|
+
const left = join(root, "Unity Scene ünicode.unity");
|
|
41
|
+
const right = join(root, "Unity Scene ünicode copy.unity");
|
|
42
|
+
await writeFile(left, "%YAML 1.1\n--- !u!1 &1\nGameObject:\n m_Name: Before\n", "utf8");
|
|
43
|
+
await writeFile(right, "%YAML 1.1\n--- !u!1 &1\nGameObject:\n m_Name: After\n", "utf8");
|
|
44
|
+
const yamlResult = await runPortableTextDiff(left, right, root, "Assets/Scene.unity (Plastic base)", "Assets/Scene.unity (workspace)");
|
|
45
|
+
assert.equal(yamlResult.backend, "diff");
|
|
46
|
+
assert.equal(yamlResult.changed, true);
|
|
47
|
+
assert.equal(yamlResult.binary, false, "Unity YAML must be treated as text based on bytes, not extension/attributes");
|
|
48
|
+
assert.match(yamlResult.output, /^--- Assets\/Scene\.unity \(Plastic base\)\n\+\+\+ Assets\/Scene\.unity \(workspace\)/);
|
|
49
|
+
assert.doesNotMatch(yamlResult.output, new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), "temporary/local paths must not appear in normalized headers");
|
|
50
|
+
|
|
51
|
+
const sameResult = await runPortableTextDiff(left, left, root, "left", "right");
|
|
52
|
+
assert.equal(sameResult.changed, false);
|
|
53
|
+
assert.equal(sameResult.binary, false);
|
|
54
|
+
|
|
55
|
+
const binaryLeft = join(root, "fixture.asset");
|
|
56
|
+
const binaryRight = join(root, "fixture-copy.asset");
|
|
57
|
+
await writeFile(binaryLeft, Buffer.from([0, 1, 2, 3]));
|
|
58
|
+
await writeFile(binaryRight, Buffer.from([0, 1, 4, 3]));
|
|
59
|
+
const binaryResult = await runPortableTextDiff(binaryLeft, binaryRight, root, "base", "workspace");
|
|
60
|
+
assert.equal(binaryResult.changed, true);
|
|
61
|
+
assert.equal(binaryResult.binary, true);
|
|
62
|
+
assert.equal(binaryResult.output, "");
|
|
63
|
+
|
|
64
|
+
const invalidUtf8Left = join(root, "invalid-utf8-left.asset");
|
|
65
|
+
const invalidUtf8Right = join(root, "invalid-utf8-right.asset");
|
|
66
|
+
await writeFile(invalidUtf8Left, Buffer.from([0xff, 0x81, 0x41]));
|
|
67
|
+
await writeFile(invalidUtf8Right, Buffer.from([0xff, 0x81, 0x42]));
|
|
68
|
+
const invalidUtf8Result = await runPortableTextDiff(invalidUtf8Left, invalidUtf8Right, root, "base", "workspace");
|
|
69
|
+
assert.equal(invalidUtf8Result.changed, true);
|
|
70
|
+
assert.equal(invalidUtf8Result.binary, true, "non-UTF-8 bytes must be reported as binary instead of replacement-decoded text");
|
|
71
|
+
assert.equal(invalidUtf8Result.output, "");
|
|
72
|
+
|
|
73
|
+
const largeLeft = join(root, "large-left.txt");
|
|
74
|
+
const largeRight = join(root, "large-right.txt");
|
|
75
|
+
await writeFile(largeLeft, `${"before\n".repeat(12_000)}`, "utf8");
|
|
76
|
+
await writeFile(largeRight, `${"after\n".repeat(12_000)}`, "utf8");
|
|
77
|
+
const largeResult = await runPortableTextDiff(largeLeft, largeRight, root, "large base", "large workspace");
|
|
78
|
+
assert.equal(largeResult.changed, true);
|
|
79
|
+
assert.equal(largeResult.truncated, true);
|
|
80
|
+
assert.ok(largeResult.totalChars > DIFF_OUTPUT_MAX_CHARS);
|
|
81
|
+
assert.match(largeResult.output, /Diff output truncated/);
|
|
82
|
+
|
|
83
|
+
console.log("PASS: plastic diff workflow tests passed");
|
|
84
|
+
} finally {
|
|
85
|
+
await rm(root, { recursive: true, force: true });
|
|
86
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { PiToolHarness, type RegisteredTool, type ToolSourceInfo } from "./pi-tool-harness.ts";
|
|
5
|
+
import {
|
|
6
|
+
BALANCED_ACTIVE_PLASTIC_TOOL_NAMES,
|
|
7
|
+
PLASTIC_SEARCH_CATALOG,
|
|
8
|
+
PLASTIC_TOOL_LOADING_MODE_ENV,
|
|
9
|
+
PLASTIC_TOOL_NAMES,
|
|
10
|
+
PLASTIC_TOOL_SEARCH_NAME,
|
|
11
|
+
searchPlasticTools,
|
|
12
|
+
} from "../src/plastic-tool-loading.ts";
|
|
13
|
+
|
|
14
|
+
const publicToolNames = [...PLASTIC_TOOL_NAMES];
|
|
15
|
+
const foreignSource: ToolSourceInfo = { path: "/extensions/foreign.ts", source: "extension", scope: "project", origin: "package" };
|
|
16
|
+
|
|
17
|
+
async function withMode<T>(mode: string | undefined, action: () => Promise<T>): Promise<T> {
|
|
18
|
+
const previous = process.env[PLASTIC_TOOL_LOADING_MODE_ENV];
|
|
19
|
+
if (mode === undefined) delete process.env[PLASTIC_TOOL_LOADING_MODE_ENV];
|
|
20
|
+
else process.env[PLASTIC_TOOL_LOADING_MODE_ENV] = mode;
|
|
21
|
+
try {
|
|
22
|
+
return await action();
|
|
23
|
+
} finally {
|
|
24
|
+
if (previous === undefined) delete process.env[PLASTIC_TOOL_LOADING_MODE_ENV];
|
|
25
|
+
else process.env[PLASTIC_TOOL_LOADING_MODE_ENV] = previous;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function loadHarness(activeTools: string[], branchEntries: unknown[] = [], options: ConstructorParameters<typeof PiToolHarness>[0] = {}): Promise<PiToolHarness> {
|
|
30
|
+
const harness = new PiToolHarness({ activeTools, branchEntries, ...options });
|
|
31
|
+
await harness.load();
|
|
32
|
+
await harness.startSession();
|
|
33
|
+
return harness;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function main(): Promise<void> {
|
|
37
|
+
await withMode(undefined, async () => {
|
|
38
|
+
const harness = await loadHarness(["read", "foreign_tool", ...publicToolNames]);
|
|
39
|
+
assert.equal(harness.registry.size, 29, "all 28 public Plastic tools plus the loader should be registered");
|
|
40
|
+
assert(harness.registry.has(PLASTIC_TOOL_SEARCH_NAME));
|
|
41
|
+
assert.deepEqual(new Set(harness.getActiveTools()), new Set(["read", "foreign_tool", PLASTIC_TOOL_SEARCH_NAME, ...BALANCED_ACTIVE_PLASTIC_TOOL_NAMES]));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await withMode("loader-only", async () => {
|
|
45
|
+
const harness = await loadHarness(["read", "foreign_tool", ...publicToolNames]);
|
|
46
|
+
assert.deepEqual(new Set(harness.getActiveTools()), new Set(["read", "foreign_tool", PLASTIC_TOOL_SEARCH_NAME]));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await withMode("all-active", async () => {
|
|
50
|
+
const harness = await loadHarness(["read", "foreign_tool", ...publicToolNames]);
|
|
51
|
+
assert.deepEqual(new Set(harness.getActiveTools()), new Set(["read", "foreign_tool", ...publicToolNames]), "all-active should expose all 28 current Plastic tools without the new loader");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await withMode("balanced", async () => {
|
|
55
|
+
const extensionPath = fileURLToPath(new URL("../index.ts", import.meta.url));
|
|
56
|
+
const relativeSource: ToolSourceInfo = {
|
|
57
|
+
path: "./index.ts",
|
|
58
|
+
source: "local",
|
|
59
|
+
scope: "project",
|
|
60
|
+
origin: "cli",
|
|
61
|
+
baseDir: dirname(extensionPath),
|
|
62
|
+
};
|
|
63
|
+
const harness = await loadHarness(["read", ...publicToolNames], [], { extensionSourceInfo: relativeSource });
|
|
64
|
+
assert.deepEqual(new Set(harness.getActiveTools()), new Set(["read", PLASTIC_TOOL_SEARCH_NAME, ...BALANCED_ACTIVE_PLASTIC_TOOL_NAMES]), "relative configured extension paths should resolve to the package-owned source");
|
|
65
|
+
const result = await harness.registry.get(PLASTIC_TOOL_SEARCH_NAME)!.execute("loader", { toolNames: ["plastic_branchList"] });
|
|
66
|
+
assert.deepEqual(result.details.added, ["plastic_branchList"], "relative-path provenance should still allow deferred activation");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
assert.deepEqual(searchPlasticTools({ toolNames: ["plastic_branchDelete"] }).map((match) => match.name), ["plastic_branchDelete"], "exact public names should select one known tool");
|
|
70
|
+
assert(searchPlasticTools({ query: "shelveset" }).every((match) => match.name.includes("shelveset")), "keyword search should find the requested capability");
|
|
71
|
+
assert.equal(searchPlasticTools({ query: "branch", limit: 99 }).length, 4, "search results should have a hard upper bound");
|
|
72
|
+
assert.deepEqual(searchPlasticTools({ query: "branch", limit: 3 }).map((match) => match.name), ["plastic_branchList", "plastic_branchExists", "plastic_currentBranch"], "ambiguous branch searches should prioritize inspection and query tools when multiple results are requested");
|
|
73
|
+
assert.deepEqual(searchPlasticTools({ query: "list available branches" }).map((match) => match.name), ["plastic_branchList"], "multi-term branch discovery should not activate unrelated list tools");
|
|
74
|
+
assert.deepEqual(searchPlasticTools({ query: "list branches in a specified workspace read-only", limit: 1 }).map((match) => match.name), ["plastic_branchList"], "the first capability domain should outrank incidental workspace context");
|
|
75
|
+
assert.deepEqual(searchPlasticTools({ query: "find existing code reviews" }).map((match) => match.name), ["plastic_codeReviewFind"], "contextual review discovery should select the smallest sufficient query tool");
|
|
76
|
+
assert.deepEqual(searchPlasticTools({ query: "what changed" }).map((match) => match.name), ["plastic_status"], "changed-file questions should route to status rather than GUI-capable diff commands");
|
|
77
|
+
const diffMatches = searchPlasticTools({ query: "diff" }).map((match) => match.name);
|
|
78
|
+
assert(!diffMatches.includes("plastic_diff"), "the disabled compatibility diff alias must never be executable through search");
|
|
79
|
+
assert.deepEqual(diffMatches.slice(0, 2), ["plastic_diffFile", "plastic_diffRevisions"], "generic diff searches should prefer the common workspace-file route before advanced revision comparison");
|
|
80
|
+
assert.deepEqual(searchPlasticTools({ query: "workspace diff" }).map((match) => match.name), ["plastic_diffFile"], "workspace diff intent should select the file diff tool");
|
|
81
|
+
assert.deepEqual(searchPlasticTools({ query: "compare revisions" }).map((match) => match.name), ["plastic_diffRevisions"], "explicit revision comparison should select the advanced tool");
|
|
82
|
+
assert.deepEqual(searchPlasticTools({ toolNames: ["plastic_diff"] }).map((match) => match.name), [], "the disabled diff alias must not be exactly selectable");
|
|
83
|
+
assert.deepEqual(searchPlasticTools({ toolNames: ["plastic_workspaceCreate"] }).map((match) => match.name), [], "workspace creation must not be discoverable until paired cleanup is available");
|
|
84
|
+
assert.deepEqual(searchPlasticTools({ toolNames: ["foreign_tool"] }).map((match) => match.name), [], "unknown names must not be selectable");
|
|
85
|
+
assert.equal(searchPlasticTools({ toolNames: ["plastic_branchDelete", "plastic_branchCreate", "plastic_branchList", "plastic_currentBranch"] }).length, 4, "up to four exact names should all be selected when no limit is supplied");
|
|
86
|
+
|
|
87
|
+
await withMode("loader-only", async () => {
|
|
88
|
+
const harness = await loadHarness(["read", "foreign_tool"]);
|
|
89
|
+
const loader = harness.registry.get(PLASTIC_TOOL_SEARCH_NAME)!;
|
|
90
|
+
|
|
91
|
+
const browseCallsBefore = harness.setActiveToolsCalls.length;
|
|
92
|
+
for (const query of [undefined, "Unity Version Control", "version control", "UVCS", "VCS"]) {
|
|
93
|
+
const browse = await loader.execute("loader", query === undefined ? {} : { query });
|
|
94
|
+
assert.equal(browse.details.browse, true, `${String(query)} should provide a browse response`);
|
|
95
|
+
assert.match(browse.content[0].text, /Browse Plastic capabilities/, "browse output should give concise recovery categories");
|
|
96
|
+
}
|
|
97
|
+
assert.equal(harness.setActiveToolsCalls.length, browseCallsBefore, "browse and recovery responses must not activate tools");
|
|
98
|
+
|
|
99
|
+
const setCallsBefore = harness.setActiveToolsCalls.length;
|
|
100
|
+
const result = await loader.execute("loader", { toolNames: ["plastic_branchDelete", "plastic_branchCreate", "plastic_branchList", "plastic_currentBranch"] });
|
|
101
|
+
assert.equal(result.details.added.length, 4, "all requested exact tools should activate by default");
|
|
102
|
+
assert(harness.getActiveTools().includes("read") && harness.getActiveTools().includes("foreign_tool"), "activation must preserve foreign tools");
|
|
103
|
+
assert.equal(harness.setActiveToolsCalls.length, setCallsBefore + 1, "new matches should be activated once");
|
|
104
|
+
assert.match(result.content[0].text, /Confirm the full branch path/, "loader results should return selected safety guidance");
|
|
105
|
+
|
|
106
|
+
const repeatCallsBefore = harness.setActiveToolsCalls.length;
|
|
107
|
+
const repeat = await loader.execute("loader", { toolNames: ["plastic_branchDelete"] });
|
|
108
|
+
assert.deepEqual(repeat.details.alreadyActive, ["plastic_branchDelete"]);
|
|
109
|
+
assert.equal(harness.setActiveToolsCalls.length, repeatCallsBefore, "already-active matches must not replace the active set");
|
|
110
|
+
|
|
111
|
+
const noMatchCallsBefore = harness.setActiveToolsCalls.length;
|
|
112
|
+
const noMatch = await loader.execute("loader", { toolNames: ["unknown_tool"] });
|
|
113
|
+
assert.deepEqual(noMatch.details.matches, []);
|
|
114
|
+
assert.deepEqual(noMatch.details.unknownToolNames, ["unknown_tool"]);
|
|
115
|
+
assert.match(noMatch.content[0].text, /unknown_tool/, "unknown exact requests must be reported clearly");
|
|
116
|
+
assert.equal(harness.setActiveToolsCalls.length, noMatchCallsBefore, "no-match searches must not change active tools");
|
|
117
|
+
|
|
118
|
+
const disabledDiff = await loader.execute("loader", { toolNames: ["plastic_diff"] });
|
|
119
|
+
assert.deepEqual(disabledDiff.details.matches, []);
|
|
120
|
+
assert.deepEqual(disabledDiff.details.unavailableToolNames, ["plastic_diff"], "the compatibility diff alias must be reported as unavailable rather than activated");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await withMode("loader-only", async () => {
|
|
124
|
+
const activeBranch = [
|
|
125
|
+
{ type: "message", message: { role: "toolResult", addedToolNames: ["plastic_diff", "plastic_merge", "plastic_branchList", "foreign_tool", "removed_tool"] } },
|
|
126
|
+
];
|
|
127
|
+
const harness = await loadHarness(["read", "foreign_tool", ...publicToolNames], activeBranch);
|
|
128
|
+
assert(harness.getActiveTools().includes("plastic_diff"), "the disabled compatibility diff alias should remain restorable from active history");
|
|
129
|
+
assert(harness.getActiveTools().includes("plastic_merge") && harness.getActiveTools().includes("plastic_branchList"), "valid active-branch loader additions should be restored");
|
|
130
|
+
assert(!harness.getActiveTools().includes("removed_tool"), "unknown historical names must not be restored");
|
|
131
|
+
assert(harness.getActiveTools().includes("foreign_tool"), "foreign active tools must survive restoration");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await withMode("loader-only", async () => {
|
|
135
|
+
const foreignBranchCreate: RegisteredTool = {
|
|
136
|
+
name: "plastic_branchCreate",
|
|
137
|
+
label: "Foreign branch create",
|
|
138
|
+
description: "Foreign collision",
|
|
139
|
+
sourceInfo: foreignSource,
|
|
140
|
+
execute: () => ({}),
|
|
141
|
+
};
|
|
142
|
+
const harness = await loadHarness(["read", "plastic_branchCreate"], [], { sourceInfoAvailable: true, foreignTools: [foreignBranchCreate] });
|
|
143
|
+
assert(harness.getActiveTools().includes("plastic_branchCreate"), "sourceInfo ownership must preserve a name-colliding foreign active tool");
|
|
144
|
+
|
|
145
|
+
const loader = harness.registry.get(PLASTIC_TOOL_SEARCH_NAME)!;
|
|
146
|
+
const callsBefore = harness.setActiveToolsCalls.length;
|
|
147
|
+
const collision = await loader.execute("loader", { toolNames: ["plastic_branchCreate", "plastic_branchList"] });
|
|
148
|
+
assert.deepEqual(collision.details.added, ["plastic_branchList"], "only the same-source effective tool may be activated");
|
|
149
|
+
assert.deepEqual(collision.details.unavailableToolNames, ["plastic_branchCreate"], "foreign collisions should be reported as unavailable from this extension");
|
|
150
|
+
assert.equal(harness.setActiveToolsCalls.length, callsBefore + 1);
|
|
151
|
+
|
|
152
|
+
const restoredHarness = await loadHarness(["read"], [
|
|
153
|
+
{ type: "message", message: { role: "toolResult", addedToolNames: ["plastic_branchCreate", "plastic_branchList"] } },
|
|
154
|
+
], { sourceInfoAvailable: true, foreignTools: [foreignBranchCreate] });
|
|
155
|
+
assert(!restoredHarness.getActiveTools().includes("plastic_branchCreate"), "foreign same-name tools must not be restored from this loader's history");
|
|
156
|
+
assert(restoredHarness.getActiveTools().includes("plastic_branchList"), "same-source historical loader additions should still restore");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await withMode("loader-only", async () => {
|
|
160
|
+
const compatibilityHarness = await loadHarness(["read", ...publicToolNames], [], { sourceInfoAvailable: false });
|
|
161
|
+
assert.deepEqual(
|
|
162
|
+
compatibilityHarness.getActiveTools(),
|
|
163
|
+
["read", ...publicToolNames],
|
|
164
|
+
"without sourceInfo, loader-only mode must preserve the active legacy set exactly",
|
|
165
|
+
);
|
|
166
|
+
assert.equal(compatibilityHarness.setActiveToolsCalls.length, 0, "unproven loader ownership must not rewrite the active set");
|
|
167
|
+
|
|
168
|
+
const foreignBranchCreate: RegisteredTool = {
|
|
169
|
+
name: "plastic_branchCreate",
|
|
170
|
+
label: "Foreign branch create without provenance",
|
|
171
|
+
description: "Foreign collision",
|
|
172
|
+
sourceInfo: foreignSource,
|
|
173
|
+
execute: () => ({}),
|
|
174
|
+
};
|
|
175
|
+
const initiallyActive = ["read", "foreign_tool", PLASTIC_TOOL_SEARCH_NAME, "plastic_status", "plastic_branchCreate"];
|
|
176
|
+
const harness = await loadHarness(initiallyActive, [], { sourceInfoAvailable: false, foreignTools: [foreignBranchCreate] });
|
|
177
|
+
assert.deepEqual(harness.getActiveTools(), initiallyActive, "without sourceInfo, startup must preserve every active Plastic-named tool");
|
|
178
|
+
assert.equal(harness.setActiveToolsCalls.length, 0, "without sourceInfo, startup must not defer or otherwise rewrite the active set when the loader is already active");
|
|
179
|
+
|
|
180
|
+
const loader = harness.registry.get(PLASTIC_TOOL_SEARCH_NAME)!;
|
|
181
|
+
const collision = await loader.execute("loader", { toolNames: ["plastic_branchCreate", "plastic_branchList"] });
|
|
182
|
+
assert.deepEqual(collision.details.matches, ["plastic_branchCreate"], "without provenance, the loader may report only already-active known matches");
|
|
183
|
+
assert.deepEqual(collision.details.added, [], "without provenance, the loader must not activate inactive Plastic names");
|
|
184
|
+
assert.deepEqual(collision.details.unavailableToolNames, ["plastic_branchList"]);
|
|
185
|
+
assert.deepEqual(harness.getActiveTools(), initiallyActive, "a foreign colliding plastic_* tool must not cause deactivation or activation without sourceInfo");
|
|
186
|
+
assert.equal(harness.setActiveToolsCalls.length, 0, "a no-sourceInfo loader request must not change the active set");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
for (const mode of ["loader-only", "all-active"] as const) await withMode(mode, async () => {
|
|
190
|
+
const foreignLoader: RegisteredTool = {
|
|
191
|
+
name: PLASTIC_TOOL_SEARCH_NAME,
|
|
192
|
+
label: "Foreign Plastic loader",
|
|
193
|
+
description: "Foreign collision",
|
|
194
|
+
sourceInfo: foreignSource,
|
|
195
|
+
execute: () => ({}),
|
|
196
|
+
};
|
|
197
|
+
const foreignBranchList: RegisteredTool = {
|
|
198
|
+
name: "plastic_branchList",
|
|
199
|
+
label: "Foreign branch list",
|
|
200
|
+
description: "Foreign collision from the same source as the foreign loader",
|
|
201
|
+
sourceInfo: foreignSource,
|
|
202
|
+
execute: () => ({}),
|
|
203
|
+
};
|
|
204
|
+
const active = ["read", PLASTIC_TOOL_SEARCH_NAME, "plastic_branchList", "plastic_status"];
|
|
205
|
+
const harness = await loadHarness(active, [], { sourceInfoAvailable: true, foreignTools: [foreignLoader, foreignBranchList] });
|
|
206
|
+
assert.deepEqual(harness.getActiveTools(), active, `${mode} must preserve an active foreign loader and same-source Plastic collision`);
|
|
207
|
+
assert.equal(harness.setActiveToolsCalls.length, 0, `${mode} must not rewrite tools when this extension does not own the effective loader`);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await withMode("loader-only", async () => {
|
|
211
|
+
const harness = await loadHarness(["read"]);
|
|
212
|
+
for (const name of PLASTIC_SEARCH_CATALOG.map((entry) => entry.name)) {
|
|
213
|
+
const tool = harness.registry.get(name)!;
|
|
214
|
+
assert.equal(tool.promptSnippet, undefined, `${name} should not rebuild the prompt after deferred activation`);
|
|
215
|
+
assert.equal(tool.promptGuidelines, undefined, `${name} should not add deferred prompt guidance`);
|
|
216
|
+
}
|
|
217
|
+
assert(harness.registry.get(PLASTIC_TOOL_SEARCH_NAME)?.promptSnippet, "the always-active loader should explain discovery");
|
|
218
|
+
assert(harness.registry.get(PLASTIC_TOOL_SEARCH_NAME)?.promptGuidelines?.length, "the always-active loader should retain universal safety guidance");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
console.log("PASS: plastic dynamic tool loading test succeeded");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
void main();
|