@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/LICENSE +21 -0
  3. package/README.md +129 -0
  4. package/action.yml +89 -0
  5. package/docs/CONFIGURATION.md +44 -0
  6. package/docs/JSON-OUTPUT.md +50 -0
  7. package/docs/RELEASING.md +65 -0
  8. package/docs/adr/0001-data-source-policy.md +36 -0
  9. package/docs/adr/0002-rule-severity-model.md +42 -0
  10. package/docs/adr/0003-release-pipeline.md +51 -0
  11. package/docs/brand/favicon.svg +8 -0
  12. package/docs/brand/guidelines.md +70 -0
  13. package/docs/brand/logo-dark.svg +11 -0
  14. package/docs/brand/logo-mono.svg +11 -0
  15. package/docs/brand/logo.svg +11 -0
  16. package/docs/brand/mark.svg +8 -0
  17. package/docs/brand/tokens.json +74 -0
  18. package/docs/demo.md +37 -0
  19. package/package.json +71 -0
  20. package/src/cli/args.ts +177 -0
  21. package/src/cli/copy.ts +76 -0
  22. package/src/cli/index.ts +5 -0
  23. package/src/cli/io.ts +20 -0
  24. package/src/cli/run.ts +98 -0
  25. package/src/cli/theme.ts +59 -0
  26. package/src/config/baseline.ts +116 -0
  27. package/src/config/config.ts +113 -0
  28. package/src/core/errors.ts +59 -0
  29. package/src/core/fs.ts +72 -0
  30. package/src/core/version.ts +9 -0
  31. package/src/report/human.ts +100 -0
  32. package/src/report/json.ts +11 -0
  33. package/src/report/sarif.ts +73 -0
  34. package/src/report/types.ts +114 -0
  35. package/src/rules/data/native-packages.json +81 -0
  36. package/src/rules/data/node-runtime.json +6 -0
  37. package/src/rules/install/engines.ts +74 -0
  38. package/src/rules/install/index.ts +27 -0
  39. package/src/rules/install/lifecycle-scripts.ts +70 -0
  40. package/src/rules/install/lockfile-presence.ts +68 -0
  41. package/src/rules/install/native-addon.ts +126 -0
  42. package/src/rules/run/index.ts +114 -0
  43. package/src/rules/runtime/builtins.ts +148 -0
  44. package/src/rules/runtime/index.ts +18 -0
  45. package/src/rules/severity.ts +46 -0
  46. package/src/scanner/execute.ts +301 -0
  47. package/src/scanner/graph.ts +77 -0
  48. package/src/scanner/lockfile.ts +545 -0
  49. package/src/scanner/manifest.ts +109 -0
  50. package/src/scanner/scan.ts +322 -0
  51. package/src/scanner/semver.ts +227 -0
  52. package/src/scanner/sources.ts +355 -0
  53. package/src/scanner/target.ts +224 -0
  54. package/src/scanner/workspaces.ts +170 -0
@@ -0,0 +1,545 @@
1
+ import { defineError, type Result } from "../core/errors";
2
+
3
+ /**
4
+ * Lockfile parsing.
5
+ *
6
+ * Four formats, hand-written, no dependencies. Each parser extracts only what
7
+ * bunready actually reasons about: locked package names, versions, and any
8
+ * install-script evidence the lockfile itself records. Nothing is inferred, so a
9
+ * format that does not record install scripts contributes no install-script
10
+ * evidence - which is exactly why the node_modules probe exists.
11
+ *
12
+ * `installScript` is true only when the lockfile says so:
13
+ * - npm v2/v3 records `hasInstallScript`
14
+ * - pnpm records `requiresBuild`
15
+ * - bun.lock and yarn.lock record nothing (verified against a real bun.lock)
16
+ */
17
+
18
+ export const LOCKFILE_KINDS = ["bun", "npm", "yarn", "pnpm"] as const;
19
+
20
+ export type LockfileKind = (typeof LOCKFILE_KINDS)[number];
21
+
22
+ export const LOCKFILE_FILENAMES: Readonly<Record<LockfileKind, string>> = {
23
+ bun: "bun.lock",
24
+ npm: "package-lock.json",
25
+ yarn: "yarn.lock",
26
+ pnpm: "pnpm-lock.yaml",
27
+ };
28
+
29
+ export interface LockedPackage {
30
+ readonly name: string;
31
+ readonly version: string;
32
+ /** true only when the lockfile marks the package as a development dependency. */
33
+ readonly dev: boolean;
34
+ readonly optional: boolean;
35
+ /** true only when the lockfile records that this package runs an install script. */
36
+ readonly installScript: boolean;
37
+ }
38
+
39
+ export interface ParsedLockfile {
40
+ readonly kind: LockfileKind;
41
+ readonly lockfileVersion: string | undefined;
42
+ readonly packages: readonly LockedPackage[];
43
+ }
44
+
45
+ function isRecord(value: unknown): value is Record<string, unknown> {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+
49
+ function asString(value: unknown): string | undefined {
50
+ return typeof value === "string" ? value : undefined;
51
+ }
52
+
53
+ /** Remove `//` and slash-star comments without touching string contents. */
54
+ export function stripJsonComments(text: string): string {
55
+ let out = "";
56
+ let index = 0;
57
+ let inString = false;
58
+
59
+ while (index < text.length) {
60
+ const char = text[index] ?? "";
61
+
62
+ if (inString) {
63
+ out += char;
64
+ if (char === "\\") {
65
+ out += text[index + 1] ?? "";
66
+ index += 2;
67
+ continue;
68
+ }
69
+ if (char === '"') {
70
+ inString = false;
71
+ }
72
+ index += 1;
73
+ continue;
74
+ }
75
+
76
+ if (char === '"') {
77
+ inString = true;
78
+ out += char;
79
+ index += 1;
80
+ continue;
81
+ }
82
+
83
+ if (char === "/" && text[index + 1] === "/") {
84
+ while (index < text.length && text[index] !== "\n") {
85
+ index += 1;
86
+ }
87
+ continue;
88
+ }
89
+
90
+ if (char === "/" && text[index + 1] === "*") {
91
+ index += 2;
92
+ while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) {
93
+ index += 1;
94
+ }
95
+ index += 2;
96
+ continue;
97
+ }
98
+
99
+ out += char;
100
+ index += 1;
101
+ }
102
+
103
+ return out;
104
+ }
105
+
106
+ /** Drop trailing commas, which Bun's text lockfile contains. */
107
+ export function stripTrailingCommas(text: string): string {
108
+ let out = "";
109
+ let inString = false;
110
+
111
+ for (let index = 0; index < text.length; index += 1) {
112
+ const char = text[index] ?? "";
113
+
114
+ if (inString) {
115
+ out += char;
116
+ if (char === "\\") {
117
+ out += text[index + 1] ?? "";
118
+ index += 1;
119
+ continue;
120
+ }
121
+ if (char === '"') {
122
+ inString = false;
123
+ }
124
+ continue;
125
+ }
126
+
127
+ if (char === '"') {
128
+ inString = true;
129
+ out += char;
130
+ continue;
131
+ }
132
+
133
+ if (char === ",") {
134
+ let lookahead = index + 1;
135
+ while (lookahead < text.length && /\s/.test(text[lookahead] ?? "")) {
136
+ lookahead += 1;
137
+ }
138
+ const next = text[lookahead];
139
+ if (next === "}" || next === "]") {
140
+ continue;
141
+ }
142
+ }
143
+
144
+ out += char;
145
+ }
146
+
147
+ return out;
148
+ }
149
+
150
+ function parseJsonc(text: string): unknown {
151
+ return JSON.parse(stripTrailingCommas(stripJsonComments(text)));
152
+ }
153
+
154
+ function parseFailure(
155
+ kind: LockfileKind,
156
+ path: string,
157
+ detail: string,
158
+ hint: string,
159
+ ): Result<ParsedLockfile> {
160
+ return {
161
+ ok: false,
162
+ error: defineError("E_PARSE", `${path} (${kind}) could not be parsed: ${detail}`, { hint }),
163
+ };
164
+ }
165
+
166
+ function mergeDevFlags(target: Map<string, LockedPackage>, pkg: LockedPackage): void {
167
+ const key = `${pkg.name}@${pkg.version}`;
168
+ const existing = target.get(key);
169
+ if (existing === undefined) {
170
+ target.set(key, pkg);
171
+ return;
172
+ }
173
+ target.set(key, {
174
+ ...existing,
175
+ dev: existing.dev && pkg.dev,
176
+ optional: existing.optional && pkg.optional,
177
+ installScript: existing.installScript || pkg.installScript,
178
+ });
179
+ }
180
+
181
+ function sortPackages(packages: Map<string, LockedPackage>): LockedPackage[] {
182
+ return [...packages.values()].sort((a, b) =>
183
+ a.name === b.name ? a.version.localeCompare(b.version) : a.name.localeCompare(b.name),
184
+ );
185
+ }
186
+
187
+ /** `@scope/pkg@1.2.3` or `pkg@1.2.3`, optionally aliased as `pkg@npm:real@1.2.3`. */
188
+ function versionFromResolution(name: string, resolution: string): string | undefined {
189
+ const prefix = `${name}@`;
190
+ let rest = resolution.startsWith(prefix) ? resolution.slice(prefix.length) : resolution;
191
+ if (rest.startsWith("npm:")) {
192
+ rest = rest.slice(4);
193
+ }
194
+ const match = /(?:^|@)(\d[^@]*)$/.exec(rest);
195
+ if (match?.[1] !== undefined) {
196
+ return match[1];
197
+ }
198
+ return /^\d/.test(rest) ? rest : undefined;
199
+ }
200
+
201
+ function parseBunLock(text: string, path: string): Result<ParsedLockfile> {
202
+ let raw: unknown;
203
+ try {
204
+ raw = parseJsonc(text);
205
+ } catch (error) {
206
+ return parseFailure(
207
+ "bun",
208
+ path,
209
+ error instanceof Error ? error.message : "invalid JSON",
210
+ "regenerate the lockfile with `bun install`.",
211
+ );
212
+ }
213
+ if (!isRecord(raw)) {
214
+ return parseFailure(
215
+ "bun",
216
+ path,
217
+ "top level is not an object",
218
+ "regenerate the lockfile with `bun install`.",
219
+ );
220
+ }
221
+
222
+ const packagesRecord = raw.packages;
223
+ if (packagesRecord !== undefined && !isRecord(packagesRecord)) {
224
+ return parseFailure(
225
+ "bun",
226
+ path,
227
+ "`packages` is not an object",
228
+ "regenerate the lockfile with `bun install`.",
229
+ );
230
+ }
231
+
232
+ const collected = new Map<string, LockedPackage>();
233
+ for (const [name, entry] of Object.entries(isRecord(packagesRecord) ? packagesRecord : {})) {
234
+ if (!Array.isArray(entry)) {
235
+ continue;
236
+ }
237
+ const resolution = asString(entry[0]) ?? "";
238
+ const metadata = isRecord(entry[2]) ? entry[2] : {};
239
+ const version = versionFromResolution(name, resolution);
240
+ if (version === undefined) {
241
+ continue;
242
+ }
243
+ mergeDevFlags(collected, {
244
+ name,
245
+ version,
246
+ dev: false,
247
+ optional: metadata.optional === true,
248
+ installScript: metadata.hasInstallScript === true,
249
+ });
250
+ }
251
+
252
+ const lockfileVersion =
253
+ raw.lockfileVersion === undefined ? undefined : String(raw.lockfileVersion);
254
+ return { ok: true, value: { kind: "bun", lockfileVersion, packages: sortPackages(collected) } };
255
+ }
256
+
257
+ function packageNameFromPath(path: string): string | undefined {
258
+ const marker = "node_modules/";
259
+ const index = path.lastIndexOf(marker);
260
+ if (index === -1) {
261
+ return undefined;
262
+ }
263
+ const tail = path.slice(index + marker.length);
264
+ if (tail === "" || tail.includes("/node_modules/")) {
265
+ return undefined;
266
+ }
267
+ if (tail.startsWith("@")) {
268
+ const segments = tail.split("/");
269
+ const scope = segments[0];
270
+ const name = segments[1];
271
+ if (scope === undefined || name === undefined || name === "") {
272
+ return undefined;
273
+ }
274
+ return `${scope}/${name}`;
275
+ }
276
+ const first = tail.split("/")[0];
277
+ return first === undefined || first === "" ? undefined : first;
278
+ }
279
+
280
+ function walkLegacyDependencies(
281
+ dependencies: Record<string, unknown>,
282
+ inherited: { dev: boolean; optional: boolean },
283
+ collected: Map<string, LockedPackage>,
284
+ ): void {
285
+ for (const [name, raw] of Object.entries(dependencies)) {
286
+ if (!isRecord(raw)) {
287
+ continue;
288
+ }
289
+ const version = asString(raw.version);
290
+ if (version !== undefined) {
291
+ mergeDevFlags(collected, {
292
+ name,
293
+ version,
294
+ dev: inherited.dev,
295
+ optional: inherited.optional || raw.optional === true,
296
+ installScript: raw.hasInstallScript === true,
297
+ });
298
+ }
299
+ if (isRecord(raw.dependencies)) {
300
+ walkLegacyDependencies(raw.dependencies, inherited, collected);
301
+ }
302
+ }
303
+ }
304
+
305
+ function parseNpmLock(text: string, path: string): Result<ParsedLockfile> {
306
+ let raw: unknown;
307
+ try {
308
+ raw = JSON.parse(text);
309
+ } catch (error) {
310
+ return parseFailure(
311
+ "npm",
312
+ path,
313
+ error instanceof Error ? error.message : "invalid JSON",
314
+ "regenerate the lockfile with `npm install`.",
315
+ );
316
+ }
317
+ if (!isRecord(raw)) {
318
+ return parseFailure(
319
+ "npm",
320
+ path,
321
+ "top level is not an object",
322
+ "regenerate the lockfile with `npm install`.",
323
+ );
324
+ }
325
+
326
+ const collected = new Map<string, LockedPackage>();
327
+
328
+ if (isRecord(raw.packages)) {
329
+ for (const [entryPath, entry] of Object.entries(raw.packages)) {
330
+ if (entryPath === "" || !isRecord(entry)) {
331
+ continue;
332
+ }
333
+ const name = packageNameFromPath(entryPath);
334
+ const version = asString(entry.version);
335
+ if (name === undefined || version === undefined) {
336
+ continue;
337
+ }
338
+ mergeDevFlags(collected, {
339
+ name,
340
+ version,
341
+ dev: entry.dev === true,
342
+ optional: entry.optional === true,
343
+ installScript: entry.hasInstallScript === true,
344
+ });
345
+ }
346
+ } else if (isRecord(raw.dependencies)) {
347
+ walkLegacyDependencies(raw.dependencies, { dev: false, optional: false }, collected);
348
+ } else {
349
+ return parseFailure(
350
+ "npm",
351
+ path,
352
+ "neither `packages` nor `dependencies` is present",
353
+ "regenerate the lockfile with `npm install`.",
354
+ );
355
+ }
356
+
357
+ const lockfileVersion =
358
+ raw.lockfileVersion === undefined ? undefined : String(raw.lockfileVersion);
359
+ return { ok: true, value: { kind: "npm", lockfileVersion, packages: sortPackages(collected) } };
360
+ }
361
+
362
+ function nameFromYarnPattern(header: string): string | undefined {
363
+ const first = (header.split(",")[0] ?? "").trim().replace(/^"|"$/g, "");
364
+ const aliasIndex = first.indexOf("@npm:");
365
+ const candidate = aliasIndex > 0 ? first.slice(0, aliasIndex) : first;
366
+ const at = candidate.lastIndexOf("@");
367
+ if (at > 0) {
368
+ return candidate.slice(0, at);
369
+ }
370
+ return candidate === "" ? undefined : candidate;
371
+ }
372
+
373
+ /** Yarn berry keeps its metadata version on the line after `__metadata:`. */
374
+ function yarnMetadataVersion(lines: readonly string[]): string | undefined {
375
+ const start = lines.findIndex((line) => /^__metadata:/.test(line));
376
+ if (start === -1) {
377
+ return undefined;
378
+ }
379
+ for (let index = start + 1; index < lines.length; index += 1) {
380
+ const line = lines[index] ?? "";
381
+ if (line.trim() === "") {
382
+ continue;
383
+ }
384
+ if (!/^\s/.test(line)) {
385
+ return undefined;
386
+ }
387
+ const match = /^\s+version:\s*(\S+)/.exec(line);
388
+ if (match?.[1] !== undefined) {
389
+ return match[1];
390
+ }
391
+ }
392
+ return undefined;
393
+ }
394
+
395
+ function parseYarnLock(text: string, path: string): Result<ParsedLockfile> {
396
+ const lines = text.split(/\r?\n/);
397
+ const berry = lines.some((line) => /^__metadata:/.test(line));
398
+ const collected = new Map<string, LockedPackage>();
399
+
400
+ let header: string | undefined;
401
+ let recognisedHeaders = 0;
402
+ for (const line of lines) {
403
+ const trimmed = line.trim();
404
+ if (trimmed === "" || trimmed.startsWith("#")) {
405
+ continue;
406
+ }
407
+
408
+ if (!/^\s/.test(line)) {
409
+ header = trimmed.endsWith(":") && trimmed !== "__metadata:" ? trimmed : undefined;
410
+ if (header !== undefined) {
411
+ recognisedHeaders += 1;
412
+ }
413
+ continue;
414
+ }
415
+
416
+ if (header === undefined) {
417
+ continue;
418
+ }
419
+
420
+ const versionLine = berry
421
+ ? /^\s{2}version:\s*"?([^"\s]+)"?\s*$/.exec(line)
422
+ : /^\s{2}version\s+"([^"]+)"/.exec(line);
423
+ const version = versionLine?.[1];
424
+ const name = nameFromYarnPattern(header);
425
+ if (version === undefined || name === undefined) {
426
+ continue;
427
+ }
428
+ mergeDevFlags(collected, { name, version, dev: false, optional: false, installScript: false });
429
+ }
430
+
431
+ // An empty graph from a non-empty lockfile is a parse failure, not "no
432
+ // dependencies": silently reporting zero packages would turn a broken parse
433
+ // into a clean-looking verdict.
434
+ if (recognisedHeaders === 0) {
435
+ return parseFailure(
436
+ "yarn",
437
+ path,
438
+ "no package blocks were recognised",
439
+ "regenerate the lockfile with the package manager that produced it.",
440
+ );
441
+ }
442
+
443
+ return {
444
+ ok: true,
445
+ value: {
446
+ kind: "yarn",
447
+ lockfileVersion: yarnMetadataVersion(lines) ?? (berry ? undefined : "1"),
448
+ packages: sortPackages(collected),
449
+ },
450
+ };
451
+ }
452
+
453
+ /** `foo@1.2.3`, `/@scope/foo@1.2.3`, `foo@1.2.3(react@18.2.0)`, `foo@1.2.3_peer@1`. */
454
+ function splitPnpmKey(key: string): { name: string; version: string } | undefined {
455
+ let cleaned = key.startsWith("/") ? key.slice(1) : key;
456
+ const peerSuffix = cleaned.search(/[(_]/);
457
+ if (peerSuffix > 0) {
458
+ cleaned = cleaned.slice(0, peerSuffix);
459
+ }
460
+ const match = /^(.+?)@(\d[^@]*)$/.exec(cleaned);
461
+ if (match?.[1] === undefined || match[2] === undefined) {
462
+ return undefined;
463
+ }
464
+ return { name: match[1], version: match[2] };
465
+ }
466
+
467
+ function parsePnpmLock(text: string, path: string): Result<ParsedLockfile> {
468
+ const lines = text.split(/\r?\n/);
469
+ const collected = new Map<string, LockedPackage>();
470
+ let inPackages = false;
471
+ let current: LockedPackage | undefined;
472
+ let sawPackagesSection = false;
473
+
474
+ for (const line of lines) {
475
+ if (line.trim() === "" || line.trimStart().startsWith("#")) {
476
+ continue;
477
+ }
478
+
479
+ if (!/^\s/.test(line)) {
480
+ inPackages = /^packages:/.test(line);
481
+ sawPackagesSection = sawPackagesSection || inPackages;
482
+ current = undefined;
483
+ continue;
484
+ }
485
+
486
+ if (!inPackages) {
487
+ continue;
488
+ }
489
+
490
+ const keyLine = /^ {2}(?:'([^']+)'|"([^"]+)"|([^:]+)):\s*$/.exec(line);
491
+ if (keyLine !== null) {
492
+ const key = keyLine[1] ?? keyLine[2] ?? keyLine[3] ?? "";
493
+ const parsed = splitPnpmKey(key.trim());
494
+ current =
495
+ parsed === undefined
496
+ ? undefined
497
+ : {
498
+ name: parsed.name,
499
+ version: parsed.version,
500
+ dev: false,
501
+ optional: false,
502
+ installScript: false,
503
+ };
504
+ if (current !== undefined) {
505
+ collected.set(`${current.name}@${current.version}`, current);
506
+ }
507
+ continue;
508
+ }
509
+
510
+ if (current !== undefined && /requiresBuild:\s*true/.test(line)) {
511
+ collected.set(`${current.name}@${current.version}`, { ...current, installScript: true });
512
+ }
513
+ }
514
+
515
+ if (!sawPackagesSection) {
516
+ return parseFailure(
517
+ "pnpm",
518
+ path,
519
+ "no `packages` section found",
520
+ "regenerate the lockfile with `pnpm install`.",
521
+ );
522
+ }
523
+
524
+ const versionLine = lines.find((line) => /^lockfileVersion:/.test(line));
525
+ const lockfileVersion = /^lockfileVersion:\s*'?"?([^'"\s]+)/.exec(versionLine ?? "")?.[1];
526
+
527
+ return { ok: true, value: { kind: "pnpm", lockfileVersion, packages: sortPackages(collected) } };
528
+ }
529
+
530
+ export function parseLockfile(
531
+ kind: LockfileKind,
532
+ text: string,
533
+ path = LOCKFILE_FILENAMES[kind],
534
+ ): Result<ParsedLockfile> {
535
+ switch (kind) {
536
+ case "bun":
537
+ return parseBunLock(text, path);
538
+ case "npm":
539
+ return parseNpmLock(text, path);
540
+ case "yarn":
541
+ return parseYarnLock(text, path);
542
+ case "pnpm":
543
+ return parsePnpmLock(text, path);
544
+ }
545
+ }
@@ -0,0 +1,109 @@
1
+ import { defineError, type Result } from "../core/errors";
2
+
3
+ /** The parts of a target's `package.json` that bunready reasons about. */
4
+ export interface Manifest {
5
+ readonly name: string | undefined;
6
+ readonly version: string | undefined;
7
+ readonly scripts: Readonly<Record<string, string>>;
8
+ readonly dependencies: Readonly<Record<string, string>>;
9
+ readonly devDependencies: Readonly<Record<string, string>>;
10
+ readonly optionalDependencies: Readonly<Record<string, string>>;
11
+ readonly peerDependencies: Readonly<Record<string, string>>;
12
+ readonly engines: Readonly<Record<string, string>>;
13
+ readonly trustedDependencies: readonly string[];
14
+ /** Workspace globs: `workspaces` as an array, or its `packages` field. */
15
+ readonly workspaces: readonly string[];
16
+ }
17
+
18
+ function isRecord(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+
22
+ function asString(value: unknown): string | undefined {
23
+ return typeof value === "string" ? value : undefined;
24
+ }
25
+
26
+ function readStringMap(value: unknown): Record<string, string> {
27
+ const result: Record<string, string> = {};
28
+ if (!isRecord(value)) {
29
+ return result;
30
+ }
31
+ for (const [key, raw] of Object.entries(value)) {
32
+ const text = asString(raw);
33
+ if (text !== undefined) {
34
+ result[key] = text;
35
+ }
36
+ }
37
+ return result;
38
+ }
39
+
40
+ function readWorkspacePatterns(value: unknown): string[] {
41
+ if (Array.isArray(value)) {
42
+ return value.filter((entry): entry is string => typeof entry === "string");
43
+ }
44
+ if (isRecord(value)) {
45
+ return readStringsFromArray(value.packages);
46
+ }
47
+ return [];
48
+ }
49
+
50
+ function readStringsFromArray(value: unknown): string[] {
51
+ return Array.isArray(value)
52
+ ? value.filter((entry): entry is string => typeof entry === "string")
53
+ : [];
54
+ }
55
+
56
+ /**
57
+ * `trustedDependencies` is documented as an array, but an object form is
58
+ * tolerated: if one appears we read its keys, because refusing to read it would
59
+ * produce a false "blocked lifecycle script" finding.
60
+ */
61
+ function readTrustedDependencies(value: unknown): string[] {
62
+ if (Array.isArray(value)) {
63
+ return value.filter((entry): entry is string => typeof entry === "string");
64
+ }
65
+ if (isRecord(value)) {
66
+ return Object.keys(value);
67
+ }
68
+ return [];
69
+ }
70
+
71
+ export function parseManifest(text: string, source: string): Result<Manifest> {
72
+ let parsed: unknown;
73
+ try {
74
+ parsed = JSON.parse(text);
75
+ } catch (error) {
76
+ return {
77
+ ok: false,
78
+ error: defineError("E_PARSE", `${source} is not valid JSON`, {
79
+ hint: "fix the JSON syntax in package.json, then run bunready again.",
80
+ cause: error,
81
+ }),
82
+ };
83
+ }
84
+
85
+ if (!isRecord(parsed)) {
86
+ return {
87
+ ok: false,
88
+ error: defineError("E_PARSE", `${source} does not contain a JSON object`, {
89
+ hint: "package.json must contain an object at the top level.",
90
+ }),
91
+ };
92
+ }
93
+
94
+ return {
95
+ ok: true,
96
+ value: {
97
+ name: asString(parsed.name),
98
+ version: asString(parsed.version),
99
+ scripts: readStringMap(parsed.scripts),
100
+ dependencies: readStringMap(parsed.dependencies),
101
+ devDependencies: readStringMap(parsed.devDependencies),
102
+ optionalDependencies: readStringMap(parsed.optionalDependencies),
103
+ peerDependencies: readStringMap(parsed.peerDependencies),
104
+ engines: readStringMap(parsed.engines),
105
+ trustedDependencies: readTrustedDependencies(parsed.trustedDependencies),
106
+ workspaces: readWorkspacePatterns(parsed.workspaces),
107
+ },
108
+ };
109
+ }