Skip to content

Commit 2df7fe6

Browse files
bomly-guyclaude
andauthored
feat(scan): subproject & module presentation with per-module manifests (#270)
* feat(output): derive project/subproject/module hierarchy from scan manifests Adds output.BuildHierarchy/ClassifyManifest: a pure presentation-layer grouping of scan manifests into project → subproject (independently discovered nested directory) → module (workspace/reactor member whose manifest path sits beneath its subproject directory). No schema changes — the hierarchy derives entirely from the existing manifest subproject and path fields. Consumers wired in this commit: - Text report: renders a grouped manifest tree when a scan spans subprojects or modules; flat scans keep the compact report. The "in M manifests" count now uses the real manifest count instead of graph roots (which undercounts once module roots have inbound edges), and the misleading "Discovered 1 subproject: . (npm)" banner on plain root scans is gone (BuildSubprojectSummary removed; the tree supersedes it). - Markdown: manifest table grouped by hierarchy location; package totals and the dependency inventory now dedup shared packages by PURL/ID instead of double-counting across manifests. - MCP compact scan: additive subprojects/modules summary counts (omitted for flat scans; mcp/1 unchanged). - Progress: resolved-subproject children show a manifest count when a resolution produced multiple entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tui): subproject and module nodes in scan and diff views The scan components tree and manifest list now group manifests using the derived hierarchy: root-level manifests attach directly under the project node, with sibling "subproject" nodes (independently discovered nested directories) and "module" nodes (workspace/reactor members), each holding their own manifests and components. Flat single-root scans keep exactly the previous tree shape. Group nodes are string-keyed ("subproject:<dir>", "module:<dir>"), default expanded, and collapsible like any other node; the tree glyph helper is now ancestors-aware instead of hardcoding two levels. Also: project node title carries subproject/module counts; manifest details rename "Relative path" to "Subproject" and add a "Module dir" line; the Source tab gains a subprojects section and per-manifest subproject/module attributes; the diff components tab group-by cycle gains a "subproject" axis (status → manifest → subproject → ecosystem) whose details pane lists the manifests the group spans. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(npm,pnpm): emit one manifest per workspace member Workspace lockfiles now yield one manifest entry per member alongside the root entry, so views and JSON present each workspace member as a module with its own manifest path (<member-dir>/package.json) and its reachable dependency subtree. - Shared detectors.SubgraphFrom(g, rootID) helper extracts a module root's reachable subtree from a merged graph (cycle- and diamond-safe). - npm: the v2/v3 packages-map parser is now workspace-aware. Member keys (non-node_modules paths) become application root nodes, node_modules link entries resolve onto the member node instead of synthesizing duplicate versionless packages, and member devDependencies produce edges with development scope (previously all dropped or mangled). - pnpm: non-"." importers are wired (previously silently dropped — member direct-dependency edges never attached), each with its own application root named from the member package.json; workspace "link:" version specs resolve to the target importer's root node. - Non-workspace lockfiles keep exactly one entry (regression-guarded); consolidation counts a shared transitive dependency once across module entries (regression test). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cargo): emit one manifest per workspace member Cargo workspaces now yield one manifest entry per member (<member-dir>/Cargo.toml plus the member's reachable dependency subtree). Single-package projects keep exactly one entry. - cargo metadata path: partitions the resolved graph by workspace_members using each package's manifest_path; the synthesized virtual workspace root never leaks into entries. - Cargo.lock path (bug fix included): the root Cargo.toml's [workspace] members are parsed (inline/multiline arrays, glob expansion) and member-aware resolution kicks in. With cargo on PATH it prefers `cargo metadata --locked` (deterministic given the lock); without it, each member's Cargo.toml is parsed and the lock graph is partitioned by member package names, with member manifest dependency lists annotating direct-edge scopes. Virtual workspace roots (workspace-only Cargo.toml) previously failed the lock path with "cargo.toml does not contain a package name" and fell back to syft — they now resolve natively. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(maven): emit one manifest per reactor module Multi-module Maven reactors now yield one manifest entry per module (<module-dir>/pom.xml plus the module's reachable dependency subtree, including inter-module dependencies) alongside a root entry for unmatched graph roots such as the aggregator pom's own node. `mvn dependency:tree -DoutputType=tgf` output carries no directory paths, so a new pom walk (walkPomModules, internal/detectors/maven/ modules.go) recursively reads <modules> declarations, resolving each module pom's groupId:artifactId (groupId falls back to <parent>), depth-capped and cycle-safe. Graph roots are matched to modules by coordinates; unmatched roots stay in the root entry so nothing is ever dropped, and any walk failure degrades to today's single merged manifest. Per-module pom positions are attached from each module's own pom.xml. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(smoke): workspace and reactor smoke cases with per-module goldens Four new pinned fixture repos (all tagged v1.0.0): - bomly-dev/example-javascript-npm-workspaces — npm workspace: root dep, two members sharing pinned vulnerable deps, workspace link, member devDependency. Golden shows apps/web + packages/lib manifest entries alongside the root lockfile. - bomly-dev/example-javascript-pnpm-workspaces — same shape through pnpm-lock.yaml importers. - bomly-dev/example-rust-cargo-workspace — virtual workspace root (no [package]) with two members, one depending on the other; exercises the Cargo.lock partitioning path without a cargo binary and proves virtual roots resolve natively. - bomly-dev/example-java-maven-multimodule — pom-packaging parent with core + web modules, web depending on core; golden shows per-module entries plus the parent root entry. Existing single-module goldens (scan-npm, scan-cargo, scan-maven, scan-recursive-monorepo) verified unchanged against the new detectors. Also fixes maven module matching to consider all graph nodes, not only roots — a module consumed by a sibling (web -> core) has inbound edges and would otherwise lose its manifest entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: subproject and module presentation - SCAN_TARGETS: new "Subprojects and modules in scan output" section — terminology, which detectors emit per-module manifests vs a merged root manifest, and the JSON derivation rule. - MODELS: manifests collection carries one entry per workspace/reactor module; hierarchy derivation documented for JSON consumers. - MCP: compact summary subprojects/modules counts. - TUI: components tree grouping (tab label was stale as "Packages"). - Architecture decision log: subproject/module terminology + view-side derivation; per-module emission lives in detectors (consolidation stays select/dedup/rebase) with the deferred-detector list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tui): merge project/module nodes with their manifests A project/module and its manifest are two faces of the same thing, so presenting them as separate tree levels was confusing. In the scan components tree and manifest list, a directory holding exactly one manifest now renders as a single node: labeled with the package's own name (web, core-lib) plus a directory hint, subtitled by its kind (project/subproject/module), with the full manifest, detector, and dependency metadata moved into the details pane. Components nest directly beneath the merged node — including for flat single-manifest scans, where the project node absorbs the root manifest. Directories holding several manifests (e.g. a root with a lockfile and GitHub Actions workflows) keep explicit manifest child rows for disambiguation. The component glyph helper gains a subtreeLast dimension so a merged project's component block draws correct continuation bars when module nodes follow it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(render): merged node lines and all-module top-level dependencies Text report: single-manifest groups render as merged nodes named by the package itself with the manifest path as a hint — "dev.bomly.example:core (module, maven) — 2 packages [core/pom.xml]" — matching the interactive tree's merged model. Top-level dependencies now cover every module: the direct-dependency classification treats graph roots plus all application-type nodes as top-level parents, so a module consumed by a sibling (web -> core) no longer hides its direct dependencies as "transitive". Maven reactor module roots are typed as applications when matched (TGF output carries no type), which the npm/pnpm/cargo member roots already were; scan JSON is unaffected (dependency type is not serialized) and existing goldens verified unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(render): markdown Name column and project-structure mermaid diagrams Markdown manifest table gains a Name column carrying the package's own name per merged node (derived via output.ManifestRootName), matching the merged project/module presentation of the interactive and text views. Architecture docs gain mermaid diagrams making the model explicit: docs/ARCHITECTURE.md shows how projects discover subprojects, how workspace/reactor expansion produces modules, and how each manifest's graph root leads to dependency instances that deduplicate into PURL-keyed packages; dev-docs/MODELS.md carries the SDK-level variant with the exact types and the ClassifyManifest derivation rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(render): nest modules under their parent manifest in the text tree - BuildHierarchy resolves which parent manifest natively produced each module (matched by package manager, falling back to ecosystem, then a sole parent manifest) via the new AttachedManifest field; hidden directories (.github/workflows) no longer classify as modules. - Text tree: modules nest under the manifest that resolves them, and manifest lines are named after their package with the path bracketed: "dev.bomly.example:multimodule-parent — 1 package, 2 modules [pom.xml]" with core/web nested beneath. Proper pluralization. - Report header: relationship and scope distributions share one line ("(2 direct, 2 transitive · runtime 5, dev 0)"), and the Enriched line is blank-line separated from the counts block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tui): absorb module roots into merged nodes; overview and target polish - Components tree: a merged project/module node now stands in for its graph root — expanding it reveals the root's direct dependencies as a top-level forest instead of a duplicative "name@version ROOT" row (the project's own ROOT row is unchanged). Merged node brackets carry the manifest path; a subproject/module with child modules merges too, nesting them beneath it before its dependency forest, mirroring the text tree. - Overview: the Relationship and Scope Distribution panes label rows by full relative manifest path — three reactor "pom.xml" rows are now distinguishable. - Git URL targets name themselves after the repository (last URL segment, .git trimmed) instead of the full URL; the URL remains the descriptor's Path. Applies everywhere the project name shows (TUI top bar, Target pane, reports). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(render,tui): dependency counts always sum consistently The report header counted every graph node as a "package" while the relationship split excluded roots and the scope split counted scoped nodes only, so "7 packages (2 direct, 2 transitive · runtime 5, dev 0)" never added up. Project and module nodes are structure, not packages: - Header total is now direct + transitive (structural nodes — graph roots and application-type module roots — excluded), and scope counts cover exactly the same node set with an "unscoped N" bucket shown when non-zero. total = direct + transitive = runtime + dev (+ unscoped) by construction. - Manifest tree lines count dependencies only ("multimodule-parent — 0 packages, 2 modules"; the web module no longer counts the core module it depends on as one of its packages). - TUI merged project/module nodes count the components beneath them, excluding the absorbed root row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tui): modules branch from their parent root; parent-first ordering - Components tree: module nodes now branch out of the ROOT component row of the manifest that resolves them (matched per package manager via the group's attachedTo), instead of hanging off the project node. The parent root leads the tree and defaults to expanded when it carries modules, so the reactor/workspace structure is visible at a glance; collapsing the parent hides its modules. Roots hidden by the active relationship/scope/severity filters fall back to the flat forest. The same nesting applies inside multi-manifest groups and the Manifests tab list. - Manifest rows sort shortest-path-first, so the parent manifest (pom.xml) precedes its modules (core/pom.xml, web/pom.xml) in the Overview Relationship/Scope Distribution panes and every other manifest listing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tui): project node describes the target; root node owns the manifest - The PROJECT node's details pane now describes the scan target only: the target info plus a Manifests section listing everything found inside it (root manifests with their module nodes nested, subprojects with theirs). The root manifest's Manifest/Detector/Dependencies sections no longer leak onto the project node. - The merged project's ROOT component row owns the root manifest: its details pane carries the component sections, the full manifest and detector metadata, and a Modules section listing the module nodes branching out of it. - Dropped the inaccurate "Press Enter to view components for this manifest." hint from manifest details (tree nodes expand with →). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tui): unified details layout for all merged nodes The project root, module, and subproject nodes are the same thing — an internal package standing on a manifest — so their details panes now share one layout (mergedNodeDetails): node identity (name, directory, components, description), a compact "Root package" section for the internal application itself (name, PURL, licenses, vulnerability summary — internal packages are rarely enriched, but a custom plugin wired to an internal advisory source can attach data, so both surface when present), the manifest and detector metadata, dependency counts, and a Modules section for nodes carrying module children. This replaces the project root's previous details dump, which stacked the full component sections (dependencies, dependents, vulnerabilities, licenses) on top of the manifest sections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b8cf529 commit 2df7fe6

57 files changed

Lines changed: 5456 additions & 298 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

dev-docs/ARCHITECTURE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,14 @@ The MCP server does not return the CLI JSON documents at all. Tool results land
132132

133133
Discovery rules are **detector-owned, not hardcoded**: each detector declares its ecosystem's ignore rules on its descriptor (`sdk.DetectorDescriptor.IgnoredDirectories` basename globs and `IgnoredDirectoryMarkers` marker files such as `pyvenv.cfg`) and marks workspace-expanding support entries with `sdk.PackageManagerSupport.MultiModule` (via `sdk.Support(...).WithMultiModule()`). Discovery aggregates the union across every registered detector (`discoveryRulesFromDetectors`), so external detector plugins contribute rules exactly like built-ins — the fields ride the existing descriptor JSON, making them backward compatible with the v1 plugin protocol (older plugins simply omit them). The walk aggregates from the request's **unfiltered** registry so `--detectors`/`--ecosystems` filters never change which directories are walked; the diagnostic probe falls back to the static built-in catalog (`registry.BuiltinDetectors`). Dot-directory skipping stays core walk behavior, independent of detector declarations.
134134

135+
### Decision: Subprojects and modules are distinct concepts, derived in views
136+
137+
A **subproject** is an independently discovered nested directory (its own discovery-time `sdk.Subproject`, `RelativePath != "."`); a **module** is a member the package manager natively resolves under one root manifest (reactor module, workspace member). The hierarchy is never stored: `output.ClassifyManifest`/`BuildHierarchy` (`internal/output/hierarchy.go`) derive it purely from each manifest's `Subproject` and repo-relative `Path` — a manifest whose directory sits below its subproject directory is a module manifest. Every surface (TUI trees, text report tree, markdown table, MCP compact counts) consumes the same helper, so the JSON schema gained no fields and consumers can apply the identical rule. The scan JSON's per-manifest `subproject` string plus `path` is therefore the single source of truth for project structure.
138+
139+
### Decision: Per-module manifest emission lives in detectors, not consolidation
140+
141+
Workspace/reactor detectors (npm and pnpm lockfile, cargo, maven) emit one `GraphEntry{Graph, ManifestMetadata}` per module using the pre-existing multi-entry `sdk.GraphContainer` — no SDK type changes. Each module entry carries the module's application root plus its reachable subtree (`detectors.SubgraphFrom`), with paths subproject-relative so consolidation's existing rebase/dedup layer stays a pure select/dedup/rebase stage. Shared transitives appear in multiple entries by design; the merged graph and PURL registry deduplicate them, and report-level counts (text manifest count, markdown/MCP package totals) deduplicate by PURL/ID rather than summing per-manifest lengths. Module directories come from the best per-ecosystem source: npm packages-map member keys, pnpm importer keys, `cargo metadata` member `manifest_path` (or `[workspace] members` globs + member `Cargo.toml`s on the lock path — which also fixed virtual workspace roots erroring), and a recursive pom `<modules>` walk for maven (TGF output carries no paths; unmatched graph roots fall back into the root entry, and any walk failure degrades to today's single merged manifest). **Deferred, degrade to one merged root manifest**: gradle and sbt (no machine-readable per-module graph in one invocation), mix and pub (low value/tool limits), yarn classic (v1 lockfiles carry no member info; berry is a follow-up), and the node *native* detectors (`npm ls` is root-scoped; per-member subprocess fan-out multiplies runtime while the lockfile detectors are the chain primaries anyway).
142+
135143
### Decision: Package locations are detector-relative today
136144

137145
`PackageLocation.Position.File` is emitted by detectors in the coordinate space of the detector working directory. For single-root projects that is already repository-relative, which lets `bomly diff` compare SARIF locations with repo-relative changed-line ranges.

dev-docs/MODELS.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,46 @@ flowchart TD
2626
F -->|PackageRef + VulnerabilityID| P
2727
```
2828

29+
## Project structure: projects, subprojects, modules, manifests
30+
31+
How dependencies relate to manifests, and manifests to the project tree. A
32+
**subproject** is an independently discovered nested directory (its own
33+
`sdk.Subproject`, what `--recursive` finds); a **module** is a member the
34+
package manager natively resolves under one root manifest (reactor module,
35+
workspace member) and gets its own manifest entry from the detector. A
36+
project/module and its manifest are two faces of the same thing — user-facing
37+
views merge them into one node when the mapping is 1:1; machine formats
38+
(JSON, SARIF, SBOM) keep the flat manifests collection.
39+
40+
```mermaid
41+
flowchart TD
42+
PR["Project (scan root)"]
43+
SP["Subproject<br/><i>independently discovered nested dir</i><br/>sdk.Subproject, RelativePath != &quot;.&quot;"]
44+
MOD["Module<br/><i>workspace/reactor member</i><br/>manifest dir below its subproject dir"]
45+
MAN["Manifest entry<br/>sdk.GraphEntry{Graph, ManifestMetadata}<br/>path, kind, resolution"]
46+
ROOT["Module root node<br/>sdk.Dependency, Type=application<br/><i>the project/module's own package</i>"]
47+
DEP["Dependency instances<br/>sdk.Dependency (direct + transitive)"]
48+
PKG["sdk.Package registry<br/>deduplicated by PURL"]
49+
50+
PR -->|"discovers (recursive walk)"| SP
51+
PR -->|"root manifests attach directly"| MAN
52+
SP -->|"its own manifests"| MAN
53+
SP -->|"native expansion (npm, pnpm, cargo, maven)"| MOD
54+
PR -->|"native expansion at the root"| MOD
55+
MOD -->|"exactly one"| MAN
56+
MAN -->|"graph root"| ROOT
57+
ROOT -->|"reachable subtree"| DEP
58+
DEP -->|"PURL identity (shared transitives dedup)"| PKG
59+
```
60+
61+
Derivation rule (implemented once in `output.ClassifyManifest` /
62+
`BuildHierarchy`, consumed by every view): `dir(manifest.path)` equal to the
63+
manifest's `subproject` directory → the manifest belongs directly to that
64+
subproject (or the project when `"."`); `dir(manifest.path)` nested beneath it
65+
→ a module keyed by that directory. Shared transitive dependencies appear in
66+
every module entry that reaches them; the PURL-keyed registry counts each
67+
package once.
68+
2969
## `sdk.Dependency` — detection node
3070

3171
```go
@@ -205,6 +245,17 @@ dependencies are **lean** — they carry detection-time facts and a `package_ref
205245
into `packages`, but no inlined vulnerabilities/scorecard. Enrichment lives once,
206246
in `packages`, and is resolved by PURL.
207247

248+
For workspace/reactor package managers (npm, pnpm, cargo, maven) the manifests
249+
collection carries **one entry per module** — e.g. `apps/web/package.json`
250+
alongside the root `package-lock.json` — each listing the module's reachable
251+
dependency instances (shared transitives appear under every module that
252+
reaches them; `packages` still deduplicates by PURL). Consumers derive the
253+
project hierarchy from the existing fields without schema additions: each
254+
manifest's `subproject` names its discovery directory ("." for the scan
255+
root), and a manifest whose `path` directory sits *below* its subproject
256+
directory is a **module** manifest (`output.ClassifyManifest` /
257+
`output.BuildHierarchy` implement this rule for every built-in view).
258+
208259
```jsonc
209260
{
210261
"schema_version": "1.0",

docs/ARCHITECTURE.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,29 @@ flowchart TD
6464

6565
In the JSON output these surface as three top-level collections — `manifests` (with their `dependencies`), `packages`, and `findings` — and the same vocabulary carries through SARIF and SBOM output. See [Output formats](OUTPUT_FORMATS.md) and the [schema reference](SCHEMAS.md) for the exact shapes.
6666

67+
Manifests themselves hang off the project structure. A **subproject** is an independently discovered nested directory (what `--recursive` finds); a **module** is a workspace or reactor member the package manager resolves natively (npm/pnpm workspace packages, Cargo workspace members, Maven reactor modules), each with its own manifest entry. A project or module and its manifest are two faces of the same thing, so user-facing views (interactive mode, text, markdown) merge them into a single named node; machine formats keep the flat `manifests` collection, from which the hierarchy is derived using the `subproject` and `path` fields.
68+
69+
```mermaid
70+
flowchart TD
71+
PR["Project (scan root)"]
72+
SP["Subproject — nested dir<br/>discovered by --recursive"]
73+
MOD["Module — workspace/reactor member<br/>resolved by the package manager"]
74+
MAN["Manifest<br/>(path, kind, detector, resolution)"]
75+
ROOT["Module's own package<br/>(graph root, application)"]
76+
DEP[Dependency instances]
77+
P2[Packages, deduplicated by PURL]
78+
79+
PR -->|discovers| SP
80+
PR -->|root manifests| MAN
81+
SP -->|its manifests| MAN
82+
PR -->|workspace / reactor expansion| MOD
83+
SP -->|workspace / reactor expansion| MOD
84+
MOD -->|one manifest each| MAN
85+
MAN -->|graph root| ROOT
86+
ROOT -->|direct + transitive| DEP
87+
DEP -->|shared transitives count once| P2
88+
```
89+
6790
## Extensibility
6891

6992
Every built-in is an implementation of the same contract an external plugin implements — there is no privileged internal path. Three extension points are pluggable today, and a fourth is planned:

docs/MCP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ MCP tool results land in an agent's context window, so they use a compact respon
108108

109109
`bomly_scan` returns:
110110

111-
- **`summary`** — manifest/package counts, vulnerable vs clean packages, findings by severity, and whether enrich/audit ran. Clean packages are counted, never listed.
111+
- **`summary`** — manifest/package counts, `subprojects` and `modules` counts for scans that span nested projects or workspace/reactor members (omitted for flat scans), vulnerable vs clean packages, findings by severity, and whether enrich/audit ran. Clean packages are counted, never listed. The full hierarchy is derived from `manifests[].subproject` + `path` in the complete CLI JSON document.
112112
- **`remediations`** — ranked groups, each one concrete change and every finding it closes: the direct dependency to change (`target_package`, full identity with org/scope and PURL), the manifest to edit, the version to move to, and an `action` (`direct-bump`, `transitive-override`, `lockfile-refresh`, `no-fix-upstream`, `policy-review`). Transitive cases carry package-manager-specific `override_advice` (npm `overrides`, pnpm `pnpm.overrides` / `pnpm-workspace.yaml`, yarn `resolutions`, Maven `dependencyManagement`, Gradle constraints, `go get` + `go mod tidy`, and so on). Groups are ranked by known-exploited (KEV) first, then severity, EPSS, and fixability.
113113
- **`informational`** — warn-disposition and policy-only findings, separated from actionable work.
114114
- **`diagnostics`** — pipeline warnings (detector fallbacks, matcher failures) so partial results explain themselves.

docs/SCAN_TARGETS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ A nested `go.mod` is an independent Go module by language semantics, so every ne
7777

7878
Like the ignore rules, multi-module expansion is declared by each detector (`sdk.PackageManagerSupport.MultiModule`), so external detector plugins can opt their package manager into pruning.
7979

80+
## Subprojects and modules in scan output
81+
82+
Scan output distinguishes two kinds of nesting:
83+
84+
- A **subproject** is an independently discovered nested directory (its own detector run) — what `--recursive` finds.
85+
- A **module** is a member the package manager natively resolves under one root manifest: a Maven reactor module, an npm/pnpm workspace member, a Cargo workspace member.
86+
87+
The npm, pnpm, cargo, and maven detectors emit **one manifest entry per module**`apps/web/package.json`, `crates/api/Cargo.toml`, `core/pom.xml` — alongside the root manifest, each carrying the module's reachable dependency subtree (a virtual Cargo workspace root emits member entries only). Detectors without per-module emission (gradle, sbt, mix, yarn classic, pub, and the node *native* detectors) keep one merged root manifest.
88+
89+
Every view derives the same hierarchy from the manifests' `subproject` and `path` fields — no extra JSON fields: the interactive components tab shows subproject and module nodes with their manifests, the text report renders a grouped manifest tree, the markdown manifest table carries a Location column, and the MCP compact summary reports `subprojects`/`modules` counts. JSON consumers can group rows the same way: a manifest whose directory sits below its `subproject` directory is a module manifest.
90+
8091
## Git repository — `--url` and `--ref`
8192

8293
Clone-then-scan, all in one step.

docs/TUI.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,16 @@ Switch with the number keys or `Tab`:
2020
| Key | View | What's shown |
2121
| --- | --- | --- |
2222
| `1` | Overview | Scan summary, totals by ecosystem and severity |
23-
| `2` | Packages | Full dependency list with tree expansion |
23+
| `2` | Components | Project tree: manifests and their dependency trees |
2424
| `3` | Vulnerabilities | Findings grouped by package |
2525
| `4` | Licenses | License inventory and conflicts |
2626
| `5` | Findings | Audit policy hits with reasons |
2727
| `6` | Source | Detected manifest and lockfile inventory |
2828

2929
`Tab` cycles forward through views; `Shift+Tab` is the reverse cycle in supporting terminals.
3030

31+
The Components tree groups manifests the way the project is actually laid out: manifests at the scan root sit directly under the project node, **subproject** nodes hold the manifests of independently discovered nested directories (what `--recursive` finds), and **module** nodes hold workspace/reactor members (npm/pnpm workspace packages, Cargo workspace members, Maven reactor modules) that resolve under one root manifest. Flat single-root scans keep a plain project → manifests tree. Group nodes expand and collapse like any other row, and their details pane shows the directory, ecosystems, and manifest counts. See [Scan targets](SCAN_TARGETS.md#subprojects-and-modules-in-scan-output) for the terminology.
32+
3133
## Navigation
3234

3335
| Key | Action |

internal/cli/cmd_progress.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ func subprojectProgressChildren(results []sdk.DetectionResult) []progress.Child
5656
if detail != "" {
5757
label += " (" + detail + ")"
5858
}
59+
// Multi-module resolutions (workspaces, reactors) produce one manifest
60+
// entry per module; surface the count so the step explains the fan-out.
61+
if r.Graphs != nil && len(r.Graphs.Entries) > 1 {
62+
children = append(children, progress.Child{Label: label, Detail: fmt.Sprintf("[%d manifests]", len(r.Graphs.Entries))})
63+
continue
64+
}
5965
children = append(children, progress.Child{Label: label})
6066
}
6167
return children

internal/cli/opts/options.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,9 +457,12 @@ func displayTargetName(target sdk.ExecutionTarget) string {
457457
if strings.TrimSpace(location) == "" {
458458
return ""
459459
}
460-
if target.Kind == sdk.ExecutionTargetContainerImage || target.Kind == sdk.ExecutionTargetGitRepository {
460+
if target.Kind == sdk.ExecutionTargetContainerImage {
461461
return location
462462
}
463+
// Git repositories and filesystem paths both name themselves after the
464+
// last path segment — the repo or directory name. The full URL stays
465+
// available as the descriptor's Path.
463466
trimmed := strings.TrimSuffix(strings.TrimRight(location, `/\`), ".git")
464467
if idx := strings.LastIndexAny(trimmed, `/\`); idx >= 0 && idx < len(trimmed)-1 {
465468
return trimmed[idx+1:]

internal/cli/opts/options_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,11 @@ func TestProjectDescriptor_UsesUserFacingTargetLabels(t *testing.T) {
5252
t.Fatalf("unexpected container project descriptor: %#v", containerProject)
5353
}
5454

55+
// Git repositories name themselves after the repo (last URL segment,
56+
// .git trimmed); the full URL remains the descriptor's Path.
5557
urlOptions := Options{executionTarget: sdk.ExecutionTarget{Kind: sdk.ExecutionTargetGitRepository, Location: `C:\Temp\bomly-clone`, RepositoryURL: "https://github.com/acme/demo.git", Ref: "main"}}
5658
urlProject := urlOptions.ProjectDescriptor()
57-
if urlProject.Name != "https://github.com/acme/demo.git" || urlProject.Path != "https://github.com/acme/demo.git" || urlProject.TargetType != "git repository" || urlProject.TargetRef != "main" {
59+
if urlProject.Name != "demo" || urlProject.Path != "https://github.com/acme/demo.git" || urlProject.TargetType != "git repository" || urlProject.TargetRef != "main" {
5860
t.Fatalf("unexpected url project descriptor: %#v", urlProject)
5961
}
6062
}

internal/cli/render/reachability_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func TestScanRendersReachabilityColumnWhenEnabled(t *testing.T) {
4141
Source: "osv",
4242
},
4343
}
44-
out := Scan(g, registry, findings, nil /*matcherStats*/, true /*enrich*/, true /*audit*/, true /*reachability*/, nil /*failOn*/, "" /*subprojectSummary*/, nil /*fallbackNotices*/)
44+
out := Scan(g, registry, findings, nil /*matcherStats*/, true /*enrich*/, true /*audit*/, true /*reachability*/, nil /*failOn*/, nil /*manifests*/, nil /*fallbackNotices*/)
4545
// The compact text format shows the finding CVE ID and package name; detailed
4646
// reachability info is available in the JSON and Markdown output formats.
4747
if !strings.Contains(StripANSI(out), "CVE-2024-0001") {
@@ -218,7 +218,7 @@ func TestScanOmitsReachabilityColumnWhenDisabled(t *testing.T) {
218218
findings := []model.Finding{
219219
{ID: "CVE-2024-0001", Kind: model.FindingKindVulnerability, PackageRef: pkg.PURL, Severity: "high", Title: "x", Source: "osv"},
220220
}
221-
out := Scan(g, nil, findings, nil, true, true, false, nil, "", nil)
221+
out := Scan(g, nil, findings, nil, true, true, false, nil, nil, nil)
222222
// Compact text format never shows a REACHABILITY column; detailed info is in JSON/Markdown.
223223
if strings.Contains(StripANSI(out), "REACHABILITY") {
224224
t.Fatalf("REACHABILITY column should not appear in compact text output; got:\n%s", out)

0 commit comments

Comments
 (0)