@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.
@@ -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,
@@ -21,6 +21,7 @@ function megabytes(bytes: number): string {
21
21
  return `${Math.round(bytes / (1024 * 1024))} MB`;
22
22
  }
23
23
 
24
+ /** Turns the outcome of a run into findings. */
24
25
  export function runFindings(
25
26
  outcome: RunOutcome,
26
27
  options: RunOptions = DEFAULT_RUN_OPTIONS,
@@ -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[],
@@ -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>> {
@@ -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,
@@ -14,6 +14,7 @@ export interface DependencyGraph {
14
14
  readonly duplicates: readonly DuplicateVersion[];
15
15
  }
16
16
 
17
+ /** A dependency resolved to more than one version. */
17
18
  export interface DuplicateVersion {
18
19
  readonly name: string;
19
20
  readonly versions: readonly string[];
@@ -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,
@@ -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 {
@@ -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, { excludePaths: rootConfig.excludePaths });
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
- const rootScan = await scanOne(dir, ".", "root", fs, runtime, options.configPath, false, config);
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
- path: target.dir,
278
- relative: target.relative,
279
- kind: target.kind,
280
- name: target.name,
281
- verdict: verdictFor(target.findings),
282
- counts: countBySeverity(target.findings.map((finding) => finding.severity)),
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,
@@ -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);
@@ -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
- * 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.
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 Mode = "code" | "single" | "double" | "template" | "line" | "block";
97
- let mode: Mode = "code";
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 (mode === "code") {
120
+ if (frame.kind === "code") {
107
121
  if (char === "/" && next === "/") {
108
- mode = "line";
122
+ frames.push({ kind: "line" });
109
123
  out.push(" ");
110
124
  index += 2;
111
125
  continue;
112
126
  }
113
127
  if (char === "/" && next === "*") {
114
- mode = "block";
128
+ frames.push({ kind: "block" });
115
129
  out.push(" ");
116
130
  index += 2;
117
131
  continue;
118
132
  }
119
- if (char === "'") {
120
- mode = "single";
121
- } else if (char === '"') {
122
- mode = "double";
123
- } else if (char === "`") {
124
- mode = "template";
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 (mode === "line") {
168
+ if (frame.kind === "line") {
132
169
  if (char === "\n") {
133
- mode = "code";
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 (mode === "block") {
179
+ if (frame.kind === "block") {
143
180
  if (char === "*" && next === "/") {
144
- mode = "code";
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
- const quote = mode === "single" ? "'" : mode === "double" ? '"' : "`";
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 === quote) {
161
- mode = "code";
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
- 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;
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 line;
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: lineOf(original, match.index) });
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