@loomhelix/deptrail 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +421 -0
- package/dist/actions/action-error.js +11 -0
- package/dist/actions/action-jobs.js +199 -0
- package/dist/actions/action-service.js +268 -0
- package/dist/actions/apply.js +120 -0
- package/dist/actions/availability.js +79 -0
- package/dist/actions/change-sets.js +117 -0
- package/dist/actions/dry-run.js +287 -0
- package/dist/actions/edge-moves.js +108 -0
- package/dist/actions/edit-plan.js +179 -0
- package/dist/actions/file-scope.js +118 -0
- package/dist/actions/fix-options.js +141 -0
- package/dist/actions/goal-check.js +80 -0
- package/dist/actions/install.js +27 -0
- package/dist/actions/job-registry.js +104 -0
- package/dist/actions/json-edit.js +189 -0
- package/dist/actions/labels.js +35 -0
- package/dist/actions/node-actions.js +102 -0
- package/dist/actions/overrides-location.js +59 -0
- package/dist/actions/parent-chain.js +210 -0
- package/dist/actions/range-style.js +38 -0
- package/dist/actions/recipes.js +31 -0
- package/dist/actions/root-files.js +30 -0
- package/dist/actions/sandbox.js +175 -0
- package/dist/actions/set-range.js +59 -0
- package/dist/actions/shared-packages.js +50 -0
- package/dist/actions/text-diff.js +203 -0
- package/dist/actions/types.js +1 -0
- package/dist/actions/update-options.js +232 -0
- package/dist/actions/workspace-edit.js +33 -0
- package/dist/adapters/command-runner.js +127 -0
- package/dist/adapters/manifest-only.js +20 -0
- package/dist/adapters/node-modules.js +148 -0
- package/dist/adapters/npm-lock.js +149 -0
- package/dist/adapters/package-manifest.js +57 -0
- package/dist/adapters/pnpm-lock.js +177 -0
- package/dist/adapters/resolve-today.js +93 -0
- package/dist/cli/cli.js +227 -0
- package/dist/cli/open-browser.js +14 -0
- package/dist/cli/package-version.js +6 -0
- package/dist/cli/usage-text.js +27 -0
- package/dist/config/glob.js +119 -0
- package/dist/config/npmrc.js +116 -0
- package/dist/config/root-config.js +103 -0
- package/dist/config/secrets.js +18 -0
- package/dist/discovery/find-roots.js +107 -0
- package/dist/enrich/credentials.js +143 -0
- package/dist/enrich/enrichment-cache.js +42 -0
- package/dist/enrich/enrichment-keys.js +71 -0
- package/dist/enrich/enrichment-service.js +399 -0
- package/dist/enrich/enrichment-status.js +86 -0
- package/dist/enrich/enrichment-store.js +143 -0
- package/dist/enrich/http.js +201 -0
- package/dist/enrich/on-demand.js +220 -0
- package/dist/enrich/osv-client.js +260 -0
- package/dist/enrich/registry-client.js +123 -0
- package/dist/enrich/types.js +1 -0
- package/dist/git/blob-reader.js +180 -0
- package/dist/git/git-commands.js +32 -0
- package/dist/git/git-process.js +71 -0
- package/dist/git/git-repository.js +136 -0
- package/dist/git/ref-list.js +78 -0
- package/dist/git/tree-index.js +87 -0
- package/dist/graph/global-index.js +19 -0
- package/dist/graph/root-graph-builder.js +114 -0
- package/dist/graph/serialize.js +19 -0
- package/dist/model.js +2 -0
- package/dist/query/children.js +38 -0
- package/dist/query/compare-shape.js +13 -0
- package/dist/query/compare.js +245 -0
- package/dist/query/decorations.js +121 -0
- package/dist/query/dependents.js +135 -0
- package/dist/query/duplicates.js +23 -0
- package/dist/query/enrichment-lookup.js +78 -0
- package/dist/query/fixes.js +60 -0
- package/dist/query/graph-diff.js +42 -0
- package/dist/query/issues.js +235 -0
- package/dist/query/lookup.js +49 -0
- package/dist/query/node-fix.js +46 -0
- package/dist/query/package-insight.js +112 -0
- package/dist/query/search.js +61 -0
- package/dist/query/subgraph.js +107 -0
- package/dist/query/versions.js +103 -0
- package/dist/scan/build-root.js +137 -0
- package/dist/scan/cache-shape.js +52 -0
- package/dist/scan/scan-cache.js +32 -0
- package/dist/scan/scan-session.js +127 -0
- package/dist/scan/source-registry.js +55 -0
- package/dist/server/auth.js +45 -0
- package/dist/server/branches.js +105 -0
- package/dist/server/error-log.js +9 -0
- package/dist/server/root-segment.js +13 -0
- package/dist/server/router.js +474 -0
- package/dist/server/server.js +166 -0
- package/dist/server/source-key.js +1 -0
- package/dist/server/static.js +59 -0
- package/dist/sources/file-reader.js +55 -0
- package/dist/sources/git-reader.js +45 -0
- package/dist/ui/assets/index-C4uyB5v3.css +1 -0
- package/dist/ui/assets/index-DYAG2LM-.js +51 -0
- package/dist/ui/index.html +15 -0
- package/package.json +72 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { SEVERITY_ORDER } from "../enrich/types.js";
|
|
3
|
+
import { PLACEHOLDER_PREFIX } from "../model.js";
|
|
4
|
+
import { compareVersions, graphDiff, packageVersions } from "../query/graph-diff.js";
|
|
5
|
+
import { messageOf, parseRootLockfile } from "../scan/build-root.js";
|
|
6
|
+
import { edgeMoveWarnings } from "./edge-moves.js";
|
|
7
|
+
import { ROOT_MANIFEST, WORKSPACE_FILE, applySteps, manifestPathOf, relockPins, stepPaths } from "./edit-plan.js";
|
|
8
|
+
import { checkGoals } from "./goal-check.js";
|
|
9
|
+
import { parseManagerVersion } from "./overrides-location.js";
|
|
10
|
+
import { LOCKFILE_ONLY_ARGS, PROBE_TIMEOUT_MS, SANDBOX_TIMEOUT_MS, commandText } from "./recipes.js";
|
|
11
|
+
import { sharedRootWarnings } from "./shared-packages.js";
|
|
12
|
+
import { createSandbox } from "./sandbox.js";
|
|
13
|
+
import { truncateUtf8, unifiedDiff } from "./text-diff.js";
|
|
14
|
+
export { checkGoals };
|
|
15
|
+
export const LOCKFILE_DIFF_MAX_BYTES = 2_097_152;
|
|
16
|
+
export const STDERR_TAIL_CHARS = 400;
|
|
17
|
+
const OUTPUT_WARNING_LIMIT = 20;
|
|
18
|
+
const CANCELED_MESSAGE = 'canceled';
|
|
19
|
+
const SANDBOX_TIMEOUT_TEXT = `${SANDBOX_TIMEOUT_MS / 60_000} minutes`;
|
|
20
|
+
const PROBE_TIMEOUT_TEXT = `${PROBE_TIMEOUT_MS / 1000} seconds`;
|
|
21
|
+
const VERSION_ARGS = ['--version'];
|
|
22
|
+
const EOVERRIDE_CODE = 'EOVERRIDE';
|
|
23
|
+
const EOVERRIDE_HINT = ' npm refused an override of a direct dependency; update the direct dependency instead.';
|
|
24
|
+
const PNPM_WARNING_PREFIX = 'WARN';
|
|
25
|
+
const NPM_WARNING_PREFIX = 'npm warn';
|
|
26
|
+
const LOCKFILE_VERSION_PATTERN = /^\s*"?lockfileVersion"?\s*:\s*['"]?([0-9][\w.]*)/m;
|
|
27
|
+
export class DryRunError extends Error {
|
|
28
|
+
cancelled;
|
|
29
|
+
constructor(message, cancelled = false) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.cancelled = cancelled;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function throwIfCancelled(signal) {
|
|
35
|
+
if (signal.aborted)
|
|
36
|
+
throw new DryRunError(CANCELED_MESSAGE, true);
|
|
37
|
+
}
|
|
38
|
+
function manifestPaths(root) {
|
|
39
|
+
return [...new Set([ROOT_MANIFEST, ...root.importers.map((importer) => manifestPathOf(importer.id))])].sort();
|
|
40
|
+
}
|
|
41
|
+
async function readTexts(sandbox, paths) {
|
|
42
|
+
const texts = new Map();
|
|
43
|
+
for (const path of paths)
|
|
44
|
+
texts.set(path, await sandbox.readText(path));
|
|
45
|
+
return texts;
|
|
46
|
+
}
|
|
47
|
+
function editedTexts(texts, steps) {
|
|
48
|
+
const changed = new Map();
|
|
49
|
+
for (const path of stepPaths(steps)) {
|
|
50
|
+
const stepsOfPath = steps.filter((step) => stepPaths([step])[0] === path);
|
|
51
|
+
try {
|
|
52
|
+
for (const [changedPath, text] of applySteps(texts, stepsOfPath))
|
|
53
|
+
changed.set(changedPath, text);
|
|
54
|
+
}
|
|
55
|
+
catch (failure) {
|
|
56
|
+
throw new DryRunError(`could not edit ${path}: ${messageOf(failure)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return changed;
|
|
60
|
+
}
|
|
61
|
+
async function editSandbox(sandbox, texts, steps) {
|
|
62
|
+
const previous = new Map();
|
|
63
|
+
for (const [path, text] of editedTexts(texts, steps)) {
|
|
64
|
+
previous.set(path, texts.get(path) ?? null);
|
|
65
|
+
texts.set(path, text);
|
|
66
|
+
await sandbox.writeText(path, text);
|
|
67
|
+
}
|
|
68
|
+
return previous;
|
|
69
|
+
}
|
|
70
|
+
async function restoreSandbox(sandbox, previous) {
|
|
71
|
+
for (const [path, text] of previous) {
|
|
72
|
+
if (text === null)
|
|
73
|
+
await sandbox.deleteFile(path);
|
|
74
|
+
else
|
|
75
|
+
await sandbox.writeText(path, text);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function isWarningLine(line) {
|
|
79
|
+
return line.startsWith(PNPM_WARNING_PREFIX) || line.toLowerCase().startsWith(NPM_WARNING_PREFIX);
|
|
80
|
+
}
|
|
81
|
+
function createOutputLog(input) {
|
|
82
|
+
const warnings = new Set();
|
|
83
|
+
return {
|
|
84
|
+
warnings,
|
|
85
|
+
onLine(line) {
|
|
86
|
+
const redacted = input.secrets.redact(line);
|
|
87
|
+
const trimmed = redacted.trim();
|
|
88
|
+
if (warnings.size < OUTPUT_WARNING_LIMIT && isWarningLine(trimmed))
|
|
89
|
+
warnings.add(trimmed);
|
|
90
|
+
input.onLine(redacted);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function failureMessage(command, result, secrets) {
|
|
95
|
+
const output = secrets.redact([result.stdout.trim(), result.stderr.trim()].filter((text) => text !== '').join('\n'));
|
|
96
|
+
const hint = output.includes(EOVERRIDE_CODE) ? EOVERRIDE_HINT : '';
|
|
97
|
+
return `${command} failed: ${output.slice(-STDERR_TAIL_CHARS)}${hint}`;
|
|
98
|
+
}
|
|
99
|
+
function startFailureMessage(input, command, failure) {
|
|
100
|
+
if (failure.code === 'ENOENT')
|
|
101
|
+
return `${input.manager.name} was not found`;
|
|
102
|
+
return `${command} could not be started: ${input.secrets.redact(messageOf(failure))}`;
|
|
103
|
+
}
|
|
104
|
+
async function runManager(input, run) {
|
|
105
|
+
const command = commandText(input.manager.name, run.args);
|
|
106
|
+
let result;
|
|
107
|
+
try {
|
|
108
|
+
result = await input.runner.run({
|
|
109
|
+
command: input.manager.name,
|
|
110
|
+
args: run.args,
|
|
111
|
+
cwd: run.cwd,
|
|
112
|
+
env: input.env,
|
|
113
|
+
timeoutMs: run.timeoutMs,
|
|
114
|
+
signal: input.signal,
|
|
115
|
+
...(run.onLine === undefined ? {} : { onLine: run.onLine }),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (failure) {
|
|
119
|
+
throw new DryRunError(startFailureMessage(input, command, failure));
|
|
120
|
+
}
|
|
121
|
+
if (result.cancelled)
|
|
122
|
+
throw new DryRunError(CANCELED_MESSAGE, true);
|
|
123
|
+
if (result.timedOut)
|
|
124
|
+
throw new DryRunError(`${command} did not finish within ${run.timeoutText}`);
|
|
125
|
+
if (result.code !== 0)
|
|
126
|
+
throw new DryRunError(failureMessage(command, result, input.secrets));
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
async function checkSandboxManager(input, cwd) {
|
|
130
|
+
const result = await runManager(input, { args: VERSION_ARGS, cwd, timeoutMs: PROBE_TIMEOUT_MS, timeoutText: PROBE_TIMEOUT_TEXT });
|
|
131
|
+
const found = parseManagerVersion(result.stdout);
|
|
132
|
+
if (found === undefined)
|
|
133
|
+
throw new DryRunError(`${commandText(input.manager.name, VERSION_ARGS)} printed no version in the temporary copy`);
|
|
134
|
+
if (found !== input.manager.version)
|
|
135
|
+
throw new DryRunError(`the temporary copy runs ${input.manager.name} ${found} instead of ${input.manager.version}`);
|
|
136
|
+
}
|
|
137
|
+
async function resolveLockfile(input, cwd, output) {
|
|
138
|
+
await runManager(input, { args: LOCKFILE_ONLY_ARGS[input.manager.name], cwd, timeoutMs: SANDBOX_TIMEOUT_MS, timeoutText: SANDBOX_TIMEOUT_TEXT, onLine: output.onLine });
|
|
139
|
+
}
|
|
140
|
+
function lockfileVersionOf(text) {
|
|
141
|
+
return text === null ? undefined : LOCKFILE_VERSION_PATTERN.exec(text)?.[1];
|
|
142
|
+
}
|
|
143
|
+
function lockfileVersionWarning(before, after) {
|
|
144
|
+
const from = lockfileVersionOf(before);
|
|
145
|
+
const to = lockfileVersionOf(after);
|
|
146
|
+
return from !== undefined && to !== undefined && from !== to ? [`Lockfile format version changes from ${from} to ${to}`] : [];
|
|
147
|
+
}
|
|
148
|
+
function writtenTexts(paths, originals, finals) {
|
|
149
|
+
const written = new Map();
|
|
150
|
+
for (const path of paths) {
|
|
151
|
+
const text = finals.get(path) ?? null;
|
|
152
|
+
if (text !== null && text !== originals.get(path))
|
|
153
|
+
written.set(path, text);
|
|
154
|
+
}
|
|
155
|
+
return written;
|
|
156
|
+
}
|
|
157
|
+
function previewFiles(written, originals, lockfile, shared) {
|
|
158
|
+
const files = [];
|
|
159
|
+
let lockfileDiff = { diff: '', truncated: false };
|
|
160
|
+
for (const [path, text] of written) {
|
|
161
|
+
const original = originals.get(path) ?? null;
|
|
162
|
+
const { diff, added, removed } = unifiedDiff(path, original, text);
|
|
163
|
+
const change = original === null ? 'created' : 'modified';
|
|
164
|
+
const sharedWith = (shared.get(path) ?? []).map((sharing) => sharing.id);
|
|
165
|
+
const sharedMark = sharedWith.length > 0 ? { sharedWith } : {};
|
|
166
|
+
if (path !== lockfile) {
|
|
167
|
+
files.push({ path, change, added, removed, diff, ...sharedMark });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const cut = truncateUtf8(diff, LOCKFILE_DIFF_MAX_BYTES);
|
|
171
|
+
lockfileDiff = { diff: cut.text, truncated: cut.truncated };
|
|
172
|
+
files.push({ path, change, added, removed, ...sharedMark, ...(cut.truncated ? { diffTruncated: true } : {}) });
|
|
173
|
+
}
|
|
174
|
+
return { files, lockfileDiff };
|
|
175
|
+
}
|
|
176
|
+
function isRegistryPackage(node) {
|
|
177
|
+
return node.nonRegistry !== true && !node.id.startsWith(PLACEHOLDER_PREFIX);
|
|
178
|
+
}
|
|
179
|
+
function newPackages(before, after) {
|
|
180
|
+
const known = packageVersions(before);
|
|
181
|
+
const fresh = new Map();
|
|
182
|
+
for (const node of after.packages.values()) {
|
|
183
|
+
if (!isRegistryPackage(node) || known.get(node.name)?.has(node.version))
|
|
184
|
+
continue;
|
|
185
|
+
fresh.set(`${node.name}@${node.version}`, { name: node.name, version: node.version });
|
|
186
|
+
}
|
|
187
|
+
return [...fresh.values()].sort((left, right) => (left.name === right.name ? compareVersions(left.version, right.version) : left.name < right.name ? -1 : 1));
|
|
188
|
+
}
|
|
189
|
+
function severityRank(issue) {
|
|
190
|
+
return issue.severity === undefined ? SEVERITY_ORDER.length : SEVERITY_ORDER.indexOf(issue.severity);
|
|
191
|
+
}
|
|
192
|
+
function compareIssues(left, right) {
|
|
193
|
+
const bySeverity = severityRank(left) - severityRank(right);
|
|
194
|
+
if (bySeverity !== 0)
|
|
195
|
+
return bySeverity;
|
|
196
|
+
if (left.name !== right.name)
|
|
197
|
+
return left.name < right.name ? -1 : 1;
|
|
198
|
+
return compareVersions(left.version, right.version);
|
|
199
|
+
}
|
|
200
|
+
function issueOf(assessed) {
|
|
201
|
+
if (assessed.severity === undefined && assessed.deprecated === undefined && assessed.notChecked === undefined)
|
|
202
|
+
return undefined;
|
|
203
|
+
return {
|
|
204
|
+
name: assessed.name,
|
|
205
|
+
version: assessed.version,
|
|
206
|
+
...(assessed.severity === undefined ? {} : { severity: assessed.severity }),
|
|
207
|
+
vulnIds: assessed.vulnIds,
|
|
208
|
+
...(assessed.deprecated === undefined ? {} : { deprecated: assessed.deprecated }),
|
|
209
|
+
...(assessed.notChecked === undefined ? {} : { notChecked: assessed.notChecked }),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
async function newIssues(input, after) {
|
|
213
|
+
const packages = newPackages(input.root, after);
|
|
214
|
+
if (packages.length === 0)
|
|
215
|
+
return [];
|
|
216
|
+
const assessed = await input.assess(packages);
|
|
217
|
+
return assessed.flatMap((entry) => issueOf(entry) ?? []).sort(compareIssues);
|
|
218
|
+
}
|
|
219
|
+
function parsedLockfile(input, text) {
|
|
220
|
+
if (text === null)
|
|
221
|
+
throw new DryRunError(`could not parse the new lockfile: ${input.lockfile} was not written`);
|
|
222
|
+
const parsed = parseRootLockfile(input.root, input.lockfile, text);
|
|
223
|
+
if ('error' in parsed)
|
|
224
|
+
throw new DryRunError(`could not parse the new lockfile: ${input.secrets.redact(parsed.error)}`);
|
|
225
|
+
return parsed.graph;
|
|
226
|
+
}
|
|
227
|
+
export async function runDryRun(input) {
|
|
228
|
+
input.onPhase('staging');
|
|
229
|
+
const manifests = manifestPaths(input.root);
|
|
230
|
+
const writeList = [...manifests, WORKSPACE_FILE, input.lockfile];
|
|
231
|
+
const sandbox = await createSandbox({
|
|
232
|
+
scope: input.scope,
|
|
233
|
+
manager: input.manager,
|
|
234
|
+
importerPaths: manifests.map((path) => posix.dirname(path)),
|
|
235
|
+
lockfile: input.lockfile,
|
|
236
|
+
});
|
|
237
|
+
try {
|
|
238
|
+
throwIfCancelled(input.signal);
|
|
239
|
+
const originals = await readTexts(sandbox, writeList);
|
|
240
|
+
const steps = input.items.flatMap((item) => item.steps);
|
|
241
|
+
const texts = new Map(originals);
|
|
242
|
+
await editSandbox(sandbox, texts, steps);
|
|
243
|
+
const relocks = steps.filter((step) => step.kind === 'relock');
|
|
244
|
+
const pins = relockPins(input.root, input.manager.name, relocks, input.overridesFile);
|
|
245
|
+
const beforePins = await editSandbox(sandbox, texts, pins);
|
|
246
|
+
throwIfCancelled(input.signal);
|
|
247
|
+
input.onPhase('resolving');
|
|
248
|
+
const output = createOutputLog(input);
|
|
249
|
+
await checkSandboxManager(input, sandbox.dir);
|
|
250
|
+
await resolveLockfile(input, sandbox.dir, output);
|
|
251
|
+
if (beforePins.size > 0) {
|
|
252
|
+
await restoreSandbox(sandbox, beforePins);
|
|
253
|
+
await resolveLockfile(input, sandbox.dir, output);
|
|
254
|
+
}
|
|
255
|
+
input.onPhase('checking');
|
|
256
|
+
const finals = await readTexts(sandbox, writeList);
|
|
257
|
+
const after = parsedLockfile(input, finals.get(input.lockfile) ?? null);
|
|
258
|
+
const issues = await newIssues(input, after);
|
|
259
|
+
throwIfCancelled(input.signal);
|
|
260
|
+
const written = writtenTexts(writeList, originals, finals);
|
|
261
|
+
const { files, lockfileDiff } = previewFiles(written, originals, input.lockfile, input.sharedManifests);
|
|
262
|
+
const warnings = [
|
|
263
|
+
...input.items.flatMap((item) => item.warnings),
|
|
264
|
+
...lockfileVersionWarning(originals.get(input.lockfile) ?? null, finals.get(input.lockfile) ?? null),
|
|
265
|
+
...edgeMoveWarnings(steps, input.root, after),
|
|
266
|
+
...sharedRootWarnings([...written.keys()], input.sharedManifests),
|
|
267
|
+
...output.warnings,
|
|
268
|
+
];
|
|
269
|
+
const preview = {
|
|
270
|
+
id: input.previewId,
|
|
271
|
+
rootId: input.root.id,
|
|
272
|
+
createdAt: input.now().toISOString(),
|
|
273
|
+
stale: false,
|
|
274
|
+
manager: { name: input.manager.name, version: input.manager.version },
|
|
275
|
+
goals: checkGoals(input.items, input.root, after, input.view),
|
|
276
|
+
newIssues: issues,
|
|
277
|
+
warnings: [...new Set(warnings)],
|
|
278
|
+
files,
|
|
279
|
+
packageChanges: graphDiff(input.root, after),
|
|
280
|
+
...(input.scope.outsideManifests.length > 0 ? { outsidePackages: input.scope.outsideManifests.length } : {}),
|
|
281
|
+
};
|
|
282
|
+
return { preview, written, originals, sources: new Map(sandbox.sources), lockfileDiff };
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
await sandbox.remove();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import semver from 'semver';
|
|
2
|
+
import { PLACEHOLDER_PREFIX } from "../model.js";
|
|
3
|
+
import { selectorParts } from "./edit-plan.js";
|
|
4
|
+
import { importerLabel } from "./labels.js";
|
|
5
|
+
import { catalogNameOf } from "./range-style.js";
|
|
6
|
+
const DEFAULT_CATALOG = 'default';
|
|
7
|
+
function targetedName(step) {
|
|
8
|
+
if (step.kind === 'relock')
|
|
9
|
+
return step.name;
|
|
10
|
+
if (step.kind === 'override')
|
|
11
|
+
return selectorParts(step.selector).name;
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
function isImporter(parent) {
|
|
15
|
+
return 'path' in parent;
|
|
16
|
+
}
|
|
17
|
+
function edgeKey(parent, edge) {
|
|
18
|
+
return isImporter(parent) ? `${parent.id}\0${edge.kind}\0${edge.range}` : `${parent.name}@${parent.version}\0${edge.kind}`;
|
|
19
|
+
}
|
|
20
|
+
function parentLabel(parent) {
|
|
21
|
+
return isImporter(parent) ? importerLabel(parent.id) : `${parent.name} ${parent.version}`;
|
|
22
|
+
}
|
|
23
|
+
function editedCatalogs(steps, name) {
|
|
24
|
+
const edits = steps.filter((step) => step.kind === 'set-range' && step.place === 'catalog' && step.dependency === name);
|
|
25
|
+
return new Set(edits.map((step) => step.catalog ?? DEFAULT_CATALOG));
|
|
26
|
+
}
|
|
27
|
+
function isSetByUpdate(parent, edge, catalogs) {
|
|
28
|
+
if (!isImporter(parent))
|
|
29
|
+
return false;
|
|
30
|
+
const catalog = catalogNameOf(edge.range);
|
|
31
|
+
return catalog !== undefined && catalogs.has(catalog);
|
|
32
|
+
}
|
|
33
|
+
function resolutionsOf(graph, name, catalogs) {
|
|
34
|
+
const resolutions = new Map();
|
|
35
|
+
const parents = [...graph.importers, ...[...graph.packages.values()].filter((node) => !node.id.startsWith(PLACEHOLDER_PREFIX))];
|
|
36
|
+
for (const parent of parents) {
|
|
37
|
+
for (const edge of parent.deps) {
|
|
38
|
+
const child = graph.packages.get(edge.to);
|
|
39
|
+
if (child === undefined || child.name !== name || child.id.startsWith(PLACEHOLDER_PREFIX))
|
|
40
|
+
continue;
|
|
41
|
+
if (isSetByUpdate(parent, edge, catalogs))
|
|
42
|
+
continue;
|
|
43
|
+
const key = edgeKey(parent, edge);
|
|
44
|
+
const known = resolutions.get(key);
|
|
45
|
+
if (known === undefined)
|
|
46
|
+
resolutions.set(key, { label: parentLabel(parent), versions: new Set([child.version]) });
|
|
47
|
+
else
|
|
48
|
+
known.versions.add(child.version);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return resolutions;
|
|
52
|
+
}
|
|
53
|
+
function onlyIn(source, other) {
|
|
54
|
+
return [...source].filter((version) => !other.has(version));
|
|
55
|
+
}
|
|
56
|
+
function satisfiesValue(version, value) {
|
|
57
|
+
if (version === value)
|
|
58
|
+
return true;
|
|
59
|
+
return semver.valid(version) !== null && semver.validRange(value) !== null && semver.satisfies(version, value);
|
|
60
|
+
}
|
|
61
|
+
function selectorMatches(range, version) {
|
|
62
|
+
if (range === undefined)
|
|
63
|
+
return true;
|
|
64
|
+
return semver.valid(version) !== null && semver.validRange(range) !== null && semver.satisfies(version, range);
|
|
65
|
+
}
|
|
66
|
+
function isIntended(intended, name, from, to) {
|
|
67
|
+
if (intended.relocks.some((step) => step.name === name && step.from === from && step.to === to))
|
|
68
|
+
return true;
|
|
69
|
+
return intended.overrides.some((step) => {
|
|
70
|
+
const selector = selectorParts(step.selector);
|
|
71
|
+
return selector.name === name && satisfiesValue(to, step.value) && selectorMatches(selector.range, from);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function isDowngrade(from, to) {
|
|
75
|
+
return semver.valid(from) !== null && semver.valid(to) !== null && semver.lt(to, from);
|
|
76
|
+
}
|
|
77
|
+
function moveText(label, name, from, to) {
|
|
78
|
+
return `${label} moves ${name} ${from} → ${to}${isDowngrade(from, to) ? ' (downgrade)' : ''}`;
|
|
79
|
+
}
|
|
80
|
+
function sameVersions(left, right) {
|
|
81
|
+
return left.size === right.size && [...left].every((version) => right.has(version));
|
|
82
|
+
}
|
|
83
|
+
export function edgeMoveWarnings(steps, before, after) {
|
|
84
|
+
const intended = {
|
|
85
|
+
relocks: steps.filter((step) => step.kind === 'relock'),
|
|
86
|
+
overrides: steps.filter((step) => step.kind === 'override'),
|
|
87
|
+
};
|
|
88
|
+
const names = new Set(steps.flatMap((step) => targetedName(step) ?? []));
|
|
89
|
+
const warnings = new Set();
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
const catalogs = editedCatalogs(steps, name);
|
|
92
|
+
const afterResolutions = resolutionsOf(after, name, catalogs);
|
|
93
|
+
for (const [key, previous] of resolutionsOf(before, name, catalogs)) {
|
|
94
|
+
const current = afterResolutions.get(key);
|
|
95
|
+
if (current === undefined || sameVersions(previous.versions, current.versions))
|
|
96
|
+
continue;
|
|
97
|
+
const removed = onlyIn(previous.versions, current.versions);
|
|
98
|
+
const added = onlyIn(current.versions, previous.versions);
|
|
99
|
+
for (const from of removed.length > 0 ? removed : [...previous.versions]) {
|
|
100
|
+
for (const to of added.length > 0 ? added : [...current.versions]) {
|
|
101
|
+
if (from !== to && !isIntended(intended, name, from, to))
|
|
102
|
+
warnings.add(moveText(previous.label, name, from, to));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return [...warnings];
|
|
108
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { parse } from 'yaml';
|
|
2
|
+
import semver from 'semver';
|
|
3
|
+
import { setJsonString } from "./json-edit.js";
|
|
4
|
+
import { fieldOf, importerLabel } from "./labels.js";
|
|
5
|
+
import { findNode } from "../query/lookup.js";
|
|
6
|
+
import { setWorkspaceString } from "./workspace-edit.js";
|
|
7
|
+
export const ROOT_MANIFEST = 'package.json';
|
|
8
|
+
export const WORKSPACE_FILE = 'pnpm-workspace.yaml';
|
|
9
|
+
const IMPORTER_ID_PREFIX = 'importer:';
|
|
10
|
+
const ROOT_IMPORTER_PATH = '.';
|
|
11
|
+
const DEFAULT_CATALOG = 'default';
|
|
12
|
+
export class EditError extends Error {
|
|
13
|
+
}
|
|
14
|
+
export function manifestPathOf(importer) {
|
|
15
|
+
const path = importer.startsWith(IMPORTER_ID_PREFIX) ? importer.slice(IMPORTER_ID_PREFIX.length) : importer;
|
|
16
|
+
return path === ROOT_IMPORTER_PATH ? ROOT_MANIFEST : `${path}/package.json`;
|
|
17
|
+
}
|
|
18
|
+
export function overridesFilePath(file) {
|
|
19
|
+
return file === 'pnpm-workspace.yaml' ? WORKSPACE_FILE : ROOT_MANIFEST;
|
|
20
|
+
}
|
|
21
|
+
function pathOfStep(step) {
|
|
22
|
+
if (step.kind === 'relock')
|
|
23
|
+
return undefined;
|
|
24
|
+
if (step.kind === 'override')
|
|
25
|
+
return overridesFilePath(step.file);
|
|
26
|
+
return step.place === 'catalog' ? WORKSPACE_FILE : manifestPathOf(step.importer);
|
|
27
|
+
}
|
|
28
|
+
export function stepPaths(steps) {
|
|
29
|
+
const paths = new Set();
|
|
30
|
+
for (const step of steps) {
|
|
31
|
+
const path = pathOfStep(step);
|
|
32
|
+
if (path !== undefined)
|
|
33
|
+
paths.add(path);
|
|
34
|
+
}
|
|
35
|
+
return [...paths];
|
|
36
|
+
}
|
|
37
|
+
function stepKey(step) {
|
|
38
|
+
const { reason: _reason, ...rest } = step;
|
|
39
|
+
return JSON.stringify(rest);
|
|
40
|
+
}
|
|
41
|
+
function hasDefaultCatalogEntry(text, dependency) {
|
|
42
|
+
if (text === null)
|
|
43
|
+
return false;
|
|
44
|
+
const document = parse(text);
|
|
45
|
+
if (typeof document !== 'object' || document === null)
|
|
46
|
+
return false;
|
|
47
|
+
const catalogs = document.catalogs;
|
|
48
|
+
if (typeof catalogs !== 'object' || catalogs === null)
|
|
49
|
+
return false;
|
|
50
|
+
const entries = catalogs[DEFAULT_CATALOG];
|
|
51
|
+
return typeof entries === 'object' && entries !== null && dependency in entries;
|
|
52
|
+
}
|
|
53
|
+
function catalogPath(step, text) {
|
|
54
|
+
if (step.catalog !== undefined)
|
|
55
|
+
return ['catalogs', step.catalog, step.dependency];
|
|
56
|
+
if (hasDefaultCatalogEntry(text, step.dependency))
|
|
57
|
+
return ['catalogs', DEFAULT_CATALOG, step.dependency];
|
|
58
|
+
return ['catalog', step.dependency];
|
|
59
|
+
}
|
|
60
|
+
function overridePath(step) {
|
|
61
|
+
if (step.file === 'package.json#pnpm.overrides')
|
|
62
|
+
return ['pnpm', 'overrides', step.selector];
|
|
63
|
+
return ['overrides', step.selector];
|
|
64
|
+
}
|
|
65
|
+
function applyOne(path, text, step) {
|
|
66
|
+
if (path === WORKSPACE_FILE) {
|
|
67
|
+
const target = step.kind === 'override' ? ['overrides', step.selector] : catalogPath(step, text);
|
|
68
|
+
return setWorkspaceString(text, target, step.kind === 'override' ? step.value : step.to);
|
|
69
|
+
}
|
|
70
|
+
if (text === null)
|
|
71
|
+
throw new EditError(`${path} is missing`);
|
|
72
|
+
const target = step.kind === 'override' ? overridePath(step) : [step.field, step.dependency];
|
|
73
|
+
return setJsonString(text, target, step.kind === 'override' ? step.value : step.to);
|
|
74
|
+
}
|
|
75
|
+
export function applySteps(texts, steps) {
|
|
76
|
+
const current = new Map(texts);
|
|
77
|
+
const changed = new Map();
|
|
78
|
+
const applied = new Set();
|
|
79
|
+
for (const step of steps) {
|
|
80
|
+
if (step.kind === 'relock')
|
|
81
|
+
continue;
|
|
82
|
+
const key = stepKey(step);
|
|
83
|
+
if (applied.has(key))
|
|
84
|
+
continue;
|
|
85
|
+
applied.add(key);
|
|
86
|
+
const path = pathOfStep(step);
|
|
87
|
+
const next = applyOne(path, current.get(path) ?? null, step);
|
|
88
|
+
current.set(path, next);
|
|
89
|
+
changed.set(path, next);
|
|
90
|
+
}
|
|
91
|
+
return changed;
|
|
92
|
+
}
|
|
93
|
+
function pinnedImporterSteps(root, relock) {
|
|
94
|
+
const steps = [];
|
|
95
|
+
for (const importer of root.importers) {
|
|
96
|
+
for (const edge of importer.deps) {
|
|
97
|
+
const field = fieldOf(edge.kind);
|
|
98
|
+
const node = findNode(root, edge.to);
|
|
99
|
+
if (field === undefined || node === undefined || node.name !== relock.name)
|
|
100
|
+
continue;
|
|
101
|
+
if (!('version' in node) || node.version !== relock.from)
|
|
102
|
+
continue;
|
|
103
|
+
if (semver.validRange(edge.range) === null || !semver.satisfies(relock.from, edge.range))
|
|
104
|
+
continue;
|
|
105
|
+
steps.push({
|
|
106
|
+
kind: 'set-range',
|
|
107
|
+
importer: importer.id,
|
|
108
|
+
dependency: relock.name,
|
|
109
|
+
field,
|
|
110
|
+
place: 'manifest',
|
|
111
|
+
from: edge.range,
|
|
112
|
+
to: relock.to,
|
|
113
|
+
reason: `npm rejects an override that changes a direct dependency of ${importerLabel(importer.id)}`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return steps;
|
|
118
|
+
}
|
|
119
|
+
export function relockPins(root, manager, relocks, file) {
|
|
120
|
+
const steps = [];
|
|
121
|
+
for (const relock of relocks) {
|
|
122
|
+
steps.push({ kind: 'override', selector: `${relock.name}@${relock.from}`, value: relock.to, file, reason: relock.reason });
|
|
123
|
+
if (manager === 'npm')
|
|
124
|
+
steps.push(...pinnedImporterSteps(root, relock));
|
|
125
|
+
}
|
|
126
|
+
return steps;
|
|
127
|
+
}
|
|
128
|
+
export function sameGoal(left, right) {
|
|
129
|
+
if (left.kind === 'fix' && right.kind === 'fix')
|
|
130
|
+
return left.name === right.name && left.version === right.version;
|
|
131
|
+
if (left.kind === 'update' && right.kind === 'update')
|
|
132
|
+
return left.name === right.name;
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
function setTarget(step) {
|
|
136
|
+
return step.place === 'catalog' ? `catalog:${step.catalog ?? DEFAULT_CATALOG}` : step.importer;
|
|
137
|
+
}
|
|
138
|
+
function setLocation(step) {
|
|
139
|
+
return step.place === 'catalog' ? `the ${step.catalog ?? DEFAULT_CATALOG} catalog` : importerLabel(step.importer);
|
|
140
|
+
}
|
|
141
|
+
export function selectorParts(selector) {
|
|
142
|
+
const separator = selector.lastIndexOf('@');
|
|
143
|
+
if (separator <= 0)
|
|
144
|
+
return { name: selector.split('>').pop() };
|
|
145
|
+
return { name: selector.slice(0, separator).split('>').pop(), range: selector.slice(separator + 1) };
|
|
146
|
+
}
|
|
147
|
+
function selectorMatchesRelock(step, relock) {
|
|
148
|
+
const { name, range } = selectorParts(step.selector);
|
|
149
|
+
return name === relock.name && range !== undefined && semver.validRange(range) !== null && semver.satisfies(relock.from, range);
|
|
150
|
+
}
|
|
151
|
+
function stepConflict(first, second) {
|
|
152
|
+
if (first.kind === 'set-range' && second.kind === 'set-range') {
|
|
153
|
+
const same = setTarget(first) === setTarget(second) && first.dependency === second.dependency;
|
|
154
|
+
return same && first.to !== second.to ? `both set ${first.dependency} in ${setLocation(first)}` : undefined;
|
|
155
|
+
}
|
|
156
|
+
if (first.kind === 'override' && second.kind === 'override') {
|
|
157
|
+
return first.selector === second.selector && first.value !== second.value ? `both override ${first.selector}` : undefined;
|
|
158
|
+
}
|
|
159
|
+
if (first.kind === 'relock' && second.kind === 'relock') {
|
|
160
|
+
return first.name === second.name && first.from === second.from && first.to !== second.to ? `both re-lock ${first.name} ${first.from}` : undefined;
|
|
161
|
+
}
|
|
162
|
+
const [overrideStep, relockStep] = first.kind === 'override' ? [first, second] : [second, first];
|
|
163
|
+
if (overrideStep.kind === 'override' && relockStep.kind === 'relock' && selectorMatchesRelock(overrideStep, relockStep)) {
|
|
164
|
+
return `the override ${overrideStep.selector} also matches the re-lock of ${relockStep.name} ${relockStep.from}`;
|
|
165
|
+
}
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
export function findConflict(items, candidate) {
|
|
169
|
+
for (const item of items) {
|
|
170
|
+
for (const existing of item.steps) {
|
|
171
|
+
for (const proposed of candidate.steps) {
|
|
172
|
+
const reason = stepConflict(existing, proposed);
|
|
173
|
+
if (reason !== undefined)
|
|
174
|
+
return { item, reason };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import path, { posix } from 'node:path';
|
|
4
|
+
import { compileWorkspacePatterns } from "../config/glob.js";
|
|
5
|
+
import { IMPORTER_PREFIX } from "../model.js";
|
|
6
|
+
export class OutsideScopeError extends Error {
|
|
7
|
+
}
|
|
8
|
+
const PARENT_SEGMENT = '..';
|
|
9
|
+
const PARENT_PREFIX = '../';
|
|
10
|
+
const PACKAGE_MANIFEST = 'package.json';
|
|
11
|
+
const UNREACHABLE_CODES = new Set(['ENOENT', 'ENOTDIR', 'ELOOP']);
|
|
12
|
+
function isMissing(failure) {
|
|
13
|
+
return failure.code === 'ENOENT';
|
|
14
|
+
}
|
|
15
|
+
function isUnreachable(failure) {
|
|
16
|
+
return UNREACHABLE_CODES.has(failure.code ?? '');
|
|
17
|
+
}
|
|
18
|
+
function isInside(realBase, candidate) {
|
|
19
|
+
return candidate === realBase || candidate.startsWith(realBase + path.sep);
|
|
20
|
+
}
|
|
21
|
+
export function leavesRoot(relative) {
|
|
22
|
+
const normalized = posix.normalize(relative);
|
|
23
|
+
return normalized === PARENT_SEGMENT || normalized.startsWith(PARENT_PREFIX) || posix.isAbsolute(normalized);
|
|
24
|
+
}
|
|
25
|
+
export function sha256Hex(content) {
|
|
26
|
+
return createHash('sha256').update(content).digest('hex');
|
|
27
|
+
}
|
|
28
|
+
function outsideScopeError(relative) {
|
|
29
|
+
return new OutsideScopeError(`${relative} is outside the root directory and its declared workspace packages`);
|
|
30
|
+
}
|
|
31
|
+
function unresolvedManifestError(relative) {
|
|
32
|
+
return new OutsideScopeError(`${relative} is missing or resolves outside its workspace package`);
|
|
33
|
+
}
|
|
34
|
+
export function outsideImporters(root) {
|
|
35
|
+
const isDeclared = compileWorkspacePatterns(root.config?.workspacePatterns ?? []);
|
|
36
|
+
const declared = [];
|
|
37
|
+
for (const importer of root.importers) {
|
|
38
|
+
const rootRelative = posix.normalize(importer.id.slice(IMPORTER_PREFIX.length));
|
|
39
|
+
if (!leavesRoot(rootRelative))
|
|
40
|
+
continue;
|
|
41
|
+
if (leavesRoot(importer.path)) {
|
|
42
|
+
return { declared, problem: { kind: 'outside-repository', path: rootRelative } };
|
|
43
|
+
}
|
|
44
|
+
if (root.manager === 'npm')
|
|
45
|
+
return { declared, problem: { kind: 'npm', path: rootRelative } };
|
|
46
|
+
if (!isDeclared(rootRelative))
|
|
47
|
+
return { declared, problem: { kind: 'undeclared', path: rootRelative } };
|
|
48
|
+
declared.push(rootRelative);
|
|
49
|
+
}
|
|
50
|
+
return { declared };
|
|
51
|
+
}
|
|
52
|
+
export function fileScopeOf(repoPath, root) {
|
|
53
|
+
const { declared } = outsideImporters(root);
|
|
54
|
+
return {
|
|
55
|
+
rootDir: path.join(repoPath, root.path),
|
|
56
|
+
repoDir: repoPath,
|
|
57
|
+
outsideManifests: declared.map((declaredPath) => `${declaredPath}/${PACKAGE_MANIFEST}`),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function resolveInsideRoot(scope, relative, normalized) {
|
|
61
|
+
const realRoot = await realpath(scope.rootDir);
|
|
62
|
+
const requested = path.resolve(realRoot, ...normalized.split('/'));
|
|
63
|
+
if (!isInside(realRoot, requested))
|
|
64
|
+
throw outsideScopeError(relative);
|
|
65
|
+
let resolved;
|
|
66
|
+
try {
|
|
67
|
+
resolved = await realpath(requested);
|
|
68
|
+
}
|
|
69
|
+
catch (failure) {
|
|
70
|
+
if (!isMissing(failure))
|
|
71
|
+
throw failure;
|
|
72
|
+
resolved = path.join(await realpath(path.dirname(requested)), path.basename(requested));
|
|
73
|
+
}
|
|
74
|
+
if (!isInside(realRoot, resolved))
|
|
75
|
+
throw outsideScopeError(relative);
|
|
76
|
+
return resolved;
|
|
77
|
+
}
|
|
78
|
+
async function resolveDeclaredManifest(scope, relative, normalized) {
|
|
79
|
+
if (!scope.outsideManifests.includes(normalized))
|
|
80
|
+
throw outsideScopeError(relative);
|
|
81
|
+
const requested = path.resolve(scope.rootDir, ...normalized.split('/'));
|
|
82
|
+
let real;
|
|
83
|
+
try {
|
|
84
|
+
real = await Promise.all([realpath(scope.repoDir), realpath(path.dirname(requested)), realpath(requested)]);
|
|
85
|
+
}
|
|
86
|
+
catch (failure) {
|
|
87
|
+
if (isUnreachable(failure))
|
|
88
|
+
throw unresolvedManifestError(relative);
|
|
89
|
+
throw failure;
|
|
90
|
+
}
|
|
91
|
+
const [realRepo, realPackageDir, realFile] = real;
|
|
92
|
+
if (!isInside(realRepo, realPackageDir) || !realFile.startsWith(realPackageDir + path.sep))
|
|
93
|
+
throw unresolvedManifestError(relative);
|
|
94
|
+
return realFile;
|
|
95
|
+
}
|
|
96
|
+
export async function resolveInScope(scope, relative) {
|
|
97
|
+
if (posix.isAbsolute(relative) || path.isAbsolute(relative))
|
|
98
|
+
throw outsideScopeError(relative);
|
|
99
|
+
const normalized = posix.normalize(relative);
|
|
100
|
+
if (leavesRoot(normalized))
|
|
101
|
+
return resolveDeclaredManifest(scope, relative, normalized);
|
|
102
|
+
return resolveInsideRoot(scope, relative, normalized);
|
|
103
|
+
}
|
|
104
|
+
export async function readInScope(scope, relative) {
|
|
105
|
+
try {
|
|
106
|
+
return await readFile(await resolveInScope(scope, relative));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export async function hashSources(scope, paths) {
|
|
113
|
+
const entries = await Promise.all(paths.map(async (relative) => {
|
|
114
|
+
const content = await readInScope(scope, relative);
|
|
115
|
+
return [relative, content === null ? null : sha256Hex(content)];
|
|
116
|
+
}));
|
|
117
|
+
return new Map(entries);
|
|
118
|
+
}
|