@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,87 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
export class TreeTooLargeError extends Error {
|
|
3
|
+
constructor(maxEntries) {
|
|
4
|
+
super(`the tree has more than ${maxEntries} entries`);
|
|
5
|
+
this.name = 'TreeTooLargeError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
const KIND_BY_MODE = {
|
|
9
|
+
'040000': 'dir',
|
|
10
|
+
'100644': 'file',
|
|
11
|
+
'100755': 'file',
|
|
12
|
+
'120000': 'symlink',
|
|
13
|
+
};
|
|
14
|
+
const TOP = '.';
|
|
15
|
+
const SUBMODULE_MODE = '160000';
|
|
16
|
+
const PARENT_PREFIX = '..';
|
|
17
|
+
function normalizePrefix(prefix) {
|
|
18
|
+
return prefix.replace(/\/+$/, '');
|
|
19
|
+
}
|
|
20
|
+
function parentOf(fullPath) {
|
|
21
|
+
const slash = fullPath.lastIndexOf('/');
|
|
22
|
+
return slash === -1 ? '' : fullPath.slice(0, slash);
|
|
23
|
+
}
|
|
24
|
+
function nameOf(fullPath) {
|
|
25
|
+
return fullPath.slice(fullPath.lastIndexOf('/') + 1);
|
|
26
|
+
}
|
|
27
|
+
function keyOf(fullPath, root) {
|
|
28
|
+
if (fullPath === root)
|
|
29
|
+
return TOP;
|
|
30
|
+
if (root === '')
|
|
31
|
+
return fullPath;
|
|
32
|
+
if (fullPath.startsWith(`${root}/`))
|
|
33
|
+
return fullPath.slice(root.length + 1);
|
|
34
|
+
return posix.relative(root, fullPath);
|
|
35
|
+
}
|
|
36
|
+
export function parseTree(output, prefix, maxEntries) {
|
|
37
|
+
const root = normalizePrefix(prefix);
|
|
38
|
+
const listings = new Map([['', []]]);
|
|
39
|
+
const blobs = new Map();
|
|
40
|
+
const kinds = new Map();
|
|
41
|
+
const submodules = [];
|
|
42
|
+
let count = 0;
|
|
43
|
+
let start = 0;
|
|
44
|
+
while (start < output.length) {
|
|
45
|
+
let end = output.indexOf(0, start);
|
|
46
|
+
if (end === -1)
|
|
47
|
+
end = output.length;
|
|
48
|
+
const record = output.subarray(start, end).toString('utf8');
|
|
49
|
+
start = end + 1;
|
|
50
|
+
if (record === '')
|
|
51
|
+
continue;
|
|
52
|
+
count += 1;
|
|
53
|
+
if (count > maxEntries)
|
|
54
|
+
throw new TreeTooLargeError(maxEntries);
|
|
55
|
+
const tab = record.indexOf('\t');
|
|
56
|
+
if (tab === -1)
|
|
57
|
+
continue;
|
|
58
|
+
const [mode = '', , id = ''] = record.slice(0, tab).split(' ');
|
|
59
|
+
const fullPath = record.slice(tab + 1);
|
|
60
|
+
if (mode === SUBMODULE_MODE) {
|
|
61
|
+
const key = keyOf(fullPath, root);
|
|
62
|
+
if (!key.startsWith(PARENT_PREFIX))
|
|
63
|
+
submodules.push(key);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const kind = KIND_BY_MODE[mode];
|
|
67
|
+
if (!kind || fullPath === '')
|
|
68
|
+
continue;
|
|
69
|
+
const parent = parentOf(fullPath);
|
|
70
|
+
let siblings = listings.get(parent);
|
|
71
|
+
if (!siblings) {
|
|
72
|
+
siblings = [];
|
|
73
|
+
listings.set(parent, siblings);
|
|
74
|
+
}
|
|
75
|
+
siblings.push({ name: nameOf(fullPath), kind });
|
|
76
|
+
const key = keyOf(fullPath, root);
|
|
77
|
+
kinds.set(key, kind);
|
|
78
|
+
if (kind === 'dir' && !listings.has(fullPath))
|
|
79
|
+
listings.set(fullPath, []);
|
|
80
|
+
if (kind === 'file')
|
|
81
|
+
blobs.set(key, id);
|
|
82
|
+
}
|
|
83
|
+
const directories = new Map([...listings].map(([fullPath, entries]) => [keyOf(fullPath, root), entries]));
|
|
84
|
+
if (!directories.has(TOP))
|
|
85
|
+
directories.set(TOP, []);
|
|
86
|
+
return { directories, blobs, kinds, submodules };
|
|
87
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { PLACEHOLDER_PREFIX } from "../model.js";
|
|
2
|
+
export function buildGlobalIndex(roots) {
|
|
3
|
+
const index = new Map();
|
|
4
|
+
for (const root of roots) {
|
|
5
|
+
if (root.status !== 'ready')
|
|
6
|
+
continue;
|
|
7
|
+
for (const node of root.packages.values()) {
|
|
8
|
+
if (node.id.startsWith(PLACEHOLDER_PREFIX))
|
|
9
|
+
continue;
|
|
10
|
+
const entry = { rootId: root.id, nodeId: node.id, version: node.version };
|
|
11
|
+
const entries = index.get(node.name);
|
|
12
|
+
if (entries)
|
|
13
|
+
entries.push(entry);
|
|
14
|
+
else
|
|
15
|
+
index.set(node.name, [entry]);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return index;
|
|
19
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { IMPORTER_PREFIX, PLACEHOLDER_PREFIX, } from "../model.js";
|
|
2
|
+
export class RootGraphBuilder {
|
|
3
|
+
init;
|
|
4
|
+
internTable = new Map();
|
|
5
|
+
importers = new Map();
|
|
6
|
+
packages = new Map();
|
|
7
|
+
warnings = [];
|
|
8
|
+
constructor(init) {
|
|
9
|
+
this.init = init;
|
|
10
|
+
}
|
|
11
|
+
intern(value) {
|
|
12
|
+
const stored = this.internTable.get(value);
|
|
13
|
+
if (stored !== undefined)
|
|
14
|
+
return stored;
|
|
15
|
+
this.internTable.set(value, value);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
addImporter(path, name) {
|
|
19
|
+
const id = IMPORTER_PREFIX + path;
|
|
20
|
+
if (!this.importers.has(id)) {
|
|
21
|
+
this.importers.set(id, { id, path, name: this.intern(name), deps: [], dependents: [] });
|
|
22
|
+
}
|
|
23
|
+
return id;
|
|
24
|
+
}
|
|
25
|
+
addPackage(id, name, version, peerSuffix) {
|
|
26
|
+
if (this.hasNode(id))
|
|
27
|
+
return;
|
|
28
|
+
const internedId = this.intern(id);
|
|
29
|
+
const node = {
|
|
30
|
+
id: internedId,
|
|
31
|
+
name: this.intern(name),
|
|
32
|
+
version: this.intern(version),
|
|
33
|
+
deps: [],
|
|
34
|
+
dependents: [],
|
|
35
|
+
};
|
|
36
|
+
if (peerSuffix !== undefined)
|
|
37
|
+
node.peerSuffix = this.intern(peerSuffix);
|
|
38
|
+
this.packages.set(internedId, node);
|
|
39
|
+
}
|
|
40
|
+
markNonRegistry(id) {
|
|
41
|
+
const node = this.packages.get(id);
|
|
42
|
+
if (node)
|
|
43
|
+
node.nonRegistry = true;
|
|
44
|
+
}
|
|
45
|
+
hasNode(id) {
|
|
46
|
+
return this.importers.has(id) || this.packages.has(id);
|
|
47
|
+
}
|
|
48
|
+
addEdge(from, edge) {
|
|
49
|
+
const source = this.findNode(from);
|
|
50
|
+
if (!source)
|
|
51
|
+
throw new Error(`Cannot add an edge from unknown node ${from}`);
|
|
52
|
+
const alreadyPresent = source.deps.some((existing) => existing.to === edge.to && existing.kind === edge.kind);
|
|
53
|
+
if (alreadyPresent)
|
|
54
|
+
return;
|
|
55
|
+
source.deps.push({ to: this.intern(edge.to), kind: edge.kind, range: this.intern(edge.range) });
|
|
56
|
+
}
|
|
57
|
+
warn(message) {
|
|
58
|
+
this.warnings.push(message);
|
|
59
|
+
}
|
|
60
|
+
build() {
|
|
61
|
+
this.resolveMissingTargets();
|
|
62
|
+
this.rebuildDependents();
|
|
63
|
+
return {
|
|
64
|
+
id: this.init.id,
|
|
65
|
+
path: this.init.path,
|
|
66
|
+
manager: this.init.manager,
|
|
67
|
+
source: this.init.source,
|
|
68
|
+
status: 'ready',
|
|
69
|
+
importers: [...this.importers.values()],
|
|
70
|
+
packages: this.packages,
|
|
71
|
+
warnings: [...this.warnings],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
findNode(id) {
|
|
75
|
+
return this.importers.get(id) ?? this.packages.get(id);
|
|
76
|
+
}
|
|
77
|
+
allNodes() {
|
|
78
|
+
return [...this.importers.values(), ...this.packages.values()];
|
|
79
|
+
}
|
|
80
|
+
resolveMissingTargets() {
|
|
81
|
+
for (const node of this.allNodes()) {
|
|
82
|
+
for (const edge of node.deps) {
|
|
83
|
+
if (this.hasNode(edge.to))
|
|
84
|
+
continue;
|
|
85
|
+
edge.to = this.addPlaceholder(edge.to);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
addPlaceholder(missingTarget) {
|
|
90
|
+
const id = this.intern(PLACEHOLDER_PREFIX + missingTarget);
|
|
91
|
+
if (this.packages.has(id))
|
|
92
|
+
return id;
|
|
93
|
+
this.packages.set(id, { id, name: this.intern('?'), version: this.intern(''), deps: [], dependents: [] });
|
|
94
|
+
this.warn(`Unresolved dependency ${missingTarget}`);
|
|
95
|
+
return id;
|
|
96
|
+
}
|
|
97
|
+
rebuildDependents() {
|
|
98
|
+
const nodes = this.allNodes();
|
|
99
|
+
for (const node of nodes)
|
|
100
|
+
node.dependents = [];
|
|
101
|
+
for (const parent of nodes) {
|
|
102
|
+
for (const edge of parent.deps)
|
|
103
|
+
this.appendDependent(edge.to, parent.id);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
appendDependent(targetId, parentId) {
|
|
107
|
+
const target = this.findNode(targetId);
|
|
108
|
+
if (!target)
|
|
109
|
+
return;
|
|
110
|
+
const lastParent = target.dependents[target.dependents.length - 1];
|
|
111
|
+
if (lastParent !== parentId)
|
|
112
|
+
target.dependents.push(parentId);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function serializeRootGraph(graph) {
|
|
2
|
+
const { config, packages, ...rest } = graph;
|
|
3
|
+
const serialized = { ...rest, hasAuth: config?.hasAuth ?? false, packages: [...packages.values()] };
|
|
4
|
+
return serialized;
|
|
5
|
+
}
|
|
6
|
+
export function deserializeRootGraph(json) {
|
|
7
|
+
const serialized = json;
|
|
8
|
+
if (typeof serialized !== 'object' || serialized === null || !Array.isArray(serialized.packages)) {
|
|
9
|
+
throw new Error('Serialized root graph is malformed');
|
|
10
|
+
}
|
|
11
|
+
const { hasAuth: _hasAuth, packages: serializedPackages, ...rest } = serialized;
|
|
12
|
+
const packages = new Map();
|
|
13
|
+
for (const node of serializedPackages)
|
|
14
|
+
packages.set(node.id, node);
|
|
15
|
+
return { ...rest, packages };
|
|
16
|
+
}
|
|
17
|
+
export function serializeScan(scan) {
|
|
18
|
+
return { repoPath: scan.repoPath, scannedAt: scan.scannedAt, roots: scan.roots.map(serializeRootGraph) };
|
|
19
|
+
}
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { passingEdges, requireNode, viewOf } from "./lookup.js";
|
|
2
|
+
function rowsOf(root, id, kinds) {
|
|
3
|
+
return passingEdges(requireNode(root, id), kinds).map((edge) => {
|
|
4
|
+
const child = requireNode(root, edge.to);
|
|
5
|
+
return {
|
|
6
|
+
node: viewOf(child),
|
|
7
|
+
kind: edge.kind,
|
|
8
|
+
range: edge.range,
|
|
9
|
+
hasChildren: child.deps.some((childEdge) => kinds.has(childEdge.kind)),
|
|
10
|
+
repeated: false,
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export function children(root, id, depth, kinds) {
|
|
15
|
+
const focus = requireNode(root, id);
|
|
16
|
+
const levels = Math.max(1, Math.floor(depth));
|
|
17
|
+
const rows = rowsOf(root, id, kinds);
|
|
18
|
+
const nested = {};
|
|
19
|
+
const expanded = new Set([id]);
|
|
20
|
+
let currentLevel = rows;
|
|
21
|
+
for (let level = 1; level <= levels && currentLevel.length > 0; level++) {
|
|
22
|
+
const nextLevel = [];
|
|
23
|
+
for (const row of currentLevel) {
|
|
24
|
+
if (expanded.has(row.node.id)) {
|
|
25
|
+
row.repeated = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (level === levels)
|
|
29
|
+
continue;
|
|
30
|
+
expanded.add(row.node.id);
|
|
31
|
+
const childRows = rowsOf(root, row.node.id, kinds);
|
|
32
|
+
nested[row.node.id] = childRows;
|
|
33
|
+
nextLevel.push(...childRows);
|
|
34
|
+
}
|
|
35
|
+
currentLevel = nextLevel;
|
|
36
|
+
}
|
|
37
|
+
return { node: viewOf(focus), rows, nested };
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
function sourceDiffers(notes) {
|
|
2
|
+
const sources = notes.flatMap((note) => (note.kind === 'inexact' ? [note.source] : []));
|
|
3
|
+
return sources.length === 1 || new Set(sources).size > 1;
|
|
4
|
+
}
|
|
5
|
+
export function hasChanges(root) {
|
|
6
|
+
return (root.presence !== 'both' ||
|
|
7
|
+
root.state !== 'ready' ||
|
|
8
|
+
sourceDiffers(root.notes) ||
|
|
9
|
+
root.versions.length > 0 ||
|
|
10
|
+
root.directs.length > 0 ||
|
|
11
|
+
root.importers.length > 0 ||
|
|
12
|
+
root.risk.length > 0);
|
|
13
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import semver from 'semver';
|
|
2
|
+
import { IMPORTER_PREFIX, PLACEHOLDER_PREFIX } from "../model.js";
|
|
3
|
+
import { SEVERITY_ORDER } from "../enrich/types.js";
|
|
4
|
+
import { nodeAdvisories, nodeMeta, worstSeverity } from "./enrichment-lookup.js";
|
|
5
|
+
import { compareVersions, packageVersions } from "./graph-diff.js";
|
|
6
|
+
export * from "./compare-shape.js";
|
|
7
|
+
const EXACT_SOURCES = new Set(['lockfile', 'installed']);
|
|
8
|
+
const NO_VERSIONS = new Set();
|
|
9
|
+
const TOP_DIRECTORY = '.';
|
|
10
|
+
function compareText(left, right) {
|
|
11
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
12
|
+
}
|
|
13
|
+
function versionKey(name, version) {
|
|
14
|
+
return `${name}@${version}`;
|
|
15
|
+
}
|
|
16
|
+
function indexVersions(root) {
|
|
17
|
+
const nodes = new Map();
|
|
18
|
+
for (const node of root.packages.values()) {
|
|
19
|
+
if (node.id.startsWith(PLACEHOLDER_PREFIX))
|
|
20
|
+
continue;
|
|
21
|
+
const key = versionKey(node.name, node.version);
|
|
22
|
+
if (!nodes.has(key))
|
|
23
|
+
nodes.set(key, node);
|
|
24
|
+
}
|
|
25
|
+
return nodes;
|
|
26
|
+
}
|
|
27
|
+
function sideStateOf(root) {
|
|
28
|
+
return { root, versions: packageVersions(root), nodes: indexVersions(root) };
|
|
29
|
+
}
|
|
30
|
+
function sorted(versions) {
|
|
31
|
+
return [...versions].sort(compareVersions);
|
|
32
|
+
}
|
|
33
|
+
function onlyIn(source, other) {
|
|
34
|
+
return sorted([...source].filter((version) => !other.has(version)));
|
|
35
|
+
}
|
|
36
|
+
function versionChangeKind(base, target) {
|
|
37
|
+
if (base.length === 0)
|
|
38
|
+
return 'added';
|
|
39
|
+
if (target.length === 0)
|
|
40
|
+
return 'removed';
|
|
41
|
+
if (base.length === 1 && target.length === 1 && semver.valid(base[0]) && semver.valid(target[0])) {
|
|
42
|
+
return semver.gt(target[0], base[0]) ? 'upgraded' : 'downgraded';
|
|
43
|
+
}
|
|
44
|
+
return 'changed';
|
|
45
|
+
}
|
|
46
|
+
function versionChanges(base, target) {
|
|
47
|
+
const names = [...new Set([...base.versions.keys(), ...target.versions.keys()])].sort(compareText);
|
|
48
|
+
const versions = [];
|
|
49
|
+
const baseOnly = new Map();
|
|
50
|
+
const targetOnly = new Map();
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
const baseSet = base.versions.get(name) ?? NO_VERSIONS;
|
|
53
|
+
const targetSet = target.versions.get(name) ?? NO_VERSIONS;
|
|
54
|
+
const removed = onlyIn(baseSet, targetSet);
|
|
55
|
+
const added = onlyIn(targetSet, baseSet);
|
|
56
|
+
if (removed.length === 0 && added.length === 0)
|
|
57
|
+
continue;
|
|
58
|
+
if (removed.length > 0)
|
|
59
|
+
baseOnly.set(name, removed);
|
|
60
|
+
if (added.length > 0)
|
|
61
|
+
targetOnly.set(name, added);
|
|
62
|
+
const baseVersions = sorted(baseSet);
|
|
63
|
+
const targetVersions = sorted(targetSet);
|
|
64
|
+
const change = { name, change: versionChangeKind(baseVersions, targetVersions), base: baseVersions, target: targetVersions };
|
|
65
|
+
const highest = targetVersions.at(-1);
|
|
66
|
+
const targetNode = highest === undefined ? undefined : target.nodes.get(versionKey(name, highest))?.id;
|
|
67
|
+
if (targetNode !== undefined)
|
|
68
|
+
change.targetNode = targetNode;
|
|
69
|
+
versions.push(change);
|
|
70
|
+
}
|
|
71
|
+
return { versions, baseOnly, targetOnly };
|
|
72
|
+
}
|
|
73
|
+
function allVersionsOf(side) {
|
|
74
|
+
return new Map([...side.versions].map(([name, versions]) => [name, sorted(versions)]));
|
|
75
|
+
}
|
|
76
|
+
function dependencyName(root, edge) {
|
|
77
|
+
if (edge.to.startsWith(IMPORTER_PREFIX))
|
|
78
|
+
return root.importers.find((importer) => importer.id === edge.to)?.name ?? edge.to;
|
|
79
|
+
return root.packages.get(edge.to)?.name ?? edge.to;
|
|
80
|
+
}
|
|
81
|
+
function edgesByKey(root, importer) {
|
|
82
|
+
const edges = new Map();
|
|
83
|
+
for (const edge of importer.deps) {
|
|
84
|
+
const name = dependencyName(root, edge);
|
|
85
|
+
edges.set(`${name}\0${edge.kind}`, { name, edge });
|
|
86
|
+
}
|
|
87
|
+
return edges;
|
|
88
|
+
}
|
|
89
|
+
function directChangesOf(base, target, baseImporter, targetImporter) {
|
|
90
|
+
const baseEdges = edgesByKey(base, baseImporter);
|
|
91
|
+
const targetEdges = edgesByKey(target, targetImporter);
|
|
92
|
+
const changes = [];
|
|
93
|
+
const identity = { importer: targetImporter.path, importerName: targetImporter.name };
|
|
94
|
+
for (const [key, before] of baseEdges) {
|
|
95
|
+
const after = targetEdges.get(key);
|
|
96
|
+
if (after === undefined) {
|
|
97
|
+
changes.push({ ...identity, name: before.name, kind: before.edge.kind, change: 'removed', base: before.edge.range });
|
|
98
|
+
}
|
|
99
|
+
else if (after.edge.range !== before.edge.range) {
|
|
100
|
+
changes.push({ ...identity, name: before.name, kind: before.edge.kind, change: 'changed', base: before.edge.range, target: after.edge.range, targetNode: after.edge.to });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const [key, after] of targetEdges) {
|
|
104
|
+
if (baseEdges.has(key))
|
|
105
|
+
continue;
|
|
106
|
+
changes.push({ ...identity, name: after.name, kind: after.edge.kind, change: 'added', target: after.edge.range, targetNode: after.edge.to });
|
|
107
|
+
}
|
|
108
|
+
return changes;
|
|
109
|
+
}
|
|
110
|
+
function workspaceChanges(base, target) {
|
|
111
|
+
const baseByPath = new Map(base.importers.map((importer) => [importer.path, importer]));
|
|
112
|
+
const targetByPath = new Map(target.importers.map((importer) => [importer.path, importer]));
|
|
113
|
+
const directs = [];
|
|
114
|
+
const importers = [];
|
|
115
|
+
for (const [path, importer] of baseByPath) {
|
|
116
|
+
const counterpart = targetByPath.get(path);
|
|
117
|
+
if (counterpart === undefined)
|
|
118
|
+
importers.push({ path, name: importer.name, change: 'removed' });
|
|
119
|
+
else
|
|
120
|
+
directs.push(...directChangesOf(base, target, importer, counterpart));
|
|
121
|
+
}
|
|
122
|
+
for (const [path, importer] of targetByPath) {
|
|
123
|
+
if (!baseByPath.has(path))
|
|
124
|
+
importers.push({ path, name: importer.name, change: 'added' });
|
|
125
|
+
}
|
|
126
|
+
directs.sort((left, right) => compareText(left.importer, right.importer) || compareText(left.name, right.name) || compareText(left.kind, right.kind));
|
|
127
|
+
importers.sort((left, right) => compareText(left.path, right.path));
|
|
128
|
+
return { directs, importers };
|
|
129
|
+
}
|
|
130
|
+
function severityRank(risk) {
|
|
131
|
+
return risk.severity === undefined ? SEVERITY_ORDER.length : SEVERITY_ORDER.indexOf(risk.severity);
|
|
132
|
+
}
|
|
133
|
+
function compareRisk(left, right) {
|
|
134
|
+
const byChange = left.change === right.change ? 0 : left.change === 'introduced' ? -1 : 1;
|
|
135
|
+
return byChange || severityRank(left) - severityRank(right) || compareText(left.name, right.name) || compareVersions(left.version, right.version);
|
|
136
|
+
}
|
|
137
|
+
function isPending(view, root, node) {
|
|
138
|
+
const match = view.matchOf(root, node);
|
|
139
|
+
if (match.state === 'pending' || view.registryOf(root, node).state === 'pending')
|
|
140
|
+
return true;
|
|
141
|
+
return (match.state === 'ok' || match.state === 'stale') && match.vulns.length > 0 && view.advisoriesOf(node.name).state === 'pending';
|
|
142
|
+
}
|
|
143
|
+
function riskOf(view, sides) {
|
|
144
|
+
const risk = [];
|
|
145
|
+
const pending = new Set();
|
|
146
|
+
for (const { side, only, change } of sides) {
|
|
147
|
+
for (const [name, versions] of only) {
|
|
148
|
+
for (const version of versions) {
|
|
149
|
+
const node = side.nodes.get(versionKey(name, version));
|
|
150
|
+
if (node === undefined)
|
|
151
|
+
continue;
|
|
152
|
+
if (isPending(view, side.root, node))
|
|
153
|
+
pending.add(versionKey(name, version));
|
|
154
|
+
const located = change === 'introduced' ? { targetNode: node.id } : {};
|
|
155
|
+
const advisories = nodeAdvisories(view, side.root, node);
|
|
156
|
+
if (advisories.length > 0) {
|
|
157
|
+
const severity = worstSeverity(advisories.map((advisory) => advisory.severity));
|
|
158
|
+
risk.push({ name, version, issue: 'vulnerability', change, ...(severity === undefined ? {} : { severity }), ...located });
|
|
159
|
+
}
|
|
160
|
+
if (nodeMeta(view, side.root, node)?.deprecated[version] !== undefined) {
|
|
161
|
+
risk.push({ name, version, issue: 'deprecated', change, ...located });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { risk: risk.sort(compareRisk), riskPending: pending.size };
|
|
167
|
+
}
|
|
168
|
+
function emptyComparison(rootId, presence, state, notes, exact) {
|
|
169
|
+
return { rootId, presence, state, notes, exact, versions: [], directs: [], importers: [], risk: [], riskPending: 0 };
|
|
170
|
+
}
|
|
171
|
+
function isExact(root) {
|
|
172
|
+
return EXACT_SOURCES.has(root.source);
|
|
173
|
+
}
|
|
174
|
+
function sideNotes(sides) {
|
|
175
|
+
const notes = [];
|
|
176
|
+
for (const [side, root] of sides) {
|
|
177
|
+
if (root === undefined)
|
|
178
|
+
continue;
|
|
179
|
+
if (root.status === 'failed')
|
|
180
|
+
notes.push({ side, kind: 'failed', error: root.error ?? '' });
|
|
181
|
+
else if (!isExact(root))
|
|
182
|
+
notes.push({ side, kind: 'inexact', source: root.source });
|
|
183
|
+
}
|
|
184
|
+
return notes;
|
|
185
|
+
}
|
|
186
|
+
function isInside(rootId, directories) {
|
|
187
|
+
return (directories ?? []).some((directory) => directory === TOP_DIRECTORY || rootId === directory || rootId.startsWith(`${directory}/`));
|
|
188
|
+
}
|
|
189
|
+
function skippingSide(rootId, sides, baseRoot, targetRoot) {
|
|
190
|
+
if (baseRoot === undefined && isInside(rootId, sides.base.skippedSubmodules))
|
|
191
|
+
return 'base';
|
|
192
|
+
if (targetRoot === undefined && isInside(rootId, sides.target.skippedSubmodules))
|
|
193
|
+
return 'target';
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
function compareRoot(view, rootId, base, target, skippedBy) {
|
|
197
|
+
const presence = base === undefined ? 'target-only' : target === undefined ? 'base-only' : 'both';
|
|
198
|
+
if (skippedBy !== undefined)
|
|
199
|
+
return emptyComparison(rootId, presence, 'ready', [{ side: skippedBy, kind: 'submodule' }], false);
|
|
200
|
+
const present = [base, target].filter((root) => root !== undefined);
|
|
201
|
+
const exact = present.every(isExact);
|
|
202
|
+
if (present.some((root) => root.status === 'scanning'))
|
|
203
|
+
return emptyComparison(rootId, presence, 'scanning', [], exact);
|
|
204
|
+
const notes = sideNotes([
|
|
205
|
+
['base', base],
|
|
206
|
+
['target', target],
|
|
207
|
+
]);
|
|
208
|
+
if (present.some((root) => root.status === 'failed'))
|
|
209
|
+
return emptyComparison(rootId, presence, 'failed', notes, exact);
|
|
210
|
+
const comparison = emptyComparison(rootId, presence, 'ready', notes, exact);
|
|
211
|
+
if (base !== undefined && target !== undefined) {
|
|
212
|
+
const baseState = sideStateOf(base);
|
|
213
|
+
const targetState = sideStateOf(target);
|
|
214
|
+
Object.assign(comparison, workspaceChanges(base, target));
|
|
215
|
+
if (!exact)
|
|
216
|
+
return comparison;
|
|
217
|
+
const { versions, baseOnly, targetOnly } = versionChanges(baseState, targetState);
|
|
218
|
+
comparison.versions = versions;
|
|
219
|
+
Object.assign(comparison, riskOf(view, [
|
|
220
|
+
{ side: targetState, only: targetOnly, change: 'introduced' },
|
|
221
|
+
{ side: baseState, only: baseOnly, change: 'resolved' },
|
|
222
|
+
]));
|
|
223
|
+
return comparison;
|
|
224
|
+
}
|
|
225
|
+
if (!exact)
|
|
226
|
+
return comparison;
|
|
227
|
+
const single = sideStateOf((base ?? target));
|
|
228
|
+
Object.assign(comparison, riskOf(view, [{ side: single, only: allVersionsOf(single), change: target === undefined ? 'resolved' : 'introduced' }]));
|
|
229
|
+
return comparison;
|
|
230
|
+
}
|
|
231
|
+
export function compareSources(view, base, target) {
|
|
232
|
+
const baseById = new Map(base.roots.map((root) => [root.id, root]));
|
|
233
|
+
const targetById = new Map(target.roots.map((root) => [root.id, root]));
|
|
234
|
+
const rootIds = [...new Set([...baseById.keys(), ...targetById.keys()])].sort(compareText);
|
|
235
|
+
const complete = [...base.roots, ...target.roots].every((root) => root.status !== 'scanning');
|
|
236
|
+
return {
|
|
237
|
+
revision: view.revision,
|
|
238
|
+
complete,
|
|
239
|
+
roots: rootIds.map((rootId) => {
|
|
240
|
+
const baseRoot = baseById.get(rootId);
|
|
241
|
+
const targetRoot = targetById.get(rootId);
|
|
242
|
+
return compareRoot(view, rootId, baseRoot, targetRoot, skippingSide(rootId, { base, target }, baseRoot, targetRoot));
|
|
243
|
+
}),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { IMPORTER_PREFIX } from "../model.js";
|
|
2
|
+
import { SEVERITY_ORDER } from "../enrich/types.js";
|
|
3
|
+
import { nodeAdvisories, nodeMeta, worstSeverity } from "./enrichment-lookup.js";
|
|
4
|
+
import { upwardIndex } from "./dependents.js";
|
|
5
|
+
import { ALL_KINDS, findNode } from "./lookup.js";
|
|
6
|
+
import { outdatedLevel } from "./versions.js";
|
|
7
|
+
const memo = new WeakMap();
|
|
8
|
+
const countsMemo = new WeakMap();
|
|
9
|
+
function memoKey(view, kinds) {
|
|
10
|
+
return `${view.revision}|${[...kinds].sort().join(',')}`;
|
|
11
|
+
}
|
|
12
|
+
function decorateNode(view, root, node) {
|
|
13
|
+
const decoration = {};
|
|
14
|
+
const advisories = nodeAdvisories(view, root, node);
|
|
15
|
+
if (advisories.length > 0) {
|
|
16
|
+
const vuln = {
|
|
17
|
+
severity: worstSeverity(advisories.map((advisory) => advisory.severity)),
|
|
18
|
+
count: advisories.length,
|
|
19
|
+
};
|
|
20
|
+
if (advisories.some((advisory) => advisory.malicious))
|
|
21
|
+
vuln.malicious = true;
|
|
22
|
+
decoration.vuln = vuln;
|
|
23
|
+
}
|
|
24
|
+
const meta = nodeMeta(view, root, node);
|
|
25
|
+
if (meta) {
|
|
26
|
+
if (meta.deprecated[node.version] !== undefined)
|
|
27
|
+
decoration.deprecated = true;
|
|
28
|
+
const latest = meta.distTags.latest;
|
|
29
|
+
const level = outdatedLevel(node.version, latest);
|
|
30
|
+
if (level && latest)
|
|
31
|
+
decoration.outdated = { level, latest };
|
|
32
|
+
}
|
|
33
|
+
return decoration;
|
|
34
|
+
}
|
|
35
|
+
function rollUpVulnerabilities(root, kinds, vulnerable, result) {
|
|
36
|
+
const { ids, positionOf, parentStarts, parentPositions } = upwardIndex(root, kinds);
|
|
37
|
+
const belowCounts = new Int32Array(ids.length);
|
|
38
|
+
const worstRanks = new Int8Array(ids.length);
|
|
39
|
+
const reachedBy = new Int32Array(ids.length).fill(-1);
|
|
40
|
+
const queue = new Int32Array(ids.length);
|
|
41
|
+
vulnerable.forEach(({ node, severity }, source) => {
|
|
42
|
+
const start = positionOf.get(node.id);
|
|
43
|
+
if (start === undefined)
|
|
44
|
+
return;
|
|
45
|
+
const rank = SEVERITY_ORDER.indexOf(severity);
|
|
46
|
+
reachedBy[start] = source;
|
|
47
|
+
queue[0] = start;
|
|
48
|
+
let queued = 1;
|
|
49
|
+
for (let cursor = 0; cursor < queued; cursor++) {
|
|
50
|
+
const position = queue[cursor];
|
|
51
|
+
for (let link = parentStarts[position]; link < parentStarts[position + 1]; link++) {
|
|
52
|
+
const ancestor = parentPositions[link];
|
|
53
|
+
if (reachedBy[ancestor] === source)
|
|
54
|
+
continue;
|
|
55
|
+
reachedBy[ancestor] = source;
|
|
56
|
+
queue[queued++] = ancestor;
|
|
57
|
+
if (belowCounts[ancestor] === 0 || rank < worstRanks[ancestor])
|
|
58
|
+
worstRanks[ancestor] = rank;
|
|
59
|
+
belowCounts[ancestor]++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
belowCounts.forEach((count, position) => {
|
|
64
|
+
if (count > 0)
|
|
65
|
+
(result[ids[position]] ??= {}).below = { count, worst: SEVERITY_ORDER[worstRanks[position]] };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export function decorations(view, root, kinds) {
|
|
69
|
+
const key = memoKey(view, kinds);
|
|
70
|
+
const cached = memo.get(root);
|
|
71
|
+
if (cached && cached.key === key)
|
|
72
|
+
return cached.result;
|
|
73
|
+
const result = {};
|
|
74
|
+
const vulnerable = [];
|
|
75
|
+
for (const node of root.packages.values()) {
|
|
76
|
+
const decoration = decorateNode(view, root, node);
|
|
77
|
+
if (decoration.vuln)
|
|
78
|
+
vulnerable.push({ node, severity: decoration.vuln.severity });
|
|
79
|
+
if (decoration.vuln || decoration.deprecated || decoration.outdated)
|
|
80
|
+
result[node.id] = decoration;
|
|
81
|
+
}
|
|
82
|
+
rollUpVulnerabilities(root, kinds, vulnerable, result);
|
|
83
|
+
memo.set(root, { key, result });
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
export function isDirectDependency(root, node, kinds) {
|
|
87
|
+
for (const dependentId of node.dependents) {
|
|
88
|
+
if (!dependentId.startsWith(IMPORTER_PREFIX))
|
|
89
|
+
continue;
|
|
90
|
+
const importer = findNode(root, dependentId);
|
|
91
|
+
if (importer?.deps.some((edge) => edge.to === node.id && kinds.has(edge.kind)))
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
export function issueCounts(view, root) {
|
|
97
|
+
const known = countsMemo.get(root);
|
|
98
|
+
if (known && known.revision === view.revision)
|
|
99
|
+
return known.counts;
|
|
100
|
+
const decorated = decorations(view, root, ALL_KINDS);
|
|
101
|
+
const severities = [];
|
|
102
|
+
let deprecated = 0;
|
|
103
|
+
let outdatedDirect = 0;
|
|
104
|
+
for (const node of root.packages.values()) {
|
|
105
|
+
const decoration = decorated[node.id];
|
|
106
|
+
if (!decoration)
|
|
107
|
+
continue;
|
|
108
|
+
if (decoration.vuln)
|
|
109
|
+
severities.push(decoration.vuln.severity);
|
|
110
|
+
if (decoration.deprecated)
|
|
111
|
+
deprecated++;
|
|
112
|
+
if (decoration.outdated && isDirectDependency(root, node, ALL_KINDS))
|
|
113
|
+
outdatedDirect++;
|
|
114
|
+
}
|
|
115
|
+
const counts = { vulnerable: severities.length, deprecated, outdatedDirect };
|
|
116
|
+
const worst = worstSeverity(severities);
|
|
117
|
+
if (worst)
|
|
118
|
+
counts.worst = worst;
|
|
119
|
+
countsMemo.set(root, { revision: view.revision, counts });
|
|
120
|
+
return counts;
|
|
121
|
+
}
|