@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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +421 -0
  3. package/dist/actions/action-error.js +11 -0
  4. package/dist/actions/action-jobs.js +199 -0
  5. package/dist/actions/action-service.js +268 -0
  6. package/dist/actions/apply.js +120 -0
  7. package/dist/actions/availability.js +79 -0
  8. package/dist/actions/change-sets.js +117 -0
  9. package/dist/actions/dry-run.js +287 -0
  10. package/dist/actions/edge-moves.js +108 -0
  11. package/dist/actions/edit-plan.js +179 -0
  12. package/dist/actions/file-scope.js +118 -0
  13. package/dist/actions/fix-options.js +141 -0
  14. package/dist/actions/goal-check.js +80 -0
  15. package/dist/actions/install.js +27 -0
  16. package/dist/actions/job-registry.js +104 -0
  17. package/dist/actions/json-edit.js +189 -0
  18. package/dist/actions/labels.js +35 -0
  19. package/dist/actions/node-actions.js +102 -0
  20. package/dist/actions/overrides-location.js +59 -0
  21. package/dist/actions/parent-chain.js +210 -0
  22. package/dist/actions/range-style.js +38 -0
  23. package/dist/actions/recipes.js +31 -0
  24. package/dist/actions/root-files.js +30 -0
  25. package/dist/actions/sandbox.js +175 -0
  26. package/dist/actions/set-range.js +59 -0
  27. package/dist/actions/shared-packages.js +50 -0
  28. package/dist/actions/text-diff.js +203 -0
  29. package/dist/actions/types.js +1 -0
  30. package/dist/actions/update-options.js +232 -0
  31. package/dist/actions/workspace-edit.js +33 -0
  32. package/dist/adapters/command-runner.js +127 -0
  33. package/dist/adapters/manifest-only.js +20 -0
  34. package/dist/adapters/node-modules.js +148 -0
  35. package/dist/adapters/npm-lock.js +149 -0
  36. package/dist/adapters/package-manifest.js +57 -0
  37. package/dist/adapters/pnpm-lock.js +177 -0
  38. package/dist/adapters/resolve-today.js +93 -0
  39. package/dist/cli/cli.js +227 -0
  40. package/dist/cli/open-browser.js +14 -0
  41. package/dist/cli/package-version.js +6 -0
  42. package/dist/cli/usage-text.js +27 -0
  43. package/dist/config/glob.js +119 -0
  44. package/dist/config/npmrc.js +116 -0
  45. package/dist/config/root-config.js +103 -0
  46. package/dist/config/secrets.js +18 -0
  47. package/dist/discovery/find-roots.js +107 -0
  48. package/dist/enrich/credentials.js +143 -0
  49. package/dist/enrich/enrichment-cache.js +42 -0
  50. package/dist/enrich/enrichment-keys.js +71 -0
  51. package/dist/enrich/enrichment-service.js +399 -0
  52. package/dist/enrich/enrichment-status.js +86 -0
  53. package/dist/enrich/enrichment-store.js +143 -0
  54. package/dist/enrich/http.js +201 -0
  55. package/dist/enrich/on-demand.js +220 -0
  56. package/dist/enrich/osv-client.js +260 -0
  57. package/dist/enrich/registry-client.js +123 -0
  58. package/dist/enrich/types.js +1 -0
  59. package/dist/git/blob-reader.js +180 -0
  60. package/dist/git/git-commands.js +32 -0
  61. package/dist/git/git-process.js +71 -0
  62. package/dist/git/git-repository.js +136 -0
  63. package/dist/git/ref-list.js +78 -0
  64. package/dist/git/tree-index.js +87 -0
  65. package/dist/graph/global-index.js +19 -0
  66. package/dist/graph/root-graph-builder.js +114 -0
  67. package/dist/graph/serialize.js +19 -0
  68. package/dist/model.js +2 -0
  69. package/dist/query/children.js +38 -0
  70. package/dist/query/compare-shape.js +13 -0
  71. package/dist/query/compare.js +245 -0
  72. package/dist/query/decorations.js +121 -0
  73. package/dist/query/dependents.js +135 -0
  74. package/dist/query/duplicates.js +23 -0
  75. package/dist/query/enrichment-lookup.js +78 -0
  76. package/dist/query/fixes.js +60 -0
  77. package/dist/query/graph-diff.js +42 -0
  78. package/dist/query/issues.js +235 -0
  79. package/dist/query/lookup.js +49 -0
  80. package/dist/query/node-fix.js +46 -0
  81. package/dist/query/package-insight.js +112 -0
  82. package/dist/query/search.js +61 -0
  83. package/dist/query/subgraph.js +107 -0
  84. package/dist/query/versions.js +103 -0
  85. package/dist/scan/build-root.js +137 -0
  86. package/dist/scan/cache-shape.js +52 -0
  87. package/dist/scan/scan-cache.js +32 -0
  88. package/dist/scan/scan-session.js +127 -0
  89. package/dist/scan/source-registry.js +55 -0
  90. package/dist/server/auth.js +45 -0
  91. package/dist/server/branches.js +105 -0
  92. package/dist/server/error-log.js +9 -0
  93. package/dist/server/root-segment.js +13 -0
  94. package/dist/server/router.js +474 -0
  95. package/dist/server/server.js +166 -0
  96. package/dist/server/source-key.js +1 -0
  97. package/dist/server/static.js +59 -0
  98. package/dist/sources/file-reader.js +55 -0
  99. package/dist/sources/git-reader.js +45 -0
  100. package/dist/ui/assets/index-C4uyB5v3.css +1 -0
  101. package/dist/ui/assets/index-DYAG2LM-.js +51 -0
  102. package/dist/ui/index.html +15 -0
  103. package/package.json +72 -0
@@ -0,0 +1,59 @@
1
+ import { coerce, gte } from 'semver';
2
+ import { parse } from 'yaml';
3
+ const PNPM_FIELD_IGNORED_WARNING = 'pnpm 11 ignores the pnpm field of package.json, so the override goes to pnpm-workspace.yaml';
4
+ function isRecord(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ function parseYamlRecord(text) {
8
+ if (text === null)
9
+ return undefined;
10
+ try {
11
+ const parsed = parse(text);
12
+ return isRecord(parsed) ? parsed : undefined;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ function parseJsonRecord(text) {
19
+ if (text === null)
20
+ return undefined;
21
+ try {
22
+ const parsed = JSON.parse(text);
23
+ return isRecord(parsed) ? parsed : undefined;
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ export function parseManagerVersion(output) {
30
+ return coerce(output.trim())?.version;
31
+ }
32
+ export function declaredOverridesFiles(files) {
33
+ const declared = [];
34
+ const workspace = parseYamlRecord(files.workspaceYaml);
35
+ const workspaceOverrides = workspace?.overrides;
36
+ if (isRecord(workspaceOverrides) && Object.keys(workspaceOverrides).length > 0)
37
+ declared.push('pnpm-workspace.yaml');
38
+ const manifest = parseJsonRecord(files.rootManifest);
39
+ if (isRecord(manifest?.pnpm) && isRecord(manifest.pnpm.overrides))
40
+ declared.push('package.json#pnpm.overrides');
41
+ if (isRecord(manifest?.overrides))
42
+ declared.push('package.json#overrides');
43
+ return declared;
44
+ }
45
+ function atLeast(version, minimum) {
46
+ const coerced = coerce(version);
47
+ return coerced !== null && gte(coerced, minimum);
48
+ }
49
+ export function overridesLocation(manager, declared) {
50
+ if (manager.name === 'npm')
51
+ return { file: 'package.json#overrides' };
52
+ const pnpmDeclared = declared.find((file) => file === 'pnpm-workspace.yaml' || file === 'package.json#pnpm.overrides');
53
+ if (pnpmDeclared === 'package.json#pnpm.overrides' && atLeast(manager.version, '11.0.0')) {
54
+ return { file: 'pnpm-workspace.yaml', warning: PNPM_FIELD_IGNORED_WARNING };
55
+ }
56
+ if (pnpmDeclared !== undefined)
57
+ return { file: pnpmDeclared };
58
+ return { file: atLeast(manager.version, '10.0.0') ? 'pnpm-workspace.yaml' : 'package.json#pnpm.overrides' };
59
+ }
@@ -0,0 +1,210 @@
1
+ import semver from 'semver';
2
+ import { IMPORTER_PREFIX } from "../model.js";
3
+ import { ParentLinkResolver } from "../query/dependents.js";
4
+ import { nodeMeta, requestedRange } from "../query/enrichment-lookup.js";
5
+ import { findNode } from "../query/lookup.js";
6
+ import { normalizeRange } from "../query/versions.js";
7
+ import { acceptedClause, fieldOf, importerLabel } from "./labels.js";
8
+ import { catalogNameOf } from "./range-style.js";
9
+ import { compareText, setRangeStep } from "./set-range.js";
10
+ export const CHAIN_MAX_DEPTH = 5;
11
+ export const CHAIN_MAX_LOADS = 20;
12
+ class ChainUnavailableError extends Error {
13
+ }
14
+ function isImporter(node) {
15
+ return node.id.startsWith(IMPORTER_PREFIX);
16
+ }
17
+ function admitsAny(range, versions) {
18
+ return versions.some((version) => semver.satisfies(version, range));
19
+ }
20
+ function versionsAbove(versions, current) {
21
+ if (semver.valid(current) === null)
22
+ return [];
23
+ return versions.filter((version) => semver.gt(version, current));
24
+ }
25
+ function intersect(known, incoming) {
26
+ const accepted = new Set(incoming);
27
+ return known.filter((version) => accepted.has(version));
28
+ }
29
+ function withClimbedFrom(climbedFrom) {
30
+ return climbedFrom === undefined ? {} : { climbedFrom };
31
+ }
32
+ function compareRelocks(left, right) {
33
+ return left.depth - right.depth || compareText(left.node.name, right.node.name) || compareText(left.node.version, right.node.version);
34
+ }
35
+ function compareSetRanges(left, right) {
36
+ return compareText(left.importer.path, right.importer.path) || compareText(left.node.name, right.node.name);
37
+ }
38
+ function setRangeKey(entry) {
39
+ const catalog = catalogNameOf(entry.specifier);
40
+ return catalog === undefined ? `${entry.importer.id}\0${entry.node.name}` : `catalog:${catalog}\0${entry.node.name}`;
41
+ }
42
+ class ChainSearch {
43
+ view;
44
+ root;
45
+ start;
46
+ safe;
47
+ links;
48
+ loadVersions;
49
+ loaded = new Map();
50
+ path = new Set();
51
+ relocks = new Map();
52
+ setRanges = new Map();
53
+ constructor(input) {
54
+ this.view = input.view;
55
+ this.root = input.root;
56
+ this.start = input.node;
57
+ this.safe = input.safe.filter((version) => semver.valid(version) !== null).sort(semver.compare);
58
+ this.links = new ParentLinkResolver(input.root, new Set([...input.kinds].filter((kind) => kind !== 'peer')));
59
+ this.loadVersions = input.loadVersions;
60
+ }
61
+ async run() {
62
+ this.path.add(this.start.id);
63
+ await this.visit({ node: this.start, acceptable: this.safe, depth: 0 });
64
+ const relockSteps = [...this.relocks.values()].sort(compareRelocks).map((entry) => this.relockStep(entry));
65
+ const setRangeSteps = [...this.setRanges.values()].sort(compareSetRanges).map((entry) => this.setRangeStep(entry));
66
+ const steps = [...relockSteps, ...setRangeSteps];
67
+ if (steps.length === 0)
68
+ throw new ChainUnavailableError('no parent path reaches a workspace package');
69
+ return steps;
70
+ }
71
+ async visit(visit) {
72
+ for (const link of this.links.of(visit.node)) {
73
+ const parent = findNode(this.root, link.id);
74
+ if (parent === undefined)
75
+ continue;
76
+ const requested = requestedRange(this.view, this.root, parent, link.range, visit.node).range;
77
+ const range = this.normalized(visit.node, requested);
78
+ if (range !== undefined && admitsAny(range, visit.acceptable)) {
79
+ this.addRequester(visit, { label: this.requesterLabel(parent, requested, visit.depth), range });
80
+ continue;
81
+ }
82
+ if (isImporter(parent)) {
83
+ this.addRaisedRange(visit, parent, link);
84
+ continue;
85
+ }
86
+ if (this.path.has(parent.id))
87
+ continue;
88
+ await this.climb(parent, visit);
89
+ }
90
+ }
91
+ async climb(parent, from) {
92
+ if (from.depth + 1 > CHAIN_MAX_DEPTH)
93
+ throw new ChainUnavailableError(`the chain is deeper than ${CHAIN_MAX_DEPTH} levels`);
94
+ const meta = await this.publishedMeta(parent.name);
95
+ const acceptable = this.acceptableVersions(parent, meta, from);
96
+ if (acceptable.length === 0)
97
+ throw new ChainUnavailableError(`${parent.name} has no version that accepts a safe ${from.node.name}`);
98
+ this.path.add(parent.id);
99
+ await this.visit({ node: parent, acceptable, depth: from.depth + 1, climbedFrom: from.node });
100
+ this.path.delete(parent.id);
101
+ }
102
+ async publishedMeta(name) {
103
+ let published = this.loaded.get(name);
104
+ if (published === undefined) {
105
+ if (this.loaded.size >= CHAIN_MAX_LOADS)
106
+ throw new ChainUnavailableError(`the search needs more than ${CHAIN_MAX_LOADS} packument loads`);
107
+ published = await this.loadVersions(name);
108
+ this.loaded.set(name, published);
109
+ }
110
+ if (published.state === 'failed')
111
+ throw new ChainUnavailableError(`could not load the versions of ${name}: ${published.reason}`);
112
+ return published.meta;
113
+ }
114
+ acceptableVersions(parent, meta, from) {
115
+ const newer = versionsAbove(meta.versions.filter((version) => semver.valid(version) !== null && semver.prerelease(version) === null), parent.version);
116
+ return newer.filter((version) => this.acceptsSafeChild(meta.manifests[version], from)).sort(semver.compare);
117
+ }
118
+ acceptsSafeChild(manifest, from) {
119
+ if (manifest === undefined)
120
+ return false;
121
+ const declared = manifest[from.node.name];
122
+ if (declared === undefined)
123
+ return true;
124
+ const range = this.normalized(from.node, declared);
125
+ return range !== undefined && admitsAny(range, from.acceptable);
126
+ }
127
+ metaOf(node) {
128
+ const published = this.loaded.get(node.name);
129
+ return published?.state === 'ok' ? published.meta : nodeMeta(this.view, this.root, node);
130
+ }
131
+ normalized(child, range) {
132
+ return normalizeRange(range, { name: child.name, catalogs: this.root.config?.catalogs ?? {}, distTags: this.metaOf(child)?.distTags });
133
+ }
134
+ requesterLabel(parent, requested, depth) {
135
+ const name = isImporter(parent) ? importerLabel(parent.id) : parent.name;
136
+ return depth === 0 ? name : `${name} ${requested}`;
137
+ }
138
+ addRequester(visit, requester) {
139
+ const key = `${visit.node.name}@${visit.node.version}`;
140
+ const entry = this.relocks.get(key);
141
+ if (entry === undefined) {
142
+ this.relocks.set(key, {
143
+ node: visit.node,
144
+ depth: visit.depth,
145
+ requesters: [requester],
146
+ acceptable: [...visit.acceptable],
147
+ ...withClimbedFrom(visit.climbedFrom),
148
+ });
149
+ return;
150
+ }
151
+ if (!entry.requesters.some((known) => known.label === requester.label && known.range === requester.range))
152
+ entry.requesters.push(requester);
153
+ entry.acceptable = intersect(entry.acceptable, visit.acceptable);
154
+ }
155
+ addRaisedRange(visit, importer, link) {
156
+ const field = fieldOf(link.kind);
157
+ if (field === undefined)
158
+ return;
159
+ const raised = {
160
+ importer,
161
+ field,
162
+ specifier: link.range,
163
+ node: visit.node,
164
+ acceptable: versionsAbove(visit.acceptable, visit.node.version),
165
+ ...withClimbedFrom(visit.climbedFrom),
166
+ };
167
+ const key = setRangeKey(raised);
168
+ const known = this.setRanges.get(key);
169
+ if (known === undefined) {
170
+ this.setRanges.set(key, raised);
171
+ return;
172
+ }
173
+ const kept = compareText(raised.importer.path, known.importer.path) < 0 ? raised : known;
174
+ this.setRanges.set(key, { ...kept, acceptable: intersect(known.acceptable, raised.acceptable) });
175
+ }
176
+ dependencyNote(node, to, child) {
177
+ const declared = this.metaOf(node)?.manifests[to]?.[child.name];
178
+ return declared === undefined ? `${to} no longer depends on ${child.name}` : `${to} requires ${child.name} ${declared}`;
179
+ }
180
+ relockStep(entry) {
181
+ const { node, requesters, climbedFrom } = entry;
182
+ const to = entry.acceptable.filter((version) => requesters.every((requester) => semver.satisfies(version, requester.range))).at(-1);
183
+ if (to === undefined)
184
+ throw new ChainUnavailableError(`${node.name} ${node.version} has no fixed version that all its parents accept`);
185
+ const clause = acceptedClause(requesters.map((requester) => requester.label));
186
+ const reason = climbedFrom === undefined ? clause : `${this.dependencyNote(node, to, climbedFrom)}; ${clause}`;
187
+ return { kind: 'relock', name: node.name, from: node.version, to, reason };
188
+ }
189
+ setRangeStep(entry) {
190
+ const { node, climbedFrom } = entry;
191
+ const to = entry.acceptable[0];
192
+ if (to === undefined)
193
+ throw new ChainUnavailableError(`no safe version of ${node.name} above ${node.version}`);
194
+ const reason = climbedFrom === undefined ? `${to} is the lowest safe ${node.name} above ${node.version}` : this.dependencyNote(node, to, climbedFrom);
195
+ const result = setRangeStep(this.root, { importer: entry.importer.id, field: entry.field, dependency: node.name, specifier: entry.specifier, target: to, reason });
196
+ if ('unavailable' in result)
197
+ throw new ChainUnavailableError(result.unavailable);
198
+ return result.step;
199
+ }
200
+ }
201
+ export async function parentChain(input) {
202
+ try {
203
+ return { steps: await new ChainSearch(input).run() };
204
+ }
205
+ catch (error) {
206
+ if (error instanceof ChainUnavailableError)
207
+ return { unavailable: error.message };
208
+ throw error;
209
+ }
210
+ }
@@ -0,0 +1,38 @@
1
+ import { valid, validRange } from 'semver';
2
+ import { isNonRegistrySpec } from "../adapters/package-manifest.js";
3
+ const CARET_PATTERN = /^\^\d+(\.\d+){0,2}$/;
4
+ const TILDE_PATTERN = /^~\d+(\.\d+){0,2}$/;
5
+ const ALIAS_PREFIX = 'npm:';
6
+ export function catalogNameOf(specifier) {
7
+ if (!specifier.startsWith('catalog:'))
8
+ return undefined;
9
+ return specifier.slice('catalog:'.length) || 'default';
10
+ }
11
+ function rewriteAlias(specifier, target) {
12
+ const separator = specifier.lastIndexOf('@');
13
+ if (separator <= ALIAS_PREFIX.length)
14
+ return { editable: false, reason: `${specifier} has no version range` };
15
+ const rewritten = rewriteRange(specifier.slice(separator + 1), target);
16
+ if (!rewritten.editable)
17
+ return rewritten;
18
+ return { ...rewritten, specifier: `${specifier.slice(0, separator + 1)}${rewritten.specifier}` };
19
+ }
20
+ export function rewriteRange(specifier, target) {
21
+ if (specifier.startsWith('workspace:'))
22
+ return { editable: false, reason: 'workspace: specifiers are not edited' };
23
+ if (specifier.startsWith('catalog:'))
24
+ return { editable: false, reason: 'catalog entries are edited in pnpm-workspace.yaml' };
25
+ if (specifier.startsWith(ALIAS_PREFIX))
26
+ return rewriteAlias(specifier, target);
27
+ if (isNonRegistrySpec(specifier))
28
+ return { editable: false, reason: `${specifier} is not a registry range` };
29
+ if (CARET_PATTERN.test(specifier))
30
+ return { editable: true, specifier: `^${target}`, formChanged: false };
31
+ if (TILDE_PATTERN.test(specifier))
32
+ return { editable: true, specifier: `~${target}`, formChanged: false };
33
+ if (valid(specifier) !== null)
34
+ return { editable: true, specifier: target, formChanged: false };
35
+ if (validRange(specifier) !== null)
36
+ return { editable: true, specifier: `^${target}`, formChanged: true };
37
+ return { editable: false, reason: `dist-tag ${specifier} is not edited` };
38
+ }
@@ -0,0 +1,31 @@
1
+ import { parseManagerVersion } from "./overrides-location.js";
2
+ export const SANDBOX_TIMEOUT_MS = 300_000;
3
+ export const INSTALL_TIMEOUT_MS = 900_000;
4
+ export const PROBE_TIMEOUT_MS = 60_000;
5
+ const STDERR_TAIL_LENGTH = 400;
6
+ export const LOCKFILE_ONLY_ARGS = {
7
+ pnpm: ['install', '--lockfile-only', '--ignore-scripts'],
8
+ npm: ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund'],
9
+ };
10
+ export const FROZEN_INSTALL_ARGS = {
11
+ pnpm: ['install', '--frozen-lockfile'],
12
+ npm: ['ci'],
13
+ };
14
+ export function commandText(manager, args) {
15
+ return [manager, ...args].join(' ');
16
+ }
17
+ export async function probeManager(runner, manager, cwd, env, secrets) {
18
+ try {
19
+ const result = await runner.run({ command: manager, args: ['--version'], cwd, env, timeoutMs: PROBE_TIMEOUT_MS });
20
+ if (result.code !== 0) {
21
+ const tail = secrets.redact(result.stderr).trim().slice(-STDERR_TAIL_LENGTH);
22
+ return { ok: false, reason: `${manager} --version failed: ${tail}` };
23
+ }
24
+ const version = parseManagerVersion(result.stdout);
25
+ return version ? { ok: true, version } : { ok: false, reason: `${manager} --version printed no version` };
26
+ }
27
+ catch (failure) {
28
+ const code = failure.code;
29
+ return { ok: false, reason: code === 'ENOENT' ? `${manager} was not found` : `${manager} could not be started` };
30
+ }
31
+ }
@@ -0,0 +1,30 @@
1
+ import { access, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ROOT_MANIFEST, WORKSPACE_FILE } from "./edit-plan.js";
4
+ import { declaredOverridesFiles, overridesLocation } from "./overrides-location.js";
5
+ const PNPM_LOCKFILE = 'pnpm-lock.yaml';
6
+ const NPM_SHRINKWRAP = 'npm-shrinkwrap.json';
7
+ const NPM_LOCKFILE = 'package-lock.json';
8
+ export function rootDirOf(repoPath, root) {
9
+ return path.join(repoPath, root.path);
10
+ }
11
+ export async function pathExists(target) {
12
+ return access(target).then(() => true, () => false);
13
+ }
14
+ export async function readTextOrNull(target) {
15
+ try {
16
+ return await readFile(target, 'utf8');
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export async function lockfileNameOf(rootDir, manager) {
23
+ if (manager === 'pnpm')
24
+ return PNPM_LOCKFILE;
25
+ return (await pathExists(path.join(rootDir, NPM_SHRINKWRAP))) ? NPM_SHRINKWRAP : NPM_LOCKFILE;
26
+ }
27
+ export async function overridesLocationOf(rootDir, manager) {
28
+ const [workspaceYaml, rootManifest] = await Promise.all([readTextOrNull(path.join(rootDir, WORKSPACE_FILE)), readTextOrNull(path.join(rootDir, ROOT_MANIFEST))]);
29
+ return overridesLocation(manager, declaredOverridesFiles({ workspaceYaml, rootManifest }));
30
+ }
@@ -0,0 +1,175 @@
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path, { posix } from 'node:path';
4
+ import { parse } from 'yaml';
5
+ import { leavesRoot, readInScope, sha256Hex } from "./file-scope.js";
6
+ const WORKSPACE_FILE = 'pnpm-workspace.yaml';
7
+ const ROOT_MANIFEST = 'package.json';
8
+ const ROOT_COPY_DIR = 'root';
9
+ const PRIVATE_DIR_MODE = 0o700;
10
+ const PNPMFILE_NAMES = ['.pnpmfile.cjs', '.pnpmfile.mjs'];
11
+ const activeSandboxDirs = new Set();
12
+ function isRecord(value) {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ }
15
+ function parseYamlRecord(text) {
16
+ if (text === null)
17
+ return undefined;
18
+ try {
19
+ const parsed = parse(text);
20
+ return isRecord(parsed) ? parsed : undefined;
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ function parseJsonRecord(text) {
27
+ if (text === null)
28
+ return undefined;
29
+ try {
30
+ const parsed = JSON.parse(text);
31
+ return isRecord(parsed) ? parsed : undefined;
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ function stringValues(value) {
38
+ return isRecord(value) ? Object.values(value).filter((entry) => typeof entry === 'string') : [];
39
+ }
40
+ function pnpmfileFromNpmrc(text) {
41
+ if (text === null)
42
+ return [];
43
+ const values = [];
44
+ for (const line of text.split(/\r?\n/)) {
45
+ const separator = line.indexOf('=');
46
+ if (separator < 0)
47
+ continue;
48
+ if (line.slice(0, separator).trim() === 'pnpmfile')
49
+ values.push(line.slice(separator + 1).trim());
50
+ }
51
+ return values.filter((value) => value !== '');
52
+ }
53
+ function isConsidered(scope, normalized) {
54
+ if (posix.isAbsolute(normalized))
55
+ return false;
56
+ if (leavesRoot(normalized))
57
+ return scope.outsideManifests.includes(normalized);
58
+ return true;
59
+ }
60
+ async function readRootText(scope, relative) {
61
+ return (await readInScope(scope, relative))?.toString('utf8') ?? null;
62
+ }
63
+ async function consideredPaths(request) {
64
+ const manifests = request.importerPaths.map((importerPath) => posix.join(importerPath, 'package.json'));
65
+ const paths = [...manifests, '.npmrc', WORKSPACE_FILE, request.lockfile];
66
+ if (request.manager.name === 'pnpm') {
67
+ const [workspaceText, npmrcText, rootManifestText] = await Promise.all([
68
+ readRootText(request.scope, WORKSPACE_FILE),
69
+ readRootText(request.scope, '.npmrc'),
70
+ readRootText(request.scope, ROOT_MANIFEST),
71
+ ]);
72
+ const workspace = parseYamlRecord(workspaceText);
73
+ const rootManifest = parseJsonRecord(rootManifestText);
74
+ const pnpmSection = isRecord(rootManifest?.pnpm) ? rootManifest.pnpm : undefined;
75
+ paths.push(...PNPMFILE_NAMES);
76
+ if (typeof workspace?.pnpmfile === 'string')
77
+ paths.push(workspace.pnpmfile);
78
+ paths.push(...pnpmfileFromNpmrc(npmrcText));
79
+ paths.push(...stringValues(workspace?.patchedDependencies), ...stringValues(pnpmSection?.patchedDependencies));
80
+ }
81
+ const seen = new Set();
82
+ for (const candidate of paths) {
83
+ const normalized = posix.normalize(candidate);
84
+ if (isConsidered(request.scope, normalized))
85
+ seen.add(normalized);
86
+ }
87
+ return [...seen];
88
+ }
89
+ function sandboxFile(layout, relative) {
90
+ const normalized = posix.join(layout.offset, relative);
91
+ if (posix.isAbsolute(relative) || leavesRoot(normalized)) {
92
+ throw new Error(`Path leaves the sandbox: ${relative}`);
93
+ }
94
+ return path.join(layout.dir, ...normalized.split('/'));
95
+ }
96
+ async function copyIntoSandbox(scope, layout, relative) {
97
+ const content = await readInScope(scope, relative);
98
+ if (content === null)
99
+ return null;
100
+ const target = sandboxFile(layout, relative);
101
+ await mkdir(path.dirname(target), { recursive: true });
102
+ await writeFile(target, content);
103
+ return sha256Hex(content);
104
+ }
105
+ function removeSandboxDir(dir) {
106
+ activeSandboxDirs.delete(dir);
107
+ return rm(dir, { recursive: true, force: true });
108
+ }
109
+ export async function removeActionSandboxes() {
110
+ await Promise.all([...activeSandboxDirs].map(removeSandboxDir));
111
+ }
112
+ async function pinManagerUnlessNamed(request, tempDir) {
113
+ const manifest = parseJsonRecord(await readRootText(request.scope, ROOT_MANIFEST));
114
+ if (manifest?.packageManager !== undefined)
115
+ return;
116
+ const pin = { private: true, packageManager: `${request.manager.name}@${request.manager.version}` };
117
+ await writeFile(path.join(tempDir, ROOT_MANIFEST), `${JSON.stringify(pin, null, 2)}\n`);
118
+ }
119
+ function leadingParentCount(manifest) {
120
+ const segments = manifest.split('/');
121
+ const firstOther = segments.findIndex((segment) => segment !== '..');
122
+ return firstOther === -1 ? segments.length : firstOther;
123
+ }
124
+ function outsideDepth(scope) {
125
+ return Math.max(0, ...scope.outsideManifests.map(leadingParentCount));
126
+ }
127
+ function rootOffset(scope) {
128
+ const segments = path.relative(scope.repoDir, scope.rootDir).split(path.sep).filter((segment) => segment !== '');
129
+ const depth = outsideDepth(scope);
130
+ if (depth > segments.length)
131
+ throw new Error(`Path leaves the sandbox: ${scope.outsideManifests.join(', ')}`);
132
+ return segments.slice(segments.length - depth).join('/');
133
+ }
134
+ export async function createSandbox(request) {
135
+ const tempDir = await mkdtemp(path.join(tmpdir(), 'deptrail-action-'));
136
+ activeSandboxDirs.add(tempDir);
137
+ try {
138
+ const offset = rootOffset(request.scope);
139
+ const copyBase = path.join(tempDir, ROOT_COPY_DIR);
140
+ await mkdir(copyBase, { mode: PRIVATE_DIR_MODE });
141
+ const dir = path.join(copyBase, ...offset.split('/').filter((segment) => segment !== ''));
142
+ await mkdir(dir, { recursive: true });
143
+ const layout = { dir: copyBase, offset };
144
+ await pinManagerUnlessNamed(request, tempDir);
145
+ const sources = new Map();
146
+ for (const relative of await consideredPaths(request)) {
147
+ sources.set(relative, await copyIntoSandbox(request.scope, layout, relative));
148
+ }
149
+ return {
150
+ dir,
151
+ sources,
152
+ async readText(relative) {
153
+ try {
154
+ return await readFile(sandboxFile(layout, relative), 'utf8');
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ },
160
+ async writeText(relative, text) {
161
+ const target = sandboxFile(layout, relative);
162
+ await mkdir(path.dirname(target), { recursive: true });
163
+ await writeFile(target, text);
164
+ },
165
+ deleteFile(relative) {
166
+ return rm(sandboxFile(layout, relative), { force: true });
167
+ },
168
+ remove: () => removeSandboxDir(tempDir),
169
+ };
170
+ }
171
+ catch (failure) {
172
+ await removeSandboxDir(tempDir);
173
+ throw failure;
174
+ }
175
+ }
@@ -0,0 +1,59 @@
1
+ import { findNode } from "../query/lookup.js";
2
+ import { importerLabel } from "./labels.js";
3
+ import { catalogNameOf, rewriteRange } from "./range-style.js";
4
+ const DEFAULT_CATALOG = 'default';
5
+ export function compareText(left, right) {
6
+ return left < right ? -1 : left > right ? 1 : 0;
7
+ }
8
+ function formNote(from, formChanged) {
9
+ return formChanged ? ` (the range form changes from ${from})` : '';
10
+ }
11
+ function catalogUsers(root, catalog, dependency) {
12
+ return root.importers
13
+ .filter((importer) => importer.deps.some((edge) => catalogNameOf(edge.range) === catalog && findNode(root, edge.to)?.name === dependency))
14
+ .sort((left, right) => compareText(left.path, right.path))
15
+ .map((importer) => importerLabel(importer.id));
16
+ }
17
+ function manifestStep(input) {
18
+ const rewritten = rewriteRange(input.specifier, input.target);
19
+ if (!rewritten.editable)
20
+ return { unavailable: `${importerLabel(input.importer)}: ${rewritten.reason}` };
21
+ return {
22
+ step: {
23
+ kind: 'set-range',
24
+ importer: input.importer,
25
+ dependency: input.dependency,
26
+ field: input.field,
27
+ place: 'manifest',
28
+ from: input.specifier,
29
+ to: rewritten.specifier,
30
+ reason: `${input.reason}${formNote(input.specifier, rewritten.formChanged)}`,
31
+ },
32
+ };
33
+ }
34
+ function catalogStep(root, input, catalog) {
35
+ const entry = root.config?.catalogs[catalog]?.[input.dependency];
36
+ if (entry === undefined)
37
+ return { unavailable: `${importerLabel(input.importer)}: the ${catalog} catalog has no entry for ${input.dependency}` };
38
+ const rewritten = rewriteRange(entry, input.target);
39
+ if (!rewritten.editable)
40
+ return { unavailable: `${importerLabel(input.importer)}: ${rewritten.reason}` };
41
+ const users = catalogUsers(root, catalog, input.dependency).join(', ');
42
+ return {
43
+ step: {
44
+ kind: 'set-range',
45
+ importer: input.importer,
46
+ dependency: input.dependency,
47
+ field: input.field,
48
+ place: 'catalog',
49
+ ...(catalog === DEFAULT_CATALOG ? {} : { catalog }),
50
+ from: entry,
51
+ to: rewritten.specifier,
52
+ reason: `${input.reason} (catalog entry used by ${users})${formNote(entry, rewritten.formChanged)}`,
53
+ },
54
+ };
55
+ }
56
+ export function setRangeStep(root, input) {
57
+ const catalog = catalogNameOf(input.specifier);
58
+ return catalog === undefined ? manifestStep(input) : catalogStep(root, input, catalog);
59
+ }
@@ -0,0 +1,50 @@
1
+ import { manifestPathOf } from "./edit-plan.js";
2
+ const SHARING_MANAGERS = ['pnpm', 'npm'];
3
+ const NAMED_FILES_LIMIT = 3;
4
+ const REPOSITORY_ROOT_ID = '.';
5
+ function canShare(root) {
6
+ return root.source === 'lockfile' && SHARING_MANAGERS.includes(root.manager);
7
+ }
8
+ function importerPaths(root) {
9
+ return new Set(root.importers.map((importer) => importer.path));
10
+ }
11
+ export function sharesWorkspacePackage(left, right) {
12
+ if (left.id === right.id)
13
+ return false;
14
+ const rightPaths = importerPaths(right);
15
+ return left.importers.some((importer) => rightPaths.has(importer.path));
16
+ }
17
+ export function sharingRoots(root, roots) {
18
+ const others = roots.filter((other) => other.id !== root.id && canShare(other)).sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
19
+ const shared = new Map();
20
+ for (const importer of root.importers) {
21
+ const sharing = others.filter((other) => importerPaths(other).has(importer.path)).map((other) => ({ id: other.id, manager: other.manager }));
22
+ if (sharing.length > 0)
23
+ shared.set(manifestPathOf(importer.id), sharing);
24
+ }
25
+ return shared;
26
+ }
27
+ function filesText(paths) {
28
+ const named = paths.slice(0, NAMED_FILES_LIMIT).join(', ');
29
+ const rest = paths.length - NAMED_FILES_LIMIT;
30
+ if (rest <= 0)
31
+ return named;
32
+ return `${named}, and ${rest} more file${rest === 1 ? '' : 's'}`;
33
+ }
34
+ export function sharedRootWarnings(paths, shared) {
35
+ const filesByRoot = new Map();
36
+ for (const path of paths) {
37
+ for (const sharing of shared.get(path) ?? []) {
38
+ const entry = filesByRoot.get(sharing.id) ?? { manager: sharing.manager, files: [] };
39
+ entry.files.push(path);
40
+ filesByRoot.set(sharing.id, entry);
41
+ }
42
+ }
43
+ return [...filesByRoot.keys()]
44
+ .sort()
45
+ .map((id) => {
46
+ const { manager, files } = filesByRoot.get(id);
47
+ const isRepositoryRoot = id === REPOSITORY_ROOT_ID;
48
+ return `${isRepositoryRoot ? 'The repository root' : id} also uses ${filesText(files)}: its lockfile is not updated; run ${manager} install in ${isRepositoryRoot ? 'the repository root' : id}`;
49
+ });
50
+ }