From be9b89bf629efa1ca5ff9f973eae02f6c0286fb1 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Sat, 3 Oct 2026 17:12:35 -0700 Subject: [PATCH] fix(cli): process every --input value instead of only the last --input was a scalar flag, so repeating it silently kept the last value: `--input a.mjs --input b.mjs` scanned only b.mjs. It is now repeatable, like --include-folder: the CLI collects every value (de-duplicated) and the `input` option accepts a string or an array of paths. The run processes the union of the files named and the files discovered under the folders named, each file once, in the order given. A missing path still throws and names that path; a non-string entry throws; an empty list means no input (a normal repo-wide run). Fixes #119 --- README.md | 4 +- src/cli.mjs | 6 +- src/core/fix-headers.mjs | 87 ++++++++++-------- tests/cli.test.vitest.mjs | 2 +- tests/repeatable-input.test.vitest.mjs | 120 +++++++++++++++++++++++++ types/src/core/fix-headers.d.mts | 2 +- 6 files changed, 179 insertions(+), 42 deletions(-) create mode 100644 tests/repeatable-input.test.vitest.mjs diff --git a/README.md b/README.md index 297a539..da80be8 100644 --- a/README.md +++ b/README.md @@ -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 ` -- `--input ` - 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: ()` and left unchanged +- `--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-non-recursive ` (repeatable) - include only that folder's own files, not its subfolders - `--exclude-folder ` (repeatable) @@ -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 diff --git a/src/cli.mjs b/src/cli.mjs index b709a36..5f64c9a 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -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 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 Working directory for project detection\n --input Single file or folder input\n --include-folder Include folder (repeatable)\n --include-folder-non-recursive Include only a folder's own files, not its subfolders (repeatable)\n --exclude-folder Exclude folder name/path (repeatable)\n --include-extension Include extension (repeatable)\n --enable-detector Enable only specific detector (repeatable)\n --disable-detector Disable detector by id (repeatable)\n --force-detector Use a force-only detector, e.g. markdown (HTML-comment headers in .md files) (repeatable)\n --project-name Override project name\n --language Override language id\n --project-root Override project root\n --marker Override marker filename\n --author-name Override author name\n --author-email Override author email\n --company Append company suffix to @Author (Name )\n --company-name @Copyright holder (default: the project manifest's author; omitted when none)\n --copyright-start-year Set the copyright start year (default: each file's @Date year)\n --spacing Empty comment lines just inside the header's opening and closing (default: 1)\n --margin Blank lines between the header and the file's next content (default: 2)\n --config 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 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 Working directory for project detection\n --input File or folder to process instead of the whole project (repeatable)\n --include-folder Include folder (repeatable)\n --include-folder-non-recursive Include only a folder's own files, not its subfolders (repeatable)\n --exclude-folder Exclude folder name/path (repeatable)\n --include-extension Include extension (repeatable)\n --enable-detector Enable only specific detector (repeatable)\n --disable-detector Disable detector by id (repeatable)\n --force-detector Use a force-only detector, e.g. markdown (HTML-comment headers in .md files) (repeatable)\n --project-name Override project name\n --language Override language id\n --project-root Override project root\n --marker Override marker filename\n --author-name Override author name\n --author-email Override author email\n --company Append company suffix to @Author (Name )\n --company-name @Copyright holder (default: the project manifest's author; omitted when none)\n --copyright-start-year Set the copyright start year (default: each file's @Date year)\n --spacing Empty comment lines just inside the header's opening and closing (default: 1)\n --margin Blank lines between the header and the file's next content (default: 2)\n --config 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. @@ -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", diff --git a/src/core/fix-headers.mjs b/src/core/fix-headers.mjs index 072b9cd..8157cde 100644 --- a/src/core/fix-headers.mjs +++ b/src/core/fix-headers.mjs @@ -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, @@ -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. @@ -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(); diff --git a/tests/cli.test.vitest.mjs b/tests/cli.test.vitest.mjs index 619b117..f6b5c53 100644 --- a/tests/cli.test.vitest.mjs +++ b/tests/cli.test.vitest.mjs @@ -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"]); diff --git a/tests/repeatable-input.test.vitest.mjs b/tests/repeatable-input.test.vitest.mjs new file mode 100644 index 0000000..549ebd7 --- /dev/null +++ b/tests/repeatable-input.test.vitest.mjs @@ -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 + * @Email: + * ----- + * @Last modified by: Nate Corcoran (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} 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); + } + }); +}); diff --git a/types/src/core/fix-headers.d.mts b/types/src/core/fix-headers.d.mts index 46fd130..31bf043 100644 --- a/types/src/core/fix-headers.d.mts +++ b/types/src/core/fix-headers.d.mts @@ -10,7 +10,7 @@ export function fixHeaders(options?: FixHeadersOptions): Promise; export type FixHeadersOptions = { cwd?: string; - input?: string; + input?: string | string[]; dryRun?: boolean; check?: boolean; fixCreatedDate?: boolean;