@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,355 @@
|
|
|
1
|
+
import { builtinModules } from "node:module";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { FileSystem } from "../core/fs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Import scanning for the target's own source.
|
|
7
|
+
*
|
|
8
|
+
* This is a deliberately small, regex-based extractor, not a JavaScript parser.
|
|
9
|
+
* It answers one question - "which Node built-ins does this repository import?"
|
|
10
|
+
* - and it says so in the finding, so nobody mistakes it for full static
|
|
11
|
+
* analysis. `node_modules`, build output and VCS data are never scanned: the
|
|
12
|
+
* question is about the code the user is moving, not their dependencies.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const SOURCE_EXTENSIONS = [
|
|
16
|
+
".ts",
|
|
17
|
+
".tsx",
|
|
18
|
+
".mts",
|
|
19
|
+
".cts",
|
|
20
|
+
".js",
|
|
21
|
+
".jsx",
|
|
22
|
+
".mjs",
|
|
23
|
+
".cjs",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export const IGNORED_DIRECTORIES = [
|
|
27
|
+
"node_modules",
|
|
28
|
+
".git",
|
|
29
|
+
"dist",
|
|
30
|
+
"build",
|
|
31
|
+
"out",
|
|
32
|
+
"coverage",
|
|
33
|
+
"vendor",
|
|
34
|
+
".next",
|
|
35
|
+
".nuxt",
|
|
36
|
+
".output",
|
|
37
|
+
".turbo",
|
|
38
|
+
".cache",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export const MAX_SOURCE_FILES = 2000;
|
|
42
|
+
|
|
43
|
+
export type ImportKind = "esm" | "cjs" | "dynamic";
|
|
44
|
+
|
|
45
|
+
export interface ImportRef {
|
|
46
|
+
readonly specifier: string;
|
|
47
|
+
readonly kind: ImportKind;
|
|
48
|
+
readonly line: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SourceFile {
|
|
52
|
+
readonly path: string;
|
|
53
|
+
readonly imports: readonly ImportRef[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SourceScan {
|
|
57
|
+
readonly files: readonly SourceFile[];
|
|
58
|
+
readonly filesScanned: number;
|
|
59
|
+
/** True when the walk stopped at the file cap, so the inventory is partial. */
|
|
60
|
+
readonly truncated: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The gap between `import`/`export` and `from` is restricted to the characters a
|
|
65
|
+
* real clause can contain (identifiers, braces, commas, `*`, `type`). Letting it
|
|
66
|
+
* span arbitrary text made one statement swallow the next one's specifier, which
|
|
67
|
+
* would have attributed imports to the wrong line and file.
|
|
68
|
+
*/
|
|
69
|
+
const IMPORT_CLAUSE = "[\\w$*{}, \\t]|\\n[ \\t]*";
|
|
70
|
+
|
|
71
|
+
/** Statement-shaped anchors. They are matched against masked text (see below). */
|
|
72
|
+
const STATEMENT_PATTERNS: readonly { kind: ImportKind; pattern: RegExp }[] = [
|
|
73
|
+
{
|
|
74
|
+
kind: "esm",
|
|
75
|
+
pattern: new RegExp(
|
|
76
|
+
`(?:^|(?<=\\n))[ \\t]*(?:import|export)[ \\t]*(?:type[ \\t]+)?(?:${IMPORT_CLAUSE})*?from[ \\t]*`,
|
|
77
|
+
"g",
|
|
78
|
+
),
|
|
79
|
+
},
|
|
80
|
+
{ kind: "esm", pattern: /(?:^|(?<=\n))[ \t]*import[ \t]*/g },
|
|
81
|
+
{ kind: "cjs", pattern: /\brequire\([ \t]*/g },
|
|
82
|
+
{ kind: "dynamic", pattern: /\bimport\([ \t]*/g },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Replace the contents of strings and comments with spaces, preserving every
|
|
87
|
+
* offset and newline.
|
|
88
|
+
*
|
|
89
|
+
* Without this, a test fixture that contains the text `import cluster from
|
|
90
|
+
* "node:cluster"` inside a string literal was counted as a real import. Masking
|
|
91
|
+
* keeps offsets aligned, so specifiers are read from the original text at the
|
|
92
|
+
* position the anchor matched.
|
|
93
|
+
*/
|
|
94
|
+
export function maskNonCode(text: string): string {
|
|
95
|
+
const out: string[] = [];
|
|
96
|
+
type Mode = "code" | "single" | "double" | "template" | "line" | "block";
|
|
97
|
+
let mode: Mode = "code";
|
|
98
|
+
let index = 0;
|
|
99
|
+
|
|
100
|
+
const blank = (char: string): string => (char === "\n" ? "\n" : " ");
|
|
101
|
+
|
|
102
|
+
while (index < text.length) {
|
|
103
|
+
const char = text[index] ?? "";
|
|
104
|
+
const next = text[index + 1] ?? "";
|
|
105
|
+
|
|
106
|
+
if (mode === "code") {
|
|
107
|
+
if (char === "/" && next === "/") {
|
|
108
|
+
mode = "line";
|
|
109
|
+
out.push(" ");
|
|
110
|
+
index += 2;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (char === "/" && next === "*") {
|
|
114
|
+
mode = "block";
|
|
115
|
+
out.push(" ");
|
|
116
|
+
index += 2;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (char === "'") {
|
|
120
|
+
mode = "single";
|
|
121
|
+
} else if (char === '"') {
|
|
122
|
+
mode = "double";
|
|
123
|
+
} else if (char === "`") {
|
|
124
|
+
mode = "template";
|
|
125
|
+
}
|
|
126
|
+
out.push(char);
|
|
127
|
+
index += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (mode === "line") {
|
|
132
|
+
if (char === "\n") {
|
|
133
|
+
mode = "code";
|
|
134
|
+
out.push("\n");
|
|
135
|
+
} else {
|
|
136
|
+
out.push(" ");
|
|
137
|
+
}
|
|
138
|
+
index += 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (mode === "block") {
|
|
143
|
+
if (char === "*" && next === "/") {
|
|
144
|
+
mode = "code";
|
|
145
|
+
out.push(" ");
|
|
146
|
+
index += 2;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
out.push(blank(char));
|
|
150
|
+
index += 1;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const quote = mode === "single" ? "'" : mode === "double" ? '"' : "`";
|
|
155
|
+
if (char === "\\") {
|
|
156
|
+
out.push(" ");
|
|
157
|
+
index += 2;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (char === quote) {
|
|
161
|
+
mode = "code";
|
|
162
|
+
out.push(char);
|
|
163
|
+
index += 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
out.push(blank(char));
|
|
167
|
+
index += 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return out.join("");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Read a quoted specifier out of the original text, starting at `from`. */
|
|
174
|
+
function readQuoted(text: string, from: number): string | undefined {
|
|
175
|
+
let index = from;
|
|
176
|
+
while (index < text.length && (text[index] === " " || text[index] === "\t")) {
|
|
177
|
+
index += 1;
|
|
178
|
+
}
|
|
179
|
+
const quote = text[index];
|
|
180
|
+
if (quote !== '"' && quote !== "'") {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let value = "";
|
|
185
|
+
index += 1;
|
|
186
|
+
while (index < text.length && text[index] !== quote) {
|
|
187
|
+
const char = text[index] ?? "";
|
|
188
|
+
if (char === "\n") {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
if (char === "\\") {
|
|
192
|
+
value += text[index + 1] ?? "";
|
|
193
|
+
index += 2;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
value += char;
|
|
197
|
+
index += 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return value === "" ? undefined : value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function lineOf(text: string, index: number): number {
|
|
204
|
+
let line = 1;
|
|
205
|
+
for (let cursor = 0; cursor < index && cursor < text.length; cursor += 1) {
|
|
206
|
+
if (text[cursor] === "\n") {
|
|
207
|
+
line += 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return line;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function collect(
|
|
214
|
+
masked: string,
|
|
215
|
+
original: string,
|
|
216
|
+
pattern: RegExp,
|
|
217
|
+
kind: ImportKind,
|
|
218
|
+
into: ImportRef[],
|
|
219
|
+
): void {
|
|
220
|
+
for (const match of masked.matchAll(pattern)) {
|
|
221
|
+
const specifier = readQuoted(original, match.index + match[0].length);
|
|
222
|
+
if (specifier === undefined) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
into.push({ specifier, kind, line: lineOf(original, match.index) });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Extract every import/require specifier in one file's text. */
|
|
230
|
+
export function extractImports(text: string): ImportRef[] {
|
|
231
|
+
const masked = maskNonCode(text);
|
|
232
|
+
const refs: ImportRef[] = [];
|
|
233
|
+
|
|
234
|
+
for (const { kind, pattern } of STATEMENT_PATTERNS) {
|
|
235
|
+
collect(masked, text, pattern, kind, refs);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const seen = new Set<string>();
|
|
239
|
+
return refs
|
|
240
|
+
.filter((ref) => {
|
|
241
|
+
const key = `${ref.line}:${ref.kind}:${ref.specifier}`;
|
|
242
|
+
if (seen.has(key)) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
seen.add(key);
|
|
246
|
+
return true;
|
|
247
|
+
})
|
|
248
|
+
.sort((a, b) => (a.line === b.line ? a.specifier.localeCompare(b.specifier) : a.line - b.line));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The set of Node built-in module names, taken from the runtime itself rather
|
|
253
|
+
* than from a list we maintain: if Node or Bun adds one, this follows.
|
|
254
|
+
*/
|
|
255
|
+
export function nodeBuiltinNames(): Set<string> {
|
|
256
|
+
const names = new Set<string>();
|
|
257
|
+
for (const name of builtinModules) {
|
|
258
|
+
names.add(name);
|
|
259
|
+
names.add(`node:${name}`);
|
|
260
|
+
}
|
|
261
|
+
return names;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export type SpecifierKind = "node-builtin" | "bun-builtin" | "relative" | "absolute" | "package";
|
|
265
|
+
|
|
266
|
+
export function classifySpecifier(
|
|
267
|
+
specifier: string,
|
|
268
|
+
builtins: ReadonlySet<string> = nodeBuiltinNames(),
|
|
269
|
+
): SpecifierKind {
|
|
270
|
+
if (specifier.startsWith("bun:")) {
|
|
271
|
+
return "bun-builtin";
|
|
272
|
+
}
|
|
273
|
+
if (builtins.has(specifier)) {
|
|
274
|
+
return "node-builtin";
|
|
275
|
+
}
|
|
276
|
+
if (
|
|
277
|
+
specifier.startsWith("./") ||
|
|
278
|
+
specifier.startsWith("../") ||
|
|
279
|
+
specifier === "." ||
|
|
280
|
+
specifier === ".."
|
|
281
|
+
) {
|
|
282
|
+
return "relative";
|
|
283
|
+
}
|
|
284
|
+
if (specifier.startsWith("/") || /^[A-Za-z]:[\\/]/.test(specifier)) {
|
|
285
|
+
return "absolute";
|
|
286
|
+
}
|
|
287
|
+
return "package";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function hasSourceExtension(name: string): boolean {
|
|
291
|
+
const lower = name.toLowerCase();
|
|
292
|
+
return SOURCE_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface ScanSourcesOptions {
|
|
296
|
+
readonly maxFiles?: number;
|
|
297
|
+
/** Substrings matched against each file path; a match skips the file. */
|
|
298
|
+
readonly excludePaths?: readonly string[];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Walk the target's own source, breadth first and sorted, so the result is
|
|
303
|
+
* deterministic. Hitting the cap is reported, never hidden.
|
|
304
|
+
*/
|
|
305
|
+
export async function scanSources(
|
|
306
|
+
dir: string,
|
|
307
|
+
fs: FileSystem,
|
|
308
|
+
options: ScanSourcesOptions = {},
|
|
309
|
+
): Promise<SourceScan> {
|
|
310
|
+
const maxFiles = options.maxFiles ?? MAX_SOURCE_FILES;
|
|
311
|
+
const excludePaths = options.excludePaths ?? [];
|
|
312
|
+
const ignored = new Set<string>(IGNORED_DIRECTORIES);
|
|
313
|
+
const queue: string[] = [dir];
|
|
314
|
+
const files: SourceFile[] = [];
|
|
315
|
+
let truncated = false;
|
|
316
|
+
|
|
317
|
+
while (queue.length > 0) {
|
|
318
|
+
const current = queue.shift();
|
|
319
|
+
if (current === undefined) {
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const entries = await fs.listDirectory(current);
|
|
324
|
+
for (const entry of entries) {
|
|
325
|
+
if (entry.isDirectory) {
|
|
326
|
+
if (!ignored.has(entry.name)) {
|
|
327
|
+
queue.push(join(current, entry.name));
|
|
328
|
+
}
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (!hasSourceExtension(entry.name)) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (files.length >= maxFiles) {
|
|
335
|
+
truncated = true;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const path = join(current, entry.name).replace(/\\/g, "/");
|
|
339
|
+
if (excludePaths.some((fragment) => path.includes(fragment))) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const outcome = await fs.readTextFile(path);
|
|
343
|
+
if (outcome.kind !== "text") {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
files.push({ path: path.replace(/\\/g, "/"), imports: extractImports(outcome.text) });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (truncated) {
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return { files, filesScanned: files.length, truncated };
|
|
355
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { isAbsolute, join } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
type BunreadyConfig,
|
|
4
|
+
CONFIG_FILENAME,
|
|
5
|
+
DEFAULT_CONFIG,
|
|
6
|
+
parseConfig,
|
|
7
|
+
} from "../config/config";
|
|
8
|
+
import { defineError, type Result } from "../core/errors";
|
|
9
|
+
import { type FileSystem, nodeFileSystem } from "../core/fs";
|
|
10
|
+
import {
|
|
11
|
+
LOCKFILE_FILENAMES,
|
|
12
|
+
LOCKFILE_KINDS,
|
|
13
|
+
type LockfileKind,
|
|
14
|
+
type ParsedLockfile,
|
|
15
|
+
parseLockfile,
|
|
16
|
+
} from "./lockfile";
|
|
17
|
+
import { type Manifest, parseManifest } from "./manifest";
|
|
18
|
+
|
|
19
|
+
/** What a dependency's own installed `package.json` says, when it is present. */
|
|
20
|
+
export interface PackageEvidence {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly path: string;
|
|
23
|
+
readonly gypfile: boolean;
|
|
24
|
+
readonly binaryField: boolean;
|
|
25
|
+
readonly installScripts: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LoadedLockfile {
|
|
29
|
+
readonly kind: LockfileKind;
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly parsed: ParsedLockfile;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UnparsedLockfile {
|
|
35
|
+
readonly kind: LockfileKind;
|
|
36
|
+
readonly path: string;
|
|
37
|
+
readonly message: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TargetSnapshot {
|
|
41
|
+
readonly dir: string;
|
|
42
|
+
readonly manifestPath: string;
|
|
43
|
+
readonly manifest: Manifest;
|
|
44
|
+
/** Lockfiles present and parseable, in detection priority order. */
|
|
45
|
+
readonly lockfiles: readonly LoadedLockfile[];
|
|
46
|
+
/** Lockfiles present but not parseable. Reported, never silently ignored. */
|
|
47
|
+
readonly unparsedLockfiles: readonly UnparsedLockfile[];
|
|
48
|
+
/** Path to a legacy binary `bun.lockb`, whose contents bunready will not guess at. */
|
|
49
|
+
readonly binaryBunLock: string | undefined;
|
|
50
|
+
readonly packageEvidence: readonly PackageEvidence[];
|
|
51
|
+
readonly config: BunreadyConfig;
|
|
52
|
+
readonly configPath: string | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const INSTALL_SCRIPT_NAMES = ["preinstall", "install", "postinstall"] as const;
|
|
56
|
+
|
|
57
|
+
/** Findings print paths with forward slashes so output is identical on every OS. */
|
|
58
|
+
function displayPath(path: string): string {
|
|
59
|
+
return path.replace(/\\/g, "/");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
63
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function directDependencyNames(manifest: Manifest): string[] {
|
|
67
|
+
return [
|
|
68
|
+
...new Set([
|
|
69
|
+
...Object.keys(manifest.dependencies),
|
|
70
|
+
...Object.keys(manifest.devDependencies),
|
|
71
|
+
...Object.keys(manifest.optionalDependencies),
|
|
72
|
+
]),
|
|
73
|
+
].sort();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Probe a dependency's installed package.json.
|
|
78
|
+
*
|
|
79
|
+
* This is the only place bunready learns about install scripts of transitive
|
|
80
|
+
* packages without a network call: if the package is installed, its own
|
|
81
|
+
* manifest is hard evidence, and if it is not installed there is simply no
|
|
82
|
+
* evidence and no finding.
|
|
83
|
+
*/
|
|
84
|
+
async function probeInstalledPackage(
|
|
85
|
+
dir: string,
|
|
86
|
+
name: string,
|
|
87
|
+
fs: FileSystem,
|
|
88
|
+
): Promise<PackageEvidence | undefined> {
|
|
89
|
+
const path = displayPath(join(dir, "node_modules", name, "package.json"));
|
|
90
|
+
const outcome = await fs.readTextFile(path);
|
|
91
|
+
if (outcome.kind !== "text") {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let raw: unknown;
|
|
96
|
+
try {
|
|
97
|
+
raw = JSON.parse(outcome.text);
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
if (!isRecord(raw)) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const scripts = isRecord(raw.scripts) ? raw.scripts : {};
|
|
106
|
+
const installScripts = INSTALL_SCRIPT_NAMES.filter(
|
|
107
|
+
(scriptName) => typeof scripts[scriptName] === "string",
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
name,
|
|
112
|
+
path,
|
|
113
|
+
gypfile: raw.gypfile === true,
|
|
114
|
+
binaryField: isRecord(raw.binary),
|
|
115
|
+
installScripts,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function readTarget(
|
|
120
|
+
dir: string,
|
|
121
|
+
fs: FileSystem = nodeFileSystem(),
|
|
122
|
+
configPath?: string,
|
|
123
|
+
skipConfigDiscovery = false,
|
|
124
|
+
): Promise<Result<TargetSnapshot>> {
|
|
125
|
+
const manifestPath = displayPath(join(dir, "package.json"));
|
|
126
|
+
const manifestOutcome = await fs.readTextFile(manifestPath);
|
|
127
|
+
|
|
128
|
+
if (manifestOutcome.kind === "missing") {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
error: defineError("E_IO", `no package.json found in ${dir}`, {
|
|
132
|
+
hint: "point bunready at the root of a Node or TypeScript repository.",
|
|
133
|
+
}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (manifestOutcome.kind === "error") {
|
|
137
|
+
return { ok: false, error: manifestOutcome.error };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const parsedManifest = parseManifest(manifestOutcome.text, manifestPath);
|
|
141
|
+
if (!parsedManifest.ok) {
|
|
142
|
+
return { ok: false, error: parsedManifest.error };
|
|
143
|
+
}
|
|
144
|
+
const manifest = parsedManifest.value;
|
|
145
|
+
|
|
146
|
+
const lockfiles: LoadedLockfile[] = [];
|
|
147
|
+
const unparsedLockfiles: UnparsedLockfile[] = [];
|
|
148
|
+
|
|
149
|
+
for (const kind of LOCKFILE_KINDS) {
|
|
150
|
+
const path = displayPath(join(dir, LOCKFILE_FILENAMES[kind]));
|
|
151
|
+
const outcome = await fs.readTextFile(path);
|
|
152
|
+
if (outcome.kind === "missing") {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (outcome.kind === "error") {
|
|
156
|
+
unparsedLockfiles.push({ kind, path, message: outcome.error.message });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const parsed = parseLockfile(kind, outcome.text, path);
|
|
160
|
+
if (parsed.ok) {
|
|
161
|
+
lockfiles.push({ kind, path, parsed: parsed.value });
|
|
162
|
+
} else {
|
|
163
|
+
unparsedLockfiles.push({ kind, path, message: parsed.error.message });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const binaryBunLockPath = displayPath(join(dir, "bun.lockb"));
|
|
168
|
+
const binaryBunLock = (await fs.pathExists(binaryBunLockPath)) ? binaryBunLockPath : undefined;
|
|
169
|
+
|
|
170
|
+
const wantedConfigPath =
|
|
171
|
+
configPath === undefined
|
|
172
|
+
? undefined
|
|
173
|
+
: isAbsolute(configPath)
|
|
174
|
+
? configPath
|
|
175
|
+
: join(dir, configPath);
|
|
176
|
+
const resolvedConfigPath = displayPath(wantedConfigPath ?? join(dir, CONFIG_FILENAME));
|
|
177
|
+
const configOutcome = skipConfigDiscovery
|
|
178
|
+
? ({ kind: "missing" } as const)
|
|
179
|
+
: await fs.readTextFile(resolvedConfigPath);
|
|
180
|
+
|
|
181
|
+
let config = DEFAULT_CONFIG;
|
|
182
|
+
let loadedConfigPath: string | undefined;
|
|
183
|
+
|
|
184
|
+
if (configOutcome.kind === "text") {
|
|
185
|
+
const parsed = parseConfig(configOutcome.text, resolvedConfigPath);
|
|
186
|
+
if (!parsed.ok) {
|
|
187
|
+
return { ok: false, error: parsed.error };
|
|
188
|
+
}
|
|
189
|
+
config = parsed.value;
|
|
190
|
+
loadedConfigPath = resolvedConfigPath;
|
|
191
|
+
} else if (configOutcome.kind === "error") {
|
|
192
|
+
return { ok: false, error: configOutcome.error };
|
|
193
|
+
} else if (wantedConfigPath !== undefined) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
error: defineError("E_IO", `no configuration file at ${resolvedConfigPath}`, {
|
|
197
|
+
hint: `--config must point at a ${CONFIG_FILENAME} file.`,
|
|
198
|
+
}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const packageEvidence: PackageEvidence[] = [];
|
|
203
|
+
for (const name of directDependencyNames(manifest)) {
|
|
204
|
+
const evidence = await probeInstalledPackage(dir, name, fs);
|
|
205
|
+
if (evidence !== undefined) {
|
|
206
|
+
packageEvidence.push(evidence);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
value: {
|
|
213
|
+
dir,
|
|
214
|
+
manifestPath,
|
|
215
|
+
manifest,
|
|
216
|
+
lockfiles,
|
|
217
|
+
unparsedLockfiles,
|
|
218
|
+
binaryBunLock,
|
|
219
|
+
packageEvidence,
|
|
220
|
+
config,
|
|
221
|
+
configPath: loadedConfigPath,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|