@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,114 @@
|
|
|
1
|
+
import type { Finding } from "../../report/types";
|
|
2
|
+
import type { RunOutcome } from "../../scanner/execute";
|
|
3
|
+
import { DEFAULT_RUN_OPTIONS, type RunOptions } from "../../scanner/execute";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Findings for the `--run` phase.
|
|
7
|
+
*
|
|
8
|
+
* A timeout is a `risk`, never a `blocker`: a server that does not exit is
|
|
9
|
+
* behaving normally. A green script is `info`: the strongest evidence a scan can
|
|
10
|
+
* produce, and still not a compatibility claim.
|
|
11
|
+
*/
|
|
12
|
+
const INSTALL_ID = "run/install";
|
|
13
|
+
const SCRIPT_FAILED_ID = "run/script-failed";
|
|
14
|
+
const SCRIPT_PASSED_ID = "run/script-passed";
|
|
15
|
+
const TIMEOUT_ID = "run/timeout";
|
|
16
|
+
const NOTHING_TO_RUN_ID = "run/nothing-to-run";
|
|
17
|
+
const CLEANUP_ID = "run/cleanup";
|
|
18
|
+
const TOO_LARGE_ID = "run/copy-too-large";
|
|
19
|
+
|
|
20
|
+
function megabytes(bytes: number): string {
|
|
21
|
+
return `${Math.round(bytes / (1024 * 1024))} MB`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function runFindings(
|
|
25
|
+
outcome: RunOutcome,
|
|
26
|
+
options: RunOptions = DEFAULT_RUN_OPTIONS,
|
|
27
|
+
): Finding[] {
|
|
28
|
+
const findings: Finding[] = [];
|
|
29
|
+
|
|
30
|
+
if (outcome.copyTooLarge) {
|
|
31
|
+
findings.push({
|
|
32
|
+
id: TOO_LARGE_ID,
|
|
33
|
+
severity: "risk",
|
|
34
|
+
title: "the project was not executed: it is larger than the copy limit",
|
|
35
|
+
detail:
|
|
36
|
+
"Copying this repository would exceed the configured limit, so nothing was installed or run.",
|
|
37
|
+
evidence: `${megabytes(outcome.measuredBytes)} of source, limit ${options.maxCopyMegabytes} MB`,
|
|
38
|
+
hint: "raise run.maxCopyMegabytes in bunready.config.json if you want it executed anyway.",
|
|
39
|
+
});
|
|
40
|
+
return findings;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (outcome.installFailed) {
|
|
44
|
+
findings.push({
|
|
45
|
+
id: INSTALL_ID,
|
|
46
|
+
severity: "blocker",
|
|
47
|
+
title: "the project does not install from a clean copy under Bun",
|
|
48
|
+
detail:
|
|
49
|
+
"Dependencies could not be installed in a clean copy of the repository, so nothing could be started.",
|
|
50
|
+
evidence:
|
|
51
|
+
outcome.install?.timedOut === true
|
|
52
|
+
? `bun install did not finish within ${Math.round(options.installTimeoutMs / 1000)}s`
|
|
53
|
+
: `bun install exited with code ${outcome.install?.code ?? "unknown"}`,
|
|
54
|
+
hint:
|
|
55
|
+
outcome.failure?.message ??
|
|
56
|
+
"run `bun install` in a copy of the repository to see the full output.",
|
|
57
|
+
});
|
|
58
|
+
} else if (outcome.script === undefined) {
|
|
59
|
+
findings.push({
|
|
60
|
+
id: NOTHING_TO_RUN_ID,
|
|
61
|
+
severity: "info",
|
|
62
|
+
title: "no start or test script to run",
|
|
63
|
+
detail:
|
|
64
|
+
"The project installs cleanly, but it declares neither a `start` nor a `test` script, so there is nothing to exercise.",
|
|
65
|
+
evidence: "package.json declares no start or test script",
|
|
66
|
+
hint: "add a test script, or set run.script in bunready.config.json to the script you want.",
|
|
67
|
+
});
|
|
68
|
+
} else if (outcome.result?.timedOut === true) {
|
|
69
|
+
findings.push({
|
|
70
|
+
id: TIMEOUT_ID,
|
|
71
|
+
severity: "risk",
|
|
72
|
+
title: `bun run ${outcome.script} did not finish in time`,
|
|
73
|
+
detail:
|
|
74
|
+
"The script was still running when the timeout expired. A long-running server behaves this way, so this is not evidence of a failure.",
|
|
75
|
+
evidence: `no exit within ${Math.round(options.scriptTimeoutMs / 1000)}s`,
|
|
76
|
+
hint: "run it yourself with a longer timeout if you need a verdict on this script.",
|
|
77
|
+
});
|
|
78
|
+
} else if (outcome.result !== undefined && outcome.result.code === 0) {
|
|
79
|
+
findings.push({
|
|
80
|
+
id: SCRIPT_PASSED_ID,
|
|
81
|
+
severity: "info",
|
|
82
|
+
title: `bun run ${outcome.script} completed successfully`,
|
|
83
|
+
detail:
|
|
84
|
+
"The project installed and its script ran to completion under Bun in a clean copy. This is the strongest evidence a scan can produce.",
|
|
85
|
+
evidence: `exit code 0 after ${outcome.result.durationMs}ms`,
|
|
86
|
+
});
|
|
87
|
+
} else if (outcome.result !== undefined) {
|
|
88
|
+
findings.push({
|
|
89
|
+
id: SCRIPT_FAILED_ID,
|
|
90
|
+
severity: "blocker",
|
|
91
|
+
title: `bun run ${outcome.script} failed`,
|
|
92
|
+
detail: "The script exited with an error under Bun in a clean copy of the repository.",
|
|
93
|
+
evidence: [
|
|
94
|
+
`exit code ${outcome.result.code}`,
|
|
95
|
+
outcome.failure?.message ?? "no error line was recognised in the output",
|
|
96
|
+
...(outcome.failure?.frames ?? []),
|
|
97
|
+
].join("\n "),
|
|
98
|
+
hint: `reproduce with a copy of the repository: bun install && bun run ${outcome.script}`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (outcome.cleanupFailed) {
|
|
103
|
+
findings.push({
|
|
104
|
+
id: CLEANUP_ID,
|
|
105
|
+
severity: "info",
|
|
106
|
+
title: "the temporary copy could not be removed",
|
|
107
|
+
detail: "The run finished but its working directory is still on disk.",
|
|
108
|
+
evidence: outcome.workDir,
|
|
109
|
+
hint: "delete it by hand; on Windows a file handle held by another process causes this.",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return findings;
|
|
114
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { Finding } from "../../report/types";
|
|
2
|
+
import { classifySpecifier, type SourceScan } from "../../scanner/sources";
|
|
3
|
+
import runtimeDataset from "../data/node-runtime.json";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Runtime-phase rule: what Node surface the repository actually depends on.
|
|
7
|
+
*
|
|
8
|
+
* The finding is deliberately an inventory with a pointer, not a verdict. Bun's
|
|
9
|
+
* Node compatibility is broad, actively changing, and version-dependent, so
|
|
10
|
+
* bunready only asserts what it can observe (these modules are imported in these
|
|
11
|
+
* files) and cites the compatibility table. A module is called out as a risk
|
|
12
|
+
* only when the vendored dataset carries a primary source for that specific
|
|
13
|
+
* claim - see docs/adr/0001-data-source-policy.md.
|
|
14
|
+
*/
|
|
15
|
+
const INVENTORY_ID = "runtime/node-builtins";
|
|
16
|
+
const GAP_ID = "runtime/known-gap";
|
|
17
|
+
const COVERAGE_ID = "runtime/scan-coverage";
|
|
18
|
+
const MAX_LISTED_MODULES = 8;
|
|
19
|
+
|
|
20
|
+
export interface RuntimeGapEntry {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly status: "partial" | "unimplemented";
|
|
23
|
+
readonly note: string;
|
|
24
|
+
readonly source: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RuntimeDataset {
|
|
28
|
+
readonly compatibilityDocs: string | undefined;
|
|
29
|
+
readonly gaps: readonly RuntimeGapEntry[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BuiltinUsage {
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly files: readonly string[];
|
|
35
|
+
}
|
|
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 readRuntimeDataset(raw: unknown = runtimeDataset): RuntimeDataset {
|
|
43
|
+
if (!isRecord(raw)) {
|
|
44
|
+
return { compatibilityDocs: undefined, gaps: [] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const gaps: RuntimeGapEntry[] = [];
|
|
48
|
+
for (const candidate of Array.isArray(raw.gaps) ? raw.gaps : []) {
|
|
49
|
+
if (!isRecord(candidate)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const { name, status, note, source } = candidate;
|
|
53
|
+
if (
|
|
54
|
+
typeof name === "string" &&
|
|
55
|
+
(status === "partial" || status === "unimplemented") &&
|
|
56
|
+
typeof note === "string" &&
|
|
57
|
+
typeof source === "string"
|
|
58
|
+
) {
|
|
59
|
+
gaps.push({ name, status, note, source });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
compatibilityDocs:
|
|
65
|
+
typeof raw.compatibilityDocs === "string" ? raw.compatibilityDocs : undefined,
|
|
66
|
+
gaps,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Node built-ins imported by the repository's own code, with where they appear. */
|
|
71
|
+
export function collectNodeBuiltins(scan: SourceScan): BuiltinUsage[] {
|
|
72
|
+
const filesByName = new Map<string, Set<string>>();
|
|
73
|
+
|
|
74
|
+
for (const file of scan.files) {
|
|
75
|
+
for (const ref of file.imports) {
|
|
76
|
+
if (classifySpecifier(ref.specifier) !== "node-builtin") {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const name = ref.specifier.startsWith("node:") ? ref.specifier.slice(5) : ref.specifier;
|
|
80
|
+
const bucket = filesByName.get(name) ?? new Set<string>();
|
|
81
|
+
bucket.add(file.path);
|
|
82
|
+
filesByName.set(name, bucket);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return [...filesByName.entries()]
|
|
87
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
88
|
+
.map(([name, files]) => ({ name, files: [...files].sort() }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalise(name: string): string {
|
|
92
|
+
return name.startsWith("node:") ? name.slice(5) : name;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function runtimeBuiltinFindings(
|
|
96
|
+
scan: SourceScan,
|
|
97
|
+
usages: readonly BuiltinUsage[],
|
|
98
|
+
dataset: RuntimeDataset = readRuntimeDataset(),
|
|
99
|
+
): Finding[] {
|
|
100
|
+
const findings: Finding[] = [];
|
|
101
|
+
const byName = new Map(usages.map((usage) => [normalise(usage.name), usage]));
|
|
102
|
+
|
|
103
|
+
if (usages.length > 0) {
|
|
104
|
+
const listed = usages.slice(0, MAX_LISTED_MODULES).map((usage) => usage.name);
|
|
105
|
+
const remainder = usages.length - listed.length;
|
|
106
|
+
const fileCount = new Set(usages.flatMap((usage) => usage.files)).size;
|
|
107
|
+
findings.push({
|
|
108
|
+
id: INVENTORY_ID,
|
|
109
|
+
severity: "info",
|
|
110
|
+
title: `the project's own code imports ${usages.length} Node built-in module(s)`,
|
|
111
|
+
detail:
|
|
112
|
+
"Bun implements a large and still-moving part of the Node API. These are the modules this repository depends on; each was found by scanning the repository's own source, not its dependencies.",
|
|
113
|
+
evidence: `${listed.join(", ")}${remainder > 0 ? ` and ${remainder} more` : ""} (in ${fileCount} file(s))`,
|
|
114
|
+
...(dataset.compatibilityDocs === undefined ? {} : { source: dataset.compatibilityDocs }),
|
|
115
|
+
hint: "run the project's own test suite under Bun: it decides more about your code than a compatibility table can.",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const gap of dataset.gaps) {
|
|
120
|
+
const usage = byName.get(normalise(gap.name));
|
|
121
|
+
if (usage === undefined) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
findings.push({
|
|
125
|
+
id: GAP_ID,
|
|
126
|
+
severity: "risk",
|
|
127
|
+
title: `${gap.name} is ${gap.status} in Bun and this project imports it`,
|
|
128
|
+
detail: gap.note,
|
|
129
|
+
evidence: `${usage.files.length} file(s), first at ${usage.files[0] ?? "unknown"}`,
|
|
130
|
+
source: gap.source,
|
|
131
|
+
hint: `check ${gap.name} against the compatibility table and cover it with a test before switching.`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (scan.truncated) {
|
|
136
|
+
findings.push({
|
|
137
|
+
id: COVERAGE_ID,
|
|
138
|
+
severity: "info",
|
|
139
|
+
title: "the source scan stopped at its file limit",
|
|
140
|
+
detail:
|
|
141
|
+
"The import inventory covers only part of the repository, so an imported module may be missing from it.",
|
|
142
|
+
evidence: `scanned ${scan.filesScanned} source file(s)`,
|
|
143
|
+
hint: "scan a subdirectory, or raise the limit if you need the complete inventory.",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return findings;
|
|
148
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Finding, sortFindings } from "../../report/types";
|
|
2
|
+
import type { SourceScan } from "../../scanner/sources";
|
|
3
|
+
import { type BuiltinUsage, type RuntimeDataset, runtimeBuiltinFindings } from "./builtins";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every runtime-phase rule.
|
|
7
|
+
*
|
|
8
|
+
* Currently one rule family: the Node surface the repository imports. Rules that
|
|
9
|
+
* would need compatibility claims bunready cannot source belong in the vendored
|
|
10
|
+
* dataset, not in code.
|
|
11
|
+
*/
|
|
12
|
+
export function runtimeFindings(
|
|
13
|
+
scan: SourceScan,
|
|
14
|
+
usages: readonly BuiltinUsage[],
|
|
15
|
+
dataset?: RuntimeDataset,
|
|
16
|
+
): Finding[] {
|
|
17
|
+
return sortFindings(runtimeBuiltinFindings(scan, usages, dataset));
|
|
18
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Severity model shared by every rule.
|
|
3
|
+
*
|
|
4
|
+
* See docs/adr/0002-rule-severity-model.md: severity is a decision, not a
|
|
5
|
+
* feeling. `blocker` means the target repo cannot run correctly on Bun until it
|
|
6
|
+
* is fixed and it therefore changes the process exit code; `risk` is a real
|
|
7
|
+
* hazard that needs a human judgement call; `info` is context.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const SEVERITIES = ["blocker", "risk", "info"] as const;
|
|
11
|
+
|
|
12
|
+
export type Severity = (typeof SEVERITIES)[number];
|
|
13
|
+
|
|
14
|
+
/** Lower rank sorts first: blockers surface above everything else. */
|
|
15
|
+
export const SEVERITY_RANK: Readonly<Record<Severity, number>> = {
|
|
16
|
+
blocker: 0,
|
|
17
|
+
risk: 1,
|
|
18
|
+
info: 2,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function compareSeverity(a: Severity, b: Severity): number {
|
|
22
|
+
return SEVERITY_RANK[a] - SEVERITY_RANK[b];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function countBySeverity(
|
|
26
|
+
severities: readonly Severity[],
|
|
27
|
+
): Readonly<Record<Severity, number>> {
|
|
28
|
+
const counts: Record<Severity, number> = { blocker: 0, risk: 0, info: 0 };
|
|
29
|
+
for (const severity of severities) {
|
|
30
|
+
counts[severity] += 1;
|
|
31
|
+
}
|
|
32
|
+
return counts;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** CI contract: blockers fail the run, risks and notes do not. */
|
|
36
|
+
export function exitCodeForSeverities(severities: readonly Severity[]): number {
|
|
37
|
+
return severities.includes("blocker") ? 1 : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Exit code for a set of findings, honouring a configured `failOn` threshold. */
|
|
41
|
+
export function exitCodeForFindings(
|
|
42
|
+
findings: readonly { readonly severity: Severity }[],
|
|
43
|
+
failOn: Severity,
|
|
44
|
+
): number {
|
|
45
|
+
return findings.some((finding) => compareSeverity(finding.severity, failOn) <= 0) ? 1 : 0;
|
|
46
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { cp, mkdtemp, readdir, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { ok, type Result } from "../core/errors";
|
|
5
|
+
import type { Manifest } from "./manifest";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `--run`: the only part of bunready that executes the target's code.
|
|
9
|
+
*
|
|
10
|
+
* Safety rules, all enforced here: always a temporary copy and never in place;
|
|
11
|
+
* VCS data, dependencies and build output are not copied; a size cap is checked
|
|
12
|
+
* before copying; every command is timed; the copy is removed even on failure.
|
|
13
|
+
* There is no network sandbox, and the report says so.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const COPY_EXCLUDES = [
|
|
17
|
+
".git",
|
|
18
|
+
"node_modules",
|
|
19
|
+
"dist",
|
|
20
|
+
"coverage",
|
|
21
|
+
".next",
|
|
22
|
+
".turbo",
|
|
23
|
+
".cache",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export const RUNNABLE_SCRIPTS = ["start", "test"] as const;
|
|
27
|
+
|
|
28
|
+
export interface ProcessResult {
|
|
29
|
+
readonly code: number | null;
|
|
30
|
+
readonly stdout: string;
|
|
31
|
+
readonly stderr: string;
|
|
32
|
+
readonly timedOut: boolean;
|
|
33
|
+
readonly durationMs: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RunCommandOptions {
|
|
37
|
+
readonly cwd: string;
|
|
38
|
+
readonly timeoutMs: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CommandRunner {
|
|
42
|
+
readonly run: (command: readonly string[], options: RunCommandOptions) => Promise<ProcessResult>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RunEnvironment {
|
|
46
|
+
readonly runner: CommandRunner;
|
|
47
|
+
readonly makeTempDir: () => Promise<string>;
|
|
48
|
+
readonly copyProject: (from: string, to: string) => Promise<void>;
|
|
49
|
+
readonly removeDir: (path: string) => Promise<void>;
|
|
50
|
+
readonly measureTreeBytes: (path: string) => Promise<number>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RunFailure {
|
|
54
|
+
readonly message: string;
|
|
55
|
+
readonly frames: readonly string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RunOutcome {
|
|
59
|
+
readonly workDir: string;
|
|
60
|
+
readonly script: string | undefined;
|
|
61
|
+
readonly install: ProcessResult | undefined;
|
|
62
|
+
readonly installFailed: boolean;
|
|
63
|
+
readonly result: ProcessResult | undefined;
|
|
64
|
+
readonly failure: RunFailure | undefined;
|
|
65
|
+
readonly cleanupFailed: boolean;
|
|
66
|
+
readonly measuredBytes: number;
|
|
67
|
+
readonly copyTooLarge: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RunOptions {
|
|
71
|
+
readonly installTimeoutMs: number;
|
|
72
|
+
readonly scriptTimeoutMs: number;
|
|
73
|
+
readonly maxCopyMegabytes: number;
|
|
74
|
+
/** Explicit script name; defaults to the first of start/test that exists. */
|
|
75
|
+
readonly script?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const DEFAULT_RUN_OPTIONS: RunOptions = {
|
|
79
|
+
installTimeoutMs: 180_000,
|
|
80
|
+
scriptTimeoutMs: 120_000,
|
|
81
|
+
maxCopyMegabytes: 250,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const STACK_FRAME = /^\s+at\s+\S/;
|
|
85
|
+
const ERROR_HINT = /(\berror\b|\bError\b|\bfailed\b|\bFAIL\b|✖|✗|×)/;
|
|
86
|
+
const MAX_FRAMES = 5;
|
|
87
|
+
|
|
88
|
+
/** The message above the first stack frame, plus a few frames. */
|
|
89
|
+
export function firstFailure(output: string): RunFailure | undefined {
|
|
90
|
+
const lines = output.split(/\r?\n/).map((line) => line.trimEnd());
|
|
91
|
+
|
|
92
|
+
const stackStart = lines.findIndex((line) => STACK_FRAME.test(line));
|
|
93
|
+
if (stackStart !== -1) {
|
|
94
|
+
const frames: string[] = [];
|
|
95
|
+
for (let index = stackStart; index < lines.length && frames.length < MAX_FRAMES; index += 1) {
|
|
96
|
+
const line = lines[index] ?? "";
|
|
97
|
+
if (!STACK_FRAME.test(line)) {
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
frames.push(line.trim());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (let index = stackStart - 1; index >= 0; index -= 1) {
|
|
104
|
+
const candidate = (lines[index] ?? "").trim();
|
|
105
|
+
if (candidate !== "") {
|
|
106
|
+
return { message: candidate, frames };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { message: frames[0] ?? "process failed", frames };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const hinted = lines.findIndex((line) => line.trim() !== "" && ERROR_HINT.test(line));
|
|
113
|
+
if (hinted === -1) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
return { message: (lines[hinted] ?? "").trim(), frames: [] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function pickScript(manifest: Manifest, requested?: string): string | undefined {
|
|
120
|
+
if (requested !== undefined) {
|
|
121
|
+
return typeof manifest.scripts[requested] === "string" ? requested : undefined;
|
|
122
|
+
}
|
|
123
|
+
return RUNNABLE_SCRIPTS.find((name) => typeof manifest.scripts[name] === "string");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function isExcludedFromCopy(path: string): boolean {
|
|
127
|
+
return (COPY_EXCLUDES as readonly string[]).includes(basename(path));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function bunCommandRunner(): CommandRunner {
|
|
131
|
+
return {
|
|
132
|
+
run: async (command, options) => {
|
|
133
|
+
const started = Date.now();
|
|
134
|
+
const child = Bun.spawn([...command], {
|
|
135
|
+
cwd: options.cwd,
|
|
136
|
+
stdout: "pipe",
|
|
137
|
+
stderr: "pipe",
|
|
138
|
+
env: { ...Bun.env, CI: "1" },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
let timedOut = false;
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
timedOut = true;
|
|
144
|
+
child.kill();
|
|
145
|
+
}, options.timeoutMs);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const [stdout, stderr] = await Promise.all([
|
|
149
|
+
new Response(child.stdout).text(),
|
|
150
|
+
new Response(child.stderr).text(),
|
|
151
|
+
]);
|
|
152
|
+
const code = await child.exited;
|
|
153
|
+
return { code, stdout, stderr, timedOut, durationMs: Date.now() - started };
|
|
154
|
+
} finally {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function treeBytes(path: string): Promise<number> {
|
|
162
|
+
let total = 0;
|
|
163
|
+
let entries: { name: string; isDirectory: () => boolean }[];
|
|
164
|
+
try {
|
|
165
|
+
entries = (await readdir(path, { withFileTypes: true })) as unknown as {
|
|
166
|
+
name: string;
|
|
167
|
+
isDirectory: () => boolean;
|
|
168
|
+
}[];
|
|
169
|
+
} catch {
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
const name = String(entry.name);
|
|
175
|
+
if (isExcludedFromCopy(name)) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const child = join(path, name);
|
|
179
|
+
if (entry.isDirectory()) {
|
|
180
|
+
total += await treeBytes(child);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
total += (await stat(child)).size;
|
|
185
|
+
} catch {
|
|
186
|
+
// An unreadable file simply does not contribute.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return total;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function systemRunEnvironment(): RunEnvironment {
|
|
193
|
+
return {
|
|
194
|
+
runner: bunCommandRunner(),
|
|
195
|
+
makeTempDir: () => mkdtemp(join(tmpdir(), "bunready-run-")),
|
|
196
|
+
copyProject: async (from, to) => {
|
|
197
|
+
await cp(from, to, { recursive: true, filter: (source) => !isExcludedFromCopy(source) });
|
|
198
|
+
},
|
|
199
|
+
removeDir: (path) => rm(path, { recursive: true, force: true }),
|
|
200
|
+
measureTreeBytes: treeBytes,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function perform(
|
|
205
|
+
workDir: string,
|
|
206
|
+
dir: string,
|
|
207
|
+
manifest: Manifest,
|
|
208
|
+
env: RunEnvironment,
|
|
209
|
+
options: RunOptions,
|
|
210
|
+
): Promise<Omit<RunOutcome, "workDir" | "cleanupFailed" | "measuredBytes" | "copyTooLarge">> {
|
|
211
|
+
await env.copyProject(dir, workDir);
|
|
212
|
+
|
|
213
|
+
const install = await env.runner.run(["bun", "install"], {
|
|
214
|
+
cwd: workDir,
|
|
215
|
+
timeoutMs: options.installTimeoutMs,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (install.timedOut || install.code !== 0) {
|
|
219
|
+
return {
|
|
220
|
+
script: undefined,
|
|
221
|
+
install,
|
|
222
|
+
installFailed: true,
|
|
223
|
+
result: undefined,
|
|
224
|
+
failure: firstFailure(`${install.stdout}\n${install.stderr}`),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const script = pickScript(manifest, options.script);
|
|
229
|
+
if (script === undefined) {
|
|
230
|
+
return {
|
|
231
|
+
script: undefined,
|
|
232
|
+
install,
|
|
233
|
+
installFailed: false,
|
|
234
|
+
result: undefined,
|
|
235
|
+
failure: undefined,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const result = await env.runner.run(["bun", "run", script], {
|
|
240
|
+
cwd: workDir,
|
|
241
|
+
timeoutMs: options.scriptTimeoutMs,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
script,
|
|
246
|
+
install,
|
|
247
|
+
installFailed: false,
|
|
248
|
+
result,
|
|
249
|
+
failure: firstFailure(`${result.stdout}\n${result.stderr}`),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function executeProject(
|
|
254
|
+
dir: string,
|
|
255
|
+
manifest: Manifest,
|
|
256
|
+
env: RunEnvironment = systemRunEnvironment(),
|
|
257
|
+
options: RunOptions = DEFAULT_RUN_OPTIONS,
|
|
258
|
+
): Promise<Result<RunOutcome>> {
|
|
259
|
+
const measuredBytes = await env.measureTreeBytes(dir);
|
|
260
|
+
const limitBytes = options.maxCopyMegabytes * 1024 * 1024;
|
|
261
|
+
|
|
262
|
+
if (measuredBytes > limitBytes) {
|
|
263
|
+
return ok({
|
|
264
|
+
workDir: "",
|
|
265
|
+
script: undefined,
|
|
266
|
+
install: undefined,
|
|
267
|
+
installFailed: false,
|
|
268
|
+
result: undefined,
|
|
269
|
+
failure: undefined,
|
|
270
|
+
cleanupFailed: false,
|
|
271
|
+
measuredBytes,
|
|
272
|
+
copyTooLarge: true,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const workDir = await env.makeTempDir();
|
|
277
|
+
|
|
278
|
+
let partial: Awaited<ReturnType<typeof perform>>;
|
|
279
|
+
try {
|
|
280
|
+
partial = await perform(workDir, dir, manifest, env, options);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
await env.removeDir(workDir).catch(() => undefined);
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
error: {
|
|
286
|
+
code: "E_IO",
|
|
287
|
+
message: `could not prepare the temporary copy: ${error instanceof Error ? error.message : String(error)}`,
|
|
288
|
+
hint: "check that the target directory is readable and that the temporary directory is writable.",
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let cleanupFailed = false;
|
|
294
|
+
try {
|
|
295
|
+
await env.removeDir(workDir);
|
|
296
|
+
} catch {
|
|
297
|
+
cleanupFailed = true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return ok({ ...partial, workDir, cleanupFailed, measuredBytes, copyTooLarge: false });
|
|
301
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ParsedLockfile } from "./lockfile";
|
|
2
|
+
import type { Manifest } from "./manifest";
|
|
3
|
+
|
|
4
|
+
/** What bunready knows about the dependency set, and nothing more. */
|
|
5
|
+
export interface DependencyGraph {
|
|
6
|
+
readonly direct: readonly string[];
|
|
7
|
+
readonly dev: readonly string[];
|
|
8
|
+
readonly optional: readonly string[];
|
|
9
|
+
readonly peer: readonly string[];
|
|
10
|
+
/** Distinct locked package names. */
|
|
11
|
+
readonly lockedNames: number;
|
|
12
|
+
/** Distinct name@version pairs in the lockfile. */
|
|
13
|
+
readonly lockedPackages: number;
|
|
14
|
+
readonly duplicates: readonly DuplicateVersion[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DuplicateVersion {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly versions: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function names(map: Readonly<Record<string, string>>): string[] {
|
|
23
|
+
return Object.keys(map).sort();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build the graph from the manifest (direct dependencies, always trustworthy)
|
|
28
|
+
* plus the lockfile (transitive packages, only as good as the lockfile).
|
|
29
|
+
*
|
|
30
|
+
* Dev-ness of transitive packages is NOT inferred: only lockfiles that record
|
|
31
|
+
* it (npm) contribute that flag, and the graph does not use it to guess.
|
|
32
|
+
*/
|
|
33
|
+
export function buildGraph(
|
|
34
|
+
manifest: Manifest,
|
|
35
|
+
lockfile: ParsedLockfile | undefined,
|
|
36
|
+
): DependencyGraph {
|
|
37
|
+
const versionsByName = new Map<string, Set<string>>();
|
|
38
|
+
for (const pkg of lockfile?.packages ?? []) {
|
|
39
|
+
const bucket = versionsByName.get(pkg.name) ?? new Set<string>();
|
|
40
|
+
bucket.add(pkg.version);
|
|
41
|
+
versionsByName.set(pkg.name, bucket);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const duplicates: DuplicateVersion[] = [];
|
|
45
|
+
for (const [name, versions] of [...versionsByName.entries()].sort((a, b) =>
|
|
46
|
+
a[0].localeCompare(b[0]),
|
|
47
|
+
)) {
|
|
48
|
+
if (versions.size > 1) {
|
|
49
|
+
duplicates.push({ name, versions: [...versions].sort() });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
direct: names(manifest.dependencies),
|
|
55
|
+
dev: names(manifest.devDependencies),
|
|
56
|
+
optional: names(manifest.optionalDependencies),
|
|
57
|
+
peer: names(manifest.peerDependencies),
|
|
58
|
+
lockedNames: versionsByName.size,
|
|
59
|
+
lockedPackages: lockfile?.packages.length ?? 0,
|
|
60
|
+
duplicates,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Every package name bunready saw, direct or transitive, deduplicated. */
|
|
65
|
+
export function knownPackageNames(
|
|
66
|
+
graph: DependencyGraph,
|
|
67
|
+
lockfile: ParsedLockfile | undefined,
|
|
68
|
+
): Set<string> {
|
|
69
|
+
const result = new Set<string>([
|
|
70
|
+
...graph.direct,
|
|
71
|
+
...graph.dev,
|
|
72
|
+
...graph.optional,
|
|
73
|
+
...graph.peer,
|
|
74
|
+
...(lockfile?.packages ?? []).map((pkg) => pkg.name),
|
|
75
|
+
]);
|
|
76
|
+
return result;
|
|
77
|
+
}
|