pm-dedup-core 2.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 +105 -0
- package/dist/buildIdentifiedFixesMap.js +6 -0
- package/dist/buildIdentifiedFixesMap.js.map +1 -0
- package/dist/buildLockstepClusters.js +119 -0
- package/dist/buildLockstepClusters.js.map +1 -0
- package/dist/buildLockstepClusters.test.js +71 -0
- package/dist/buildLockstepClusters.test.js.map +1 -0
- package/dist/clusterLabel.js +59 -0
- package/dist/clusterLabel.js.map +1 -0
- package/dist/clusterLabel.test.js +56 -0
- package/dist/clusterLabel.test.js.map +1 -0
- package/dist/compareCandidateVersions.js +18 -0
- package/dist/compareCandidateVersions.js.map +1 -0
- package/dist/compareCandidateVersions.test.js +32 -0
- package/dist/compareCandidateVersions.test.js.map +1 -0
- package/dist/createPackageFilter.js +81 -0
- package/dist/createPackageFilter.js.map +1 -0
- package/dist/createPackageFilter.test.js +115 -0
- package/dist/createPackageFilter.test.js.map +1 -0
- package/dist/definitions/buildIdentifiedFixesMap.d.ts +4 -0
- package/dist/definitions/buildIdentifiedFixesMap.d.ts.map +1 -0
- package/dist/definitions/buildLockstepClusters.d.ts +8 -0
- package/dist/definitions/buildLockstepClusters.d.ts.map +1 -0
- package/dist/definitions/buildLockstepClusters.test.d.ts +2 -0
- package/dist/definitions/buildLockstepClusters.test.d.ts.map +1 -0
- package/dist/definitions/clusterLabel.d.ts +8 -0
- package/dist/definitions/clusterLabel.d.ts.map +1 -0
- package/dist/definitions/clusterLabel.test.d.ts +2 -0
- package/dist/definitions/clusterLabel.test.d.ts.map +1 -0
- package/dist/definitions/compareCandidateVersions.d.ts +10 -0
- package/dist/definitions/compareCandidateVersions.d.ts.map +1 -0
- package/dist/definitions/compareCandidateVersions.test.d.ts +2 -0
- package/dist/definitions/compareCandidateVersions.test.d.ts.map +1 -0
- package/dist/definitions/createPackageFilter.d.ts +45 -0
- package/dist/definitions/createPackageFilter.d.ts.map +1 -0
- package/dist/definitions/createPackageFilter.test.d.ts +2 -0
- package/dist/definitions/createPackageFilter.test.d.ts.map +1 -0
- package/dist/definitions/duplicateSnapshot.d.ts +8 -0
- package/dist/definitions/duplicateSnapshot.d.ts.map +1 -0
- package/dist/definitions/fileSnapshot.d.ts +7 -0
- package/dist/definitions/fileSnapshot.d.ts.map +1 -0
- package/dist/definitions/findProjectRoot.d.ts +27 -0
- package/dist/definitions/findProjectRoot.d.ts.map +1 -0
- package/dist/definitions/findProjectRoot.test.d.ts +2 -0
- package/dist/definitions/findProjectRoot.test.d.ts.map +1 -0
- package/dist/definitions/identifyLockstepClusterFixes.d.ts +89 -0
- package/dist/definitions/identifyLockstepClusterFixes.d.ts.map +1 -0
- package/dist/definitions/identifyLockstepClusterFixes.test.d.ts +2 -0
- package/dist/definitions/identifyLockstepClusterFixes.test.d.ts.map +1 -0
- package/dist/definitions/identifyResolutionFixes.d.ts +28 -0
- package/dist/definitions/identifyResolutionFixes.d.ts.map +1 -0
- package/dist/definitions/identifyResolutionFixes.test.d.ts +2 -0
- package/dist/definitions/identifyResolutionFixes.test.d.ts.map +1 -0
- package/dist/definitions/index.d.ts +44 -0
- package/dist/definitions/index.d.ts.map +1 -0
- package/dist/definitions/packageDependenciesUtils.d.ts +47 -0
- package/dist/definitions/packageDependenciesUtils.d.ts.map +1 -0
- package/dist/definitions/packageDependenciesUtils.test.d.ts +2 -0
- package/dist/definitions/packageDependenciesUtils.test.d.ts.map +1 -0
- package/dist/definitions/packageFilterArgs.d.ts +74 -0
- package/dist/definitions/packageFilterArgs.d.ts.map +1 -0
- package/dist/definitions/packageFilterArgs.test.d.ts +2 -0
- package/dist/definitions/packageFilterArgs.test.d.ts.map +1 -0
- package/dist/definitions/packageStyles.d.ts +14 -0
- package/dist/definitions/packageStyles.d.ts.map +1 -0
- package/dist/definitions/packageStyles.test.d.ts +2 -0
- package/dist/definitions/packageStyles.test.d.ts.map +1 -0
- package/dist/definitions/parseBinArgs.d.ts +14 -0
- package/dist/definitions/parseBinArgs.d.ts.map +1 -0
- package/dist/definitions/parseBinArgs.test.d.ts +2 -0
- package/dist/definitions/parseBinArgs.test.d.ts.map +1 -0
- package/dist/definitions/planClusterApply.d.ts +39 -0
- package/dist/definitions/planClusterApply.d.ts.map +1 -0
- package/dist/definitions/planClusterApply.test.d.ts +2 -0
- package/dist/definitions/planClusterApply.test.d.ts.map +1 -0
- package/dist/definitions/renderApplyPlan.d.ts +22 -0
- package/dist/definitions/renderApplyPlan.d.ts.map +1 -0
- package/dist/definitions/renderApplyPlan.test.d.ts +2 -0
- package/dist/definitions/renderApplyPlan.test.d.ts.map +1 -0
- package/dist/definitions/renderDedupeSummary.d.ts +22 -0
- package/dist/definitions/renderDedupeSummary.d.ts.map +1 -0
- package/dist/definitions/renderDedupeSummary.test.d.ts +2 -0
- package/dist/definitions/renderDedupeSummary.test.d.ts.map +1 -0
- package/dist/definitions/renderDuplicatesReport.d.ts +39 -0
- package/dist/definitions/renderDuplicatesReport.d.ts.map +1 -0
- package/dist/definitions/renderDuplicatesReport.test.d.ts +2 -0
- package/dist/definitions/renderDuplicatesReport.test.d.ts.map +1 -0
- package/dist/definitions/reportColors.d.ts +25 -0
- package/dist/definitions/reportColors.d.ts.map +1 -0
- package/dist/definitions/reportColors.test.d.ts +2 -0
- package/dist/definitions/reportColors.test.d.ts.map +1 -0
- package/dist/definitions/reportText.d.ts +29 -0
- package/dist/definitions/reportText.d.ts.map +1 -0
- package/dist/definitions/selectExplainedPackages.d.ts +22 -0
- package/dist/definitions/selectExplainedPackages.d.ts.map +1 -0
- package/dist/definitions/selectExplainedPackages.test.d.ts +2 -0
- package/dist/definitions/selectExplainedPackages.test.d.ts.map +1 -0
- package/dist/definitions/unconditionalOverrides.d.ts +24 -0
- package/dist/definitions/unconditionalOverrides.d.ts.map +1 -0
- package/dist/definitions/unconditionalOverrides.test.d.ts +2 -0
- package/dist/definitions/unconditionalOverrides.test.d.ts.map +1 -0
- package/dist/definitions/versionsSnapshot.d.ts +40 -0
- package/dist/definitions/versionsSnapshot.d.ts.map +1 -0
- package/dist/definitions/versionsSnapshot.test.d.ts +2 -0
- package/dist/definitions/versionsSnapshot.test.d.ts.map +1 -0
- package/dist/definitions/workspaceManifest.d.ts +21 -0
- package/dist/definitions/workspaceManifest.d.ts.map +1 -0
- package/dist/definitions/workspaceManifest.test.d.ts +2 -0
- package/dist/definitions/workspaceManifest.test.d.ts.map +1 -0
- package/dist/duplicateSnapshot.js +10 -0
- package/dist/duplicateSnapshot.js.map +1 -0
- package/dist/fileSnapshot.js +21 -0
- package/dist/fileSnapshot.js.map +1 -0
- package/dist/findProjectRoot.js +48 -0
- package/dist/findProjectRoot.js.map +1 -0
- package/dist/findProjectRoot.test.js +53 -0
- package/dist/findProjectRoot.test.js.map +1 -0
- package/dist/identifyLockstepClusterFixes.js +352 -0
- package/dist/identifyLockstepClusterFixes.js.map +1 -0
- package/dist/identifyLockstepClusterFixes.test.js +372 -0
- package/dist/identifyLockstepClusterFixes.test.js.map +1 -0
- package/dist/identifyResolutionFixes.js +102 -0
- package/dist/identifyResolutionFixes.js.map +1 -0
- package/dist/identifyResolutionFixes.test.js +70 -0
- package/dist/identifyResolutionFixes.test.js.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/packageDependenciesUtils.js +91 -0
- package/dist/packageDependenciesUtils.js.map +1 -0
- package/dist/packageDependenciesUtils.test.js +539 -0
- package/dist/packageDependenciesUtils.test.js.map +1 -0
- package/dist/packageFilterArgs.js +65 -0
- package/dist/packageFilterArgs.js.map +1 -0
- package/dist/packageFilterArgs.test.js +93 -0
- package/dist/packageFilterArgs.test.js.map +1 -0
- package/dist/packageStyles.js +33 -0
- package/dist/packageStyles.js.map +1 -0
- package/dist/packageStyles.test.js +58 -0
- package/dist/packageStyles.test.js.map +1 -0
- package/dist/parseBinArgs.js +28 -0
- package/dist/parseBinArgs.js.map +1 -0
- package/dist/parseBinArgs.test.js +62 -0
- package/dist/parseBinArgs.test.js.map +1 -0
- package/dist/planClusterApply.js +69 -0
- package/dist/planClusterApply.js.map +1 -0
- package/dist/planClusterApply.test.js +152 -0
- package/dist/planClusterApply.test.js.map +1 -0
- package/dist/renderApplyPlan.js +51 -0
- package/dist/renderApplyPlan.js.map +1 -0
- package/dist/renderApplyPlan.test.js +106 -0
- package/dist/renderApplyPlan.test.js.map +1 -0
- package/dist/renderDedupeSummary.js +38 -0
- package/dist/renderDedupeSummary.js.map +1 -0
- package/dist/renderDedupeSummary.test.js +91 -0
- package/dist/renderDedupeSummary.test.js.map +1 -0
- package/dist/renderDuplicatesReport.js +375 -0
- package/dist/renderDuplicatesReport.js.map +1 -0
- package/dist/renderDuplicatesReport.test.js +598 -0
- package/dist/renderDuplicatesReport.test.js.map +1 -0
- package/dist/reportColors.js +51 -0
- package/dist/reportColors.js.map +1 -0
- package/dist/reportColors.test.js +49 -0
- package/dist/reportColors.test.js.map +1 -0
- package/dist/reportText.js +29 -0
- package/dist/reportText.js.map +1 -0
- package/dist/selectExplainedPackages.js +42 -0
- package/dist/selectExplainedPackages.js.map +1 -0
- package/dist/selectExplainedPackages.test.js +55 -0
- package/dist/selectExplainedPackages.test.js.map +1 -0
- package/dist/unconditionalOverrides.js +36 -0
- package/dist/unconditionalOverrides.js.map +1 -0
- package/dist/unconditionalOverrides.test.js +71 -0
- package/dist/unconditionalOverrides.test.js.map +1 -0
- package/dist/versionsSnapshot.js +49 -0
- package/dist/versionsSnapshot.js.map +1 -0
- package/dist/versionsSnapshot.test.js +79 -0
- package/dist/versionsSnapshot.test.js.map +1 -0
- package/dist/workspaceManifest.js +63 -0
- package/dist/workspaceManifest.js.map +1 -0
- package/dist/workspaceManifest.test.js +159 -0
- package/dist/workspaceManifest.test.js.map +1 -0
- package/package.json +68 -0
- package/src/buildIdentifiedFixesMap.ts +22 -0
- package/src/buildLockstepClusters.test.ts +90 -0
- package/src/buildLockstepClusters.ts +155 -0
- package/src/clusterLabel.test.ts +79 -0
- package/src/clusterLabel.ts +75 -0
- package/src/compareCandidateVersions.test.ts +39 -0
- package/src/compareCandidateVersions.ts +23 -0
- package/src/createPackageFilter.test.ts +156 -0
- package/src/createPackageFilter.ts +142 -0
- package/src/duplicateSnapshot.ts +24 -0
- package/src/fileSnapshot.ts +28 -0
- package/src/findProjectRoot.test.ts +66 -0
- package/src/findProjectRoot.ts +61 -0
- package/src/identifyLockstepClusterFixes.test.ts +462 -0
- package/src/identifyLockstepClusterFixes.ts +602 -0
- package/src/identifyResolutionFixes.test.ts +133 -0
- package/src/identifyResolutionFixes.ts +183 -0
- package/src/index.ts +123 -0
- package/src/packageDependenciesUtils.test.ts +593 -0
- package/src/packageDependenciesUtils.ts +156 -0
- package/src/packageFilterArgs.test.ts +116 -0
- package/src/packageFilterArgs.ts +98 -0
- package/src/packageStyles.test.ts +77 -0
- package/src/packageStyles.ts +42 -0
- package/src/parseBinArgs.test.ts +73 -0
- package/src/parseBinArgs.ts +33 -0
- package/src/planClusterApply.test.ts +163 -0
- package/src/planClusterApply.ts +105 -0
- package/src/renderApplyPlan.test.ts +136 -0
- package/src/renderApplyPlan.ts +95 -0
- package/src/renderDedupeSummary.test.ts +121 -0
- package/src/renderDedupeSummary.ts +78 -0
- package/src/renderDuplicatesReport.test.ts +724 -0
- package/src/renderDuplicatesReport.ts +645 -0
- package/src/reportColors.test.ts +57 -0
- package/src/reportColors.ts +78 -0
- package/src/reportText.ts +58 -0
- package/src/selectExplainedPackages.test.ts +67 -0
- package/src/selectExplainedPackages.ts +79 -0
- package/src/unconditionalOverrides.test.ts +99 -0
- package/src/unconditionalOverrides.ts +60 -0
- package/src/versionsSnapshot.test.ts +146 -0
- package/src/versionsSnapshot.ts +93 -0
- package/src/workspaceManifest.test.ts +220 -0
- package/src/workspaceManifest.ts +100 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it } from "bun:test";
|
|
2
|
+
import { strictEqual } from "node:assert/strict";
|
|
3
|
+
import { applyWorkspaceRangeEdit, nextSelector } from "./workspaceManifest.js";
|
|
4
|
+
const manifest = [
|
|
5
|
+
"{",
|
|
6
|
+
' "name": "root",',
|
|
7
|
+
' "dependencies": {',
|
|
8
|
+
' "metro": "0.84.5"',
|
|
9
|
+
" },",
|
|
10
|
+
' "devDependencies": {',
|
|
11
|
+
' "metro": "^0.84.5",',
|
|
12
|
+
' "lodash.merge": "~4.6.0",',
|
|
13
|
+
' "aliased": "npm:metro@^0.84.5"',
|
|
14
|
+
" }",
|
|
15
|
+
"}",
|
|
16
|
+
"",
|
|
17
|
+
].join("\n");
|
|
18
|
+
// a block whose other declarations carry protocols the parser has no list for:
|
|
19
|
+
// they have to parse so the target rewrite is not aborted by a neighbour
|
|
20
|
+
const manifestWithProtocols = [
|
|
21
|
+
"{",
|
|
22
|
+
' "name": "root",',
|
|
23
|
+
' "dependencies": {',
|
|
24
|
+
' "local": "workspace:^1.0.0",',
|
|
25
|
+
' "vendored": "someproto:whatever",',
|
|
26
|
+
' "forked": "git+ssh://git@host/r.git#v1",',
|
|
27
|
+
' "metro": "^0.84.5"',
|
|
28
|
+
" }",
|
|
29
|
+
"}",
|
|
30
|
+
"",
|
|
31
|
+
].join("\n");
|
|
32
|
+
describe("nextSelector", () => {
|
|
33
|
+
it("keeps a caret or tilde on the target line", () => {
|
|
34
|
+
strictEqual(nextSelector("^0.84.5", "0.87.0"), "^0.87.0");
|
|
35
|
+
strictEqual(nextSelector("~4.6.0", "4.7.1"), "~4.7.1");
|
|
36
|
+
});
|
|
37
|
+
it("pins exactly when the range has no target-line equivalent", () => {
|
|
38
|
+
strictEqual(nextSelector("0.84.5", "0.87.0"), "0.87.0");
|
|
39
|
+
strictEqual(nextSelector("*", "0.87.0"), "0.87.0");
|
|
40
|
+
strictEqual(nextSelector("0.83 - 0.86", "0.87.0"), "0.87.0");
|
|
41
|
+
strictEqual(nextSelector("^0.83 - 0.86", "0.87.0"), "0.87.0");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("applyWorkspaceRangeEdit", () => {
|
|
45
|
+
it("edits only the declaration in the requested depType", () => {
|
|
46
|
+
strictEqual(applyWorkspaceRangeEdit(manifest, {
|
|
47
|
+
packageName: "metro",
|
|
48
|
+
depType: "devDependencies",
|
|
49
|
+
range: "^0.84.5",
|
|
50
|
+
to: "0.87.0",
|
|
51
|
+
}), manifest.replace('"metro": "^0.84.5"', '"metro": "^0.87.0"'));
|
|
52
|
+
});
|
|
53
|
+
it("edits the exact pin in dependencies without touching devDependencies", () => {
|
|
54
|
+
strictEqual(applyWorkspaceRangeEdit(manifest, {
|
|
55
|
+
packageName: "metro",
|
|
56
|
+
depType: "dependencies",
|
|
57
|
+
range: "0.84.5",
|
|
58
|
+
to: "0.87.0",
|
|
59
|
+
}), manifest.replace('"metro": "0.84.5"', '"metro": "0.87.0"'));
|
|
60
|
+
});
|
|
61
|
+
it("keeps the npm: alias prefix", () => {
|
|
62
|
+
strictEqual(applyWorkspaceRangeEdit(manifest, {
|
|
63
|
+
packageName: "metro",
|
|
64
|
+
depType: "devDependencies",
|
|
65
|
+
range: "^0.84.5",
|
|
66
|
+
to: "0.87.0",
|
|
67
|
+
})?.includes('"aliased": "npm:metro@^0.84.5"'), true);
|
|
68
|
+
strictEqual(applyWorkspaceRangeEdit(manifest, {
|
|
69
|
+
packageName: "metro",
|
|
70
|
+
depType: "devDependencies",
|
|
71
|
+
range: "^0.84.5",
|
|
72
|
+
to: "0.87.0",
|
|
73
|
+
})?.includes('"metro": "^0.87.0"'), true);
|
|
74
|
+
});
|
|
75
|
+
it("matches a dotted name literally", () => {
|
|
76
|
+
strictEqual(applyWorkspaceRangeEdit(manifest, {
|
|
77
|
+
packageName: "lodash.merge",
|
|
78
|
+
depType: "devDependencies",
|
|
79
|
+
range: "~4.6.0",
|
|
80
|
+
to: "4.7.1",
|
|
81
|
+
}), manifest.replace('"lodash.merge": "~4.6.0"', '"lodash.merge": "~4.7.1"'));
|
|
82
|
+
});
|
|
83
|
+
it("rewrites past siblings declared with protocols it does not know", () => {
|
|
84
|
+
strictEqual(applyWorkspaceRangeEdit(manifestWithProtocols, {
|
|
85
|
+
packageName: "metro",
|
|
86
|
+
depType: "dependencies",
|
|
87
|
+
range: "^0.84.5",
|
|
88
|
+
to: "0.87.0",
|
|
89
|
+
}), manifestWithProtocols.replace('"metro": "^0.84.5"', '"metro": "^0.87.0"'));
|
|
90
|
+
});
|
|
91
|
+
// the range reaches here already stripped of its protocol, and `stringify`
|
|
92
|
+
// puts it back: rewriting `workspace:^1.0.0` to a bare `2.0.0` would change
|
|
93
|
+
// which package the declaration points at
|
|
94
|
+
it("keeps a workspace protocol on the rewritten declaration", () => {
|
|
95
|
+
strictEqual(applyWorkspaceRangeEdit(manifestWithProtocols, {
|
|
96
|
+
packageName: "local",
|
|
97
|
+
depType: "dependencies",
|
|
98
|
+
range: "^1.0.0",
|
|
99
|
+
to: "2.0.0",
|
|
100
|
+
}), manifestWithProtocols.replace('"local": "workspace:^1.0.0"', '"local": "workspace:^2.0.0"'));
|
|
101
|
+
});
|
|
102
|
+
it("edits an aliased declaration through its own key", () => {
|
|
103
|
+
strictEqual(applyWorkspaceRangeEdit([
|
|
104
|
+
"{",
|
|
105
|
+
' "dependencies": {',
|
|
106
|
+
' "aliased": "npm:metro@^0.84.5"',
|
|
107
|
+
" }",
|
|
108
|
+
"}",
|
|
109
|
+
"",
|
|
110
|
+
].join("\n"), {
|
|
111
|
+
packageName: "metro",
|
|
112
|
+
depType: "dependencies",
|
|
113
|
+
range: "^0.84.5",
|
|
114
|
+
to: "0.87.0",
|
|
115
|
+
})?.includes('"aliased": "npm:metro@^0.87.0"'), true);
|
|
116
|
+
});
|
|
117
|
+
// The manifest key `typescript` is an alias for another package entirely,
|
|
118
|
+
// while the real `typescript` is reached through `@typescript/native`. Keying
|
|
119
|
+
// the rewrite on the package name would edit the wrong line in both
|
|
120
|
+
// directions.
|
|
121
|
+
it("edits the key an alias reaches the package through, not its namesake", () => {
|
|
122
|
+
const swapped = [
|
|
123
|
+
"{",
|
|
124
|
+
' "devDependencies": {',
|
|
125
|
+
' "@typescript/native": "npm:typescript@^7.0.0",',
|
|
126
|
+
' "typescript": "npm:@typescript/typescript6@6.0.2"',
|
|
127
|
+
" }",
|
|
128
|
+
"}",
|
|
129
|
+
"",
|
|
130
|
+
].join("\n");
|
|
131
|
+
strictEqual(applyWorkspaceRangeEdit(swapped, {
|
|
132
|
+
packageName: "typescript",
|
|
133
|
+
depType: "devDependencies",
|
|
134
|
+
range: "^7.0.0",
|
|
135
|
+
to: "7.1.0",
|
|
136
|
+
}), swapped.replace('"npm:typescript@^7.0.0"', '"npm:typescript@^7.1.0"'));
|
|
137
|
+
strictEqual(applyWorkspaceRangeEdit(swapped, {
|
|
138
|
+
packageName: "@typescript/typescript6",
|
|
139
|
+
depType: "devDependencies",
|
|
140
|
+
range: "6.0.2",
|
|
141
|
+
to: "6.1.0",
|
|
142
|
+
}), swapped.replace('"npm:@typescript/typescript6@6.0.2"', '"npm:@typescript/typescript6@6.1.0"'));
|
|
143
|
+
});
|
|
144
|
+
it("reports a declaration that moved on since the lockfile was read", () => {
|
|
145
|
+
strictEqual(applyWorkspaceRangeEdit(manifest, {
|
|
146
|
+
packageName: "metro",
|
|
147
|
+
depType: "devDependencies",
|
|
148
|
+
range: "^0.80.0",
|
|
149
|
+
to: "0.87.0",
|
|
150
|
+
}), undefined);
|
|
151
|
+
strictEqual(applyWorkspaceRangeEdit(manifest, {
|
|
152
|
+
packageName: "metro",
|
|
153
|
+
depType: "optionalDependencies",
|
|
154
|
+
range: "^0.84.5",
|
|
155
|
+
to: "0.87.0",
|
|
156
|
+
}), undefined);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
//# sourceMappingURL=workspaceManifest.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspaceManifest.test.js","sourceRoot":"","sources":["../src/workspaceManifest.test.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pm-dedup-core",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "package manager utils",
|
|
5
|
+
"keywords": [],
|
|
6
|
+
"homepage": "https://github.com/christophehurpeau/pm-tools",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/christophehurpeau/pm-tools/issues"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Christophe Hurpeau <christophe@hurpeau.com> (https://christophe.hurpeau.com)",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/christophehurpeau/pm-tools.git",
|
|
15
|
+
"directory": "packages/pm-dedup-core"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/definitions/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
"./package.json": "./package.json",
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/definitions/index.d.ts",
|
|
29
|
+
"node": {
|
|
30
|
+
"import": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc",
|
|
36
|
+
"lint": "bun run lint:oxlint && bun run lint:eslint",
|
|
37
|
+
"lint:eslint": "bun run --cwd '../..' eslint --quiet 'packages/pm-dedup-core'",
|
|
38
|
+
"lint:oxlint": "bun run --cwd '../..' oxlint --quiet 'packages/pm-dedup-core'",
|
|
39
|
+
"test": "bun run --cwd '../..' test -- 'packages/pm-dedup-core'",
|
|
40
|
+
"tsc": "tsc"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"picomatch": "4.0.7",
|
|
44
|
+
"semver": "7.8.5",
|
|
45
|
+
"tslib": "^2.8.1"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/bun": "1.4.2",
|
|
49
|
+
"@types/picomatch": "4.0.3",
|
|
50
|
+
"@typescript/native": "npm:typescript@7.0.2",
|
|
51
|
+
"typescript": "npm:@typescript/typescript6@6.0.2"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=22.18.0"
|
|
55
|
+
},
|
|
56
|
+
"pob": {
|
|
57
|
+
"bundler": "tsc",
|
|
58
|
+
"entries": [
|
|
59
|
+
"index"
|
|
60
|
+
],
|
|
61
|
+
"envs": [
|
|
62
|
+
{
|
|
63
|
+
"target": "node",
|
|
64
|
+
"version": "22"
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ResolutionDependent,
|
|
3
|
+
type ResolutionDependentsMap,
|
|
4
|
+
type ResolutionEntry,
|
|
5
|
+
type ResolutionFix,
|
|
6
|
+
identifyResolutionFixes,
|
|
7
|
+
} from "./identifyResolutionFixes.ts";
|
|
8
|
+
|
|
9
|
+
export type ResolutionsMap = Record<string, ResolutionEntry[]>;
|
|
10
|
+
|
|
11
|
+
export const buildIdentifiedFixesMap = <D extends ResolutionDependent>(
|
|
12
|
+
duplicatesResolutionsMap: ResolutionsMap,
|
|
13
|
+
dependents: ResolutionDependentsMap<D>,
|
|
14
|
+
): Map<string, ResolutionFix[]> =>
|
|
15
|
+
new Map<string, ResolutionFix[]>(
|
|
16
|
+
Object.entries(duplicatesResolutionsMap).map(
|
|
17
|
+
([packageName, resolutions]) => [
|
|
18
|
+
packageName,
|
|
19
|
+
identifyResolutionFixes(resolutions, dependents),
|
|
20
|
+
],
|
|
21
|
+
),
|
|
22
|
+
);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type LockstepGraph,
|
|
4
|
+
buildLockstepClusters,
|
|
5
|
+
} from "./buildLockstepClusters.ts";
|
|
6
|
+
|
|
7
|
+
const npm = (
|
|
8
|
+
version: string,
|
|
9
|
+
dependencies: Record<string, string> = {},
|
|
10
|
+
): { version: string; isNpm: true; dependencies: Record<string, string> } => ({
|
|
11
|
+
version,
|
|
12
|
+
isNpm: true,
|
|
13
|
+
dependencies,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("buildLockstepClusters", () => {
|
|
17
|
+
it("unions a co-versioned family across caret and exact pins", () => {
|
|
18
|
+
// a@1 and a@2 both request b/c at their own version; b requests c at its
|
|
19
|
+
// own version. `noise` is requested at an unrelated version by everyone.
|
|
20
|
+
const graph: LockstepGraph = {
|
|
21
|
+
a: [
|
|
22
|
+
npm("1.0.0", { b: "1.0.0", c: "^1.0.0", noise: "^3.0.0" }),
|
|
23
|
+
npm("2.0.0", { b: "2.0.0", c: "^2.0.0", noise: "^3.0.0" }),
|
|
24
|
+
],
|
|
25
|
+
b: [npm("1.0.0", { c: "1.0.0" }), npm("2.0.0", { c: "2.0.0" })],
|
|
26
|
+
c: [npm("1.0.0"), npm("2.0.0")],
|
|
27
|
+
noise: [npm("3.1.0"), npm("3.2.0")],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const clusters = buildLockstepClusters(graph);
|
|
31
|
+
|
|
32
|
+
expect(clusters).toEqual([["a", "b", "c"]]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not union when a version observation breaks co-version", () => {
|
|
36
|
+
// a@2 requests b at b's matching version, but a@1 requests b at a stale
|
|
37
|
+
// version: not every observation co-versions, so no union.
|
|
38
|
+
const graph: LockstepGraph = {
|
|
39
|
+
a: [npm("1.0.0", { b: "2.0.0" }), npm("2.0.0", { b: "2.0.0" })],
|
|
40
|
+
b: [npm("1.0.0"), npm("2.0.0")],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
expect(buildLockstepClusters(graph)).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not union when the anchor version is not installed", () => {
|
|
47
|
+
// Real case: `d@1.0.1` requests `type@^1.0.1`, so the range anchors on
|
|
48
|
+
// d's own version by coincidence — no `type@1.0.1` is installed, the
|
|
49
|
+
// request resolves to `type@1.2.0`, and the two do not move together.
|
|
50
|
+
const graph: LockstepGraph = {
|
|
51
|
+
d: [npm("1.0.1", { type: "^1.0.1" })],
|
|
52
|
+
esniff: [npm("2.0.1", { d: "^1.0.1", type: "^2.7.2" })],
|
|
53
|
+
type: [npm("1.2.0"), npm("2.7.2")],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
expect(buildLockstepClusters(graph)).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("unions when the anchor version is installed alongside others", () => {
|
|
60
|
+
// Same shape, but the lock does carry `b@1.0.0`: the pin is real, and the
|
|
61
|
+
// duplicate `b@1.2.0` is what the cluster pass exists to collapse.
|
|
62
|
+
const graph: LockstepGraph = {
|
|
63
|
+
a: [npm("1.0.0", { b: "^1.0.0" })],
|
|
64
|
+
b: [npm("1.0.0"), npm("1.2.0")],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
expect(buildLockstepClusters(graph)).toEqual([["a", "b"]]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps unioning pnpm-style edges that store the resolved version", () => {
|
|
71
|
+
// pnpm records the resolved version, so the anchor is installed by
|
|
72
|
+
// construction and detection is unchanged.
|
|
73
|
+
const graph: LockstepGraph = {
|
|
74
|
+
a: [npm("7.28.0", { b: "7.28.0" })],
|
|
75
|
+
b: [npm("7.28.0")],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
expect(buildLockstepClusters(graph)).toEqual([["a", "b"]]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("ignores edges to packages absent from the graph and non-npm resolutions", () => {
|
|
82
|
+
const graph: LockstepGraph = {
|
|
83
|
+
a: [npm("1.0.0", { missing: "1.0.0", b: "1.0.0" })],
|
|
84
|
+
b: [npm("1.0.0")],
|
|
85
|
+
w: [{ version: "", isNpm: false, dependencies: { a: "1.0.0" } }],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
expect(buildLockstepClusters(graph)).toEqual([["a", "b"]]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import semver from "semver";
|
|
2
|
+
|
|
3
|
+
// One installed resolution of a package, reduced to what cluster detection
|
|
4
|
+
// needs. `dependencies` maps a dependency name to either a requested range
|
|
5
|
+
// (bun stores `^8.61.0`) or a resolved version (pnpm snapshots store `8.61.0`);
|
|
6
|
+
// both are handled by anchoring on the minimum version.
|
|
7
|
+
export interface LockstepResolution {
|
|
8
|
+
version: string;
|
|
9
|
+
isNpm: boolean;
|
|
10
|
+
dependencies: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// name -> installed resolutions. Package-manager packages adapt their own
|
|
14
|
+
// lockfile model into this neutral shape.
|
|
15
|
+
export type LockstepGraph = Record<string, LockstepResolution[]>;
|
|
16
|
+
|
|
17
|
+
interface UnionFind {
|
|
18
|
+
find: (value: string) => string;
|
|
19
|
+
union: (a: string, b: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const createUnionFind = (names: string[]): UnionFind => {
|
|
23
|
+
const parent = new Map<string, string>(names.map((name) => [name, name]));
|
|
24
|
+
|
|
25
|
+
const find = (value: string): string => {
|
|
26
|
+
let root = value;
|
|
27
|
+
while (parent.get(root) !== root) {
|
|
28
|
+
root = parent.get(root)!;
|
|
29
|
+
}
|
|
30
|
+
let cursor = value;
|
|
31
|
+
while (parent.get(cursor) !== root) {
|
|
32
|
+
const next = parent.get(cursor)!;
|
|
33
|
+
parent.set(cursor, root);
|
|
34
|
+
cursor = next;
|
|
35
|
+
}
|
|
36
|
+
return root;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const union = (a: string, b: string): void => {
|
|
40
|
+
const rootA = find(a);
|
|
41
|
+
const rootB = find(b);
|
|
42
|
+
if (rootA !== rootB) {
|
|
43
|
+
parent.set(rootA, rootB);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return { find, union };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// npm versions installed for each name, normalized, for anchor lookups.
|
|
51
|
+
const collectNpmVersions = (graph: LockstepGraph): Map<string, Set<string>> => {
|
|
52
|
+
const versionsByName = new Map<string, Set<string>>();
|
|
53
|
+
|
|
54
|
+
for (const [name, resolutions] of Object.entries(graph)) {
|
|
55
|
+
const versions = new Set<string>();
|
|
56
|
+
for (const resolution of resolutions) {
|
|
57
|
+
if (!resolution.isNpm) continue;
|
|
58
|
+
const parsed = semver.parse(resolution.version);
|
|
59
|
+
if (parsed) versions.add(parsed.version);
|
|
60
|
+
}
|
|
61
|
+
versionsByName.set(name, versions);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return versionsByName;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// A dependency edge `A@version --(range)--> B` is "co-version" when the range
|
|
68
|
+
// is anchored on the same version A itself carries, regardless of the caret /
|
|
69
|
+
// tilde operator (`^8.61.0`, `8.61.0` and `~8.61.0` are all co-version with
|
|
70
|
+
// `8.61.0`). This distinguishes a lockstep-published family member from an
|
|
71
|
+
// ordinary external dependency such as `semver`/`debug`.
|
|
72
|
+
//
|
|
73
|
+
// The anchor also has to be a version of B the lock actually installs, or the
|
|
74
|
+
// equality is a coincidence rather than a lockstep pin: `d@1.0.1` requesting
|
|
75
|
+
// `type@^1.0.1` anchors on `1.0.1`, yet the only installed `type` versions are
|
|
76
|
+
// `1.2.0` and `2.7.2`, so `d` and `type` do not move together. Lockfiles that
|
|
77
|
+
// store resolved versions instead of ranges (pnpm) satisfy this by
|
|
78
|
+
// construction; it brings the range-storing ones in line.
|
|
79
|
+
const isCoVersion = (
|
|
80
|
+
version: string,
|
|
81
|
+
range: string,
|
|
82
|
+
depVersions: Set<string>,
|
|
83
|
+
): boolean => {
|
|
84
|
+
const min = (() => {
|
|
85
|
+
try {
|
|
86
|
+
return semver.minVersion(range);
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
})();
|
|
91
|
+
if (min === null) return false;
|
|
92
|
+
return semver.eq(min, version) && depVersions.has(min.version);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Detect families of packages published in lockstep (versions move together)
|
|
96
|
+
// via union-find over the realized resolution graph. Names A and B are unioned
|
|
97
|
+
// when a real dependency edge A->B exists AND across every installed resolution
|
|
98
|
+
// where A requests B they carry the same version, that version being one the
|
|
99
|
+
// lock installs for B. Returns connected components
|
|
100
|
+
// of size > 1, members sorted, components ordered by their first member.
|
|
101
|
+
export const buildLockstepClusters = (graph: LockstepGraph): string[][] => {
|
|
102
|
+
const names = Object.keys(graph);
|
|
103
|
+
const unionFind = createUnionFind(names);
|
|
104
|
+
const versionsByName = collectNpmVersions(graph);
|
|
105
|
+
|
|
106
|
+
for (const [name, resolutions] of Object.entries(graph)) {
|
|
107
|
+
const observationsByDep = new Map<
|
|
108
|
+
string,
|
|
109
|
+
{ version: string; range: string }[]
|
|
110
|
+
>();
|
|
111
|
+
|
|
112
|
+
for (const resolution of resolutions) {
|
|
113
|
+
if (!resolution.isNpm) continue;
|
|
114
|
+
|
|
115
|
+
for (const [depName, range] of Object.entries(resolution.dependencies)) {
|
|
116
|
+
if (depName === name) continue;
|
|
117
|
+
// edge must point to a package that actually exists in the lock
|
|
118
|
+
if (!graph[depName]) continue;
|
|
119
|
+
|
|
120
|
+
let observations = observationsByDep.get(depName);
|
|
121
|
+
if (!observations) {
|
|
122
|
+
observations = [];
|
|
123
|
+
observationsByDep.set(depName, observations);
|
|
124
|
+
}
|
|
125
|
+
observations.push({ version: resolution.version, range });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const [depName, observations] of observationsByDep) {
|
|
130
|
+
const depVersions = versionsByName.get(depName) ?? new Set<string>();
|
|
131
|
+
const allCoVersion = observations.every((observation) =>
|
|
132
|
+
isCoVersion(observation.version, observation.range, depVersions),
|
|
133
|
+
);
|
|
134
|
+
if (allCoVersion) {
|
|
135
|
+
unionFind.union(name, depName);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const componentsByRoot = new Map<string, string[]>();
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
const root = unionFind.find(name);
|
|
143
|
+
let members = componentsByRoot.get(root);
|
|
144
|
+
if (!members) {
|
|
145
|
+
members = [];
|
|
146
|
+
componentsByRoot.set(root, members);
|
|
147
|
+
}
|
|
148
|
+
members.push(name);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return [...componentsByRoot.values()]
|
|
152
|
+
.filter((members) => members.length > 1)
|
|
153
|
+
.map((members) => members.toSorted((a, b) => a.localeCompare(b)))
|
|
154
|
+
.toSorted((a, b) => a[0]!.localeCompare(b[0]!));
|
|
155
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it } from "bun:test";
|
|
2
|
+
import { strictEqual } from "node:assert/strict";
|
|
3
|
+
import { clusterLabel } from "./clusterLabel.ts";
|
|
4
|
+
|
|
5
|
+
const typescriptEslint = [
|
|
6
|
+
"@typescript-eslint/eslint-plugin",
|
|
7
|
+
"@typescript-eslint/parser",
|
|
8
|
+
"@typescript-eslint/project-service",
|
|
9
|
+
"@typescript-eslint/scope-manager",
|
|
10
|
+
"@typescript-eslint/tsconfig-utils",
|
|
11
|
+
"@typescript-eslint/type-utils",
|
|
12
|
+
"@typescript-eslint/types",
|
|
13
|
+
"@typescript-eslint/typescript-estree",
|
|
14
|
+
"@typescript-eslint/utils",
|
|
15
|
+
"@typescript-eslint/visitor-keys",
|
|
16
|
+
"typescript-eslint",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const metroFamily = [
|
|
20
|
+
"@react-native/asset-utils",
|
|
21
|
+
"@react-native/babel-plugin-codegen",
|
|
22
|
+
"@react-native/babel-preset",
|
|
23
|
+
"@react-native/codegen",
|
|
24
|
+
"metro",
|
|
25
|
+
"metro-cache",
|
|
26
|
+
"metro-config",
|
|
27
|
+
"metro-core",
|
|
28
|
+
"ob1",
|
|
29
|
+
"react-native",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
describe("clusterLabel", () => {
|
|
33
|
+
it("names a family by its shared scope", () => {
|
|
34
|
+
strictEqual(
|
|
35
|
+
clusterLabel(typescriptEslint),
|
|
36
|
+
"@typescript-eslint/* (+1 more)",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("names the two largest groups and counts the rest", () => {
|
|
41
|
+
strictEqual(
|
|
42
|
+
clusterLabel(metroFamily),
|
|
43
|
+
"@react-native/* + metro* (+2 more)",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("uses a dash prefix when no member carries the prefix itself", () => {
|
|
48
|
+
strictEqual(
|
|
49
|
+
clusterLabel([
|
|
50
|
+
"babel-plugin-syntax-hermes-parser",
|
|
51
|
+
"hermes-estree",
|
|
52
|
+
"hermes-parser",
|
|
53
|
+
]),
|
|
54
|
+
"hermes-* (+1 more)",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("keeps the member name when it is the prefix", () => {
|
|
59
|
+
strictEqual(
|
|
60
|
+
clusterLabel(["mini-metro", "mini-metro-config"]),
|
|
61
|
+
"mini-metro*",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("names a single member exactly", () => {
|
|
66
|
+
strictEqual(clusterLabel(["metro"]), "metro");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not name a second group that covers one member", () => {
|
|
70
|
+
strictEqual(
|
|
71
|
+
clusterLabel(["metro", "metro-config", "ob1"]),
|
|
72
|
+
"metro* (+1 more)",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns an empty label for no members", () => {
|
|
77
|
+
strictEqual(clusterLabel([]), "");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const leadingPrefixes = (member: string): string[] => {
|
|
2
|
+
const scopeEnd = member.startsWith("@") ? member.indexOf("/") : -1;
|
|
3
|
+
const scope = scopeEnd === -1 ? null : member.slice(0, scopeEnd);
|
|
4
|
+
const rest = scope === null ? member : member.slice(scopeEnd + 1);
|
|
5
|
+
|
|
6
|
+
const segments = rest.split("-");
|
|
7
|
+
const withinRest = segments.map((_segment, index) => {
|
|
8
|
+
const joined = segments.slice(0, index + 1).join("-");
|
|
9
|
+
return scope === null ? joined : `${scope}/${joined}`;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
return scope === null ? withinRest : [scope, ...withinRest];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const covers = (prefix: string, member: string): boolean =>
|
|
16
|
+
member === prefix ||
|
|
17
|
+
member.startsWith(`${prefix}-`) ||
|
|
18
|
+
member.startsWith(`${prefix}/`);
|
|
19
|
+
|
|
20
|
+
interface PrefixGroup {
|
|
21
|
+
prefix: string;
|
|
22
|
+
covered: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The prefix shared by the most members. Ties go to the longest prefix, so a
|
|
27
|
+
* family named after a scope wins over the bare package name it also matches.
|
|
28
|
+
*/
|
|
29
|
+
const bestGroup = (members: string[]): PrefixGroup | null => {
|
|
30
|
+
const candidates = [...new Set(members.flatMap(leadingPrefixes))];
|
|
31
|
+
|
|
32
|
+
let best: PrefixGroup | null = null;
|
|
33
|
+
for (const prefix of candidates.toSorted((a, b) => a.localeCompare(b))) {
|
|
34
|
+
const covered = members.filter((member) => covers(prefix, member));
|
|
35
|
+
if (
|
|
36
|
+
best === null ||
|
|
37
|
+
covered.length > best.covered.length ||
|
|
38
|
+
(covered.length === best.covered.length &&
|
|
39
|
+
prefix.length > best.prefix.length)
|
|
40
|
+
) {
|
|
41
|
+
best = { prefix, covered };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return best;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const formatGroup = ({ prefix, covered }: PrefixGroup): string => {
|
|
48
|
+
if (covered.length === 1 && covered[0] === prefix) return prefix;
|
|
49
|
+
if (prefix.startsWith("@") && !prefix.includes("/")) return `${prefix}/*`;
|
|
50
|
+
return covered.includes(prefix) ? `${prefix}*` : `${prefix}-*`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A readable identity for a lockstep cluster, derived from the prefixes its
|
|
55
|
+
* members share: `@typescript-eslint/* (+1 more)`, `metro* + @react-native/*`.
|
|
56
|
+
* A second group is only worth naming when it covers more than one member;
|
|
57
|
+
* anything left over is counted, never guessed at.
|
|
58
|
+
*/
|
|
59
|
+
export const clusterLabel = (members: string[]): string => {
|
|
60
|
+
const first = bestGroup(members);
|
|
61
|
+
if (first === null) return "";
|
|
62
|
+
|
|
63
|
+
const groups = [first];
|
|
64
|
+
const remaining = members.filter((member) => !covers(first.prefix, member));
|
|
65
|
+
|
|
66
|
+
const second = remaining.length > 0 ? bestGroup(remaining) : null;
|
|
67
|
+
if (second !== null && second.covered.length > 1) groups.push(second);
|
|
68
|
+
|
|
69
|
+
const uncovered = members.filter(
|
|
70
|
+
(member) => !groups.some((group) => covers(group.prefix, member)),
|
|
71
|
+
).length;
|
|
72
|
+
|
|
73
|
+
const label = groups.map(formatGroup).join(" + ");
|
|
74
|
+
return uncovered === 0 ? label : `${label} (+${uncovered} more)`;
|
|
75
|
+
};
|