depopsy 1.0.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 +309 -0
- package/bin/cli.js +48 -0
- package/package.json +51 -0
- package/src/analyze/detector.js +144 -0
- package/src/analyze/grouper.js +298 -0
- package/src/analyze/scorer.js +40 -0
- package/src/cli/analyze-command.js +57 -0
- package/src/cli/fix-command.js +37 -0
- package/src/cli/index.js +27 -0
- package/src/cli/trace-command.js +104 -0
- package/src/fix/fixer.js +90 -0
- package/src/graph/builder.js +30 -0
- package/src/parser/graph.js +64 -0
- package/src/parser/index.js +47 -0
- package/src/parser/npm-parser.js +143 -0
- package/src/parser/pnpm-parser.js +257 -0
- package/src/parser/yarn-parser.js +93 -0
- package/src/report/formatter.js +309 -0
- package/src/utils/workspace.js +50 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export function buildGraphTraversal(reverseGraph, topLevelSet) {
|
|
2
|
+
const cache = new Map();
|
|
3
|
+
|
|
4
|
+
function traverse(pkgId) {
|
|
5
|
+
if (cache.has(pkgId)) return cache.get(pkgId);
|
|
6
|
+
|
|
7
|
+
const visited = new Set();
|
|
8
|
+
const stack = [pkgId];
|
|
9
|
+
const parents = [];
|
|
10
|
+
const roots = [];
|
|
11
|
+
const immediateParents = reverseGraph.get(pkgId) || [];
|
|
12
|
+
|
|
13
|
+
while (stack.length > 0) {
|
|
14
|
+
let current = stack.pop();
|
|
15
|
+
const currParents = reverseGraph.get(current) || [];
|
|
16
|
+
|
|
17
|
+
for (const parent of currParents) {
|
|
18
|
+
if (!visited.has(parent)) {
|
|
19
|
+
visited.add(parent);
|
|
20
|
+
parents.push(parent);
|
|
21
|
+
stack.push(parent);
|
|
22
|
+
|
|
23
|
+
const lastAtIdx = parent.lastIndexOf('@');
|
|
24
|
+
const nameOnly = lastAtIdx > 0 ? parent.substring(0, lastAtIdx) : parent;
|
|
25
|
+
|
|
26
|
+
if (topLevelSet.has(nameOnly)) {
|
|
27
|
+
roots.push(parent);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Sort and unique the arrays to ensure stability
|
|
34
|
+
const result = {
|
|
35
|
+
parents: Array.from(new Set(immediateParents)),
|
|
36
|
+
allParents: Array.from(new Set(parents)),
|
|
37
|
+
roots: Array.from(new Set(roots))
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
cache.set(pkgId, result);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { traverse };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function getTopLevelSet(projectDir, fs, path) {
|
|
48
|
+
try {
|
|
49
|
+
const pkgJsonPath = path.join(projectDir, 'package.json');
|
|
50
|
+
const content = await fs.readFile(pkgJsonPath, 'utf-8');
|
|
51
|
+
const pkg = JSON.parse(content);
|
|
52
|
+
|
|
53
|
+
const topLevelSet = new Set();
|
|
54
|
+
if (pkg.dependencies) {
|
|
55
|
+
Object.keys(pkg.dependencies).forEach(d => topLevelSet.add(d));
|
|
56
|
+
}
|
|
57
|
+
if (pkg.devDependencies) {
|
|
58
|
+
Object.keys(pkg.devDependencies).forEach(d => topLevelSet.add(d));
|
|
59
|
+
}
|
|
60
|
+
return topLevelSet;
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return new Set();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { parseNpmLockfile } from './npm-parser.js';
|
|
4
|
+
import { parseYarnLockfile } from './yarn-parser.js';
|
|
5
|
+
import { parsePnpmLockfile } from './pnpm-parser.js';
|
|
6
|
+
|
|
7
|
+
export async function parseLockfile(projectDir) {
|
|
8
|
+
// Detect lockfile
|
|
9
|
+
const hasNpm = await fs.access(path.join(projectDir, 'package-lock.json')).then(() => true).catch(() => false);
|
|
10
|
+
const hasYarn = await fs.access(path.join(projectDir, 'yarn.lock')).then(() => true).catch(() => false);
|
|
11
|
+
const hasPnpm = await fs.access(path.join(projectDir, 'pnpm-lock.yaml')).then(() => true).catch(() => false);
|
|
12
|
+
|
|
13
|
+
if (hasPnpm) {
|
|
14
|
+
const { packagesMap, topLevelDeps } = await parsePnpmLockfile(projectDir);
|
|
15
|
+
return { type: 'pnpm', map: packagesMap, topLevelDeps };
|
|
16
|
+
} else if (hasYarn) {
|
|
17
|
+
return { type: 'yarn', map: await parseYarnLockfile(projectDir) };
|
|
18
|
+
} else if (hasNpm) {
|
|
19
|
+
return { type: 'npm', map: await parseNpmLockfile(projectDir) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
throw new Error('No supported lockfile found (package-lock.json, yarn.lock, pnpm-lock.yaml). Please run install first.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Common graph insertion utility used by all parsers
|
|
27
|
+
*/
|
|
28
|
+
export function addToPackagesMap(packagesMap, name, version, instancePath, graphData = { parents: [], allParents: [], roots: [] }) {
|
|
29
|
+
if (!name || instancePath === '') return;
|
|
30
|
+
|
|
31
|
+
if (!packagesMap.has(name)) {
|
|
32
|
+
packagesMap.set(name, {
|
|
33
|
+
versions: new Set(),
|
|
34
|
+
instances: []
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const entry = packagesMap.get(name);
|
|
39
|
+
entry.versions.add(version);
|
|
40
|
+
entry.instances.push({
|
|
41
|
+
path: instancePath,
|
|
42
|
+
version,
|
|
43
|
+
parents: graphData.parents,
|
|
44
|
+
allParents: graphData.allParents,
|
|
45
|
+
roots: graphData.roots
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { addToPackagesMap } from './index.js';
|
|
4
|
+
import { buildGraphTraversal, getTopLevelSet } from './graph.js';
|
|
5
|
+
|
|
6
|
+
export async function parseNpmLockfile(projectDir) {
|
|
7
|
+
const lockfilePath = path.join(projectDir, 'package-lock.json');
|
|
8
|
+
const lockfileContent = await fs.readFile(lockfilePath, 'utf-8');
|
|
9
|
+
const lockfile = JSON.parse(lockfileContent);
|
|
10
|
+
const packagesMap = new Map();
|
|
11
|
+
|
|
12
|
+
const reverseMap = new Map();
|
|
13
|
+
|
|
14
|
+
if (lockfile.packages) {
|
|
15
|
+
// Map paths securely to extract resolution models securely
|
|
16
|
+
const pathMap = new Map();
|
|
17
|
+
for (const [pkgPath, pkgData] of Object.entries(lockfile.packages)) {
|
|
18
|
+
if (pkgPath === '' || pkgData.link) continue;
|
|
19
|
+
const parts = pkgPath.split('node_modules/');
|
|
20
|
+
const name = parts[parts.length - 1];
|
|
21
|
+
pathMap.set(pkgPath, `${name}@${pkgData.version}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const resolveDependencyPath = (callerPath, depName) => {
|
|
25
|
+
let currentPath = callerPath;
|
|
26
|
+
while (currentPath !== '') {
|
|
27
|
+
const probe = `${currentPath}/node_modules/${depName}`;
|
|
28
|
+
if (pathMap.has(probe)) return pathMap.get(probe);
|
|
29
|
+
const lastIndex = currentPath.lastIndexOf('/node_modules/');
|
|
30
|
+
if (lastIndex === -1) {
|
|
31
|
+
currentPath = '';
|
|
32
|
+
} else {
|
|
33
|
+
currentPath = currentPath.substring(0, lastIndex);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const rootProbe = `node_modules/${depName}`;
|
|
37
|
+
if (pathMap.has(rootProbe)) return pathMap.get(rootProbe);
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
for (const [pkgPath, pkgData] of Object.entries(lockfile.packages)) {
|
|
42
|
+
if (pkgPath === '' || pkgData.link) continue;
|
|
43
|
+
const callerId = pathMap.get(pkgPath);
|
|
44
|
+
|
|
45
|
+
const requires = pkgData.dependencies || {};
|
|
46
|
+
for (const [depName] of Object.entries(requires)) {
|
|
47
|
+
const resolvedDepId = resolveDependencyPath(pkgPath, depName);
|
|
48
|
+
if (resolvedDepId) {
|
|
49
|
+
if (!reverseMap.has(resolvedDepId)) reverseMap.set(resolvedDepId, new Set());
|
|
50
|
+
reverseMap.get(resolvedDepId).add(callerId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const topLevelSet = await getTopLevelSet(projectDir, fs, path);
|
|
56
|
+
const graph = buildGraphTraversal(reverseMap, topLevelSet);
|
|
57
|
+
|
|
58
|
+
for (const [pkgPath, pkgData] of Object.entries(lockfile.packages)) {
|
|
59
|
+
if (pkgPath === '' || pkgData.link) continue;
|
|
60
|
+
|
|
61
|
+
const parts = pkgPath.split('node_modules/');
|
|
62
|
+
const name = parts[parts.length - 1];
|
|
63
|
+
|
|
64
|
+
const selfId = `${name}@${pkgData.version}`;
|
|
65
|
+
const graphData = graph.traverse(selfId);
|
|
66
|
+
|
|
67
|
+
addToPackagesMap(packagesMap, name, pkgData.version, pkgPath, graphData);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
} else if (lockfile.dependencies) {
|
|
71
|
+
// Fallback for extremely old v1 lockfiles without packages block
|
|
72
|
+
// V1 resolution mirrors V2 closely but operates through nested arrays natively.
|
|
73
|
+
const pathMap = new Map();
|
|
74
|
+
|
|
75
|
+
const indexDependencies = (deps, basePath = '') => {
|
|
76
|
+
for (const [name, data] of Object.entries(deps)) {
|
|
77
|
+
const currentPath = basePath ? `${basePath}/node_modules/${name}` : `node_modules/${name}`;
|
|
78
|
+
pathMap.set(currentPath, `${name}@${data.version}`);
|
|
79
|
+
if (data.dependencies) {
|
|
80
|
+
indexDependencies(data.dependencies, currentPath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
indexDependencies(lockfile.dependencies);
|
|
85
|
+
|
|
86
|
+
const resolveDependencyPathV1 = (callerPath, depName) => {
|
|
87
|
+
let currentPath = callerPath;
|
|
88
|
+
while (currentPath !== '') {
|
|
89
|
+
const probe = `${currentPath}/node_modules/${depName}`;
|
|
90
|
+
if (pathMap.has(probe)) return pathMap.get(probe);
|
|
91
|
+
const lastIndex = currentPath.lastIndexOf('/node_modules/');
|
|
92
|
+
if (lastIndex === -1) {
|
|
93
|
+
currentPath = '';
|
|
94
|
+
} else {
|
|
95
|
+
currentPath = currentPath.substring(0, lastIndex);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const rootProbe = `node_modules/${depName}`;
|
|
99
|
+
if (pathMap.has(rootProbe)) return pathMap.get(rootProbe);
|
|
100
|
+
return null;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const linkDependencies = (deps, basePath = '') => {
|
|
104
|
+
for (const [name, data] of Object.entries(deps)) {
|
|
105
|
+
const currentPath = basePath ? `${basePath}/node_modules/${name}` : `node_modules/${name}`;
|
|
106
|
+
const callerId = pathMap.get(currentPath);
|
|
107
|
+
|
|
108
|
+
const requires = data.requires || {};
|
|
109
|
+
for (const [depName] of Object.entries(requires)) {
|
|
110
|
+
const resolvedDepId = resolveDependencyPathV1(currentPath, depName);
|
|
111
|
+
if (resolvedDepId) {
|
|
112
|
+
if (!reverseMap.has(resolvedDepId)) reverseMap.set(resolvedDepId, new Set());
|
|
113
|
+
reverseMap.get(resolvedDepId).add(callerId);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (data.dependencies) {
|
|
118
|
+
linkDependencies(data.dependencies, currentPath);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
linkDependencies(lockfile.dependencies);
|
|
123
|
+
|
|
124
|
+
const topLevelSet = await getTopLevelSet(projectDir, fs, path);
|
|
125
|
+
const graph = buildGraphTraversal(reverseMap, topLevelSet);
|
|
126
|
+
|
|
127
|
+
const processDependencies = (deps, basePath = '') => {
|
|
128
|
+
for (const [name, data] of Object.entries(deps)) {
|
|
129
|
+
const currentPath = basePath ? `${basePath}/node_modules/${name}` : `node_modules/${name}`;
|
|
130
|
+
const selfId = `${name}@${data.version}`;
|
|
131
|
+
const graphData = graph.traverse(selfId);
|
|
132
|
+
|
|
133
|
+
addToPackagesMap(packagesMap, name, data.version, currentPath, graphData);
|
|
134
|
+
if (data.dependencies) {
|
|
135
|
+
processDependencies(data.dependencies, currentPath);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
processDependencies(lockfile.dependencies);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return packagesMap;
|
|
143
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { addToPackagesMap } from './index.js';
|
|
5
|
+
|
|
6
|
+
// ── Key normalization ────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
/** Strip leading "/" from lockfile keys (pnpm v5/v6 format) */
|
|
9
|
+
function normalizePkgKey(key) {
|
|
10
|
+
return key.replace(/^\//, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Strip parenthesised peer suffix: "1.2.3(react@18)" → "1.2.3" */
|
|
14
|
+
function cleanVersion(version) {
|
|
15
|
+
return String(version).split('(')[0].trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extract a bare package name from a "name@version" key.
|
|
20
|
+
* Handles scoped packages: "@babel/core@7.0.0" → "@babel/core"
|
|
21
|
+
*/
|
|
22
|
+
function pkgNameOf(key) {
|
|
23
|
+
if (!key) return '';
|
|
24
|
+
if (key.startsWith('@')) {
|
|
25
|
+
const second = key.indexOf('@', 1);
|
|
26
|
+
return second > 0 ? key.substring(0, second) : key;
|
|
27
|
+
}
|
|
28
|
+
const at = key.indexOf('@');
|
|
29
|
+
return at > 0 ? key.substring(0, at) : key;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Importer extraction (handles v5 / v6 / v7 / v9 formats) ─────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Return the root importer object from parsed lockfile.
|
|
36
|
+
* pnpm v5: importers['.'] (or absent — everything is packages only)
|
|
37
|
+
* pnpm v6+: importers['.'] with { dependencies: { name: { specifier, version } } }
|
|
38
|
+
* pnpm v9: same but lockfileVersion is "9.0"
|
|
39
|
+
*/
|
|
40
|
+
function getRootImporter(parsed) {
|
|
41
|
+
if (!parsed.importers) return {};
|
|
42
|
+
// Try '.', then '' (empty string), then first key
|
|
43
|
+
return parsed.importers['.']
|
|
44
|
+
|| parsed.importers['']
|
|
45
|
+
|| Object.values(parsed.importers)[0]
|
|
46
|
+
|| {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract a bare version string from a dependency spec.
|
|
51
|
+
* Handles both string specs ("1.2.3") and object specs ({ specifier, version }).
|
|
52
|
+
*/
|
|
53
|
+
function extractVersion(spec) {
|
|
54
|
+
if (!spec) return null;
|
|
55
|
+
if (typeof spec === 'string') return cleanVersion(spec);
|
|
56
|
+
if (typeof spec === 'object') {
|
|
57
|
+
if (spec.version) return cleanVersion(spec.version);
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get all top-level dep names from the importer.
|
|
64
|
+
* Returns bare package names like ["next", "eslint", "@babel/core"].
|
|
65
|
+
*/
|
|
66
|
+
function getTopLevelDepNames(importer) {
|
|
67
|
+
const deps = {
|
|
68
|
+
...(importer.dependencies || {}),
|
|
69
|
+
...(importer.devDependencies || {}),
|
|
70
|
+
...(importer.optionalDependencies || {}),
|
|
71
|
+
};
|
|
72
|
+
return new Set(Object.keys(deps));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Package entry iteration ──────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the forward dependency graph and reverse graph from the packages block.
|
|
79
|
+
* Returns { forwardGraph, reverseGraph, allPkgKeys }
|
|
80
|
+
*
|
|
81
|
+
* Handles two package block formats:
|
|
82
|
+
* pnpm v5/v6: { "/pkg@1.0.0": { dependencies: { dep: "version" } } }
|
|
83
|
+
* pnpm v9: { "pkg@1.0.0": { dependencies: { dep: "version" } } }
|
|
84
|
+
*/
|
|
85
|
+
function buildGraphs(parsed) {
|
|
86
|
+
const forwardGraph = {};
|
|
87
|
+
const reverseGraph = {};
|
|
88
|
+
const packages = parsed.packages || {};
|
|
89
|
+
|
|
90
|
+
// pnpm v9 "snapshots" block takes precedence if present; otherwise use packages
|
|
91
|
+
const pkgBlock = parsed.snapshots || packages;
|
|
92
|
+
|
|
93
|
+
for (const rawKey of Object.keys(pkgBlock)) {
|
|
94
|
+
const pkgKey = normalizePkgKey(rawKey);
|
|
95
|
+
if (!forwardGraph[pkgKey]) forwardGraph[pkgKey] = [];
|
|
96
|
+
if (!reverseGraph[pkgKey]) reverseGraph[pkgKey] = [];
|
|
97
|
+
|
|
98
|
+
// Get deps from both snapshots and packages (pnpm v9 split them)
|
|
99
|
+
const pkgEntry = pkgBlock[rawKey] || {};
|
|
100
|
+
const pkgMeta = packages[rawKey] || packages['/' + rawKey] || {};
|
|
101
|
+
const deps = { ...(pkgMeta.dependencies || {}), ...(pkgEntry.dependencies || {}) };
|
|
102
|
+
|
|
103
|
+
for (const [depName, depSpec] of Object.entries(deps)) {
|
|
104
|
+
const ver = extractVersion(depSpec);
|
|
105
|
+
if (!ver || String(depSpec).startsWith('link:')) continue;
|
|
106
|
+
|
|
107
|
+
const depKey = `${depName}@${ver}`;
|
|
108
|
+
forwardGraph[pkgKey].push(depKey);
|
|
109
|
+
if (!reverseGraph[depKey]) reverseGraph[depKey] = [];
|
|
110
|
+
reverseGraph[depKey].push(pkgKey);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { forwardGraph, reverseGraph };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Top-down ownership DFS ───────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* For each top-level dep, DFS through forwardGraph to find every package
|
|
121
|
+
* it introduces. Returns Map<pkgKey, Set<topLevelDepName>>.
|
|
122
|
+
*
|
|
123
|
+
* Start keys are found by:
|
|
124
|
+
* 1. Matching pkgKey name against topLevelDeps set
|
|
125
|
+
* 2. Seeding from importer version specs (for exact key format)
|
|
126
|
+
*/
|
|
127
|
+
function buildOwnershipMap(parsed, forwardGraph, topLevelDeps, importer) {
|
|
128
|
+
// Step A — find versioned start keys for each top-level dep
|
|
129
|
+
const topLevelVersioned = new Map(); // depName → Set<pkgKey>
|
|
130
|
+
|
|
131
|
+
// Seed from package keys
|
|
132
|
+
const pkgBlock = parsed.snapshots || parsed.packages || {};
|
|
133
|
+
for (const rawKey of Object.keys(pkgBlock)) {
|
|
134
|
+
const pkgKey = normalizePkgKey(rawKey);
|
|
135
|
+
const name = pkgNameOf(pkgKey);
|
|
136
|
+
if (topLevelDeps.has(name)) {
|
|
137
|
+
if (!topLevelVersioned.has(name)) topLevelVersioned.set(name, new Set());
|
|
138
|
+
topLevelVersioned.get(name).add(pkgKey);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Seed from importer dep version specs (catches exact key variations)
|
|
143
|
+
const allImporterDeps = {
|
|
144
|
+
...(importer.dependencies || {}),
|
|
145
|
+
...(importer.devDependencies || {}),
|
|
146
|
+
...(importer.optionalDependencies || {}),
|
|
147
|
+
};
|
|
148
|
+
for (const [depName, spec] of Object.entries(allImporterDeps)) {
|
|
149
|
+
const ver = extractVersion(spec);
|
|
150
|
+
if (ver) {
|
|
151
|
+
const key = `${depName}@${ver}`;
|
|
152
|
+
if (!topLevelVersioned.has(depName)) topLevelVersioned.set(depName, new Set());
|
|
153
|
+
topLevelVersioned.get(depName).add(key);
|
|
154
|
+
// Also try without peer suffix
|
|
155
|
+
const cleanKey = `${depName}@${cleanVersion(ver)}`;
|
|
156
|
+
topLevelVersioned.get(depName).add(cleanKey);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Step B — DFS from each start key
|
|
161
|
+
const ownership = new Map(); // pkgKey → Set<topLevelDepName>
|
|
162
|
+
|
|
163
|
+
for (const [depName, startKeys] of topLevelVersioned) {
|
|
164
|
+
for (const startKey of startKeys) {
|
|
165
|
+
const stack = [startKey];
|
|
166
|
+
const visited = new Set([startKey]);
|
|
167
|
+
|
|
168
|
+
while (stack.length) {
|
|
169
|
+
const node = stack.pop();
|
|
170
|
+
if (!ownership.has(node)) ownership.set(node, new Set());
|
|
171
|
+
ownership.get(node).add(depName);
|
|
172
|
+
|
|
173
|
+
for (const child of (forwardGraph[node] || [])) {
|
|
174
|
+
if (!visited.has(child)) {
|
|
175
|
+
visited.add(child);
|
|
176
|
+
stack.push(child);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return ownership;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Main parser ──────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
export async function parsePnpmLockfile(projectDir) {
|
|
189
|
+
const lockfilePath = path.join(projectDir, 'pnpm-lock.yaml');
|
|
190
|
+
const lockfileContent = await fs.readFile(lockfilePath, 'utf-8');
|
|
191
|
+
const parsed = yaml.load(lockfileContent);
|
|
192
|
+
const packagesMap = new Map();
|
|
193
|
+
|
|
194
|
+
const packages = parsed.packages || {};
|
|
195
|
+
if (Object.keys(packages).length === 0 && !parsed.snapshots) {
|
|
196
|
+
return { packagesMap, topLevelDeps: new Set() };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Importer & top-level dep extraction ──────────────────────────────────
|
|
200
|
+
const importer = getRootImporter(parsed);
|
|
201
|
+
const topLevelDeps = getTopLevelDepNames(importer);
|
|
202
|
+
|
|
203
|
+
// ── Graph construction ────────────────────────────────────────────────────
|
|
204
|
+
const { forwardGraph, reverseGraph } = buildGraphs(parsed);
|
|
205
|
+
|
|
206
|
+
// ── Ownership map (top-down DFS) ──────────────────────────────────────────
|
|
207
|
+
const ownership = buildOwnershipMap(parsed, forwardGraph, topLevelDeps, importer);
|
|
208
|
+
|
|
209
|
+
// ── Emit packages ─────────────────────────────────────────────────────────
|
|
210
|
+
// Use packages block (not snapshots) as the canonical source of package identity
|
|
211
|
+
for (const rawKey of Object.keys(packages)) {
|
|
212
|
+
const pkgKey = normalizePkgKey(rawKey);
|
|
213
|
+
const lastAt = pkgKey.lastIndexOf('@');
|
|
214
|
+
if (lastAt <= 0) continue;
|
|
215
|
+
|
|
216
|
+
const name = pkgKey.substring(0, lastAt);
|
|
217
|
+
const resolvedVersion = pkgKey.substring(lastAt + 1);
|
|
218
|
+
const selfId = `${name}@${resolvedVersion}`;
|
|
219
|
+
|
|
220
|
+
// roots = bare top-level dep names that introduce this package
|
|
221
|
+
const roots = Array.from(ownership.get(selfId) || []);
|
|
222
|
+
// parents = immediate dependents from reverse graph
|
|
223
|
+
const parents = Array.from(new Set(reverseGraph[selfId] || []));
|
|
224
|
+
|
|
225
|
+
addToPackagesMap(packagesMap, name, resolvedVersion, rawKey, {
|
|
226
|
+
roots,
|
|
227
|
+
parents,
|
|
228
|
+
allParents: ancestors(reverseGraph, selfId), // full chain for grouper fallback
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { packagesMap, topLevelDeps };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Walk reverse graph upward to collect all ancestors (BFS, depth-limited).
|
|
237
|
+
* Used as allParents so the grouper fallback layers have data if roots are empty.
|
|
238
|
+
*/
|
|
239
|
+
function ancestors(reverseGraph, startId, maxDepth = 8) {
|
|
240
|
+
const result = new Set();
|
|
241
|
+
const queue = [{ node: startId, depth: 0 }];
|
|
242
|
+
const visited = new Set([startId]);
|
|
243
|
+
|
|
244
|
+
while (queue.length) {
|
|
245
|
+
const { node, depth } = queue.shift();
|
|
246
|
+
if (depth >= maxDepth) continue;
|
|
247
|
+
for (const parent of (reverseGraph[node] || [])) {
|
|
248
|
+
if (!visited.has(parent)) {
|
|
249
|
+
visited.add(parent);
|
|
250
|
+
result.add(parent);
|
|
251
|
+
queue.push({ node: parent, depth: depth + 1 });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return Array.from(result);
|
|
257
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import lockfileParser from '@yarnpkg/lockfile';
|
|
5
|
+
import { addToPackagesMap } from './index.js';
|
|
6
|
+
import { buildGraphTraversal, getTopLevelSet } from './graph.js';
|
|
7
|
+
|
|
8
|
+
export async function parseYarnLockfile(projectDir) {
|
|
9
|
+
const lockfilePath = path.join(projectDir, 'yarn.lock');
|
|
10
|
+
const lockfileContent = await fs.readFile(lockfilePath, 'utf-8');
|
|
11
|
+
|
|
12
|
+
const packagesMap = new Map();
|
|
13
|
+
|
|
14
|
+
// Try to determine if it's Yarn v1 or v2+
|
|
15
|
+
// Yarn v2+ lockfiles usually have "__metadata" block and standard YAML structure
|
|
16
|
+
let parsed;
|
|
17
|
+
if (lockfileContent.includes('__metadata')) {
|
|
18
|
+
// It's likely Yarn v2+ (YAML)
|
|
19
|
+
parsed = yaml.load(lockfileContent);
|
|
20
|
+
// Remove metadata
|
|
21
|
+
delete parsed.__metadata;
|
|
22
|
+
} else {
|
|
23
|
+
// Yarn v1
|
|
24
|
+
const result = lockfileParser.parse(lockfileContent);
|
|
25
|
+
if (result.type !== 'success') {
|
|
26
|
+
throw new Error('Failed to parse yarn.lock');
|
|
27
|
+
}
|
|
28
|
+
parsed = result.object;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Pass 1: Build exact version lookup map
|
|
32
|
+
const exactVersions = new Map();
|
|
33
|
+
for (const [key, pkgData] of Object.entries(parsed)) {
|
|
34
|
+
const resolutions = key.split(',').map(s => s.trim());
|
|
35
|
+
for (let res of resolutions) {
|
|
36
|
+
if (res.includes('@npm:')) res = res.replace('@npm:', '@');
|
|
37
|
+
if (res.includes('@workspace:')) res = res.replace('@workspace:', '@');
|
|
38
|
+
exactVersions.set(res, pkgData.version);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Pass 2: Build specific name@version Reverse Graph Matrix
|
|
43
|
+
const reverseMap = new Map();
|
|
44
|
+
for (const [key, pkgData] of Object.entries(parsed)) {
|
|
45
|
+
const resolutions = key.split(',').map(s => s.trim());
|
|
46
|
+
let firstRes = resolutions[0];
|
|
47
|
+
if (firstRes.includes('@npm:')) firstRes = firstRes.replace('@npm:', '@');
|
|
48
|
+
if (firstRes.includes('@workspace:')) firstRes = firstRes.replace('@workspace:', '@');
|
|
49
|
+
const lastAtIdx = firstRes.lastIndexOf('@');
|
|
50
|
+
if (lastAtIdx <= 0) continue;
|
|
51
|
+
|
|
52
|
+
const name = firstRes.substring(0, lastAtIdx);
|
|
53
|
+
const callerId = `${name}@${pkgData.version}`;
|
|
54
|
+
|
|
55
|
+
if (pkgData.dependencies) {
|
|
56
|
+
for (const [depName, depReq] of Object.entries(pkgData.dependencies)) {
|
|
57
|
+
const depKey = `${depName}@${depReq}`;
|
|
58
|
+
const resolvedVersion = exactVersions.get(depKey);
|
|
59
|
+
if (resolvedVersion) {
|
|
60
|
+
const depId = `${depName}@${resolvedVersion}`;
|
|
61
|
+
if (!reverseMap.has(depId)) reverseMap.set(depId, new Set());
|
|
62
|
+
reverseMap.get(depId).add(callerId);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Pass 3: Evaluate unified recursive graph limits
|
|
69
|
+
const topLevelSet = await getTopLevelSet(projectDir, fs, path);
|
|
70
|
+
const graph = buildGraphTraversal(reverseMap, topLevelSet);
|
|
71
|
+
|
|
72
|
+
for (const [key, pkgData] of Object.entries(parsed)) {
|
|
73
|
+
// Extract the raw package name.
|
|
74
|
+
const resolutions = key.split(',').map(s => s.trim());
|
|
75
|
+
let firstRes = resolutions[0];
|
|
76
|
+
|
|
77
|
+
if (firstRes.includes('@npm:')) firstRes = firstRes.replace('@npm:', '@');
|
|
78
|
+
if (firstRes.includes('@workspace:')) firstRes = firstRes.replace('@workspace:', '@');
|
|
79
|
+
|
|
80
|
+
const lastAtIdx = firstRes.lastIndexOf('@');
|
|
81
|
+
if (lastAtIdx <= 0) continue; // safety check
|
|
82
|
+
|
|
83
|
+
const name = firstRes.substring(0, lastAtIdx);
|
|
84
|
+
const selfId = `${name}@${pkgData.version}`;
|
|
85
|
+
|
|
86
|
+
const graphData = graph.traverse(selfId);
|
|
87
|
+
|
|
88
|
+
// In Yarn, packages are flattened into the lockfile. The "instance path" is abstract.
|
|
89
|
+
addToPackagesMap(packagesMap, name, pkgData.version, key, graphData);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return packagesMap;
|
|
93
|
+
}
|