@4da/mcp-server 6.2.4 → 6.2.5
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 +484 -0
- package/README.md +22 -6
- package/dist/db.d.ts +38 -0
- package/dist/db.js +78 -23
- package/dist/doctor.js +6 -3
- package/dist/embeddings.d.ts +1 -1
- package/dist/embeddings.js +16 -14
- package/dist/index.js +73 -103
- package/dist/live/advisory-groups.d.ts +68 -0
- package/dist/live/advisory-groups.js +131 -0
- package/dist/live/cache.js +11 -3
- package/dist/live/cargo-platform.d.ts +6 -0
- package/dist/live/cargo-platform.js +8 -2
- package/dist/live/changelog.js +38 -6
- package/dist/live/dependency-set.d.ts +9 -1
- package/dist/live/dependency-set.js +10 -2
- package/dist/live/fix-paths.d.ts +258 -9
- package/dist/live/fix-paths.js +790 -45
- package/dist/live/github-releases.js +3 -0
- package/dist/live/held-dependencies.d.ts +93 -0
- package/dist/live/held-dependencies.js +212 -0
- package/dist/live/held-targets.d.ts +30 -0
- package/dist/live/held-targets.js +107 -0
- package/dist/live/http-utils.js +13 -3
- package/dist/live/index.d.ts +7 -4
- package/dist/live/index.js +24 -19
- package/dist/live/install-state.js +6 -2
- package/dist/live/js-lockfile-readers.d.ts +8 -1
- package/dist/live/js-lockfile-readers.js +43 -6
- package/dist/live/lockfile-parents.d.ts +15 -0
- package/dist/live/lockfile-parents.js +85 -10
- package/dist/live/lockfile-parsers-pygo.d.ts +47 -2
- package/dist/live/lockfile-parsers-pygo.js +230 -12
- package/dist/live/lockfile-parsers-rbphp.d.ts +38 -0
- package/dist/live/lockfile-parsers-rbphp.js +123 -0
- package/dist/live/lockfile-parsers.d.ts +6 -0
- package/dist/live/lockfile-parsers.js +116 -4
- package/dist/live/lockfile-types.d.ts +28 -1
- package/dist/live/maintenance.d.ts +20 -4
- package/dist/live/maintenance.js +31 -2
- package/dist/live/malware.d.ts +20 -0
- package/dist/live/malware.js +31 -0
- package/dist/live/npm-packument.d.ts +4 -1
- package/dist/live/npm-packument.js +5 -2
- package/dist/live/offered-release.d.ts +53 -0
- package/dist/live/offered-release.js +117 -0
- package/dist/live/osv-ranges.d.ts +111 -0
- package/dist/live/osv-ranges.js +173 -14
- package/dist/live/osv-scanner.d.ts +87 -5
- package/dist/live/osv-scanner.js +310 -59
- package/dist/live/pinned-advisories.d.ts +2 -2
- package/dist/live/pinned-advisories.js +3 -3
- package/dist/live/pypi-advice.d.ts +36 -0
- package/dist/live/pypi-advice.js +158 -0
- package/dist/live/rate-limiter.d.ts +7 -0
- package/dist/live/rate-limiter.js +22 -1
- package/dist/live/release-metadata.d.ts +5 -0
- package/dist/live/resolution.d.ts +3 -0
- package/dist/live/resolution.js +6 -3
- package/dist/live/scan-coverage.d.ts +26 -0
- package/dist/live/scan-coverage.js +70 -0
- package/dist/live/semver-utils.d.ts +8 -0
- package/dist/live/semver-utils.js +26 -3
- package/dist/live/severity-scope.d.ts +26 -1
- package/dist/live/severity-scope.js +32 -0
- package/dist/live/shell-safe.d.ts +19 -0
- package/dist/live/shell-safe.js +36 -0
- package/dist/live/types.d.ts +134 -8
- package/dist/live/version-compare.d.ts +49 -0
- package/dist/live/version-compare.js +131 -0
- package/dist/live/withdrawn-releases.d.ts +50 -0
- package/dist/live/withdrawn-releases.js +125 -0
- package/dist/project-manifests.d.ts +7 -0
- package/dist/project-manifests.js +45 -5
- package/dist/project-scanner.js +6 -4
- package/dist/project-tree.d.ts +56 -0
- package/dist/project-tree.js +174 -5
- package/dist/schema-registry.d.ts +47 -4
- package/dist/schema-registry.js +76 -9
- package/dist/server-instructions.js +4 -2
- package/dist/setup.d.ts +8 -0
- package/dist/setup.js +21 -2
- package/dist/tool-dispatch.js +3 -3
- package/dist/tools/agent-memory.d.ts +5 -0
- package/dist/tools/agent-memory.js +9 -0
- package/dist/tools/briefing-task-scope.js +5 -2
- package/dist/tools/decision-memory.js +2 -0
- package/dist/tools/dependency-check-held.d.ts +44 -0
- package/dist/tools/dependency-check-held.js +98 -0
- package/dist/tools/dependency-check-signals.d.ts +19 -2
- package/dist/tools/dependency-check-signals.js +84 -13
- package/dist/tools/dependency-check-versions.d.ts +37 -4
- package/dist/tools/dependency-check-versions.js +48 -13
- package/dist/tools/dependency-check.d.ts +11 -1
- package/dist/tools/dependency-check.js +81 -29
- package/dist/tools/dependency-health.js +209 -45
- package/dist/tools/get-context.d.ts +3 -0
- package/dist/tools/get-context.js +4 -0
- package/dist/tools/project-scope.d.ts +8 -0
- package/dist/tools/project-scope.js +21 -0
- package/dist/tools/upgrade-impact-features.d.ts +37 -0
- package/dist/tools/upgrade-impact-features.js +176 -0
- package/dist/tools/upgrade-impact-literals.js +26 -4
- package/dist/tools/upgrade-impact-report.d.ts +5 -1
- package/dist/tools/upgrade-impact-report.js +26 -5
- package/dist/tools/upgrade-impact.js +10 -3
- package/dist/tools/upgrade-planner-fixpaths.d.ts +162 -5
- package/dist/tools/upgrade-planner-fixpaths.js +0 -0
- package/dist/tools/upgrade-planner.d.ts +59 -4
- package/dist/tools/upgrade-planner.js +237 -61
- package/dist/tools/vulnerability-scan-concise.d.ts +47 -2
- package/dist/tools/vulnerability-scan-concise.js +37 -3
- package/dist/tools/vulnerability-scan-format.d.ts +126 -24
- package/dist/tools/vulnerability-scan-format.js +193 -26
- package/dist/tools/vulnerability-scan.d.ts +224 -63
- package/dist/tools/vulnerability-scan.js +50 -15
- package/dist/tools/what-should-i-know.d.ts +6 -1
- package/dist/tools/what-should-i-know.js +72 -15
- package/package.json +2 -2
|
@@ -249,8 +249,11 @@ export function readPnpmLock(content, versions, instances) {
|
|
|
249
249
|
setFirst(name, version);
|
|
250
250
|
if (!instances)
|
|
251
251
|
return;
|
|
252
|
+
// A Set, not `transitive.some` per entry: that was O(n²), and a 200k-entry pnpm-lock.yaml
|
|
253
|
+
// held the single-threaded stdio server for 409 s (audit 6.2.5 C-8).
|
|
254
|
+
const seen = new Set(transitive.map(([n, v]) => `${n}${v}`));
|
|
252
255
|
for (const [name, version] of versions) {
|
|
253
|
-
if (!
|
|
256
|
+
if (!seen.has(`${name}${version}`))
|
|
254
257
|
instances.add(name, version);
|
|
255
258
|
}
|
|
256
259
|
for (const [name, version] of transitive)
|
|
@@ -265,30 +268,63 @@ export function readPnpmLock(content, versions, instances) {
|
|
|
265
268
|
* names the real package in `resolution:`; v1 only in the alias spec.
|
|
266
269
|
*/
|
|
267
270
|
export function yarnPackageName(block, firstLine, headerName) {
|
|
268
|
-
const resolution =
|
|
271
|
+
const resolution = yarnBlockField(block, /^[ \t]+resolution:[ \t]+"?((?:@[^\s/@"]+\/)?[^\s/@"]+)@npm:/);
|
|
269
272
|
if (resolution)
|
|
270
|
-
return resolution
|
|
273
|
+
return resolution;
|
|
271
274
|
const alias = /^"?(?:@[^\s/@"]+\/)?[^\s/@"]+@npm:((?:@[^\s/@"]+\/)?[^\s/@"]+)@/.exec(firstLine);
|
|
272
275
|
return alias ? alias[1] : headerName;
|
|
273
276
|
}
|
|
277
|
+
/** Lines longer than this in a yarn.lock are not read: no real entry comes near it. */
|
|
278
|
+
export const MAX_LOCKFILE_LINE = 4096;
|
|
279
|
+
/**
|
|
280
|
+
* The first capture of `lineRe` on any line of a yarn.lock block, or null.
|
|
281
|
+
* Line by line with single-line regexes: the multiline `/^\s+version.../m`
|
|
282
|
+
* forms they replace let `\s+` run across lines and took ~5 s on a hostile
|
|
283
|
+
* 64 KB block (audit 2026-10-12, B-10).
|
|
284
|
+
*/
|
|
285
|
+
function yarnBlockField(block, lineRe) {
|
|
286
|
+
for (const line of block.split(/\r?\n/)) {
|
|
287
|
+
if (line.length > MAX_LOCKFILE_LINE)
|
|
288
|
+
continue;
|
|
289
|
+
const m = lineRe.exec(line);
|
|
290
|
+
if (m)
|
|
291
|
+
return m[1];
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
/** The `version` of a yarn.lock block: v1 ` version "1.2.3"`, berry ` version: 1.2.3`. */
|
|
296
|
+
export function yarnBlockVersion(block) {
|
|
297
|
+
return yarnBlockField(block, /^[ \t]+version:?[ \t]+"?([^"\s]+)"?[ \t]*$/);
|
|
298
|
+
}
|
|
274
299
|
/**
|
|
275
300
|
* Reads a yarn lockfile (v1 or berry) into `versions`.
|
|
276
301
|
* v1: `"@scope/pkg@^1.0.0", "@scope/pkg@^1.1.0":` then ` version "1.2.3"`.
|
|
277
302
|
* berry: `"@scope/pkg@npm:^1.0.0":` then ` version: 1.2.3`.
|
|
278
303
|
* Workspace, link, portal, file, exec and patch entries do not name a registry version.
|
|
304
|
+
* Returns how many entries were skipped because a line of theirs is longer
|
|
305
|
+
* than MAX_LOCKFILE_LINE, so the caller can say the read was partial (audit
|
|
306
|
+
* L5: the skip was silent).
|
|
279
307
|
*/
|
|
280
308
|
export function readYarnLock(content, versions, instances, specs) {
|
|
309
|
+
let skipped = 0;
|
|
281
310
|
for (const block of content.split(/\r?\n(?=\S)/)) {
|
|
282
311
|
const firstLine = block.split(/\r?\n/)[0];
|
|
312
|
+
if (firstLine.length > MAX_LOCKFILE_LINE) {
|
|
313
|
+
skipped++;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
283
316
|
const header = /^"?(@?[^@\s"]+)@/.exec(firstLine);
|
|
284
317
|
if (!header || header[1].startsWith("__"))
|
|
285
318
|
continue;
|
|
286
319
|
if (/@(?:workspace|link|portal|file|exec|patch):/.test(firstLine))
|
|
287
320
|
continue;
|
|
288
|
-
const
|
|
289
|
-
const version =
|
|
290
|
-
if (!version)
|
|
321
|
+
const raw = yarnBlockVersion(block);
|
|
322
|
+
const version = raw ? exactVersion(raw) : null;
|
|
323
|
+
if (!version) {
|
|
324
|
+
if (block.split(/\r?\n/).some((line) => line.length > MAX_LOCKFILE_LINE))
|
|
325
|
+
skipped++;
|
|
291
326
|
continue;
|
|
327
|
+
}
|
|
292
328
|
const name = yarnPackageName(block, firstLine, header[1]);
|
|
293
329
|
// One block per resolved version. Blocks are sorted by spec, so the first
|
|
294
330
|
// block of a name can be an old nested copy; `specs` lets the caller pick
|
|
@@ -302,4 +338,5 @@ export function readYarnLock(content, versions, instances, specs) {
|
|
|
302
338
|
}
|
|
303
339
|
}
|
|
304
340
|
}
|
|
341
|
+
return skipped;
|
|
305
342
|
}
|
|
@@ -17,6 +17,19 @@ export interface ParentEdge {
|
|
|
17
17
|
range: string | null;
|
|
18
18
|
/** True when the parent is the project itself or a workspace member (then the package is direct there). */
|
|
19
19
|
local: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* True when the copy ships inside `parent`'s tarball (package-lock `inBundle`): `range` is then the
|
|
22
|
+
* exact version shipped. No refresh moves it and no override reaches it (both checked with npm
|
|
23
|
+
* 10.9.2, 2026-10-15); only a new `parent` release, or replacing `parent`, does (audit E8).
|
|
24
|
+
*/
|
|
25
|
+
bundled?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* For a local edge: the declaring manifest's directory relative to the lockfile's ("." for the
|
|
28
|
+
* project itself, "packages/a" for a workspace member), when the lockfile names it.
|
|
29
|
+
*/
|
|
30
|
+
dir?: string;
|
|
31
|
+
/** For a local edge: the manifest section that declares it. */
|
|
32
|
+
section?: "dependencies" | "devDependencies" | "optionalDependencies" | "peerDependencies";
|
|
20
33
|
}
|
|
21
34
|
export interface LockfileParents {
|
|
22
35
|
format: LockfileFormat;
|
|
@@ -30,10 +43,12 @@ interface LockEntry {
|
|
|
30
43
|
name?: string;
|
|
31
44
|
version?: string;
|
|
32
45
|
link?: boolean;
|
|
46
|
+
inBundle?: boolean;
|
|
33
47
|
requires?: Record<string, string>;
|
|
34
48
|
dependencies?: Record<string, string> | Record<string, LockEntry>;
|
|
35
49
|
optionalDependencies?: Record<string, string>;
|
|
36
50
|
peerDependencies?: Record<string, string>;
|
|
51
|
+
devDependencies?: Record<string, string>;
|
|
37
52
|
}
|
|
38
53
|
export declare function packageLockParents(lock: {
|
|
39
54
|
packages?: Record<string, LockEntry>;
|
|
@@ -11,9 +11,16 @@
|
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { stripTrailingCommas } from "./bun-lockfile.js";
|
|
14
|
-
import { exactVersion, parsePnpmKey, pnpmEntryIdentities, pnpmKeyRef, yarnPackageName } from "./js-lockfile-readers.js";
|
|
14
|
+
import { exactVersion, MAX_LOCKFILE_LINE, parsePnpmKey, pnpmEntryIdentities, pnpmKeyRef, yarnBlockVersion, yarnPackageName } from "./js-lockfile-readers.js";
|
|
15
15
|
import { packageNameFromNodeModulesPath, parseCargoLockPackages } from "./lockfile-parsers.js";
|
|
16
16
|
const DEP_FIELDS = ["dependencies", "optionalDependencies", "peerDependencies"];
|
|
17
|
+
/**
|
|
18
|
+
* The project's own manifest and each workspace member's also declare devDependencies, which a
|
|
19
|
+
* dependency's lockfile entry never carries. Leaving them out read a dev-only transitive's lever
|
|
20
|
+
* as "no declared dependency above" (navcal: @vitest/mocker and tinypool arrive through the
|
|
21
|
+
* devDependency vitest) and a member's devDependency as undeclared (final release audit, finding 3).
|
|
22
|
+
*/
|
|
23
|
+
const LOCAL_DEP_FIELDS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
|
|
17
24
|
/** The lockfile format of a path, or null for a file this module does not read. */
|
|
18
25
|
export function lockfileFormat(file, content) {
|
|
19
26
|
const base = path.basename(file);
|
|
@@ -73,27 +80,72 @@ function resolveInstallPath(packages, from, name) {
|
|
|
73
80
|
dir = cut >= 0 ? dir.slice(0, cut) : "";
|
|
74
81
|
}
|
|
75
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* The package a bundled copy at install path `at` ships inside: the nearest enclosing entry that is
|
|
85
|
+
* not itself `inBundle`. A level the lockfile does not list is passed over. Null when none is found.
|
|
86
|
+
*/
|
|
87
|
+
function bundleOwner(packages, at) {
|
|
88
|
+
let dir = at;
|
|
89
|
+
for (;;) {
|
|
90
|
+
const cut = dir.lastIndexOf("/node_modules/");
|
|
91
|
+
if (cut < 0)
|
|
92
|
+
return null;
|
|
93
|
+
dir = dir.slice(0, cut);
|
|
94
|
+
const entry = packages[dir];
|
|
95
|
+
if (entry && !entry.inBundle)
|
|
96
|
+
return dir;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** A key that installs a DEPENDENCY (under some `node_modules/`), not the root or a workspace member's folder. */
|
|
100
|
+
function isDependencyKey(key) {
|
|
101
|
+
return key.startsWith("node_modules/") || key.includes("/node_modules/");
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Whether the entry at `key` is a copy bundled inside a DEPENDENCY's tarball. A project's or workspace
|
|
105
|
+
* member's own `bundleDependencies` also set `inBundle` (Arborist's bundler can be the root), but those
|
|
106
|
+
* copies sit in the user's tree, where `npm update` and overrides reach them like any other.
|
|
107
|
+
*/
|
|
108
|
+
function bundledInDependency(packages, key) {
|
|
109
|
+
if (!packages[key]?.inBundle)
|
|
110
|
+
return false;
|
|
111
|
+
const owner = bundleOwner(packages, key);
|
|
112
|
+
return owner !== null && isDependencyKey(owner);
|
|
113
|
+
}
|
|
76
114
|
export function packageLockParents(lock, name, version) {
|
|
77
115
|
const edges = [];
|
|
78
116
|
if (lock.packages && Object.keys(lock.packages).length > 0) {
|
|
79
117
|
const packages = lock.packages;
|
|
118
|
+
// A bundled copy hangs off its bundle owner, pinned to the version shipped: the ranges the
|
|
119
|
+
// bundle's own packages declare say nothing about what a refresh can reach (audit E8:
|
|
120
|
+
// NodeGoat's debug 4.1.1 inside fsevents read "npm update debug" from needle's ^4.1.0).
|
|
121
|
+
const owners = new Set();
|
|
122
|
+
for (const [key, entry] of Object.entries(packages)) {
|
|
123
|
+
if (entry?.version !== version || packageNameFromNodeModulesPath(key) !== name || !bundledInDependency(packages, key))
|
|
124
|
+
continue;
|
|
125
|
+
const owner = bundleOwner(packages, key);
|
|
126
|
+
if (!owner || owners.has(owner))
|
|
127
|
+
continue;
|
|
128
|
+
owners.add(owner);
|
|
129
|
+
edges.push({ parent: packageNameFromNodeModulesPath(owner), parentVersion: packages[owner]?.version ?? null, range: version, local: false, bundled: true });
|
|
130
|
+
}
|
|
80
131
|
for (const [key, entry] of Object.entries(packages)) {
|
|
81
132
|
if (!entry || entry.link)
|
|
82
133
|
continue;
|
|
83
|
-
|
|
134
|
+
const local = !key.startsWith("node_modules/") && !key.includes("/node_modules/");
|
|
135
|
+
for (const field of local ? LOCAL_DEP_FIELDS : DEP_FIELDS) {
|
|
84
136
|
const declared = entry[field];
|
|
85
137
|
const range = declared?.[name];
|
|
86
138
|
if (typeof range !== "string")
|
|
87
139
|
continue;
|
|
88
140
|
const at = resolveInstallPath(packages, key, name);
|
|
89
|
-
if (!at || packages[at]?.version !== version)
|
|
141
|
+
if (!at || packages[at]?.version !== version || bundledInDependency(packages, at))
|
|
90
142
|
continue;
|
|
91
|
-
const local = !key.startsWith("node_modules/") && !key.includes("/node_modules/");
|
|
92
143
|
edges.push({
|
|
93
144
|
parent: local ? entry.name ?? (key || lock.name || ".") : packageNameFromNodeModulesPath(key),
|
|
94
145
|
parentVersion: local ? null : entry.version ?? null,
|
|
95
146
|
range,
|
|
96
147
|
local,
|
|
148
|
+
...(local ? { dir: key === "" ? "." : key, section: field } : {}),
|
|
97
149
|
});
|
|
98
150
|
break;
|
|
99
151
|
}
|
|
@@ -215,7 +267,7 @@ export function pnpmParents(content, name, version) {
|
|
|
215
267
|
importerSpecifier = null;
|
|
216
268
|
if (pendingDep && m?.[4]) {
|
|
217
269
|
if (isTarget(pendingDep, m[4]))
|
|
218
|
-
add({ parent: importer, parentVersion: null, range: null, local: true });
|
|
270
|
+
add({ parent: importer, parentVersion: null, range: null, local: true, dir: importer });
|
|
219
271
|
pendingDep = null;
|
|
220
272
|
}
|
|
221
273
|
}
|
|
@@ -226,7 +278,7 @@ export function pnpmParents(content, name, version) {
|
|
|
226
278
|
const ver = /^version:\s*(.+)$/.exec(text);
|
|
227
279
|
if (ver) {
|
|
228
280
|
if (isTarget(pendingDep, ver[1]))
|
|
229
|
-
add({ parent: importer, parentVersion: null, range: importerSpecifier, local: true });
|
|
281
|
+
add({ parent: importer, parentVersion: null, range: importerSpecifier, local: true, dir: importer });
|
|
230
282
|
pendingDep = null;
|
|
231
283
|
}
|
|
232
284
|
}
|
|
@@ -237,17 +289,40 @@ export function pnpmParents(content, name, version) {
|
|
|
237
289
|
// yarn.lock (v1 and berry)
|
|
238
290
|
// ---------------------------------------------------------------------------
|
|
239
291
|
const unquote = (s) => s.trim().replace(/^"|"$/g, "");
|
|
292
|
+
/**
|
|
293
|
+
* A yarn dependency line: v1 `name "range"`, berry `name: "npm:range"` or
|
|
294
|
+
* `"@scope/x": range`. The name by regex, the range by hand: the single regex
|
|
295
|
+
* this replaces had a lazy group before `"?\s*$` and was quadratic, ~5 s on a
|
|
296
|
+
* hostile 64 KB line (audit 2026-10-12, B-10). Same results otherwise.
|
|
297
|
+
*/
|
|
298
|
+
function yarnDepLine(text) {
|
|
299
|
+
if (text.length > MAX_LOCKFILE_LINE)
|
|
300
|
+
return null;
|
|
301
|
+
const head = /^"?((?:@[^\s"/]+\/)?[^\s":]+)"?:?\s+/.exec(text);
|
|
302
|
+
if (!head)
|
|
303
|
+
return null;
|
|
304
|
+
let range = text.slice(head[0].length).trimEnd();
|
|
305
|
+
if (range.startsWith('"'))
|
|
306
|
+
range = range.slice(1);
|
|
307
|
+
if (range.endsWith('"'))
|
|
308
|
+
range = range.slice(0, -1);
|
|
309
|
+
if (range === "" || range.includes('"'))
|
|
310
|
+
return null;
|
|
311
|
+
return [head[1], range];
|
|
312
|
+
}
|
|
240
313
|
function parseYarnBlocks(content) {
|
|
241
314
|
const blocks = [];
|
|
242
315
|
for (const block of content.split(/\r?\n(?=\S)/)) {
|
|
243
316
|
const lines = block.split(/\r?\n/);
|
|
244
317
|
const header = lines[0];
|
|
318
|
+
if (header.length > MAX_LOCKFILE_LINE)
|
|
319
|
+
continue;
|
|
245
320
|
const headerName = /^"?(@?[^@\s"]+)@/.exec(header)?.[1];
|
|
246
321
|
if (!headerName || headerName.startsWith("__"))
|
|
247
322
|
continue;
|
|
248
323
|
// An npm alias block (`string-width-cjs@npm:string-width@^4.2.0`) installs the real package.
|
|
249
324
|
const name = yarnPackageName(block, header, headerName);
|
|
250
|
-
const version =
|
|
325
|
+
const version = yarnBlockVersion(block);
|
|
251
326
|
if (!version)
|
|
252
327
|
continue;
|
|
253
328
|
const specs = header.replace(/:\s*$/, "").split(",").map((s) => unquote(s).replace(/@npm:/, "@"));
|
|
@@ -266,9 +341,9 @@ function parseYarnBlocks(content) {
|
|
|
266
341
|
}
|
|
267
342
|
if (inDeps && indent > depIndent) {
|
|
268
343
|
// v1: `name "range"`; berry: `name: "npm:range"` or `"@scope/x": range`.
|
|
269
|
-
const
|
|
270
|
-
if (
|
|
271
|
-
deps.push(
|
|
344
|
+
const dep = yarnDepLine(text);
|
|
345
|
+
if (dep)
|
|
346
|
+
deps.push(dep);
|
|
272
347
|
}
|
|
273
348
|
else {
|
|
274
349
|
inDeps = false;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* pseudo-version, so older advisories were missed and later ones invented.
|
|
15
15
|
* go.sum also lists versions the build never selected.
|
|
16
16
|
*/
|
|
17
|
-
import { InstanceSet, type VersionSource } from "./lockfile-types.js";
|
|
17
|
+
import { InstanceSet, type LockfileProblem, type VersionSource } from "./lockfile-types.js";
|
|
18
18
|
/** PEP 503 normalised name: lowercase, runs of `-`, `_`, `.` become one `-`. */
|
|
19
19
|
export declare function normalizePythonName(name: string): string;
|
|
20
20
|
/**
|
|
@@ -44,11 +44,56 @@ export declare function resolvePython(cwd: string): VersionSource;
|
|
|
44
44
|
* includes (relative to the file) and applying `-c`/`--constraint` files:
|
|
45
45
|
* a constraints file installs nothing by itself, so its pins only give a
|
|
46
46
|
* version to a name the requirements list without one. Null when unreadable.
|
|
47
|
+
*
|
|
48
|
+
* Includes are confined to `root`, the project directory (default: the
|
|
49
|
+
* file's own directory). A cloned repository's `-r ../outside/secrets.txt`,
|
|
50
|
+
* an absolute path, or a UNC path (on Windows, opening `\\host\share\x.txt`
|
|
51
|
+
* makes an SMB connection that sends the user's NTLM hash) used to be read,
|
|
52
|
+
* and every `NAME==VALUE` line in it went to OSV.dev and back to the agent.
|
|
53
|
+
* Such an include is skipped; absolute and UNC ones before any fs call.
|
|
54
|
+
* Skipped is not silent: each one goes to `problems` (audit M4), which the
|
|
55
|
+
* scan reports as a file whose pins were not checked.
|
|
47
56
|
*/
|
|
48
|
-
export declare function readRequirementsFile(file: string, visited?: Set<string
|
|
57
|
+
export declare function readRequirementsFile(file: string, visited?: Set<string>, root?: string, problems?: LockfileProblem[]): Array<{
|
|
49
58
|
name: string;
|
|
50
59
|
version: string;
|
|
51
60
|
}> | null;
|
|
61
|
+
/**
|
|
62
|
+
* The include's absolute path when `target` is a relative path that stays
|
|
63
|
+
* inside `root` (also after resolving symlinks, when it exists); otherwise
|
|
64
|
+
* null. Absolute, drive-letter and UNC targets are refused before any
|
|
65
|
+
* filesystem call.
|
|
66
|
+
*/
|
|
67
|
+
export declare function confinedInclude(dir: string, target: string, root: string): string | null;
|
|
68
|
+
/** `-r file` / `--requirement=file` / `-c file` / `--constraint file` lines (local paths only). */
|
|
69
|
+
export declare function requirementOptions(content: string): Array<{
|
|
70
|
+
option: "r" | "c";
|
|
71
|
+
target: string;
|
|
72
|
+
}>;
|
|
73
|
+
/**
|
|
74
|
+
* A requirements file a resolver wrote: `uv pip compile` or `pip-compile` output. Its source of
|
|
75
|
+
* truth is the input it was compiled from (requirements.in, pyproject.toml), and a hand edit of a
|
|
76
|
+
* pin is reverted by the next compile. Each pin's `# via` annotation says why it is there: an
|
|
77
|
+
* input (`-r requirements.in`, `name (pyproject.toml)`) or only the packages that require it.
|
|
78
|
+
* The server read `# via requests` pins as declared and said "change `idna==2.7` to `idna==3.15`
|
|
79
|
+
* in requirements.txt", which requests 2.19.1 (`idna<2.8`) then refused (audit 6.2.5 A-4).
|
|
80
|
+
*/
|
|
81
|
+
export interface CompiledRequirements {
|
|
82
|
+
/** The tool its header names; null when only `# via` annotations show it (a `--no-header` compile). */
|
|
83
|
+
tool: "uv" | "pip-compile" | null;
|
|
84
|
+
/** The single input file the header's command (else the `# via` annotations) names, as written there; null when none or several. */
|
|
85
|
+
input: string | null;
|
|
86
|
+
/** PEP 503 name -> its `# via` entries as written (an entry per required-by package or input). */
|
|
87
|
+
via: Map<string, string[]>;
|
|
88
|
+
}
|
|
89
|
+
export declare function compiledRequirements(content: string): CompiledRequirements | null;
|
|
90
|
+
/**
|
|
91
|
+
* Indirect: compiled in only because other packages require it (every `# via` entry is a package).
|
|
92
|
+
* False for a pin an input names, and for one with no annotation (its reason is unknown).
|
|
93
|
+
*/
|
|
94
|
+
export declare function compiledIndirect(compiled: CompiledRequirements, name: string): boolean;
|
|
95
|
+
/** The packages a compiled pin is `# via` (those that require it), PEP 503 normalised. */
|
|
96
|
+
export declare function compiledRequiredBy(compiled: CompiledRequirements, name: string): string[];
|
|
52
97
|
/**
|
|
53
98
|
* [[package]] tables from poetry.lock (any version) or uv.lock. A pre-1.2
|
|
54
99
|
* poetry.lock records `category = "dev"` for dev-only packages; newer files
|
|
@@ -53,7 +53,10 @@ export function requirementFiles(cwd) {
|
|
|
53
53
|
catch {
|
|
54
54
|
// no requirements/ folder
|
|
55
55
|
}
|
|
56
|
-
|
|
56
|
+
// A symlinked requirements-dev.txt or a junctioned requirements/ folder can point anywhere; only
|
|
57
|
+
// `-r` includes were confined, so pins from outside the project reached OSV and the output
|
|
58
|
+
// (audit 6.2.5 C-3). Every file read must resolve inside the project.
|
|
59
|
+
return out.filter((file) => confinedInclude(cwd, path.relative(cwd, file), cwd) !== null);
|
|
57
60
|
}
|
|
58
61
|
/** Above this a .txt is not read to decide whether it is a requirements file. */
|
|
59
62
|
const MAX_SNIFF_BYTES = 256 * 1024;
|
|
@@ -139,26 +142,39 @@ export function resolvePython(cwd) {
|
|
|
139
142
|
}
|
|
140
143
|
catch { /* unreadable */ }
|
|
141
144
|
}
|
|
145
|
+
const collected = [];
|
|
142
146
|
for (const file of requirementFiles(cwd)) {
|
|
143
147
|
const dev = devRequirementsFile(file) ? true : undefined;
|
|
144
|
-
const pins = readRequirementsFile(file);
|
|
148
|
+
const pins = readRequirementsFile(file, new Set(), cwd, collected);
|
|
145
149
|
if (pins === null)
|
|
146
150
|
continue;
|
|
147
151
|
for (const { name, version } of pins)
|
|
148
152
|
take(name, version, dev);
|
|
149
153
|
sources.push(file);
|
|
150
154
|
}
|
|
155
|
+
// One problem per file, however many requirement files name it.
|
|
156
|
+
const problems = [...new Map(collected.map((p) => [p.file.toLowerCase(), p])).values()];
|
|
151
157
|
if (sources.length === 0)
|
|
152
|
-
return emptySource();
|
|
153
|
-
|
|
158
|
+
return problems.length > 0 ? { ...emptySource(), problems } : emptySource();
|
|
159
|
+
const read = found(versions, instances, sources[0], lockfile ? "lockfile" : "manifest", { extraSources: sources.slice(1) });
|
|
160
|
+
return problems.length > 0 ? { ...read, problems } : read;
|
|
154
161
|
}
|
|
155
162
|
/**
|
|
156
163
|
* Exact pins from one requirements file, following `-r`/`--requirement`
|
|
157
164
|
* includes (relative to the file) and applying `-c`/`--constraint` files:
|
|
158
165
|
* a constraints file installs nothing by itself, so its pins only give a
|
|
159
166
|
* version to a name the requirements list without one. Null when unreadable.
|
|
167
|
+
*
|
|
168
|
+
* Includes are confined to `root`, the project directory (default: the
|
|
169
|
+
* file's own directory). A cloned repository's `-r ../outside/secrets.txt`,
|
|
170
|
+
* an absolute path, or a UNC path (on Windows, opening `\\host\share\x.txt`
|
|
171
|
+
* makes an SMB connection that sends the user's NTLM hash) used to be read,
|
|
172
|
+
* and every `NAME==VALUE` line in it went to OSV.dev and back to the agent.
|
|
173
|
+
* Such an include is skipped; absolute and UNC ones before any fs call.
|
|
174
|
+
* Skipped is not silent: each one goes to `problems` (audit M4), which the
|
|
175
|
+
* scan reports as a file whose pins were not checked.
|
|
160
176
|
*/
|
|
161
|
-
export function readRequirementsFile(file, visited = new Set()) {
|
|
177
|
+
export function readRequirementsFile(file, visited = new Set(), root = path.dirname(path.resolve(file)), problems = []) {
|
|
162
178
|
const key = path.resolve(file).toLowerCase();
|
|
163
179
|
if (visited.has(key))
|
|
164
180
|
return [];
|
|
@@ -172,11 +188,23 @@ export function readRequirementsFile(file, visited = new Set()) {
|
|
|
172
188
|
}
|
|
173
189
|
const dir = path.dirname(file);
|
|
174
190
|
const out = parseRequirements(content);
|
|
191
|
+
const overlong = overlongRequirementLines(content);
|
|
192
|
+
if (overlong > 0) {
|
|
193
|
+
problems.push({
|
|
194
|
+
file: path.resolve(file),
|
|
195
|
+
skipped: overlong,
|
|
196
|
+
error: `${overlong === 1 ? "1 line" : `${overlong} lines`} longer than ${MAX_REQUIREMENT_LINE} characters (not requirement syntax) ${overlong === 1 ? "was" : "were"} skipped, so any pin on ${overlong === 1 ? "it was" : "them was"} not checked`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
175
199
|
const constraints = new Map();
|
|
176
200
|
for (const { option, target } of requirementOptions(content)) {
|
|
177
|
-
const included =
|
|
201
|
+
const included = confinedInclude(dir, target, root);
|
|
202
|
+
if (included === null) {
|
|
203
|
+
problems.push(refusedInclude(file, option, target));
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
178
206
|
if (option === "r")
|
|
179
|
-
out.push(...(readRequirementsFile(included, visited) ?? []));
|
|
207
|
+
out.push(...(readRequirementsFile(included, visited, root, problems) ?? []));
|
|
180
208
|
else
|
|
181
209
|
for (const pin of parseRequirements(safeRead(included)))
|
|
182
210
|
constraints.set(pin.name, pin.version);
|
|
@@ -191,6 +219,84 @@ export function readRequirementsFile(file, visited = new Set()) {
|
|
|
191
219
|
}
|
|
192
220
|
return out;
|
|
193
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* A refused include as a coverage problem. `file` is the include as it
|
|
224
|
+
* resolves (an absolute, drive-letter or UNC target as written: it is never
|
|
225
|
+
* resolved or touched), so the note names the file whose pins went unchecked.
|
|
226
|
+
*/
|
|
227
|
+
function refusedInclude(from, option, target) {
|
|
228
|
+
const absolute = /^[\\/]/.test(target) || /^[A-Za-z]:/.test(target) || path.isAbsolute(target);
|
|
229
|
+
return {
|
|
230
|
+
file: absolute ? target : path.resolve(path.dirname(from), target),
|
|
231
|
+
refused: true,
|
|
232
|
+
error: `\`-${option} ${target}\` in ${path.basename(from)} points outside the project directory, and includes outside it are never read, so the ${option === "r" ? "pins" : "constraints"} in it were not checked`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The include's absolute path when `target` is a relative path that stays
|
|
237
|
+
* inside `root` (also after resolving symlinks, when it exists); otherwise
|
|
238
|
+
* null. Absolute, drive-letter and UNC targets are refused before any
|
|
239
|
+
* filesystem call.
|
|
240
|
+
*/
|
|
241
|
+
export function confinedInclude(dir, target, root) {
|
|
242
|
+
if (/^[\\/]/.test(target) || /^[A-Za-z]:/.test(target) || path.isAbsolute(target))
|
|
243
|
+
return null;
|
|
244
|
+
const within = (base, p) => {
|
|
245
|
+
const rel = path.relative(base, p);
|
|
246
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
247
|
+
};
|
|
248
|
+
const rootDir = path.resolve(root);
|
|
249
|
+
const included = path.resolve(dir, target);
|
|
250
|
+
if (!within(rootDir, included))
|
|
251
|
+
return null;
|
|
252
|
+
let real;
|
|
253
|
+
try {
|
|
254
|
+
real = fs.realpathSync(included);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return included; // missing: nothing to escape through, and the read fails quietly
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
return within(fs.realpathSync(rootDir), real) ? included : null;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** Longer lines are not requirements; capping them keeps the regexes below linear in the file size. */
|
|
267
|
+
const MAX_REQUIREMENT_LINE = 4096;
|
|
268
|
+
/**
|
|
269
|
+
* Logical lines (backslash continuations joined, `--hash` options dropped),
|
|
270
|
+
* with over-long lines left out. The cap used to apply with the hashes in:
|
|
271
|
+
* a `pip-compile --generate-hashes` entry with many wheels (aiohttp 3.8.0:
|
|
272
|
+
* 72 files, 5.7 KB) was dropped silently and never scanned. A hash is not
|
|
273
|
+
* part of the pin, so it goes before the cap; the split is on whitespace,
|
|
274
|
+
* which stays linear on a hostile line.
|
|
275
|
+
*/
|
|
276
|
+
function requirementLines(content) {
|
|
277
|
+
return logicalRequirementLines(content).filter((line) => line.length <= MAX_REQUIREMENT_LINE);
|
|
278
|
+
}
|
|
279
|
+
function logicalRequirementLines(content) {
|
|
280
|
+
return content
|
|
281
|
+
.replace(/\\\r?\n/g, " ")
|
|
282
|
+
.split(/\r?\n/)
|
|
283
|
+
.map((line) => (line.includes("--hash") ? withoutHashes(line) : line));
|
|
284
|
+
}
|
|
285
|
+
function withoutHashes(line) {
|
|
286
|
+
const tokens = line.split(/[ \t]+/);
|
|
287
|
+
const kept = [];
|
|
288
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
289
|
+
if (tokens[i] === "--hash")
|
|
290
|
+
i++; // `--hash sha256:...`: the value is the next token
|
|
291
|
+
else if (!tokens[i].startsWith("--hash=") && !tokens[i].startsWith("--hash:"))
|
|
292
|
+
kept.push(tokens[i]);
|
|
293
|
+
}
|
|
294
|
+
return kept.join(" ");
|
|
295
|
+
}
|
|
296
|
+
/** Lines left out for length that are not comments: what a read missed (audit L5's class). */
|
|
297
|
+
function overlongRequirementLines(content) {
|
|
298
|
+
return logicalRequirementLines(content).filter((line) => line.length > MAX_REQUIREMENT_LINE && !line.trimStart().startsWith("#")).length;
|
|
299
|
+
}
|
|
194
300
|
function safeRead(file) {
|
|
195
301
|
try {
|
|
196
302
|
return fs.readFileSync(file, "utf-8");
|
|
@@ -200,9 +306,9 @@ function safeRead(file) {
|
|
|
200
306
|
}
|
|
201
307
|
}
|
|
202
308
|
/** `-r file` / `--requirement=file` / `-c file` / `--constraint file` lines (local paths only). */
|
|
203
|
-
function requirementOptions(content) {
|
|
309
|
+
export function requirementOptions(content) {
|
|
204
310
|
const out = [];
|
|
205
|
-
for (const raw of content
|
|
311
|
+
for (const raw of requirementLines(content)) {
|
|
206
312
|
const m = /^\s*(?:-(r|c)|--(requirement|constraint))(?:\s*=\s*|\s+)(\S+)/.exec(raw);
|
|
207
313
|
if (!m || /^[a-z][a-z+]*:\/\//i.test(m[3]))
|
|
208
314
|
continue;
|
|
@@ -210,10 +316,120 @@ function requirementOptions(content) {
|
|
|
210
316
|
}
|
|
211
317
|
return out;
|
|
212
318
|
}
|
|
319
|
+
/** Header lines searched for the tool and its command (uv writes 2, pip-compile 5 to 7). */
|
|
320
|
+
const COMPILED_HEADER_LINES = 12;
|
|
321
|
+
export function compiledRequirements(content) {
|
|
322
|
+
const lines = content.split(/\r?\n/);
|
|
323
|
+
const head = lines.slice(0, COMPILED_HEADER_LINES);
|
|
324
|
+
const tool = head.some((l) => /^#.*autogenerated by uv\b/i.test(l))
|
|
325
|
+
? "uv"
|
|
326
|
+
: head.some((l) => /^#.*autogenerated by pip-compile\b/i.test(l))
|
|
327
|
+
? "pip-compile"
|
|
328
|
+
: null;
|
|
329
|
+
const via = viaAnnotations(lines);
|
|
330
|
+
if (tool === null && via.size === 0)
|
|
331
|
+
return null;
|
|
332
|
+
return { tool, input: headerInput(head) ?? viaInput(via), via };
|
|
333
|
+
}
|
|
334
|
+
/** Whether a `# via` entry is a package that requires the pin (not an input file or `-c` constraint). */
|
|
335
|
+
function viaIsPackage(entry) {
|
|
336
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\[[^\]]*\])?$/.test(entry);
|
|
337
|
+
}
|
|
338
|
+
/** The input a `# via` entry names: `-r requirements.in`, or `project (pyproject.toml)`. */
|
|
339
|
+
function viaSource(entry) {
|
|
340
|
+
return /^-r\s+(\S+)$/.exec(entry)?.[1] ?? /^\S+\s+\(([^()\s]+)\)$/.exec(entry)?.[1] ?? null;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Indirect: compiled in only because other packages require it (every `# via` entry is a package).
|
|
344
|
+
* False for a pin an input names, and for one with no annotation (its reason is unknown).
|
|
345
|
+
*/
|
|
346
|
+
export function compiledIndirect(compiled, name) {
|
|
347
|
+
const entries = (compiled.via.get(normalizePythonName(name)) ?? []).filter((e) => !/^-c\s/.test(e));
|
|
348
|
+
return entries.length > 0 && entries.every(viaIsPackage);
|
|
349
|
+
}
|
|
350
|
+
/** The packages a compiled pin is `# via` (those that require it), PEP 503 normalised. */
|
|
351
|
+
export function compiledRequiredBy(compiled, name) {
|
|
352
|
+
return (compiled.via.get(normalizePythonName(name)) ?? []).filter(viaIsPackage).map((e) => normalizePythonName(e.replace(/\[.*$/, "")));
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* `# via` annotations per pin, in both styles the tools write: split (pip-tools 6+, uv)
|
|
356
|
+
* idna==2.7
|
|
357
|
+
* # via requests or # via
|
|
358
|
+
* # requests
|
|
359
|
+
* # -r requirements.in
|
|
360
|
+
* and pip-tools 5's inline `idna==2.7 # via requests, urllib3`.
|
|
361
|
+
*/
|
|
362
|
+
function viaAnnotations(lines) {
|
|
363
|
+
const out = new Map();
|
|
364
|
+
let current = null;
|
|
365
|
+
let listing = false;
|
|
366
|
+
const add = (entry) => {
|
|
367
|
+
const e = entry.trim();
|
|
368
|
+
if (current && e)
|
|
369
|
+
out.set(current, [...(out.get(current) ?? []), e]);
|
|
370
|
+
};
|
|
371
|
+
for (const raw of lines) {
|
|
372
|
+
if (raw.length > MAX_REQUIREMENT_LINE)
|
|
373
|
+
continue;
|
|
374
|
+
const line = raw.trim();
|
|
375
|
+
if (!line.startsWith("#")) {
|
|
376
|
+
listing = false;
|
|
377
|
+
if (line.startsWith("--hash") || line === "" || line.startsWith("-"))
|
|
378
|
+
continue; // a hash continuation or an option keeps the pin
|
|
379
|
+
const pin = /^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*===?/.exec(line);
|
|
380
|
+
current = pin ? normalizePythonName(pin[1]) : null;
|
|
381
|
+
const inline = /\s#\s*via\s+(.+)$/.exec(line);
|
|
382
|
+
if (current && inline)
|
|
383
|
+
for (const e of inline[1].split(","))
|
|
384
|
+
add(e);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const via = /^#\s*via\b\s*(.*)$/.exec(line);
|
|
388
|
+
if (via) {
|
|
389
|
+
listing = via[1].trim() === "";
|
|
390
|
+
if (!listing)
|
|
391
|
+
add(via[1]);
|
|
392
|
+
}
|
|
393
|
+
else if (listing && /^#\s{2,}\S/.test(line)) {
|
|
394
|
+
add(line.replace(/^#\s+/, ""));
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
listing = false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
/** Input-file names a compile command takes: requirements.in / *.txt, pyproject.toml, setup.cfg, setup.py. */
|
|
403
|
+
const COMPILE_INPUT = /(?:\.in|\.txt|(?:^|\/)pyproject\.toml|(?:^|\/)setup\.cfg|(?:^|\/)setup\.py)$/i;
|
|
404
|
+
/** Options whose value is a file but not the input (output, constraints, overrides). */
|
|
405
|
+
const COMPILE_FILE_OPTIONS = new Set(["-o", "--output-file", "-c", "--constraint", "--constraints", "--override", "--overrides", "-b", "--build-constraint", "--build-constraints"]);
|
|
406
|
+
/** The one input file the header's command line names (uv: after "via the following command:"; pip-compile: "by the following command:" / "To update, run:"). */
|
|
407
|
+
function headerInput(head) {
|
|
408
|
+
const at = head.findIndex((l) => /following command:|to update, run:/i.test(l));
|
|
409
|
+
if (at < 0)
|
|
410
|
+
return null;
|
|
411
|
+
const command = head.slice(at + 1).map((l) => l.replace(/^#\s*/, "").trim()).find((l) => l.length > 0);
|
|
412
|
+
if (!command)
|
|
413
|
+
return null;
|
|
414
|
+
const tokens = command.split(/\s+/);
|
|
415
|
+
const inputs = tokens.filter((t, i) => !t.startsWith("-") && COMPILE_INPUT.test(t) && !COMPILE_FILE_OPTIONS.has(tokens[i - 1] ?? ""));
|
|
416
|
+
return inputs.length === 1 ? inputs[0] : null;
|
|
417
|
+
}
|
|
418
|
+
/** The one input the `# via` annotations name (`-r requirements.in`, `(pyproject.toml)`), when they name exactly one. */
|
|
419
|
+
function viaInput(via) {
|
|
420
|
+
const sources = new Set();
|
|
421
|
+
for (const entries of via.values())
|
|
422
|
+
for (const e of entries) {
|
|
423
|
+
const s = viaSource(e);
|
|
424
|
+
if (s)
|
|
425
|
+
sources.add(s);
|
|
426
|
+
}
|
|
427
|
+
return sources.size === 1 ? [...sources][0] : null;
|
|
428
|
+
}
|
|
213
429
|
/** Names a requirements file lists with no exact pin ("flask", "flask>=2"), PEP 503 normalised. */
|
|
214
430
|
function unpinnedRequirementNames(content) {
|
|
215
431
|
const out = [];
|
|
216
|
-
for (const raw of content
|
|
432
|
+
for (const raw of requirementLines(content)) {
|
|
217
433
|
const line = raw.replace(/(^|\s)#.*$/, "").trim();
|
|
218
434
|
if (!line || line.startsWith("-"))
|
|
219
435
|
continue;
|
|
@@ -278,8 +494,10 @@ export function readTomlPackageLock(content) {
|
|
|
278
494
|
*/
|
|
279
495
|
export function parseRequirements(content) {
|
|
280
496
|
const out = [];
|
|
281
|
-
// Backslash continuations join physical lines into one requirement
|
|
282
|
-
|
|
497
|
+
// Backslash continuations join physical lines into one requirement; a line
|
|
498
|
+
// over 4 KB is no requirement and is dropped before any regex sees it (a
|
|
499
|
+
// 64 KB line took ~5 s in the pin regex, audit 2026-10-12 B-10).
|
|
500
|
+
const logical = requirementLines(content);
|
|
283
501
|
for (const raw of logical) {
|
|
284
502
|
// A `#` starts a comment at line start or after whitespace (PEP 508 URLs may contain `#`).
|
|
285
503
|
const line = raw.replace(/(^|\s)#.*$/, "").trim();
|