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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Common CLI options:
- `--force-last-modified-author-update`
- `--use-gpg-signer-author` (the signing key's UID name, with the OpenPGP UID comment dropped)
- `--cwd <path>`
- `--input <path>` - process one file or folder instead of the whole project. 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
- `--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-non-recursive <path>` (repeatable) - include only that folder's own files, not its subfolders
- `--exclude-folder <path>` (repeatable)
Expand Down Expand Up @@ -115,7 +115,7 @@ Runs header normalization. Project/language/author/email metadata is auto-detect
Important options:

- `cwd?: string` - start directory for project detection
- `input?: string` - explicit single file or folder path to process. A file whose type cannot carry a header is listed in the result's `skipped` instead of being changed (see [Supported file types](#supported-file-types))
- `input?: string | string[]` - file or folder paths to process instead of the whole project. Every path is processed: the files named plus the files discovered under the folders named, each file once, in the order given. A path that does not exist throws; an empty list means no input. A file whose type cannot carry a header is listed in the result's `skipped` instead of being changed (see [Supported file types](#supported-file-types))
- `dryRun?: boolean` - compute changes without writing files
- `check?: boolean` - validate each existing header's dates and write nothing (implies `dryRun`). Each result entry gets `dateIssues`, and the result gets `filesWithDateDrift` and `dateAdvisories`. See [Date checks](#date-checks)
- `fixCreatedDate?: boolean` - move an existing `@Date` back to the oldest of itself, the file's git first commit, and its filesystem creation time (see [Creation date](#creation-date)). It only ever moves `@Date` earlier. Off by default: an existing `@Date` is kept as written
Expand Down
6 changes: 3 additions & 3 deletions src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import fixHeaders from "./fix-header.mjs";
* @module fix-headers/cli
*/

const HELP_TEXT = `fix-headers CLI\n\nUsage:\n fix-headers [options]\n\nOptions:\n -h, --help Show help\n --dry-run Compute changes without writing files\n --check Validate header dates without writing; exit 1 on date drift\n --fix-created-date Move an existing @Date back to the oldest of git first commit / file creation\n --strict-created-date With --check, fail when @Date is later than git first commit / file creation\n --normalize-date-format Write every header date in the ISO 8601 T-form (git %aI)\n --timezone <name> Write new dates in an IANA time zone (e.g. America/Los_Angeles, UTC); the instant never changes\n --convert-timezone With --timezone, also rewrite existing @Date/@Last modified time values into that zone\n --json Print JSON output\n --verbose Print updated file paths in summary mode; with --sample-output or --diff, also print each file's field differences\n --sample-output Show previous/new header sample for changed files\n --diff Print a unified diff of each changed file's header (implies sample output)\n --force-author-update Always update @Author/@Email to detected/current values\n --force-last-modified-author-update Always update @Last modified by to detected/current values\n --use-gpg-signer-author Use signed-commit UID (%GS) for detected @Author\n --cwd <path> Working directory for project detection\n --input <path> Single file or folder input\n --include-folder <path> Include folder (repeatable)\n --include-folder-non-recursive <path> Include only a folder's own files, not its subfolders (repeatable)\n --exclude-folder <path> Exclude folder name/path (repeatable)\n --include-extension <ext> Include extension (repeatable)\n --enable-detector <id> Enable only specific detector (repeatable)\n --disable-detector <id> Disable detector by id (repeatable)\n --force-detector <id> Use a force-only detector, e.g. markdown (HTML-comment headers in .md files) (repeatable)\n --project-name <name> Override project name\n --language <id> Override language id\n --project-root <path> Override project root\n --marker <name|null> Override marker filename\n --author-name <name> Override author name\n --author-email <email> Override author email\n --company <name> Append company suffix to @Author (Name <Company>)\n --company-name <name> @Copyright holder (default: the project manifest's author; omitted when none)\n --copyright-start-year <year> Set the copyright start year (default: each file's @Date year)\n --spacing <n> Empty comment lines just inside the header's opening and closing (default: 1)\n --margin <n> Blank lines between the header and the file's next content (default: 2)\n --config <path> Load JSON options file; its 'extends' can pull in shared configs (URL, package or path)\n\nExamples:\n fix-headers --dry-run --include-folder src\n fix-headers --dry-run --diff --verbose\n fix-headers --check --verbose\n fix-headers --timezone America/Los_Angeles --convert-timezone\n fix-headers --project-name @scope/pkg --company-name "Catalyzed Motivation Inc."\n`;
const HELP_TEXT = `fix-headers CLI\n\nUsage:\n fix-headers [options]\n\nOptions:\n -h, --help Show help\n --dry-run Compute changes without writing files\n --check Validate header dates without writing; exit 1 on date drift\n --fix-created-date Move an existing @Date back to the oldest of git first commit / file creation\n --strict-created-date With --check, fail when @Date is later than git first commit / file creation\n --normalize-date-format Write every header date in the ISO 8601 T-form (git %aI)\n --timezone <name> Write new dates in an IANA time zone (e.g. America/Los_Angeles, UTC); the instant never changes\n --convert-timezone With --timezone, also rewrite existing @Date/@Last modified time values into that zone\n --json Print JSON output\n --verbose Print updated file paths in summary mode; with --sample-output or --diff, also print each file's field differences\n --sample-output Show previous/new header sample for changed files\n --diff Print a unified diff of each changed file's header (implies sample output)\n --force-author-update Always update @Author/@Email to detected/current values\n --force-last-modified-author-update Always update @Last modified by to detected/current values\n --use-gpg-signer-author Use signed-commit UID (%GS) for detected @Author\n --cwd <path> Working directory for project detection\n --input <path> File or folder to process instead of the whole project (repeatable)\n --include-folder <path> Include folder (repeatable)\n --include-folder-non-recursive <path> Include only a folder's own files, not its subfolders (repeatable)\n --exclude-folder <path> Exclude folder name/path (repeatable)\n --include-extension <ext> Include extension (repeatable)\n --enable-detector <id> Enable only specific detector (repeatable)\n --disable-detector <id> Disable detector by id (repeatable)\n --force-detector <id> Use a force-only detector, e.g. markdown (HTML-comment headers in .md files) (repeatable)\n --project-name <name> Override project name\n --language <id> Override language id\n --project-root <path> Override project root\n --marker <name|null> Override marker filename\n --author-name <name> Override author name\n --author-email <email> Override author email\n --company <name> Append company suffix to @Author (Name <Company>)\n --company-name <name> @Copyright holder (default: the project manifest's author; omitted when none)\n --copyright-start-year <year> Set the copyright start year (default: each file's @Date year)\n --spacing <n> Empty comment lines just inside the header's opening and closing (default: 1)\n --margin <n> Blank lines between the header and the file's next content (default: 2)\n --config <path> Load JSON options file; its 'extends' can pull in shared configs (URL, package or path)\n\nExamples:\n fix-headers --dry-run --include-folder src\n fix-headers --dry-run --diff --verbose\n fix-headers --check --verbose\n fix-headers --timezone America/Los_Angeles --convert-timezone\n fix-headers --project-name @scope/pkg --company-name "Catalyzed Motivation Inc."\n`;

/**
* Converts CLI flag token to camelCase key.
Expand Down Expand Up @@ -55,11 +55,11 @@ export function parseCliArgs(argv) {
"include-extension": "includeExtensions",
"enable-detector": "enabledDetectors",
"disable-detector": "disabledDetectors",
"force-detector": "forcedDetectors"
"force-detector": "forcedDetectors",
input: "input"
};
const scalarMap = {
cwd: "cwd",
input: "input",
"project-name": "projectName",
language: "language",
"project-root": "projectRoot",
Expand Down
87 changes: 52 additions & 35 deletions src/core/fix-headers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { assertTimeZone, toDatePayload } from "../utils/time.mjs";
/**
* @typedef {{
* cwd?: string,
* input?: string,
* input?: string | string[],
* dryRun?: boolean,
* check?: boolean,
* fixCreatedDate?: boolean,
Expand Down Expand Up @@ -164,6 +164,23 @@ function resolveRuntimeOptions(options) {
return applyConfigOption(options, "configFile");
}

/**
* Normalizes the `input` option to a list of paths. A string is one path, an array is several
* (CLI `--input` is repeatable); blank entries are dropped, so an empty list means no input.
* @param {unknown} input - Option value.
* @returns {string[]} Non-blank input paths, possibly empty.
*/
function resolveInputs(input) {
if (input === undefined || input === null) {
return [];
}
const list = Array.isArray(input) ? input : [input];
if (list.some((entry) => typeof entry !== "string")) {
throw new Error(`input must be a path or an array of paths, got ${JSON.stringify(input)}`);
}
return list.filter((entry) => entry.trim().length > 0);
}

/**
* Extracts original author identity from an existing header block.
* @param {string} headerText - Existing header content.
Expand Down Expand Up @@ -278,44 +295,44 @@ export async function fixHeaders(options = {}) {
const toZone = timeZone ? (payload) => convertDatePayload(payload, timeZone) : keepDate;
const toZoneIfSweeping = convertTimezone ? toZone : keepDate;

const discoveryOptions = {
language: metadata.language,
enabledDetectors: effectiveOptions.enabledDetectors,
disabledDetectors: effectiveOptions.disabledDetectors,
forcedDetectors,
includeFolders: effectiveOptions.includeFolders,
excludeFolders: effectiveOptions.excludeFolders,
includeExtensions: effectiveOptions.includeExtensions,
gitignore: effectiveOptions.gitignore
};
const inputs = resolveInputs(effectiveOptions.input);

/** @type {string[]} */
let files;
if (typeof effectiveOptions.input === "string" && effectiveOptions.input.trim().length > 0) {
const inputPath = resolve(scanRoot, effectiveOptions.input);
const targetStats = await stat(inputPath).catch(() => null);
if (!targetStats) {
throw new Error(`Input path does not exist: ${effectiveOptions.input}`);
}

if (targetStats.isFile()) {
files = [inputPath];
} else if (targetStats.isDirectory()) {
files = await discoverFiles({
projectRoot: inputPath,
language: metadata.language,
enabledDetectors: effectiveOptions.enabledDetectors,
disabledDetectors: effectiveOptions.disabledDetectors,
forcedDetectors,
includeFolders: effectiveOptions.includeFolders,
excludeFolders: effectiveOptions.excludeFolders,
includeExtensions: effectiveOptions.includeExtensions,
gitignore: effectiveOptions.gitignore
});
} else {
throw new Error(`Input path must be a file or directory: ${effectiveOptions.input}`);
if (inputs.length > 0) {
// Every input is processed: the union of the files named and the files discovered
// under the folders named, each file once, in the order the inputs were given.
const collected = new Set();
for (const input of inputs) {
const inputPath = resolve(scanRoot, input);
const targetStats = await stat(inputPath).catch(() => null);
if (!targetStats) {
throw new Error(`Input path does not exist: ${input}`);
}

if (targetStats.isFile()) {
collected.add(inputPath);
} else if (targetStats.isDirectory()) {
for (const file of await discoverFiles({ ...discoveryOptions, projectRoot: inputPath })) {
collected.add(file);
}
} else {
throw new Error(`Input path must be a file or directory: ${input}`);
}
}
files = Array.from(collected);
} else {
files = await discoverFiles({
projectRoot: scanRoot,
language: metadata.language,
enabledDetectors: effectiveOptions.enabledDetectors,
disabledDetectors: effectiveOptions.disabledDetectors,
forcedDetectors,
includeFolders: effectiveOptions.includeFolders,
excludeFolders: effectiveOptions.excludeFolders,
includeExtensions: effectiveOptions.includeExtensions,
gitignore: effectiveOptions.gitignore
});
files = await discoverFiles({ ...discoveryOptions, projectRoot: scanRoot });
}

const currentYear = new Date().getFullYear();
Expand Down
2 changes: 1 addition & 1 deletion tests/cli.test.vitest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe("cli", () => {
expect(parsed.json).toBe(true);
expect(parsed.options.dryRun).toBe(true);
expect(parsed.options.company).toBe("CLDMV");
expect(parsed.options.input).toBe("src/main.mjs");
expect(parsed.options.input).toEqual(["src/main.mjs"]);
expect(parsed.options.includeFolders).toEqual(["src", "scripts"]);
expect(parsed.options.excludeFolders).toEqual(["dist"]);
expect(parsed.options.enabledDetectors).toEqual(["node"]);
Expand Down
120 changes: 120 additions & 0 deletions tests/repeatable-input.test.vitest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
*
* @Project: @cldmv/fix-headers
* @Filename: /tests/repeatable-input.test.vitest.mjs
* @Date: 2026-10-03T17:10:07-07:00 (1791072607)
* @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:11:21-07:00 (1791072681)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*
*/

import { join } from "node:path";
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { parseCliArgs, runCli } from "../src/cli.mjs";
import { fixHeaders } from "../src/core/fix-headers.mjs";
import { cleanupWorkspace, createWorkspace, writeWorkspaceFile } from "./helpers/workspace.mjs";

/**
* @fileoverview `input` / `--input` takes several paths: every file and folder given is
* processed, each file once (#119).
*/

const IDENTITY = { authorName: "Input Tester", authorEmail: "input@example.com", companyName: "Catalyzed Motivation Inc." };

/**
* Creates a workspace with header-less files in two folders.
* @param {string} name - Workspace name.
* @returns {Promise<string>} Workspace path.
*/
async function createInputWorkspace(name) {
const workspace = await createWorkspace(name);
await writeWorkspaceFile(join(workspace, "package.json"), JSON.stringify({ name: "repeatable-input" }, null, 2));
await writeWorkspaceFile(join(workspace, "a.mjs"), "export const a = 1;\n");
await writeWorkspaceFile(join(workspace, "b.mjs"), "export const b = 2;\n");
await writeWorkspaceFile(join(workspace, "c.mjs"), "export const c = 3;\n");
await writeWorkspaceFile(join(workspace, "lib", "one.mjs"), "export const one = 1;\n");
await writeWorkspaceFile(join(workspace, "lib", "two.mjs"), "export const two = 2;\n");
return workspace;
}

describe("repeatable input", () => {
it("parses repeated --input values into a de-duplicated list", () => {
expect(parseCliArgs(["--input", "a.mjs"]).options.input).toEqual(["a.mjs"]);
expect(parseCliArgs(["--input", "a.mjs", "--input", "b.mjs", "--input", "a.mjs"]).options.input).toEqual(["a.mjs", "b.mjs"]);
expect(() => parseCliArgs(["--input", "a.mjs", "--input"])).toThrow(/Missing value for --input/);
});

it("processes every file given with --input, not just the last", async () => {
const workspace = await createInputWorkspace("input-cli-two-files");
try {
const lines = [];
const code = await runCli(
["--cwd", workspace, "--input", "a.mjs", "--input", "b.mjs", "--author-name", "A", "--author-email", "a@x.dev", "--verbose"],
{ stdout: (message) => lines.push(message) }
);
expect(code).toBe(0);
expect(lines).toEqual(["fix-headers complete: scanned=2, updated=2, dryRun=false", "updated: a.mjs", "updated: b.mjs"]);
expect(await readFile(join(workspace, "a.mjs"), "utf8")).toMatch(/^\/\*\*\n/);
expect(await readFile(join(workspace, "b.mjs"), "utf8")).toMatch(/^\/\*\*\n/);
expect(await readFile(join(workspace, "c.mjs"), "utf8")).toBe("export const c = 3;\n");
} finally {
await cleanupWorkspace(workspace);
}
});

it("takes the union of files and folders, each file once, in the order given", async () => {
const workspace = await createInputWorkspace("input-api-union");
try {
const result = await fixHeaders({
cwd: workspace,
...IDENTITY,
dryRun: true,
input: ["b.mjs", "lib", "lib/one.mjs", "./b.mjs", join(workspace, "a.mjs")]
});
expect(result.changes.map((change) => change.file)).toEqual(["b.mjs", join("lib", "one.mjs"), join("lib", "two.mjs"), "a.mjs"]);
expect(result.filesScanned).toBe(4);
} finally {
await cleanupWorkspace(workspace);
}
});

it("treats a string input as before and an empty list as no input", async () => {
const workspace = await createInputWorkspace("input-api-string");
try {
const single = await fixHeaders({ cwd: workspace, ...IDENTITY, dryRun: true, input: "lib" });
expect(single.filesScanned).toBe(2);

const empty = await fixHeaders({ cwd: workspace, ...IDENTITY, dryRun: true, input: [] });
expect(empty.filesScanned).toBe(5);

const blanks = await fixHeaders({ cwd: workspace, ...IDENTITY, dryRun: true, input: ["", " "] });
expect(blanks.filesScanned).toBe(5);
} finally {
await cleanupWorkspace(workspace);
}
});

it("names the missing path when one of several inputs does not exist, and rejects non-path entries", async () => {
const workspace = await createInputWorkspace("input-api-invalid");
try {
await expect(fixHeaders({ cwd: workspace, ...IDENTITY, dryRun: true, input: ["a.mjs", "missing.mjs"] })).rejects.toThrow(
"Input path does not exist: missing.mjs"
);
await expect(fixHeaders({ cwd: workspace, ...IDENTITY, dryRun: true, input: ["a.mjs", 3] })).rejects.toThrow(
/input must be a path or an array of paths/
);
await expect(fixHeaders({ cwd: workspace, ...IDENTITY, dryRun: true, input: 3 })).rejects.toThrow(
/input must be a path or an array of paths/
);
expect(await readFile(join(workspace, "a.mjs"), "utf8")).toBe("export const a = 1;\n");
} finally {
await cleanupWorkspace(workspace);
}
});
});
Loading
Loading