From adb3d7c3e0ea71476ee1f07de2e38973e4746bb3 Mon Sep 17 00:00:00 2001
From: Shinrai
Date: Sat, 3 Oct 2026 17:18:22 -0700
Subject: [PATCH] fix(discovery): never walk dependency folders, at any depth
Discovery skipped only .git by name, so a project without a .gitignore (or
a sub-package with its own node_modules, or a tracked dependency folder,
or gitignore: false) had headers stamped into installed packages.
Dependency folders are now never walked, at any depth and whatever the
ignore files say: node_modules, bower_components, jspm_packages,
.pnpm-store and .yarn, plus a vendor folder that holds Composer's
autoload.php or Go's modules.txt. Other folders named vendor are still
processed, since the name is also used for code a project maintains
itself (front-end vendor/ scripts, Laravel's resources/views/vendor).
A path inside a dependency folder that is named explicitly, through
includeFolders / --include-folder or input / --input, is still processed.
Build output (dist, build, coverage, ...) keeps being processed unless an
ignore file or excludeFolders says otherwise. Tests from #71 that expected
node_modules to be walked are updated to the new rule.
Fixes #123
---
README.md | 18 +-
src/core/file-discovery.mjs | 49 ++++-
...scovery-dependency-folders.test.vitest.mjs | 178 ++++++++++++++++++
.../file-discovery-gitignore.test.vitest.mjs | 19 +-
...ile-discovery-ignore-files.test.vitest.mjs | 20 +-
tests/file-discovery-overlap.test.vitest.mjs | 5 +-
types/src/core/file-discovery.d.mts | 8 +
7 files changed, 259 insertions(+), 38 deletions(-)
create mode 100644 tests/file-discovery-dependency-folders.test.vitest.mjs
diff --git a/README.md b/README.md
index da80be8..5a00a31 100644
--- a/README.md
+++ b/README.md
@@ -85,7 +85,7 @@ Common CLI options:
- `--use-gpg-signer-author` (the signing key's UID name, with the OpenPGP UID comment dropped)
- `--cwd `
- `--input ` (repeatable) - process these files and folders instead of the whole project: the union of every value, each file once (`--input src/a.mjs --input src/b.mjs --input scripts`). A named file whose type cannot carry a header (see [Supported file types](#supported-file-types)) is reported as `skipped: ()` and left unchanged
-- `--include-folder ` (repeatable)
+- `--include-folder ` (repeatable) - naming a folder inside a dependency folder (`--include-folder node_modules/pkg`) processes it, although discovery otherwise skips dependency folders (see [Which files are processed](#which-files-are-processed))
- `--include-folder-non-recursive ` (repeatable) - include only that folder's own files, not its subfolders
- `--exclude-folder ` (repeatable)
- `--include-extension ` (repeatable)
@@ -131,8 +131,8 @@ Important options:
- `forcedDetectors?: string[]` - force-only detector ids to turn on (currently only `"markdown"`). A forced detector is used for `input` and for discovery alike, even when `enabledDetectors` does not list it; `disabledDetectors` still turns it off. An id that is unknown or does not need forcing throws. See [Supported file types](#supported-file-types)
- `detectorSyntaxOverrides?: Record` - override detector comment syntax tokens
- `includeFolders?: Array` - project-relative folders to scan. A string entry is scanned recursively; `{ path, recursive: false }` includes only that folder's own files (for example `{ path: ".", recursive: false }` for the project-root files without the whole tree). Overlapping entries are collapsed, so every file is scanned once however the folders nest or are spelled (`"."` next to `"src"`, `"src"` next to `"src/core"`, `"./src"` next to `"src/"`)
-- `excludeFolders?: string[]` - folder names or relative paths to exclude, on top of what the ignore files exclude
-- `gitignore?: boolean | string | string[]` - which ignore files decide what discovery skips. Omitted (or `true`): every ignore file git honours (see [Which files are processed](#which-files-are-processed)). `false`: no ignore files, every file is processed. A path or array of paths (relative to the project root): exactly those files, parsed with `.gitignore` syntax, without asking git.
+- `excludeFolders?: string[]` - folder names or relative paths to exclude, on top of what the ignore files exclude. Dependency folders are always excluded: `node_modules`, `bower_components`, `jspm_packages`, `.pnpm-store` and `.yarn` at any depth, and a `vendor` folder holding Composer's `autoload.php` or Go's `modules.txt` (see [Which files are processed](#which-files-are-processed)); list a path inside one in `includeFolders` (or pass it as `input`) to process it anyway
+- `gitignore?: boolean | string | string[]` - which ignore files decide what discovery skips. Omitted (or `true`): every ignore file git honours (see [Which files are processed](#which-files-are-processed)). `false`: no ignore files, every file is processed except those in dependency folders. A path or array of paths (relative to the project root): exactly those files, parsed with `.gitignore` syntax, without asking git.
- `projectName?: string` - the `@Project` value for every file, instead of the manifest name
- `language?: string` - the reported `language` for every file (does not change comment syntax or project resolution)
- `projectRoot?: string` - the project root for every file (the base of `@Filename` and of git history lookups) and the scan root
@@ -439,12 +439,18 @@ skipped: package.json (no enabled detector handles .json files)
## Which files are processed
-By default every file with a supported extension (see [Supported file types](#supported-file-types)) is processed. Nothing is skipped because of its name: `node_modules`, `dist`, `build`, `coverage`, `tmp` and the like are processed unless something excludes them. Files are skipped only when:
+By default every file with a supported extension (see [Supported file types](#supported-file-types)) is processed. Build output is not skipped by name: `dist`, `build`, `coverage`, `tmp` and the like are processed unless something excludes them. Files are skipped only when:
+- they are inside a dependency folder (below),
- the project's ignore files ignore them, or
- you exclude them with `excludeFolders` / `--exclude-folder`.
-The one exception is `.git`, git's own storage, which is never walked.
+`.git`, git's own storage, is never walked. Neither are dependency folders, which hold installed third-party code and never the project's own source. They are skipped at any depth (a sub-package's own `node_modules` included) and whatever the ignore files say, so a project with no `.gitignore`, a tracked dependency folder or `gitignore: false` does not stamp headers into them:
+
+- `node_modules`, `bower_components`, `jspm_packages`, `.pnpm-store` and `.yarn`
+- `vendor`, but only when it holds Composer's `vendor/autoload.php` or Go's `vendor/modules.txt`. Any other `vendor` folder is processed, because the name is also used for code the project maintains itself (front-end `vendor/` scripts, Laravel's published `resources/views/vendor`). Exclude such a folder with `excludeFolders` if it holds third-party code.
+
+A path inside a dependency folder that you name explicitly is still processed: an `includeFolders` / `--include-folder` entry such as `node_modules/pkg`, or an `input` / `--input` file or folder.
"Ignore files" means everything git itself honours: the root `.gitignore`, `.gitignore` files in subfolders (each applying to its own folder), `.git/info/exclude`, and the global excludes file (`core.excludesFile`), including negation patterns.
@@ -458,7 +464,7 @@ The one exception is `.git`, git's own storage, which is never walked.
## Notes
- `excludeFolders` supports both folder-name and nested path matching.
-- `includeFolders` entries never double-count a file. A folder that lies inside another recursive include is not walked a second time; the exception is a folder the outer walk never enters because `excludeFolders` excludes it (for example `node_modules/pkg` listed explicitly while `node_modules` is excluded), which keeps being walked on its own because it was named explicitly. An `includeFolders` entry does not override the ignore files: a folder they ignore contributes no files.
+- `includeFolders` entries never double-count a file. A folder that lies inside another recursive include is not walked a second time; the exception is a folder the outer walk never enters because it is a dependency folder or `excludeFolders` excludes it (for example `node_modules/pkg` listed explicitly), which keeps being walked on its own because it was named explicitly. An `includeFolders` entry does not override the ignore files: a folder they ignore contributes no files.
- File discovery is described in [Which files are processed](#which-files-are-processed).
- For monorepos, each file resolves its project from the nearest manifest in its parent tree (see [Project name and root](#project-name-and-root)).
- With `sampleOutput` enabled, each changed file includes `previousValue`, `newValue`, `diff`, `issues`, and `detectedValues` in results.
diff --git a/src/core/file-discovery.mjs b/src/core/file-discovery.mjs
index 07a3508..20288d6 100644
--- a/src/core/file-discovery.mjs
+++ b/src/core/file-discovery.mjs
@@ -13,6 +13,7 @@
*
*/
+import { existsSync } from "node:fs";
import { join, relative, resolve, sep } from "node:path";
import { realpath, stat } from "node:fs/promises";
import { getAllowedExtensions } from "../detectors/index.mjs";
@@ -39,11 +40,44 @@ function resolveExtensions(options) {
}
/**
- * Git's own storage is never a project file, so discovery never walks into it. Nothing else is
- * skipped by name: ignore files and the consumer's `excludeFolders` decide.
+ * Package-manager dependency folders. They hold installed third-party code, never the project's
+ * own source, so discovery skips them at any depth whatever the ignore files say (a project with
+ * no `.gitignore`, a sub-package's own `node_modules`, a tracked dependency folder). A folder
+ * named explicitly through `includeFolders` / `input` is still processed.
+ * @type {readonly string[]}
+ */
+export const DEPENDENCY_FOLDERS = Object.freeze(["node_modules", "bower_components", "jspm_packages", ".pnpm-store", ".yarn"]);
+
+/**
+ * Folders discovery never walks into: git's own storage plus {@link DEPENDENCY_FOLDERS}. Nothing
+ * else is skipped by name (build output such as `dist` included): ignore files and the consumer's
+ * `excludeFolders` decide.
* @type {Set}
*/
-const NEVER_WALKED_FOLDERS = new Set([".git"]);
+const NEVER_WALKED_FOLDERS = new Set([".git", ...DEPENDENCY_FOLDERS]);
+
+/**
+ * Files only a package manager's `vendor` folder holds: Composer's autoloader and Go's
+ * `vendor/modules.txt`. A `vendor` folder without one is treated as project source, because the
+ * name is also used for hand-maintained code (front-end `vendor/` scripts, Laravel's published
+ * `resources/views/vendor`).
+ * @type {readonly string[]}
+ */
+const VENDOR_MARKERS = Object.freeze(["autoload.php", "modules.txt"]);
+
+/**
+ * Whether discovery skips a folder by name: one of {@link NEVER_WALKED_FOLDERS}, or a `vendor`
+ * folder a package manager installed (see {@link VENDOR_MARKERS}).
+ * @param {string} dirPath - Folder path.
+ * @param {string} dirName - Folder name.
+ * @returns {boolean} True when the folder is never walked.
+ */
+function isNeverWalked(dirPath, dirName) {
+ if (NEVER_WALKED_FOLDERS.has(dirName)) {
+ return true;
+ }
+ return dirName === "vendor" && VENDOR_MARKERS.some((marker) => existsSync(join(dirPath, marker)));
+}
/**
* Normalizes a user-provided folder path to a project-relative value.
@@ -174,13 +208,14 @@ export async function discoverFiles(options) {
const exclusionMatcher = buildExclusionMatcher(options.projectRoot, excludeFolders);
const ignoreFilter = createIgnoreFilter({ root: options.projectRoot, gitignore: options.gitignore });
/**
- * Whether the walk skips a directory: the consumer excluded it, or an ignore file ignores it.
+ * Whether the walk skips a directory: it is a package-manager `vendor` folder, the consumer
+ * excluded it, or an ignore file ignores it.
* @param {string} targetPath - Directory path.
* @param {string} targetName - Directory name.
* @returns {Promise} True when the directory is skipped.
*/
const shouldSkipDirectory = async (targetPath, targetName) =>
- exclusionMatcher(targetPath, targetName) || (await ignoreFilter.isIgnored(targetPath, true));
+ isNeverWalked(targetPath, targetName) || exclusionMatcher(targetPath, targetName) || (await ignoreFilter.isIgnored(targetPath, true));
const requestedRoots =
includeFolders.length > 0
? includeFolders.map((entry) => ({
@@ -232,14 +267,14 @@ export async function discoverFiles(options) {
}
// The container's walk only reaches the candidate if it descends through every directory in
- // between. A directory it never enters (`.git`, a consumer exclusion) hides the candidate's
+ // between. A directory it never enters (`.git`, a dependency folder, a consumer exclusion) hides the candidate's
// files from it, so an explicitly listed folder under such a directory keeps being walked on
// its own. A directory an ignore file ignores does not: everything inside it is ignored too,
// so walking the candidate separately could only return files that are then dropped.
let current = container.rootPath;
for (const segment of candidate.realRoot.slice(containerPrefix.length).split(sep)) {
current = join(current, segment);
- if (NEVER_WALKED_FOLDERS.has(segment) || exclusionMatcher(current, segment)) {
+ if (isNeverWalked(current, segment) || exclusionMatcher(current, segment)) {
return false;
}
}
diff --git a/tests/file-discovery-dependency-folders.test.vitest.mjs b/tests/file-discovery-dependency-folders.test.vitest.mjs
new file mode 100644
index 0000000..47b81a7
--- /dev/null
+++ b/tests/file-discovery-dependency-folders.test.vitest.mjs
@@ -0,0 +1,178 @@
+/**
+ *
+ * @Project: @cldmv/fix-headers
+ * @Filename: /tests/file-discovery-dependency-folders.test.vitest.mjs
+ * @Date: 2026-10-03T17:13:58-07:00 (1791072838)
+ * @Author: Nate Corcoran
+ * @Email:
+ * -----
+ * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com)
+ * @Last modified time: 2026-10-03T17:16:18-07:00 (1791072978)
+ * -----
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
+ *
+ */
+
+import { afterEach, describe, expect, it } from "vitest";
+import { execFile } from "node:child_process";
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import { runCli } from "../src/cli.mjs";
+import { DEPENDENCY_FOLDERS, discoverFiles } from "../src/core/file-discovery.mjs";
+import { fixHeaders } from "../src/core/fix-headers.mjs";
+import { cleanupWorkspace, createWorkspace, writeWorkspaceFile } from "./helpers/workspace.mjs";
+
+/**
+ * @fileoverview Dependency folders (`node_modules`, `bower_components`, `jspm_packages`,
+ * `.pnpm-store`, `.yarn`, and a Composer or Go `vendor`) are never walked, at any depth and
+ * whatever the ignore files say, unless a path inside one is named explicitly (#123).
+ */
+
+const execFileAsync = promisify(execFile);
+const DEPENDENCY_SOURCE = "module.exports = 1;\n";
+
+/**
+ * Project-relative, forward-slashed, sorted file list.
+ * @param {string} base - Fixture root.
+ * @param {string[]} files - Absolute discovered paths.
+ * @returns {string[]} Relative sorted paths.
+ */
+const rel = (base, files) => files.map((file) => file.slice(base.length + 1).replace(/\\/g, "/")).sort();
+
+/**
+ * Writes a fixture project with no `.gitignore`: own source at several depths, and dependency
+ * folders at the root, inside a sub-package, and three or more levels down.
+ * @param {string} root - Fixture root.
+ * @returns {Promise} Completion promise.
+ */
+async function writeProject(root) {
+ const files = {
+ "package.json": JSON.stringify({ name: "dependency-folders" }),
+ "src/a.mjs": "export const a = 1;\n",
+ "node_modules/pkg/index.js": DEPENDENCY_SOURCE,
+ "node_modules/pkg/node_modules/inner/index.js": DEPENDENCY_SOURCE,
+ "packages/sub/package.json": JSON.stringify({ name: "sub" }),
+ "packages/sub/src/b.mjs": "export const b = 2;\n",
+ "packages/sub/node_modules/dep/index.js": DEPENDENCY_SOURCE,
+ "one/two/three/own.mjs": "export const own = 3;\n",
+ "one/two/three/node_modules/deep/index.js": DEPENDENCY_SOURCE,
+ "bower_components/lib/lib.js": DEPENDENCY_SOURCE,
+ "jspm_packages/npm/x.js": DEPENDENCY_SOURCE,
+ ".pnpm-store/v3/files/x.js": DEPENDENCY_SOURCE,
+ ".yarn/unplugged/pkg/index.js": DEPENDENCY_SOURCE,
+ "dist/out.js": "export const out = 1;\n",
+ "build/out.js": "export const built = 1;\n",
+ "coverage/lcov.js": "export const cov = 1;\n"
+ };
+ for (const [path, content] of Object.entries(files)) {
+ await writeWorkspaceFile(join(root, path), content);
+ }
+}
+
+const OWN_FILES = ["build/out.js", "coverage/lcov.js", "dist/out.js", "one/two/three/own.mjs", "packages/sub/src/b.mjs", "src/a.mjs"];
+
+describe("dependency folders are never walked by default", () => {
+ /** @type {string | null} */
+ let root = null;
+
+ afterEach(async () => {
+ if (root) {
+ await cleanupWorkspace(root);
+ }
+ root = null;
+ });
+
+ it("lists the default dependency folders", () => {
+ expect(DEPENDENCY_FOLDERS).toEqual(["node_modules", "bower_components", "jspm_packages", ".pnpm-store", ".yarn"]);
+ });
+
+ it("skips them at the root, in sub-packages and three or more levels down without a .gitignore", async () => {
+ root = await createWorkspace("dependency-folders-default");
+ await writeProject(root);
+
+ expect(rel(root, await discoverFiles({ projectRoot: root }))).toEqual(OWN_FILES);
+ expect(rel(root, await discoverFiles({ projectRoot: root, gitignore: false }))).toEqual(OWN_FILES);
+ });
+
+ it("a default run leaves every dependency file untouched", async () => {
+ root = await createWorkspace("dependency-folders-run");
+ await writeProject(root);
+
+ const lines = [];
+ const code = await runCli(["--cwd", root, "--author-name", "A", "--author-email", "a@x.dev"], { stdout: (line) => lines.push(line) });
+ expect(code).toBe(0);
+ expect(lines[0]).toBe(`fix-headers complete: scanned=${OWN_FILES.length}, updated=${OWN_FILES.length}, dryRun=false`);
+ for (const path of [
+ "node_modules/pkg/index.js",
+ "packages/sub/node_modules/dep/index.js",
+ "one/two/three/node_modules/deep/index.js",
+ ".yarn/unplugged/pkg/index.js"
+ ]) {
+ expect(await readFile(join(root, path), "utf8")).toBe(DEPENDENCY_SOURCE);
+ }
+ expect(await readFile(join(root, "src", "a.mjs"), "utf8")).toMatch(/^\/\*\*\n/);
+ });
+
+ it("skips them even when git tracks them", async () => {
+ root = await createWorkspace("dependency-folders-git");
+ await writeProject(root);
+ await execFileAsync("git", ["init"], { cwd: root });
+ await execFileAsync("git", ["add", "-A"], { cwd: root });
+
+ expect(rel(root, await discoverFiles({ projectRoot: root }))).toEqual(OWN_FILES);
+ });
+
+ it("still processes a dependency folder named with includeFolders or input", async () => {
+ root = await createWorkspace("dependency-folders-explicit");
+ await writeProject(root);
+
+ expect(rel(root, await discoverFiles({ projectRoot: root, includeFolders: ["node_modules/pkg"] }))).toEqual([
+ "node_modules/pkg/index.js"
+ ]);
+ expect(
+ rel(root, await discoverFiles({ projectRoot: root, includeFolders: [".", "node_modules/pkg", "one/two/three/node_modules"] }))
+ ).toEqual([...OWN_FILES, "node_modules/pkg/index.js", "one/two/three/node_modules/deep/index.js"].sort());
+
+ const lines = [];
+ await runCli(["--cwd", root, "--dry-run", "--verbose", "--include-folder", "packages/sub/node_modules"], {
+ stdout: (line) => lines.push(line)
+ });
+ expect(lines).toEqual(["fix-headers complete: scanned=1, updated=1, dryRun=true", "updated: packages/sub/node_modules/dep/index.js"]);
+
+ const folderInput = await fixHeaders({ cwd: root, dryRun: true, input: "node_modules/pkg" });
+ expect(folderInput.changes.map((change) => change.file.replace(/\\/g, "/"))).toEqual(["node_modules/pkg/index.js"]);
+
+ const fileInput = await fixHeaders({ cwd: root, dryRun: true, input: "node_modules/pkg/node_modules/inner/index.js" });
+ expect(fileInput.filesScanned).toBe(1);
+ });
+
+ it("skips a Composer or Go vendor folder but keeps any other folder named vendor", async () => {
+ root = await createWorkspace("dependency-folders-vendor");
+ const files = {
+ "composer/composer.json": JSON.stringify({ name: "acme/app" }),
+ "composer/src/App.php": "hi
\n"
+ };
+ for (const [path, content] of Object.entries(files)) {
+ await writeWorkspaceFile(join(root, path), content);
+ }
+
+ expect(rel(root, await discoverFiles({ projectRoot: root }))).toEqual([
+ "composer/src/App.php",
+ "gomod/main.go",
+ "web/resources/views/vendor/mail/layout.html",
+ "web/vendor/own.js"
+ ]);
+ expect(rel(root, await discoverFiles({ projectRoot: root, includeFolders: [".", "composer/vendor/acme"] }))).toContain(
+ "composer/vendor/acme/lib/Lib.php"
+ );
+ });
+});
diff --git a/tests/file-discovery-gitignore.test.vitest.mjs b/tests/file-discovery-gitignore.test.vitest.mjs
index 49c1c95..03647e5 100644
--- a/tests/file-discovery-gitignore.test.vitest.mjs
+++ b/tests/file-discovery-gitignore.test.vitest.mjs
@@ -23,7 +23,7 @@ import { discoverFiles } from "../src/core/file-discovery.mjs";
* @fileoverview Real-filesystem coverage for discoverFiles' `.gitignore` support outside a git
* work tree (auto-detect, disable, and explicit-file). Only the ignore file decides: `/build`
* skips the top-level build folder while a nested `tools/build` is still processed, and
- * `node_modules` is processed because nothing ignores it (issue #71). Git-backed ignore rules are
+ * `node_modules` is skipped although nothing ignores it, because it is a dependency folder (issue #123). Git-backed ignore rules are
* covered in file-discovery-ignore-files.test.vitest.mjs.
* @module fix-headers/tests/file-discovery-gitignore
*/
@@ -40,7 +40,7 @@ describe("discoverFiles ignore scoping + .gitignore", () => {
});
/**
- * Builds a fixture tree with a .gitignore, a nested vs top-level `build`, and an unignored node_modules.
+ * Builds a fixture tree with a .gitignore, a nested vs top-level `build`, and an unignored node_modules (a dependency folder, skipped anyway).
* @returns {Promise} Absolute fixture root.
*/
async function fixture() {
@@ -69,21 +69,14 @@ describe("discoverFiles ignore scoping + .gitignore", () => {
it("auto-detects .gitignore: skips what it lists (top-level /build, ignored/, *.skip.mjs) and nothing else", async () => {
const base = await fixture();
const files = await discoverFiles({ projectRoot: base, includeExtensions: [".mjs"] });
- // node_modules is not in the .gitignore, so it is processed
- expect(rel(base, files)).toEqual(["node_modules/pkg/dep.mjs", "src/keep.mjs", "tools/build/nested.mjs"]);
+ // node_modules is not in the .gitignore, but dependency folders are never walked
+ expect(rel(base, files)).toEqual(["src/keep.mjs", "tools/build/nested.mjs"]);
});
- it("gitignore:false skips nothing", async () => {
+ it("gitignore:false skips nothing but dependency folders", async () => {
const base = await fixture();
const files = await discoverFiles({ projectRoot: base, includeExtensions: [".mjs"], gitignore: false });
- expect(rel(base, files)).toEqual([
- "build/out.mjs",
- "ignored/a.mjs",
- "node_modules/pkg/dep.mjs",
- "src/drop.skip.mjs",
- "src/keep.mjs",
- "tools/build/nested.mjs"
- ]);
+ expect(rel(base, files)).toEqual(["build/out.mjs", "ignored/a.mjs", "src/drop.skip.mjs", "src/keep.mjs", "tools/build/nested.mjs"]);
});
it("accepts an explicit gitignore file path", async () => {
diff --git a/tests/file-discovery-ignore-files.test.vitest.mjs b/tests/file-discovery-ignore-files.test.vitest.mjs
index 7d271c7..e558165 100644
--- a/tests/file-discovery-ignore-files.test.vitest.mjs
+++ b/tests/file-discovery-ignore-files.test.vitest.mjs
@@ -38,9 +38,8 @@ const execFileAsync = promisify(execFile);
/** Several git spawns per test (fixture setup plus discovery), too slow for vitest's 5s default under load. */
const GIT_TIMEOUT = { timeout: 30_000 };
-/** Folder names the old discovery skipped without any ignore file saying so. */
+/** Build-output folder names the old discovery skipped without any ignore file saying so; now processed unless ignored. */
const FORMERLY_SKIPPED = [
- "node_modules/pkg/index.mjs",
"dist/out.mjs",
"build/out.mjs",
"coverage/report.mjs",
@@ -149,16 +148,16 @@ async function discover(root, options = {}) {
}
describe("discovery inside a git work tree", () => {
- it("includes node_modules, dist, build, coverage, tmp, .next and .turbo when no ignore file lists them", GIT_TIMEOUT, async () => {
+ it("includes dist, build, coverage, tmp, .next and .turbo when no ignore file lists them, never node_modules", GIT_TIMEOUT, async () => {
const root = await gitWorkspace("ignore-none");
- await writeTree(root, ["src/a.mjs", ...FORMERLY_SKIPPED]);
+ await writeTree(root, ["src/a.mjs", "node_modules/pkg/index.mjs", ...FORMERLY_SKIPPED]);
expect(await discover(root)).toEqual([...FORMERLY_SKIPPED, "src/a.mjs"].sort());
});
it("excludes exactly the folders a .gitignore lists", GIT_TIMEOUT, async () => {
const root = await gitWorkspace("ignore-listed");
- await writeTree(root, ["src/a.mjs", ...FORMERLY_SKIPPED]);
+ await writeTree(root, ["src/a.mjs", "node_modules/pkg/index.mjs", ...FORMERLY_SKIPPED]);
await writeTree(root, { ".gitignore": "node_modules/\ndist/\ncoverage/\ntmp/\n" });
expect(await discover(root)).toEqual([".next/server.mjs", ".turbo/cache.mjs", "build/out.mjs", "src/a.mjs"]);
@@ -241,7 +240,7 @@ describe("discovery inside a git work tree", () => {
const root = await gitWorkspace("ignore-exclude-folders");
await writeTree(root, ["src/a.mjs", "src/generated/g.mjs", "dist/d.mjs", "node_modules/pkg/index.mjs"]);
- expect(await discover(root, { excludeFolders: ["dist", "src/generated"] })).toEqual(["node_modules/pkg/index.mjs", "src/a.mjs"]);
+ expect(await discover(root, { excludeFolders: ["dist", "src/generated"] })).toEqual(["src/a.mjs"]);
});
it("uses the repository's rules when discovery starts in a subfolder", GIT_TIMEOUT, async () => {
@@ -272,9 +271,9 @@ describe("discovery inside a git work tree", () => {
});
describe("discovery outside a git work tree", () => {
- it("includes every folder when there are no ignore files", async () => {
+ it("includes every folder but node_modules when there are no ignore files", async () => {
const root = await plainWorkspace("plain-none");
- await writeTree(root, ["src/a.mjs", ...FORMERLY_SKIPPED]);
+ await writeTree(root, ["src/a.mjs", "node_modules/pkg/index.mjs", ...FORMERLY_SKIPPED]);
expect(await discover(root)).toEqual([...FORMERLY_SKIPPED, "src/a.mjs"].sort());
});
@@ -293,8 +292,9 @@ describe("discovery outside a git work tree", () => {
]);
await writeTree(root, { ".gitignore": "dist/\n*.gen.mjs\n", "lib/.gitignore": "!keep.gen.mjs\ncache/\n" });
- expect(await discover(root)).toEqual(["cache/c.mjs", "lib/keep.gen.mjs", "node_modules/pkg/index.mjs", "src/a.mjs"]);
- expect(await discover(root, { gitignore: false })).toHaveLength(8);
+ expect(await discover(root)).toEqual(["cache/c.mjs", "lib/keep.gen.mjs", "src/a.mjs"]);
+ // node_modules is a dependency folder, skipped even with ignore files disabled
+ expect(await discover(root, { gitignore: false })).toHaveLength(7);
});
it("ignores an include folder under an ignored folder, whatever a nested .gitignore re-includes", async () => {
diff --git a/tests/file-discovery-overlap.test.vitest.mjs b/tests/file-discovery-overlap.test.vitest.mjs
index 54ad46d..cccd4c0 100644
--- a/tests/file-discovery-overlap.test.vitest.mjs
+++ b/tests/file-discovery-overlap.test.vitest.mjs
@@ -242,7 +242,7 @@ describe("discoverFiles with overlapping includeFolders (issue #59)", () => {
expect(rel(root, walked)).toEqual(["", "node_modules/pkg"]);
});
- it("walks node_modules and root build folders from '.' when nothing ignores them (issue #71)", async () => {
+ it("walks root build folders from '.' when nothing ignores them (issue #71), and an explicit node_modules folder on its own (issue #123)", async () => {
const root = await fixture(["src/a.mjs", "node_modules/pkg/dep.mjs", "build/out.mjs", "build/sub/deep.mjs"]);
const { discover, walked } = await loadWithWalkSpy();
@@ -253,7 +253,8 @@ describe("discoverFiles with overlapping includeFolders (issue #59)", () => {
});
expect(rel(root, files).sort()).toEqual(["build/out.mjs", "build/sub/deep.mjs", "node_modules/pkg/dep.mjs", "src/a.mjs"]);
- expect(rel(root, walked)).toEqual([""]);
+ // "." never enters node_modules, so the explicitly listed node_modules/pkg is walked separately
+ expect(rel(root, walked)).toEqual(["", "node_modules/pkg"]);
});
it("collapses a symlinked root onto the real folder it points at", async () => {
diff --git a/types/src/core/file-discovery.d.mts b/types/src/core/file-discovery.d.mts
index 6fd9ae5..0cfe459 100644
--- a/types/src/core/file-discovery.d.mts
+++ b/types/src/core/file-discovery.d.mts
@@ -29,6 +29,14 @@ export function discoverFiles(options: {
excludeFolders?: string[];
gitignore?: boolean | string | string[];
}): Promise;
+/**
+ * Package-manager dependency folders. They hold installed third-party code, never the project's
+ * own source, so discovery skips them at any depth whatever the ignore files say (a project with
+ * no `.gitignore`, a sub-package's own `node_modules`, a tracked dependency folder). A folder
+ * named explicitly through `includeFolders` / `input` is still processed.
+ * @type {readonly string[]}
+ */
+export const DEPENDENCY_FOLDERS: readonly string[];
/**
* An `includeFolders` entry: a project-relative folder path (walked recursively), or an object
* form that can switch recursion off so only the folder's own files are included.