Skip to content

Commit 7f6c3fe

Browse files
authored
chore: refactor node module collector, extract explicit DependencyTree, update types to be generic and respective to npm list vs pnpm list dependency trees (electron-userland#8872)
- Moves functions around so that they're topologically ordered. - Updates `types` for explicit usage of what is the result `JSON.parse` and what is used for the production tree - Removes `delete array[key]` logic by refactoring to `reduce` (no more modifying the original `JSON.parse` tree) - Extracts `JSON.parse` functionality to be specific to each collector - Extracts `DependencyTree` with initial set `implicitDependenciesInjected = false` so that the boolean isn't optional by using DFS approach - Consolidates implicit dependency injection to npm-specific logic by extracting simplified dependency tree w/o `dependencies` - Converted the abstract collector class to be generic of type: `NodeModulesCollector<T extends Dependency<T, OptionalsType>, OptionalsType>` - This way, all abstracted and overridden methods are easily typed per their respective `JSON.parse` objects. Then uses a common method to construct the dependencies into a `DependencyTree` and then internally (as before) hoisting and converting to `NodeModuleInfo[]` - This accommodates the differences of `optionalDependencies` within `pnpm list` (`PnpmDependency`) and `npm list` (`string`)
1 parent 7dbac1b commit 7f6c3fe

File tree

6 files changed

+257
-148
lines changed

6 files changed

+257
-148
lines changed

.changeset/funny-gorillas-fold.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"app-builder-lib": patch
3+
---
4+
5+
chore: refactor node module collector, extract explicit `DependencyTree`, update types to be generic and respective to `npm list` vs `pnpm list` dependency trees

.changeset/lazy-boats-love.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"app-builder-lib": patch
3+
---
4+
5+
chore: refactor node module collector to reduce recursion, extract explicit DependencyTree, update types
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,77 @@
11
import { hoist, type HoisterTree, type HoisterResult } from "./hoist"
22
import * as path from "path"
33
import * as fs from "fs"
4-
import { NodeModuleInfo, DependencyTree, DependencyGraph } from "./types"
4+
import type { NodeModuleInfo, DependencyTree, DependencyGraph, Dependency } from "./types"
55
import { exec, log } from "builder-util"
66

7-
export abstract class NodeModulesCollector {
8-
private nodeModules: NodeModuleInfo[]
9-
protected dependencyPathMap: Map<string, string>
10-
protected allDependencies: Map<string, DependencyTree> = new Map()
7+
export abstract class NodeModulesCollector<T extends Dependency<T, OptionalsType>, OptionalsType> {
8+
private nodeModules: NodeModuleInfo[] = []
9+
protected dependencyPathMap: Map<string, string> = new Map()
10+
protected allDependencies: Map<string, T> = new Map()
1111

12-
constructor(private readonly rootDir: string) {
13-
this.dependencyPathMap = new Map()
14-
this.nodeModules = []
12+
constructor(private readonly rootDir: string) {}
13+
14+
public async getNodeModules(): Promise<NodeModuleInfo[]> {
15+
const tree: T = await this.getDependenciesTree()
16+
const realTree: T = this.getTreeFromWorkspaces(tree)
17+
const parsedTree: Dependency<T, OptionalsType> = this.extractRelevantData(realTree)
18+
19+
this.collectAllDependencies(parsedTree)
20+
21+
const productionTree: DependencyTree = this.extractProductionDependencyTree(parsedTree)
22+
const dependencyGraph: DependencyGraph = this.convertToDependencyGraph(productionTree)
23+
24+
const hoisterResult: HoisterResult = hoist(this.transToHoisterTree(dependencyGraph), { check: true })
25+
this._getNodeModules(hoisterResult.dependencies, this.nodeModules)
26+
27+
return this.nodeModules
1528
}
1629

17-
private transToHoisterTree(obj: DependencyGraph, key: string = `.`, nodes: Map<string, HoisterTree> = new Map()): HoisterTree {
18-
let node = nodes.get(key)
19-
const name = key.match(/@?[^@]+/)![0]
20-
if (!node) {
21-
node = {
22-
name,
23-
identName: name,
24-
reference: key.match(/@?[^@]+@?(.+)?/)![1] || ``,
25-
dependencies: new Set<HoisterTree>(),
26-
peerNames: new Set<string>([]),
27-
}
28-
nodes.set(key, node)
30+
protected abstract getCommand(): string
31+
protected abstract getArgs(): string[]
32+
protected abstract parseDependenciesTree(jsonBlob: string): T
33+
protected abstract extractProductionDependencyTree(tree: Dependency<T, OptionalsType>): DependencyTree
2934

30-
for (const dep of (obj[key] || {}).dependencies || []) {
31-
node.dependencies.add(this.transToHoisterTree(obj, dep, nodes))
32-
}
35+
protected async getDependenciesTree(): Promise<T> {
36+
const command = this.getCommand()
37+
const args = this.getArgs()
38+
const dependencies = await exec(command, args, {
39+
cwd: this.rootDir,
40+
shell: true,
41+
})
42+
return this.parseDependenciesTree(dependencies)
43+
}
44+
45+
protected extractRelevantData(npmTree: T): Dependency<T, OptionalsType> {
46+
// Do not use `...npmTree` as we are explicitly extracting the data we need
47+
const { name, version, path, workspaces, dependencies } = npmTree
48+
const tree: Dependency<T, OptionalsType> = {
49+
name,
50+
version,
51+
path,
52+
workspaces,
53+
// DFS extract subtree
54+
dependencies: this.extractInternal(dependencies),
3355
}
34-
return node
56+
57+
return tree
3558
}
3659

37-
protected resolvePath(filePath: string) {
60+
protected extractInternal(deps: T["dependencies"]): T["dependencies"] {
61+
return deps && Object.keys(deps).length > 0
62+
? Object.entries(deps).reduce((accum, [packageName, depObjectOrVersionString]) => {
63+
return {
64+
...accum,
65+
[packageName]:
66+
typeof depObjectOrVersionString === "object" && Object.keys(depObjectOrVersionString).length > 0
67+
? this.extractRelevantData(depObjectOrVersionString)
68+
: depObjectOrVersionString,
69+
}
70+
}, {})
71+
: undefined
72+
}
73+
74+
protected resolvePath(filePath: string): string {
3875
try {
3976
const stats = fs.lstatSync(filePath)
4077
if (stats.isSymbolicLink()) {
@@ -48,70 +85,89 @@ export abstract class NodeModulesCollector {
4885
}
4986
}
5087

51-
private convertToDependencyGraph(tree: DependencyTree): DependencyGraph {
52-
const result: DependencyGraph = { ".": {} }
53-
54-
const flatten = (node: DependencyTree, parentKey = ".") => {
55-
const dependencies = node.dependencies || {}
56-
57-
for (const [key, value] of Object.entries(dependencies)) {
58-
// Skip empty dependencies(like some optionalDependencies)
59-
if (Object.keys(value).length === 0) {
60-
continue
61-
}
62-
const version = value.version || ""
63-
const newKey = `${key}@${version}`
64-
this.dependencyPathMap.set(newKey, path.normalize(this.resolvePath(value.path)))
65-
if (!result[parentKey]?.dependencies) {
66-
result[parentKey] = { dependencies: [] }
67-
}
68-
result[parentKey].dependencies!.push(newKey)
69-
70-
if (node.__circularDependencyDetected) {
71-
continue
72-
}
73-
flatten(value, newKey)
88+
private convertToDependencyGraph(tree: DependencyTree, parentKey = "."): DependencyGraph {
89+
return Object.entries(tree.dependencies || {}).reduce<DependencyGraph>((acc, curr) => {
90+
const [packageName, dependencies] = curr
91+
// Skip empty dependencies (like some optionalDependencies)
92+
if (Object.keys(dependencies).length === 0) {
93+
return acc
94+
}
95+
const version = dependencies.version || ""
96+
const newKey = `${packageName}@${version}`
97+
if (!dependencies.path) {
98+
log.error(
99+
{
100+
packageName,
101+
data: dependencies,
102+
parentModule: tree.name,
103+
parentVersion: tree.version,
104+
},
105+
"dependency path is undefined"
106+
)
107+
throw new Error("unable to parse `path` during `tree.dependencies` reduce")
108+
}
109+
// Map dependency details: name, version and path to the dependency tree
110+
this.dependencyPathMap.set(newKey, path.normalize(this.resolvePath(dependencies.path)))
111+
if (!acc[parentKey]) {
112+
acc[parentKey] = { dependencies: [] }
113+
}
114+
acc[parentKey].dependencies.push(newKey)
115+
if (tree.implicitDependenciesInjected) {
116+
log.debug(
117+
{
118+
dependency: packageName,
119+
version,
120+
path: dependencies.path,
121+
parentModule: tree.name,
122+
parentVersion: tree.version,
123+
},
124+
"converted implicit dependency"
125+
)
126+
return acc
74127
}
75-
}
76128

77-
flatten(tree)
78-
return result
129+
return { ...acc, ...this.convertToDependencyGraph(dependencies, newKey) }
130+
}, {})
79131
}
80132

81-
getAllDependencies(tree: DependencyTree) {
82-
const dependencies = tree.dependencies || {}
83-
for (const [key, value] of Object.entries(dependencies)) {
84-
if (value.dependencies && Object.keys(value.dependencies).length > 0) {
133+
private collectAllDependencies(tree: Dependency<T, OptionalsType>) {
134+
for (const [key, value] of Object.entries(tree.dependencies || {})) {
135+
if (Object.keys(value.dependencies ?? {}).length > 0) {
85136
this.allDependencies.set(`${key}@${value.version}`, value)
86-
this.getAllDependencies(value)
137+
this.collectAllDependencies(value)
87138
}
88139
}
89140
}
90141

91-
abstract getCommand(): string
92-
abstract getArgs(): string[]
93-
abstract removeNonProductionDependencie(tree: DependencyTree): void
142+
private getTreeFromWorkspaces(tree: T): T {
143+
if (tree.workspaces && tree.dependencies) {
144+
for (const [key, value] of Object.entries(tree.dependencies)) {
145+
if (this.rootDir.endsWith(path.normalize(key))) {
146+
return value
147+
}
148+
}
149+
}
150+
return tree
151+
}
94152

95-
protected async getDependenciesTree(): Promise<DependencyTree> {
96-
const command = this.getCommand()
97-
const args = this.getArgs()
98-
const dependencies = await exec(command, args, {
99-
cwd: this.rootDir,
100-
shell: true,
101-
})
102-
const dependencyTree: DependencyTree | DependencyTree[] = JSON.parse(dependencies)
153+
private transToHoisterTree(obj: DependencyGraph, key: string = `.`, nodes: Map<string, HoisterTree> = new Map()): HoisterTree {
154+
let node = nodes.get(key)
155+
const name = key.match(/@?[^@]+/)![0]
156+
if (!node) {
157+
node = {
158+
name,
159+
identName: name,
160+
reference: key.match(/@?[^@]+@?(.+)?/)![1] || ``,
161+
dependencies: new Set<HoisterTree>(),
162+
peerNames: new Set<string>([]),
163+
}
164+
nodes.set(key, node)
103165

104-
// pnpm returns an array of dependency trees
105-
if (Array.isArray(dependencyTree)) {
106-
const tree = dependencyTree[0]
107-
if (tree.optionalDependencies) {
108-
tree.dependencies = { ...tree.dependencies, ...tree.optionalDependencies }
166+
for (const dep of (obj[key] || {}).dependencies || []) {
167+
node.dependencies.add(this.transToHoisterTree(obj, dep, nodes))
109168
}
110-
return tree
111169
}
112-
113-
// yarn and npm return a single dependency tree
114-
return dependencyTree
170+
return node
115171
}
116172

117173
private _getNodeModules(dependencies: Set<HoisterResult>, result: NodeModuleInfo[]) {
@@ -133,32 +189,10 @@ export abstract class NodeModulesCollector {
133189
}
134190
result.push(node)
135191
if (d.dependencies.size > 0) {
136-
node["dependencies"] = []
137-
this._getNodeModules(d.dependencies, node["dependencies"])
192+
node.dependencies = []
193+
this._getNodeModules(d.dependencies, node.dependencies)
138194
}
139195
}
140196
result.sort((a, b) => a.name.localeCompare(b.name))
141197
}
142-
143-
private getTreeFromWorkspaces(tree: DependencyTree): DependencyTree {
144-
if (tree.workspaces && tree.dependencies) {
145-
for (const [key, value] of Object.entries(tree.dependencies)) {
146-
if (this.rootDir.endsWith(path.normalize(key))) {
147-
return value
148-
}
149-
}
150-
}
151-
return tree
152-
}
153-
154-
public async getNodeModules(): Promise<NodeModuleInfo[]> {
155-
const tree = await this.getDependenciesTree()
156-
const realTree = this.getTreeFromWorkspaces(tree)
157-
this.getAllDependencies(realTree)
158-
this.removeNonProductionDependencie(realTree)
159-
const dependencyGraph = this.convertToDependencyGraph(realTree)
160-
const hoisterResult = hoist(this.transToHoisterTree(dependencyGraph), { check: true })
161-
this._getNodeModules(hoisterResult.dependencies, this.nodeModules)
162-
return this.nodeModules
163-
}
164198
}
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { NodeModulesCollector } from "./nodeModulesCollector"
2-
import { DependencyTree } from "./types"
2+
import { DependencyTree, NpmDependency, ParsedDependencyTree } from "./types"
33
import { log } from "builder-util"
44

5-
export class NpmNodeModulesCollector extends NodeModulesCollector {
5+
export class NpmNodeModulesCollector extends NodeModulesCollector<NpmDependency, string> {
66
constructor(rootDir: string) {
77
super(rootDir)
88
}
@@ -15,22 +15,56 @@ export class NpmNodeModulesCollector extends NodeModulesCollector {
1515
return ["list", "-a", "--include", "prod", "--include", "optional", "--omit", "dev", "--json", "--long", "--silent"]
1616
}
1717

18-
removeNonProductionDependencie(tree: DependencyTree) {
19-
const dependencies = tree.dependencies || {}
20-
const _dependencies = tree._dependencies || {}
21-
if (Object.keys(_dependencies).length > 0 && Object.keys(dependencies).length === 0) {
22-
tree.dependencies = this.allDependencies.get(`${tree.name}@${tree.version}`)?.dependencies || {}
23-
tree.__circularDependencyDetected = true
24-
log.debug({ name: tree.name, version: tree.version }, "circular dependency detected")
25-
return
18+
protected extractRelevantData(npmTree: NpmDependency): NpmDependency {
19+
const tree = super.extractRelevantData(npmTree)
20+
const { optionalDependencies, _dependencies } = npmTree
21+
return { ...tree, optionalDependencies, _dependencies }
22+
}
23+
24+
protected extractProductionDependencyTree(tree: NpmDependency): DependencyTree {
25+
const _deps = tree._dependencies ?? {}
26+
27+
let deps = tree.dependencies ?? {}
28+
let implicitDependenciesInjected = false
29+
30+
if (Object.keys(_deps).length > 0 && Object.keys(deps).length === 0) {
31+
log.debug({ name: tree.name, version: tree.version }, "injecting implicit _dependencies")
32+
deps = this.allDependencies.get(`${tree.name}@${tree.version}`)?.dependencies ?? {}
33+
implicitDependenciesInjected = true
2634
}
2735

28-
for (const [key, value] of Object.entries(dependencies)) {
29-
if (!_dependencies[key] || Object.keys(value).length === 0) {
30-
delete dependencies[key]
31-
continue
36+
const dependencies = Object.entries(deps).reduce<DependencyTree["dependencies"]>((acc, curr) => {
37+
const [packageName, dependency] = curr
38+
if (!_deps[packageName] || Object.keys(dependency).length === 0) {
39+
return acc
40+
}
41+
if (implicitDependenciesInjected) {
42+
const { name, version, path, workspaces } = dependency
43+
const simplifiedTree: ParsedDependencyTree = { name, version, path, workspaces }
44+
return {
45+
...acc,
46+
[packageName]: { ...simplifiedTree, implicitDependenciesInjected },
47+
}
3248
}
33-
this.removeNonProductionDependencie(value)
49+
return {
50+
...acc,
51+
[packageName]: this.extractProductionDependencyTree(dependency),
52+
}
53+
}, {})
54+
55+
const { name, version, path: packagePath, workspaces } = tree
56+
const depTree: DependencyTree = {
57+
name,
58+
version,
59+
path: packagePath,
60+
workspaces,
61+
dependencies,
62+
implicitDependenciesInjected,
3463
}
64+
return depTree
65+
}
66+
67+
protected parseDependenciesTree(jsonBlob: string): NpmDependency {
68+
return JSON.parse(jsonBlob)
3569
}
3670
}

0 commit comments

Comments
 (0)