@funeste38/allmight 1.0.0 → 1.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/dist/cli/index.js +50 -10
- package/dist/core/doctor/analyzePublishDoctor.d.ts +2 -0
- package/dist/core/doctor/analyzePublishDoctor.js +80 -0
- package/dist/core/multi/analyzeRepoFamilies.d.ts +2 -0
- package/dist/core/multi/analyzeRepoFamilies.js +215 -0
- package/dist/core/repo/readRepoRootMeta.d.ts +2 -0
- package/dist/core/repo/readRepoRootMeta.js +120 -0
- package/dist/core/report/writeMultiRootArtifacts.d.ts +3 -0
- package/dist/core/report/writeMultiRootArtifacts.js +73 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/types.d.ts +85 -0
- package/dist/utils/system.d.ts +9 -0
- package/dist/utils/system.js +48 -0
- package/dist/utils/text.d.ts +1 -0
- package/dist/utils/text.js +24 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -3,40 +3,80 @@ import path from "node:path";
|
|
|
3
3
|
import { scanDuplicateFamilies } from "../core/scanner/scanDuplicateFamilies.js";
|
|
4
4
|
import { applySafePatches } from "../core/patcher/applySafePatches.js";
|
|
5
5
|
import { writeReportArtifacts } from "../core/report/writeReportArtifacts.js";
|
|
6
|
+
import { analyzeRepoFamilies } from "../core/multi/analyzeRepoFamilies.js";
|
|
7
|
+
import { analyzePublishDoctor } from "../core/doctor/analyzePublishDoctor.js";
|
|
8
|
+
import { writeDoctorArtifacts, writeMultiRootArtifacts } from "../core/report/writeMultiRootArtifacts.js";
|
|
6
9
|
function usage() {
|
|
7
10
|
console.log([
|
|
8
|
-
"allmight <command> [
|
|
11
|
+
"allmight <command> [args] [--output <dir>]",
|
|
9
12
|
"",
|
|
10
13
|
"Commands:",
|
|
11
14
|
" scan scan repo and write report artifacts",
|
|
12
15
|
" report alias of scan",
|
|
13
16
|
" propose scan repo and write diff proposals",
|
|
14
17
|
" fix-safe scan repo, apply safe patches, then write artifacts",
|
|
15
|
-
" canonicalize scan repo and print canonical map"
|
|
18
|
+
" canonicalize scan repo and print canonical map",
|
|
19
|
+
" multi-scan scan multiple roots and write repo family + publish risk artifacts",
|
|
20
|
+
" doctor publish verify package/dist/bin/global-install coherence",
|
|
21
|
+
" doctor bin focus on bin targets and install shadows",
|
|
22
|
+
" doctor install-shadow detect global/local install mismatch for a package"
|
|
16
23
|
].join("\n"));
|
|
17
24
|
}
|
|
18
25
|
function parseArgs(argv) {
|
|
19
|
-
const
|
|
20
|
-
const positional = argv.filter((arg) => !arg.startsWith("--"));
|
|
26
|
+
const positional = argv.filter((arg, index) => !(arg === "--output" || argv[index - 1] === "--output") && !arg.startsWith("--output="));
|
|
21
27
|
const outputIndex = argv.indexOf("--output");
|
|
22
|
-
const
|
|
23
|
-
const
|
|
28
|
+
const inlineOutput = argv.find((arg) => arg.startsWith("--output="));
|
|
29
|
+
const outputDir = outputIndex !== -1
|
|
30
|
+
? argv[outputIndex + 1] ?? ""
|
|
31
|
+
: inlineOutput
|
|
32
|
+
? inlineOutput.slice("--output=".length)
|
|
33
|
+
: "";
|
|
24
34
|
return {
|
|
25
|
-
command,
|
|
26
|
-
|
|
35
|
+
command: positional[0],
|
|
36
|
+
positional,
|
|
27
37
|
outputDir: outputDir || path.resolve(process.cwd(), "allmight-output")
|
|
28
38
|
};
|
|
29
39
|
}
|
|
30
40
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
31
|
-
const { command,
|
|
41
|
+
const { command, positional, outputDir } = parseArgs(argv);
|
|
32
42
|
if (!command || command === "--help" || command === "-h") {
|
|
33
43
|
usage();
|
|
34
44
|
return 0;
|
|
35
45
|
}
|
|
36
|
-
|
|
46
|
+
if (command === "multi-scan") {
|
|
47
|
+
const roots = positional.slice(1).map((root) => path.resolve(root));
|
|
48
|
+
if (roots.length === 0) {
|
|
49
|
+
usage();
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
const report = await analyzeRepoFamilies(roots);
|
|
53
|
+
await writeMultiRootArtifacts(report, outputDir);
|
|
54
|
+
console.log(`Scanned ${roots.length} roots.`);
|
|
55
|
+
console.log(`Repo families: ${report.repoFamilies.length}`);
|
|
56
|
+
console.log(`Command shadows: ${report.commandShadows.length}`);
|
|
57
|
+
console.log(`Artifacts: ${outputDir}`);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
if (command === "doctor") {
|
|
61
|
+
const mode = positional[1];
|
|
62
|
+
if (!mode || !["publish", "bin", "install-shadow"].includes(mode)) {
|
|
63
|
+
usage();
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const root = positional[2] ? path.resolve(positional[2]) : process.cwd();
|
|
67
|
+
const report = await analyzePublishDoctor(root);
|
|
68
|
+
await writeDoctorArtifacts(report, outputDir, mode);
|
|
69
|
+
console.log(`Doctor ${mode} -> ${report.publishRisk}`);
|
|
70
|
+
console.log(`Artifacts: ${outputDir}`);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
const root = positional[1] ? path.resolve(positional[1]) : process.cwd();
|
|
74
|
+
let report = await scanDuplicateFamilies(root, { outputDir });
|
|
37
75
|
await writeReportArtifacts(report, outputDir);
|
|
38
76
|
if (command === "fix-safe") {
|
|
39
77
|
const applied = await applySafePatches(report);
|
|
78
|
+
report = await scanDuplicateFamilies(root, { outputDir });
|
|
79
|
+
await writeReportArtifacts(report, outputDir);
|
|
40
80
|
console.log(`Applied ${applied.length} safe patches.`);
|
|
41
81
|
return 0;
|
|
42
82
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readGlobalNpmState, findCommandPaths } from "../../utils/system.js";
|
|
3
|
+
import { toPosix } from "../../utils/text.js";
|
|
4
|
+
import { readRepoRootMeta } from "../repo/readRepoRootMeta.js";
|
|
5
|
+
function toRisk(reasons) {
|
|
6
|
+
if (reasons.some((reason) => /mismatch|missing|shadow|different/i.test(reason))) {
|
|
7
|
+
return "dangerous";
|
|
8
|
+
}
|
|
9
|
+
if (reasons.length > 0) {
|
|
10
|
+
return "review";
|
|
11
|
+
}
|
|
12
|
+
return "safe";
|
|
13
|
+
}
|
|
14
|
+
function buildInstallShadowFinding(root, packageName, packageVersion, bin, globalInfo, globalPrefix) {
|
|
15
|
+
const commandPaths = findCommandPaths(bin);
|
|
16
|
+
const reasons = [];
|
|
17
|
+
const normalizedRoot = toPosix(path.resolve(root)).toLowerCase();
|
|
18
|
+
const normalizedResolved = toPosix(String(globalInfo?.resolved || "").replace(/^file:/i, "")).toLowerCase();
|
|
19
|
+
if (commandPaths.length === 0) {
|
|
20
|
+
reasons.push("bin is not available on PATH");
|
|
21
|
+
}
|
|
22
|
+
if (globalInfo?.resolved && normalizedResolved && normalizedResolved !== normalizedRoot) {
|
|
23
|
+
reasons.push("global install resolves to a different root");
|
|
24
|
+
}
|
|
25
|
+
if (globalInfo?.version && packageVersion && globalInfo.version !== packageVersion) {
|
|
26
|
+
reasons.push("global install version differs from package.json");
|
|
27
|
+
}
|
|
28
|
+
if (commandPaths.some((entry) => !toPosix(entry).toLowerCase().startsWith(toPosix(globalPrefix).toLowerCase()))) {
|
|
29
|
+
reasons.push("command shadow detected outside the npm global prefix");
|
|
30
|
+
}
|
|
31
|
+
if (!globalInfo && packageName) {
|
|
32
|
+
reasons.push("package is not installed globally");
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
bin,
|
|
36
|
+
commandPaths,
|
|
37
|
+
globalPackageVersion: globalInfo?.version,
|
|
38
|
+
globalPackageResolved: globalInfo?.resolved,
|
|
39
|
+
risk: toRisk(reasons),
|
|
40
|
+
reason: reasons.join(" | ") || "install path looks coherent"
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export async function analyzePublishDoctor(root) {
|
|
44
|
+
const meta = await readRepoRootMeta(root);
|
|
45
|
+
const npmState = readGlobalNpmState();
|
|
46
|
+
const globalPackageInfo = meta.packageName ? npmState.packages[meta.packageName] : undefined;
|
|
47
|
+
const installShadow = meta.binEntries.map((entry) => (buildInstallShadowFinding(meta.root, meta.packageName, meta.packageVersion, entry.name, globalPackageInfo, npmState.prefix)));
|
|
48
|
+
const reasons = [];
|
|
49
|
+
if (!meta.packageName)
|
|
50
|
+
reasons.push("missing package.json name");
|
|
51
|
+
if (meta.packagePrivate === true)
|
|
52
|
+
reasons.push("package is marked private");
|
|
53
|
+
if (!meta.hasDist)
|
|
54
|
+
reasons.push("dist/ is missing");
|
|
55
|
+
if (!meta.hasSrc)
|
|
56
|
+
reasons.push("src/ is missing");
|
|
57
|
+
if (meta.packageLockVersion && meta.packageVersion && meta.packageLockVersion !== meta.packageVersion) {
|
|
58
|
+
reasons.push("package-lock version mismatch");
|
|
59
|
+
}
|
|
60
|
+
if (meta.binEntries.some((entry) => !entry.exists)) {
|
|
61
|
+
reasons.push("at least one bin target is missing");
|
|
62
|
+
}
|
|
63
|
+
if (installShadow.some((entry) => entry.risk === "dangerous")) {
|
|
64
|
+
reasons.push("install shadow or global mismatch detected");
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
createdAt: new Date().toISOString(),
|
|
68
|
+
root: meta.root,
|
|
69
|
+
packageName: meta.packageName,
|
|
70
|
+
packageVersion: meta.packageVersion,
|
|
71
|
+
packagePrivate: meta.packagePrivate,
|
|
72
|
+
packageLockVersion: meta.packageLockVersion,
|
|
73
|
+
hasDist: meta.hasDist,
|
|
74
|
+
hasSrc: meta.hasSrc,
|
|
75
|
+
bins: meta.binEntries,
|
|
76
|
+
installShadow,
|
|
77
|
+
publishRisk: toRisk(reasons),
|
|
78
|
+
reasons
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { levenshteinDistance, toPosix } from "../../utils/text.js";
|
|
3
|
+
import { readRepoRootMeta } from "../repo/readRepoRootMeta.js";
|
|
4
|
+
function pathPenalty(root) {
|
|
5
|
+
const normalized = toPosix(root).toLowerCase();
|
|
6
|
+
let penalty = 0;
|
|
7
|
+
if (normalized.includes("/poubele/"))
|
|
8
|
+
penalty += 80;
|
|
9
|
+
if (normalized.includes("/tmp/"))
|
|
10
|
+
penalty += 60;
|
|
11
|
+
if (normalized.includes("/dump/"))
|
|
12
|
+
penalty += 40;
|
|
13
|
+
if (normalized.includes("/backup/"))
|
|
14
|
+
penalty += 45;
|
|
15
|
+
if (normalized.includes("/copy/"))
|
|
16
|
+
penalty += 35;
|
|
17
|
+
if (normalized.includes("/old/"))
|
|
18
|
+
penalty += 35;
|
|
19
|
+
if (normalized.includes("/gpt6.0/"))
|
|
20
|
+
penalty += 20;
|
|
21
|
+
return penalty;
|
|
22
|
+
}
|
|
23
|
+
function versionScore(version) {
|
|
24
|
+
if (!version)
|
|
25
|
+
return 0;
|
|
26
|
+
const [major = 0, minor = 0, patch = 0] = version.split(".").map((value) => Number(value) || 0);
|
|
27
|
+
return major * 30 + minor * 4 + patch * 0.2;
|
|
28
|
+
}
|
|
29
|
+
function canonicalScore(meta, family) {
|
|
30
|
+
const basename = meta.basename.toLowerCase();
|
|
31
|
+
const packageName = meta.packageName?.toLowerCase() ?? "";
|
|
32
|
+
let score = 0;
|
|
33
|
+
if (basename === family)
|
|
34
|
+
score += 70;
|
|
35
|
+
if (packageName.endsWith(`/${family}`) || packageName === family)
|
|
36
|
+
score += 45;
|
|
37
|
+
if (basename.includes(family))
|
|
38
|
+
score += 18;
|
|
39
|
+
if (meta.hasSrc)
|
|
40
|
+
score += 20;
|
|
41
|
+
if (meta.hasDist)
|
|
42
|
+
score += 10;
|
|
43
|
+
if (meta.binEntries.length > 0)
|
|
44
|
+
score += 12;
|
|
45
|
+
if (meta.binEntries.every((entry) => entry.exists))
|
|
46
|
+
score += 10;
|
|
47
|
+
if (meta.packagePrivate === true)
|
|
48
|
+
score -= 30;
|
|
49
|
+
score += versionScore(meta.packageVersion);
|
|
50
|
+
score -= pathPenalty(meta.root);
|
|
51
|
+
return score;
|
|
52
|
+
}
|
|
53
|
+
function memberRole(root, canonicalRoot) {
|
|
54
|
+
if (root === canonicalRoot) {
|
|
55
|
+
return "canonical";
|
|
56
|
+
}
|
|
57
|
+
const normalized = toPosix(root).toLowerCase();
|
|
58
|
+
if (normalized.includes("/funesterie/") || normalized.includes("/a11/")) {
|
|
59
|
+
return "transition";
|
|
60
|
+
}
|
|
61
|
+
return "mirror";
|
|
62
|
+
}
|
|
63
|
+
function collectCollisions(members, selector) {
|
|
64
|
+
const groups = new Map();
|
|
65
|
+
for (const meta of members) {
|
|
66
|
+
for (const entry of selector(meta)) {
|
|
67
|
+
const existing = groups.get(entry.name) ?? {
|
|
68
|
+
name: entry.name,
|
|
69
|
+
roots: [],
|
|
70
|
+
packages: []
|
|
71
|
+
};
|
|
72
|
+
existing.roots.push(meta.root);
|
|
73
|
+
if (entry.packageName) {
|
|
74
|
+
existing.packages.push(entry.packageName);
|
|
75
|
+
}
|
|
76
|
+
groups.set(entry.name, existing);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return Array.from(groups.values())
|
|
80
|
+
.filter((entry) => entry.roots.length > 1)
|
|
81
|
+
.map((entry) => ({
|
|
82
|
+
...entry,
|
|
83
|
+
roots: Array.from(new Set(entry.roots)),
|
|
84
|
+
packages: Array.from(new Set(entry.packages))
|
|
85
|
+
}))
|
|
86
|
+
.sort((left, right) => right.roots.length - left.roots.length || left.name.localeCompare(right.name));
|
|
87
|
+
}
|
|
88
|
+
function publishRiskLevel(score) {
|
|
89
|
+
if (score >= 60)
|
|
90
|
+
return "dangerous";
|
|
91
|
+
if (score >= 30)
|
|
92
|
+
return "review";
|
|
93
|
+
return "safe";
|
|
94
|
+
}
|
|
95
|
+
function familyReasons(family, canonicalRoot, members, packageCollisions, binCollisions) {
|
|
96
|
+
const reasons = [`canonical source proposed for ${family}: ${canonicalRoot}`];
|
|
97
|
+
if (members.some((meta) => pathPenalty(meta.root) > 0 && meta.packagePrivate !== true)) {
|
|
98
|
+
reasons.push("legacy or trash-area copy still looks publishable");
|
|
99
|
+
}
|
|
100
|
+
if (packageCollisions.length > 0) {
|
|
101
|
+
reasons.push("same package name appears in multiple roots");
|
|
102
|
+
}
|
|
103
|
+
if (binCollisions.length > 0) {
|
|
104
|
+
reasons.push("same CLI bin is exposed by multiple roots");
|
|
105
|
+
}
|
|
106
|
+
if (members.some((meta) => meta.binEntries.some((entry) => !entry.exists))) {
|
|
107
|
+
reasons.push("at least one bin target is missing");
|
|
108
|
+
}
|
|
109
|
+
return reasons;
|
|
110
|
+
}
|
|
111
|
+
function buildRepoFamilyReport(family, members) {
|
|
112
|
+
const scoredMembers = members
|
|
113
|
+
.map((meta) => ({ meta, score: canonicalScore(meta, family) }))
|
|
114
|
+
.sort((left, right) => right.score - left.score || left.meta.root.localeCompare(right.meta.root));
|
|
115
|
+
const canonicalRoot = scoredMembers[0]?.meta.root ?? members[0].root;
|
|
116
|
+
const packageCollisions = collectCollisions(members, (meta) => (meta.packageName ? [{ name: meta.packageName, packageName: meta.packageName }] : []));
|
|
117
|
+
const binCollisions = collectCollisions(members, (meta) => (meta.binEntries.map((entry) => ({ name: entry.name, packageName: meta.packageName }))));
|
|
118
|
+
const reasons = familyReasons(family, canonicalRoot, members, packageCollisions, binCollisions);
|
|
119
|
+
let publishRiskScore = 0;
|
|
120
|
+
publishRiskScore += packageCollisions.length * 28;
|
|
121
|
+
publishRiskScore += binCollisions.length * 24;
|
|
122
|
+
publishRiskScore += members.filter((meta) => pathPenalty(meta.root) > 0 && meta.packagePrivate !== true).length * 12;
|
|
123
|
+
publishRiskScore += members.filter((meta) => meta.binEntries.some((entry) => !entry.exists)).length * 12;
|
|
124
|
+
publishRiskScore = Math.min(100, publishRiskScore);
|
|
125
|
+
const memberReports = scoredMembers.map(({ meta, score }) => ({
|
|
126
|
+
root: meta.root,
|
|
127
|
+
packageName: meta.packageName,
|
|
128
|
+
packageVersion: meta.packageVersion,
|
|
129
|
+
role: memberRole(meta.root, canonicalRoot),
|
|
130
|
+
score,
|
|
131
|
+
bins: meta.binEntries.map((entry) => entry.name).sort()
|
|
132
|
+
}));
|
|
133
|
+
return {
|
|
134
|
+
family,
|
|
135
|
+
canonicalRoot,
|
|
136
|
+
canonicalPackage: scoredMembers[0]?.meta.packageName,
|
|
137
|
+
members: memberReports,
|
|
138
|
+
mirrors: memberReports.filter((member) => member.role === "mirror").map((member) => member.root),
|
|
139
|
+
transitions: memberReports.filter((member) => member.role === "transition").map((member) => member.root),
|
|
140
|
+
packageCollisions,
|
|
141
|
+
binCollisions,
|
|
142
|
+
publishRiskScore,
|
|
143
|
+
publishRisk: publishRiskLevel(publishRiskScore),
|
|
144
|
+
reasons
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function isSimilarCommand(left, right) {
|
|
148
|
+
if (left === right)
|
|
149
|
+
return false;
|
|
150
|
+
const distance = levenshteinDistance(left, right);
|
|
151
|
+
if (distance <= 1)
|
|
152
|
+
return true;
|
|
153
|
+
return left.startsWith(right) || right.startsWith(left);
|
|
154
|
+
}
|
|
155
|
+
function commandShadowRisk(left, right, roots) {
|
|
156
|
+
if (levenshteinDistance(left, right) <= 1 || roots.length > 1) {
|
|
157
|
+
return "dangerous";
|
|
158
|
+
}
|
|
159
|
+
return "review";
|
|
160
|
+
}
|
|
161
|
+
function buildCommandShadows(roots) {
|
|
162
|
+
const commandToRoots = new Map();
|
|
163
|
+
for (const meta of roots) {
|
|
164
|
+
for (const entry of meta.binEntries) {
|
|
165
|
+
const existing = commandToRoots.get(entry.name) ?? new Set();
|
|
166
|
+
existing.add(meta.root);
|
|
167
|
+
commandToRoots.set(entry.name, existing);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const commands = Array.from(commandToRoots.keys()).sort();
|
|
171
|
+
const shadows = [];
|
|
172
|
+
for (let index = 0; index < commands.length; index += 1) {
|
|
173
|
+
for (let other = index + 1; other < commands.length; other += 1) {
|
|
174
|
+
const left = commands[index];
|
|
175
|
+
const right = commands[other];
|
|
176
|
+
if (!isSimilarCommand(left, right))
|
|
177
|
+
continue;
|
|
178
|
+
const rootsForPair = Array.from(new Set([
|
|
179
|
+
...(commandToRoots.get(left) ?? new Set()),
|
|
180
|
+
...(commandToRoots.get(right) ?? new Set())
|
|
181
|
+
])).sort();
|
|
182
|
+
shadows.push({
|
|
183
|
+
left,
|
|
184
|
+
right,
|
|
185
|
+
distance: levenshteinDistance(left, right),
|
|
186
|
+
roots: rootsForPair,
|
|
187
|
+
risk: commandShadowRisk(left, right, rootsForPair),
|
|
188
|
+
reason: `commands ${left} and ${right} are visually close and may be mistaken during install or publish`
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return shadows.sort((left, right) => left.distance - right.distance || left.left.localeCompare(right.left));
|
|
193
|
+
}
|
|
194
|
+
export async function analyzeRepoFamilies(roots) {
|
|
195
|
+
const uniqueRoots = Array.from(new Set(roots.map((root) => path.resolve(root))));
|
|
196
|
+
const metas = await Promise.all(uniqueRoots.map((root) => readRepoRootMeta(root)));
|
|
197
|
+
const familyBuckets = new Map();
|
|
198
|
+
for (const meta of metas) {
|
|
199
|
+
for (const family of meta.familyTags) {
|
|
200
|
+
const existing = familyBuckets.get(family) ?? [];
|
|
201
|
+
existing.push(meta);
|
|
202
|
+
familyBuckets.set(family, existing);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const repoFamilies = Array.from(familyBuckets.entries())
|
|
206
|
+
.filter(([, members]) => members.length >= 2)
|
|
207
|
+
.map(([family, members]) => buildRepoFamilyReport(family, members))
|
|
208
|
+
.sort((left, right) => right.publishRiskScore - left.publishRiskScore || left.family.localeCompare(right.family));
|
|
209
|
+
return {
|
|
210
|
+
createdAt: new Date().toISOString(),
|
|
211
|
+
roots: metas,
|
|
212
|
+
repoFamilies,
|
|
213
|
+
commandShadows: buildCommandShadows(metas)
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { toPosix } from "../../utils/text.js";
|
|
4
|
+
function normalizeLower(value) {
|
|
5
|
+
return value.trim().toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
function hasFamilyHint(value, hints) {
|
|
8
|
+
const normalized = normalizeLower(value);
|
|
9
|
+
return hints.some((hint) => normalized.includes(hint));
|
|
10
|
+
}
|
|
11
|
+
function detectFamilyTags(root, packageName) {
|
|
12
|
+
const sources = [path.basename(root), packageName ?? ""].filter(Boolean);
|
|
13
|
+
const tags = new Set();
|
|
14
|
+
if (sources.some((value) => hasFamilyHint(value, ["qflush", "qflash"]))) {
|
|
15
|
+
tags.add("qflush");
|
|
16
|
+
}
|
|
17
|
+
if (sources.some((value) => hasFamilyHint(value, ["envapt", "envaptex"]))) {
|
|
18
|
+
tags.add("envapt");
|
|
19
|
+
}
|
|
20
|
+
if (sources.some((value) => hasFamilyHint(value, ["allmight"]))) {
|
|
21
|
+
tags.add("allmight");
|
|
22
|
+
}
|
|
23
|
+
if (sources.some((value) => hasFamilyHint(value, ["spyder"]))) {
|
|
24
|
+
tags.add("spyder");
|
|
25
|
+
}
|
|
26
|
+
return Array.from(tags).sort();
|
|
27
|
+
}
|
|
28
|
+
function readPackageLockVersion(parsed) {
|
|
29
|
+
if (typeof parsed.version === "string" && parsed.version.trim()) {
|
|
30
|
+
return parsed.version;
|
|
31
|
+
}
|
|
32
|
+
const packages = parsed.packages;
|
|
33
|
+
if (!packages || typeof packages !== "object") {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const rootPackage = packages[""];
|
|
37
|
+
if (!rootPackage || typeof rootPackage !== "object") {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const version = rootPackage.version;
|
|
41
|
+
return typeof version === "string" && version.trim() ? version : undefined;
|
|
42
|
+
}
|
|
43
|
+
function normalizeBinEntries(root, rawBin) {
|
|
44
|
+
const entries = new Map();
|
|
45
|
+
if (typeof rawBin === "string" && rawBin.trim()) {
|
|
46
|
+
entries.set(path.basename(root), rawBin.trim());
|
|
47
|
+
}
|
|
48
|
+
else if (rawBin && typeof rawBin === "object") {
|
|
49
|
+
for (const [name, target] of Object.entries(rawBin)) {
|
|
50
|
+
if (typeof target === "string" && target.trim()) {
|
|
51
|
+
entries.set(name, target.trim());
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return Array.from(entries.entries())
|
|
56
|
+
.map(([name, target]) => {
|
|
57
|
+
const absoluteTarget = path.resolve(root, target);
|
|
58
|
+
return fs.access(absoluteTarget)
|
|
59
|
+
.then(() => ({
|
|
60
|
+
name,
|
|
61
|
+
target,
|
|
62
|
+
absoluteTarget,
|
|
63
|
+
exists: true
|
|
64
|
+
}))
|
|
65
|
+
.catch(() => ({
|
|
66
|
+
name,
|
|
67
|
+
target,
|
|
68
|
+
absoluteTarget,
|
|
69
|
+
exists: false
|
|
70
|
+
}));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export async function readRepoRootMeta(root) {
|
|
74
|
+
const resolvedRoot = path.resolve(root);
|
|
75
|
+
const packageJsonPath = path.join(resolvedRoot, "package.json");
|
|
76
|
+
const packageLockPath = path.join(resolvedRoot, "package-lock.json");
|
|
77
|
+
const srcPath = path.join(resolvedRoot, "src");
|
|
78
|
+
const distPath = path.join(resolvedRoot, "dist");
|
|
79
|
+
let packageName;
|
|
80
|
+
let packageVersion;
|
|
81
|
+
let packagePrivate;
|
|
82
|
+
let rawBin;
|
|
83
|
+
try {
|
|
84
|
+
const rawPackageJson = await fs.readFile(packageJsonPath, "utf8");
|
|
85
|
+
const parsed = JSON.parse(rawPackageJson);
|
|
86
|
+
packageName = typeof parsed.name === "string" ? parsed.name : undefined;
|
|
87
|
+
packageVersion = typeof parsed.version === "string" ? parsed.version : undefined;
|
|
88
|
+
packagePrivate = typeof parsed.private === "boolean" ? parsed.private : undefined;
|
|
89
|
+
rawBin = parsed.bin;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// root without package.json is still a valid scan root
|
|
93
|
+
}
|
|
94
|
+
let packageLockVersion;
|
|
95
|
+
try {
|
|
96
|
+
const rawPackageLock = await fs.readFile(packageLockPath, "utf8");
|
|
97
|
+
const parsed = JSON.parse(rawPackageLock);
|
|
98
|
+
packageLockVersion = readPackageLockVersion(parsed);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
packageLockVersion = undefined;
|
|
102
|
+
}
|
|
103
|
+
const [hasSrc, hasDist] = await Promise.all([
|
|
104
|
+
fs.access(srcPath).then(() => true).catch(() => false),
|
|
105
|
+
fs.access(distPath).then(() => true).catch(() => false)
|
|
106
|
+
]);
|
|
107
|
+
const binEntries = await Promise.all(normalizeBinEntries(resolvedRoot, rawBin));
|
|
108
|
+
return {
|
|
109
|
+
root: resolvedRoot,
|
|
110
|
+
basename: path.basename(resolvedRoot),
|
|
111
|
+
packageName,
|
|
112
|
+
packageVersion,
|
|
113
|
+
packagePrivate,
|
|
114
|
+
packageLockVersion,
|
|
115
|
+
familyTags: detectFamilyTags(toPosix(resolvedRoot), packageName),
|
|
116
|
+
hasSrc,
|
|
117
|
+
hasDist,
|
|
118
|
+
binEntries
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { MultiRepoReport, PublishDoctorReport } from "../../types.js";
|
|
2
|
+
export declare function writeMultiRootArtifacts(report: MultiRepoReport, outputDir: string): Promise<void>;
|
|
3
|
+
export declare function writeDoctorArtifacts(report: PublishDoctorReport, outputDir: string, mode: string): Promise<void>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { ensureDir, writeJson, writeText } from "../../utils/fs.js";
|
|
3
|
+
function buildMultiScanSummary(report) {
|
|
4
|
+
const lines = [
|
|
5
|
+
"# Allmight Multi Scan",
|
|
6
|
+
"",
|
|
7
|
+
`Created at: ${report.createdAt}`,
|
|
8
|
+
`Roots scanned: ${report.roots.length}`,
|
|
9
|
+
`Families detected: ${report.repoFamilies.length}`,
|
|
10
|
+
`Command shadows: ${report.commandShadows.length}`,
|
|
11
|
+
"",
|
|
12
|
+
"## Families",
|
|
13
|
+
""
|
|
14
|
+
];
|
|
15
|
+
for (const family of report.repoFamilies) {
|
|
16
|
+
lines.push(`- ${family.family}: canonical=${family.canonicalRoot}`);
|
|
17
|
+
lines.push(` risk=${family.publishRisk} (${family.publishRiskScore})`);
|
|
18
|
+
}
|
|
19
|
+
if (report.commandShadows.length > 0) {
|
|
20
|
+
lines.push("", "## Command Shadows", "");
|
|
21
|
+
for (const shadow of report.commandShadows) {
|
|
22
|
+
lines.push(`- ${shadow.left} vs ${shadow.right}: ${shadow.risk}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return lines.join("\n");
|
|
26
|
+
}
|
|
27
|
+
function buildDoctorSummary(report, mode) {
|
|
28
|
+
const lines = [
|
|
29
|
+
"# Allmight Doctor",
|
|
30
|
+
"",
|
|
31
|
+
`Mode: ${mode}`,
|
|
32
|
+
`Root: ${report.root}`,
|
|
33
|
+
`Package: ${report.packageName ?? "unknown"}`,
|
|
34
|
+
`Version: ${report.packageVersion ?? "unknown"}`,
|
|
35
|
+
`Risk: ${report.publishRisk}`,
|
|
36
|
+
""
|
|
37
|
+
];
|
|
38
|
+
if (report.reasons.length > 0) {
|
|
39
|
+
lines.push("## Reasons", "");
|
|
40
|
+
for (const reason of report.reasons) {
|
|
41
|
+
lines.push(`- ${reason}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (report.installShadow.length > 0) {
|
|
45
|
+
lines.push("", "## Install Shadow", "");
|
|
46
|
+
for (const entry of report.installShadow) {
|
|
47
|
+
lines.push(`- ${entry.bin}: ${entry.reason}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
export async function writeMultiRootArtifacts(report, outputDir) {
|
|
53
|
+
await ensureDir(outputDir);
|
|
54
|
+
await writeJson(path.join(outputDir, "repo-families.json"), report.repoFamilies);
|
|
55
|
+
await writeJson(path.join(outputDir, "publish-risk-report.json"), {
|
|
56
|
+
createdAt: report.createdAt,
|
|
57
|
+
roots: report.roots,
|
|
58
|
+
repoFamilies: report.repoFamilies,
|
|
59
|
+
commandShadows: report.commandShadows
|
|
60
|
+
});
|
|
61
|
+
await writeJson(path.join(outputDir, "command-shadow-report.json"), report.commandShadows);
|
|
62
|
+
await writeText(path.join(outputDir, "summary.md"), buildMultiScanSummary(report));
|
|
63
|
+
}
|
|
64
|
+
export async function writeDoctorArtifacts(report, outputDir, mode) {
|
|
65
|
+
await ensureDir(outputDir);
|
|
66
|
+
const fileName = mode === "install-shadow"
|
|
67
|
+
? "install-shadow-report.json"
|
|
68
|
+
: mode === "bin"
|
|
69
|
+
? "bin-risk-report.json"
|
|
70
|
+
: "publish-risk-report.json";
|
|
71
|
+
await writeJson(path.join(outputDir, fileName), report);
|
|
72
|
+
await writeText(path.join(outputDir, "summary.md"), buildDoctorSummary(report, mode));
|
|
73
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,4 +6,7 @@ export { applySafePatches } from "./core/patcher/applySafePatches.js";
|
|
|
6
6
|
export { buildProposedPatches } from "./core/patcher/buildProposedPatches.js";
|
|
7
7
|
export { rewriteImportsToCanonicalSource, createLegacyReexportShim } from "./core/patcher/rewriters.js";
|
|
8
8
|
export { writeReportArtifacts } from "./core/report/writeReportArtifacts.js";
|
|
9
|
-
export
|
|
9
|
+
export { analyzeRepoFamilies } from "./core/multi/analyzeRepoFamilies.js";
|
|
10
|
+
export { analyzePublishDoctor } from "./core/doctor/analyzePublishDoctor.js";
|
|
11
|
+
export { writeMultiRootArtifacts, writeDoctorArtifacts } from "./core/report/writeMultiRootArtifacts.js";
|
|
12
|
+
export type { CanonicalMapEntry, CommandShadow, DuplicateFamily, DuplicateType, FileFingerprint, InstallShadowFinding, MultiRepoReport, PatchProposal, PublishDoctorReport, RepoFamilyReport, RepoRootMeta, ScanOptions, ScanReport } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -6,3 +6,6 @@ export { applySafePatches } from "./core/patcher/applySafePatches.js";
|
|
|
6
6
|
export { buildProposedPatches } from "./core/patcher/buildProposedPatches.js";
|
|
7
7
|
export { rewriteImportsToCanonicalSource, createLegacyReexportShim } from "./core/patcher/rewriters.js";
|
|
8
8
|
export { writeReportArtifacts } from "./core/report/writeReportArtifacts.js";
|
|
9
|
+
export { analyzeRepoFamilies } from "./core/multi/analyzeRepoFamilies.js";
|
|
10
|
+
export { analyzePublishDoctor } from "./core/doctor/analyzePublishDoctor.js";
|
|
11
|
+
export { writeMultiRootArtifacts, writeDoctorArtifacts } from "./core/report/writeMultiRootArtifacts.js";
|
package/dist/types.d.ts
CHANGED
|
@@ -74,3 +74,88 @@ export type ScanOptions = {
|
|
|
74
74
|
outputDir?: string;
|
|
75
75
|
includeContent?: boolean;
|
|
76
76
|
};
|
|
77
|
+
export type RepoBinEntry = {
|
|
78
|
+
name: string;
|
|
79
|
+
target: string;
|
|
80
|
+
exists: boolean;
|
|
81
|
+
absoluteTarget: string;
|
|
82
|
+
};
|
|
83
|
+
export type RepoRootMeta = {
|
|
84
|
+
root: string;
|
|
85
|
+
basename: string;
|
|
86
|
+
packageName?: string;
|
|
87
|
+
packageVersion?: string;
|
|
88
|
+
packagePrivate?: boolean;
|
|
89
|
+
packageLockVersion?: string;
|
|
90
|
+
familyTags: string[];
|
|
91
|
+
hasSrc: boolean;
|
|
92
|
+
hasDist: boolean;
|
|
93
|
+
binEntries: RepoBinEntry[];
|
|
94
|
+
};
|
|
95
|
+
export type RepoFamilyMemberRole = "canonical" | "transition" | "mirror";
|
|
96
|
+
export type RepoFamilyMember = {
|
|
97
|
+
root: string;
|
|
98
|
+
packageName?: string;
|
|
99
|
+
packageVersion?: string;
|
|
100
|
+
role: RepoFamilyMemberRole;
|
|
101
|
+
score: number;
|
|
102
|
+
bins: string[];
|
|
103
|
+
};
|
|
104
|
+
export type RepoCollision = {
|
|
105
|
+
name: string;
|
|
106
|
+
roots: string[];
|
|
107
|
+
packages: string[];
|
|
108
|
+
};
|
|
109
|
+
export type CommandShadow = {
|
|
110
|
+
left: string;
|
|
111
|
+
right: string;
|
|
112
|
+
distance: number;
|
|
113
|
+
roots: string[];
|
|
114
|
+
risk: RiskLevel;
|
|
115
|
+
reason: string;
|
|
116
|
+
};
|
|
117
|
+
export type RepoFamilyReport = {
|
|
118
|
+
family: string;
|
|
119
|
+
canonicalRoot: string;
|
|
120
|
+
canonicalPackage?: string;
|
|
121
|
+
members: RepoFamilyMember[];
|
|
122
|
+
mirrors: string[];
|
|
123
|
+
transitions: string[];
|
|
124
|
+
packageCollisions: RepoCollision[];
|
|
125
|
+
binCollisions: RepoCollision[];
|
|
126
|
+
publishRiskScore: number;
|
|
127
|
+
publishRisk: RiskLevel;
|
|
128
|
+
reasons: string[];
|
|
129
|
+
};
|
|
130
|
+
export type MultiRepoReport = {
|
|
131
|
+
createdAt: string;
|
|
132
|
+
roots: RepoRootMeta[];
|
|
133
|
+
repoFamilies: RepoFamilyReport[];
|
|
134
|
+
commandShadows: CommandShadow[];
|
|
135
|
+
};
|
|
136
|
+
export type GlobalPackageInfo = {
|
|
137
|
+
version?: string;
|
|
138
|
+
resolved?: string;
|
|
139
|
+
};
|
|
140
|
+
export type InstallShadowFinding = {
|
|
141
|
+
bin: string;
|
|
142
|
+
commandPaths: string[];
|
|
143
|
+
globalPackageVersion?: string;
|
|
144
|
+
globalPackageResolved?: string;
|
|
145
|
+
risk: RiskLevel;
|
|
146
|
+
reason: string;
|
|
147
|
+
};
|
|
148
|
+
export type PublishDoctorReport = {
|
|
149
|
+
createdAt: string;
|
|
150
|
+
root: string;
|
|
151
|
+
packageName?: string;
|
|
152
|
+
packageVersion?: string;
|
|
153
|
+
packagePrivate?: boolean;
|
|
154
|
+
packageLockVersion?: string;
|
|
155
|
+
hasDist: boolean;
|
|
156
|
+
hasSrc: boolean;
|
|
157
|
+
bins: RepoBinEntry[];
|
|
158
|
+
installShadow: InstallShadowFinding[];
|
|
159
|
+
publishRisk: RiskLevel;
|
|
160
|
+
reasons: string[];
|
|
161
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
function runCommand(command, args) {
|
|
3
|
+
try {
|
|
4
|
+
const result = spawnSync(command, args, {
|
|
5
|
+
encoding: "utf8",
|
|
6
|
+
windowsHide: true,
|
|
7
|
+
shell: process.platform === "win32"
|
|
8
|
+
});
|
|
9
|
+
return {
|
|
10
|
+
status: result.status,
|
|
11
|
+
stdout: String(result.stdout ?? ""),
|
|
12
|
+
stderr: String(result.stderr ?? "")
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
return {
|
|
17
|
+
status: 1,
|
|
18
|
+
stdout: "",
|
|
19
|
+
stderr: String(error?.message ?? error)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function findCommandPaths(commandName) {
|
|
24
|
+
const result = process.platform === "win32"
|
|
25
|
+
? runCommand("where.exe", [commandName])
|
|
26
|
+
: runCommand("which", ["-a", commandName]);
|
|
27
|
+
if (result.status !== 0) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
return result.stdout
|
|
31
|
+
.split(/\r?\n/g)
|
|
32
|
+
.map((line) => line.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
export function readGlobalNpmState() {
|
|
36
|
+
const prefix = runCommand("npm", ["prefix", "-g"]).stdout.trim();
|
|
37
|
+
const root = runCommand("npm", ["root", "-g"]).stdout.trim();
|
|
38
|
+
const listResult = runCommand("npm", ["list", "-g", "--depth=0", "--json"]);
|
|
39
|
+
let packages = {};
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(listResult.stdout);
|
|
42
|
+
packages = parsed.dependencies ?? {};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
packages = {};
|
|
46
|
+
}
|
|
47
|
+
return { prefix, root, packages };
|
|
48
|
+
}
|
package/dist/utils/text.d.ts
CHANGED
|
@@ -6,3 +6,4 @@ export declare function jaccardSimilarity(a: Set<string>, b: Set<string>): numbe
|
|
|
6
6
|
export declare function sharedValues(a: string[], b: string[]): string[];
|
|
7
7
|
export declare function slugify(value: string): string;
|
|
8
8
|
export declare function removeExtension(filePath: string): string;
|
|
9
|
+
export declare function levenshteinDistance(left: string, right: string): number;
|
package/dist/utils/text.js
CHANGED
|
@@ -49,3 +49,27 @@ export function removeExtension(filePath) {
|
|
|
49
49
|
const ext = path.extname(filePath);
|
|
50
50
|
return filePath.slice(0, filePath.length - ext.length);
|
|
51
51
|
}
|
|
52
|
+
export function levenshteinDistance(left, right) {
|
|
53
|
+
if (left === right)
|
|
54
|
+
return 0;
|
|
55
|
+
if (!left.length)
|
|
56
|
+
return right.length;
|
|
57
|
+
if (!right.length)
|
|
58
|
+
return left.length;
|
|
59
|
+
const rows = right.length + 1;
|
|
60
|
+
const cols = left.length + 1;
|
|
61
|
+
const matrix = Array.from({ length: rows }, () => Array(cols).fill(0));
|
|
62
|
+
for (let row = 0; row < rows; row += 1) {
|
|
63
|
+
matrix[row][0] = row;
|
|
64
|
+
}
|
|
65
|
+
for (let col = 0; col < cols; col += 1) {
|
|
66
|
+
matrix[0][col] = col;
|
|
67
|
+
}
|
|
68
|
+
for (let row = 1; row < rows; row += 1) {
|
|
69
|
+
for (let col = 1; col < cols; col += 1) {
|
|
70
|
+
const substitutionCost = right[row - 1] === left[col - 1] ? 0 : 1;
|
|
71
|
+
matrix[row][col] = Math.min(matrix[row - 1][col] + 1, matrix[row][col - 1] + 1, matrix[row - 1][col - 1] + substitutionCost);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return matrix[rows - 1][cols - 1];
|
|
75
|
+
}
|