osv-scanner-mcp 0.19.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
@@ -295,7 +295,7 @@ Detects the lockfiles and manifests in a project and scans the Java / JavaScript
295
295
  ```
296
296
 
297
297
  - **Always check `coverage`.** If `complete` is `false`, some dependencies were not scanned, so zero findings does not mean the project is safe
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), 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
299
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)
300
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
301
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`)
@@ -311,9 +311,21 @@ Detects the lockfiles and manifests in a project and scans the Java / JavaScript
311
311
  - `requirements.txt`: dependencies written in the file are direct; dependencies resolved through deps.dev are transitive
312
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`
313
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`
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`. `introduced_by` and `declared_in` are not available yet
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
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
- - 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`
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.
317
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
318
330
  - Upgrade recommendations (`suggest_fix`) are available for Maven, npm, PyPI, Go, Rust (crates.io), PHP (Packagist), and .NET (NuGet; source checkout).
319
331
 
@@ -493,7 +505,7 @@ For npm, Go, PyPI, and Rust, the "same release line" is the range a caret (`^`)
493
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.
494
506
  - Suggestions include an `update_hint`, tailored to whether the package is a direct or transitive dependency:
495
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
496
- - 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.
497
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.
498
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
499
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
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.19.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,9 +86,9 @@ 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, pom.xml, Cargo.lock, .NET files and Composer; unknown where the format or captured metadata cannot establish it). " +
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
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: {
@@ -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, Cargo.lock and Composer also introduced_by and declared_in). 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. " +
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. " +
@@ -53,9 +53,9 @@ function updateHint(pkg) {
53
53
  }
54
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";
55
55
  if (relation === "direct")
56
- 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;
57
57
  if (relation === "transitive")
58
- 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;
59
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;
60
60
  }
61
61
  case "npm":
@@ -65,7 +65,7 @@ function updateHint(pkg) {
65
65
  }
66
66
  if (relation === "transitive") {
67
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. ` +
68
- "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";
69
69
  }
70
70
  return UPDATE_HINTS.npm;
71
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, composerLookup } 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,7 @@ 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);
193
199
  }
194
200
  else if (format === "composer.lock") {
195
201
  lookup = composerLookup ?? null;
@@ -304,6 +310,16 @@ export async function scanFromSnapshot(project, options) {
304
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(" / ")})`);
305
311
  }
306
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
+ }
307
323
  for (const copy of project.requirementsCopies) {
308
324
  if (copy.entries.length === 0)
309
325
  continue;
@@ -322,7 +338,7 @@ export async function scanFromSnapshot(project, options) {
322
338
  for (const copy of project.nugetCopies) {
323
339
  const generated = await snapshot.writeGenerated(nugetLockCopyContent(copy.packages));
324
340
  targets.push({ path: generated, format: "packages.lock.json" });
325
- 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 });
326
342
  }
327
343
  for (const copy of project.composerCopies ?? []) {
328
344
  const generated = await snapshot.writeGenerated(copy.content);
@@ -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";
@@ -234,14 +236,27 @@ async function npmLockfileRecords(cache, projectDir, lockfile, memberDir) {
234
236
  const key = path.relative(path.dirname(lockAbs), path.join(projectDir, memberDir)).split(path.sep).join("/");
235
237
  return keys.has(key);
236
238
  }
237
- /**
238
- * lockfileの無いマーカー(package.json等)を判定する。
239
- * - 同じディレクトリに同じエコシステムのlockfileがあれば充足
240
- * - 上位のlockfileだけの場合は、収録を確認できたときだけ充足(npm workspacesのpackage-lock.json)。
241
- * workspace設定の無いルートのlockfileで、独立した子プロジェクトの欠落を隠さないため
242
- */
243
- 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) {
244
258
  const cache = new LockfileKeyCache();
259
+ const packageFiles = markers.filter(m => m.group === "npm").map(m => m.path);
245
260
  const missing = [];
246
261
  for (const marker of markers) {
247
262
  const markerDir = path.dirname(marker.path);
@@ -258,9 +273,13 @@ async function findLockfileMissing(projectDir, markers, manifests, cargoCache) {
258
273
  for (const candidate of candidates) {
259
274
  const result = candidate.format === "package-lock.json"
260
275
  ? await npmLockfileRecords(cache, projectDir, candidate, markerDir)
261
- : candidate.format === "Cargo.lock"
262
- ? await cargoLockfileRecords(cargoCache, projectDir, candidate, marker.path)
263
- : 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;
264
283
  if (result === true)
265
284
  recorded = true;
266
285
  if (result !== false)
@@ -281,6 +300,9 @@ async function findLockfileMissing(projectDir, markers, manifests, cargoCache) {
281
300
  }
282
301
  async function finalize(projectDir, manifests, markers, allowedRootReal, nugetFiles = emptyNugetFiles()) {
283
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);
284
306
  const result = {
285
307
  projectDir,
286
308
  allowedRootReal,
@@ -289,11 +311,13 @@ async function finalize(projectDir, manifests, markers, allowedRootReal, nugetFi
289
311
  requirementsCopies: [],
290
312
  cargoCopies: [],
291
313
  composerCopies: [],
314
+ pnpmCopies: [],
315
+ yarnCopies: [],
292
316
  composerWarnings: [],
293
317
  composerLocalPackages: 0,
294
318
  localCrateCount: 0,
295
319
  unscannableCrates: [],
296
- lockfileMissing: await findLockfileMissing(projectDir, markers, manifests, cargoCache),
320
+ lockfileMissing: await findLockfileMissing(projectDir, markers, manifests, cargoCache, pnpmCache, yarnCache),
297
321
  requirementIssues: [],
298
322
  requirementReferences: [],
299
323
  lowerBounds: [],
@@ -323,7 +347,23 @@ async function finalize(projectDir, manifests, markers, allowedRootReal, nugetFi
323
347
  }
324
348
  for (const manifest of manifests) {
325
349
  const absolute = path.join(projectDir, manifest.path);
326
- 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") {
327
367
  const read = await readRegularFile(absolute, { maxBytes: Math.min(MAX_NPM_LOCK_BYTES, composerBytes), boundary: projectDir });
328
368
  if (!read.ok) {
329
369
  result.skippedFiles.push({ path: manifest.path, reason: capitalizeReason(read.message) });
@@ -0,0 +1,164 @@
1
+ import { MAX_DEPENDENCY_EDGES } from "./dependencyRelations.js";
2
+ import { compareNugetVersions, parseNugetVersion } from "./nugetVersion.js";
3
+ export const createNugetGraphBudget = () => ({ remaining: MAX_DEPENDENCY_EDGES });
4
+ const record = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5
+ const text = (v) => typeof v === "string" ? v : "";
6
+ const split = (key) => { const i = key.lastIndexOf("/"); return i > 0 ? { name: key.slice(0, i), version: key.slice(i + 1) } : null; };
7
+ const dependencyList = (v, exact = false) => {
8
+ if (v === undefined)
9
+ return [];
10
+ if (!record(v))
11
+ return null;
12
+ const dependencies = [];
13
+ for (const [name, version] of Object.entries(v)) {
14
+ if (typeof version !== "string")
15
+ return null;
16
+ dependencies.push({ name, ...(exact ? { version } : {}) });
17
+ }
18
+ return dependencies;
19
+ };
20
+ /** One graph per locked TFM/RID; range constraints are not solved again. */
21
+ export function nugetLockGraphs(data) {
22
+ if (!record(data.dependencies))
23
+ return [];
24
+ return Object.values(data.dependencies).filter(record).map(framework => Object.entries(framework).flatMap(([name, raw]) => {
25
+ if (!record(raw) || !["Direct", "Transitive", "CentralTransitive", "Project"].includes(text(raw.type)))
26
+ return [];
27
+ return [{ name, version: text(raw.resolved), direct: raw.type === "Direct", dependencies: dependencyList(raw.dependencies) }];
28
+ }));
29
+ }
30
+ /** Graphs remain separated by target; deps.json dependencies carry exact versions. */
31
+ export function nugetTargetGraphs(data, kind) {
32
+ if (!record(data.targets))
33
+ return [];
34
+ const libraries = record(data.libraries) ? data.libraries : {};
35
+ const project = record(data.project) ? data.project : {};
36
+ const frameworks = record(project.frameworks) ? project.frameworks : {};
37
+ const frameworkIndex = new Map();
38
+ for (const [key, value] of Object.entries(frameworks)) {
39
+ if (!record(value))
40
+ continue;
41
+ for (const alias of new Set([key.toLowerCase(), text(value.targetAlias).toLowerCase()].filter(Boolean))) {
42
+ frameworkIndex.set(alias, frameworkIndex.has(alias) && frameworkIndex.get(alias) !== value ? null : value);
43
+ }
44
+ }
45
+ const graphs = [];
46
+ for (const [targetName, rawTarget] of Object.entries(data.targets)) {
47
+ if (!record(rawTarget))
48
+ continue;
49
+ const base = targetName.split("/")[0].toLowerCase();
50
+ const framework = frameworkIndex.get(base);
51
+ const direct = new Set(kind === "assets" && framework && record(framework.dependencies) ? Object.keys(framework.dependencies).map(n => n.toLowerCase()) : []);
52
+ const nodes = [];
53
+ const projectDependencies = [];
54
+ for (const [key, raw] of Object.entries(rawTarget)) {
55
+ const id = split(key), library = record(libraries[key]) ? libraries[key] : null;
56
+ if (!id || !record(raw))
57
+ continue;
58
+ const type = kind === "assets" ? text(raw.type) : text(library?.type);
59
+ if (!["package", "runtimepack", "project", "reference"].includes(type))
60
+ continue;
61
+ const name = type === "runtimepack" && id.name.toLowerCase().startsWith("runtimepack.") ? id.name.slice("runtimepack.".length) : id.name;
62
+ const dependencies = dependencyList(raw.dependencies, kind === "deps");
63
+ if (kind === "deps" && type === "project" && dependencies) {
64
+ for (const dependency of dependencies)
65
+ projectDependencies.push(dependency);
66
+ }
67
+ nodes.push({ name, version: id.version, lookupName: id.name, direct: (type === "package" || type === "runtimepack") && direct.has(name.toLowerCase()), dependencies });
68
+ }
69
+ if (kind === "deps") {
70
+ const roots = new Set();
71
+ for (const dep of projectDependencies) {
72
+ if (parseNugetVersion(dep.version))
73
+ roots.add(nugetGraphKey(dep.name, dep.version));
74
+ }
75
+ for (const node of nodes) {
76
+ const library = libraries[`${node.lookupName}/${node.version}`];
77
+ const type = record(library) ? library.type : null;
78
+ if (type !== "package" && type !== "runtimepack")
79
+ continue;
80
+ node.direct = parseNugetVersion(node.version) !== null && roots.has(nugetGraphKey(node.name, node.version));
81
+ }
82
+ }
83
+ graphs.push(nodes);
84
+ }
85
+ return graphs;
86
+ }
87
+ /** Exact package identity for merging target-specific reachability without crossing versions. */
88
+ export function nugetGraphKey(name, version) {
89
+ const parsed = parseNugetVersion(version);
90
+ return JSON.stringify([name.toLowerCase(), parsed ? [parsed.parts, parsed.prerelease] : version]);
91
+ }
92
+ /** Metadata enrichment only. Existing direct/transitive classification remains authoritative. */
93
+ export function nugetGraphOrigins(graphs, budget) {
94
+ const introduced = new Map();
95
+ const displayNames = new Map();
96
+ const tick = () => --budget.remaining >= 0;
97
+ for (const nodes of graphs) {
98
+ const names = new Map();
99
+ for (const node of nodes) {
100
+ if (!tick())
101
+ return null;
102
+ for (const name of new Set([node.name.toLowerCase(), (node.lookupName ?? node.name).toLowerCase()])) {
103
+ const entries = names.get(name) ?? [];
104
+ entries.push(node);
105
+ names.set(name, entries);
106
+ }
107
+ }
108
+ const resolve = (dep) => {
109
+ const entries = names.get(dep.name.toLowerCase()) ?? [];
110
+ if (dep.version === undefined)
111
+ return entries.length === 1 ? entries[0] : null;
112
+ let selected = null;
113
+ for (const entry of entries) {
114
+ if (!tick())
115
+ return null;
116
+ if (parseNugetVersion(dep.version) && parseNugetVersion(entry.version) && compareNugetVersions(dep.version, entry.version) === 0) {
117
+ if (selected)
118
+ return null;
119
+ selected = entry;
120
+ }
121
+ }
122
+ return selected;
123
+ };
124
+ for (const origin of nodes) {
125
+ if (!tick())
126
+ return null;
127
+ if (!origin.direct || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/.test(origin.name) || !parseNugetVersion(origin.version) || names.get(origin.name.toLowerCase())?.length !== 1)
128
+ continue;
129
+ const originKey = origin.name.toLowerCase();
130
+ if (!displayNames.has(originKey))
131
+ displayNames.set(originKey, origin.name);
132
+ const visited = new Set([origin]);
133
+ const queue = [origin];
134
+ while (queue.length) {
135
+ if (!tick())
136
+ return null;
137
+ for (const dep of queue.pop().dependencies ?? []) {
138
+ if (!tick())
139
+ return null;
140
+ const target = resolve(dep);
141
+ if (budget.remaining < 0)
142
+ return null;
143
+ if (!target || visited.has(target))
144
+ continue;
145
+ visited.add(target);
146
+ queue.push(target);
147
+ const key = nugetGraphKey(target.name, target.version);
148
+ const sources = introduced.get(key) ?? new Set();
149
+ sources.add(displayNames.get(originKey));
150
+ introduced.set(key, sources);
151
+ }
152
+ }
153
+ }
154
+ }
155
+ return introduced;
156
+ }
157
+ export function enrichNugetRelations(base, origins, declared) {
158
+ return (name, version, type) => {
159
+ const info = base?.(name, version, type) ?? { relation: "unknown" };
160
+ const introducedBy = origins?.get(nugetGraphKey(name, version));
161
+ const manifest = info.relation === "direct" ? declared.get(name.toLowerCase()) : undefined;
162
+ return { ...info, ...(introducedBy?.size ? { introducedBy } : {}), ...(manifest ? { declaredIn: new Set([manifest]) } : {}) };
163
+ };
164
+ }
@@ -11,6 +11,7 @@
11
11
  import { ScanToolError } from "../errors.js";
12
12
  import { createNpmLockWorkBudget, parseNpmLock } from "./npmLockCopy.js";
13
13
  import { decodeEntities } from "./pomParent.js";
14
+ import { nugetLockGraphs, nugetTargetGraphs } from "./nugetDependencyGraph.js";
14
15
  /** NuGetの版の形式(4つ目の数・プレリリース・ビルドメタデータを含む)。これ以外は確定版とみなさない */
15
16
  const NUGET_VERSION = /^\d{1,9}(?:\.\d{1,9}){0,3}(?:-[0-9A-Za-z][0-9A-Za-z.-]{0,63})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
16
17
  /** NuGetのパッケージIDに使える文字 */
@@ -94,7 +95,7 @@ export function parsePackagesLock(source, budget = createNpmLockWorkBudget()) {
94
95
  out.unscannable.push({ name: name.slice(0, 200), version: text(entry.resolved).slice(0, 100), reason: "unknown_type" });
95
96
  }
96
97
  }
97
- return { ok: true, ...out, packages: dedupe(out.packages), localProjects: localProjects.size };
98
+ return { ok: true, ...out, packages: dedupe(out.packages), localProjects: localProjects.size, graphs: nugetLockGraphs(data) };
98
99
  }
99
100
  const RUNTIME_PACK_PREFIX = "runtimepack.";
100
101
  /** `Name/Version`形式のライブラリのキー */
@@ -154,6 +155,7 @@ export function parseProjectAssets(source, budget = createNpmLockWorkBudget()) {
154
155
  // Windowsのパスも区切る
155
156
  projectFileName: projectPath === "" ? null : projectPath.split(/[\\/]/).pop() || null,
156
157
  projectName: text(restore.projectName) || null,
158
+ graphs: nugetTargetGraphs(data, "assets"),
157
159
  };
158
160
  }
159
161
  /**
@@ -211,7 +213,7 @@ export function parseDepsJson(source, budget = createNpmLockWorkBudget()) {
211
213
  out.unscannable.push({ name: id.name, version: id.version, reason: "unknown_type" });
212
214
  }
213
215
  }
214
- return { ok: true, ...out, packages: dedupe(out.packages) };
216
+ return { ok: true, ...out, packages: dedupe(out.packages), graphs: nugetTargetGraphs(data, "deps") };
215
217
  }
216
218
  const MAX_XML_ELEMENTS = 200_000;
217
219
  const MAX_XML_DEPTH = 64;
@@ -14,7 +14,8 @@ import path from "node:path";
14
14
  import { ScanToolError } from "../errors.js";
15
15
  import { capitalizeReason, readRegularFile } from "./safeRead.js";
16
16
  import { createNpmLockWorkBudget } from "./npmLockCopy.js";
17
- import { analyzeProjectFile, hasNonNugetOrgSource, packageReferenceNames, parseDepsJson, parsePackagesConfig, parsePackagesLock, parseProjectAssets, parseXml, } from "./nugetFiles.js";
17
+ import { analyzeProjectFile, hasNonNugetOrgSource, packageReferenceNames, parseDepsJson, parsePackagesConfig, parsePackagesLock, parseProjectAssets, parseXml, nugetRelations, } from "./nugetFiles.js";
18
+ import { createNugetGraphBudget, enrichNugetRelations, nugetGraphOrigins } from "./nugetDependencyGraph.js";
18
19
  export const NUGET_LOCKFILE = /^packages(?:\.(.+))?\.lock\.json$/i;
19
20
  export const NUGET_PACKAGES_CONFIG = /^packages(?:\.(.+))?\.config$/i;
20
21
  export const NUGET_DEPS_JSON = /^.+\.deps\.json$/i;
@@ -137,6 +138,12 @@ export async function analyzeNuget(projectDir, files) {
137
138
  let lowerBounds = false;
138
139
  let directOnly = false;
139
140
  const staleAssets = [];
141
+ const sourceOwners = new Map();
142
+ const recordOwner = (file, owner) => {
143
+ const owners = sourceOwners.get(file) ?? new Set();
144
+ owners.add(owner);
145
+ sourceOwners.set(file, owners);
146
+ };
140
147
  const addCopy = (file, format, outcome) => {
141
148
  let packages = outcome.packages;
142
149
  if (packages.length > packageBudget) {
@@ -153,7 +160,7 @@ export async function analyzeNuget(projectDir, files) {
153
160
  result.unscannable.push(...outcome.unscannable.map((u) => ({ file, ...u })));
154
161
  result.manifests.push({ path: file, format });
155
162
  if (packages.length > 0)
156
- result.copies.push({ path: file, format, packages });
163
+ result.copies.push({ path: file, format, packages, graphs: outcome.graphs });
157
164
  };
158
165
  const parseFile = async (file, maxBytes, parse) => {
159
166
  const read = await reader.read(file, maxBytes);
@@ -239,8 +246,10 @@ export async function analyzeNuget(projectDir, files) {
239
246
  continue;
240
247
  addCopy(file, format, parsed);
241
248
  const owner = ownerOf(file, pattern, projectsByDir, customLockPath, chargeAssociation);
242
- if (owner !== null)
249
+ if (owner !== null) {
243
250
  covered.add(owner);
251
+ recordOwner(file, owner);
252
+ }
244
253
  else if (format === "packages.lock.json")
245
254
  unownedLockDirs.add(path.dirname(file));
246
255
  }
@@ -275,6 +284,7 @@ export async function analyzeNuget(projectDir, files) {
275
284
  : assets.projectName !== null && assets.projectName.toLowerCase() === projectBaseName(project);
276
285
  if (matches) {
277
286
  assetsAddCopy(assetsFile, assets);
287
+ recordOwner(assetsFile, project);
278
288
  covered.add(project);
279
289
  const [assetsStat, projectStat] = await Promise.all([
280
290
  stat(path.join(projectDir, assetsFile)).catch(() => null),
@@ -303,7 +313,53 @@ export async function analyzeNuget(projectDir, files) {
303
313
  if (analysis.references.some((r) => r.lowerBound))
304
314
  lowerBounds = true;
305
315
  addCopy(project, "PackageReference", { ok: true, packages: analysis.references, localProjects: 0, unscannable: [] });
316
+ recordOwner(project, project);
317
+ }
318
+ const graphBudget = createNugetGraphBudget();
319
+ let limitedGraphs = 0;
320
+ const declarationCache = new Map();
321
+ for (const copy of result.copies) {
322
+ const declared = new Map();
323
+ const owners = sourceOwners.get(copy.path);
324
+ if (owners?.size === 1 && graphBudget.remaining > 0) {
325
+ const owner = [...owners][0];
326
+ const analysis = projectAnalyses.get(owner);
327
+ let names = declarationCache.get(owner);
328
+ if (!names && analysis?.ok) {
329
+ names = new Set();
330
+ for (const ref of analysis.references) {
331
+ if (--graphBudget.remaining < 0)
332
+ break;
333
+ names.add(ref.name.toLowerCase());
334
+ }
335
+ for (const ref of analysis.unresolved) {
336
+ if (--graphBudget.remaining < 0)
337
+ break;
338
+ if (["property", "range", "floating", "central_package_management"].includes(ref.reason) && /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/.test(ref.name))
339
+ names.add(ref.name.toLowerCase());
340
+ }
341
+ if (graphBudget.remaining < 0)
342
+ names.clear();
343
+ declarationCache.set(owner, names);
344
+ }
345
+ if (names)
346
+ for (const pkg of copy.packages) {
347
+ if (--graphBudget.remaining < 0) {
348
+ declared.clear();
349
+ break;
350
+ }
351
+ if (pkg.relation === "direct" && names.has(pkg.name.toLowerCase()))
352
+ declared.set(pkg.name.toLowerCase(), path.relative(path.dirname(copy.path), owner));
353
+ }
354
+ }
355
+ const origins = nugetGraphOrigins(copy.graphs ?? [], graphBudget);
356
+ if (origins === null || graphBudget.remaining < 0)
357
+ limitedGraphs++;
358
+ copy.relations = enrichNugetRelations(nugetRelations(copy.packages), origins, declared);
359
+ delete copy.graphs;
306
360
  }
361
+ if (limitedGraphs > 0)
362
+ result.warnings.push(`NuGet relationship analysis reached its per-scan work limit for ${limitedGraphs} source(s); introduced_by/declared_in may be unavailable. Package vulnerability scanning still ran`);
307
363
  if (staleAssets.length > 0) {
308
364
  // プロジェクトごとに1件ずつ並べると応答が際限なく大きくなるため、1件にまとめる
309
365
  const listed = staleAssets.slice(0, MAX_STALE_LISTED).join(", ");