From d645c6170103ca5181190017f8469425fe874a56 Mon Sep 17 00:00:00 2001 From: Youngsup Oh Date: Fri, 16 Oct 2026 15:42:22 +0900 Subject: [PATCH 1/3] fix: stop offering installed packages' dist, __pycache__ and vendor outside projects as safe to delete The walk treated each package in a node_modules outside any project, in Python site-packages and in the Go module cache as a project of its own, so their shipped dist/, __pycache__ and vendor were reported as safe build output. These trees are now skipped whole, whatever ecosystems are scanned. A project's own node_modules is still reported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DKZ2ySvoEcv2ezHfF7YmTY --- docs/ARCHITECTURE.md | 2 +- docs/SPEC.md | 2 +- docs/decisions/prune-non-project-trees.md | 4 +++ internal/scanner/walk.go | 19 +++++++++++--- internal/scanner/walk_prune_test.go | 30 +++++++++++++++++++++++ internal/scanner/walk_test.go | 14 +++++------ 6 files changed, 58 insertions(+), 13 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b45820b..5ca3707 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,7 +67,7 @@ Tables can also: - opt into **`ScanResult.ProjectRoot` attribution** — the Python table sets the matched root on every result because its artifacts sit at arbitrary depth and output grouping needs explicit attribution (nested project roots win over parents); - declare a **`PruneRoot`** hook that skips the ecosystem's own SDK checkout. -**Pruned trees** are skipped whole — no artifact matching, no project context, no descent — so nothing inside is ever a deletion target: installed-package trees (pnpm store version dirs, macOS `.app` bundles) engine-wide, and an ecosystem's SDK checkout via `PruneRoot` — [decision](decisions/prune-non-project-trees.md). +**Pruned trees** are skipped whole — no artifact matching, no project context, no descent — so nothing inside is ever a deletion target: installed-package trees (pnpm store version dirs, macOS `.app` bundles, `node_modules` not matched as a project artifact, Python `site-packages`/`dist-packages`, the Go module cache) engine-wide, and an ecosystem's SDK checkout via `PruneRoot` — [decision](decisions/prune-non-project-trees.md). **Deduplication**: a directory matching rules of several active ecosystems is reported once, attributed to the first table in order (node → rust → ruby → python → go → flutter → android), and no scanner descends into another's matched artifact (`__pycache__` inside `node_modules` is not reported separately). Attribution can therefore differ between a full scan and an `--eco` subset scan. diff --git a/docs/SPEC.md b/docs/SPEC.md index 0069535..229e1e9 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -328,7 +328,7 @@ Sizes use **decimal SI units** (1 KB = 1000 B). `--min-size` uses the same conve **pnpm-installed `node_modules`**: pnpm clones (macOS) or hard-links (Linux) pnpm store files into `node_modules` by default, so deleting it alone frees little; running `pnpm store prune` afterwards removes packages no project references. The result carries this note when `node_modules/.modules.yaml` (written by pnpm on install, JSON since pnpm 12; `pnpm-lock.yaml` alone does not prove pnpm populated the folder) names a `storeDir` that exists on the same volume. On macOS its `size` leaves out blocks shared with the store or other projects. A store on another volume forces pnpm to copy, so no note is given. `packageImportMethod: copy` is not recorded in `.modules.yaml`, so such installs still get the note. -**Installed-package trees are excluded**: `node_modules`/`dist` inside a pnpm store or a macOS `.app` bundle are package content, not project output — see [decision](decisions/prune-non-project-trees.md). +**Installed-package trees are excluded**: anything inside a pnpm store, a macOS `.app` bundle, a `node_modules` that is not a project's own `node_modules` artifact (global install prefixes, tools' bundled dependencies), Python `site-packages`/`dist-packages`, or the Go module cache is package content, not project output — see [decision](decisions/prune-non-project-trees.md). **Monorepo support**: artifacts in sub-packages (apps/, packages/) are grouped under the git root project. Sub-packages are displayed with headers showing their path and total size. diff --git a/docs/decisions/prune-non-project-trees.md b/docs/decisions/prune-non-project-trees.md index 4673c65..f226f76 100644 --- a/docs/decisions/prune-non-project-trees.md +++ b/docs/decisions/prune-non-project-trees.md @@ -7,6 +7,9 @@ The walk engine skips these trees whole — no artifact matching, no project con - **Installed-package trees**, engine-wide regardless of which ecosystems are active: their marker files belong to shipped packages, not projects. Detected by invariant layout, never install path. - pnpm store version dir (`v` with `files/` and `index/` or `index.db`) - macOS app bundle (`*.app` with `Contents/`) + - `node_modules` reached without matching a project's `node_modules` rule (a global install prefix such as `lib/node_modules`, or a tool's bundled dependencies) + - Python `site-packages` / `dist-packages` + - Go module cache (`mod/` with `cache/download/`) - **An ecosystem's own SDK checkout**, via the table's `PruneRoot` hook. The SDK root is detected by its invariant bootstrap layout, never a hardcoded install path (Flutter: `bin/flutter` + `bin/internal/engine.version`). When adding an ecosystem or rule, scan that tool's real SDK/toolchain checkout and verify what gets flagged. A scanner's own false positive is fixed in that scanner's PR. @@ -23,6 +26,7 @@ go test ./internal/scanner/ -run 'TestWalkExcludesInstalledPackageTrees|TestFlut - Gitignore-aware protection does not catch this: committed-clean files are not `protected`, so they classify as `safe` and become deletion targets. - pnpm v11's `links/` unpacks packages whose `dist/` would match; deleting it corrupts the store every project hard-links from. The store itself is reported by the Global Caches scanner. - Apps ship their runtime inside the bundle (e.g. Electron `node_modules` in VS Code update copies under `~/Library/Caches`). +- A `node_modules` outside any project holds installed packages whose own `package.json` would root a project, so their shipped `dist/` matched as `safe` build output (e.g. the npm and corepack bundled in a GitHub Actions runner, a VS Code extension's SDK cache). The same holds for packages in `site-packages` and module sources in the read-only Go module cache. A project's own `node_modules` is matched as an artifact before descent, so it is still reported. - The Android SDK ships no `build.gradle`, so it never establishes a project context and needs no `PruneRoot`. ## Rejected alternatives diff --git a/internal/scanner/walk.go b/internal/scanner/walk.go index 29e9769..3b81a65 100644 --- a/internal/scanner/walk.go +++ b/internal/scanner/walk.go @@ -3,6 +3,7 @@ package scanner import ( "context" "io/fs" + "os" "path/filepath" "runtime" "slices" @@ -197,7 +198,7 @@ func runWalk(ctx context.Context, root string, tables []walkEcosystem) ([]model. // Prune before establishing any context or descending, so nothing // inside is ever a deletion target. - if isInstalledPackageTree(filepath.Base(dir), names) { + if isInstalledPackageTree(dir, names) { return false } for i := range tables { @@ -271,9 +272,21 @@ func runWalk(ctx context.Context, root string, tables []walkEcosystem) ([]model. // (v11). v11's links/ unpacks packages whose dist/ would match; deleting // it corrupts the store every pnpm project hard-links from. // - macOS app bundle: *.app with Contents/ (e.g. Electron node_modules). -func isInstalledPackageTree(name string, names map[string]bool) bool { - if strings.HasSuffix(name, ".app") && names["Contents"] { +// - node_modules reached without matching a project's node_modules rule: +// a global install prefix (lib/node_modules) or a tool's bundled +// dependencies, whose packages' dist/ is their shipped code. +// - Python site-packages / dist-packages: installed distributions. +// - Go module cache: mod/ with cache/download/ (read-only module sources). +func isInstalledPackageTree(dir string, names map[string]bool) bool { + name := filepath.Base(dir) + switch { + case strings.HasSuffix(name, ".app") && names["Contents"]: + return true + case name == "node_modules", name == "site-packages", name == "dist-packages": return true + case name == "mod" && names["cache"]: + info, err := os.Stat(filepath.Join(dir, "cache", "download")) + return err == nil && info.IsDir() } return isPnpmStoreVersionDir(name) && names["files"] && (names["index"] || names["index.db"]) } diff --git a/internal/scanner/walk_prune_test.go b/internal/scanner/walk_prune_test.go index bb9a7f9..2db924d 100644 --- a/internal/scanner/walk_prune_test.go +++ b/internal/scanner/walk_prune_test.go @@ -39,6 +39,36 @@ func TestWalkExcludesInstalledPackageTrees(t *testing.T) { }, want: []artifact{{"app/node_modules", model.EcoNode, model.CatDeps, model.SafetySafe, ""}}, }, + { + name: "node_modules outside a project", + ecos: node, + tree: []string{ + "sdk-cache/darwin-arm64/node_modules/hono/package.json", + "sdk-cache/darwin-arm64/node_modules/hono/dist/index.js", + "runner/node20/lib/node_modules/npm/package.json", + "runner/node20/lib/node_modules/npm/node_modules/abbrev/", + "app/package.json", "app/tools/node_modules/x/package.json", "app/tools/node_modules/x/dist/", + }, + }, + { + name: "python site-packages", + ecos: []model.Ecosystem{model.EcoPython}, + tree: []string{ + "tool/.venv/lib/python3.12/site-packages/numpy/f2py/setup.py", + "tool/.venv/lib/python3.12/site-packages/numpy/f2py/__pycache__/x.pyc", + "usr/lib/python3/dist-packages/pkg/setup.py", + "usr/lib/python3/dist-packages/pkg/__pycache__/x.pyc", + }, + }, + { + name: "go module cache", + ecos: []model.Ecosystem{model.EcoGo}, + tree: []string{ + "go/pkg/mod/cache/download/x/@v/list", + "go/pkg/mod/github.com/briandowns/spinner@v1.23.2/go.mod", + "go/pkg/mod/github.com/briandowns/spinner@v1.23.2/vendor/", + }, + }, { name: "app bundle without node in the scan", ecos: []model.Ecosystem{model.EcoPython}, diff --git a/internal/scanner/walk_test.go b/internal/scanner/walk_test.go index 717f8bf..12f7e6e 100644 --- a/internal/scanner/walk_test.go +++ b/internal/scanner/walk_test.go @@ -74,8 +74,8 @@ func TestWalkScan_DedupCoverage(t *testing.T) { // TestWalkScan_DedupNodeModulesShieldsPython pins the second double-count // fix: a matched artifact is skipped entirely, so no other active ecosystem -// reports anything inside it. With python alone, node_modules is not an -// artifact and its contents are scanned as before. +// reports anything inside it. With python alone, node_modules is still an +// installed-package tree and is skipped whole. func TestWalkScan_DedupNodeModulesShieldsPython(t *testing.T) { root := t.TempDir() @@ -108,16 +108,14 @@ func TestWalkScan_DedupNodeModulesShieldsPython(t *testing.T) { t.Errorf("expected node_modules attributed to node, got %s (%s)", results[0].Path, results[0].Ecosystem) } - // python alone: descends into node_modules and finds the __pycache__. + // python alone: node_modules is an installed-package tree, so nothing + // inside it is reported either. results, err = WalkScan(context.Background(), root, model.EcoPython) if err != nil { t.Fatalf("WalkScan error: %v", err) } - if len(results) != 1 { - t.Fatalf("expected exactly 1 result for python alone, got %d", len(results)) - } - if results[0].Path != pyc || results[0].Ecosystem != model.EcoPython { - t.Errorf("expected __pycache__ attributed to python, got %s (%s)", results[0].Path, results[0].Ecosystem) + if len(results) != 0 { + t.Fatalf("expected no result for python alone, got %d (%s)", len(results), results[0].Path) } } From 5b97278cd978271af93cf7439b606389a2b023f0 Mon Sep 17 00:00:00 2001 From: Youngsup Oh Date: Fri, 16 Oct 2026 15:52:10 +0900 Subject: [PATCH 2/3] refactor: prune installed-package dirs by name before reading them and reuse isDir node_modules, site-packages and dist-packages are pruned in Enter, after artifact matching, so their entries are never read. The Go module cache check reuses isDir, and the decision doc states that a name only package managers create also counts as invariant. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DKZ2ySvoEcv2ezHfF7YmTY --- docs/decisions/prune-non-project-trees.md | 2 +- internal/scanner/walk.go | 39 +++++++++++++++-------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/decisions/prune-non-project-trees.md b/docs/decisions/prune-non-project-trees.md index f226f76..ff8e8ff 100644 --- a/docs/decisions/prune-non-project-trees.md +++ b/docs/decisions/prune-non-project-trees.md @@ -4,7 +4,7 @@ The walk engine skips these trees whole — no artifact matching, no project context, no descent — so nothing inside is ever a deletion target: -- **Installed-package trees**, engine-wide regardless of which ecosystems are active: their marker files belong to shipped packages, not projects. Detected by invariant layout, never install path. +- **Installed-package trees**, engine-wide regardless of which ecosystems are active: their marker files belong to shipped packages, not projects. Detected by invariant layout or by a directory name only package managers create, never install path. - pnpm store version dir (`v` with `files/` and `index/` or `index.db`) - macOS app bundle (`*.app` with `Contents/`) - `node_modules` reached without matching a project's `node_modules` rule (a global install prefix such as `lib/node_modules`, or a tool's bundled dependencies) diff --git a/internal/scanner/walk.go b/internal/scanner/walk.go index 3b81a65..40d3c06 100644 --- a/internal/scanner/walk.go +++ b/internal/scanner/walk.go @@ -3,7 +3,6 @@ package scanner import ( "context" "io/fs" - "os" "path/filepath" "runtime" "slices" @@ -187,6 +186,11 @@ func runWalk(ctx context.Context, root string, tables []walkEcosystem) ([]model. ReportProgress(ctx, len(results)) return false // matched artifact: do not descend } + // Checked after artifact matching, so a project's own node_modules + // is still reported; by name alone, so the tree is never read. + if installedPackageDirNames[name] { + return false + } // The root is always entered: the hidden-dir rule applies below it. return dir == root || !strings.HasPrefix(name, ".") || hiddenDescend[name] }, @@ -264,31 +268,40 @@ func runWalk(ctx context.Context, root string, tables []walkEcosystem) ([]model. return results, nil } +// installedPackageDirNames are directories that hold only installed packages +// whenever the walk reaches them without matching an artifact rule; Enter +// prunes them by name. Their packages' own markers (package.json, setup.py) +// would otherwise root projects whose shipped dist/ or __pycache__ match. +// +// - node_modules: a global install prefix (lib/node_modules) or a tool's +// bundled dependencies, whose packages' dist/ is their shipped code. +// - site-packages / dist-packages: installed Python distributions. +var installedPackageDirNames = map[string]bool{ + "node_modules": true, + "site-packages": true, + "dist-packages": true, +} + // isInstalledPackageTree reports whether a directory roots a tree of installed -// packages, whose marker files belong to shipped packages, not projects. -// Checked for every ecosystem, so results don't depend on the --eco subset. +// packages identified by its entries. Checked for every ecosystem, so results +// don't depend on the --eco subset. // // - pnpm store version dir: v with files/ and index/ (v10) or index.db // (v11). v11's links/ unpacks packages whose dist/ would match; deleting // it corrupts the store every pnpm project hard-links from. -// - macOS app bundle: *.app with Contents/ (e.g. Electron node_modules). -// - node_modules reached without matching a project's node_modules rule: -// a global install prefix (lib/node_modules) or a tool's bundled -// dependencies, whose packages' dist/ is their shipped code. -// - Python site-packages / dist-packages: installed distributions. +// - macOS app bundle: *.app with Contents/ (e.g. an app's bundled dist/). // - Go module cache: mod/ with cache/download/ (read-only module sources). func isInstalledPackageTree(dir string, names map[string]bool) bool { name := filepath.Base(dir) switch { case strings.HasSuffix(name, ".app") && names["Contents"]: return true - case name == "node_modules", name == "site-packages", name == "dist-packages": - return true case name == "mod" && names["cache"]: - info, err := os.Stat(filepath.Join(dir, "cache", "download")) - return err == nil && info.IsDir() + return isDir(filepath.Join(dir, "cache", "download")) + case isPnpmStoreVersionDir(name) && names["files"] && (names["index"] || names["index.db"]): + return true } - return isPnpmStoreVersionDir(name) && names["files"] && (names["index"] || names["index.db"]) + return false } func isPnpmStoreVersionDir(name string) bool { From 3b56fcb81dac546b20e3ba386aeb215fa5bab607 Mon Sep 17 00:00:00 2001 From: Youngsup Oh Date: Fri, 16 Oct 2026 16:03:31 +0900 Subject: [PATCH 3/3] docs: describe installed-package pruning by detection method and drop redundant comments Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DKZ2ySvoEcv2ezHfF7YmTY --- docs/ARCHITECTURE.md | 2 +- docs/SPEC.md | 2 +- docs/decisions/prune-non-project-trees.md | 15 ++++++------- internal/scanner/walk.go | 26 +++++++++-------------- internal/scanner/walk_test.go | 7 +++--- 5 files changed, 21 insertions(+), 31 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5ca3707..889e4ea 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,7 +67,7 @@ Tables can also: - opt into **`ScanResult.ProjectRoot` attribution** — the Python table sets the matched root on every result because its artifacts sit at arbitrary depth and output grouping needs explicit attribution (nested project roots win over parents); - declare a **`PruneRoot`** hook that skips the ecosystem's own SDK checkout. -**Pruned trees** are skipped whole — no artifact matching, no project context, no descent — so nothing inside is ever a deletion target: installed-package trees (pnpm store version dirs, macOS `.app` bundles, `node_modules` not matched as a project artifact, Python `site-packages`/`dist-packages`, the Go module cache) engine-wide, and an ecosystem's SDK checkout via `PruneRoot` — [decision](decisions/prune-non-project-trees.md). +**Pruned trees** are skipped whole — no artifact matching, no project context, no descent — so nothing inside is ever a deletion target: installed-package trees (`node_modules` outside a project, `site-packages`, package stores, app bundles) engine-wide, and an ecosystem's SDK checkout via `PruneRoot` — [decision](decisions/prune-non-project-trees.md). **Deduplication**: a directory matching rules of several active ecosystems is reported once, attributed to the first table in order (node → rust → ruby → python → go → flutter → android), and no scanner descends into another's matched artifact (`__pycache__` inside `node_modules` is not reported separately). Attribution can therefore differ between a full scan and an `--eco` subset scan. diff --git a/docs/SPEC.md b/docs/SPEC.md index 229e1e9..73b31bb 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -328,7 +328,7 @@ Sizes use **decimal SI units** (1 KB = 1000 B). `--min-size` uses the same conve **pnpm-installed `node_modules`**: pnpm clones (macOS) or hard-links (Linux) pnpm store files into `node_modules` by default, so deleting it alone frees little; running `pnpm store prune` afterwards removes packages no project references. The result carries this note when `node_modules/.modules.yaml` (written by pnpm on install, JSON since pnpm 12; `pnpm-lock.yaml` alone does not prove pnpm populated the folder) names a `storeDir` that exists on the same volume. On macOS its `size` leaves out blocks shared with the store or other projects. A store on another volume forces pnpm to copy, so no note is given. `packageImportMethod: copy` is not recorded in `.modules.yaml`, so such installs still get the note. -**Installed-package trees are excluded**: anything inside a pnpm store, a macOS `.app` bundle, a `node_modules` that is not a project's own `node_modules` artifact (global install prefixes, tools' bundled dependencies), Python `site-packages`/`dist-packages`, or the Go module cache is package content, not project output — see [decision](decisions/prune-non-project-trees.md). +**Installed-package trees are excluded**: nothing inside a `node_modules` that is not a project's own, Python `site-packages`/`dist-packages`, the Go module cache, a pnpm store or a macOS `.app` bundle is reported — see [decision](decisions/prune-non-project-trees.md). **Monorepo support**: artifacts in sub-packages (apps/, packages/) are grouped under the git root project. Sub-packages are displayed with headers showing their path and total size. diff --git a/docs/decisions/prune-non-project-trees.md b/docs/decisions/prune-non-project-trees.md index ff8e8ff..ff99fca 100644 --- a/docs/decisions/prune-non-project-trees.md +++ b/docs/decisions/prune-non-project-trees.md @@ -4,12 +4,9 @@ The walk engine skips these trees whole — no artifact matching, no project context, no descent — so nothing inside is ever a deletion target: -- **Installed-package trees**, engine-wide regardless of which ecosystems are active: their marker files belong to shipped packages, not projects. Detected by invariant layout or by a directory name only package managers create, never install path. - - pnpm store version dir (`v` with `files/` and `index/` or `index.db`) - - macOS app bundle (`*.app` with `Contents/`) - - `node_modules` reached without matching a project's `node_modules` rule (a global install prefix such as `lib/node_modules`, or a tool's bundled dependencies) - - Python `site-packages` / `dist-packages` - - Go module cache (`mod/` with `cache/download/`) +- **Installed-package trees**, whichever ecosystems are active. Detected by layout or by a name only package managers create, never by install path: + - by name, when the directory is not itself a matched artifact: `node_modules`, `site-packages`, `dist-packages` + - by entries: pnpm store version dir (`v` with `files/` and `index/` or `index.db`), macOS app bundle (`*.app` with `Contents/`), Go module cache (`mod/` with `cache/download/`) - **An ecosystem's own SDK checkout**, via the table's `PruneRoot` hook. The SDK root is detected by its invariant bootstrap layout, never a hardcoded install path (Flutter: `bin/flutter` + `bin/internal/engine.version`). When adding an ecosystem or rule, scan that tool's real SDK/toolchain checkout and verify what gets flagged. A scanner's own false positive is fixed in that scanner's PR. @@ -22,15 +19,15 @@ go test ./internal/scanner/ -run 'TestWalkExcludesInstalledPackageTrees|TestFlut ## Rationale +- Each package in an installed-package tree, including the runtime an app ships inside its bundle, carries its own marker (`package.json`, `setup.py`, `go.mod`). Without pruning it roots a project, and its shipped `dist/`, `__pycache__` or `vendor/` matches as `safe` output — deleting the `dist/` of the npm bundled in a GitHub Actions runner breaks the runner. A project's own `node_modules` matches as an artifact before the name check, so it is still reported. +- pnpm v11's `links/` holds unpacked packages; deleting it corrupts the store every project hard-links from. The store itself is reported by the Global Caches scanner. - A directory name like `build`/`dist` is not always regenerable output. Inside an SDK checkout it can be committed *source*: the Flutter SDK's `engine/src/build` / `engine/src/flutter/build` are GN build-system source trees tracked in git. - Gitignore-aware protection does not catch this: committed-clean files are not `protected`, so they classify as `safe` and become deletion targets. -- pnpm v11's `links/` unpacks packages whose `dist/` would match; deleting it corrupts the store every project hard-links from. The store itself is reported by the Global Caches scanner. -- Apps ship their runtime inside the bundle (e.g. Electron `node_modules` in VS Code update copies under `~/Library/Caches`). -- A `node_modules` outside any project holds installed packages whose own `package.json` would root a project, so their shipped `dist/` matched as `safe` build output (e.g. the npm and corepack bundled in a GitHub Actions runner, a VS Code extension's SDK cache). The same holds for packages in `site-packages` and module sources in the read-only Go module cache. A project's own `node_modules` is matched as an artifact before descent, so it is still reported. - The Android SDK ships no `build.gradle`, so it never establishes a project context and needs no `PruneRoot`. ## Rejected alternatives - **Detect by install path** — SDKs and stores live wherever the user put them; a hardcoded path misses relocated checkouts. - **Rely on gitignore-aware protection** — misses committed-clean source (above). +- **Prune every directory a deps rule names** — would also prune `vendor/` and `venv/` outside projects, which are not package-manager trees. - **Defer a scanner's false positive to a separate change** — the scanner ships able to delete real source in the meantime. diff --git a/internal/scanner/walk.go b/internal/scanner/walk.go index 40d3c06..7ac60e1 100644 --- a/internal/scanner/walk.go +++ b/internal/scanner/walk.go @@ -186,8 +186,8 @@ func runWalk(ctx context.Context, root string, tables []walkEcosystem) ([]model. ReportProgress(ctx, len(results)) return false // matched artifact: do not descend } - // Checked after artifact matching, so a project's own node_modules - // is still reported; by name alone, so the tree is never read. + // After artifact matching, so a project's own node_modules is + // still reported. if installedPackageDirNames[name] { return false } @@ -268,14 +268,10 @@ func runWalk(ctx context.Context, root string, tables []walkEcosystem) ([]model. return results, nil } -// installedPackageDirNames are directories that hold only installed packages -// whenever the walk reaches them without matching an artifact rule; Enter -// prunes them by name. Their packages' own markers (package.json, setup.py) -// would otherwise root projects whose shipped dist/ or __pycache__ match. -// -// - node_modules: a global install prefix (lib/node_modules) or a tool's -// bundled dependencies, whose packages' dist/ is their shipped code. -// - site-packages / dist-packages: installed Python distributions. +// installedPackageDirNames hold only installed packages when the walk reaches +// them without matching an artifact rule (a global lib/node_modules, a tool's +// bundled dependencies, a Python environment's site-packages). Enter prunes +// them by name, so they are never read. var installedPackageDirNames = map[string]bool{ "node_modules": true, "site-packages": true, @@ -283,14 +279,12 @@ var installedPackageDirNames = map[string]bool{ } // isInstalledPackageTree reports whether a directory roots a tree of installed -// packages identified by its entries. Checked for every ecosystem, so results -// don't depend on the --eco subset. +// packages identified by its entries: // // - pnpm store version dir: v with files/ and index/ (v10) or index.db -// (v11). v11's links/ unpacks packages whose dist/ would match; deleting -// it corrupts the store every pnpm project hard-links from. -// - macOS app bundle: *.app with Contents/ (e.g. an app's bundled dist/). -// - Go module cache: mod/ with cache/download/ (read-only module sources). +// (v11). Deleting v11's links/ corrupts the store. +// - macOS app bundle: *.app with Contents/. +// - Go module cache: mod/ with cache/download/. func isInstalledPackageTree(dir string, names map[string]bool) bool { name := filepath.Base(dir) switch { diff --git a/internal/scanner/walk_test.go b/internal/scanner/walk_test.go index 12f7e6e..ba1c5ca 100644 --- a/internal/scanner/walk_test.go +++ b/internal/scanner/walk_test.go @@ -74,8 +74,8 @@ func TestWalkScan_DedupCoverage(t *testing.T) { // TestWalkScan_DedupNodeModulesShieldsPython pins the second double-count // fix: a matched artifact is skipped entirely, so no other active ecosystem -// reports anything inside it. With python alone, node_modules is still an -// installed-package tree and is skipped whole. +// reports anything inside it. With python alone, node_modules is pruned as an +// installed-package tree. func TestWalkScan_DedupNodeModulesShieldsPython(t *testing.T) { root := t.TempDir() @@ -108,8 +108,7 @@ func TestWalkScan_DedupNodeModulesShieldsPython(t *testing.T) { t.Errorf("expected node_modules attributed to node, got %s (%s)", results[0].Path, results[0].Ecosystem) } - // python alone: node_modules is an installed-package tree, so nothing - // inside it is reported either. + // python alone: node_modules is pruned, so nothing inside is reported. results, err = WalkScan(context.Background(), root, model.EcoPython) if err != nil { t.Fatalf("WalkScan error: %v", err)