@mh-alikhani/bunready 0.3.1 → 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 +53 -3
- 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 +2 -1
- package/src/report/types.ts +12 -2
- package/src/scanner/scan.ts +34 -11
- package/src/scanner/sources.ts +145 -42
package/CHANGELOG.md
CHANGED
|
@@ -9,7 +9,54 @@ 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.
|
|
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
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- Source files are read with bounded concurrency instead of one at a time. A scan
|
|
49
|
+
of 800 files went from a 200.9ms median to 86.2ms on Windows (un run bench),
|
|
50
|
+
with identical output.
|
|
51
|
+
|
|
52
|
+
### Notes
|
|
53
|
+
|
|
54
|
+
- Measured and rejected: --bytecode cannot compile the entry point because it
|
|
55
|
+
uses top-level wait, --minify produces a byte-identical 82.2MB binary (the
|
|
56
|
+
size is the Bun runtime), and deferring the scanner behind dynamic imports moved
|
|
57
|
+
the cost into the scan path without a repeatable win.
|
|
58
|
+
|
|
59
|
+
## [0.3.1] - 2026-09-16
|
|
13
60
|
|
|
14
61
|
### Fixed
|
|
15
62
|
|
|
@@ -22,7 +69,7 @@ Nothing yet.
|
|
|
22
69
|
Behaviour is unchanged; `tests/workspaces.test.ts` and `tests/semver.test.ts`
|
|
23
70
|
cover the affected paths.
|
|
24
71
|
|
|
25
|
-
## [0.3.0] - 2026-09-
|
|
72
|
+
## [0.3.0] - 2026-09-16
|
|
26
73
|
|
|
27
74
|
### Added
|
|
28
75
|
|
|
@@ -201,7 +248,10 @@ Nothing yet.
|
|
|
201
248
|
and that claim needs a primary source. Until then the rule reports what the
|
|
202
249
|
repository imports and cites the compatibility table.
|
|
203
250
|
|
|
204
|
-
[Unreleased]: https://github.com/MHAlikhani/bunready/compare/v0.3.
|
|
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
|
|
254
|
+
[0.3.2]: https://github.com/MHAlikhani/bunready/releases/tag/v0.3.2
|
|
205
255
|
[0.3.1]: https://github.com/MHAlikhani/bunready/releases/tag/v0.3.1
|
|
206
256
|
[0.3.0]: https://github.com/MHAlikhani/bunready/releases/tag/v0.3.0
|
|
207
257
|
[0.2.0]: https://github.com/MHAlikhani/bunready/releases/tag/v0.2.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
|
-
**
|
|
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
|
[](https://github.com/MHAlikhani/bunready/actions/workflows/ci.yml)
|
|
12
14
|
[](https://github.com/MHAlikhani/bunready/actions/workflows/security.yml)
|
|
15
|
+
[](https://www.npmjs.com/package/@mh-alikhani/bunready)
|
|
13
16
|
[](LICENSE)
|
|
14
17
|
[](https://bun.sh)
|
|
15
18
|
[](tsconfig.json)
|
|
16
|
-
[](#status)
|
|
17
|
-
|
|
18
|
-
[](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 . #
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
## Monorepos
|
|
46
|
+
## What it checks
|
|
71
47
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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 #
|
|
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
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
##
|
|
92
|
+
## Configuration
|
|
106
93
|
|
|
107
|
-
|
|
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
|
-
##
|
|
96
|
+
## Documentation
|
|
118
97
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
123
|
+
## Licence
|
|
145
124
|
|
|
146
|
-
[MIT](LICENSE)
|
|
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 0003 - Release pipeline
|
|
2
2
|
|
|
3
3
|
- **Status:** accepted
|
|
4
|
-
- **Date:** 2026-08-
|
|
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.
|
|
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",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"check": "bun run typecheck && bun run lint && bun run test",
|
|
65
65
|
"og": "bun run scripts/generate-og-image.ts",
|
|
66
66
|
"smoke": "bun run scripts/smoke-real.ts",
|
|
67
|
+
"bench": "bun run scripts/bench.ts",
|
|
67
68
|
"prepare": "simple-git-hooks"
|
|
68
69
|
},
|
|
69
70
|
"devDependencies": {
|
package/src/report/types.ts
CHANGED
|
@@ -88,7 +88,13 @@ export interface ScanReport {
|
|
|
88
88
|
readonly baseline?: BaselineSummary;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/**
|
|
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
|
-
|
|
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
|
|
package/src/scanner/scan.ts
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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,
|
package/src/scanner/sources.ts
CHANGED
|
@@ -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
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
97
|
-
|
|
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 (
|
|
114
|
+
if (frame.kind === "code") {
|
|
107
115
|
if (char === "/" && next === "/") {
|
|
108
|
-
|
|
116
|
+
frames.push({ kind: "line" });
|
|
109
117
|
out.push(" ");
|
|
110
118
|
index += 2;
|
|
111
119
|
continue;
|
|
112
120
|
}
|
|
113
121
|
if (char === "/" && next === "*") {
|
|
114
|
-
|
|
122
|
+
frames.push({ kind: "block" });
|
|
115
123
|
out.push(" ");
|
|
116
124
|
index += 2;
|
|
117
125
|
continue;
|
|
118
126
|
}
|
|
119
|
-
if (char === "'") {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 (
|
|
162
|
+
if (frame.kind === "line") {
|
|
132
163
|
if (char === "\n") {
|
|
133
|
-
|
|
164
|
+
frames.pop();
|
|
134
165
|
out.push("\n");
|
|
135
166
|
} else {
|
|
136
167
|
out.push(" ");
|
|
@@ -139,9 +170,9 @@ export function maskNonCode(text: string): string {
|
|
|
139
170
|
continue;
|
|
140
171
|
}
|
|
141
172
|
|
|
142
|
-
if (
|
|
173
|
+
if (frame.kind === "block") {
|
|
143
174
|
if (char === "*" && next === "/") {
|
|
144
|
-
|
|
175
|
+
frames.pop();
|
|
145
176
|
out.push(" ");
|
|
146
177
|
index += 2;
|
|
147
178
|
continue;
|
|
@@ -151,18 +182,41 @@ export function maskNonCode(text: string): string {
|
|
|
151
182
|
continue;
|
|
152
183
|
}
|
|
153
184
|
|
|
154
|
-
|
|
185
|
+
if (frame.kind === "string") {
|
|
186
|
+
if (char === "\\") {
|
|
187
|
+
out.push(" ");
|
|
188
|
+
index += 2;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (char === frame.quote) {
|
|
192
|
+
frames.pop();
|
|
193
|
+
out.push(char);
|
|
194
|
+
index += 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
out.push(blank(char));
|
|
198
|
+
index += 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
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 ===
|
|
161
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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:
|
|
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>();
|
|
@@ -301,8 +373,41 @@ export interface ScanSourcesOptions {
|
|
|
301
373
|
}
|
|
302
374
|
|
|
303
375
|
/**
|
|
304
|
-
*
|
|
305
|
-
*
|
|
376
|
+
* Bounded-concurrency reads.
|
|
377
|
+
*
|
|
378
|
+
* Files are independent, so the ordered part (the walk) and the slow part (the
|
|
379
|
+
* reads) are separated: the walk decides the order, the pool does the waiting.
|
|
380
|
+
* Results keep their walk order because each file lands in its own slot.
|
|
381
|
+
*/
|
|
382
|
+
const MAX_READ_CONCURRENCY = 16;
|
|
383
|
+
|
|
384
|
+
async function readSourceFiles(paths: readonly string[], fs: FileSystem): Promise<SourceFile[]> {
|
|
385
|
+
const slots = new Array<SourceFile | undefined>(paths.length);
|
|
386
|
+
let cursor = 0;
|
|
387
|
+
|
|
388
|
+
const worker = async (): Promise<void> => {
|
|
389
|
+
while (cursor < paths.length) {
|
|
390
|
+
const index = cursor;
|
|
391
|
+
cursor += 1;
|
|
392
|
+
const path = paths[index];
|
|
393
|
+
if (path === undefined) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const outcome = await fs.readTextFile(path);
|
|
397
|
+
if (outcome.kind === "text") {
|
|
398
|
+
slots[index] = { path, imports: extractImports(outcome.text) };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
await Promise.all(Array.from({ length: Math.min(MAX_READ_CONCURRENCY, paths.length) }, worker));
|
|
404
|
+
return slots.filter((file): file is SourceFile => file !== undefined);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Walk the target's own source, breadth first and sorted, then read it. Reading
|
|
409
|
+
* a repository of hundreds of files one at a time was the slowest thing a scan
|
|
410
|
+
* did, and the files have no dependency on each other.
|
|
306
411
|
*/
|
|
307
412
|
export async function scanSources(
|
|
308
413
|
dir: string,
|
|
@@ -313,7 +418,7 @@ export async function scanSources(
|
|
|
313
418
|
const excludePaths = options.excludePaths ?? [];
|
|
314
419
|
const ignored = new Set<string>(IGNORED_DIRECTORIES);
|
|
315
420
|
const queue: string[] = [dir];
|
|
316
|
-
const
|
|
421
|
+
const candidates: string[] = [];
|
|
317
422
|
let truncated = false;
|
|
318
423
|
|
|
319
424
|
while (queue.length > 0) {
|
|
@@ -322,8 +427,7 @@ export async function scanSources(
|
|
|
322
427
|
break;
|
|
323
428
|
}
|
|
324
429
|
|
|
325
|
-
const
|
|
326
|
-
for (const entry of entries) {
|
|
430
|
+
for (const entry of await fs.listDirectory(current)) {
|
|
327
431
|
if (entry.isDirectory) {
|
|
328
432
|
if (!ignored.has(entry.name)) {
|
|
329
433
|
queue.push(join(current, entry.name));
|
|
@@ -333,19 +437,17 @@ export async function scanSources(
|
|
|
333
437
|
if (!hasSourceExtension(entry.name)) {
|
|
334
438
|
continue;
|
|
335
439
|
}
|
|
336
|
-
if (files.length >= maxFiles) {
|
|
337
|
-
truncated = true;
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
340
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.
|
|
341
443
|
if (excludePaths.some((fragment) => path.includes(fragment))) {
|
|
342
444
|
continue;
|
|
343
445
|
}
|
|
344
|
-
|
|
345
|
-
|
|
446
|
+
if (candidates.length >= maxFiles) {
|
|
447
|
+
truncated = true;
|
|
346
448
|
continue;
|
|
347
449
|
}
|
|
348
|
-
|
|
450
|
+
candidates.push(path);
|
|
349
451
|
}
|
|
350
452
|
|
|
351
453
|
if (truncated) {
|
|
@@ -353,5 +455,6 @@ export async function scanSources(
|
|
|
353
455
|
}
|
|
354
456
|
}
|
|
355
457
|
|
|
458
|
+
const files = await readSourceFiles(candidates, fs);
|
|
356
459
|
return { files, filesScanned: files.length, truncated };
|
|
357
460
|
}
|