@mh-alikhani/bunready 0.3.2 → 0.3.3

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 CHANGED
@@ -9,7 +9,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  Nothing yet.
11
11
 
12
- ## [0.3.2] - 2026-09-20
12
+ ## [0.3.3] - 2026-09-16
13
+
14
+ ### Fixed
15
+
16
+ - `targets[].path` and `report.target` are normalised like `finding.path`, so
17
+ grouping findings by target works on Windows as well as POSIX. The new
18
+ per-target tests caught this.
19
+ - Per-target verdicts and counts are computed from the findings that survived
20
+ configuration, so a package can no longer read "blocked" while the report says
21
+ "ready" because its blocker was ignored.
22
+ - An excluded path no longer consumes the source-file budget: the scan used to
23
+ report itself as truncated for files it was never going to read.
24
+ - Template-literal interpolations are scanned as code. `${require("node:fs")}`
25
+ is an import; template text that merely looks like one is still ignored.
26
+
27
+ ### Changed
28
+
29
+ - A workspace root no longer re-walks its packages' sources. Benchmark, 8
30
+ packages of 800 files each: 410ms and 8,400 files read became 303ms and 6,400 -
31
+ now exactly one package's worth per target.
32
+ - Line numbers come from one newline map per file instead of a rescan per match.
33
+ - `sortFindings` is documented and tested as a total order (severity, id, title,
34
+ path), so output is identical however the findings were collected.
35
+ - The README is rebuilt around a quickstart, what it checks, CI, FAQ and a
36
+ documentation index.
37
+
38
+ ### Added
39
+
40
+ - `bun run bench` measures a single package and a workspace.
41
+ - `docs/README.md` indexes the documentation.
42
+
43
+
44
+ ## [0.3.2] - 2026-09-16
13
45
 
14
46
  ### Changed
15
47
 
@@ -24,7 +56,7 @@ Nothing yet.
24
56
  size is the Bun runtime), and deferring the scanner behind dynamic imports moved
25
57
  the cost into the scan path without a repeatable win.
26
58
 
27
- ## [0.3.1] - 2026-09-19
59
+ ## [0.3.1] - 2026-09-16
28
60
 
29
61
  ### Fixed
30
62
 
@@ -37,7 +69,7 @@ Nothing yet.
37
69
  Behaviour is unchanged; `tests/workspaces.test.ts` and `tests/semver.test.ts`
38
70
  cover the affected paths.
39
71
 
40
- ## [0.3.0] - 2026-09-18
72
+ ## [0.3.0] - 2026-09-16
41
73
 
42
74
  ### Added
43
75
 
@@ -216,7 +248,9 @@ Nothing yet.
216
248
  and that claim needs a primary source. Until then the rule reports what the
217
249
  repository imports and cites the compatibility table.
218
250
 
219
- [Unreleased]: https://github.com/MHAlikhani/bunready/compare/v0.3.2...HEAD
251
+ [Unreleased]: https://github.com/MHAlikhani/bunready/compare/v0.3.3...HEAD
252
+ [0.3.3]: https://github.com/MHAlikhani/bunready/releases/tag/v0.3.3
253
+ [0.3.3]: https://github.com/MHAlikhani/bunready/releases/tag/v0.3.3
220
254
  [0.3.2]: https://github.com/MHAlikhani/bunready/releases/tag/v0.3.2
221
255
  [0.3.1]: https://github.com/MHAlikhani/bunready/releases/tag/v0.3.1
222
256
  [0.3.0]: https://github.com/MHAlikhani/bunready/releases/tag/v0.3.0
package/README.md CHANGED
@@ -6,141 +6,120 @@
6
6
  <img src="docs/brand/logo-card.svg" alt="bunready" width="360">
7
7
  </picture>
8
8
 
9
- **One command that tells you what will break before you move a Node/TS repo to Bun — and gives you one clear verdict.**
9
+ **Know what breaks before you move a Node/TS repo to Bun.**
10
+
11
+ One command. One honest verdict. Evidence, not estimates — and it runs in CI.
10
12
 
11
13
  [![ci](https://github.com/MHAlikhani/bunready/actions/workflows/ci.yml/badge.svg)](https://github.com/MHAlikhani/bunready/actions/workflows/ci.yml)
12
14
  [![security](https://github.com/MHAlikhani/bunready/actions/workflows/security.yml/badge.svg)](https://github.com/MHAlikhani/bunready/actions/workflows/security.yml)
15
+ [![npm](https://img.shields.io/npm/v/@mh-alikhani/bunready)](https://www.npmjs.com/package/@mh-alikhani/bunready)
13
16
  [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
14
17
  [![runtime](https://img.shields.io/badge/runtime-Bun%20%E2%89%A5%201.4-black)](https://bun.sh)
15
18
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6)](tsconfig.json)
16
- [![status](https://img.shields.io/badge/status-pre--alpha-orange)](#status)
17
-
18
- [![npm](https://img.shields.io/npm/v/@mh-alikhani/bunready)](https://www.npmjs.com/package/@mh-alikhani/bunready)
19
- -->
20
19
 
21
20
  </div>
22
21
 
23
- ---
24
-
25
- ## Status
26
-
27
- Pre-alpha, and precise about it. `bunready <path>` reads `package.json`, any
28
- lockfile, and the repository's own imports, then prints a report with a verdict
29
- and an exit code CI can gate on.
30
-
31
- `--run` goes further: it copies the target to a temporary directory, installs
32
- and boots it under Bun there, and reports the first real failure with timings.
33
- Every compatibility claim carries a source link, so you can check the
34
- underlying evidence yourself. See [STATE.md](STATE.md) for details.
35
-
36
- ## Install
22
+ ## Quickstart
37
23
 
38
24
  ```sh
39
- bunx @mh-alikhani/bunready . # run without installing
40
- bun add --global @mh-alikhani/bunready
25
+ bunx @mh-alikhani/bunready . # scan the current directory
26
+ bunx @mh-alikhani/bunready . --json # machine-readable report
27
+ bunx @mh-alikhani/bunready --help # every flag
41
28
  ```
42
29
 
43
- Or from a checkout:
30
+ Scanning this repository prints its findings and one verdict:
44
31
 
45
- ```sh
46
- git clone https://github.com/MHAlikhani/bunready.git
47
- cd bunready
48
- bun install
49
32
  ```
33
+ bunready 0.3.2 · 106 locked packages · bun.lock
34
+ /path/to/your/project
50
35
 
51
- ## Usage
36
+ info the project's own code imports 4 Node built-in module(s) (runtime/node-builtins)
37
+ Bun implements a large and still-moving part of the Node API…
38
+ evidence: fs/promises, module, os, path (in 6 file(s))
39
+ source: https://bun.com/docs/runtime/nodejs-compat
52
40
 
53
- ```sh
54
- bunx @mh-alikhani/bunready --help
55
-
56
- # against a target repository
57
- bunx @mh-alikhani/bunready /path/to/node-project
41
+ ready - no Bun compatibility blockers found
58
42
  ```
59
43
 
60
- | Flag | Meaning |
61
- | --- | --- |
62
- | `--help` | Print usage and exit. |
63
- | `--version` | Print the CLI version and exit. |
64
- | `--json` | Emit machine-readable JSON instead of the terminal report. |
65
- | `--run [script]` | Copy the target to a temporary directory, `bun install` and run a script (default `start`) there under Bun, and report the first failure with timings. Never executes in place; a timeout is a `risk`, a pass is `info`. |
66
- | `NO_COLOR` | Environment variable: disable ANSI color when set. |
44
+ Exit codes are the contract: **0** nothing at or above your threshold, **1** findings, **2** usage error.
67
45
 
68
- Exit codes: `0` no blockers, `1` blockers found, `2` usage error.
69
-
70
- ## Monorepos
46
+ ## What it checks
71
47
 
72
- A `workspaces` field in `package.json`, or a `pnpm-workspace.yaml`, is detected
73
- automatically: the root and every package are scanned, and the report aggregates
74
- them with a `targets` list and a `path` on each finding. `--scope packages/api`
75
- narrows a scan to the matching packages.
48
+ | Phase | Rules | Why it matters |
49
+ | --- | --- | --- |
50
+ | Install | Blocked lifecycle scripts, native addons, `engines` conflicts, lockfile gaps (`bun.lockb`, missing or unreadable lockfiles) | `bun install` behaves differently from npm: untrusted packages' install scripts do not run, and native builds need a toolchain |
51
+ | Runtime | The Node built-ins your own code imports, with a link to Bun's compatibility table | Tells you which surface to test rather than guessing |
52
+ | Run (`--run`) | Installs and boots your `start`/`test` script in a temporary copy, then reports the first real failure with its stack frames | The only way to know your project actually runs |
76
53
 
77
- ## Baselines
78
-
79
- ```sh
80
- bunready . --write-baseline bunready.baseline.json # accept today's findings
81
- bunready . --baseline bunready.baseline.json # fail only on new ones
82
- ```
83
-
84
- A baseline records rule, package and path - not the message - so rewording a
85
- finding does not resurrect it.
54
+ Every finding carries the evidence it rests on and, when it makes a compatibility claim, a link to the Bun documentation or issue that supports it — see [ADR 0001](docs/adr/0001-data-source-policy.md). bunready never invents compatibility facts.
86
55
 
87
56
  ## In CI
88
57
 
89
58
  ```yaml
90
59
  permissions:
91
60
  contents: read
92
- security-events: write # required for the SARIF upload
61
+ security-events: write # for the SARIF upload
93
62
 
94
63
  steps:
95
64
  - uses: actions/checkout@v7
96
- - uses: MHAlikhani/bunready@v0.2.0
65
+ - uses: MHAlikhani/bunready@v0.3.2
97
66
  with:
98
67
  path: .
99
68
  ```
100
69
 
101
- The action writes a JSON report, uploads the SARIF report to code scanning and
102
- fails the step when findings at or above `failOn` exist. Inputs: `path`,
103
- `version` (`latest` or `local`), `sarif-file`, `json-file`, `upload`.
70
+ The [action](action.yml) writes a JSON report, uploads SARIF to code scanning, and fails the step when findings at or above `failOn` exist. It is also [on the GitHub Marketplace](https://github.com/marketplace/actions/bunready).
71
+
72
+ ## Monorepos
73
+
74
+ A `workspaces` field or a `pnpm-workspace.yaml` is detected: every package is scanned, findings name the directory they came from, and the report aggregates them.
75
+
76
+ ```sh
77
+ bunready . --scope packages/api # one package
78
+ bunready . --sarif > bunready.sarif # SARIF 2.1.0 for code scanning
79
+ ```
80
+
81
+ ## Baselines
82
+
83
+ Accept today's findings, then fail only on what is new:
84
+
85
+ ```sh
86
+ bunready . --write-baseline bunready.baseline.json
87
+ bunready . --baseline bunready.baseline.json
88
+ ```
89
+
90
+ A fingerprint is rule + package + path — not the message — so rewording a finding does not resurrect one you already triaged.
104
91
 
105
- ## How it is different
92
+ ## Configuration
106
93
 
107
- - **Evidence, not estimates.** Every finding records what was observed in your
108
- repository, and every compatibility claim links to Bun's documentation or an
109
- issue. Nothing is inferred from a package name.
110
- - **Built for CI.** Stable exit codes, `--json` with a versioned schema,
111
- `--sarif` for code scanning, and baselines so a repository can fail on what is
112
- new instead of on its whole history.
113
- - **Whole-repository aware.** Workspace packages are scanned and aggregated, and
114
- `--run` installs and boots the project in a temporary copy to catch the first
115
- real failure rather than predicting one.
94
+ `bunready.config.json` in the repository root: ignore rules or packages, allowlist native addons, exclude paths, set `failOn`, and choose what `--run` does. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
116
95
 
117
- ## Why
96
+ ## Documentation
118
97
 
119
- Moving a repo to Bun is usually a pile of small unknowns: which npm lifecycle
120
- scripts actually run, which APIs are partial, which packages are native, which
121
- test runner behaviours differ. bunready answers that with evidence and a single
122
- verdict instead of a checklist you have to interpret yourself.
98
+ | Document | What it covers |
99
+ | --- | --- |
100
+ | [Configuration](docs/CONFIGURATION.md) | Every key in `bunready.config.json` |
101
+ | [JSON output](docs/JSON-OUTPUT.md) | The `--json` contract, `schemaVersion`, exit codes |
102
+ | [Releasing](docs/RELEASING.md) | How a release is cut and verified |
103
+ | [ADRs](docs/adr/) | Data source policy, severity model, release pipeline |
104
+ | [Brand](docs/brand/guidelines.md) | Logo, tokens, voice |
105
+ | [State](STATE.md) | Decisions and open questions, kept current |
106
+
107
+ ## FAQ
123
108
 
124
- ## Compatibility data policy
109
+ **Does it need network access?** No. A scan reads your repository and nothing else. Only `--run` reaches the registry, because it installs your dependencies in a temporary copy — and the report says so.
125
110
 
126
- Compatibility claims come only from public Bun documentation and issue tracker
127
- entries, shipped as versioned JSON with source links. We never invent
128
- compatibility facts. See [ADR 0001](docs/adr/0001-data-source-policy.md).
111
+ **Does it modify my repository?** No. Scans are read-only. `--run` copies the project to a temporary directory, excluding `.git`, `node_modules` and build output, and removes it afterwards.
129
112
 
130
- ## Contributing
113
+ **Why is the npm name scoped?** The plain name is refused by npm's similarity rule (`bun-ready` already exists), so the package is `@mh-alikhani/bunready`. The command, repository and action are all still `bunready`.
131
114
 
132
- See [CONTRIBUTING.md](CONTRIBUTING.md) and the
133
- [Code of Conduct](CODE_OF_CONDUCT.md). Security reports go through
134
- [SECURITY.md](SECURITY.md).
115
+ **How is it different from an estimate-style checker?** Every claim here is either observed in your repository or sourced from Bun's own documentation, the output is built for CI (versioned JSON, SARIF, baselines, exit codes), and `--run` settles the question by executing your project instead of predicting it.
135
116
 
136
- ## Disclaimer
117
+ **Does it support monorepos?** Yes — workspace packages are detected and scanned individually, with `--scope` to narrow the scan.
118
+
119
+ ## Status
137
120
 
138
- bunready is an independent, community project. It is **not affiliated with,
139
- endorsed by, or sponsored by the Bun project, Oven**. "Bun" and
140
- the Bun logo are trademarks of their respective owners and are used here only
141
- for descriptive, nominative purposes. bunready ships no Bun code and no Bun
142
- branding.
121
+ Pre-alpha, and precise about it. Install-phase and runtime-surface checks are complete and covered by tests; `--run` executes your own scripts (opt-in, in a temporary copy). What is not done is listed in [STATE.md](STATE.md#open-questions), and the 0.3.x line is a stable CLI contract: `--json` carries `schemaVersion`, and exit codes will not change.
143
122
 
144
- ## License
123
+ ## Licence
145
124
 
146
- [MIT](LICENSE) © Mohammad Hosein Alikhani
125
+ [MIT](LICENSE). Not affiliated with the Bun project or Oven.
package/docs/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # Documentation
2
+
3
+ | Document | What it covers |
4
+ | --- | --- |
5
+ | [CONFIGURATION.md](CONFIGURATION.md) | Every key in `bunready.config.json`, and what configuration deliberately cannot do |
6
+ | [JSON-OUTPUT.md](JSON-OUTPUT.md) | The `--json` contract: `schemaVersion`, field meanings, exit codes |
7
+ | [RELEASING.md](RELEASING.md) | How a release is cut, its two prerequisites, and how to verify one |
8
+ | [demo.md](demo.md) | The 30-second demo recording script |
9
+ | [adr/0001](adr/0001-data-source-policy.md) | Where compatibility claims may come from |
10
+ | [adr/0002](adr/0002-rule-severity-model.md) | What `blocker`, `risk` and `info` mean, and why they map to exit codes |
11
+ | [adr/0003](adr/0003-release-pipeline.md) | Why releases are tag-gated, published over OIDC, and shipped with an SBOM |
12
+ | [brand/guidelines.md](brand/guidelines.md) | Logo, colour, type and voice |
13
+ | [brand/tokens.json](brand/tokens.json) | The machine-readable palette, spacing, radii and terminal colours |
14
+
15
+ Working state — decisions, frozen interfaces, open questions — lives in
16
+ [../STATE.md](../STATE.md).
@@ -1,7 +1,7 @@
1
1
  # ADR 0001 - Compatibility data source policy
2
2
 
3
3
  - **Status:** accepted
4
- - **Date:** 2026-09-15
4
+ - **Date:** 2026-08-07
5
5
  - **Context:** `bunready`'s only value is that its verdict can be trusted.
6
6
 
7
7
  ## Decision
@@ -1,7 +1,7 @@
1
1
  # ADR 0002 - Rule severity model
2
2
 
3
3
  - **Status:** accepted
4
- - **Date:** 2026-09-15
4
+ - **Date:** 2026-08-07
5
5
  - **Context:** The report has to be actionable, and CI has to be able to gate on
6
6
  it, without turning a migration into a wall of red.
7
7
 
@@ -1,7 +1,7 @@
1
1
  # ADR 0003 - Release pipeline
2
2
 
3
3
  - **Status:** accepted
4
- - **Date:** 2026-08-26
4
+ - **Date:** 2026-08-27
5
5
  - **Context:** bunready is a CLI that people will run on their own machines, so
6
6
  "trust the release" has to be something a stranger can verify, not a promise.
7
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mh-alikhani/bunready",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Bun-readiness scanner: one command that shows what will break before you move a Node/TS repo to Bun.",
5
5
  "keywords": [
6
6
  "bun",
@@ -88,7 +88,13 @@ export interface ScanReport {
88
88
  readonly baseline?: BaselineSummary;
89
89
  }
90
90
 
91
- /** Blockers first, then risks, then info; stable within a severity. */
91
+ /**
92
+ * The single ordering authority for findings: blockers first, then risks, then
93
+ * info; within a severity by rule id, then title, then the directory it came
94
+ * from. The tiebreakers make the order *total*, so the same repository produces
95
+ * byte-identical output however the findings were collected - which is what a
96
+ * diff in a pull request or a baseline comparison depends on.
97
+ */
92
98
  export function sortFindings(findings: readonly Finding[]): Finding[] {
93
99
  return [...findings].sort((a, b) => {
94
100
  const bySeverity = compareSeverity(a.severity, b.severity);
@@ -96,7 +102,11 @@ export function sortFindings(findings: readonly Finding[]): Finding[] {
96
102
  return bySeverity;
97
103
  }
98
104
  const byId = a.id.localeCompare(b.id);
99
- return byId !== 0 ? byId : a.title.localeCompare(b.title);
105
+ if (byId !== 0) {
106
+ return byId;
107
+ }
108
+ const byTitle = a.title.localeCompare(b.title);
109
+ return byTitle !== 0 ? byTitle : (a.path ?? "").localeCompare(b.path ?? "");
100
110
  });
101
111
  }
102
112
 
@@ -75,6 +75,7 @@ async function scanOne(
75
75
  configPath: string | undefined,
76
76
  skipConfigDiscovery: boolean,
77
77
  rootConfig: TargetSnapshot["config"],
78
+ extraExcludePaths: readonly string[] = [],
78
79
  ): Promise<Result<TargetResult>> {
79
80
  const target = await readTarget(dir, fs, configPath, skipConfigDiscovery);
80
81
  if (!target.ok) {
@@ -84,7 +85,9 @@ async function scanOne(
84
85
  // One configuration governs the whole scan; a package's own file is ignored.
85
86
  const snapshot: TargetSnapshot = { ...target.value, config: rootConfig };
86
87
  const graph = buildGraph(snapshot.manifest, snapshot.lockfiles[0]?.parsed);
87
- const sources = await scanSources(dir, fs, { excludePaths: rootConfig.excludePaths });
88
+ const sources = await scanSources(dir, fs, {
89
+ excludePaths: [...rootConfig.excludePaths, ...extraExcludePaths],
90
+ });
88
91
  const usages = collectNodeBuiltins(sources);
89
92
 
90
93
  const findings = [
@@ -176,7 +179,20 @@ export async function scanTarget(
176
179
 
177
180
  const targets: TargetResult[] = [];
178
181
 
179
- const rootScan = await scanOne(dir, ".", "root", fs, runtime, options.configPath, false, config);
182
+ // The root's walk skips the packages: each one is scanned as its own target,
183
+ // and descending into them from the root spent the file budget on files that
184
+ // were about to be read a second time.
185
+ const rootScan = await scanOne(
186
+ dir,
187
+ ".",
188
+ "root",
189
+ fs,
190
+ runtime,
191
+ options.configPath,
192
+ false,
193
+ config,
194
+ packages.map((pkg) => `${pkg.relative}/`),
195
+ );
180
196
  if (!rootScan.ok) {
181
197
  return { ok: false, error: rootScan.error };
182
198
  }
@@ -271,16 +287,23 @@ export async function scanTarget(
271
287
  );
272
288
  const counts = countBySeverity(findings.map((finding) => finding.severity));
273
289
 
290
+ // Per-target verdicts are computed from the findings that survived config
291
+ // filtering, so a package can never say "blocked" while the overall report
292
+ // says "ready" because the blocker was ignored.
274
293
  const scannedTargets: readonly ScannedTarget[] =
275
294
  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
- }))
295
+ ? targets.map((target) => {
296
+ const normalized = target.dir.replace(/\\/g, "/");
297
+ const own = findings.filter((finding) => finding.path === normalized);
298
+ return {
299
+ path: target.dir.replace(/\\/g, "/"),
300
+ relative: target.relative,
301
+ kind: target.kind,
302
+ name: target.name,
303
+ verdict: verdictFor(own),
304
+ counts: countBySeverity(own.map((finding) => finding.severity)),
305
+ };
306
+ })
284
307
  : [];
285
308
 
286
309
  const builtinNames = new Set(targets.flatMap((target) => target.builtinNames));
@@ -292,7 +315,7 @@ export async function scanTarget(
292
315
  failOn: config.failOn,
293
316
  tool: TOOL_NAME,
294
317
  version: TOOL_VERSION,
295
- target: dir,
318
+ target: dir.replace(/\\/g, "/"),
296
319
  verdict: verdictFor(findings),
297
320
  counts,
298
321
  findings,
@@ -86,51 +86,82 @@ const STATEMENT_PATTERNS: readonly { kind: ImportKind; pattern: RegExp }[] = [
86
86
  * Replace the contents of strings and comments with spaces, preserving every
87
87
  * offset and newline.
88
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.
89
+ * Template literals are the interesting case: the literal text is still text,
90
+ * but a \`\${ ... }\` interpolation is real code and may contain imports, so the
91
+ * expression is scanned while the surrounding literal stays masked. Without
92
+ * this, \`\${require("node:fs")}\` counted as nothing, and string fixtures that
93
+ * merely contained import-shaped text were counted as imports.
93
94
  */
94
95
  export function maskNonCode(text: string): string {
95
96
  const out: string[] = [];
96
- type Mode = "code" | "single" | "double" | "template" | "line" | "block";
97
- let mode: Mode = "code";
97
+ type Frame =
98
+ | { readonly kind: "code"; readonly depth: number }
99
+ | { readonly kind: "string"; readonly quote: string }
100
+ | { readonly kind: "template" }
101
+ | { readonly kind: "line" }
102
+ | { readonly kind: "block" };
103
+ const frames: Frame[] = [{ kind: "code", depth: 0 }];
98
104
  let index = 0;
99
105
 
100
106
  const blank = (char: string): string => (char === "\n" ? "\n" : " ");
107
+ const top = (): Frame => frames[frames.length - 1] ?? { kind: "code", depth: 0 };
101
108
 
102
109
  while (index < text.length) {
103
110
  const char = text[index] ?? "";
104
111
  const next = text[index + 1] ?? "";
112
+ const frame = top();
105
113
 
106
- if (mode === "code") {
114
+ if (frame.kind === "code") {
107
115
  if (char === "/" && next === "/") {
108
- mode = "line";
116
+ frames.push({ kind: "line" });
109
117
  out.push(" ");
110
118
  index += 2;
111
119
  continue;
112
120
  }
113
121
  if (char === "/" && next === "*") {
114
- mode = "block";
122
+ frames.push({ kind: "block" });
115
123
  out.push(" ");
116
124
  index += 2;
117
125
  continue;
118
126
  }
119
- if (char === "'") {
120
- mode = "single";
121
- } else if (char === '"') {
122
- mode = "double";
123
- } else if (char === "`") {
124
- mode = "template";
127
+ if (char === '"' || char === "'") {
128
+ frames.push({ kind: "string", quote: char });
129
+ out.push(char);
130
+ index += 1;
131
+ continue;
132
+ }
133
+ if (char === "`") {
134
+ frames.push({ kind: "template" });
135
+ out.push(char);
136
+ index += 1;
137
+ continue;
138
+ }
139
+ if (char === "{") {
140
+ frames[frames.length - 1] = { kind: "code", depth: frame.depth + 1 };
141
+ out.push(char);
142
+ index += 1;
143
+ continue;
144
+ }
145
+ if (char === "}") {
146
+ const parent = frames[frames.length - 2];
147
+ if (frame.depth === 0 && parent?.kind === "template") {
148
+ // The interpolation ended: back inside the template literal.
149
+ frames.pop();
150
+ } else {
151
+ frames[frames.length - 1] = { kind: "code", depth: Math.max(0, frame.depth - 1) };
152
+ }
153
+ out.push(char);
154
+ index += 1;
155
+ continue;
125
156
  }
126
157
  out.push(char);
127
158
  index += 1;
128
159
  continue;
129
160
  }
130
161
 
131
- if (mode === "line") {
162
+ if (frame.kind === "line") {
132
163
  if (char === "\n") {
133
- mode = "code";
164
+ frames.pop();
134
165
  out.push("\n");
135
166
  } else {
136
167
  out.push(" ");
@@ -139,30 +170,53 @@ export function maskNonCode(text: string): string {
139
170
  continue;
140
171
  }
141
172
 
142
- if (mode === "block") {
173
+ if (frame.kind === "block") {
143
174
  if (char === "*" && next === "/") {
144
- mode = "code";
175
+ frames.pop();
176
+ out.push(" ");
177
+ index += 2;
178
+ continue;
179
+ }
180
+ out.push(blank(char));
181
+ index += 1;
182
+ continue;
183
+ }
184
+
185
+ if (frame.kind === "string") {
186
+ if (char === "\\") {
145
187
  out.push(" ");
146
188
  index += 2;
147
189
  continue;
148
190
  }
191
+ if (char === frame.quote) {
192
+ frames.pop();
193
+ out.push(char);
194
+ index += 1;
195
+ continue;
196
+ }
149
197
  out.push(blank(char));
150
198
  index += 1;
151
199
  continue;
152
200
  }
153
201
 
154
- const quote = mode === "single" ? "'" : mode === "double" ? '"' : "`";
202
+ // Template body: masked, except that ${ opens a code frame again.
155
203
  if (char === "\\") {
156
204
  out.push(" ");
157
205
  index += 2;
158
206
  continue;
159
207
  }
160
- if (char === quote) {
161
- mode = "code";
208
+ if (char === "`") {
209
+ frames.pop();
162
210
  out.push(char);
163
211
  index += 1;
164
212
  continue;
165
213
  }
214
+ if (char === "$" && next === "{") {
215
+ frames.push({ kind: "code", depth: 0 });
216
+ out.push("${");
217
+ index += 2;
218
+ continue;
219
+ }
166
220
  out.push(blank(char));
167
221
  index += 1;
168
222
  }
@@ -200,19 +254,36 @@ function readQuoted(text: string, from: number): string | undefined {
200
254
  return value === "" ? undefined : value;
201
255
  }
202
256
 
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;
257
+ /** Newline offsets for one file, computed once and searched per match. */
258
+ function newlinePositions(text: string): number[] {
259
+ const positions: number[] = [];
260
+ for (let index = 0; index < text.length; index += 1) {
261
+ if (text[index] === "\n") {
262
+ positions.push(index);
263
+ }
264
+ }
265
+ return positions;
266
+ }
267
+
268
+ /** 1-based line for an offset, by binary search over the newline positions. */
269
+ function lineAt(positions: readonly number[], index: number): number {
270
+ let low = 0;
271
+ let high = positions.length;
272
+ while (low < high) {
273
+ const mid = (low + high) >> 1;
274
+ if ((positions[mid] ?? 0) < index) {
275
+ low = mid + 1;
276
+ } else {
277
+ high = mid;
208
278
  }
209
279
  }
210
- return line;
280
+ return low + 1;
211
281
  }
212
282
 
213
283
  function collect(
214
284
  masked: string,
215
285
  original: string,
286
+ newlines: readonly number[],
216
287
  pattern: RegExp,
217
288
  kind: ImportKind,
218
289
  into: ImportRef[],
@@ -222,17 +293,18 @@ function collect(
222
293
  if (specifier === undefined) {
223
294
  continue;
224
295
  }
225
- into.push({ specifier, kind, line: lineOf(original, match.index) });
296
+ into.push({ specifier, kind, line: lineAt(newlines, match.index) });
226
297
  }
227
298
  }
228
299
 
229
300
  /** Extract every import/require specifier in one file's text. */
230
301
  export function extractImports(text: string): ImportRef[] {
231
302
  const masked = maskNonCode(text);
303
+ const newlines = newlinePositions(text);
232
304
  const refs: ImportRef[] = [];
233
305
 
234
306
  for (const { kind, pattern } of STATEMENT_PATTERNS) {
235
- collect(masked, text, pattern, kind, refs);
307
+ collect(masked, text, newlines, pattern, kind, refs);
236
308
  }
237
309
 
238
310
  const seen = new Set<string>();
@@ -365,14 +437,16 @@ export async function scanSources(
365
437
  if (!hasSourceExtension(entry.name)) {
366
438
  continue;
367
439
  }
368
- if (candidates.length >= maxFiles) {
369
- truncated = true;
370
- continue;
371
- }
372
440
  const path = join(current, entry.name).replace(/\\/g, "/");
441
+ // Excluded before the budget is spent: a file the caller asked us to skip
442
+ // must not be what makes the scan report itself as truncated.
373
443
  if (excludePaths.some((fragment) => path.includes(fragment))) {
374
444
  continue;
375
445
  }
446
+ if (candidates.length >= maxFiles) {
447
+ truncated = true;
448
+ continue;
449
+ }
376
450
  candidates.push(path);
377
451
  }
378
452