osv-scanner-mcp 0.18.0 → 0.20.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/README.md CHANGED
@@ -27,7 +27,11 @@ An MCP server that wraps Google's [OSV-Scanner](https://github.com/google/osv-sc
27
27
 
28
28
  ## Composer support
29
29
 
30
- Pass a project directory, `composer.lock`, or `composer.json` to `scan_project` or `suggest_fix`. The lockfile records both direct and transitive dependencies; their relationship is currently reported as `unknown`. A `composer.json` without a lockfile in the same directory is reported as missing/unconfirmed in `coverage.lockfile_missing` (a project with no scannable files returns `no_manifest_found`). An ancestor's lockfile does not establish coverage for an independent child project. `vendor` directories are not traversed.
30
+ Pass a project directory, `composer.lock`, or `composer.json` to `scan_project` or `suggest_fix`. Direct/transitive dependency analysis (source checkout) uses the companion `composer.json` in the lockfile's own directory. A `composer.json` without a lockfile in the same directory is reported as missing/unconfirmed in `coverage.lockfile_missing` (a project with no scannable files returns `no_manifest_found`). An ancestor's lockfile does not establish coverage for an independent child project. `vendor` directories are not traversed.
31
+
32
+ The companion's `require` and `require-dev` are direct dependencies. Packages reachable through the locked packages' `require` are transitive; a dependency's own `require-dev`, `suggest`, and `conflict` are not dependency edges. Direct dependencies include their project-relative `composer.json` in `declared_in`. `introduced_by` names each direct root that transitively requires a package (up to 10, with the remainder in `introduced_by_omitted`). A direct dependency also required by another root remains direct and includes that root in `introduced_by`; cycles do not introduce a package through itself. Transitive dependencies do not have `declared_in`, since the root does not declare them directly.
33
+
34
+ This is name-based graph traversal of the lockfile, not a Composer constraint solver or freshness check. Duplicate installed names, virtual requirements, and references affected by `provide`/`replace` are not guessed; unreachable packages remain `unknown`. Local path and skipped branch entries may connect roots to scanned packages, but their names/metadata are still excluded from scanner input. No path-repository URL or nested package's composer.json is followed. Missing, malformed, symlinked, oversized, or otherwise unreadable companion manifests leave relationships unknown, with a `composer_warnings` note; locked release packages continue to be scanned. The companion is captured safely once (4 MiB per file), shares the Composer 64 MiB input/JSON structure budgets, and traversal/indexing share a 2,000,000-operation budget across all Composer lockfiles. Relationship uncertainty does not itself make dependency scan coverage incomplete.
31
35
 
32
36
  The server reads each lockfile safely and scans a private sanitized copy containing only package names and release versions. Repository URLs, dist/source commit references, scripts, and credentials are not passed to OSV-Scanner. Both production and development packages are included. JSON is limited to 16 MiB per lockfile and 64 MiB per scan, with structural work limits. Invalid JSON is excluded; invalid entries and unsupported versions are omitted and reported in `skipped_files`, making `coverage.complete` false.
33
37
 
@@ -291,7 +295,7 @@ Detects the lockfiles and manifests in a project and scans the Java / JavaScript
291
295
  ```
292
296
 
293
297
  - **Always check `coverage`.** If `complete` is `false`, some dependencies were not scanned, so zero findings does not mean the project is safe
294
- - `lockfile_missing`: manifests without a lockfile (`package.json`, `pyproject.toml`, `Pipfile`, `setup.py`, `build.gradle`, `Cargo.toml`, and so on). Not recorded if a lockfile of the same ecosystem is in the same directory. If there is only a lockfile in a parent directory, the manifest is not recorded only when `package-lock.json` (v2 or later) is confirmed to contain that directory (npm workspaces), or when `Cargo.lock` contains a local crate with the `[package] name` of the `Cargo.toml` and the directory is a member of the workspace according to `members` / `exclude` of the `Cargo.toml` next to `Cargo.lock` (Cargo workspaces; `members` globs with `*` and `?` are supported). A crate with the same name that cannot be confirmed as a member, such as a path dependency outside the workspace, is recorded as `unconfirmed`. Library crates often do not commit `Cargo.lock`, so their `Cargo.toml` is recorded here; generate one with `cargo generate-lockfile`. Otherwise it is recorded with `status: "missing"`, or `status: "unconfirmed"` for formats that cannot be checked (yarn.lock, Python lockfiles, and so on). Generate the lockfile as described in `hint` (in a trusted environment) and scan again
298
+ - `lockfile_missing`: manifests without a lockfile (`package.json`, `pyproject.toml`, `Pipfile`, `setup.py`, `build.gradle`, `Cargo.toml`, and so on). Not recorded if a lockfile of the same ecosystem is in the same directory. If there is only a lockfile in a parent directory, the manifest is not recorded only when `package-lock.json` (v2 or later) is confirmed to contain that directory (npm workspaces), when `pnpm-lock.yaml` (formats 5.3/5.4/6.0/9.0) contains its exact normalized importer path (pnpm workspaces; source checkout), when Yarn workspace membership and captured declarations can be confirmed (see below; source checkout), or when `Cargo.lock` contains a local crate with the `[package] name` of the `Cargo.toml` and the directory is a member of the workspace according to `members` / `exclude` of the `Cargo.toml` next to `Cargo.lock` (Cargo workspaces; `members` globs with `*` and `?` are supported). A crate with the same name that cannot be confirmed as a member, such as a path dependency outside the workspace, is recorded as `unconfirmed`. Library crates often do not commit `Cargo.lock`, so their `Cargo.toml` is recorded here; generate one with `cargo generate-lockfile`. Otherwise it is recorded with `status: "missing"`, or `status: "unconfirmed"` for formats that cannot be checked (unsupported Yarn layouts, Python lockfiles, and so on). Generate the lockfile as described in `hint` (in a trusted environment) and scan again
295
299
  - `unpinned_requirements`: lines in `requirements.txt` that do not pin a version. `kind` is `unpinned` (no version), `range` (`>`, `<`, `!=`, `==1.*`, combined ranges, and so on), or `lower_bound` (`>=`, `~=`). OSV-Scanner does not scan `unpinned` and `range` lines, and scans `lower_bound` lines at the lower bound (those packages are marked `version_is_lower_bound: true` and may differ from the installed version)
296
300
  - `unscannable_requirements`: lines that are not scanned, with the reason: `-e`, `name @ URL`, paths, includes that were not expanded (outside the project directory, missing, URLs, or over the limits), constraint files (`-c`, not applied), and options or version specifiers that cannot be interpreted. Lines that cannot be interpreted are recorded here instead of being ignored
297
301
  - `local_crates` (only when a `Cargo.lock` was scanned): the number of crates without a source in `Cargo.lock` (workspace members and path dependencies). They are your own code and are not scanned; they do not make `complete` false. A third-party crate vendored as a path dependency is not scanned either (as with `cargo audit`)
@@ -307,8 +311,21 @@ Detects the lockfiles and manifests in a project and scans the Java / JavaScript
307
311
  - `requirements.txt`: dependencies written in the file are direct; dependencies resolved through deps.dev are transitive
308
312
  - `pom.xml`: OSV-Scanner reports the dependencies declared in `pom.xml` (and its parent POMs) and the transitive dependencies resolved through deps.dev as separate results (`source.type` `lockfile` / `unknown`), and this split is used (so parent POMs, profiles, properties, and dependency management are interpreted exactly as OSV-Scanner does). `introduced_by` / `declared_in` are not available. This relies on undocumented OSV-Scanner output, so anything unexpected is reported as `unknown`
309
313
  - `Cargo.lock`: crates.io dependencies immediately required by a local/workspace/path crate are direct; crates.io dependencies reachable through them (including git and alternate-registry origins) are transitive. `introduced_by` names the first direct dependency on each path. References are resolved by exact name, version, and source where provided; ambiguous references are not guessed. Replacements are followed. `declared_in` lists project-relative Cargo.toml paths only when every declaring crate has a unique matching manifest. Otherwise `declaring_crates` names the unmapped declaring crates (up to 10, with the rest in `declaring_crates_omitted`). Dev/build/target-specific distinctions are unavailable. Traversal over 2,000,000 edges returns `unknown`
310
- - .NET: `Direct` / `Transitive` in `packages.lock.json`; the dependencies of `project.frameworks` in `project.assets.json`; the dependencies of the project library in `*.deps.json`; and every reference read from a project file are direct. `packages.config` does not say, so it is `unknown`. `introduced_by` and `declared_in` are not available yet
311
- - Other formats (`gradle.lockfile`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lock`, `poetry.lock`, `uv.lock`, `Pipfile.lock`, and `pdm.lock`), npm lockfile version 1, and entries not reachable from the project are `unknown`. If lockfiles disagree, the value is `mixed`
314
+ - .NET: `Direct` / `Transitive` in `packages.lock.json`; the dependencies of `project.frameworks` in `project.assets.json`; the dependencies of the project library in `*.deps.json`; and every reference read from a project file are direct. `packages.config` does not say, so it is `unknown`. Origin/declaration enrichment is available in the source checkout as described below.
315
+ - Composer (source checkout): the companion root's `require` / `require-dev` are direct, and locked `require` reachability establishes transitive dependencies. `declared_in` names the companion for direct dependencies; `introduced_by` lists transitively requiring direct roots. See [Composer support](#composer-support) for ambiguity and work limits.
316
+ - `pnpm-lock.yaml` (source checkout): lockfile formats 5.3, 5.4, 6.0 and 9.0 are supported. Importer `dependencies`, `devDependencies` and `optionalDependencies` are direct, with the lockfile's declaring importer `package.json` paths in `declared_in`. Locked dependency and optional-dependency edges establish `introduced_by` and transitive reachability. Workspace importers, npm aliases, scoped packages, multiple origins and peer-context-specific snapshots are supported. A package that is both direct and transitively required remains direct and also lists its origins. Local/Git references, unresolved identities and unreachable packages do not establish relationships; links and source files are never followed. Declaration paths reflect the lockfile, not independently verified current manifests. Workspace coverage (source checkout) uses the exact normalized importer path relative to an ancestor pnpm-lock.yaml: included children are covered, absent importers are `missing`, and unsupported/malformed/ambiguous or analysis-limited importer metadata is `unconfirmed`. Empty importer maps and Unicode/space paths are supported. Importer presence does not prove freshness or agreement with current package.json dependencies. Only lockfiles found inside the selected scan directory are considered. Coverage and vulnerability scanning use the same captured lockfile bytes (16 MiB per file, 64 MiB total); unreadable or oversized inputs are skipped with a reason. Importer parsing/indexing shares a 2,000,000-operation budget per detection, separate from relationship analysis. The lockfile is read from YAML's syntax tree in linear time (only strings, maps, and sequences; a 28,000-package lockfile takes about a second). Unsupported/malformed YAML, duplicate keys, anchors, aliases, tags, multi-document files, conservative nesting/line limits, inputs above 16 MiB, or more than 2,000,000 relationship operations per scan (shared by all pnpm lockfiles) leave relationships unknown; scanning continues. The existing shared relationship input byte limit and 10-origin output limit apply.
317
+ - `yarn.lock` (source checkout): Classic v1 and Berry metadata versions 4/5/6/8 support direct/transitive relations, `introduced_by` and `declared_in`. Classic uses captured root/workspace `package.json` declarations; Berry uses exact locked descriptors and `workspace:` entries. Dependencies and optional dependencies are followed; peers are not independently guessed or installed. Multiple selectors, scoped/npm aliases, cycles, multiple roots and direct/transitive overlap are supported. Local/Git/patch/portal/virtual identities that cannot establish a registry release remain unknown and are never followed to source files.
318
+ - Other formats (`gradle.lockfile`, `bun.lock`, `poetry.lock`, `uv.lock`, `Pipfile.lock`, and `pdm.lock`), npm lockfile version 1, and entries not reachable from the project are `unknown`. If lockfiles disagree, the value is `mixed`
319
+
320
+ **Yarn workspace coverage (source checkout):** Classic does not contain workspace importer records, so a captured private root's `workspaces` array (or `workspaces.packages`) must include the child and its captured dependency descriptors must resolve in the lockfile or to a uniquely named configured local workspace satisfying the requested SemVer range. Berry matches exact normalized `workspace:` resolution paths and checks captured dependency declarations against the locked workspace. Classic children need a nonempty name and usable version; manifests without them are not workspace members. Wildcards do not consume dot-prefixed path segments unless the pattern explicitly starts that segment with a dot. `*`, `?`, whole-segment `**`, exclusion patterns, one trailing `/` (as Yarn strips it), and Unicode/space paths are supported; other empty segments are unconfirmed; brace/extglob/character-class patterns remain unconfirmed. Independent children are missing; unreadable declarations, unresolved/ambiguous entries and analysis limits are unconfirmed. Coverage confirmation does not prove lockfile freshness or platform compatibility.
321
+
322
+ Yarn lockfiles and manifests are captured once within the selected scan boundary; coverage, relation metadata and scanning use those same inputs. Yarn is never run during a scan, and links, configuration plugins and source paths are not evaluated. Lockfiles are limited to 16 MiB each / 64 MiB total; captured package manifests to 4 MiB each / 64 MiB total with bounded JSON parsing. YAML nesting/line limits apply. Parsing and workspace coverage share one 2,000,000-operation budget per scan, and relationship analysis has a separate 2,000,000-operation budget, so exceeding the relationship limit leaves relations unknown without making confirmed coverage unconfirmed. Unsupported metadata leaves relations unknown and membership unconfirmed; unreadable/oversized lockfiles are skipped with a reason. A selected lockfile alone does not cause child manifests or files outside the selected scan directory to be searched. Berry declaration paths may come from locked workspace entries when companions were not captured.
323
+
324
+ **NuGet origins and declarations (source checkout):** `introduced_by` follows dependency edges from direct NuGet packages in each locked TFM or assets/deps target separately, then merges origins only for the same package/version in the report. Case-insensitive IDs and equivalent normalized versions match. `packages.lock.json` and assets targets contain a resolved solution, so edges resolve a unique installed name within that target; dependency constraints are not solved again. `deps.json` edges additionally require an exact normalized version match. Multiple origins and cycles are supported; a root does not introduce itself. Duplicate/ambiguous names and unmatched target frameworks are not guessed. Local project nodes can be traversed but are not guessed as direct package origins and remain excluded from OSV input. `introduced_by` retains the existing 10-name limit and omitted count.
325
+
326
+ `declared_in` is added to direct packages only when the captured source is uniquely associated with a `.csproj`/`.fsproj`/`.vbproj` and that captured project contains an unambiguous, unconditional `PackageReference` for the ID. A missing/property/range/floating version can still identify the reference when the restore result confirms its direct package; version resolution continues to come from restore metadata. Conditional, removed, updated, or duplicated references are not guessed. The field names the reference's project file, not the storage location of its centrally managed version. Direct-only project scans also expose this path and preserve lower-bound/coverage warnings. A standalone selected lockfile/assets file without captured project evidence, an ambiguous shared lockfile, `packages.config`, and a deployed `deps.json` do not imply a project declaration path. Paths embedded in restore/build outputs are not followed to read source files.
327
+
328
+ Graphs and declarations use already captured, size-limited inputs, without running restore or MSBuild or forwarding graph metadata to OSV. A shared 2,000,000-operation relationship budget bounds indexing/traversal/declaration work across the scan. Exhaustion omits uncertain metadata and adds a NuGet warning; existing vulnerability scanning and direct/transitive classification continue. Neither dependency reachability nor declaration matching proves freshness, constraint satisfaction, or TFM/RID compatibility. Update hints in both tools name a confirmed declaring project or the direct package origins where available.
312
329
  - `dependency_groups` is the raw dependency group reported by OSV-Scanner (for example `dev`). It is missing or inaccurate for some lockfile formats (absent for pnpm, `optional` for pdm, and so on), so treat it as informational
313
330
  - Upgrade recommendations (`suggest_fix`) are available for Maven, npm, PyPI, Go, Rust (crates.io), PHP (Packagist), and .NET (NuGet; source checkout).
314
331
 
@@ -488,7 +505,8 @@ For npm, Go, PyPI, and Rust, the "same release line" is the range a caret (`^`)
488
505
  - Rust uses SemVer (`SEMVER` ranges) and Cargo caret compatibility: 0.x minor updates and 0.0.x patch updates are `cross_major`. `unmaintained` and `notice` advisories, and `unsound` advisories without a newer fixed version, remain in `per_cve_detail` with `tier: "informational"`, their kind, and `recommended_status`, but are not fix targets or counted as unfixed. An `unsound` advisory with a newer fixed version is a version-specific defect, so it is a fix target like a vulnerability (its tier is computed normally and it keeps `informational: "unsound"`). Packages with no fix target left return `verification: "informational_only"` without an upgrade. Unmaintained crates may need replacement; use `explain_vulnerability` for notices.
489
506
  - Suggestions include an `update_hint`, tailored to whether the package is a direct or transitive dependency:
490
507
  - Maven (from `pom.xml`): for a direct dependency, update the `<dependency>` version (or the parent POM, property, or BOM that manages it); for a transitive dependency, override the version in `<dependencyManagement>` or update the direct dependency that requires it
491
- - npm: for a direct dependency, update the version in the `package.json` listed in `declared_in`; for a transitive dependency, update the direct dependency named in `introduced_by`, or set the version with `overrides` in the root `package.json` (effective only in the root project)
508
+ - npm (including pnpm): for a direct dependency, update the version in the `package.json` listed in `declared_in`; for a transitive dependency, update the direct dependency named in `introduced_by`, or use your package manager's root override configuration. Regenerate the lockfile and rescan.
509
+ - Composer: for direct dependencies, update the constraint in `declared_in`; for transitive dependencies, update the direct roots in `introduced_by` or their constraints, then regenerate the lockfile with dependency updates allowed, scripts and plugins disabled. PHP and dependency compatibility still require verification.
492
510
  - Go: `go get <module>@<version>` (also for `// indirect` modules). For a module affected by `replace`, update the version in the `replace` directive instead of `require`. Major versions 2 and later use a different module path (`/v2` and so on) and are separate packages in OSV, so fixes in a newer major line are not included as candidates. If the current version is a pseudo-version (an untagged commit), `upgrade_note` says so
493
511
  - PyPI: update the version in `requirements.txt`, `pyproject.toml`, or `Pipfile` and regenerate the lockfile; for a transitive dependency, use a pip constraints file (`-c`) or the override settings of uv or Poetry
494
512
  - Rust: where version requirements permit it, `cargo update -p <crate>@<current> --precise <recommended>` updates the lockfile. Otherwise change the direct requirement in the declaring Cargo.toml or update the parent dependency in `introduced_by`. Cross-major upgrades may break compatibility, including 0.x updates. Pre-releases may require explicit `=<version>` requirements and must be allowed by transitive parents. Build metadata is omitted from `--precise`. `[patch.crates-io]` changes a source, not the allowed version range; backports must satisfy parent requirements. Check yanked versions, MSRV, and compiler compatibility. Cargo is never executed by the server
package/dist/index.js CHANGED
@@ -66,7 +66,7 @@ if (startupWarning !== null) {
66
66
  }
67
67
  }
68
68
  // NOTE: リリース時はpackage.jsonのversionと同じ値に更新すること
69
- const SERVER_VERSION = "0.18.0";
69
+ const SERVER_VERSION = "0.20.0";
70
70
  const server = new McpServer({
71
71
  name: "osv-scanner-mcp",
72
72
  version: SERVER_VERSION,
@@ -86,10 +86,10 @@ server.registerTool("scan_project", {
86
86
  "For Cargo.lock, only crates from crates.io are scanned: the project's own crates are counted in local_crates, and git or private-registry crates are listed in unscannable_crates. " +
87
87
  "For .NET, each project file is matched to its own lockfile or obj/project.assets.json; projects without one are scanned from their direct PackageReference items only (lockfile_missing status direct_only or unconfirmed), " +
88
88
  "a PackageReference Version=\"x\" is looked up at its lower bound (version_is_lower_bound), and references with properties, ranges without a lower bound, floating versions, conditions, or Update/Remove are not looked up (unresolved_package_references). " +
89
- "The project's own projects are counted in local_projects. Names of packages from private NuGet feeds are also sent to api.osv.dev; see nuget_warnings. " +
89
+ "The project's own projects are counted in local_projects. NuGet introduced_by follows target-scoped restore/build dependency graphs; declared_in names a uniquely associated, captured project with an unambiguous unconditional PackageReference. Ambiguous declarations, standalone build files, or analysis limits omit uncertain metadata. Graph paths are not followed and restore/MSBuild are not run. Names of packages from private NuGet feeds are also sent to api.osv.dev; see nuget_warnings. " +
90
90
  "Vulnerabilities marked informational (RustSec notices such as unmaintained or unsound crates) are not counted in vulnerability_count; they are counted in informational_count. " +
91
- "Each package's dependency_relation tells whether it is a direct or transitive dependency (determined for package-lock.json, go.mod, requirements.txt, and pom.xml; unknown otherwise). " +
92
- "Composer files are scanned from sanitized copies containing package names and release versions only; local dist.type=path packages are not looked up and are counted in local_composer_packages; branch/dev versions and invalid entries are excluded and reported in skipped_files. Composer and PHP code are never run. Other private Composer package names are sent to api.osv.dev; composer_warnings flags missing standard Packagist notification URLs, which are only a heuristic for origin. " +
91
+ "Each package's dependency_relation tells whether it is a direct or transitive dependency (determined for package-lock.json, pnpm-lock.yaml formats 5.3/5.4/6.0/9.0, Yarn Classic/Berry, go.mod, requirements.txt, pom.xml, Cargo.lock, .NET files and Composer; unknown where the format or captured metadata cannot establish it). pnpm uses locked importer declarations for declared_in and peer-context-specific dependency reachability for introduced_by; local links are not followed and unsupported/limited analysis remains unknown. Yarn derives origins/declarations from captured Classic manifests or Berry workspace entries; workspace coverage uses confirmed workspace membership and locked dependency descriptors, with ambiguous/unsupported cases unconfirmed. Yarn and project code are not executed. pnpm workspace coverage matches child project paths against captured ancestor lockfile importers; absent children are missing and unverifiable membership remains unconfirmed. " +
92
+ "Composer files are scanned from sanitized copies containing package names and release versions only; local dist.type=path packages are not looked up and are counted in local_composer_packages; branch/dev versions and invalid entries are excluded and reported in skipped_files. A safely captured companion composer.json defines direct require/require-dev roots; locked require edges establish introduced_by, and direct declarations use declared_in. Missing/uninterpretable companions, ambiguous providers/replacements or names, and analysis limits leave relationships unknown. Composer and PHP code are never run. Other private Composer package names are sent to api.osv.dev; composer_warnings flags missing standard Packagist notification URLs, which are only a heuristic for origin. " +
93
93
  "If coverage.complete is false, do not conclude that the project is safe even with zero findings. Upgrade recommendations (suggest_fix) are available for Java, JavaScript, Python, Go, Rust, PHP, and .NET (NuGet). Cargo.lock dependency graphs provide direct/transitive relations, introduced_by, and uniquely mapped declaring Cargo.toml paths in declared_in.",
94
94
  inputSchema: {
95
95
  project_path: z
@@ -121,7 +121,7 @@ server.registerTool("suggest_fix", {
121
121
  "(same_minor: same release line -> major_internal: same major version -> cross_major: major upgrade), " +
122
122
  "and the response gives the recommended version, the upgrade distance (upgrade_tier), and the fixed version per CVE. For npm, Go, PyPI, and Rust, a minor update within 0.x is also cross_major (may include breaking changes). " +
123
123
  "Dependencies scanned at a requirements.txt lower bound (>=) or a NuGet PackageReference minimum/range are marked version_is_lower_bound, and the recommendation then means raising the lower bound rather than confirming the installed version. " +
124
- "update_hint explains how to upgrade, depending on whether the package is a direct or transitive dependency (dependency_relation; for npm and Cargo.lock also introduced_by and declared_in). Cargo updates are conditional on parent version requirements; cross-major updates may require Cargo.toml changes. " +
124
+ "update_hint explains how to upgrade, depending on whether the package is a direct or transitive dependency (dependency_relation; for npm, Cargo.lock, Composer and NuGet also introduced_by and declared_in where evidence permits). NuGet hints name a confirmed declaring project or introducing direct packages; central management and SDK/runtime constraints still require verification. Composer hints name the declaring manifest for direct dependencies and the introducing roots for transitive dependencies; regenerate the lockfile in a trusted environment and verify constraints. Cargo updates are conditional on parent version requirements; cross-major updates may require Cargo.toml changes. " +
125
125
  "The recommended version is checked against api.osv.dev, and versions with known vulnerabilities that do not affect the current version are avoided (result in candidate_check). " +
126
126
  "clean excludes known unfixed advisories, known informational advisories that are not fix targets, and new Rust unmaintained/notice records; these may still apply. New unsound records are fix targets. " +
127
127
  "With has_known_vulnerabilities, failed, or skipped, the recommended version has not been confirmed safe; conflict means the data disagreed and the recommendation is withheld. " +
@@ -38,15 +38,24 @@ function updateHint(pkg) {
38
38
  const relation = pkg.dependency_relation;
39
39
  const by = pkg.introduced_by?.join(", ");
40
40
  switch (pkg.ecosystem) {
41
+ case "Packagist": {
42
+ if (relation === "direct")
43
+ return `Direct dependency. Update its constraint in ${pkg.declared_in?.join(", ") ?? "composer.json"}` +
44
+ (by ? `. It is also required transitively by ${by}, whose constraints may need updating` : "") +
45
+ ". Regenerate composer.lock in a trusted environment with Composer scripts and plugins disabled; check PHP, extension and dependency constraints. Composer is not executed by this server";
46
+ if (relation === "transitive")
47
+ return `Transitive dependency. Update ${by ? `the direct dependencies that require it (${by})` : "the direct dependency that requires it"} or adjust their constraints, then regenerate composer.lock with --with-all-dependencies --no-scripts --no-plugins in a trusted environment. Check PHP and extension requirements. Composer is not executed by this server`;
48
+ return UPDATE_HINTS.Packagist;
49
+ }
41
50
  case "NuGet": {
42
51
  if (/^Microsoft\.(?:NETCore|AspNetCore|WindowsDesktop)\.App\.Runtime\./i.test(pkg.name)) {
43
52
  return "Runtime pack. Update the .NET SDK/runtime or target framework that supplies this pack and regenerate restore/build outputs; do not add an explicit PackageReference merely to change the runtime pack. Check framework/RID compatibility and rescan. This server does not run dotnet restore or MSBuild";
44
53
  }
45
54
  const shared = "If centrally managed, change PackageVersion in Directory.Packages.props. Regenerate restore/lockfiles and rescan in a trusted environment; verify target-framework compatibility and dependency constraints. This server does not run dotnet restore or MSBuild";
46
55
  if (relation === "direct")
47
- return "Direct dependency. Update its PackageReference in the project file (or packages.config for older projects). " + shared;
56
+ return `Direct dependency. Update its PackageReference in ${pkg.declared_in?.join(", ") ?? "the project file"} (or packages.config for older projects). ` + shared;
48
57
  if (relation === "transitive")
49
- return "Transitive dependency. Update the direct dependency that requires it, or explicitly reference a compatible fixed version when NuGet dependency resolution permits it. " + shared;
58
+ return `Transitive dependency. Update ${by ? `the direct dependencies that require it (${by})` : "the direct dependency that requires it"}, or explicitly reference a compatible fixed version when NuGet dependency resolution permits it. ` + shared;
50
59
  return "Update the dependency version in the project file or packages.config; for a transitive dependency, update its parent or explicitly reference a compatible fixed version. " + shared;
51
60
  }
52
61
  case "npm":
@@ -56,7 +65,7 @@ function updateHint(pkg) {
56
65
  }
57
66
  if (relation === "transitive") {
58
67
  return `Transitive dependency. Update ${by ? `the direct dependency that requires it (${by})` : "the direct dependency that requires it"} to a version that requires the recommended version or later. ` +
59
- "If updating the direct dependency does not fix it, set the version with overrides in the root package.json (effective only in the root project)";
68
+ "If updating the direct dependency does not fix it, use the package manager's root override configuration to select a compatible fixed version, regenerate the lockfile, and rescan";
60
69
  }
61
70
  return UPDATE_HINTS.npm;
62
71
  case "Go": {
@@ -5,6 +5,7 @@
5
5
  * complete=falseの場合は「検出0件でも安全とは言えない」旨の警告を付ける。
6
6
  */
7
7
  import { writeFile } from "node:fs/promises";
8
+ import { createPnpmWorkBudget, pnpmLockRelations } from "../utils/pnpmRelations.js";
8
9
  import path from "node:path";
9
10
  import { ScanToolError } from "../errors.js";
10
11
  import { isRemoteResolutionDisabled, runOsvScan } from "../osv/runner.js";
@@ -165,20 +166,25 @@ const MAX_INTRODUCED_BY = 10;
165
166
  export async function buildRelationLookups(copies, maxTotalBytes = MAX_RELATION_LOCKFILE_BYTES) {
166
167
  const lookups = new Map();
167
168
  const npmWorkBudget = createNpmLockWorkBudget();
169
+ // pnpmの関係の解析の作業量はスキャン全体で共有する(Composer・NuGetと同じ)
170
+ const pnpmWorkBudget = createPnpmWorkBudget();
168
171
  let remaining = maxTotalBytes;
169
- for (const { copy, format, originalRelative, entries, cargoPackages, declaringManifests, nugetPackages } of copies) {
172
+ for (const { copy, format, originalRelative, entries, cargoPackages, declaringManifests, nugetPackages, nugetLookup, composerLookup, yarnLookup } of copies) {
170
173
  const lockDir = originalRelative === null ? "" : path.dirname(originalRelative);
171
174
  let lookup = null;
172
175
  try {
173
- if (format === "package-lock.json" || format === "go.mod") {
176
+ if (format === "package-lock.json" || format === "pnpm-lock.yaml" || format === "go.mod") {
174
177
  // 残りの予算を上限に、読み込みの途中で打ち切る(予算を超えるファイルは全体を読まずにunknownにする)
175
- const read = await readRegularFile(copy, { maxBytes: format === "package-lock.json" ? Math.min(MAX_NPM_LOCK_BYTES, remaining) : remaining });
178
+ const read = await readRegularFile(copy, { maxBytes: format !== "go.mod" ? Math.min(MAX_NPM_LOCK_BYTES, remaining) : remaining });
176
179
  if (read.ok) {
177
180
  remaining -= read.bytes.length;
178
181
  const text = read.bytes.toString("utf8");
179
- lookup = format === "go.mod" ? goModRelations(text) : npmLockRelations(parseNpmLock(text, npmWorkBudget));
182
+ lookup = format === "go.mod" ? goModRelations(text) : format === "pnpm-lock.yaml" ? pnpmLockRelations(text, pnpmWorkBudget) : npmLockRelations(parseNpmLock(text, npmWorkBudget));
180
183
  }
181
184
  }
185
+ else if (format === "yarn.lock") {
186
+ lookup = yarnLookup ?? null;
187
+ }
182
188
  else if (format === "pom.xml") {
183
189
  lookup = pomRelations(); // ファイルは読まず、osv-scannerのsource.typeで判定する
184
190
  }
@@ -189,7 +195,10 @@ export async function buildRelationLookups(copies, maxTotalBytes = MAX_RELATION_
189
195
  lookup = cargoLockRelations(cargoPackages, declaringManifests);
190
196
  }
191
197
  else if (format === "packages.lock.json" && nugetPackages !== undefined) {
192
- lookup = nugetRelations(nugetPackages);
198
+ lookup = nugetLookup ?? nugetRelations(nugetPackages);
199
+ }
200
+ else if (format === "composer.lock") {
201
+ lookup = composerLookup ?? null;
193
202
  }
194
203
  }
195
204
  catch {
@@ -301,6 +310,16 @@ export async function scanFromSnapshot(project, options) {
301
310
  throw new ScanToolError(skipped.every((s) => s.kind === "outside_allowed_root") ? "path_outside_allowed_root" : "no_manifest_found", `No lockfile or manifest can be scanned (${project.skippedFiles.map((s) => `${s.path}: ${s.reason}`).join(" / ")})`);
302
311
  }
303
312
  }
313
+ for (const copy of project.yarnCopies ?? []) {
314
+ const generated = await snapshot.writeGenerated(copy.content);
315
+ targets.push({ path: generated, format: "yarn.lock" });
316
+ relationSources.push({ copy: generated, format: "yarn.lock", originalRelative: copy.path, yarnLookup: copy.relations });
317
+ }
318
+ for (const copy of project.pnpmCopies ?? []) {
319
+ const generated = await snapshot.writeGenerated(copy.content);
320
+ targets.push({ path: generated, format: "pnpm-lock.yaml" });
321
+ relationSources.push({ copy: generated, format: "pnpm-lock.yaml", originalRelative: copy.path });
322
+ }
304
323
  for (const copy of project.requirementsCopies) {
305
324
  if (copy.entries.length === 0)
306
325
  continue;
@@ -319,12 +338,12 @@ export async function scanFromSnapshot(project, options) {
319
338
  for (const copy of project.nugetCopies) {
320
339
  const generated = await snapshot.writeGenerated(nugetLockCopyContent(copy.packages));
321
340
  targets.push({ path: generated, format: "packages.lock.json" });
322
- relationSources.push({ copy: generated, format: "packages.lock.json", originalRelative: copy.path, nugetPackages: copy.packages });
341
+ relationSources.push({ copy: generated, format: "packages.lock.json", originalRelative: copy.path, nugetPackages: copy.packages, nugetLookup: copy.relations });
323
342
  }
324
343
  for (const copy of project.composerCopies ?? []) {
325
344
  const generated = await snapshot.writeGenerated(copy.content);
326
345
  targets.push({ path: generated, format: "composer.lock" });
327
- relationSources.push({ copy: generated, format: "composer.lock", originalRelative: copy.path });
346
+ relationSources.push({ copy: generated, format: "composer.lock", originalRelative: copy.path, composerLookup: copy.relations });
328
347
  }
329
348
  if (targets.length === 0)
330
349
  return parseOsvScanOutput({ results: [] });
@@ -1,5 +1,6 @@
1
1
  import { parseNpmLock } from "./npmLockCopy.js";
2
2
  import { parseComposerVersion } from "./composerVersion.js";
3
+ import { composerLinkNames, composerPackageName } from "./composerRelations.js";
3
4
  const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4
5
  /** Only names and release versions reach OSV; repository URLs, scripts, and commit IDs are never forwarded. */
5
6
  export function composerLockCopy(text, budget) {
@@ -9,6 +10,7 @@ export function composerLockCopy(text, budget) {
9
10
  }
10
11
  const issues = [];
11
12
  const warnings = [];
13
+ const relationPackages = [];
12
14
  let uncertainOrigin = 0;
13
15
  // dist.type=pathは自分のコード(monorepoのパッケージ)。Rustのlocal_crates・.NETのlocal_projectsと同じく数だけ返し、範囲の欠けにしない
14
16
  let localPackages = 0;
@@ -17,11 +19,16 @@ export function composerLockCopy(text, budget) {
17
19
  const packages = [];
18
20
  let skipped = 0;
19
21
  for (const raw of (data[group] ?? [])) {
22
+ if (record(raw) && composerPackageName(raw.name)) {
23
+ const provides = composerLinkNames(raw.provide), replaces = composerLinkNames(raw.replace);
24
+ relationPackages.push({ name: raw.name, version: typeof raw.version === "string" ? raw.version : "",
25
+ requires: composerLinkNames(raw.require), aliases: provides === null || replaces === null ? null : [...provides, ...replaces] });
26
+ }
20
27
  if (record(raw) && record(raw.dist) && raw.dist.type === "path") {
21
28
  localPackages++;
22
29
  continue;
23
30
  }
24
- if (!record(raw) || typeof raw.name !== "string" || !/^[a-z0-9][a-z0-9_.-]*\/[a-z0-9][a-z0-9_.-]*$/.test(raw.name) || raw.name.length > 200 ||
31
+ if (!record(raw) || !composerPackageName(raw.name) ||
25
32
  typeof raw.version !== "string" || parseComposerVersion(raw.version) === null) {
26
33
  skipped++;
27
34
  continue;
@@ -36,5 +43,5 @@ export function composerLockCopy(text, budget) {
36
43
  }
37
44
  if (uncertainOrigin > 0)
38
45
  warnings.push(`${uncertainOrigin} Composer package entries lack the standard Packagist notification URL. Their names and versions are sent to api.osv.dev as Packagist packages; private or forked packages may produce false positives. The notification URL is only a heuristic and does not prove origin`);
39
- return { content: JSON.stringify(output), issues, warnings, packageCount: output.packages.length + output["packages-dev"].length, localPackages };
46
+ return { content: JSON.stringify(output), issues, warnings, packageCount: output.packages.length + output["packages-dev"].length, localPackages, relationPackages };
40
47
  }
@@ -0,0 +1,96 @@
1
+ import { MAX_DEPENDENCY_EDGES } from "./dependencyRelations.js";
2
+ import { parseNpmLock } from "./npmLockCopy.js";
3
+ export const createComposerRelationBudget = () => ({ remaining: MAX_DEPENDENCY_EDGES });
4
+ const record = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5
+ export const composerPackageName = (v) => typeof v === "string" && v.length <= 200 && /^[a-z0-9][a-z0-9_.-]*\/[a-z0-9][a-z0-9_.-]*$/.test(v);
6
+ /** Platform requirements such as php, ext-json and lib-* are not package edges. */
7
+ export function composerLinkNames(value) {
8
+ if (value === undefined)
9
+ return [];
10
+ if (!record(value))
11
+ return null;
12
+ const names = [];
13
+ for (const [name, constraint] of Object.entries(value)) {
14
+ if (typeof constraint !== "string")
15
+ return null;
16
+ const looksLikePackage = name.includes("/");
17
+ if (composerPackageName(name))
18
+ names.push(name);
19
+ else if (looksLikePackage)
20
+ return null;
21
+ }
22
+ return names;
23
+ }
24
+ /** Analyze only the captured companion manifest, never another root or a path repository's URL. */
25
+ export function composerRootRequirements(text, budget) {
26
+ const root = parseNpmLock(text, budget);
27
+ if (!record(root))
28
+ return null;
29
+ const production = composerLinkNames(root.require), dev = composerLinkNames(root["require-dev"]);
30
+ return production === null || dev === null ? null : [...new Set([...production, ...dev])];
31
+ }
32
+ /** Name-based reachability of the locked solution, not a Composer constraint solver.
33
+ * require-dev is root-only; suggest/conflict/replace/provide are not dependency edges.
34
+ * Provider/replacement ambiguity and duplicate installed names are never guessed.
35
+ */
36
+ export function composerRelations(packages, roots, budget = createComposerRelationBudget()) {
37
+ const charge = () => --budget.remaining >= 0;
38
+ const byName = new Map();
39
+ const aliased = new Set();
40
+ for (const pkg of packages) {
41
+ if (!charge() || pkg.aliases === null)
42
+ return null;
43
+ byName.set(pkg.name, byName.has(pkg.name) ? null : pkg);
44
+ for (const alias of pkg.aliases) {
45
+ if (!charge())
46
+ return null;
47
+ if (alias !== pkg.name)
48
+ aliased.add(alias);
49
+ }
50
+ }
51
+ const resolve = (name) => aliased.has(name) ? null : byName.get(name) ?? null;
52
+ const direct = new Set();
53
+ for (const name of roots) {
54
+ if (!charge())
55
+ return null;
56
+ if (resolve(name))
57
+ direct.add(name);
58
+ }
59
+ const introduced = new Map();
60
+ for (const origin of direct) {
61
+ const visited = new Set([origin]);
62
+ const queue = [origin];
63
+ while (queue.length > 0) {
64
+ if (!charge())
65
+ return null;
66
+ const current = resolve(queue.pop());
67
+ for (const name of current?.requires ?? []) {
68
+ if (!charge())
69
+ return null;
70
+ const target = resolve(name);
71
+ if (!target || visited.has(name))
72
+ continue;
73
+ visited.add(name);
74
+ queue.push(name);
75
+ let sources = introduced.get(name);
76
+ if (!sources)
77
+ introduced.set(name, sources = new Set());
78
+ sources.add(origin);
79
+ }
80
+ }
81
+ }
82
+ const results = new Map();
83
+ for (const [name, pkg] of byName) {
84
+ if (!charge())
85
+ return null;
86
+ if (!pkg || aliased.has(name))
87
+ continue;
88
+ const sources = introduced.get(name);
89
+ results.set(JSON.stringify([name, pkg.version]), {
90
+ relation: direct.has(name) ? "direct" : sources ? "transitive" : "unknown",
91
+ ...(direct.has(name) ? { declaredIn: new Set(["composer.json"]) } : {}),
92
+ ...(sources ? { introducedBy: sources } : {}),
93
+ });
94
+ }
95
+ return (name, version) => results.get(JSON.stringify([name, version])) ?? { relation: "unknown" };
96
+ }
@@ -10,6 +10,8 @@
10
10
  * 検出・除外の根拠はdocs/DESIGN_TODO.md「対象エコシステム拡大 詳細設計メモ」を参照。
11
11
  */
12
12
  import { stat } from "node:fs/promises";
13
+ import { PnpmLockCache } from "./pnpmLockCache.js";
14
+ import { YarnLockCache } from "./yarnLockCache.js";
13
15
  import path from "node:path";
14
16
  import { ScanToolError } from "../errors.js";
15
17
  import { isManifestFormat } from "./manifestFormats.js";
@@ -20,6 +22,7 @@ import { analyzeRequirementsFile, createRequirementsReadContext, } from "./requi
20
22
  import { cargoTomlPackageName, cargoWorkspace, workspaceIncludes, createGlobWorkBudget, classifyCargoPackages, createCargoLockParseBudget, parseCargoLock, UNSCANNABLE_CRATE_REASONS, } from "./cargoLock.js";
21
23
  import { analyzeNuget, collectNugetFile, emptyNugetFiles, NUGET_DEPS_JSON, NUGET_LOCKFILE, NUGET_PACKAGES_CONFIG, NUGET_PROJECT_ASSETS, NUGET_PROJECT_FILE, } from "./nugetProject.js";
22
24
  import { composerLockCopy } from "./composerLock.js";
25
+ import { composerRelations, composerRootRequirements, createComposerRelationBudget } from "./composerRelations.js";
23
26
  /** ファイル名→(エコシステム, 解析形式)。npm-shrinkwrap.jsonはpackage-lock.json形式で解析する */
24
27
  const LOCKFILES = new Map([
25
28
  ["pom.xml", { ecosystem: "Maven", format: "pom.xml" }],
@@ -233,14 +236,27 @@ async function npmLockfileRecords(cache, projectDir, lockfile, memberDir) {
233
236
  const key = path.relative(path.dirname(lockAbs), path.join(projectDir, memberDir)).split(path.sep).join("/");
234
237
  return keys.has(key);
235
238
  }
236
- /**
237
- * lockfileの無いマーカー(package.json等)を判定する。
238
- * - 同じディレクトリに同じエコシステムのlockfileがあれば充足
239
- * - 上位のlockfileだけの場合は、収録を確認できたときだけ充足(npm workspacesのpackage-lock.json)。
240
- * workspace設定の無いルートのlockfileで、独立した子プロジェクトの欠落を隠さないため
241
- */
242
- async function findLockfileMissing(projectDir, markers, manifests, cargoCache) {
239
+ /** pnpmのimporterの完全一致で収録を確認する。親や同名のpackageからは推測しない。 */
240
+ async function pnpmLockfileRecords(cache, projectDir, lockfile, memberDir) {
241
+ const lockAbs = path.join(projectDir, lockfile.path);
242
+ const captured = await cache.get(lockAbs, projectDir);
243
+ if ("reason" in captured || captured.importers === null)
244
+ return null;
245
+ const key = path.relative(path.dirname(lockAbs), path.join(projectDir, memberDir)).split(path.sep).join("/");
246
+ return captured.importers.has(key);
247
+ }
248
+ async function yarnLockfileRecords(cache, projectDir, lockfile, member, packageFiles) {
249
+ const absolute = path.join(projectDir, lockfile.path);
250
+ const captured = await cache.get(absolute, projectDir, packageFiles);
251
+ if ("reason" in captured)
252
+ return null;
253
+ const key = path.relative(path.dirname(absolute), path.join(projectDir, member)).split(path.sep).join("/");
254
+ return captured.members.get(key) ?? null;
255
+ }
256
+ /** 同じディレクトリのlockfile、または収録を確認できた上位lockfileだけで充足とする。 */
257
+ async function findLockfileMissing(projectDir, markers, manifests, cargoCache, pnpmCache, yarnCache) {
243
258
  const cache = new LockfileKeyCache();
259
+ const packageFiles = markers.filter(m => m.group === "npm").map(m => m.path);
244
260
  const missing = [];
245
261
  for (const marker of markers) {
246
262
  const markerDir = path.dirname(marker.path);
@@ -257,9 +273,13 @@ async function findLockfileMissing(projectDir, markers, manifests, cargoCache) {
257
273
  for (const candidate of candidates) {
258
274
  const result = candidate.format === "package-lock.json"
259
275
  ? await npmLockfileRecords(cache, projectDir, candidate, markerDir)
260
- : candidate.format === "Cargo.lock"
261
- ? await cargoLockfileRecords(cargoCache, projectDir, candidate, marker.path)
262
- : null;
276
+ : candidate.format === "pnpm-lock.yaml"
277
+ ? await pnpmLockfileRecords(pnpmCache, projectDir, candidate, markerDir)
278
+ : candidate.format === "yarn.lock"
279
+ ? await yarnLockfileRecords(yarnCache, projectDir, candidate, marker.path, packageFiles)
280
+ : candidate.format === "Cargo.lock"
281
+ ? await cargoLockfileRecords(cargoCache, projectDir, candidate, marker.path)
282
+ : null;
263
283
  if (result === true)
264
284
  recorded = true;
265
285
  if (result !== false)
@@ -280,6 +300,9 @@ async function findLockfileMissing(projectDir, markers, manifests, cargoCache) {
280
300
  }
281
301
  async function finalize(projectDir, manifests, markers, allowedRootReal, nugetFiles = emptyNugetFiles()) {
282
302
  const cargoCache = new CargoLockCache();
303
+ const pnpmCache = new PnpmLockCache();
304
+ const yarnCache = new YarnLockCache();
305
+ const packageFiles = markers.filter(m => m.group === "npm").map(m => m.path);
283
306
  const result = {
284
307
  projectDir,
285
308
  allowedRootReal,
@@ -288,11 +311,13 @@ async function finalize(projectDir, manifests, markers, allowedRootReal, nugetFi
288
311
  requirementsCopies: [],
289
312
  cargoCopies: [],
290
313
  composerCopies: [],
314
+ pnpmCopies: [],
315
+ yarnCopies: [],
291
316
  composerWarnings: [],
292
317
  composerLocalPackages: 0,
293
318
  localCrateCount: 0,
294
319
  unscannableCrates: [],
295
- lockfileMissing: await findLockfileMissing(projectDir, markers, manifests, cargoCache),
320
+ lockfileMissing: await findLockfileMissing(projectDir, markers, manifests, cargoCache, pnpmCache, yarnCache),
296
321
  requirementIssues: [],
297
322
  requirementReferences: [],
298
323
  lowerBounds: [],
@@ -306,6 +331,7 @@ async function finalize(projectDir, manifests, markers, allowedRootReal, nugetFi
306
331
  };
307
332
  const readContext = createRequirementsReadContext();
308
333
  const composerBudget = createNpmLockWorkBudget();
334
+ const composerRelationBudget = createComposerRelationBudget();
309
335
  let composerBytes = MAX_NPM_LOCK_TOTAL_BYTES;
310
336
  // A null value permanently marks a crate name with multiple declaration paths.
311
337
  const cargoManifests = new Map();
@@ -321,7 +347,23 @@ async function finalize(projectDir, manifests, markers, allowedRootReal, nugetFi
321
347
  }
322
348
  for (const manifest of manifests) {
323
349
  const absolute = path.join(projectDir, manifest.path);
324
- if (manifest.format === "composer.lock") {
350
+ if (manifest.format === "yarn.lock") {
351
+ const captured = await yarnCache.get(absolute, projectDir, packageFiles);
352
+ if ("reason" in captured) {
353
+ result.skippedFiles.push({ path: manifest.path, reason: captured.reason });
354
+ continue;
355
+ }
356
+ result.yarnCopies.push({ path: manifest.path, content: captured.content, relations: captured.lookup });
357
+ }
358
+ else if (manifest.format === "pnpm-lock.yaml") {
359
+ const captured = await pnpmCache.get(absolute, projectDir);
360
+ if ("reason" in captured) {
361
+ result.skippedFiles.push({ path: manifest.path, reason: captured.reason });
362
+ continue;
363
+ }
364
+ result.pnpmCopies.push({ path: manifest.path, content: captured.content });
365
+ }
366
+ else if (manifest.format === "composer.lock") {
325
367
  const read = await readRegularFile(absolute, { maxBytes: Math.min(MAX_NPM_LOCK_BYTES, composerBytes), boundary: projectDir });
326
368
  if (!read.ok) {
327
369
  result.skippedFiles.push({ path: manifest.path, reason: capitalizeReason(read.message) });
@@ -330,8 +372,24 @@ async function finalize(projectDir, manifests, markers, allowedRootReal, nugetFi
330
372
  composerBytes -= read.bytes.length;
331
373
  try {
332
374
  const parsed = composerLockCopy(read.bytes.toString("utf8"), composerBudget);
333
- if (parsed.packageCount > 0)
334
- result.composerCopies.push({ path: manifest.path, content: parsed.content });
375
+ if (parsed.packageCount > 0) {
376
+ let relations = null;
377
+ const companion = await readRegularFile(path.join(path.dirname(absolute), "composer.json"), {
378
+ maxBytes: Math.min(4 * 1024 * 1024, composerBytes), boundary: projectDir,
379
+ });
380
+ if (companion.ok) {
381
+ composerBytes -= companion.bytes.length;
382
+ try {
383
+ const roots = composerRootRequirements(companion.bytes.toString("utf8"), composerBudget);
384
+ if (roots !== null)
385
+ relations = composerRelations(parsed.relationPackages, roots, composerRelationBudget);
386
+ }
387
+ catch { /* A companion failure affects relationships, not the scanned lockfile. */ }
388
+ }
389
+ if (relations === null)
390
+ result.composerWarnings.push(`${manifest.path}: Dependency relationships are unknown because its companion composer.json is unavailable/uninterpretable or the dependency analysis limit was reached; locked release packages are still scanned`);
391
+ result.composerCopies.push({ path: manifest.path, content: parsed.content, relations });
392
+ }
335
393
  result.skippedFiles.push(...parsed.issues.map((reason) => ({ path: manifest.path, reason })));
336
394
  result.composerWarnings.push(...parsed.warnings.map((warning) => `${manifest.path}: ${warning}`));
337
395
  result.composerLocalPackages += parsed.localPackages;