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.