@4da/mcp-server 6.2.3 → 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.
Files changed (123) hide show
  1. package/CHANGELOG.md +520 -0
  2. package/README.md +22 -6
  3. package/dist/db.d.ts +38 -0
  4. package/dist/db.js +78 -23
  5. package/dist/doctor.js +6 -3
  6. package/dist/embeddings.d.ts +1 -1
  7. package/dist/embeddings.js +16 -14
  8. package/dist/index.js +73 -103
  9. package/dist/live/advisory-groups.d.ts +68 -0
  10. package/dist/live/advisory-groups.js +131 -0
  11. package/dist/live/cache.js +11 -3
  12. package/dist/live/cargo-platform.d.ts +6 -0
  13. package/dist/live/cargo-platform.js +8 -2
  14. package/dist/live/changelog.js +38 -6
  15. package/dist/live/dependency-set.d.ts +9 -1
  16. package/dist/live/dependency-set.js +10 -2
  17. package/dist/live/fix-paths.d.ts +289 -16
  18. package/dist/live/fix-paths.js +876 -93
  19. package/dist/live/github-releases.js +3 -0
  20. package/dist/live/held-dependencies.d.ts +93 -0
  21. package/dist/live/held-dependencies.js +212 -0
  22. package/dist/live/held-targets.d.ts +30 -0
  23. package/dist/live/held-targets.js +107 -0
  24. package/dist/live/http-utils.js +13 -3
  25. package/dist/live/index.d.ts +7 -4
  26. package/dist/live/index.js +24 -19
  27. package/dist/live/install-state.js +6 -2
  28. package/dist/live/js-lockfile-readers.d.ts +8 -1
  29. package/dist/live/js-lockfile-readers.js +43 -6
  30. package/dist/live/lockfile-parents.d.ts +15 -0
  31. package/dist/live/lockfile-parents.js +85 -10
  32. package/dist/live/lockfile-parsers-pygo.d.ts +47 -2
  33. package/dist/live/lockfile-parsers-pygo.js +230 -12
  34. package/dist/live/lockfile-parsers-rbphp.d.ts +38 -0
  35. package/dist/live/lockfile-parsers-rbphp.js +123 -0
  36. package/dist/live/lockfile-parsers.d.ts +6 -0
  37. package/dist/live/lockfile-parsers.js +116 -4
  38. package/dist/live/lockfile-types.d.ts +28 -1
  39. package/dist/live/maintenance.d.ts +20 -4
  40. package/dist/live/maintenance.js +31 -2
  41. package/dist/live/malware.d.ts +20 -0
  42. package/dist/live/malware.js +31 -0
  43. package/dist/live/npm-packument.d.ts +4 -1
  44. package/dist/live/npm-packument.js +5 -2
  45. package/dist/live/offered-release.d.ts +53 -0
  46. package/dist/live/offered-release.js +117 -0
  47. package/dist/live/osv-ranges.d.ts +111 -0
  48. package/dist/live/osv-ranges.js +173 -14
  49. package/dist/live/osv-scanner.d.ts +87 -5
  50. package/dist/live/osv-scanner.js +310 -59
  51. package/dist/live/pinned-advisories.d.ts +2 -2
  52. package/dist/live/pinned-advisories.js +3 -3
  53. package/dist/live/pypi-advice.d.ts +36 -0
  54. package/dist/live/pypi-advice.js +158 -0
  55. package/dist/live/range-satisfies.d.ts +4 -2
  56. package/dist/live/range-satisfies.js +7 -5
  57. package/dist/live/rate-limiter.d.ts +7 -0
  58. package/dist/live/rate-limiter.js +22 -1
  59. package/dist/live/refresh-rule.d.ts +36 -0
  60. package/dist/live/refresh-rule.js +49 -0
  61. package/dist/live/release-metadata.d.ts +5 -0
  62. package/dist/live/resolution.d.ts +3 -0
  63. package/dist/live/resolution.js +6 -3
  64. package/dist/live/scan-coverage.d.ts +26 -0
  65. package/dist/live/scan-coverage.js +70 -0
  66. package/dist/live/semver-utils.d.ts +8 -0
  67. package/dist/live/semver-utils.js +26 -3
  68. package/dist/live/severity-scope.d.ts +26 -1
  69. package/dist/live/severity-scope.js +32 -0
  70. package/dist/live/shell-safe.d.ts +19 -0
  71. package/dist/live/shell-safe.js +36 -0
  72. package/dist/live/types.d.ts +134 -8
  73. package/dist/live/version-compare.d.ts +51 -0
  74. package/dist/live/version-compare.js +152 -1
  75. package/dist/live/withdrawn-releases.d.ts +50 -0
  76. package/dist/live/withdrawn-releases.js +125 -0
  77. package/dist/project-manifests.d.ts +7 -0
  78. package/dist/project-manifests.js +45 -5
  79. package/dist/project-scanner.js +6 -4
  80. package/dist/project-tree.d.ts +56 -0
  81. package/dist/project-tree.js +174 -5
  82. package/dist/schema-registry.d.ts +47 -4
  83. package/dist/schema-registry.js +76 -9
  84. package/dist/server-instructions.js +4 -2
  85. package/dist/setup.d.ts +8 -0
  86. package/dist/setup.js +21 -2
  87. package/dist/tool-dispatch.js +3 -3
  88. package/dist/tools/agent-memory.d.ts +5 -0
  89. package/dist/tools/agent-memory.js +9 -0
  90. package/dist/tools/briefing-task-scope.js +5 -2
  91. package/dist/tools/decision-memory.js +2 -0
  92. package/dist/tools/dependency-check-held.d.ts +44 -0
  93. package/dist/tools/dependency-check-held.js +98 -0
  94. package/dist/tools/dependency-check-signals.d.ts +19 -2
  95. package/dist/tools/dependency-check-signals.js +84 -13
  96. package/dist/tools/dependency-check-versions.d.ts +37 -4
  97. package/dist/tools/dependency-check-versions.js +48 -13
  98. package/dist/tools/dependency-check.d.ts +11 -1
  99. package/dist/tools/dependency-check.js +81 -29
  100. package/dist/tools/dependency-health.js +209 -45
  101. package/dist/tools/get-context.d.ts +3 -0
  102. package/dist/tools/get-context.js +4 -0
  103. package/dist/tools/project-scope.d.ts +8 -0
  104. package/dist/tools/project-scope.js +21 -0
  105. package/dist/tools/upgrade-impact-features.d.ts +37 -0
  106. package/dist/tools/upgrade-impact-features.js +176 -0
  107. package/dist/tools/upgrade-impact-literals.js +26 -4
  108. package/dist/tools/upgrade-impact-report.d.ts +5 -1
  109. package/dist/tools/upgrade-impact-report.js +26 -5
  110. package/dist/tools/upgrade-impact.js +10 -3
  111. package/dist/tools/upgrade-planner-fixpaths.d.ts +164 -5
  112. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  113. package/dist/tools/upgrade-planner.d.ts +70 -7
  114. package/dist/tools/upgrade-planner.js +237 -61
  115. package/dist/tools/vulnerability-scan-concise.d.ts +47 -2
  116. package/dist/tools/vulnerability-scan-concise.js +37 -3
  117. package/dist/tools/vulnerability-scan-format.d.ts +126 -24
  118. package/dist/tools/vulnerability-scan-format.js +193 -26
  119. package/dist/tools/vulnerability-scan.d.ts +224 -63
  120. package/dist/tools/vulnerability-scan.js +50 -15
  121. package/dist/tools/what-should-i-know.d.ts +6 -1
  122. package/dist/tools/what-should-i-know.js +72 -15
  123. package/package.json +2 -2
@@ -30,7 +30,7 @@ function nameKey(ecosystem, name) {
30
30
  /** Explicitly listed versions for one package of one record. */
31
31
  function listedVersions(vuln, name, ecosystem) {
32
32
  return (vuln.affected ?? [])
33
- .filter((a) => a.package.ecosystem === ecosystem && samePackageName(a.package.name, name, ecosystem))
33
+ .filter((a) => a.package?.ecosystem === ecosystem && samePackageName(a.package.name, name, ecosystem))
34
34
  .flatMap((a) => a.versions ?? []);
35
35
  }
36
36
  export class PinnedRecordsSource {
@@ -40,7 +40,7 @@ export class PinnedRecordsSource {
40
40
  constructor(records) {
41
41
  for (const record of records) {
42
42
  this.byId.set(record.id, record);
43
- const keys = new Set((record.affected ?? []).map((a) => `${a.package.ecosystem}\0${nameKey(a.package.ecosystem, a.package.name)}`));
43
+ const keys = new Set((record.affected ?? []).flatMap((a) => (a.package ? [`${a.package.ecosystem}\0${nameKey(a.package.ecosystem, a.package.name)}`] : [])));
44
44
  for (const key of keys) {
45
45
  const list = this.byPackage.get(key);
46
46
  if (list)
@@ -66,7 +66,7 @@ export class PinnedRecordsSource {
66
66
  .map((vuln) => vuln.id);
67
67
  }
68
68
  async query(deps) {
69
- return deps.map((dep) => (dep.version === null ? [] : this.affecting(dep.ecosystem, dep.name, dep.version)));
69
+ return { ids: deps.map((dep) => (dep.version === null ? [] : this.affecting(dep.ecosystem, dep.name, dep.version))), failure: null };
70
70
  }
71
71
  async details(ids) {
72
72
  const out = new Map();
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How a PyPI pin is moved, worded for how its requirements file is produced and installed
3
+ * (audit 6.2.5 A-4). Two kinds of advice installed nothing when run as written:
4
+ *
5
+ * - A compiled file (`uv pip compile`, `pip-compile`) pins the whole tree. Its `# via requests`
6
+ * pins are transitive, and every pin is regenerated from the input (requirements.in,
7
+ * pyproject.toml) on the next compile. "change `idna==2.7` to `idna==3.15` in requirements.txt"
8
+ * conflicted with requests 2.19.1 (`idna<2.8`), and a hand edit is reverted anyway. The fix is a
9
+ * re-compile with `--upgrade-package "idna==3.15"`, the checked target (a `>=` let the resolver
10
+ * take a newer release nothing checked). uv 0.6.8 and pip-compile 7.6.2 both refuse to resolve
11
+ * when a package that requires idna excludes 3.15 (run 2026-10-26 on Track A's hb-f6b fixture),
12
+ * where a bare `--upgrade-package idna` silently kept 2.7.
13
+ * - A flat file that pins everything with `==`: each edit was presented as safe on its own, but 4
14
+ * of pygoat's 15 do not resolve against the other pins (Django 5.2.17 needs asgiref>=3.8.1,
15
+ * asgiref==3.6.0 is pinned), and neither does the full set. No resolver runs here, so the advice
16
+ * says the edits must be re-resolved together; it never claims they are.
17
+ *
18
+ * A command is spliced only from plain tokens (audit 6.2.5 C-1); otherwise the words name no command.
19
+ */
20
+ /**
21
+ * The advice for `name` at `installed` when a compiled requirements file in `projectDir` pins it;
22
+ * null when no compiled file does (the caller keeps its own advice). Both a declared pin and an
23
+ * indirect one are moved by re-compiling, never by editing the output.
24
+ */
25
+ export declare function pypiCompiledInstruction(projectDir: string, name: string, installed: string, target: string): string | null;
26
+ /**
27
+ * The sentence for an `==` edit of `name` in a flat file that pins every requirement exactly (two
28
+ * or more): its edits are not independently safe and must be resolved together. Null otherwise.
29
+ * The file is the first that pins it, in the order directFix reads them.
30
+ */
31
+ export declare function pypiPinnedTogether(projectDir: string, name: string, installed: string): string | null;
32
+ /**
33
+ * vulnerability_scan's short form, appended to "Upgrade X a → b": a compiled file is re-compiled
34
+ * and a fully pinned one re-resolved, never one `==` edit taken as safe alone (audit 6.2.5 A-4).
35
+ */
36
+ export declare function pypiScanNote(projectDir: string, name: string, installed: string): string | null;
@@ -0,0 +1,158 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * How a PyPI pin is moved, worded for how its requirements file is produced and installed
4
+ * (audit 6.2.5 A-4). Two kinds of advice installed nothing when run as written:
5
+ *
6
+ * - A compiled file (`uv pip compile`, `pip-compile`) pins the whole tree. Its `# via requests`
7
+ * pins are transitive, and every pin is regenerated from the input (requirements.in,
8
+ * pyproject.toml) on the next compile. "change `idna==2.7` to `idna==3.15` in requirements.txt"
9
+ * conflicted with requests 2.19.1 (`idna<2.8`), and a hand edit is reverted anyway. The fix is a
10
+ * re-compile with `--upgrade-package "idna==3.15"`, the checked target (a `>=` let the resolver
11
+ * take a newer release nothing checked). uv 0.6.8 and pip-compile 7.6.2 both refuse to resolve
12
+ * when a package that requires idna excludes 3.15 (run 2026-10-26 on Track A's hb-f6b fixture),
13
+ * where a bare `--upgrade-package idna` silently kept 2.7.
14
+ * - A flat file that pins everything with `==`: each edit was presented as safe on its own, but 4
15
+ * of pygoat's 15 do not resolve against the other pins (Django 5.2.17 needs asgiref>=3.8.1,
16
+ * asgiref==3.6.0 is pinned), and neither does the full set. No resolver runs here, so the advice
17
+ * says the edits must be re-resolved together; it never claims they are.
18
+ *
19
+ * A command is spliced only from plain tokens (audit 6.2.5 C-1); otherwise the words name no command.
20
+ */
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import { compiledIndirect, compiledRequiredBy, compiledRequirements, confinedInclude, normalizePythonName, parseRequirements, requirementFiles, requirementOptions, } from "./lockfile-parsers-pygo.js";
24
+ import { isShellSafeName, isShellSafeVersion } from "./shell-safe.js";
25
+ /** A relative path of plain segments (`requirements.in`, `requirements/dev.txt`): no `..`, no root, no drive, nothing a shell expands. */
26
+ function isPlainPath(file) {
27
+ return /^[A-Za-z0-9_][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_][A-Za-z0-9_.-]*)*$/.test(file) && !file.split("/").includes("..") && file.length <= 256;
28
+ }
29
+ function readText(file) {
30
+ try {
31
+ return fs.readFileSync(file, "utf-8");
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /** Requirement files that pin `name` at `installed`, relative to `projectDir`: the ones the scan reads, then their confined `-r` includes. */
38
+ function filesPinning(projectDir, name, installed) {
39
+ const want = normalizePythonName(name);
40
+ const files = ["requirements.txt", "requirements-dev.txt", "requirements/base.txt"];
41
+ for (const f of requirementFiles(projectDir)) {
42
+ const rel = path.relative(projectDir, f).replace(/\\/g, "/");
43
+ if (!files.includes(rel))
44
+ files.push(rel);
45
+ }
46
+ const out = [];
47
+ for (let i = 0; i < files.length && i < 32; i++) {
48
+ const body = readText(path.join(projectDir, files[i]));
49
+ if (body === null)
50
+ continue;
51
+ if (parseRequirements(body).some((p) => p.name === want && p.version === installed))
52
+ out.push({ file: files[i], body });
53
+ for (const { option, target } of requirementOptions(body)) {
54
+ const included = option === "r" ? confinedInclude(path.dirname(path.join(projectDir, files[i])), target, projectDir) : null;
55
+ const rel = included ? path.relative(projectDir, included).replace(/\\/g, "/") : null;
56
+ if (rel && !files.includes(rel))
57
+ files.push(rel);
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ /** The input a compiled file names, when it is a plain path that exists inside the project (where the compile runs). */
63
+ function presentInput(projectDir, compiled) {
64
+ const input = compiled.input;
65
+ if (!input || !isPlainPath(input))
66
+ return null;
67
+ const at = confinedInclude(projectDir, input, projectDir);
68
+ return at && fs.existsSync(at) ? input : null;
69
+ }
70
+ /** `uv pip compile` / `pip-compile` with the upgrade, when every spliced value is a plain token. */
71
+ function compileCommand(tool, input, output, name, target) {
72
+ if (!isPlainPath(input) || !isPlainPath(output) || !isShellSafeName(name, "PyPI") || !isShellSafeVersion(target))
73
+ return null;
74
+ // Double quotes: `>` is a redirect outside them on every shell; cmd.exe keeps single quotes.
75
+ const upgrade = `--upgrade-package "${name}==${target}"`;
76
+ return tool === "uv" ? `uv pip compile ${input} -o ${output} ${upgrade}` : `pip-compile ${input} --output-file ${output} ${upgrade}`;
77
+ }
78
+ /**
79
+ * The advice for `name` at `installed` when a compiled requirements file in `projectDir` pins it;
80
+ * null when no compiled file does (the caller keeps its own advice). Both a declared pin and an
81
+ * indirect one are moved by re-compiling, never by editing the output.
82
+ */
83
+ export function pypiCompiledInstruction(projectDir, name, installed, target) {
84
+ for (const { file, body } of filesPinning(projectDir, name, installed)) {
85
+ const compiled = compiledRequirements(body);
86
+ if (!compiled)
87
+ continue;
88
+ // Prose too carries only plain tokens: the target comes from advisory data, the rest from the repo.
89
+ const label = isShellSafeName(name, "PyPI") ? name : "this package";
90
+ const version = isShellSafeVersion(target) ? target : "the fixed release";
91
+ const safeFile = isPlainPath(file) ? file : "the requirements file";
92
+ const by = compiled.tool === "uv" ? " by `uv pip compile`" : compiled.tool === "pip-compile" ? " by `pip-compile`" : "";
93
+ const input = presentInput(projectDir, compiled);
94
+ const from = input ? ` from ${input}` : "";
95
+ const indirect = compiledIndirect(compiled, name);
96
+ const parents = compiledRequiredBy(compiled, name).filter((p) => isShellSafeName(p, "PyPI")).slice(0, 3);
97
+ const command = input && compiled.tool && isPlainPath(file) ? compileCommand(compiled.tool, input, file, name, target) : null;
98
+ const upgrade = isShellSafeName(name, "PyPI") && isShellSafeVersion(target) ? `\`--upgrade-package "${name}==${target}"\`` : "an `--upgrade-package` option for it";
99
+ const what = indirect
100
+ ? `${label} is not declared: ${safeFile} is compiled${by}${from} and pins it only because ${parents.length > 0 ? parents.join(", ") : "other packages"} require${parents.length === 1 ? "s" : ""} it`
101
+ : `${safeFile} is compiled${by}${from}, so a hand edit of it is reverted by the next compile${input ? `; make sure the ${label} requirement in ${input} admits ${version}` : `; make sure the ${label} requirement in the file it is compiled from admits ${version}`}`;
102
+ const instead = indirect ? " instead of editing it" : "";
103
+ const how = command
104
+ ? `${indirect ? "" : "then "}re-compile with the upgrade${instead}: \`${command}\`, keeping the other options its header records`
105
+ : compiled.tool
106
+ ? `${indirect ? "" : "then "}re-compile it${input ? "" : " from its input"} with ${upgrade}${instead}, keeping the options its header records`
107
+ : `${indirect ? "" : "then "}re-compile it with the tool that wrote it (\`uv pip compile\` or \`pip-compile\`) and ${upgrade}${instead}`;
108
+ const check = `the resolver refuses when ${indirect && parents.length > 0 ? parents.join(" or ") : "a package that requires it"} excludes ${version}; then raise ${indirect ? "that package" : "the one it names"} in the input as well, and put the other upgrades to ${safeFile} in the same compile (one --upgrade-package each)`;
109
+ return `${what}; ${how}. ${capitalise(check)}`;
110
+ }
111
+ return null;
112
+ }
113
+ function capitalise(s) {
114
+ return s.charAt(0).toUpperCase() + s.slice(1);
115
+ }
116
+ /**
117
+ * The sentence for an `==` edit of `name` in a flat file that pins every requirement exactly (two
118
+ * or more): its edits are not independently safe and must be resolved together. Null otherwise.
119
+ * The file is the first that pins it, in the order directFix reads them.
120
+ */
121
+ export function pypiPinnedTogether(projectDir, name, installed) {
122
+ const first = filesPinning(projectDir, name, installed)[0];
123
+ if (!first || compiledRequirements(first.body))
124
+ return null;
125
+ const { file, body } = first;
126
+ const pins = parseRequirements(body).length;
127
+ if (pins < 2 || pins !== requirementCount(body))
128
+ return null;
129
+ const where = isPlainPath(file) ? file : "the requirements file";
130
+ const check = isPlainPath(file) ? ` (\`uv pip compile ${file}\` prints the resolution and writes nothing; \`pip install --dry-run -r ${file}\` also checks)` : "";
131
+ return `${where} pins every requirement exactly, so this edit is not safe on its own: make every pin change to it together, re-resolve the file${check}, and raise any other pin the resolver reports as conflicting before installing`;
132
+ }
133
+ /** Requirement lines in a file (pins and ranges alike), options and comments aside. */
134
+ function requirementCount(body) {
135
+ let n = 0;
136
+ for (const raw of body.replace(/\\\r?\n/g, " ").split(/\r?\n/)) {
137
+ const line = raw.replace(/(^|\s)#.*$/, "").trim();
138
+ if (line && !line.startsWith("-") && /^[A-Za-z0-9]/.test(line))
139
+ n++;
140
+ }
141
+ return n;
142
+ }
143
+ /**
144
+ * vulnerability_scan's short form, appended to "Upgrade X a → b": a compiled file is re-compiled
145
+ * and a fully pinned one re-resolved, never one `==` edit taken as safe alone (audit 6.2.5 A-4).
146
+ */
147
+ export function pypiScanNote(projectDir, name, installed) {
148
+ const first = filesPinning(projectDir, name, installed)[0];
149
+ if (!first)
150
+ return null;
151
+ const where = isPlainPath(first.file) ? first.file : "the requirements file";
152
+ const compiled = compiledRequirements(first.body);
153
+ if (compiled) {
154
+ const input = presentInput(projectDir, compiled);
155
+ return `${where} is compiled${input ? ` from ${input}` : ""}: re-compile it with --upgrade-package instead of editing it${compiledIndirect(compiled, name) ? " (it is pinned only because other packages require it)" : ""}; upgrade_planner words the step`;
156
+ }
157
+ return pypiPinnedTogether(projectDir, name, installed) ? `${where} pins every requirement exactly: re-resolve it with the other pin changes before installing` : null;
158
+ }
@@ -13,8 +13,10 @@
13
13
  */
14
14
  /**
15
15
  * Whether an npm range admits `version`. Null when the range is not a
16
- * registry semver range (git, file, link, workspace, alias, tag) or the
17
- * version is unreadable.
16
+ * registry semver range (git, file, link, workspace, tag) or the version is
17
+ * unreadable. An npm alias ("npm:string-width@^4.2.0", the spec a lockfile
18
+ * records for "string-width-cjs") is read by its range: it is the parent's
19
+ * requirement on the aliased package.
18
20
  */
19
21
  export declare function npmRangeAdmits(range: string, version: string): boolean | null;
20
22
  /** Whether a Cargo version requirement ("0.8", "^1.2", ">=0.10, <0.11", "=1.0.5") admits `version`. Null when unreadable. */
@@ -130,16 +130,18 @@ function test(set, version) {
130
130
  }
131
131
  /**
132
132
  * Whether an npm range admits `version`. Null when the range is not a
133
- * registry semver range (git, file, link, workspace, alias, tag) or the
134
- * version is unreadable.
133
+ * registry semver range (git, file, link, workspace, tag) or the version is
134
+ * unreadable. An npm alias ("npm:string-width@^4.2.0", the spec a lockfile
135
+ * records for "string-width-cjs") is read by its range: it is the parent's
136
+ * requirement on the aliased package.
135
137
  */
136
138
  export function npmRangeAdmits(range, version) {
137
139
  const v = parseSemverPrecedence(version);
138
140
  if (!v)
139
141
  return null;
140
- // yarn berry writes "npm:^1.2.0"; an alias ("npm:other@^1") names another package.
141
- const text = range.trim().replace(/^npm:(?=[\^~<>=\dvxX*\s]|$)/, "");
142
- // Protocols (git+https:, file:, link:, workspace:, npm:alias@), paths, GitHub shorthand.
142
+ // yarn berry writes "npm:^1.2.0"; an alias writes "npm:<name>@<range>" (scoped names too).
143
+ const text = range.trim().replace(/^npm:(?:(?:@[^@\s/]+\/)?[^@\s/]+@)?(?=[\^~<>=\dvxX*\s]|$)/, "");
144
+ // Protocols (git+https:, file:, link:, workspace:), paths, GitHub shorthand.
143
145
  if (/^[a-z][a-z+]*:/i.test(text) || text.includes("/"))
144
146
  return null;
145
147
  // A dist-tag ("latest", "next", "beta").
@@ -5,6 +5,13 @@ export declare class RateLimiter {
5
5
  }>);
6
6
  canProceed(source: string): boolean;
7
7
  consume(source: string): boolean;
8
+ /**
9
+ * Take a token, waiting for one to refill when the bucket is empty, as long
10
+ * as it arrives by `deadline` (epoch ms). False when it would not: the
11
+ * caller then has no answer for that request and must say so, never treat
12
+ * it as an empty one.
13
+ */
14
+ acquire(source: string, deadline: number): Promise<boolean>;
8
15
  waitTimeMs(source: string): number;
9
16
  private refill;
10
17
  }
@@ -28,6 +28,22 @@ export class RateLimiter {
28
28
  bucket.tokens -= 1;
29
29
  return true;
30
30
  }
31
+ /**
32
+ * Take a token, waiting for one to refill when the bucket is empty, as long
33
+ * as it arrives by `deadline` (epoch ms). False when it would not: the
34
+ * caller then has no answer for that request and must say so, never treat
35
+ * it as an empty one.
36
+ */
37
+ async acquire(source, deadline) {
38
+ for (;;) {
39
+ if (this.consume(source))
40
+ return true;
41
+ const wait = Math.max(1, this.waitTimeMs(source));
42
+ if (Date.now() + wait > deadline)
43
+ return false;
44
+ await new Promise((resolve) => setTimeout(resolve, wait));
45
+ }
46
+ }
31
47
  waitTimeMs(source) {
32
48
  const bucket = this.buckets.get(source);
33
49
  if (!bucket)
@@ -46,7 +62,12 @@ export class RateLimiter {
46
62
  }
47
63
  }
48
64
  export const DEFAULT_RATE_LIMITS = {
49
- osv: { maxPerMinute: 10 },
65
+ // querybatch requests (1,000 dependencies each). OSV.dev publishes no
66
+ // per-client limit for it and osv-scanner sends a tree's batches back to
67
+ // back; 10 a minute made any tree over 10,000 dependencies fail outright
68
+ // (D:\4DA\src-tauri, 10,568, reported 0 vulnerable on 2026-10-12). A burst
69
+ // of 60 covers 60,000 dependencies; past that a scan waits for tokens.
70
+ osv: { maxPerMinute: 60 },
50
71
  // Per-package advisory lists for fix-path checks (/v1/query), one request per vulnerable package.
51
72
  "osv-package": { maxPerMinute: 300 },
52
73
  // Fix-path lookups on registry CDNs (crates.io sparse index, abbreviated npm
@@ -0,0 +1,36 @@
1
+ /**
2
+ * THE refresh-or-parent rule for a transitive copy (4DA AD-055 ¶1): given
3
+ * every requirement read for the parents that pull `installed` in, does a
4
+ * lockfile refresh reach `target`?
5
+ *
6
+ * - Any requirement that excludes the target sends the fix to that parent,
7
+ * even when another parent's requirement cannot be read.
8
+ * - Every requirement admitting it makes a refresh enough.
9
+ * - Nothing (or not everything) readable falls back to semver compatibility,
10
+ * the caret rule: same major at >= 1.0, same minor below it, any bump at
11
+ * 0.0.z leaves it. That answer is `inferred`, and every rendering says so.
12
+ *
13
+ * The 4DA desktop app's `osv::fix_path::refresh_verdict` is the same rule;
14
+ * the shared rule vectors (`refresh_verdict.jsonl`) hold both engines to it.
15
+ * Pure: no I/O.
16
+ */
17
+ /** How a requirement is written: npm (a bare version is an exact pin) or Cargo (a bare version is a caret). */
18
+ export type RequirementDialect = "npm" | "cargo";
19
+ export interface ParentRequirement {
20
+ parent: string;
21
+ /** The requirement as declared; null when it could not be read at all. */
22
+ text: string | null;
23
+ dialect: RequirementDialect;
24
+ }
25
+ export interface RefreshRule {
26
+ verdict: "refresh" | "parent_must_move" | "unknown";
27
+ /** True when no requirement decided and the verdict comes from semver compatibility. */
28
+ inferred: boolean;
29
+ /** Parents whose requirement excludes the target, in input order. */
30
+ via: string[];
31
+ }
32
+ /** Whether a requirement admits a version; null when either cannot be read. */
33
+ export declare function requirementAdmits(req: ParentRequirement, version: string): boolean | null;
34
+ /** True when moving from `installed` to `target` leaves the caret range of `installed`; null when either is unreadable. */
35
+ export declare function leavesCaret(installed: string, target: string): boolean | null;
36
+ export declare function refreshRule(installed: string, target: string, requirements: ParentRequirement[]): RefreshRule;
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * THE refresh-or-parent rule for a transitive copy (4DA AD-055 ¶1): given
4
+ * every requirement read for the parents that pull `installed` in, does a
5
+ * lockfile refresh reach `target`?
6
+ *
7
+ * - Any requirement that excludes the target sends the fix to that parent,
8
+ * even when another parent's requirement cannot be read.
9
+ * - Every requirement admitting it makes a refresh enough.
10
+ * - Nothing (or not everything) readable falls back to semver compatibility,
11
+ * the caret rule: same major at >= 1.0, same minor below it, any bump at
12
+ * 0.0.z leaves it. That answer is `inferred`, and every rendering says so.
13
+ *
14
+ * The 4DA desktop app's `osv::fix_path::refresh_verdict` is the same rule;
15
+ * the shared rule vectors (`refresh_verdict.jsonl`) hold both engines to it.
16
+ * Pure: no I/O.
17
+ */
18
+ import { cargoReqAdmits, npmRangeAdmits } from "./range-satisfies.js";
19
+ import { parseSemverPrecedence } from "./semver-precedence.js";
20
+ /** Whether a requirement admits a version; null when either cannot be read. */
21
+ export function requirementAdmits(req, version) {
22
+ if (req.text === null)
23
+ return null;
24
+ return req.dialect === "cargo" ? cargoReqAdmits(req.text, version) : npmRangeAdmits(req.text, version);
25
+ }
26
+ /** True when moving from `installed` to `target` leaves the caret range of `installed`; null when either is unreadable. */
27
+ export function leavesCaret(installed, target) {
28
+ const a = parseSemverPrecedence(installed.replace(/^v(?=\d)/, ""));
29
+ const b = parseSemverPrecedence(target.replace(/^v(?=\d)/, ""));
30
+ if (!a || !b)
31
+ return null;
32
+ if (a.major !== b.major)
33
+ return true;
34
+ if (a.major === 0 && a.minor !== b.minor)
35
+ return true;
36
+ return a.major === 0 && a.minor === 0 && a.patch !== b.patch;
37
+ }
38
+ export function refreshRule(installed, target, requirements) {
39
+ const read = requirements.map((r) => requirementAdmits(r, target));
40
+ const via = requirements.filter((_, i) => read[i] === false).map((r) => r.parent);
41
+ if (via.length > 0)
42
+ return { verdict: "parent_must_move", inferred: false, via: [...new Set(via)] };
43
+ if (read.length > 0 && read.every((ok) => ok === true))
44
+ return { verdict: "refresh", inferred: false, via: [] };
45
+ const leaves = leavesCaret(installed, target);
46
+ if (leaves === null)
47
+ return { verdict: "unknown", inferred: true, via: [] };
48
+ return { verdict: leaves ? "parent_must_move" : "refresh", inferred: true, via: [] };
49
+ }
@@ -51,6 +51,11 @@ export interface PackageReleaseIndex {
51
51
  /** When the package itself was first published, or null. */
52
52
  createdAt: string | null;
53
53
  releases: Record<string, ReleaseRecord>;
54
+ /**
55
+ * npm: the `latest` dist-tag. A package whose latest release is deprecated
56
+ * withdraws no deprecated release (withdrawn-releases.ts). Absent elsewhere.
57
+ */
58
+ latest?: string | null;
54
59
  }
55
60
  /** The outcome of one registry read: data, a definite "no such package", or "could not reach it". */
56
61
  export type RegistryLookup<T> = {
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { type FileSignature } from "./file-signature.js";
20
20
  import type { InstallDriftRecord, ResolutionSourceRecord, ResolvedDependency } from "./types.js";
21
+ import type { LockfileProblem } from "./lockfile-types.js";
21
22
  /** The inputs of one resolution, kept so the same resolution can be re-run. */
22
23
  export interface ResolutionGroup {
23
24
  dir: string;
@@ -45,6 +46,8 @@ export interface GroupResolution {
45
46
  manifest: string;
46
47
  ranges: number;
47
48
  } | null;
49
+ /** Lockfiles present in this directory that could not be read, with the reason. */
50
+ problems: LockfileProblem[];
48
51
  signatures: Map<string, FileSignature>;
49
52
  resolvedAt: string;
50
53
  }
@@ -35,7 +35,10 @@ export function resolveGroup(group) {
35
35
  // script in package.json must not throw away a scan of an unchanged lockfile.
36
36
  if (manifest && !readFiles.includes(manifest))
37
37
  signatures.delete(manifest);
38
- let resolved = resolveVersionsFrom(group.dir, group.deps, group.devDeps, ecosystem, read.versions, group.targets);
38
+ // Ruby and PHP: the lockfile (and composer.json) name the direct dependencies; the project scanner does not.
39
+ const deps = group.deps.length === 0 && group.devDeps.length === 0 && read.direct ? read.direct.deps : group.deps;
40
+ const devDeps = group.deps.length === 0 && group.devDeps.length === 0 && read.direct ? read.direct.devDeps : group.devDeps;
41
+ let resolved = resolveVersionsFrom(group.dir, deps, devDeps, ecosystem, read.versions, group.targets);
39
42
  // No lockfile: a ranged dependency's version is the range's floor, a label.
40
43
  const ranges = read.kind === "declared_ranges" ? read.ranges ?? new Map() : null;
41
44
  if (ranges && ranges.size > 0) {
@@ -57,14 +60,14 @@ export function resolveGroup(group) {
57
60
  signatures.set(file, signature);
58
61
  }
59
62
  const audit = [
60
- ...resolveAuditVersionsFrom(group.dir, group.deps, group.devDeps, ecosystem, read.versions, group.targets, resolved, read.instances),
63
+ ...resolveAuditVersionsFrom(group.dir, deps, devDeps, ecosystem, read.versions, group.targets, resolved, read.instances),
61
64
  ...driftAudit,
62
65
  ].filter((dep) => !dep.declaredRange);
63
66
  const sources = read.kind
64
67
  ? readFiles.map((file) => ({ path: file, kind: read.kind, mtimeMs: signatureMtime(signatures.get(file)) }))
65
68
  : [];
66
69
  const unresolved = ranges && ranges.size > 0 && read.source ? { manifest: read.source, ranges: ranges.size } : null;
67
- return { group, resolved, audit, drift, source: sources[0] ?? null, sources, unresolved, signatures, resolvedAt };
70
+ return { group, resolved, audit, drift, source: sources[0] ?? null, sources, unresolved, problems: read.problems ?? [], signatures, resolvedAt };
68
71
  }
69
72
  /** True when any file the resolution depended on changed, appeared, or vanished. */
70
73
  export function groupIsStale(result) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Whether a vulnerability scan answered for every dependency it was given.
3
+ *
4
+ * Until 6.2.5 an OSV.dev outage, a 503, a timeout or the server's own rate
5
+ * limit turned a scan into `total_vulnerable_packages: 0`, flagged only by
6
+ * `_meta.offline` at the end of the answer, and dependency_health and
7
+ * upgrade_planner then scored and ranked that as a clean project (10,568
8
+ * dependencies of D:\4DA\src-tauri, 0 vulnerable, 2026-10-12). Every scan
9
+ * consumer now asks this module first, and an incomplete scan leads its
10
+ * answer with what was not checked.
11
+ */
12
+ import type { VulnerabilityCoverage, VulnerabilityScanResult } from "./types.js";
13
+ /** The reason given when live lookups are switched off. */
14
+ export declare const LOOKUPS_DISABLED = "live lookups are disabled (FOURDA_OFFLINE=true)";
15
+ /**
16
+ * Null when every dependency the scan was given has an advisory answer.
17
+ * Otherwise how many have none, and why. `result` null means no scan backs
18
+ * the answer at all: `expectedTotal` dependencies are then all unchecked.
19
+ */
20
+ export declare function vulnerabilityCoverage(result: VulnerabilityScanResult | null, opts: {
21
+ enabled: boolean;
22
+ expectedTotal?: number;
23
+ notLoaded?: string;
24
+ }): VulnerabilityCoverage | null;
25
+ /** The first line an incomplete answer leads with. */
26
+ export declare function notCheckedLine(reason: string, unchecked: number, total: number): string;
@@ -0,0 +1,70 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Whether a vulnerability scan answered for every dependency it was given.
4
+ *
5
+ * Until 6.2.5 an OSV.dev outage, a 503, a timeout or the server's own rate
6
+ * limit turned a scan into `total_vulnerable_packages: 0`, flagged only by
7
+ * `_meta.offline` at the end of the answer, and dependency_health and
8
+ * upgrade_planner then scored and ranked that as a clean project (10,568
9
+ * dependencies of D:\4DA\src-tauri, 0 vulnerable, 2026-10-12). Every scan
10
+ * consumer now asks this module first, and an incomplete scan leads its
11
+ * answer with what was not checked.
12
+ */
13
+ /** The reason given when live lookups are switched off. */
14
+ export const LOOKUPS_DISABLED = "live lookups are disabled (FOURDA_OFFLINE=true)";
15
+ /**
16
+ * Null when every dependency the scan was given has an advisory answer.
17
+ * Otherwise how many have none, and why. `result` null means no scan backs
18
+ * the answer at all: `expectedTotal` dependencies are then all unchecked.
19
+ */
20
+ export function vulnerabilityCoverage(result, opts) {
21
+ if (!result) {
22
+ const total = opts.expectedTotal ?? 0;
23
+ const reason = opts.enabled ? (opts.notLoaded ?? "the OSV.dev scan did not finish in time or OSV.dev was unreachable") : LOOKUPS_DISABLED;
24
+ return build(total, total, reason);
25
+ }
26
+ // A stub or an older result that says offline without a count answered for nothing.
27
+ const unchecked = result.unchecked ?? (result.offline ? result.totalScanned : 0);
28
+ const stale = result.staleAnswers ?? 0;
29
+ if (unchecked <= 0 && stale <= 0)
30
+ return null;
31
+ if (unchecked > 0) {
32
+ const gap = build(result.totalScanned, unchecked, result.uncheckedReason ?? "OSV.dev could not be queried");
33
+ return stale > 0 ? { ...gap, stale_dependencies: stale, note: `${gap.note} ${staleLine(result, stale)}` } : gap;
34
+ }
35
+ // Every dependency has an answer, but some only from an expired cache because the fresh query
36
+ // failed. That answer can predate an advisory, so it is not a current all-clear (audit 6.2.5
37
+ // B-2: dependency_check said `proceed` and the scan "0 vulnerable" on a 1 h old answer).
38
+ const reason = result.staleReason ?? "OSV.dev could not be queried";
39
+ return {
40
+ complete: false,
41
+ total_dependencies: result.totalScanned,
42
+ checked_dependencies: result.totalScanned,
43
+ unchecked_dependencies: 0,
44
+ stale_dependencies: stale,
45
+ reason,
46
+ note: `STALE: ${reason}. ${staleLine(result, stale)}`,
47
+ };
48
+ }
49
+ /** "N dependencies were answered only from an expired cached answer (the oldest fetched ...)". */
50
+ function staleLine(result, stale) {
51
+ const n = stale.toLocaleString("en-US");
52
+ const oldest = result.staleOldestFetchedAt ? ` (the oldest fetched ${result.staleOldestFetchedAt} UTC)` : "";
53
+ return `${n} dependenc${stale === 1 ? "y was" : "ies were"} answered only from an expired cached answer${oldest}; an advisory published since would not show, so this is not a current all-clear. Call again once OSV.dev answers (force_refresh).`;
54
+ }
55
+ function build(total, unchecked, reason) {
56
+ return {
57
+ complete: false,
58
+ total_dependencies: total,
59
+ checked_dependencies: Math.max(0, total - unchecked),
60
+ unchecked_dependencies: unchecked,
61
+ reason,
62
+ note: notCheckedLine(reason, unchecked, total),
63
+ };
64
+ }
65
+ /** The first line an incomplete answer leads with. */
66
+ export function notCheckedLine(reason, unchecked, total) {
67
+ const n = (x) => x.toLocaleString("en-US");
68
+ const scope = total > 0 ? `${n(unchecked)} of ${n(total)} dependencies have no vulnerability answer` : "no dependency has a vulnerability answer";
69
+ return `NOT CHECKED: ${reason} — ${scope}. Their absence from the findings means nothing: this is not a clean result. Call again once OSV.dev answers (force_refresh), or check them another way.`;
70
+ }
@@ -9,6 +9,14 @@ export declare function parseSemver(version: string): [number, number, number] |
9
9
  * The numeric fields stay literal (0.8.6 -> 0.9.0 is major 0, minor 1).
10
10
  */
11
11
  export declare function computeSemverDistance(current: string, latest: string): SemverDistance | null;
12
+ /**
13
+ * Whether the suffix after MAJOR.MINOR.PATCH marks a prerelease. Semver: only
14
+ * `-` does; `+build` metadata is ignored for precedence, so `0.25.17+spec-1.1.0`
15
+ * (toml_edit) is a release, and `+incompatible` is a Go release. Until 6.2.5 a
16
+ * `+` counted as a prerelease, which named toml_edit 0.23.9 its latest stable.
17
+ * PEP 440 spellings (PyPI uses this too): `a`/`b`/`rc`/`alpha`/`beta`/`c`/`pre`
18
+ * and `.dev` mark a prerelease; `.post` and `+local` do not.
19
+ */
12
20
  export declare function isPreRelease(version: string): boolean;
13
21
  /**
14
22
  * Compare two versions by MAJOR.MINOR.PATCH. Returns 1 if a > b, -1 if a < b,
@@ -5,6 +5,13 @@ export function parseSemver(version) {
5
5
  return null;
6
6
  return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
7
7
  }
8
+ /** MAJOR[.MINOR[.PATCH]] at the start of a version, missing parts as 0; null without a leading number. */
9
+ function parseLooseRelease(version) {
10
+ const match = version.replace(/^v/, "").match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
11
+ if (!match)
12
+ return null;
13
+ return [parseInt(match[1]), parseInt(match[2] ?? "0"), parseInt(match[3] ?? "0")];
14
+ }
8
15
  /**
9
16
  * How far `current` is behind `latest`. `label` is the kind of bump, by the
10
17
  * caret rules npm and Cargo apply: below 1.0 a minor bump is breaking (^0.8.6
@@ -14,8 +21,11 @@ export function parseSemver(version) {
14
21
  * The numeric fields stay literal (0.8.6 -> 0.9.0 is major 0, minor 1).
15
22
  */
16
23
  export function computeSemverDistance(current, latest) {
17
- const c = parseSemver(current);
18
- const l = parseSemver(latest);
24
+ // Missing parts count as 0: PyPI and Go write `4.2` and `1.23`. Requiring three parts left
25
+ // Django 4.2 -> 5.2.17 and urllib3 1.23 -> 2.8.0 "unknown", which the planner counted as
26
+ // non-breaking (audit 6.2.5 A-3 / D-9).
27
+ const c = parseLooseRelease(current);
28
+ const l = parseLooseRelease(latest);
19
29
  if (!c || !l)
20
30
  return null;
21
31
  const major = l[0] - c[0];
@@ -30,8 +40,21 @@ export function computeSemverDistance(current, latest) {
30
40
  label = c[0] === 0 && c[1] === 0 ? "major" : "patch";
31
41
  return { major: Math.max(0, major), minor: Math.max(0, minor), patch: Math.max(0, patch), label };
32
42
  }
43
+ /**
44
+ * Whether the suffix after MAJOR.MINOR.PATCH marks a prerelease. Semver: only
45
+ * `-` does; `+build` metadata is ignored for precedence, so `0.25.17+spec-1.1.0`
46
+ * (toml_edit) is a release, and `+incompatible` is a Go release. Until 6.2.5 a
47
+ * `+` counted as a prerelease, which named toml_edit 0.23.9 its latest stable.
48
+ * PEP 440 spellings (PyPI uses this too): `a`/`b`/`rc`/`alpha`/`beta`/`c`/`pre`
49
+ * and `.dev` mark a prerelease; `.post` and `+local` do not.
50
+ */
33
51
  export function isPreRelease(version) {
34
- return /[-+]/.test(version.replace(/^v/, "").replace(/^\d+\.\d+\.\d+/, "").slice(0, 1));
52
+ const suffix = version.replace(/^v/, "").replace(/^\d+\.\d+\.\d+/, "").split("+")[0].toLowerCase();
53
+ if (suffix === "")
54
+ return false;
55
+ if (suffix.startsWith("-"))
56
+ return true;
57
+ return /^[._]?(?:a|b|c|rc|alpha|beta|pre|preview|dev)\d*/.test(suffix) && !/^[._-]?post/.test(suffix);
35
58
  }
36
59
  /**
37
60
  * Compare two versions by MAJOR.MINOR.PATCH. Returns 1 if a > b, -1 if a < b,