@mh-alikhani/bunready 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/CHANGELOG.md +140 -0
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/action.yml +89 -0
- package/docs/CONFIGURATION.md +44 -0
- package/docs/JSON-OUTPUT.md +50 -0
- package/docs/RELEASING.md +65 -0
- package/docs/adr/0001-data-source-policy.md +36 -0
- package/docs/adr/0002-rule-severity-model.md +42 -0
- package/docs/adr/0003-release-pipeline.md +51 -0
- package/docs/brand/favicon.svg +8 -0
- package/docs/brand/guidelines.md +70 -0
- package/docs/brand/logo-dark.svg +11 -0
- package/docs/brand/logo-mono.svg +11 -0
- package/docs/brand/logo.svg +11 -0
- package/docs/brand/mark.svg +8 -0
- package/docs/brand/tokens.json +74 -0
- package/docs/demo.md +37 -0
- package/package.json +71 -0
- package/src/cli/args.ts +177 -0
- package/src/cli/copy.ts +76 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/io.ts +20 -0
- package/src/cli/run.ts +98 -0
- package/src/cli/theme.ts +59 -0
- package/src/config/baseline.ts +116 -0
- package/src/config/config.ts +113 -0
- package/src/core/errors.ts +59 -0
- package/src/core/fs.ts +72 -0
- package/src/core/version.ts +9 -0
- package/src/report/human.ts +100 -0
- package/src/report/json.ts +11 -0
- package/src/report/sarif.ts +73 -0
- package/src/report/types.ts +114 -0
- package/src/rules/data/native-packages.json +81 -0
- package/src/rules/data/node-runtime.json +6 -0
- package/src/rules/install/engines.ts +74 -0
- package/src/rules/install/index.ts +27 -0
- package/src/rules/install/lifecycle-scripts.ts +70 -0
- package/src/rules/install/lockfile-presence.ts +68 -0
- package/src/rules/install/native-addon.ts +126 -0
- package/src/rules/run/index.ts +114 -0
- package/src/rules/runtime/builtins.ts +148 -0
- package/src/rules/runtime/index.ts +18 -0
- package/src/rules/severity.ts +46 -0
- package/src/scanner/execute.ts +301 -0
- package/src/scanner/graph.ts +77 -0
- package/src/scanner/lockfile.ts +545 -0
- package/src/scanner/manifest.ts +109 -0
- package/src/scanner/scan.ts +322 -0
- package/src/scanner/semver.ts +227 -0
- package/src/scanner/sources.ts +355 -0
- package/src/scanner/target.ts +224 -0
- package/src/scanner/workspaces.ts +170 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { compareSeverity, type Severity } from "../rules/severity";
|
|
2
|
+
import type { Finding, ScanReport } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SARIF 2.1.0 output, so the same findings can be uploaded to GitHub code
|
|
6
|
+
* scanning (or any SARIF consumer) without a second implementation of the rules.
|
|
7
|
+
*/
|
|
8
|
+
const LEVELS: Readonly<Record<Severity, string>> = {
|
|
9
|
+
blocker: "error",
|
|
10
|
+
risk: "warning",
|
|
11
|
+
info: "note",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json";
|
|
15
|
+
const INFORMATION_URI = "https://github.com/MHAlikhani/bunready";
|
|
16
|
+
|
|
17
|
+
function worstByRule(
|
|
18
|
+
findings: readonly Finding[],
|
|
19
|
+
): Map<string, { severity: Severity; sample: Finding }> {
|
|
20
|
+
const byRule = new Map<string, { severity: Severity; sample: Finding }>();
|
|
21
|
+
for (const finding of findings) {
|
|
22
|
+
const existing = byRule.get(finding.id);
|
|
23
|
+
if (existing === undefined || compareSeverity(finding.severity, existing.severity) < 0) {
|
|
24
|
+
byRule.set(finding.id, { severity: finding.severity, sample: finding });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return byRule;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function renderSarifReport(report: ScanReport): string {
|
|
31
|
+
const byRule = worstByRule(report.findings);
|
|
32
|
+
|
|
33
|
+
const rules = [...byRule.entries()]
|
|
34
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
35
|
+
.map(([id, entry]) => ({
|
|
36
|
+
id,
|
|
37
|
+
name: id.replace(/[^A-Za-z0-9]+/g, "-"),
|
|
38
|
+
shortDescription: { text: entry.sample.title },
|
|
39
|
+
fullDescription: { text: entry.sample.detail },
|
|
40
|
+
defaultConfiguration: { level: LEVELS[entry.severity] },
|
|
41
|
+
...(entry.sample.source === undefined ? {} : { helpUri: entry.sample.source }),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const results = report.findings.map((finding) => ({
|
|
45
|
+
ruleId: finding.id,
|
|
46
|
+
level: LEVELS[finding.severity],
|
|
47
|
+
message: { text: `${finding.title}. ${finding.detail}` },
|
|
48
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: finding.path ?? report.target } } }],
|
|
49
|
+
partialFingerprints: { bunreadyFinding: `${finding.id}:${finding.title}` },
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
return JSON.stringify(
|
|
53
|
+
{
|
|
54
|
+
$schema: SARIF_SCHEMA,
|
|
55
|
+
version: "2.1.0",
|
|
56
|
+
runs: [
|
|
57
|
+
{
|
|
58
|
+
tool: {
|
|
59
|
+
driver: {
|
|
60
|
+
name: report.tool,
|
|
61
|
+
version: report.version,
|
|
62
|
+
informationUri: INFORMATION_URI,
|
|
63
|
+
rules,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
results,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
null,
|
|
71
|
+
2,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { compareSeverity, type Severity } from "../rules/severity";
|
|
2
|
+
|
|
3
|
+
/** The `--json` contract version. Bumped only for a breaking field change. */
|
|
4
|
+
export const SCHEMA_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A single thing bunready can say about a target repo.
|
|
8
|
+
*
|
|
9
|
+
* Every finding must be traceable to evidence: either something observed in the
|
|
10
|
+
* scanned repository (`evidence`) or a public Bun documentation / issue entry
|
|
11
|
+
* (`source`). A finding with neither should not ship.
|
|
12
|
+
*/
|
|
13
|
+
export interface Finding {
|
|
14
|
+
/** Stable rule id, namespaced by phase, e.g. `install/native-addon`. */
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly severity: Severity;
|
|
17
|
+
readonly title: string;
|
|
18
|
+
readonly detail: string;
|
|
19
|
+
/** The dependency this finding is about, when there is one. */
|
|
20
|
+
readonly package?: string;
|
|
21
|
+
/** The scanned directory this finding came from; set when more than one was scanned. */
|
|
22
|
+
readonly path?: string;
|
|
23
|
+
/** Present when a baseline was applied: false means it was already accepted. */
|
|
24
|
+
readonly isNew?: boolean;
|
|
25
|
+
/** Observed proof from the scanned repo, e.g. the offending dependency. */
|
|
26
|
+
readonly evidence?: string;
|
|
27
|
+
/** Link to the Bun doc or issue backing the compatibility claim. */
|
|
28
|
+
readonly source?: string;
|
|
29
|
+
readonly hint?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type Verdict = "ready" | "risky" | "blocked";
|
|
33
|
+
|
|
34
|
+
/** One scanned directory in a multi-package repository. */
|
|
35
|
+
export interface ScannedTarget {
|
|
36
|
+
readonly path: string;
|
|
37
|
+
readonly relative: string;
|
|
38
|
+
readonly kind: "root" | "workspace";
|
|
39
|
+
readonly name: string | undefined;
|
|
40
|
+
readonly verdict: Verdict;
|
|
41
|
+
readonly counts: Readonly<Record<Severity, number>>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What a baseline did, when one was applied. */
|
|
45
|
+
export interface BaselineSummary {
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly known: number;
|
|
48
|
+
readonly new: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What `--run` actually did, so a run result can be read without the log. */
|
|
52
|
+
export interface RunSummary {
|
|
53
|
+
readonly script: string | undefined;
|
|
54
|
+
readonly installExitCode: number | null;
|
|
55
|
+
readonly exitCode: number | null;
|
|
56
|
+
readonly timedOut: boolean;
|
|
57
|
+
readonly durationMs: number | undefined;
|
|
58
|
+
readonly firstFailure: string | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What the scan looked at, so a verdict can be read in proportion. */
|
|
62
|
+
export interface ScanStats {
|
|
63
|
+
readonly directDependencies: number;
|
|
64
|
+
readonly devDependencies: number;
|
|
65
|
+
readonly lockedPackages: number;
|
|
66
|
+
readonly duplicateVersions: number;
|
|
67
|
+
readonly lockfiles: readonly string[];
|
|
68
|
+
readonly sourceFiles: number;
|
|
69
|
+
readonly nodeBuiltins: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The machine-readable shape emitted by `--json`. */
|
|
73
|
+
export interface ScanReport {
|
|
74
|
+
/** Bumped when a field is renamed or removed. CI can pin on it. */
|
|
75
|
+
readonly schemaVersion: number;
|
|
76
|
+
/** Lowest severity that makes this report fail; the exit code follows it. */
|
|
77
|
+
readonly failOn: Severity;
|
|
78
|
+
readonly tool: string;
|
|
79
|
+
readonly version: string;
|
|
80
|
+
readonly target: string;
|
|
81
|
+
readonly verdict: Verdict;
|
|
82
|
+
readonly counts: Readonly<Record<Severity, number>>;
|
|
83
|
+
readonly findings: readonly Finding[];
|
|
84
|
+
readonly stats?: ScanStats;
|
|
85
|
+
readonly run?: RunSummary;
|
|
86
|
+
/** Present only when more than one directory was scanned. */
|
|
87
|
+
readonly targets?: readonly ScannedTarget[];
|
|
88
|
+
readonly baseline?: BaselineSummary;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Blockers first, then risks, then info; stable within a severity. */
|
|
92
|
+
export function sortFindings(findings: readonly Finding[]): Finding[] {
|
|
93
|
+
return [...findings].sort((a, b) => {
|
|
94
|
+
const bySeverity = compareSeverity(a.severity, b.severity);
|
|
95
|
+
if (bySeverity !== 0) {
|
|
96
|
+
return bySeverity;
|
|
97
|
+
}
|
|
98
|
+
const byId = a.id.localeCompare(b.id);
|
|
99
|
+
return byId !== 0 ? byId : a.title.localeCompare(b.title);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function verdictFor(findings: readonly Finding[]): Verdict {
|
|
104
|
+
let verdict: Verdict = "ready";
|
|
105
|
+
for (const finding of findings) {
|
|
106
|
+
if (finding.severity === "blocker") {
|
|
107
|
+
return "blocked";
|
|
108
|
+
}
|
|
109
|
+
if (finding.severity === "risk") {
|
|
110
|
+
verdict = "risky";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return verdict;
|
|
114
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"datasetVersion": "2026-09-15.1",
|
|
3
|
+
"note": "Packages that are known to ship a native addon or to download a platform binary at install time. Each entry asserts a property of the PACKAGE (visible in its own published metadata), not a Bun compatibility claim. bunready never asserts that one of these will fail on Bun; it reports the build step so the user can check their own platform. Entries are only added with a source link, per docs/adr/0001-data-source-policy.md.",
|
|
4
|
+
"packages": [
|
|
5
|
+
{
|
|
6
|
+
"name": "better-sqlite3",
|
|
7
|
+
"reason": "Native addon built with node-gyp; needs a matching prebuild or a working C++ toolchain.",
|
|
8
|
+
"source": "https://www.npmjs.com/package/better-sqlite3"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "sharp",
|
|
12
|
+
"reason": "Ships prebuilt libvips binaries per platform and rebuilds from source when no prebuild matches.",
|
|
13
|
+
"source": "https://www.npmjs.com/package/sharp"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "canvas",
|
|
17
|
+
"reason": "Native addon built with node-gyp against system cairo/pango libraries.",
|
|
18
|
+
"source": "https://www.npmjs.com/package/canvas"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "bcrypt",
|
|
22
|
+
"reason": "Native addon built with node-gyp; the prebuild is platform specific.",
|
|
23
|
+
"source": "https://www.npmjs.com/package/bcrypt"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "sqlite3",
|
|
27
|
+
"reason": "Native addon built with node-gyp, with a prebuild download step.",
|
|
28
|
+
"source": "https://www.npmjs.com/package/sqlite3"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "node-sass",
|
|
32
|
+
"reason": "Native addon with a binary download step; deprecated upstream in favour of dart-sass.",
|
|
33
|
+
"source": "https://www.npmjs.com/package/node-sass"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "argon2",
|
|
37
|
+
"reason": "Native addon built with node-gyp.",
|
|
38
|
+
"source": "https://www.npmjs.com/package/argon2"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "leveldown",
|
|
42
|
+
"reason": "Native LevelDB binding built with node-gyp.",
|
|
43
|
+
"source": "https://www.npmjs.com/package/leveldown"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "zeromq",
|
|
47
|
+
"reason": "Native addon with a bundled build step for libzmq.",
|
|
48
|
+
"source": "https://www.npmjs.com/package/zeromq"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "isolated-vm",
|
|
52
|
+
"reason": "Native addon built with node-gyp.",
|
|
53
|
+
"source": "https://www.npmjs.com/package/isolated-vm"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "node-pty",
|
|
57
|
+
"reason": "Native addon built with node-gyp or fetched as a prebuild.",
|
|
58
|
+
"source": "https://www.npmjs.com/package/node-pty"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "bufferutil",
|
|
62
|
+
"reason": "Optional native addon with a node-gyp fallback build.",
|
|
63
|
+
"source": "https://www.npmjs.com/package/bufferutil"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "utf-8-validate",
|
|
67
|
+
"reason": "Optional native addon with a node-gyp fallback build.",
|
|
68
|
+
"source": "https://www.npmjs.com/package/utf-8-validate"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"name": "puppeteer",
|
|
72
|
+
"reason": "Downloads a Chromium build in a postinstall script.",
|
|
73
|
+
"source": "https://www.npmjs.com/package/puppeteer"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "@prisma/client",
|
|
77
|
+
"reason": "Runs a postinstall that generates and downloads query engine binaries.",
|
|
78
|
+
"source": "https://www.npmjs.com/package/@prisma/client"
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"datasetVersion": "2026-09-15.1",
|
|
3
|
+
"compatibilityDocs": "https://bun.com/docs/runtime/nodejs-compat",
|
|
4
|
+
"note": "Runtime compatibility data. `gaps` is intentionally EMPTY: every entry here asserts that a specific Node built-in is partial or missing, and that claim needs a primary source that was read, not remembered. Bun's own position is that a package working in Node.js and failing in Bun is a bug in Bun, so a module is only listed once a specific issue or documentation entry establishes the gap. Until then bunready reports what the repository imports and points at the compatibility table instead of guessing.",
|
|
5
|
+
"gaps": []
|
|
6
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Finding } from "../../report/types";
|
|
2
|
+
import type { Manifest } from "../../scanner/manifest";
|
|
3
|
+
import { satisfies } from "../../scanner/semver";
|
|
4
|
+
|
|
5
|
+
/** The runtime the scan is running under, injected so tests are deterministic. */
|
|
6
|
+
export interface RuntimeInfo {
|
|
7
|
+
readonly bun: string;
|
|
8
|
+
readonly node: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `engines` conflicts.
|
|
13
|
+
*
|
|
14
|
+
* A declared Bun range that the running Bun does not satisfy is a blocker: the
|
|
15
|
+
* project states it needs a different runtime, and no amount of user patience
|
|
16
|
+
* changes that. A declared Node range that Bun's Node compatibility layer does
|
|
17
|
+
* not satisfy is a risk, because Bun's compatibility work often covers an
|
|
18
|
+
* older range than the project asks for, and only the project's own tests can
|
|
19
|
+
* settle it. A range we cannot parse is reported as info, never as a failure.
|
|
20
|
+
*/
|
|
21
|
+
export function enginesFindings(manifest: Manifest, runtime: RuntimeInfo): Finding[] {
|
|
22
|
+
const findings: Finding[] = [];
|
|
23
|
+
|
|
24
|
+
const bunRange = manifest.engines.bun;
|
|
25
|
+
if (bunRange !== undefined) {
|
|
26
|
+
const satisfied = satisfies(runtime.bun, bunRange);
|
|
27
|
+
if (satisfied === false) {
|
|
28
|
+
findings.push({
|
|
29
|
+
id: "install/engines-bun",
|
|
30
|
+
severity: "blocker",
|
|
31
|
+
title: `this project requires Bun ${bunRange}`,
|
|
32
|
+
detail: "The running Bun does not satisfy the range this project declares in package.json.",
|
|
33
|
+
evidence: `engines.bun = "${bunRange}", running Bun ${runtime.bun}`,
|
|
34
|
+
hint: `switch to a Bun version matching ${bunRange} before running this project.`,
|
|
35
|
+
});
|
|
36
|
+
} else if (satisfied === undefined) {
|
|
37
|
+
findings.push({
|
|
38
|
+
id: "install/engines-bun",
|
|
39
|
+
severity: "info",
|
|
40
|
+
title: "could not evaluate the declared Bun range",
|
|
41
|
+
detail:
|
|
42
|
+
"bunready evaluates a subset of semver (comparators, caret, tilde, x-ranges, hyphen ranges, `||`). This range uses syntax outside that subset, so it was reported instead of judged.",
|
|
43
|
+
evidence: `engines.bun = "${bunRange}", running Bun ${runtime.bun}`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const nodeRange = manifest.engines.node;
|
|
49
|
+
if (nodeRange !== undefined) {
|
|
50
|
+
const satisfied = satisfies(runtime.node, nodeRange);
|
|
51
|
+
if (satisfied === false) {
|
|
52
|
+
findings.push({
|
|
53
|
+
id: "install/engines-node",
|
|
54
|
+
severity: "risk",
|
|
55
|
+
title: `this project declares Node ${nodeRange}`,
|
|
56
|
+
detail:
|
|
57
|
+
"Bun reports a different Node compatibility version, so any code path that depends on Node-version-specific behaviour should be exercised before the move.",
|
|
58
|
+
evidence: `engines.node = "${nodeRange}", Bun reports Node ${runtime.node}`,
|
|
59
|
+
hint: "check the project's own test suite under Bun; a Node range alone does not decide Bun compatibility.",
|
|
60
|
+
});
|
|
61
|
+
} else if (satisfied === undefined) {
|
|
62
|
+
findings.push({
|
|
63
|
+
id: "install/engines-node",
|
|
64
|
+
severity: "info",
|
|
65
|
+
title: "could not evaluate the declared Node range",
|
|
66
|
+
detail:
|
|
67
|
+
"bunready evaluates a subset of semver and this range falls outside it, so it was reported instead of judged.",
|
|
68
|
+
evidence: `engines.node = "${nodeRange}", Bun reports Node ${runtime.node}`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return findings;
|
|
74
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Finding, sortFindings } from "../../report/types";
|
|
2
|
+
import type { DependencyGraph } from "../../scanner/graph";
|
|
3
|
+
import type { TargetSnapshot } from "../../scanner/target";
|
|
4
|
+
import { enginesFindings, type RuntimeInfo } from "./engines";
|
|
5
|
+
import { lifecycleScriptFindings } from "./lifecycle-scripts";
|
|
6
|
+
import { lockfileFindings } from "./lockfile-presence";
|
|
7
|
+
import { nativeAddonFindings } from "./native-addon";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every install-phase rule. Runtime-phase rules (Node built-ins Bun does not
|
|
11
|
+
* implement, the curated list of packages known to misbehave at runtime) are a
|
|
12
|
+
* separate phase and are deliberately absent.
|
|
13
|
+
*/
|
|
14
|
+
export function installFindings(
|
|
15
|
+
snapshot: TargetSnapshot,
|
|
16
|
+
graph: DependencyGraph,
|
|
17
|
+
runtime: RuntimeInfo,
|
|
18
|
+
): Finding[] {
|
|
19
|
+
const primaryLockfile = snapshot.lockfiles[0]?.parsed;
|
|
20
|
+
|
|
21
|
+
return sortFindings([
|
|
22
|
+
...lockfileFindings(snapshot),
|
|
23
|
+
...nativeAddonFindings(snapshot, graph, primaryLockfile),
|
|
24
|
+
...lifecycleScriptFindings(snapshot, primaryLockfile),
|
|
25
|
+
...enginesFindings(snapshot.manifest, runtime),
|
|
26
|
+
]);
|
|
27
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Finding } from "../../report/types";
|
|
2
|
+
import type { ParsedLockfile } from "../../scanner/lockfile";
|
|
3
|
+
import type { TargetSnapshot } from "../../scanner/target";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The highest-value rule in the install phase.
|
|
7
|
+
*
|
|
8
|
+
* `bun install` does not run lifecycle scripts for packages that are not listed
|
|
9
|
+
* in `trustedDependencies`. That is a real, observable behaviour change when
|
|
10
|
+
* moving off npm, and when a dependency's install step is what builds a native
|
|
11
|
+
* addon or downloads a binary, skipping it leaves the package broken. The user
|
|
12
|
+
* cannot fix it by "doing nothing", so it is a blocker.
|
|
13
|
+
*
|
|
14
|
+
* Evidence comes from the lockfile (npm's `hasInstallScript`, pnpm's
|
|
15
|
+
* `requiresBuild`) or from an installed copy's own manifest. Both are observed
|
|
16
|
+
* facts; nothing is inferred from package names.
|
|
17
|
+
*/
|
|
18
|
+
export const LIFECYCLE_DOC = "https://bun.com/docs/pm/lifecycle";
|
|
19
|
+
export const TRUSTED_DEPENDENCIES_GUIDE = "https://bun.com/guides/install/trusted";
|
|
20
|
+
|
|
21
|
+
const ID = "install/lifecycle-script";
|
|
22
|
+
|
|
23
|
+
export function lifecycleScriptFindings(
|
|
24
|
+
snapshot: TargetSnapshot,
|
|
25
|
+
lockfile: ParsedLockfile | undefined,
|
|
26
|
+
): Finding[] {
|
|
27
|
+
const trusted = new Set(snapshot.manifest.trustedDependencies);
|
|
28
|
+
const evidence = new Map<string, string>();
|
|
29
|
+
|
|
30
|
+
for (const pkg of lockfile?.packages ?? []) {
|
|
31
|
+
if (pkg.installScript) {
|
|
32
|
+
evidence.set(pkg.name, `the lockfile marks ${pkg.name} as requiring a build step`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const probe of snapshot.packageEvidence) {
|
|
37
|
+
if (probe.installScripts.length === 0) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const scripts = probe.installScripts.map((name) => `"${name}"`).join(", ");
|
|
41
|
+
evidence.set(probe.name, `${probe.path} declares ${scripts}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return [...evidence.entries()]
|
|
45
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
46
|
+
.map(([name, where]) => {
|
|
47
|
+
if (trusted.has(name)) {
|
|
48
|
+
return {
|
|
49
|
+
id: ID,
|
|
50
|
+
severity: "info" as const,
|
|
51
|
+
title: `${name} runs an install script and is trusted`,
|
|
52
|
+
package: name,
|
|
53
|
+
detail:
|
|
54
|
+
"Bun will run this package's install script because it is listed in trustedDependencies.",
|
|
55
|
+
evidence: where,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
id: ID,
|
|
60
|
+
severity: "blocker" as const,
|
|
61
|
+
title: `${name} installs nothing: its install script will not run`,
|
|
62
|
+
package: name,
|
|
63
|
+
detail:
|
|
64
|
+
"Bun installs dependencies without running their lifecycle scripts unless the package is listed in trustedDependencies. If this package builds a native addon or downloads a binary during install, that step is skipped.",
|
|
65
|
+
evidence: where,
|
|
66
|
+
hint: `add "${name}" to trustedDependencies in package.json, then reinstall.`,
|
|
67
|
+
source: LIFECYCLE_DOC,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Finding } from "../../report/types";
|
|
2
|
+
import type { TargetSnapshot } from "../../scanner/target";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the scan could and could not see.
|
|
6
|
+
*
|
|
7
|
+
* These findings describe the evidence base itself. A scan that could not read
|
|
8
|
+
* the lockfile must say so, because "no dependency problems found" would
|
|
9
|
+
* otherwise be indistinguishable from "we only looked at one package.json".
|
|
10
|
+
*/
|
|
11
|
+
const NO_LOCKFILE_ID = "install/no-lockfile";
|
|
12
|
+
const BINARY_LOCKFILE_ID = "install/binary-lockfile";
|
|
13
|
+
const UNPARSED_LOCKFILE_ID = "install/unparsed-lockfile";
|
|
14
|
+
const MULTIPLE_LOCKFILES_ID = "install/multiple-lockfiles";
|
|
15
|
+
|
|
16
|
+
export function lockfileFindings(snapshot: TargetSnapshot): Finding[] {
|
|
17
|
+
const findings: Finding[] = [];
|
|
18
|
+
|
|
19
|
+
if (snapshot.lockfiles.length === 0 && snapshot.binaryBunLock === undefined) {
|
|
20
|
+
findings.push({
|
|
21
|
+
id: NO_LOCKFILE_ID,
|
|
22
|
+
severity: "info",
|
|
23
|
+
title: "no lockfile found",
|
|
24
|
+
detail:
|
|
25
|
+
"Without a lockfile the dependency graph covers only the packages named in package.json, so transitive install-phase problems cannot be seen.",
|
|
26
|
+
evidence: `looked for bun.lock, package-lock.json, yarn.lock and pnpm-lock.yaml in ${snapshot.dir}`,
|
|
27
|
+
hint: "commit a lockfile so the move to Bun installs the same versions you tested.",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (snapshot.binaryBunLock !== undefined) {
|
|
32
|
+
findings.push({
|
|
33
|
+
id: BINARY_LOCKFILE_ID,
|
|
34
|
+
severity: "risk",
|
|
35
|
+
title: "bun.lockb is a binary lockfile",
|
|
36
|
+
detail:
|
|
37
|
+
"bunready reads the text lockfile format only. The contents of bun.lockb are not guessed at, so this scan cannot see your transitive dependencies.",
|
|
38
|
+
evidence: `${snapshot.binaryBunLock} exists`,
|
|
39
|
+
hint: "regenerate the lockfile with `bun install` on Bun 1.2 or newer to produce the text format bun.lock.",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (snapshot.lockfiles.length > 1) {
|
|
44
|
+
const names = snapshot.lockfiles.map((entry) => entry.path).join(", ");
|
|
45
|
+
findings.push({
|
|
46
|
+
id: MULTIPLE_LOCKFILES_ID,
|
|
47
|
+
severity: "info",
|
|
48
|
+
title: "more than one lockfile is present",
|
|
49
|
+
detail:
|
|
50
|
+
"The graph was built from the highest-priority lockfile in the repository; the others were ignored.",
|
|
51
|
+
evidence: names,
|
|
52
|
+
hint: "remove the lockfiles belonging to the package manager you are actually leaving behind.",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const unparsed of snapshot.unparsedLockfiles) {
|
|
57
|
+
findings.push({
|
|
58
|
+
id: UNPARSED_LOCKFILE_ID,
|
|
59
|
+
severity: "risk",
|
|
60
|
+
title: `${unparsed.path} could not be read`,
|
|
61
|
+
detail: unparsed.message,
|
|
62
|
+
evidence: `${unparsed.kind} lockfile at ${unparsed.path}`,
|
|
63
|
+
hint: "regenerate the lockfile with the package manager that produced it, then scan again.",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return findings;
|
|
68
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { Finding } from "../../report/types";
|
|
2
|
+
import type { DependencyGraph } from "../../scanner/graph";
|
|
3
|
+
import { knownPackageNames } from "../../scanner/graph";
|
|
4
|
+
import type { ParsedLockfile } from "../../scanner/lockfile";
|
|
5
|
+
import type { TargetSnapshot } from "../../scanner/target";
|
|
6
|
+
import nativeDataset from "../data/native-packages.json";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Native-addon detection, in evidence order:
|
|
10
|
+
* 1. the package is in our curated list (a property of the package, with a
|
|
11
|
+
* source link), 2. the target's own manifest pulls in a build tool, 3. an
|
|
12
|
+
* installed copy declares `gypfile`.
|
|
13
|
+
*
|
|
14
|
+
* All three produce `risk`, never `blocker`: plenty of these packages ship a
|
|
15
|
+
* prebuilt binary for the common platforms, so "will not work on Bun" would be
|
|
16
|
+
* a claim bunready cannot back. The hard failure lives in the lifecycle rule,
|
|
17
|
+
* where a skipped install script is observable.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface NativePackageEntry {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly reason: string;
|
|
23
|
+
readonly source: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const BUILD_TOOL_PACKAGES = [
|
|
27
|
+
"node-gyp",
|
|
28
|
+
"node-pre-gyp",
|
|
29
|
+
"prebuild-install",
|
|
30
|
+
"node-gyp-build",
|
|
31
|
+
"cmake-js",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
const NATIVE_ADDON_ID = "install/native-addon";
|
|
35
|
+
const BUILD_TOOL_ID = "install/native-build-tools";
|
|
36
|
+
|
|
37
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Validate the vendored dataset instead of trusting the import blindly. */
|
|
42
|
+
export function readDataset(raw: unknown = nativeDataset): NativePackageEntry[] {
|
|
43
|
+
if (!isRecord(raw) || !Array.isArray(raw.packages)) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const entries: NativePackageEntry[] = [];
|
|
47
|
+
for (const candidate of raw.packages) {
|
|
48
|
+
if (!isRecord(candidate)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const { name, reason, source } = candidate;
|
|
52
|
+
if (typeof name === "string" && typeof reason === "string" && typeof source === "string") {
|
|
53
|
+
entries.push({ name, reason, source });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return entries;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function nativeAddonFindings(
|
|
60
|
+
snapshot: TargetSnapshot,
|
|
61
|
+
graph: DependencyGraph,
|
|
62
|
+
lockfile: ParsedLockfile | undefined,
|
|
63
|
+
): Finding[] {
|
|
64
|
+
const findings = new Map<string, Finding>();
|
|
65
|
+
const known = knownPackageNames(graph, lockfile);
|
|
66
|
+
const direct = new Set([...graph.direct, ...graph.dev, ...graph.optional]);
|
|
67
|
+
const allowed = new Set(snapshot.config.nativeAllowlist);
|
|
68
|
+
|
|
69
|
+
for (const entry of readDataset()) {
|
|
70
|
+
if (!known.has(entry.name) || allowed.has(entry.name)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
findings.set(entry.name, {
|
|
74
|
+
id: NATIVE_ADDON_ID,
|
|
75
|
+
severity: "risk",
|
|
76
|
+
title: `${entry.name} ships a native addon`,
|
|
77
|
+
detail: entry.reason,
|
|
78
|
+
package: entry.name,
|
|
79
|
+
evidence: direct.has(entry.name) ? "declared in package.json" : "present in the lockfile",
|
|
80
|
+
source: entry.source,
|
|
81
|
+
hint: "check that a prebuilt binary exists for your platform, otherwise this one needs a working C/C++ toolchain.",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const evidence of snapshot.packageEvidence) {
|
|
86
|
+
if (!evidence.gypfile || findings.has(evidence.name)) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
findings.set(evidence.name, {
|
|
90
|
+
id: NATIVE_ADDON_ID,
|
|
91
|
+
severity: "risk",
|
|
92
|
+
title: `${evidence.name} builds a native addon on install`,
|
|
93
|
+
detail:
|
|
94
|
+
"The installed copy of this package sets `gypfile`, so it compiles a native addon rather than shipping one.",
|
|
95
|
+
package: evidence.name,
|
|
96
|
+
evidence: `${evidence.path} sets "gypfile": true`,
|
|
97
|
+
hint: "this needs a C/C++ toolchain and a Python interpreter available at install time.",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const declaredTools = BUILD_TOOL_PACKAGES.filter((tool) => {
|
|
102
|
+
if (allowed.has(tool)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
if (direct.has(tool)) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
return Object.values(snapshot.manifest.scripts).some((script) => script.includes(tool));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const result = [...findings.values()];
|
|
112
|
+
if (declaredTools.length > 0) {
|
|
113
|
+
result.push({
|
|
114
|
+
id: BUILD_TOOL_ID,
|
|
115
|
+
severity: "risk",
|
|
116
|
+
title: "the project builds native code with node-gyp tooling",
|
|
117
|
+
detail: `Resolving these build tools is a prerequisite for every native dependency to install: ${declaredTools.join(", ")}.`,
|
|
118
|
+
evidence: direct.has(declaredTools[0] ?? "")
|
|
119
|
+
? `declared as a dependency: ${declaredTools.join(", ")}`
|
|
120
|
+
: `referenced by a package.json script: ${declaredTools.join(", ")}`,
|
|
121
|
+
hint: "install the toolchain you already rely on, or switch the dependency to a package that ships prebuilds.",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return result;
|
|
126
|
+
}
|