@mh-alikhani/bunready 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +140 -0
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/action.yml +89 -0
- package/docs/CONFIGURATION.md +44 -0
- package/docs/JSON-OUTPUT.md +50 -0
- package/docs/RELEASING.md +65 -0
- package/docs/adr/0001-data-source-policy.md +36 -0
- package/docs/adr/0002-rule-severity-model.md +42 -0
- package/docs/adr/0003-release-pipeline.md +51 -0
- package/docs/brand/favicon.svg +8 -0
- package/docs/brand/guidelines.md +70 -0
- package/docs/brand/logo-dark.svg +11 -0
- package/docs/brand/logo-mono.svg +11 -0
- package/docs/brand/logo.svg +11 -0
- package/docs/brand/mark.svg +8 -0
- package/docs/brand/tokens.json +74 -0
- package/docs/demo.md +37 -0
- package/package.json +71 -0
- package/src/cli/args.ts +177 -0
- package/src/cli/copy.ts +76 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/io.ts +20 -0
- package/src/cli/run.ts +98 -0
- package/src/cli/theme.ts +59 -0
- package/src/config/baseline.ts +116 -0
- package/src/config/config.ts +113 -0
- package/src/core/errors.ts +59 -0
- package/src/core/fs.ts +72 -0
- package/src/core/version.ts +9 -0
- package/src/report/human.ts +100 -0
- package/src/report/json.ts +11 -0
- package/src/report/sarif.ts +73 -0
- package/src/report/types.ts +114 -0
- package/src/rules/data/native-packages.json +81 -0
- package/src/rules/data/node-runtime.json +6 -0
- package/src/rules/install/engines.ts +74 -0
- package/src/rules/install/index.ts +27 -0
- package/src/rules/install/lifecycle-scripts.ts +70 -0
- package/src/rules/install/lockfile-presence.ts +68 -0
- package/src/rules/install/native-addon.ts +126 -0
- package/src/rules/run/index.ts +114 -0
- package/src/rules/runtime/builtins.ts +148 -0
- package/src/rules/runtime/index.ts +18 -0
- package/src/rules/severity.ts +46 -0
- package/src/scanner/execute.ts +301 -0
- package/src/scanner/graph.ts +77 -0
- package/src/scanner/lockfile.ts +545 -0
- package/src/scanner/manifest.ts +109 -0
- package/src/scanner/scan.ts +322 -0
- package/src/scanner/semver.ts +227 -0
- package/src/scanner/sources.ts +355 -0
- package/src/scanner/target.ts +224 -0
- package/src/scanner/workspaces.ts +170 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { applyBaseline, parseBaseline } from "../config/baseline";
|
|
3
|
+
import { defineError, type Result } from "../core/errors";
|
|
4
|
+
import { type FileSystem, nodeFileSystem } from "../core/fs";
|
|
5
|
+
import { TOOL_NAME, TOOL_VERSION } from "../core/version";
|
|
6
|
+
import {
|
|
7
|
+
type Finding,
|
|
8
|
+
type RunSummary,
|
|
9
|
+
SCHEMA_VERSION,
|
|
10
|
+
type ScannedTarget,
|
|
11
|
+
type ScanReport,
|
|
12
|
+
sortFindings,
|
|
13
|
+
verdictFor,
|
|
14
|
+
} from "../report/types";
|
|
15
|
+
import { installFindings } from "../rules/install";
|
|
16
|
+
import type { RuntimeInfo } from "../rules/install/engines";
|
|
17
|
+
import { runFindings } from "../rules/run";
|
|
18
|
+
import { runtimeFindings } from "../rules/runtime";
|
|
19
|
+
import { collectNodeBuiltins } from "../rules/runtime/builtins";
|
|
20
|
+
import { countBySeverity } from "../rules/severity";
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_RUN_OPTIONS,
|
|
23
|
+
executeProject,
|
|
24
|
+
type RunEnvironment,
|
|
25
|
+
type RunOptions,
|
|
26
|
+
systemRunEnvironment,
|
|
27
|
+
} from "./execute";
|
|
28
|
+
import { buildGraph } from "./graph";
|
|
29
|
+
import { scanSources } from "./sources";
|
|
30
|
+
import { readTarget, type TargetSnapshot } from "./target";
|
|
31
|
+
import {
|
|
32
|
+
findWorkspacePackages,
|
|
33
|
+
readPnpmWorkspace,
|
|
34
|
+
scopeMatches,
|
|
35
|
+
workspacePatterns,
|
|
36
|
+
} from "./workspaces";
|
|
37
|
+
|
|
38
|
+
/** The runtime the scan runs under. Injected in tests so results are stable. */
|
|
39
|
+
export function detectRuntime(): RuntimeInfo {
|
|
40
|
+
return { bun: Bun.version, node: process.versions.node };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ScanOptions {
|
|
44
|
+
readonly fs?: FileSystem;
|
|
45
|
+
readonly runtime?: RuntimeInfo;
|
|
46
|
+
/** Opt in to executing the target's code in a temporary copy (root only). */
|
|
47
|
+
readonly run?: boolean;
|
|
48
|
+
readonly runScript?: string;
|
|
49
|
+
readonly configPath?: string;
|
|
50
|
+
/** Restrict a workspace scan to the packages matching this string. */
|
|
51
|
+
readonly scope?: string;
|
|
52
|
+
/** Compare against a recorded baseline and mark new findings. */
|
|
53
|
+
readonly baselinePath?: string;
|
|
54
|
+
readonly runEnvironment?: RunEnvironment;
|
|
55
|
+
readonly runOptions?: RunOptions;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface TargetResult {
|
|
59
|
+
readonly dir: string;
|
|
60
|
+
readonly relative: string;
|
|
61
|
+
readonly kind: "root" | "workspace";
|
|
62
|
+
readonly name: string | undefined;
|
|
63
|
+
readonly findings: readonly Finding[];
|
|
64
|
+
readonly builtinNames: readonly string[];
|
|
65
|
+
readonly sourceFiles: number;
|
|
66
|
+
readonly snapshot: TargetSnapshot;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function scanOne(
|
|
70
|
+
dir: string,
|
|
71
|
+
relative: string,
|
|
72
|
+
kind: "root" | "workspace",
|
|
73
|
+
fs: FileSystem,
|
|
74
|
+
runtime: RuntimeInfo,
|
|
75
|
+
configPath: string | undefined,
|
|
76
|
+
skipConfigDiscovery: boolean,
|
|
77
|
+
rootConfig: TargetSnapshot["config"],
|
|
78
|
+
): Promise<Result<TargetResult>> {
|
|
79
|
+
const target = await readTarget(dir, fs, configPath, skipConfigDiscovery);
|
|
80
|
+
if (!target.ok) {
|
|
81
|
+
return { ok: false, error: target.error };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// One configuration governs the whole scan; a package's own file is ignored.
|
|
85
|
+
const snapshot: TargetSnapshot = { ...target.value, config: rootConfig };
|
|
86
|
+
const graph = buildGraph(snapshot.manifest, snapshot.lockfiles[0]?.parsed);
|
|
87
|
+
const sources = await scanSources(dir, fs, { excludePaths: rootConfig.excludePaths });
|
|
88
|
+
const usages = collectNodeBuiltins(sources);
|
|
89
|
+
|
|
90
|
+
const findings = [
|
|
91
|
+
...installFindings(snapshot, graph, runtime),
|
|
92
|
+
...runtimeFindings(sources, usages),
|
|
93
|
+
].map((finding) => ({ ...finding, path: dir.replace(/\\/g, "/") }));
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
value: {
|
|
98
|
+
dir,
|
|
99
|
+
relative,
|
|
100
|
+
kind,
|
|
101
|
+
name: snapshot.manifest.name,
|
|
102
|
+
findings,
|
|
103
|
+
builtinNames: usages.map((usage) => usage.name),
|
|
104
|
+
sourceFiles: sources.filesScanned,
|
|
105
|
+
snapshot,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Scan one repository, or every package in a workspace.
|
|
112
|
+
*
|
|
113
|
+
* Read-only unless `run` is set. Configuration is read once from the root and
|
|
114
|
+
* applied to every package, so a monorepo cannot end up with two different
|
|
115
|
+
* thresholds in one report.
|
|
116
|
+
*/
|
|
117
|
+
export async function scanTarget(
|
|
118
|
+
dir: string,
|
|
119
|
+
options: ScanOptions = {},
|
|
120
|
+
): Promise<Result<ScanReport>> {
|
|
121
|
+
const fs = options.fs ?? nodeFileSystem();
|
|
122
|
+
const runtime = options.runtime ?? detectRuntime();
|
|
123
|
+
|
|
124
|
+
const rootTarget = await readTarget(dir, fs, options.configPath);
|
|
125
|
+
if (!rootTarget.ok) {
|
|
126
|
+
return { ok: false, error: rootTarget.error };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const rootSnapshot = rootTarget.value;
|
|
130
|
+
const config = rootSnapshot.config;
|
|
131
|
+
|
|
132
|
+
const patterns = workspacePatterns(rootSnapshot.manifest, await readPnpmWorkspace(dir, fs));
|
|
133
|
+
const packages = patterns.length > 0 ? await findWorkspacePackages(dir, patterns, fs) : [];
|
|
134
|
+
|
|
135
|
+
let selected = packages;
|
|
136
|
+
if (options.scope !== undefined) {
|
|
137
|
+
if (packages.length === 0) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
error: defineError(
|
|
141
|
+
"E_USAGE",
|
|
142
|
+
"--scope was given but this repository declares no workspaces",
|
|
143
|
+
{
|
|
144
|
+
hint: "remove --scope, or add a workspaces field to package.json.",
|
|
145
|
+
},
|
|
146
|
+
),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
selected = packages.filter((pkg) => scopeMatches(pkg.relative, options.scope ?? ""));
|
|
150
|
+
if (selected.length === 0) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
error: defineError("E_USAGE", `no workspace matches "${options.scope}"`, {
|
|
154
|
+
hint: `available packages: ${packages.map((pkg) => pkg.relative).join(", ")}.`,
|
|
155
|
+
}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const requestedScript = options.runScript ?? config.run.script;
|
|
161
|
+
if (
|
|
162
|
+
requestedScript !== undefined &&
|
|
163
|
+
rootSnapshot.manifest.scripts[requestedScript] === undefined
|
|
164
|
+
) {
|
|
165
|
+
const available = Object.keys(rootSnapshot.manifest.scripts).sort();
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: defineError("E_USAGE", `this project has no "${requestedScript}" script`, {
|
|
169
|
+
hint:
|
|
170
|
+
available.length === 0
|
|
171
|
+
? "package.json declares no scripts at all."
|
|
172
|
+
: `available scripts: ${available.join(", ")}.`,
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const targets: TargetResult[] = [];
|
|
178
|
+
|
|
179
|
+
const rootScan = await scanOne(dir, ".", "root", fs, runtime, options.configPath, false, config);
|
|
180
|
+
if (!rootScan.ok) {
|
|
181
|
+
return { ok: false, error: rootScan.error };
|
|
182
|
+
}
|
|
183
|
+
if (options.scope === undefined) {
|
|
184
|
+
targets.push(rootScan.value);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const pkg of selected) {
|
|
188
|
+
const scanned = await scanOne(
|
|
189
|
+
join(dir, pkg.relative),
|
|
190
|
+
pkg.relative,
|
|
191
|
+
"workspace",
|
|
192
|
+
fs,
|
|
193
|
+
runtime,
|
|
194
|
+
undefined,
|
|
195
|
+
true,
|
|
196
|
+
config,
|
|
197
|
+
);
|
|
198
|
+
if (!scanned.ok) {
|
|
199
|
+
return { ok: false, error: scanned.error };
|
|
200
|
+
}
|
|
201
|
+
targets.push(scanned.value);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let collected: readonly Finding[] = targets.flatMap((target) => target.findings);
|
|
205
|
+
let baselineSummary: ScanReport["baseline"];
|
|
206
|
+
|
|
207
|
+
if (options.baselinePath !== undefined) {
|
|
208
|
+
const outcome = await fs.readTextFile(options.baselinePath);
|
|
209
|
+
if (outcome.kind === "missing") {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: defineError("E_IO", `no baseline file at ${options.baselinePath}`, {
|
|
213
|
+
hint: "create one with `bunready <path> --write-baseline <file>`.",
|
|
214
|
+
}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (outcome.kind === "error") {
|
|
218
|
+
return { ok: false, error: outcome.error };
|
|
219
|
+
}
|
|
220
|
+
const parsed = parseBaseline(outcome.text, options.baselinePath);
|
|
221
|
+
if (!parsed.ok) {
|
|
222
|
+
return { ok: false, error: parsed.error };
|
|
223
|
+
}
|
|
224
|
+
const applied = applyBaseline(collected, parsed.value, options.baselinePath);
|
|
225
|
+
collected = applied.findings;
|
|
226
|
+
baselineSummary = applied.summary;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let executedFindings: readonly Finding[] = [];
|
|
230
|
+
let runSummary: RunSummary | undefined;
|
|
231
|
+
|
|
232
|
+
if (options.run === true) {
|
|
233
|
+
const runOptions: RunOptions = {
|
|
234
|
+
installTimeoutMs: DEFAULT_RUN_OPTIONS.installTimeoutMs,
|
|
235
|
+
scriptTimeoutMs: DEFAULT_RUN_OPTIONS.scriptTimeoutMs,
|
|
236
|
+
maxCopyMegabytes: config.run.maxCopyMegabytes,
|
|
237
|
+
...(requestedScript === undefined ? {} : { script: requestedScript }),
|
|
238
|
+
};
|
|
239
|
+
const executed = await executeProject(
|
|
240
|
+
dir,
|
|
241
|
+
rootSnapshot.manifest,
|
|
242
|
+
options.runEnvironment ?? systemRunEnvironment(),
|
|
243
|
+
runOptions,
|
|
244
|
+
);
|
|
245
|
+
if (!executed.ok) {
|
|
246
|
+
return { ok: false, error: executed.error };
|
|
247
|
+
}
|
|
248
|
+
const outcome = executed.value;
|
|
249
|
+
executedFindings = runFindings(outcome, runOptions).map((finding) => ({
|
|
250
|
+
...finding,
|
|
251
|
+
path: dir.replace(/\\/g, "/"),
|
|
252
|
+
}));
|
|
253
|
+
runSummary = {
|
|
254
|
+
script: outcome.script,
|
|
255
|
+
installExitCode: outcome.install?.code ?? null,
|
|
256
|
+
exitCode: outcome.result?.code ?? null,
|
|
257
|
+
timedOut: outcome.result?.timedOut ?? false,
|
|
258
|
+
durationMs: outcome.result?.durationMs,
|
|
259
|
+
firstFailure: outcome.failure?.message,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const ignored = new Set(config.ignore);
|
|
264
|
+
const ignoredPackages = new Set(config.ignorePackages);
|
|
265
|
+
const findings = sortFindings(
|
|
266
|
+
[...collected, ...executedFindings].filter(
|
|
267
|
+
(finding) =>
|
|
268
|
+
!ignored.has(finding.id) &&
|
|
269
|
+
(finding.package === undefined || !ignoredPackages.has(finding.package)),
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
const counts = countBySeverity(findings.map((finding) => finding.severity));
|
|
273
|
+
|
|
274
|
+
const scannedTargets: readonly ScannedTarget[] =
|
|
275
|
+
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
|
+
}))
|
|
284
|
+
: [];
|
|
285
|
+
|
|
286
|
+
const builtinNames = new Set(targets.flatMap((target) => target.builtinNames));
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
ok: true,
|
|
290
|
+
value: {
|
|
291
|
+
schemaVersion: SCHEMA_VERSION,
|
|
292
|
+
failOn: config.failOn,
|
|
293
|
+
tool: TOOL_NAME,
|
|
294
|
+
version: TOOL_VERSION,
|
|
295
|
+
target: dir,
|
|
296
|
+
verdict: verdictFor(findings),
|
|
297
|
+
counts,
|
|
298
|
+
findings,
|
|
299
|
+
stats: {
|
|
300
|
+
directDependencies: targets.reduce(
|
|
301
|
+
(total, target) => total + Object.keys(target.snapshot.manifest.dependencies).length,
|
|
302
|
+
0,
|
|
303
|
+
),
|
|
304
|
+
devDependencies: targets.reduce(
|
|
305
|
+
(total, target) => total + Object.keys(target.snapshot.manifest.devDependencies).length,
|
|
306
|
+
0,
|
|
307
|
+
),
|
|
308
|
+
lockedPackages: targets.reduce(
|
|
309
|
+
(total, target) => total + (target.snapshot.lockfiles[0]?.parsed.packages.length ?? 0),
|
|
310
|
+
0,
|
|
311
|
+
),
|
|
312
|
+
duplicateVersions: 0,
|
|
313
|
+
lockfiles: rootSnapshot.lockfiles.map((entry) => entry.path),
|
|
314
|
+
sourceFiles: targets.reduce((total, target) => total + target.sourceFiles, 0),
|
|
315
|
+
nodeBuiltins: builtinNames.size,
|
|
316
|
+
},
|
|
317
|
+
...(scannedTargets.length > 0 ? { targets: scannedTargets } : {}),
|
|
318
|
+
...(runSummary === undefined ? {} : { run: runSummary }),
|
|
319
|
+
...(baselineSummary === undefined ? {} : { baseline: baselineSummary }),
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal semver range evaluation.
|
|
3
|
+
*
|
|
4
|
+
* This exists so the `engines` rule can compare declared ranges against the
|
|
5
|
+
* running runtime without adding a dependency. It deliberately supports the
|
|
6
|
+
* subset that appears in `engines` fields: comparators, caret, tilde, x-ranges,
|
|
7
|
+
* hyphen ranges and `||` alternation. Anything it cannot parse returns
|
|
8
|
+
* `undefined`, and callers must report "could not evaluate" rather than guess.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ParsedVersion {
|
|
12
|
+
readonly major: number;
|
|
13
|
+
readonly minor: number;
|
|
14
|
+
readonly patch: number;
|
|
15
|
+
readonly prerelease: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Operator = "<" | "<=" | ">" | ">=" | "=";
|
|
19
|
+
|
|
20
|
+
interface Comparator {
|
|
21
|
+
readonly operator: Operator;
|
|
22
|
+
readonly version: ParsedVersion;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Conjunction = readonly Comparator[];
|
|
26
|
+
type Disjunction = readonly Conjunction[];
|
|
27
|
+
|
|
28
|
+
const VERSION_PATTERN = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$/;
|
|
29
|
+
|
|
30
|
+
export function parseVersion(text: string): ParsedVersion | undefined {
|
|
31
|
+
const cleaned = text.trim().replace(/^[=v]+/, "");
|
|
32
|
+
const match = VERSION_PATTERN.exec(cleaned);
|
|
33
|
+
if (match === null) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
major: Number(match[1]),
|
|
38
|
+
minor: match[2] === undefined ? 0 : Number(match[2]),
|
|
39
|
+
patch: match[3] === undefined ? 0 : Number(match[3]),
|
|
40
|
+
prerelease: match[4],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Which components the author actually wrote, needed for x-range expansion. */
|
|
45
|
+
function writtenParts(text: string): number {
|
|
46
|
+
return (
|
|
47
|
+
text
|
|
48
|
+
.trim()
|
|
49
|
+
.replace(/^[=v]+/, "")
|
|
50
|
+
.split("-")[0]
|
|
51
|
+
?.split(".").length ?? 0
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function compareVersions(a: ParsedVersion, b: ParsedVersion): number {
|
|
56
|
+
if (a.major !== b.major) {
|
|
57
|
+
return a.major < b.major ? -1 : 1;
|
|
58
|
+
}
|
|
59
|
+
if (a.minor !== b.minor) {
|
|
60
|
+
return a.minor < b.minor ? -1 : 1;
|
|
61
|
+
}
|
|
62
|
+
if (a.patch !== b.patch) {
|
|
63
|
+
return a.patch < b.patch ? -1 : 1;
|
|
64
|
+
}
|
|
65
|
+
if (a.prerelease === b.prerelease) {
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
if (a.prerelease === undefined) {
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
if (b.prerelease === undefined) {
|
|
72
|
+
return -1;
|
|
73
|
+
}
|
|
74
|
+
return a.prerelease < b.prerelease ? -1 : 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function version(major: number, minor: number, patch: number): ParsedVersion {
|
|
78
|
+
return { major, minor, patch, prerelease: undefined };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isWildcardPart(part: string | undefined): boolean {
|
|
82
|
+
return part === undefined || part === "" || part === "x" || part === "X" || part === "*";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Turn one token ("^1.2.3", ">=2", "1.x", "*", "1.2.3") into comparators.
|
|
87
|
+
* Returns undefined when the token is not a form this module understands.
|
|
88
|
+
*/
|
|
89
|
+
function comparatorsForToken(token: string): Comparator[] | undefined {
|
|
90
|
+
const trimmed = token.trim();
|
|
91
|
+
if (trimmed === "" || trimmed === "*" || trimmed === "x" || trimmed === "latest") {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const operatorMatch = /^(>=|<=|>|<|=|\^|~)?\s*(.*)$/.exec(trimmed);
|
|
96
|
+
if (operatorMatch === null) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const operator = operatorMatch[1];
|
|
100
|
+
const rest = operatorMatch[2] ?? "";
|
|
101
|
+
if (rest === "" || rest === "*" || rest === "x") {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const parts = rest.split(".");
|
|
106
|
+
const partsWritten = writtenParts(rest);
|
|
107
|
+
|
|
108
|
+
if (operator === "^") {
|
|
109
|
+
const base = parseVersion(rest);
|
|
110
|
+
if (base === undefined) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const upper =
|
|
114
|
+
base.major > 0
|
|
115
|
+
? version(base.major + 1, 0, 0)
|
|
116
|
+
: base.minor > 0
|
|
117
|
+
? version(0, base.minor + 1, 0)
|
|
118
|
+
: version(0, 0, base.patch + 1);
|
|
119
|
+
return [
|
|
120
|
+
{ operator: ">=", version: base },
|
|
121
|
+
{ operator: "<", version: upper },
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (operator === "~") {
|
|
126
|
+
const base = parseVersion(rest);
|
|
127
|
+
if (base === undefined) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
const upper =
|
|
131
|
+
partsWritten >= 2 ? version(base.major, base.minor + 1, 0) : version(base.major + 1, 0, 0);
|
|
132
|
+
return [
|
|
133
|
+
{ operator: ">=", version: base },
|
|
134
|
+
{ operator: "<", version: upper },
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (operator === ">" || operator === ">=" || operator === "<" || operator === "<=") {
|
|
139
|
+
const bounded = parseVersion(rest);
|
|
140
|
+
return bounded === undefined ? undefined : [{ operator, version: bounded }];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// x-ranges and partial versions: "1" -> 1.x, "1.2" -> 1.2.x, "1.2.x" -> 1.2.x
|
|
144
|
+
if (isWildcardPart(parts[1]) || isWildcardPart(parts[2]) || partsWritten < 3) {
|
|
145
|
+
const base = parseVersion(
|
|
146
|
+
`${parts[0] ?? "0"}.${isWildcardPart(parts[1]) ? "0" : (parts[1] ?? "0")}.${isWildcardPart(parts[2]) ? "0" : (parts[2] ?? "0")}`,
|
|
147
|
+
);
|
|
148
|
+
if (base === undefined) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
const upper = isWildcardPart(parts[1])
|
|
152
|
+
? version(base.major + 1, 0, 0)
|
|
153
|
+
: version(base.major, base.minor + 1, 0);
|
|
154
|
+
return [
|
|
155
|
+
{ operator: ">=", version: base },
|
|
156
|
+
{ operator: "<", version: upper },
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const exact = parseVersion(rest);
|
|
161
|
+
return exact === undefined ? undefined : [{ operator: "=", version: exact }];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function parseRange(range: string): Disjunction | undefined {
|
|
165
|
+
const trimmed = range.trim();
|
|
166
|
+
if (trimmed === "" || trimmed === "*" || trimmed === "latest") {
|
|
167
|
+
return [[]];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const alternatives: Conjunction[] = [];
|
|
171
|
+
for (const rawAlternative of trimmed.split("||")) {
|
|
172
|
+
const alternative = rawAlternative.trim();
|
|
173
|
+
if (alternative === "") {
|
|
174
|
+
alternatives.push([]);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(alternative);
|
|
179
|
+
if (hyphen !== null) {
|
|
180
|
+
const low = parseVersion(hyphen[1] ?? "");
|
|
181
|
+
const high = parseVersion(hyphen[2] ?? "");
|
|
182
|
+
if (low === undefined || high === undefined) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
alternatives.push([
|
|
186
|
+
{ operator: ">=", version: low },
|
|
187
|
+
{ operator: "<=", version: high },
|
|
188
|
+
]);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const conjunction: Comparator[] = [];
|
|
193
|
+
for (const token of alternative.split(/\s+/)) {
|
|
194
|
+
const comparators = comparatorsForToken(token);
|
|
195
|
+
if (comparators === undefined) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
conjunction.push(...comparators);
|
|
199
|
+
}
|
|
200
|
+
alternatives.push(conjunction);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return alternatives;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** undefined means "could not evaluate"; never turn that into a false. */
|
|
207
|
+
const COMPARATOR_TESTS: Readonly<Record<Operator, (order: number) => boolean>> = {
|
|
208
|
+
"<": (order) => order < 0,
|
|
209
|
+
"<=": (order) => order <= 0,
|
|
210
|
+
">": (order) => order > 0,
|
|
211
|
+
">=": (order) => order >= 0,
|
|
212
|
+
"=": (order) => order === 0,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
export function satisfies(candidate: string, range: string): boolean | undefined {
|
|
216
|
+
const parsedRange = parseRange(range);
|
|
217
|
+
const parsedVersion = parseVersion(candidate);
|
|
218
|
+
if (parsedRange === undefined || parsedVersion === undefined) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return parsedRange.some((conjunction) =>
|
|
223
|
+
conjunction.every((comparator) =>
|
|
224
|
+
COMPARATOR_TESTS[comparator.operator](compareVersions(parsedVersion, comparator.version)),
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
}
|