@mh-alikhani/bunready 0.3.2 → 0.3.4
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 +47 -4
- package/README.md +70 -91
- package/docs/README.md +16 -0
- package/docs/adr/0001-data-source-policy.md +1 -1
- package/docs/adr/0002-rule-severity-model.md +1 -1
- package/docs/adr/0003-release-pipeline.md +1 -1
- package/package.json +1 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/copy.ts +4 -0
- package/src/cli/io.ts +1 -0
- package/src/cli/run.ts +4 -0
- package/src/cli/theme.ts +2 -0
- package/src/config/baseline.ts +5 -0
- package/src/config/config.ts +5 -0
- package/src/core/errors.ts +6 -0
- package/src/core/fs.ts +2 -0
- package/src/core/version.ts +2 -1
- package/src/report/human.ts +1 -0
- package/src/report/sarif.ts +1 -0
- package/src/report/types.ts +14 -2
- package/src/rules/install/lifecycle-scripts.ts +2 -0
- package/src/rules/install/lockfile-presence.ts +1 -0
- package/src/rules/install/native-addon.ts +3 -1
- package/src/rules/run/index.ts +1 -0
- package/src/rules/runtime/builtins.ts +5 -1
- package/src/rules/severity.ts +3 -0
- package/src/scanner/execute.ts +14 -0
- package/src/scanner/graph.ts +1 -0
- package/src/scanner/lockfile.ts +5 -0
- package/src/scanner/manifest.ts +1 -0
- package/src/scanner/scan.ts +35 -11
- package/src/scanner/semver.ts +4 -0
- package/src/scanner/sources.ts +117 -34
- package/src/scanner/target.ts +4 -0
- package/src/scanner/workspaces.ts +4 -0
|
@@ -3,7 +3,7 @@ import type { DependencyGraph } from "../../scanner/graph";
|
|
|
3
3
|
import { knownPackageNames } from "../../scanner/graph";
|
|
4
4
|
import type { ParsedLockfile } from "../../scanner/lockfile";
|
|
5
5
|
import type { TargetSnapshot } from "../../scanner/target";
|
|
6
|
-
import nativeDataset from "../data/native-packages.json";
|
|
6
|
+
import nativeDataset from "../data/native-packages.json" with { type: "json" };
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Native-addon detection, in evidence order:
|
|
@@ -23,6 +23,7 @@ export interface NativePackageEntry {
|
|
|
23
23
|
readonly source: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** The build-tool packages the native-addon rule recognises. */
|
|
26
27
|
export const BUILD_TOOL_PACKAGES = [
|
|
27
28
|
"node-gyp",
|
|
28
29
|
"node-pre-gyp",
|
|
@@ -56,6 +57,7 @@ export function readDataset(raw: unknown = nativeDataset): NativePackageEntry[]
|
|
|
56
57
|
return entries;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/** Flags native addons that need a compiler toolchain. */
|
|
59
61
|
export function nativeAddonFindings(
|
|
60
62
|
snapshot: TargetSnapshot,
|
|
61
63
|
graph: DependencyGraph,
|
package/src/rules/run/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Finding } from "../../report/types";
|
|
2
2
|
import { classifySpecifier, type SourceScan } from "../../scanner/sources";
|
|
3
|
-
import runtimeDataset from "../data/node-runtime.json";
|
|
3
|
+
import runtimeDataset from "../data/node-runtime.json" with { type: "json" };
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Runtime-phase rule: what Node surface the repository actually depends on.
|
|
@@ -17,6 +17,7 @@ const GAP_ID = "runtime/known-gap";
|
|
|
17
17
|
const COVERAGE_ID = "runtime/scan-coverage";
|
|
18
18
|
const MAX_LISTED_MODULES = 8;
|
|
19
19
|
|
|
20
|
+
/** One row of the Node runtime gap dataset. */
|
|
20
21
|
export interface RuntimeGapEntry {
|
|
21
22
|
readonly name: string;
|
|
22
23
|
readonly status: "partial" | "unimplemented";
|
|
@@ -24,11 +25,13 @@ export interface RuntimeGapEntry {
|
|
|
24
25
|
readonly source: string;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** The shipped dataset of Node runtime gaps, each with its source. */
|
|
27
29
|
export interface RuntimeDataset {
|
|
28
30
|
readonly compatibilityDocs: string | undefined;
|
|
29
31
|
readonly gaps: readonly RuntimeGapEntry[];
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
/** A Node built-in the project imports, with where it is used. */
|
|
32
35
|
export interface BuiltinUsage {
|
|
33
36
|
readonly name: string;
|
|
34
37
|
readonly files: readonly string[];
|
|
@@ -92,6 +95,7 @@ function normalise(name: string): string {
|
|
|
92
95
|
return name.startsWith("node:") ? name.slice(5) : name;
|
|
93
96
|
}
|
|
94
97
|
|
|
98
|
+
/** Reports the Node built-ins the project's own code imports. */
|
|
95
99
|
export function runtimeBuiltinFindings(
|
|
96
100
|
scan: SourceScan,
|
|
97
101
|
usages: readonly BuiltinUsage[],
|
package/src/rules/severity.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
export const SEVERITIES = ["blocker", "risk", "info"] as const;
|
|
11
11
|
|
|
12
|
+
/** Severity levels, ordered from most to least serious. */
|
|
12
13
|
export type Severity = (typeof SEVERITIES)[number];
|
|
13
14
|
|
|
14
15
|
/** Lower rank sorts first: blockers surface above everything else. */
|
|
@@ -18,10 +19,12 @@ export const SEVERITY_RANK: Readonly<Record<Severity, number>> = {
|
|
|
18
19
|
info: 2,
|
|
19
20
|
};
|
|
20
21
|
|
|
22
|
+
/** Orders severities for sorting, blockers first. */
|
|
21
23
|
export function compareSeverity(a: Severity, b: Severity): number {
|
|
22
24
|
return SEVERITY_RANK[a] - SEVERITY_RANK[b];
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/** Counts findings per severity. */
|
|
25
28
|
export function countBySeverity(
|
|
26
29
|
severities: readonly Severity[],
|
|
27
30
|
): Readonly<Record<Severity, number>> {
|
package/src/scanner/execute.ts
CHANGED
|
@@ -23,8 +23,10 @@ export const COPY_EXCLUDES = [
|
|
|
23
23
|
".cache",
|
|
24
24
|
] as const;
|
|
25
25
|
|
|
26
|
+
/** Script names the run phase is willing to execute, in order of preference. */
|
|
26
27
|
export const RUNNABLE_SCRIPTS = ["start", "test"] as const;
|
|
27
28
|
|
|
29
|
+
/** Exit status and captured output of one child process. */
|
|
28
30
|
export interface ProcessResult {
|
|
29
31
|
readonly code: number | null;
|
|
30
32
|
readonly stdout: string;
|
|
@@ -33,15 +35,18 @@ export interface ProcessResult {
|
|
|
33
35
|
readonly durationMs: number;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/** Options for a single command invocation. */
|
|
36
39
|
export interface RunCommandOptions {
|
|
37
40
|
readonly cwd: string;
|
|
38
41
|
readonly timeoutMs: number;
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/** Runs a command; injected so tests need not spawn processes. */
|
|
41
45
|
export interface CommandRunner {
|
|
42
46
|
readonly run: (command: readonly string[], options: RunCommandOptions) => Promise<ProcessResult>;
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
/** What the run phase needs: a scratch directory and a way to run commands. */
|
|
45
50
|
export interface RunEnvironment {
|
|
46
51
|
readonly runner: CommandRunner;
|
|
47
52
|
readonly makeTempDir: () => Promise<string>;
|
|
@@ -50,11 +55,13 @@ export interface RunEnvironment {
|
|
|
50
55
|
readonly measureTreeBytes: (path: string) => Promise<number>;
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
/** A failure observed while running the project's scripts. */
|
|
53
59
|
export interface RunFailure {
|
|
54
60
|
readonly message: string;
|
|
55
61
|
readonly frames: readonly string[];
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
/** What the run phase ended up doing, successful or not. */
|
|
58
65
|
export interface RunOutcome {
|
|
59
66
|
readonly workDir: string;
|
|
60
67
|
readonly script: string | undefined;
|
|
@@ -67,6 +74,7 @@ export interface RunOutcome {
|
|
|
67
74
|
readonly copyTooLarge: boolean;
|
|
68
75
|
}
|
|
69
76
|
|
|
77
|
+
/** Limits and toggles for the run phase. */
|
|
70
78
|
export interface RunOptions {
|
|
71
79
|
readonly installTimeoutMs: number;
|
|
72
80
|
readonly scriptTimeoutMs: number;
|
|
@@ -75,6 +83,7 @@ export interface RunOptions {
|
|
|
75
83
|
readonly script?: string;
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
/** The default run settings. */
|
|
78
87
|
export const DEFAULT_RUN_OPTIONS: RunOptions = {
|
|
79
88
|
installTimeoutMs: 180_000,
|
|
80
89
|
scriptTimeoutMs: 120_000,
|
|
@@ -116,6 +125,7 @@ export function firstFailure(output: string): RunFailure | undefined {
|
|
|
116
125
|
return { message: (lines[hinted] ?? "").trim(), frames: [] };
|
|
117
126
|
}
|
|
118
127
|
|
|
128
|
+
/** Chooses the script to run, when the project defines a runnable one. */
|
|
119
129
|
export function pickScript(manifest: Manifest, requested?: string): string | undefined {
|
|
120
130
|
if (requested !== undefined) {
|
|
121
131
|
return typeof manifest.scripts[requested] === "string" ? requested : undefined;
|
|
@@ -123,10 +133,12 @@ export function pickScript(manifest: Manifest, requested?: string): string | und
|
|
|
123
133
|
return RUNNABLE_SCRIPTS.find((name) => typeof manifest.scripts[name] === "string");
|
|
124
134
|
}
|
|
125
135
|
|
|
136
|
+
/** Whether a path is left out of the temporary copy the run phase works in. */
|
|
126
137
|
export function isExcludedFromCopy(path: string): boolean {
|
|
127
138
|
return (COPY_EXCLUDES as readonly string[]).includes(basename(path));
|
|
128
139
|
}
|
|
129
140
|
|
|
141
|
+
/** The CommandRunner that spawns commands with Bun. */
|
|
130
142
|
export function bunCommandRunner(): CommandRunner {
|
|
131
143
|
return {
|
|
132
144
|
run: async (command, options) => {
|
|
@@ -189,6 +201,7 @@ async function treeBytes(path: string): Promise<number> {
|
|
|
189
201
|
return total;
|
|
190
202
|
}
|
|
191
203
|
|
|
204
|
+
/** The real run environment: working directory, temp directory and runner. */
|
|
192
205
|
export function systemRunEnvironment(): RunEnvironment {
|
|
193
206
|
return {
|
|
194
207
|
runner: bunCommandRunner(),
|
|
@@ -250,6 +263,7 @@ async function perform(
|
|
|
250
263
|
};
|
|
251
264
|
}
|
|
252
265
|
|
|
266
|
+
/** Copies the project, installs, runs the chosen script and reports what happened. */
|
|
253
267
|
export async function executeProject(
|
|
254
268
|
dir: string,
|
|
255
269
|
manifest: Manifest,
|
package/src/scanner/graph.ts
CHANGED
package/src/scanner/lockfile.ts
CHANGED
|
@@ -17,8 +17,10 @@ import { defineError, type Result } from "../core/errors";
|
|
|
17
17
|
|
|
18
18
|
export const LOCKFILE_KINDS = ["bun", "npm", "yarn", "pnpm"] as const;
|
|
19
19
|
|
|
20
|
+
/** The lockfile formats the scanner understands. */
|
|
20
21
|
export type LockfileKind = (typeof LOCKFILE_KINDS)[number];
|
|
21
22
|
|
|
23
|
+
/** Lockfile filenames, in the order they are preferred. */
|
|
22
24
|
export const LOCKFILE_FILENAMES: Readonly<Record<LockfileKind, string>> = {
|
|
23
25
|
bun: "bun.lock",
|
|
24
26
|
npm: "package-lock.json",
|
|
@@ -26,6 +28,7 @@ export const LOCKFILE_FILENAMES: Readonly<Record<LockfileKind, string>> = {
|
|
|
26
28
|
pnpm: "pnpm-lock.yaml",
|
|
27
29
|
};
|
|
28
30
|
|
|
31
|
+
/** One package as recorded by a lockfile. */
|
|
29
32
|
export interface LockedPackage {
|
|
30
33
|
readonly name: string;
|
|
31
34
|
readonly version: string;
|
|
@@ -36,6 +39,7 @@ export interface LockedPackage {
|
|
|
36
39
|
readonly installScript: boolean;
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
/** A parsed lockfile: its format, packages and direct dependencies. */
|
|
39
43
|
export interface ParsedLockfile {
|
|
40
44
|
readonly kind: LockfileKind;
|
|
41
45
|
readonly lockfileVersion: string | undefined;
|
|
@@ -527,6 +531,7 @@ function parsePnpmLock(text: string, path: string): Result<ParsedLockfile> {
|
|
|
527
531
|
return { ok: true, value: { kind: "pnpm", lockfileVersion, packages: sortPackages(collected) } };
|
|
528
532
|
}
|
|
529
533
|
|
|
534
|
+
/** Parses lockfile text into packages, or reports why it could not. */
|
|
530
535
|
export function parseLockfile(
|
|
531
536
|
kind: LockfileKind,
|
|
532
537
|
text: string,
|
package/src/scanner/manifest.ts
CHANGED
|
@@ -68,6 +68,7 @@ function readTrustedDependencies(value: unknown): string[] {
|
|
|
68
68
|
return [];
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** Reads package.json, reporting malformed JSON as an error. */
|
|
71
72
|
export function parseManifest(text: string, source: string): Result<Manifest> {
|
|
72
73
|
let parsed: unknown;
|
|
73
74
|
try {
|
package/src/scanner/scan.ts
CHANGED
|
@@ -40,6 +40,7 @@ export function detectRuntime(): RuntimeInfo {
|
|
|
40
40
|
return { bun: Bun.version, node: process.versions.node };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Options for scanning a target: configuration, runtime versions and paths. */
|
|
43
44
|
export interface ScanOptions {
|
|
44
45
|
readonly fs?: FileSystem;
|
|
45
46
|
readonly runtime?: RuntimeInfo;
|
|
@@ -75,6 +76,7 @@ async function scanOne(
|
|
|
75
76
|
configPath: string | undefined,
|
|
76
77
|
skipConfigDiscovery: boolean,
|
|
77
78
|
rootConfig: TargetSnapshot["config"],
|
|
79
|
+
extraExcludePaths: readonly string[] = [],
|
|
78
80
|
): Promise<Result<TargetResult>> {
|
|
79
81
|
const target = await readTarget(dir, fs, configPath, skipConfigDiscovery);
|
|
80
82
|
if (!target.ok) {
|
|
@@ -84,7 +86,9 @@ async function scanOne(
|
|
|
84
86
|
// One configuration governs the whole scan; a package's own file is ignored.
|
|
85
87
|
const snapshot: TargetSnapshot = { ...target.value, config: rootConfig };
|
|
86
88
|
const graph = buildGraph(snapshot.manifest, snapshot.lockfiles[0]?.parsed);
|
|
87
|
-
const sources = await scanSources(dir, fs, {
|
|
89
|
+
const sources = await scanSources(dir, fs, {
|
|
90
|
+
excludePaths: [...rootConfig.excludePaths, ...extraExcludePaths],
|
|
91
|
+
});
|
|
88
92
|
const usages = collectNodeBuiltins(sources);
|
|
89
93
|
|
|
90
94
|
const findings = [
|
|
@@ -176,7 +180,20 @@ export async function scanTarget(
|
|
|
176
180
|
|
|
177
181
|
const targets: TargetResult[] = [];
|
|
178
182
|
|
|
179
|
-
|
|
183
|
+
// The root's walk skips the packages: each one is scanned as its own target,
|
|
184
|
+
// and descending into them from the root spent the file budget on files that
|
|
185
|
+
// were about to be read a second time.
|
|
186
|
+
const rootScan = await scanOne(
|
|
187
|
+
dir,
|
|
188
|
+
".",
|
|
189
|
+
"root",
|
|
190
|
+
fs,
|
|
191
|
+
runtime,
|
|
192
|
+
options.configPath,
|
|
193
|
+
false,
|
|
194
|
+
config,
|
|
195
|
+
packages.map((pkg) => `${pkg.relative}/`),
|
|
196
|
+
);
|
|
180
197
|
if (!rootScan.ok) {
|
|
181
198
|
return { ok: false, error: rootScan.error };
|
|
182
199
|
}
|
|
@@ -271,16 +288,23 @@ export async function scanTarget(
|
|
|
271
288
|
);
|
|
272
289
|
const counts = countBySeverity(findings.map((finding) => finding.severity));
|
|
273
290
|
|
|
291
|
+
// Per-target verdicts are computed from the findings that survived config
|
|
292
|
+
// filtering, so a package can never say "blocked" while the overall report
|
|
293
|
+
// says "ready" because the blocker was ignored.
|
|
274
294
|
const scannedTargets: readonly ScannedTarget[] =
|
|
275
295
|
targets.length > 1
|
|
276
|
-
? targets.map((target) =>
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
296
|
+
? targets.map((target) => {
|
|
297
|
+
const normalized = target.dir.replace(/\\/g, "/");
|
|
298
|
+
const own = findings.filter((finding) => finding.path === normalized);
|
|
299
|
+
return {
|
|
300
|
+
path: target.dir.replace(/\\/g, "/"),
|
|
301
|
+
relative: target.relative,
|
|
302
|
+
kind: target.kind,
|
|
303
|
+
name: target.name,
|
|
304
|
+
verdict: verdictFor(own),
|
|
305
|
+
counts: countBySeverity(own.map((finding) => finding.severity)),
|
|
306
|
+
};
|
|
307
|
+
})
|
|
284
308
|
: [];
|
|
285
309
|
|
|
286
310
|
const builtinNames = new Set(targets.flatMap((target) => target.builtinNames));
|
|
@@ -292,7 +316,7 @@ export async function scanTarget(
|
|
|
292
316
|
failOn: config.failOn,
|
|
293
317
|
tool: TOOL_NAME,
|
|
294
318
|
version: TOOL_VERSION,
|
|
295
|
-
target: dir,
|
|
319
|
+
target: dir.replace(/\\/g, "/"),
|
|
296
320
|
verdict: verdictFor(findings),
|
|
297
321
|
counts,
|
|
298
322
|
findings,
|
package/src/scanner/semver.ts
CHANGED
|
@@ -27,6 +27,7 @@ type Disjunction = readonly Conjunction[];
|
|
|
27
27
|
|
|
28
28
|
const VERSION_PATTERN = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$/;
|
|
29
29
|
|
|
30
|
+
/** Parses a semantic version, ignoring any surrounding operator. */
|
|
30
31
|
export function parseVersion(text: string): ParsedVersion | undefined {
|
|
31
32
|
const cleaned = text.trim().replace(/^[=v]+/, "");
|
|
32
33
|
const match = VERSION_PATTERN.exec(cleaned);
|
|
@@ -52,6 +53,7 @@ function writtenParts(text: string): number {
|
|
|
52
53
|
);
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** Orders two parsed versions. */
|
|
55
57
|
export function compareVersions(a: ParsedVersion, b: ParsedVersion): number {
|
|
56
58
|
if (a.major !== b.major) {
|
|
57
59
|
return a.major < b.major ? -1 : 1;
|
|
@@ -161,6 +163,7 @@ function comparatorsForToken(token: string): Comparator[] | undefined {
|
|
|
161
163
|
return exact === undefined ? undefined : [{ operator: "=", version: exact }];
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
/** Parses a version range into its operator and version parts. */
|
|
164
167
|
export function parseRange(range: string): Disjunction | undefined {
|
|
165
168
|
const trimmed = range.trim();
|
|
166
169
|
if (trimmed === "" || trimmed === "*" || trimmed === "latest") {
|
|
@@ -212,6 +215,7 @@ const COMPARATOR_TESTS: Readonly<Record<Operator, (order: number) => boolean>> =
|
|
|
212
215
|
"=": (order) => order === 0,
|
|
213
216
|
};
|
|
214
217
|
|
|
218
|
+
/** Whether a version satisfies a range. */
|
|
215
219
|
export function satisfies(candidate: string, range: string): boolean | undefined {
|
|
216
220
|
const parsedRange = parseRange(range);
|
|
217
221
|
const parsedVersion = parseVersion(candidate);
|
package/src/scanner/sources.ts
CHANGED
|
@@ -23,6 +23,7 @@ export const SOURCE_EXTENSIONS = [
|
|
|
23
23
|
".cjs",
|
|
24
24
|
] as const;
|
|
25
25
|
|
|
26
|
+
/** Directories the source walk never descends into. */
|
|
26
27
|
export const IGNORED_DIRECTORIES = [
|
|
27
28
|
"node_modules",
|
|
28
29
|
".git",
|
|
@@ -38,21 +39,26 @@ export const IGNORED_DIRECTORIES = [
|
|
|
38
39
|
".cache",
|
|
39
40
|
] as const;
|
|
40
41
|
|
|
42
|
+
/** Cap on how many source files are read per target. */
|
|
41
43
|
export const MAX_SOURCE_FILES = 2000;
|
|
42
44
|
|
|
45
|
+
/** How a module was imported: static import, dynamic import or require. */
|
|
43
46
|
export type ImportKind = "esm" | "cjs" | "dynamic";
|
|
44
47
|
|
|
48
|
+
/** One import found in a file: its specifier, kind and line. */
|
|
45
49
|
export interface ImportRef {
|
|
46
50
|
readonly specifier: string;
|
|
47
51
|
readonly kind: ImportKind;
|
|
48
52
|
readonly line: number;
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
/** A source file read during the scan. */
|
|
51
56
|
export interface SourceFile {
|
|
52
57
|
readonly path: string;
|
|
53
58
|
readonly imports: readonly ImportRef[];
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
/** What the walk found: files, imports, and whether the cap truncated it. */
|
|
56
62
|
export interface SourceScan {
|
|
57
63
|
readonly files: readonly SourceFile[];
|
|
58
64
|
readonly filesScanned: number;
|
|
@@ -86,51 +92,82 @@ const STATEMENT_PATTERNS: readonly { kind: ImportKind; pattern: RegExp }[] = [
|
|
|
86
92
|
* Replace the contents of strings and comments with spaces, preserving every
|
|
87
93
|
* offset and newline.
|
|
88
94
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
95
|
+
* Template literals are the interesting case: the literal text is still text,
|
|
96
|
+
* but a \`\${ ... }\` interpolation is real code and may contain imports, so the
|
|
97
|
+
* expression is scanned while the surrounding literal stays masked. Without
|
|
98
|
+
* this, \`\${require("node:fs")}\` counted as nothing, and string fixtures that
|
|
99
|
+
* merely contained import-shaped text were counted as imports.
|
|
93
100
|
*/
|
|
94
101
|
export function maskNonCode(text: string): string {
|
|
95
102
|
const out: string[] = [];
|
|
96
|
-
type
|
|
97
|
-
|
|
103
|
+
type Frame =
|
|
104
|
+
| { readonly kind: "code"; readonly depth: number }
|
|
105
|
+
| { readonly kind: "string"; readonly quote: string }
|
|
106
|
+
| { readonly kind: "template" }
|
|
107
|
+
| { readonly kind: "line" }
|
|
108
|
+
| { readonly kind: "block" };
|
|
109
|
+
const frames: Frame[] = [{ kind: "code", depth: 0 }];
|
|
98
110
|
let index = 0;
|
|
99
111
|
|
|
100
112
|
const blank = (char: string): string => (char === "\n" ? "\n" : " ");
|
|
113
|
+
const top = (): Frame => frames[frames.length - 1] ?? { kind: "code", depth: 0 };
|
|
101
114
|
|
|
102
115
|
while (index < text.length) {
|
|
103
116
|
const char = text[index] ?? "";
|
|
104
117
|
const next = text[index + 1] ?? "";
|
|
118
|
+
const frame = top();
|
|
105
119
|
|
|
106
|
-
if (
|
|
120
|
+
if (frame.kind === "code") {
|
|
107
121
|
if (char === "/" && next === "/") {
|
|
108
|
-
|
|
122
|
+
frames.push({ kind: "line" });
|
|
109
123
|
out.push(" ");
|
|
110
124
|
index += 2;
|
|
111
125
|
continue;
|
|
112
126
|
}
|
|
113
127
|
if (char === "/" && next === "*") {
|
|
114
|
-
|
|
128
|
+
frames.push({ kind: "block" });
|
|
115
129
|
out.push(" ");
|
|
116
130
|
index += 2;
|
|
117
131
|
continue;
|
|
118
132
|
}
|
|
119
|
-
if (char === "'") {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
133
|
+
if (char === '"' || char === "'") {
|
|
134
|
+
frames.push({ kind: "string", quote: char });
|
|
135
|
+
out.push(char);
|
|
136
|
+
index += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (char === "`") {
|
|
140
|
+
frames.push({ kind: "template" });
|
|
141
|
+
out.push(char);
|
|
142
|
+
index += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (char === "{") {
|
|
146
|
+
frames[frames.length - 1] = { kind: "code", depth: frame.depth + 1 };
|
|
147
|
+
out.push(char);
|
|
148
|
+
index += 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (char === "}") {
|
|
152
|
+
const parent = frames[frames.length - 2];
|
|
153
|
+
if (frame.depth === 0 && parent?.kind === "template") {
|
|
154
|
+
// The interpolation ended: back inside the template literal.
|
|
155
|
+
frames.pop();
|
|
156
|
+
} else {
|
|
157
|
+
frames[frames.length - 1] = { kind: "code", depth: Math.max(0, frame.depth - 1) };
|
|
158
|
+
}
|
|
159
|
+
out.push(char);
|
|
160
|
+
index += 1;
|
|
161
|
+
continue;
|
|
125
162
|
}
|
|
126
163
|
out.push(char);
|
|
127
164
|
index += 1;
|
|
128
165
|
continue;
|
|
129
166
|
}
|
|
130
167
|
|
|
131
|
-
if (
|
|
168
|
+
if (frame.kind === "line") {
|
|
132
169
|
if (char === "\n") {
|
|
133
|
-
|
|
170
|
+
frames.pop();
|
|
134
171
|
out.push("\n");
|
|
135
172
|
} else {
|
|
136
173
|
out.push(" ");
|
|
@@ -139,30 +176,53 @@ export function maskNonCode(text: string): string {
|
|
|
139
176
|
continue;
|
|
140
177
|
}
|
|
141
178
|
|
|
142
|
-
if (
|
|
179
|
+
if (frame.kind === "block") {
|
|
143
180
|
if (char === "*" && next === "/") {
|
|
144
|
-
|
|
181
|
+
frames.pop();
|
|
182
|
+
out.push(" ");
|
|
183
|
+
index += 2;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
out.push(blank(char));
|
|
187
|
+
index += 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (frame.kind === "string") {
|
|
192
|
+
if (char === "\\") {
|
|
145
193
|
out.push(" ");
|
|
146
194
|
index += 2;
|
|
147
195
|
continue;
|
|
148
196
|
}
|
|
197
|
+
if (char === frame.quote) {
|
|
198
|
+
frames.pop();
|
|
199
|
+
out.push(char);
|
|
200
|
+
index += 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
149
203
|
out.push(blank(char));
|
|
150
204
|
index += 1;
|
|
151
205
|
continue;
|
|
152
206
|
}
|
|
153
207
|
|
|
154
|
-
|
|
208
|
+
// Template body: masked, except that ${ opens a code frame again.
|
|
155
209
|
if (char === "\\") {
|
|
156
210
|
out.push(" ");
|
|
157
211
|
index += 2;
|
|
158
212
|
continue;
|
|
159
213
|
}
|
|
160
|
-
if (char ===
|
|
161
|
-
|
|
214
|
+
if (char === "`") {
|
|
215
|
+
frames.pop();
|
|
162
216
|
out.push(char);
|
|
163
217
|
index += 1;
|
|
164
218
|
continue;
|
|
165
219
|
}
|
|
220
|
+
if (char === "$" && next === "{") {
|
|
221
|
+
frames.push({ kind: "code", depth: 0 });
|
|
222
|
+
out.push("${");
|
|
223
|
+
index += 2;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
166
226
|
out.push(blank(char));
|
|
167
227
|
index += 1;
|
|
168
228
|
}
|
|
@@ -200,19 +260,36 @@ function readQuoted(text: string, from: number): string | undefined {
|
|
|
200
260
|
return value === "" ? undefined : value;
|
|
201
261
|
}
|
|
202
262
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
263
|
+
/** Newline offsets for one file, computed once and searched per match. */
|
|
264
|
+
function newlinePositions(text: string): number[] {
|
|
265
|
+
const positions: number[] = [];
|
|
266
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
267
|
+
if (text[index] === "\n") {
|
|
268
|
+
positions.push(index);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return positions;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** 1-based line for an offset, by binary search over the newline positions. */
|
|
275
|
+
function lineAt(positions: readonly number[], index: number): number {
|
|
276
|
+
let low = 0;
|
|
277
|
+
let high = positions.length;
|
|
278
|
+
while (low < high) {
|
|
279
|
+
const mid = (low + high) >> 1;
|
|
280
|
+
if ((positions[mid] ?? 0) < index) {
|
|
281
|
+
low = mid + 1;
|
|
282
|
+
} else {
|
|
283
|
+
high = mid;
|
|
208
284
|
}
|
|
209
285
|
}
|
|
210
|
-
return
|
|
286
|
+
return low + 1;
|
|
211
287
|
}
|
|
212
288
|
|
|
213
289
|
function collect(
|
|
214
290
|
masked: string,
|
|
215
291
|
original: string,
|
|
292
|
+
newlines: readonly number[],
|
|
216
293
|
pattern: RegExp,
|
|
217
294
|
kind: ImportKind,
|
|
218
295
|
into: ImportRef[],
|
|
@@ -222,17 +299,18 @@ function collect(
|
|
|
222
299
|
if (specifier === undefined) {
|
|
223
300
|
continue;
|
|
224
301
|
}
|
|
225
|
-
into.push({ specifier, kind, line:
|
|
302
|
+
into.push({ specifier, kind, line: lineAt(newlines, match.index) });
|
|
226
303
|
}
|
|
227
304
|
}
|
|
228
305
|
|
|
229
306
|
/** Extract every import/require specifier in one file's text. */
|
|
230
307
|
export function extractImports(text: string): ImportRef[] {
|
|
231
308
|
const masked = maskNonCode(text);
|
|
309
|
+
const newlines = newlinePositions(text);
|
|
232
310
|
const refs: ImportRef[] = [];
|
|
233
311
|
|
|
234
312
|
for (const { kind, pattern } of STATEMENT_PATTERNS) {
|
|
235
|
-
collect(masked, text, pattern, kind, refs);
|
|
313
|
+
collect(masked, text, newlines, pattern, kind, refs);
|
|
236
314
|
}
|
|
237
315
|
|
|
238
316
|
const seen = new Set<string>();
|
|
@@ -261,8 +339,10 @@ export function nodeBuiltinNames(): Set<string> {
|
|
|
261
339
|
return names;
|
|
262
340
|
}
|
|
263
341
|
|
|
342
|
+
/** The categories an import specifier can fall into. */
|
|
264
343
|
export type SpecifierKind = "node-builtin" | "bun-builtin" | "relative" | "absolute" | "package";
|
|
265
344
|
|
|
345
|
+
/** Classifies an import specifier. */
|
|
266
346
|
export function classifySpecifier(
|
|
267
347
|
specifier: string,
|
|
268
348
|
builtins: ReadonlySet<string> = nodeBuiltinNames(),
|
|
@@ -294,6 +374,7 @@ function hasSourceExtension(name: string): boolean {
|
|
|
294
374
|
return SOURCE_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
|
295
375
|
}
|
|
296
376
|
|
|
377
|
+
/** Limits for the source walk. */
|
|
297
378
|
export interface ScanSourcesOptions {
|
|
298
379
|
readonly maxFiles?: number;
|
|
299
380
|
/** Substrings matched against each file path; a match skips the file. */
|
|
@@ -365,14 +446,16 @@ export async function scanSources(
|
|
|
365
446
|
if (!hasSourceExtension(entry.name)) {
|
|
366
447
|
continue;
|
|
367
448
|
}
|
|
368
|
-
if (candidates.length >= maxFiles) {
|
|
369
|
-
truncated = true;
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
449
|
const path = join(current, entry.name).replace(/\\/g, "/");
|
|
450
|
+
// Excluded before the budget is spent: a file the caller asked us to skip
|
|
451
|
+
// must not be what makes the scan report itself as truncated.
|
|
373
452
|
if (excludePaths.some((fragment) => path.includes(fragment))) {
|
|
374
453
|
continue;
|
|
375
454
|
}
|
|
455
|
+
if (candidates.length >= maxFiles) {
|
|
456
|
+
truncated = true;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
376
459
|
candidates.push(path);
|
|
377
460
|
}
|
|
378
461
|
|