Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Common CLI options:
- `--use-gpg-signer-author` (the signing key's UID name, with the OpenPGP UID comment dropped)
- `--cwd <path>`
- `--input <path>` (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: <file> (<reason>)` and left unchanged
- `--include-folder <path>` (repeatable)
- `--include-folder <path>` (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 <path>` (repeatable) - include only that folder's own files, not its subfolders
- `--exclude-folder <path>` (repeatable)
- `--include-extension <ext>` (repeatable)
Expand Down Expand Up @@ -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<string, { linePrefix?: string, lineSeparator?: string, blockStart?: string, blockLinePrefix?: string, blockEnd?: string }>` - override detector comment syntax tokens
- `includeFolders?: Array<string | { path: string, recursive?: boolean }>` - 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
Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand Down
49 changes: 42 additions & 7 deletions src/core/file-discovery.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string>}
*/
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.
Expand Down Expand Up @@ -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<boolean>} 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) => ({
Expand Down Expand Up @@ -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;
}
}
Expand Down
178 changes: 178 additions & 0 deletions tests/file-discovery-dependency-folders.test.vitest.mjs
Original file line number Diff line number Diff line change
@@ -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 <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (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<void>} 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": "<?php\n",
"composer/vendor/autoload.php": "<?php\n",
"composer/vendor/acme/lib/Lib.php": "<?php\n",
"gomod/go.mod": "module example.com/app\n",
"gomod/main.go": "package main\n",
"gomod/vendor/modules.txt": "# example.com/dep v1.0.0\n",
"gomod/vendor/example.com/dep/dep.go": "package dep\n",
"web/vendor/own.js": "export const own = 1;\n",
"web/resources/views/vendor/mail/layout.html": "<p>hi</p>\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"
);
});
});
Loading
Loading