@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,234 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
export const PLASTIC_TOOL_LOADING_MODE_ENV = "PI_PLASTIC_TOOL_LOADING_MODE";
|
|
4
|
+
export const PLASTIC_TOOL_SEARCH_NAME = "plastic_tool_search";
|
|
5
|
+
export const BALANCED_ACTIVE_PLASTIC_TOOL_NAMES = ["plastic_status", "plastic_currentBranch"] as const;
|
|
6
|
+
export const MAX_PLASTIC_TOOL_SEARCH_RESULTS = 4;
|
|
7
|
+
|
|
8
|
+
export type PlasticToolLoadingMode = "balanced" | "loader-only" | "all-active";
|
|
9
|
+
|
|
10
|
+
export type PlasticSearchCatalogEntry = {
|
|
11
|
+
name: string;
|
|
12
|
+
aliases: readonly string[];
|
|
13
|
+
tags: readonly string[];
|
|
14
|
+
guidance: readonly string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Package-owned executable capability vocabulary. `plastic_diff` deliberately
|
|
19
|
+
* remains outside this catalog: it is registered for compatibility, but is a
|
|
20
|
+
* disabled alias rather than a safe executable diff capability.
|
|
21
|
+
*/
|
|
22
|
+
export const PLASTIC_SEARCH_CATALOG: readonly PlasticSearchCatalogEntry[] = [
|
|
23
|
+
{ name: "plastic_status", aliases: ["status", "pending changes", "working changes", "what changed", "changed files", "list changes"], tags: ["workspace", "inspect", "changes"], guidance: ["Use machineReadable=true to list changed, added, moved, deleted, and private paths without invoking GUI-capable cm diff."] },
|
|
24
|
+
{ name: "plastic_update", aliases: ["update", "sync", "pull latest"], tags: ["workspace", "synchronize", "safe"], guidance: ["Review pending changes before updating a workspace."] },
|
|
25
|
+
{ name: "plastic_add", aliases: ["add files", "track files", "add item"], tags: ["workspace", "changes", "mutation"], guidance: ["Confirm the intended paths before adding them to source control."] },
|
|
26
|
+
{ name: "plastic_checkin", aliases: ["checkin", "check in", "commit changes"], tags: ["changes", "mutation", "changeset"], guidance: ["Inspect the selected paths and checkin message before creating a changeset."] },
|
|
27
|
+
{ name: "plastic_undo", aliases: ["undo changes", "revert changes", "discard changes"], tags: ["changes", "mutation", "destructive"], guidance: ["Confirm the exact paths before discarding pending changes."] },
|
|
28
|
+
{ name: "plastic_resolveDeleteChangeConflict", aliases: ["resolve delete conflict", "deleted conflict"], tags: ["merge", "conflict", "resolution"], guidance: ["Inspect the conflict and choose whether files should remain on disk before resolving it."] },
|
|
29
|
+
{ name: "plastic_patch", aliases: ["patch", "generate patch", "review patch"], tags: ["diff", "patch", "export"], guidance: ["Inspect generated patches before sharing because they can include source, paths, or secrets."] },
|
|
30
|
+
{ name: "plastic_diffFile", aliases: ["diff", "diff file", "file diff", "workspace diff", "compare file"], tags: ["diff", "file", "workspace", "inspect"], guidance: ["For the common workspace diff, pass path and omit revision; use a documented changeset, branch, label, file-qualified, or global revision only when needed."] },
|
|
31
|
+
{ name: "plastic_diffRevisions", aliases: ["diff revisions", "compare revisions", "revision diff"], tags: ["diff", "revision", "inspect"], guidance: ["Use this only for two explicit file-qualified revision specifications."] },
|
|
32
|
+
{ name: "plastic_branchCreate", aliases: ["create branch", "new branch"], tags: ["branch", "mutation", "workflow"], guidance: ["Create normal work branches beneath an intended parent, which may differ from the loaded branch. Use allowRootBranch only for intentional top-level branches."] },
|
|
33
|
+
{ name: "plastic_switchBranch", aliases: ["switch branch", "checkout branch", "change branch"], tags: ["branch", "workspace", "mutation"], guidance: ["Check pending changes and choose how to handle them before switching branches."] },
|
|
34
|
+
{ name: "plastic_merge", aliases: ["merge branch", "merge changes"], tags: ["merge", "conflict", "workflow"], guidance: ["Inspect merge state and resolve conflicts before checkin."] },
|
|
35
|
+
{ name: "plastic_mergeToBranch", aliases: ["merge to branch", "close branch", "merge closeout"], tags: ["merge", "branch", "closeout"], guidance: ["Confirm source and target branches before the non-interactive merge closeout flow."] },
|
|
36
|
+
{ name: "plastic_finalizeMerge", aliases: ["finalize merge", "complete merge", "resolve merge"], tags: ["merge", "conflict", "finalize"], guidance: ["Use after reviewed manual conflict resolution when Plastic still needs merge metadata finalized."] },
|
|
37
|
+
{ name: "plastic_currentBranch", aliases: ["current branch", "which branch", "branch name"], tags: ["branch", "workspace", "inspect"], guidance: ["Inspect the current branch before branch, merge, or checkin operations."] },
|
|
38
|
+
{ name: "plastic_branchList", aliases: ["list branches", "find branch", "branches"], tags: ["branch", "inspect", "query"], guidance: ["Use the returned full branch paths when selecting a branch for another operation."] },
|
|
39
|
+
{ name: "plastic_branchExists", aliases: ["branch exists", "check branch", "verify branch"], tags: ["branch", "inspect", "query"], guidance: ["Verify the full branch path before creating, switching, merging, or deleting."] },
|
|
40
|
+
{ name: "plastic_branchDelete", aliases: ["delete branch", "remove branch"], tags: ["branch", "mutation", "destructive"], guidance: ["Confirm the full branch path and whether its changesets may be deleted before deleting it."] },
|
|
41
|
+
{ name: "plastic_shelvesetCreate", aliases: ["create shelveset", "shelve changes", "stash changes"], tags: ["shelveset", "changes", "mutation"], guidance: ["Inspect selected pending changes and shelveset comments before shelving."] },
|
|
42
|
+
{ name: "plastic_shelvesetApply", aliases: ["apply shelveset", "unshelve", "restore shelveset"], tags: ["shelveset", "changes", "mutation"], guidance: ["Check the target workspace and pending changes before applying a shelveset."] },
|
|
43
|
+
{ name: "plastic_shelvesetDelete", aliases: ["delete shelveset", "remove shelveset"], tags: ["shelveset", "mutation", "destructive"], guidance: ["Confirm the shelveset identifier before deleting it."] },
|
|
44
|
+
{ name: "plastic_shelvesetList", aliases: ["list shelvesets", "find shelveset", "shelvesets"], tags: ["shelveset", "inspect", "query"], guidance: ["Inspect shelveset ownership and comments before applying or deleting one."] },
|
|
45
|
+
{ name: "plastic_codeReviewCreate", aliases: ["create code review", "new review"], tags: ["review", "code review", "mutation"], guidance: ["Confirm review targets, title, and reviewers before creating a code review."] },
|
|
46
|
+
{ name: "plastic_codeReviewUpdate", aliases: ["update code review", "edit review"], tags: ["review", "code review", "mutation"], guidance: ["Confirm the review ID and requested fields before updating a code review."] },
|
|
47
|
+
{ name: "plastic_codeReviewDelete", aliases: ["delete code review", "remove review"], tags: ["review", "code review", "mutation", "destructive"], guidance: ["Confirm review IDs before deleting code reviews."] },
|
|
48
|
+
{ name: "plastic_codeReviewFind", aliases: ["find code review", "list reviews", "search reviews"], tags: ["review", "code review", "inspect", "query"], guidance: ["Use filters to identify the intended review before changing or deleting it."] },
|
|
49
|
+
{ name: "plastic_workspaceList", aliases: ["list workspaces", "find workspace", "workspaces"], tags: ["workspace", "inspect", "query"], guidance: ["Inspect workspace paths and repository identity before using a workspace in a mutation."] },
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
export const PLASTIC_COMPATIBILITY_TOOL_NAMES = ["plastic_diff"] as const;
|
|
53
|
+
export const PLASTIC_SEARCHABLE_TOOL_NAMES = new Set(PLASTIC_SEARCH_CATALOG.map((entry) => entry.name));
|
|
54
|
+
export const PLASTIC_TOOL_NAMES = new Set([...PLASTIC_SEARCHABLE_TOOL_NAMES, ...PLASTIC_COMPATIBILITY_TOOL_NAMES]);
|
|
55
|
+
|
|
56
|
+
export type ToolSourceInfo = {
|
|
57
|
+
path: string;
|
|
58
|
+
source: string;
|
|
59
|
+
scope: string;
|
|
60
|
+
origin: string;
|
|
61
|
+
baseDir?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type PlasticToolInfo = { name?: unknown; sourceInfo?: unknown };
|
|
65
|
+
export type PlasticToolOwnership = { ownedToolNames: Set<string>; usesSourceInfo: boolean };
|
|
66
|
+
|
|
67
|
+
export function getPlasticToolLoadingMode(value = process.env[PLASTIC_TOOL_LOADING_MODE_ENV]): PlasticToolLoadingMode {
|
|
68
|
+
switch (value?.trim().toLowerCase()) {
|
|
69
|
+
case "all-active": return "all-active";
|
|
70
|
+
case "loader-only": return "loader-only";
|
|
71
|
+
default: return "balanced";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getInitiallyInactivePlasticTools(mode: PlasticToolLoadingMode): Set<string> {
|
|
76
|
+
if (mode === "all-active") return new Set();
|
|
77
|
+
if (mode === "loader-only") return new Set(PLASTIC_TOOL_NAMES);
|
|
78
|
+
return new Set([...PLASTIC_TOOL_NAMES].filter((name) => !BALANCED_ACTIVE_PLASTIC_TOOL_NAMES.includes(name as typeof BALANCED_ACTIVE_PLASTIC_TOOL_NAMES[number])));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function asSourceInfo(value: unknown): ToolSourceInfo | undefined {
|
|
82
|
+
if (!value || typeof value !== "object") return undefined;
|
|
83
|
+
const candidate = value as Partial<ToolSourceInfo>;
|
|
84
|
+
return typeof candidate.path === "string" && typeof candidate.source === "string" && typeof candidate.scope === "string" && typeof candidate.origin === "string"
|
|
85
|
+
? candidate as ToolSourceInfo
|
|
86
|
+
: undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeSourcePath(value: string): string {
|
|
90
|
+
const normalized = value.replaceAll("\\", "/").replace(/\/$/, "");
|
|
91
|
+
return /^[a-z]:\//i.test(normalized) ? normalized.toLowerCase() : normalized;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveSourcePath(sourceInfo: ToolSourceInfo): string {
|
|
95
|
+
return normalizeSourcePath(resolve(sourceInfo.baseDir ?? process.cwd(), sourceInfo.path));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasSourcePath(tool: PlasticToolInfo, expectedSourcePath: string): boolean {
|
|
99
|
+
const sourceInfo = asSourceInfo(tool.sourceInfo);
|
|
100
|
+
return Boolean(sourceInfo && resolveSourcePath(sourceInfo) === normalizeSourcePath(resolve(expectedSourcePath)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Pi 0.82 exposes effective tool provenance. Compare effective definitions to
|
|
105
|
+
* this module's own import path, so even a foreign first-registration-wins
|
|
106
|
+
* collision on the public loader name cannot become an ownership anchor.
|
|
107
|
+
* Without that proof, callers preserve the active set unchanged.
|
|
108
|
+
*/
|
|
109
|
+
export function getEffectivePlasticToolOwnership(allTools: readonly PlasticToolInfo[], expectedSourcePath: string): PlasticToolOwnership {
|
|
110
|
+
const effectiveLoader = allTools.find((tool) => tool.name === PLASTIC_TOOL_SEARCH_NAME);
|
|
111
|
+
if (!effectiveLoader || !hasSourcePath(effectiveLoader, expectedSourcePath)) {
|
|
112
|
+
return { ownedToolNames: new Set(), usesSourceInfo: false };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
ownedToolNames: new Set(allTools
|
|
117
|
+
.filter((tool) => typeof tool.name === "string" && PLASTIC_TOOL_NAMES.has(tool.name))
|
|
118
|
+
.filter((tool) => hasSourcePath(tool, expectedSourcePath))
|
|
119
|
+
.map((tool) => tool.name as string)),
|
|
120
|
+
usesSourceInfo: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type PlasticToolSearchInput = { query?: unknown; toolNames?: unknown; limit?: unknown };
|
|
125
|
+
export type PlasticToolSearchMatch = PlasticSearchCatalogEntry & { description?: string; score: number };
|
|
126
|
+
|
|
127
|
+
const SEARCH_STOP_WORDS = new Set(["a", "an", "available", "existing", "for", "my", "of", "the", "this", "to"]);
|
|
128
|
+
const SEARCH_DOMAIN_TERMS = new Set(["branch", "changeset", "diff", "merge", "patch", "review", "shelveset", "workspace"]);
|
|
129
|
+
const MUTATION_INTENT_TERMS = new Set(["add", "apply", "checkin", "create", "delete", "discard", "edit", "finalize", "merge", "remove", "resolve", "switch", "undo", "update"]);
|
|
130
|
+
|
|
131
|
+
function normalizeSearchToken(token: string): string {
|
|
132
|
+
if (token.length > 4 && /(?:ch|sh|x|z)es$/.test(token)) return token.slice(0, -2);
|
|
133
|
+
if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss")) return token.slice(0, -1);
|
|
134
|
+
return token;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function tokenize(value: string): string[] {
|
|
138
|
+
return [...new Set(value.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).map(normalizeSearchToken).filter((token) => !SEARCH_STOP_WORDS.has(token)))];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function exactRequestedNames(value: unknown): Set<string> {
|
|
142
|
+
if (!Array.isArray(value)) return new Set();
|
|
143
|
+
return new Set(value.filter((name): name is string => typeof name === "string").map((name) => name.trim().toLowerCase()).filter(Boolean));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function getUnknownExactPlasticToolNames(value: unknown): string[] {
|
|
147
|
+
if (!Array.isArray(value)) return [];
|
|
148
|
+
const knownNames = new Set([...PLASTIC_TOOL_NAMES].map((name) => name.toLowerCase()));
|
|
149
|
+
return [...new Set(value.filter((name): name is string => typeof name === "string" && name.trim().length > 0)
|
|
150
|
+
.map((name) => name.trim())
|
|
151
|
+
.filter((name) => !knownNames.has(name.toLowerCase())))];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeLimit(value: unknown, requestedNames: ReadonlySet<string>): number {
|
|
155
|
+
if (typeof value === "number" && Number.isInteger(value)) return Math.max(1, Math.min(MAX_PLASTIC_TOOL_SEARCH_RESULTS, value));
|
|
156
|
+
return requestedNames.size > 0 ? MAX_PLASTIC_TOOL_SEARCH_RESULTS : 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function safetyPriority(entry: PlasticSearchCatalogEntry): number {
|
|
160
|
+
if (entry.tags.includes("query")) return 0;
|
|
161
|
+
if (entry.tags.includes("inspect")) return 1;
|
|
162
|
+
if (entry.tags.includes("mutation")) return 3;
|
|
163
|
+
return 2;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Search the executable catalog with deterministic scoring and safe bare-domain ordering. */
|
|
167
|
+
export function searchPlasticTools(input: PlasticToolSearchInput, descriptions: ReadonlyMap<string, string> = new Map()): PlasticToolSearchMatch[] {
|
|
168
|
+
const requestedNames = exactRequestedNames(input.toolNames);
|
|
169
|
+
const terms = typeof input.query === "string" ? tokenize(input.query) : [];
|
|
170
|
+
const normalizedLimit = normalizeLimit(input.limit, requestedNames);
|
|
171
|
+
const limit = input.limit === undefined && requestedNames.size === 0 && terms.length === 1 && terms[0] === "diff" ? 2 : normalizedLimit;
|
|
172
|
+
const domainTerms = terms.filter((term) => SEARCH_DOMAIN_TERMS.has(term));
|
|
173
|
+
const primaryDomainTerm = domainTerms[0];
|
|
174
|
+
const mutationIntent = terms.some((term) => MUTATION_INTENT_TERMS.has(term));
|
|
175
|
+
const preferReadOnly = domainTerms.length > 0 && !mutationIntent;
|
|
176
|
+
|
|
177
|
+
return PLASTIC_SEARCH_CATALOG.map((entry, index) => {
|
|
178
|
+
const description = descriptions.get(entry.name);
|
|
179
|
+
const nameTokens = new Set(tokenize(entry.name));
|
|
180
|
+
const aliases = entry.aliases.map(tokenize);
|
|
181
|
+
const tags = entry.tags.map(tokenize);
|
|
182
|
+
const descriptionTokens = new Set(tokenize(description ?? ""));
|
|
183
|
+
const exact = requestedNames.has(entry.name.toLowerCase());
|
|
184
|
+
let score = exact ? 1000 : 0;
|
|
185
|
+
let matchedTerms = 0;
|
|
186
|
+
for (const term of terms) {
|
|
187
|
+
const nameMatch = nameTokens.has(term);
|
|
188
|
+
const aliasMatch = aliases.some((alias) => alias.includes(term));
|
|
189
|
+
const tagMatch = tags.some((tag) => tag.includes(term));
|
|
190
|
+
const descriptionMatch = descriptionTokens.has(term);
|
|
191
|
+
if (nameMatch || aliasMatch || tagMatch || descriptionMatch) matchedTerms += 1;
|
|
192
|
+
if (nameMatch) score += 8;
|
|
193
|
+
if (aliasMatch) score += 6;
|
|
194
|
+
if (tagMatch) score += 4;
|
|
195
|
+
if (descriptionMatch) score += 1;
|
|
196
|
+
}
|
|
197
|
+
const domainMatches = primaryDomainTerm && (nameTokens.has(primaryDomainTerm) || aliases.some((alias) => alias.includes(primaryDomainTerm)) || tags.some((tag) => tag.includes(primaryDomainTerm)) || descriptionTokens.has(primaryDomainTerm)) ? 1 : 0;
|
|
198
|
+
return { ...entry, description, score, matchedTerms, domainMatches, index, exact };
|
|
199
|
+
})
|
|
200
|
+
.filter((entry) => entry.exact || entry.score > 0)
|
|
201
|
+
.filter((entry) => entry.exact || domainTerms.length === 0 || entry.domainMatches > 0)
|
|
202
|
+
.filter((entry, _index, entries) => {
|
|
203
|
+
if (entry.exact || terms.length <= 1) return true;
|
|
204
|
+
const bestCoverage = Math.max(...entries.map((candidate) => candidate.matchedTerms));
|
|
205
|
+
return entry.matchedTerms === bestCoverage;
|
|
206
|
+
})
|
|
207
|
+
.filter((entry) => entry.exact || !preferReadOnly || !entry.tags.includes("mutation"))
|
|
208
|
+
.sort((left, right) => right.score - left.score || (preferReadOnly ? safetyPriority(left) - safetyPriority(right) : 0) || left.index - right.index)
|
|
209
|
+
.slice(0, limit)
|
|
210
|
+
.map(({ matchedTerms: _matchedTerms, domainMatches: _domainMatches, index: _index, exact: _exact, ...entry }) => entry);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const BROAD_PRODUCT_QUERIES = new Set(["plastic", "plastic scm", "unity version control", "version control", "uvcs", "vcs"]);
|
|
214
|
+
|
|
215
|
+
/** Empty and product-level requests are discovery prompts, never activation requests. */
|
|
216
|
+
export function isPlasticToolBrowseRequest(input: PlasticToolSearchInput): boolean {
|
|
217
|
+
if (exactRequestedNames(input.toolNames).size > 0) return false;
|
|
218
|
+
const query = typeof input.query === "string" ? input.query.trim().toLowerCase().replace(/\s+/g, " ") : "";
|
|
219
|
+
return query.length === 0 || BROAD_PRODUCT_QUERIES.has(query);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const PLASTIC_TOOL_BROWSE_TEXT = "Browse Plastic capabilities without activating tools: workspace status/sync; changes and checkins; text-only diffs and patches; branches and merges; shelvesets; code reviews; and workspaces. Examples: ‘pending changes’, ‘diff file’, ‘list branches’, or ‘find code reviews’.";
|
|
223
|
+
|
|
224
|
+
export function getRestoredPlasticToolNames(branchEntries: readonly unknown[], effectiveToolNames: ReadonlySet<string> = PLASTIC_TOOL_NAMES): string[] {
|
|
225
|
+
const restored = new Set<string>();
|
|
226
|
+
for (const entry of branchEntries) {
|
|
227
|
+
const candidate = entry as { type?: unknown; message?: { role?: unknown; addedToolNames?: unknown } };
|
|
228
|
+
if (candidate.type !== "message" || candidate.message?.role !== "toolResult" || !Array.isArray(candidate.message.addedToolNames)) continue;
|
|
229
|
+
for (const name of candidate.message.addedToolNames) {
|
|
230
|
+
if (typeof name === "string" && effectiveToolNames.has(name)) restored.add(name);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return [...PLASTIC_COMPATIBILITY_TOOL_NAMES, ...PLASTIC_SEARCH_CATALOG.map((entry) => entry.name)].filter((name) => restored.has(name));
|
|
234
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { dirname, join, parse, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type Found<T> = { kind: "found"; value: T };
|
|
5
|
+
export type ParseOutcome<T> = Found<T> | { kind: "not_found" } | { kind: "malformed"; reason: string };
|
|
6
|
+
export type WorkspaceDiscoveryOutcome =
|
|
7
|
+
| Found<{ root: string; plasticDir: string; markerPath: string }>
|
|
8
|
+
| { kind: "not_found" }
|
|
9
|
+
| { kind: "unavailable"; reason: string };
|
|
10
|
+
|
|
11
|
+
export type PlasticSelector = {
|
|
12
|
+
branch: string;
|
|
13
|
+
repository?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PlasticStatusBranch = {
|
|
17
|
+
branch?: string;
|
|
18
|
+
changesetId?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type WorkspaceFileSystem = {
|
|
22
|
+
realpath(path: string): Promise<string>;
|
|
23
|
+
stat(path: string): Promise<{ isFile(): boolean }>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const defaultWorkspaceFileSystem: WorkspaceFileSystem = {
|
|
27
|
+
realpath: (path) => fs.realpath(path),
|
|
28
|
+
stat: (path) => fs.stat(path),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const isMissingPathError = (error: unknown): boolean =>
|
|
32
|
+
error instanceof Error && ["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "");
|
|
33
|
+
|
|
34
|
+
/** Find the nearest enclosing Plastic workspace using only the documented marker. */
|
|
35
|
+
export async function discoverPlasticWorkspace(
|
|
36
|
+
cwd: string,
|
|
37
|
+
fileSystem: WorkspaceFileSystem = defaultWorkspaceFileSystem,
|
|
38
|
+
): Promise<WorkspaceDiscoveryOutcome> {
|
|
39
|
+
let current: string;
|
|
40
|
+
try {
|
|
41
|
+
current = await fileSystem.realpath(resolve(cwd));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return {
|
|
44
|
+
kind: "unavailable",
|
|
45
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
while (true) {
|
|
50
|
+
const plasticDir = join(current, ".plastic");
|
|
51
|
+
const markerPath = join(plasticDir, "plastic.workspace");
|
|
52
|
+
try {
|
|
53
|
+
if ((await fileSystem.stat(markerPath)).isFile()) {
|
|
54
|
+
return { kind: "found", value: { root: current, plasticDir, markerPath } };
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!isMissingPathError(error)) {
|
|
58
|
+
return {
|
|
59
|
+
kind: "unavailable",
|
|
60
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const parent = dirname(current);
|
|
66
|
+
if (parent === current || current === parse(current).root) return { kind: "not_found" };
|
|
67
|
+
current = parent;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const parseSelectorValue = (line: string, key: string): string | undefined => {
|
|
72
|
+
const match = line.match(new RegExp(`^\\s*${key}\\s+(.*?)\\s*$`, "i"));
|
|
73
|
+
if (!match) return undefined;
|
|
74
|
+
const value = match[1].replace(/^(["'])(.*)\1$/, "$2").trim();
|
|
75
|
+
return value;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Parse the local selector without treating it as proof that a workspace exists. */
|
|
79
|
+
export function parsePlasticSelector(content: string): ParseOutcome<PlasticSelector> {
|
|
80
|
+
let repository: string | undefined;
|
|
81
|
+
let branch: string | undefined;
|
|
82
|
+
let sawBranchKey = false;
|
|
83
|
+
|
|
84
|
+
for (const line of content.split(/\r?\n/)) {
|
|
85
|
+
if (/^\s*(?:#|$)/.test(line)) continue;
|
|
86
|
+
if (/^\s*repository(?:\s|$)/i.test(line)) repository = parseSelectorValue(line, "repository");
|
|
87
|
+
if (/^\s*smartbranch(?:\s|$)/i.test(line)) {
|
|
88
|
+
sawBranchKey = true;
|
|
89
|
+
branch = parseSelectorValue(line, "smartbranch");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!sawBranchKey) return { kind: "not_found" };
|
|
94
|
+
if (!branch || /[\u0000-\u001f\u007f]/.test(branch)) {
|
|
95
|
+
return { kind: "malformed", reason: "Selector smartbranch is blank or contains control characters." };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
kind: "found",
|
|
100
|
+
value: { branch, ...(repository ? { repository } : {}) },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const nonEmptyLines = (output: string): string[] => output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
105
|
+
|
|
106
|
+
const normalizeStatusBranch = (branch: string): string =>
|
|
107
|
+
branch.trim().replace(/^br:/i, "").replace(/@[^@\s)]+(?:@[^@\s)]+)*$/, "");
|
|
108
|
+
|
|
109
|
+
/** Parse normal or compact `cm status` output into an unqualified branch path. */
|
|
110
|
+
export function parsePlasticStatusBranch(output: string): ParseOutcome<PlasticStatusBranch> {
|
|
111
|
+
const lines = nonEmptyLines(output);
|
|
112
|
+
if (lines.length === 0) return { kind: "not_found" };
|
|
113
|
+
|
|
114
|
+
const workspaceMatch = lines[0].match(/^([^@\s]+)@/);
|
|
115
|
+
if (workspaceMatch) return { kind: "found", value: { branch: normalizeStatusBranch(workspaceMatch[1]) } };
|
|
116
|
+
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
const branchSpec = line.match(/\bbr:[^\s)]+/i);
|
|
119
|
+
if (branchSpec) return { kind: "found", value: { branch: normalizeStatusBranch(branchSpec[0]) } };
|
|
120
|
+
|
|
121
|
+
const branchLine = line.match(/^branch\s*[:=]\s*(.+)$/i);
|
|
122
|
+
if (branchLine?.[1]?.trim()) return { kind: "found", value: { branch: normalizeStatusBranch(branchLine[1]) } };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const changeset = output.match(/\bcs:(\d+)/i);
|
|
126
|
+
if (changeset) return { kind: "found", value: { changesetId: changeset[1] } };
|
|
127
|
+
|
|
128
|
+
return { kind: "malformed", reason: "Status output contains no recognized branch or changeset." };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const isCombining = (codePoint: number): boolean =>
|
|
132
|
+
(codePoint >= 0x0300 && codePoint <= 0x036f)
|
|
133
|
+
|| (codePoint >= 0x1ab0 && codePoint <= 0x1aff)
|
|
134
|
+
|| (codePoint >= 0x1dc0 && codePoint <= 0x1dff)
|
|
135
|
+
|| (codePoint >= 0xfe20 && codePoint <= 0xfe2f);
|
|
136
|
+
|
|
137
|
+
const isWide = (codePoint: number): boolean =>
|
|
138
|
+
codePoint >= 0x1100 && (
|
|
139
|
+
codePoint <= 0x115f || codePoint === 0x2329 || codePoint === 0x232a
|
|
140
|
+
|| (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f)
|
|
141
|
+
|| (codePoint >= 0xac00 && codePoint <= 0xd7a3)
|
|
142
|
+
|| (codePoint >= 0xf900 && codePoint <= 0xfaff)
|
|
143
|
+
|| (codePoint >= 0xfe10 && codePoint <= 0xfe19)
|
|
144
|
+
|| (codePoint >= 0xfe30 && codePoint <= 0xfe6f)
|
|
145
|
+
|| (codePoint >= 0xff00 && codePoint <= 0xff60)
|
|
146
|
+
|| (codePoint >= 0xffe0 && codePoint <= 0xffe6)
|
|
147
|
+
|| (codePoint >= 0x1f300 && codePoint <= 0x1faff)
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
export function sanitizePlasticBranch(branch: string, maxColumns = 80): string {
|
|
151
|
+
const clean = branch.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
|
|
152
|
+
if (maxColumns <= 0) return "";
|
|
153
|
+
|
|
154
|
+
let width = 0;
|
|
155
|
+
let result = "";
|
|
156
|
+
let truncated = false;
|
|
157
|
+
for (const character of clean) {
|
|
158
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
159
|
+
const characterWidth = isCombining(codePoint) ? 0 : isWide(codePoint) ? 2 : 1;
|
|
160
|
+
if (width + characterWidth > maxColumns) {
|
|
161
|
+
truncated = true;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
result += character;
|
|
165
|
+
width += characterWidth;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!truncated) return result;
|
|
169
|
+
while (result && width > maxColumns - 1) {
|
|
170
|
+
const character = Array.from(result).pop()!;
|
|
171
|
+
result = Array.from(result).slice(0, -1).join("");
|
|
172
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
173
|
+
width -= isCombining(codePoint) ? 0 : isWide(codePoint) ? 2 : 1;
|
|
174
|
+
}
|
|
175
|
+
return `${result}…`;
|
|
176
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { access, readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
// Structural copies of the narrow public v1 contract keep the policy module loadable
|
|
6
|
+
// when the optional registry package is absent. The installed registry validates the
|
|
7
|
+
// record when registration is available.
|
|
8
|
+
type RepoSearchOwnerV1 = Readonly<{ packageName: string; packageVersion: string; packageRoot: string; registeredBy: string }>;
|
|
9
|
+
type RepoSearchExecutionContextV1 = Readonly<{ cwd: string; requestId?: string; signal: AbortSignal }>;
|
|
10
|
+
type RepositoryPolicyRequestV1 = Readonly<{ workspaceRoot: string; roots: readonly string[]; includeHidden: boolean; options?: Readonly<Record<string, unknown>>; signal: AbortSignal }>;
|
|
11
|
+
type RepositoryPolicyResultV1 =
|
|
12
|
+
| Readonly<{ outcome: "not_applicable" }>
|
|
13
|
+
| Readonly<{ outcome: "applied"; roots: readonly Readonly<{ root: string; policyOwnedRoot?: string; excludeGlobs?: readonly string[]; ignoreFiles?: readonly string[]; disclosures: readonly string[] }>[] }>
|
|
14
|
+
| Readonly<{ outcome: "unavailable" | "error"; code: string; retryable: boolean }>;
|
|
15
|
+
type RepositoryPolicyV1 = Readonly<{ contractVersion: 1; id: string; kind: "repository-search-policy"; owner: RepoSearchOwnerV1; evaluate(context: RepoSearchExecutionContextV1, request: RepositoryPolicyRequestV1): Promise<RepositoryPolicyResultV1> }>;
|
|
16
|
+
import { discoverPlasticWorkspace } from "./plastic-workspace.ts";
|
|
17
|
+
|
|
18
|
+
export const PLASTIC_REPOSITORY_SEARCH_POLICY_ID = "plastic.ignore-files" as const;
|
|
19
|
+
|
|
20
|
+
/** Resolve this physical package copy's identity for repository-search registration. */
|
|
21
|
+
export async function loadPlasticOwnerV1(moduleUrl: string = import.meta.url): Promise<RepoSearchOwnerV1> {
|
|
22
|
+
const registeredBy = fileURLToPath(moduleUrl);
|
|
23
|
+
const packageRoot = await realpath(fileURLToPath(new URL("../", moduleUrl)));
|
|
24
|
+
const manifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")) as { name?: unknown; version?: unknown };
|
|
25
|
+
if (manifest.name !== "@aefree/pi-plastic" || typeof manifest.version !== "string" || manifest.version.trim() === "") throw new Error(`Invalid @aefree/pi-plastic package identity at ${packageRoot}.`);
|
|
26
|
+
return Object.freeze({ packageName: "@aefree/pi-plastic", packageVersion: manifest.version, packageRoot, registeredBy });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Independent executable policy for bounded Plastic ignore/cloaking discovery. */
|
|
30
|
+
export function createPlasticRepositorySearchPolicyV1(owner: RepoSearchOwnerV1): RepositoryPolicyV1 {
|
|
31
|
+
return Object.freeze({
|
|
32
|
+
contractVersion: 1, id: PLASTIC_REPOSITORY_SEARCH_POLICY_ID, kind: "repository-search-policy", owner,
|
|
33
|
+
async evaluate(context: RepoSearchExecutionContextV1, request: RepositoryPolicyRequestV1): Promise<RepositoryPolicyResultV1> {
|
|
34
|
+
throwIfAborted(context.signal);
|
|
35
|
+
const roots = [] as { root: string; policyOwnedRoot: string; ignoreFiles: string[]; disclosures: string[] }[];
|
|
36
|
+
for (const root of request.roots) {
|
|
37
|
+
throwIfAborted(context.signal);
|
|
38
|
+
const workspace = await discoverPlasticWorkspace(root);
|
|
39
|
+
if (workspace.kind === "unavailable") return { outcome: "unavailable", code: "plastic_workspace_unavailable", retryable: true };
|
|
40
|
+
if (workspace.kind !== "found") continue;
|
|
41
|
+
const canonicalRoot = await realpath(root).catch(() => undefined);
|
|
42
|
+
const canonicalWorkspace = await realpath(workspace.value.root).catch(() => undefined);
|
|
43
|
+
if (!canonicalRoot || !canonicalWorkspace || !isWithin(canonicalWorkspace, canonicalRoot)) return { outcome: "error", code: "plastic_workspace_boundary_invalid", retryable: false };
|
|
44
|
+
const ignoreFiles = await discoverIgnoreFiles(canonicalWorkspace, canonicalRoot, context.signal);
|
|
45
|
+
roots.push({ root: canonicalRoot, policyOwnedRoot: canonicalWorkspace, ignoreFiles, disclosures: [ignoreFiles.length ? `Plastic ignore policy applied (${ignoreFiles.length} file${ignoreFiles.length === 1 ? "" : "s"}).` : "Plastic workspace detected; no readable ignore.conf or cloaked.conf found."] });
|
|
46
|
+
}
|
|
47
|
+
return roots.length ? { outcome: "applied", roots } : { outcome: "not_applicable" };
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function discoverIgnoreFiles(workspaceRoot: string, root: string, signal: AbortSignal): Promise<string[]> {
|
|
53
|
+
const stats = await stat(root); let current = stats.isDirectory() ? root : dirname(root); const directories: string[] = [];
|
|
54
|
+
while (true) { directories.push(current); if (samePath(current, workspaceRoot)) break; const parent = dirname(current); if (parent === current || !isWithin(workspaceRoot, parent)) break; current = parent; }
|
|
55
|
+
directories.reverse(); const files: string[] = [];
|
|
56
|
+
for (const directory of directories) for (const name of ["ignore.conf", "cloaked.conf"]) {
|
|
57
|
+
throwIfAborted(signal); const candidate = join(directory, name);
|
|
58
|
+
try { if ((await stat(candidate)).isFile()) { await access(candidate, fsConstants.R_OK); files.push(candidate); } } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT" && code !== "ENOTDIR") throw error; }
|
|
59
|
+
}
|
|
60
|
+
return files;
|
|
61
|
+
}
|
|
62
|
+
function isWithin(root: string, target: string): boolean { const normalizedRoot = root.replaceAll("\\", "/").replace(/\/$/, ""); const normalizedTarget = target.replaceAll("\\", "/"); return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}/`); }
|
|
63
|
+
function samePath(a: string, b: string): boolean { return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b; }
|
|
64
|
+
function throwIfAborted(signal: AbortSignal): void { if (signal.aborted) { const error = new Error("Operation cancelled."); error.name = "AbortError"; throw error; } }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { __bashCmDiffGuardInternals } from "../extensions/bash-cm-diff-guard.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 expectBlocked = (command: string): void =>
|
|
12
|
+
{
|
|
13
|
+
assert(
|
|
14
|
+
__bashCmDiffGuardInternals.commandRunsCmDiff(command),
|
|
15
|
+
`Expected command to be blocked: ${command}`,
|
|
16
|
+
);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const expectAllowed = (command: string): void =>
|
|
20
|
+
{
|
|
21
|
+
assert(
|
|
22
|
+
!__bashCmDiffGuardInternals.commandRunsCmDiff(command),
|
|
23
|
+
`Expected command to be allowed: ${command}`,
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const main = (): void =>
|
|
28
|
+
{
|
|
29
|
+
expectBlocked("cm diff cs:123");
|
|
30
|
+
expectBlocked("env FOO=bar cm diff cs:123");
|
|
31
|
+
expectBlocked("command cm diff cs:123");
|
|
32
|
+
expectBlocked("sudo cm diff cs:123");
|
|
33
|
+
expectBlocked("time cm diff cs:123");
|
|
34
|
+
expectBlocked("nice -n 5 cm diff cs:123");
|
|
35
|
+
expectBlocked("zsh -lc \"cm diff cs:123\"");
|
|
36
|
+
expectBlocked("zsh -c \"cm diff cs:123\"");
|
|
37
|
+
expectBlocked("zsh -lc \"command cm diff cs:123\"");
|
|
38
|
+
expectBlocked("bash -lc \"env FOO=bar cm diff cs:123\"");
|
|
39
|
+
expectBlocked("cmd /c \"cm diff cs:123\"");
|
|
40
|
+
expectBlocked("cmd /c cm diff cs:123");
|
|
41
|
+
expectBlocked("pwsh -command \"cm diff cs:123\"");
|
|
42
|
+
expectBlocked("powershell -command cm diff cs:123");
|
|
43
|
+
expectBlocked("\"C:\\Program Files\\PlasticSCM5\\client\\cm.exe\" diff cs:123");
|
|
44
|
+
expectBlocked("cm status & cm diff cs:123");
|
|
45
|
+
expectBlocked("cm status\ncm diff cs:123");
|
|
46
|
+
|
|
47
|
+
expectAllowed("cm status");
|
|
48
|
+
expectAllowed("cm merge br:/main");
|
|
49
|
+
expectAllowed("git diff --stat");
|
|
50
|
+
expectAllowed("zsh -lc \"cm status\"");
|
|
51
|
+
|
|
52
|
+
console.log("PASS: bash cm diff guard validation succeeded");
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
main();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Workspace status for cs:42@br:/main/feature/footer@sanitized-repository@sanitized-server
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/main/feature/footer@sanitized-repository@sanitized-server (cs:42 - head)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { createPlasticRepositorySearchPolicyV1, loadPlasticOwnerV1 } from "../src/repository-search-provider.ts";
|
|
8
|
+
|
|
9
|
+
const temp = await mkdtemp(path.join(tmpdir(), "pi-plastic-packed-copies-"));
|
|
10
|
+
try {
|
|
11
|
+
const npmCli = process.env.npm_execpath ?? (process.platform === "win32" ? path.join(process.env.APPDATA!, "npm", "node_modules", "npm", "bin", "npm-cli.js") : "/usr/lib/node_modules/npm/bin/npm-cli.js");
|
|
12
|
+
const packed = spawnSync(process.execPath, [npmCli, "pack", "--json", "--pack-destination", temp], { cwd: path.join(import.meta.dirname, ".."), encoding: "utf8", shell: false });
|
|
13
|
+
assert.equal(packed.status, 0, packed.stderr);
|
|
14
|
+
const filename = JSON.parse(packed.stdout)[0].filename as string;
|
|
15
|
+
const first = path.join(temp, "first"); const second = path.join(temp, "second");
|
|
16
|
+
await mkdir(first); await mkdir(second);
|
|
17
|
+
for (const destination of [first, second]) {
|
|
18
|
+
const extracted = spawnSync("tar", ["-xf", filename, "-C", path.basename(destination), "package/package.json", "package/extensions/repository-search-provider.ts"], { cwd: temp, encoding: "utf8", shell: false });
|
|
19
|
+
assert.equal(extracted.status, 0, extracted.stderr);
|
|
20
|
+
}
|
|
21
|
+
const entry = (root: string) => pathToFileURL(path.join(root, "package", "extensions", "repository-search-provider.ts")).href;
|
|
22
|
+
const ownerOne = await loadPlasticOwnerV1(entry(first));
|
|
23
|
+
const ownerTwo = await loadPlasticOwnerV1(entry(second));
|
|
24
|
+
assert.notEqual(ownerOne.packageRoot, ownerTwo.packageRoot);
|
|
25
|
+
assert.equal(ownerOne.packageVersion, ownerTwo.packageVersion);
|
|
26
|
+
assert.equal(createPlasticRepositorySearchPolicyV1(ownerOne).id, createPlasticRepositorySearchPolicyV1(ownerTwo).id);
|
|
27
|
+
} finally { await rm(temp, { recursive: true, force: true }); }
|
|
28
|
+
console.log("PASS: two packed physical pi-plastic copies retain distinct owner roots");
|