diff --git a/README.md b/README.md index 710a9be..89d3044 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Common CLI options: - `--json` - `--sample-output` - `--force-author-update` +- `--force-last-modified-author-update` - `--use-gpg-signer-author` - `--cwd ` - `--input ` @@ -103,6 +104,7 @@ Important options: - `authorEmail?: string` - `company?: string` - appends to `@Author` as `Name ` - `forceAuthorUpdate?: boolean` - force update `@Author`/`@Email` to detected or overridden current values +- `forceLastModifiedAuthorUpdate?: boolean` - force update `@Last modified by` to detected or overridden current values. Without this, an existing header's recorded `@Last modified by` identity is preserved and does not by itself trigger an update just because the running author differs (e.g. a different `git config user.name` than whoever last touched the file) - `useGpgSignerAuthor?: boolean` - use signed-commit UID (`%GS`) for detected `@Author` (includes signer comment when present) - `companyName?: string` (default: `Catalyzed Motivation Inc.`) - `copyrightStartYear?: number` (default: current year) diff --git a/src/cli.mjs b/src/cli.mjs index 72c1338..7a2f2cd 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -23,7 +23,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 --json Print JSON output\n --verbose Print updated file paths in summary mode\n --sample-output Show previous/new header sample for changed files\n --force-author-update Always update @Author/@Email 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 --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 --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 Override company name\n --copyright-start-year Override copyright start year\n --config Load JSON options file\n\nExamples:\n fix-headers --dry-run --include-folder src\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 --json Print JSON output\n --verbose Print updated file paths in summary mode\n --sample-output Show previous/new header sample for changed files\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 --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 --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 Override company name\n --copyright-start-year Override copyright start year\n --config Load JSON options file\n\nExamples:\n fix-headers --dry-run --include-folder src\n fix-headers --project-name @scope/pkg --company-name "Catalyzed Motivation Inc."\n`; /** * Converts CLI flag token to camelCase key. @@ -95,6 +95,10 @@ export function parseCliArgs(argv) { options.forceAuthorUpdate = true; continue; } + if (arg === "--force-last-modified-author-update") { + options.forceLastModifiedAuthorUpdate = true; + continue; + } if (arg === "--use-gpg-signer-author") { options.useGpgSignerAuthor = true; continue; diff --git a/src/core/fix-headers.mjs b/src/core/fix-headers.mjs index 34c0773..77593f0 100644 --- a/src/core/fix-headers.mjs +++ b/src/core/fix-headers.mjs @@ -29,6 +29,7 @@ import { toDatePayload } from "../utils/time.mjs"; * configFile?: string, * sampleOutput?: boolean, * forceAuthorUpdate?: boolean, + * forceLastModifiedAuthorUpdate?: boolean, * useGpgSignerAuthor?: boolean, * enabledDetectors?: string[], * disabledDetectors?: string[], @@ -129,6 +130,20 @@ function extractHeaderAuthorIdentity(headerText) { }; } +/** + * Extracts original last-modified-by identity from an existing header block. + * @param {string} headerText - Existing header content. + * @returns {{ authorName?: string, authorEmail?: string }} Parsed identity values. + */ +function extractHeaderLastModifiedIdentity(headerText) { + const match = headerText.match(/@Last modified by:\s*(.+?)\s*\(([^)\n]+)\)\s*$/m); + + return { + authorName: match?.[1]?.trim(), + authorEmail: match?.[2]?.trim() + }; +} + /** * Extracts original created-at payload from an existing header block. * @param {string} headerText - Existing header content. @@ -253,6 +268,7 @@ export async function fixHeaders(options = {}) { }); const existingHeaderText = existingHeader ? original.slice(existingHeader.start, existingHeader.end) : ""; const existingIdentity = existingHeaderText.length > 0 ? extractHeaderAuthorIdentity(existingHeaderText) : {}; + const existingLastModifiedIdentity = existingHeaderText.length > 0 ? extractHeaderLastModifiedIdentity(existingHeaderText) : {}; const existingCreatedAt = existingHeaderText.length > 0 ? extractHeaderCreatedAt(existingHeaderText) : null; const existingLastModifiedAt = existingHeaderText.length > 0 ? extractHeaderLastModifiedAt(existingHeaderText) : null; const filesystemDates = await readFileDates(filePath); @@ -270,6 +286,7 @@ export async function fixHeaders(options = {}) { const createdAt = existingCreatedAt || gitCreated || toDatePayload(filesystemDates.createdAt); const comparisonLastModifiedAt = existingLastModifiedAt || gitLastUpdated || toDatePayload(filesystemDates.updatedAt); const shouldForceAuthorUpdate = effectiveOptions.forceAuthorUpdate === true; + const shouldForceLastModifiedAuthorUpdate = effectiveOptions.forceLastModifiedAuthorUpdate === true; const comparisonHeader = buildHeader({ absoluteFilePath: filePath, @@ -284,8 +301,12 @@ export async function fixHeaders(options = {}) { projectName: fileMetadata.projectName, createdByName: shouldForceAuthorUpdate ? fileMetadata.authorName : existingIdentity.authorName || fileMetadata.authorName, createdByEmail: shouldForceAuthorUpdate ? fileMetadata.authorEmail : existingIdentity.authorEmail || fileMetadata.authorEmail, - lastModifiedByName: fileMetadata.authorName, - lastModifiedByEmail: fileMetadata.authorEmail, + lastModifiedByName: shouldForceLastModifiedAuthorUpdate + ? fileMetadata.authorName + : existingLastModifiedIdentity.authorName || fileMetadata.authorName, + lastModifiedByEmail: shouldForceLastModifiedAuthorUpdate + ? fileMetadata.authorEmail + : existingLastModifiedIdentity.authorEmail || fileMetadata.authorEmail, authorName: fileMetadata.authorName, authorEmail: fileMetadata.authorEmail, createdAt, @@ -319,8 +340,12 @@ export async function fixHeaders(options = {}) { projectName: fileMetadata.projectName, createdByName: shouldForceAuthorUpdate ? fileMetadata.authorName : existingIdentity.authorName || fileMetadata.authorName, createdByEmail: shouldForceAuthorUpdate ? fileMetadata.authorEmail : existingIdentity.authorEmail || fileMetadata.authorEmail, - lastModifiedByName: fileMetadata.authorName, - lastModifiedByEmail: fileMetadata.authorEmail, + lastModifiedByName: shouldForceLastModifiedAuthorUpdate + ? fileMetadata.authorName + : existingLastModifiedIdentity.authorName || fileMetadata.authorName, + lastModifiedByEmail: shouldForceLastModifiedAuthorUpdate + ? fileMetadata.authorEmail + : existingLastModifiedIdentity.authorEmail || fileMetadata.authorEmail, authorName: fileMetadata.authorName, authorEmail: fileMetadata.authorEmail, createdAt, diff --git a/tests/cli.test.vitest.mjs b/tests/cli.test.vitest.mjs index 0e17c0f..e24e637 100644 --- a/tests/cli.test.vitest.mjs +++ b/tests/cli.test.vitest.mjs @@ -89,6 +89,11 @@ describe("cli", () => { expect(parsed.options.forceAuthorUpdate).toBe(true); }); + it("parses force-last-modified-author-update flag", () => { + const parsed = parseCliArgs(["--force-last-modified-author-update"]); + expect(parsed.options.forceLastModifiedAuthorUpdate).toBe(true); + }); + it("parses use-gpg-signer-author flag", () => { const parsed = parseCliArgs(["--use-gpg-signer-author"]); expect(parsed.options.useGpgSignerAuthor).toBe(true); diff --git a/tests/core-edge.test.vitest.mjs b/tests/core-edge.test.vitest.mjs index 8b91a5d..5b5f058 100644 --- a/tests/core-edge.test.vitest.mjs +++ b/tests/core-edge.test.vitest.mjs @@ -204,21 +204,56 @@ describe("core edge coverage", () => { } }); - it("preserves original header author and updates only last modified by", async () => { + it("preserves original header author and last-modified identity when nothing else changes", async () => { const workspace = await createWorkspace("core-edge-preserve-author"); + const currentYear = new Date().getFullYear(); try { await writeWorkspaceFile(join(workspace, "package.json"), JSON.stringify({ name: "core-edge-preserve-author" }, null, 2)); await writeWorkspaceFile( join(workspace, "src", "one.mjs"), - `/**\n *\t@Project: core-edge-preserve-author\n *\t@Filename: /src/one.mjs\n *\t@Date: 2026-01-01 00:00:00 +00:00 (1735689600)\n *\t@Author: Original Author\n *\t@Email: \n *\t-----\n *\t@Last modified by: Old Updater (old@example.com)\n *\t@Last modified time: 2026-01-02 00:00:00 +00:00 (1735776000)\n *\t-----\n *\t@Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.\n */\n\nexport const one = true;\n` + `/**\n *\t@Project: core-edge-preserve-author\n *\t@Filename: /src/one.mjs\n *\t@Date: 2026-01-01 00:00:00 +00:00 (1735689600)\n *\t@Author: Original Author\n *\t@Email: \n *\t-----\n *\t@Last modified by: Old Updater (old@example.com)\n *\t@Last modified time: 2026-01-02 00:00:00 +00:00 (1735776000)\n *\t-----\n *\t@Copyright: Copyright (c) ${currentYear}-${currentYear} Catalyzed Motivation Inc. All rights reserved.\n */\n\nexport const one = true;\n` ); - await coreFixHeaders({ + const result = await coreFixHeaders({ + cwd: workspace, + input: "src/one.mjs", + authorName: "New Updater", + authorEmail: "new@example.com", + dryRun: false + }); + + const updated = await readFile(join(workspace, "src", "one.mjs"), "utf8"); + expect(updated).toContain("@Author: Original Author"); + expect(updated).toContain("@Email: "); + expect(updated).toContain("@Last modified by: Old Updater (old@example.com)"); + expect(updated).toContain("@Last modified time: 2026-01-02 00:00:00 +00:00 (1735776000)"); + expect(result.filesUpdated).toBe(0); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("forces last-modified identity update when enabled without forcing created-by", async () => { + const workspace = await createWorkspace("core-edge-force-last-modified-author-update"); + const currentYear = new Date().getFullYear(); + + try { + await writeWorkspaceFile( + join(workspace, "package.json"), + JSON.stringify({ name: "core-edge-force-last-modified-author-update" }, null, 2) + ); + await writeWorkspaceFile( + join(workspace, "src", "one.mjs"), + `/**\n *\t@Project: core-edge-force-last-modified-author-update\n *\t@Filename: /src/one.mjs\n *\t@Date: 2026-01-01 00:00:00 +00:00 (1735689600)\n *\t@Author: Original Author\n *\t@Email: \n *\t-----\n *\t@Last modified by: Old Updater (old@example.com)\n *\t@Last modified time: 2026-01-02 00:00:00 +00:00 (1735776000)\n *\t-----\n *\t@Copyright: Copyright (c) ${currentYear}-${currentYear} Catalyzed Motivation Inc. All rights reserved.\n */\n\nexport const one = true;\n` + ); + + const result = await coreFixHeaders({ cwd: workspace, input: "src/one.mjs", authorName: "New Updater", authorEmail: "new@example.com", + forceLastModifiedAuthorUpdate: true, dryRun: false }); @@ -226,6 +261,8 @@ describe("core edge coverage", () => { expect(updated).toContain("@Author: Original Author"); expect(updated).toContain("@Email: "); expect(updated).toContain("@Last modified by: New Updater (new@example.com)"); + expect(updated).not.toContain("@Last modified time: 2026-01-02 00:00:00 +00:00 (1735776000)"); + expect(result.filesUpdated).toBe(1); } finally { await cleanupWorkspace(workspace); } @@ -259,6 +296,34 @@ describe("core edge coverage", () => { } }); + it("does not cascade a forced author update into the last-modified identity of an unrelated rewrite", async () => { + const workspace = await createWorkspace("core-edge-force-author-no-cascade"); + + try { + await writeWorkspaceFile(join(workspace, "package.json"), JSON.stringify({ name: "core-edge-force-author-no-cascade" }, null, 2)); + await writeWorkspaceFile( + join(workspace, "src", "one.mjs"), + `/**\n *\t@Project: core-edge-force-author-no-cascade\n *\t@Filename: /src/one.mjs\n *\t@Date: 2026-01-01 00:00:00 +00:00 (1735689600)\n *\t@Author: Original Author\n *\t@Email: \n *\t-----\n *\t@Last modified by: Old Updater (old@example.com)\n *\t@Last modified time: 2026-01-02 00:00:00 +00:00 (1735776000)\n *\t-----\n *\t@Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc All rights reserved.\n */\n\nexport const one = true;\n` + ); + + await coreFixHeaders({ + cwd: workspace, + input: "src/one.mjs", + authorName: "Forced Author", + authorEmail: "forced@example.com", + forceAuthorUpdate: true, + dryRun: false + }); + + const updated = await readFile(join(workspace, "src", "one.mjs"), "utf8"); + expect(updated).toContain("@Author: Forced Author"); + expect(updated).toContain("@Last modified by: Old Updater (old@example.com)"); + expect(updated).not.toContain("@Last modified time: 2026-01-02 00:00:00 +00:00 (1735776000)"); + } finally { + await cleanupWorkspace(workspace); + } + }); + it("applies company suffix to generated author line", async () => { const workspace = await createWorkspace("core-edge-author-company"); diff --git a/types/src/constants.d.mts b/types/src/constants.d.mts index 3c8b3e9..c39eb54 100644 --- a/types/src/constants.d.mts +++ b/types/src/constants.d.mts @@ -1,7 +1,27 @@ /** @type {string} */ export const DEFAULT_COMPANY_NAME: string; -/** @type {number} */ +/** Header must sit near the top of the file, but a metadata block can legitimately + * run long; cap the scan generously so a long block's closing `*​/` is still seen. + * @type {number} */ export const DEFAULT_MAX_HEADER_SCAN_LINES: number; -/** @type {Set} */ +/** + * Folders skipped at ANY depth — vendored / VCS directories that are never source and can + * legitimately nest (hoisted `node_modules`, submodule `.git`). + * @type {Set} + */ +export const ALWAYS_IGNORE_FOLDERS: Set; +/** + * Folders skipped ONLY at the project root — build / cache output directories. Anchored to + * the root so a nested SOURCE directory that happens to share the name (e.g. `tools/build`, + * `packages/x/dist`-style source) is still processed; only the top-level `/build`, `/dist`, + * `/coverage`, … are ignored. + * @type {Set} + */ +export const ROOT_IGNORE_FOLDERS: Set; +/** + * Backward-compatible union of {@link ALWAYS_IGNORE_FOLDERS} and {@link ROOT_IGNORE_FOLDERS}. + * Discovery applies the two sets with different scoping; prefer the specific sets. + * @type {Set} + */ export const DEFAULT_IGNORE_FOLDERS: Set; export { DETECTOR_PROFILES, getAllowedExtensions, getEnabledDetectors } from "./detectors/index.mjs"; diff --git a/types/src/core/file-discovery.d.mts b/types/src/core/file-discovery.d.mts index db289cb..b364b62 100644 --- a/types/src/core/file-discovery.d.mts +++ b/types/src/core/file-discovery.d.mts @@ -7,8 +7,10 @@ * enabledDetectors?: string[], * disabledDetectors?: string[], * includeFolders?: string[], - * excludeFolders?: string[] - * }} options - File discovery options. + * excludeFolders?: string[], + * gitignore?: boolean | string | string[] + * }} options - File discovery options. `gitignore`: `false` disables; a path or array of + * paths loads those ignore files; anything else / omitted auto-detects `/.gitignore`. * @returns {Promise} Absolute file paths. */ export function discoverFiles(options: { @@ -19,4 +21,5 @@ export function discoverFiles(options: { disabledDetectors?: string[]; includeFolders?: string[]; excludeFolders?: string[]; + gitignore?: boolean | string | string[]; }): Promise; diff --git a/types/src/core/fix-headers.d.mts b/types/src/core/fix-headers.d.mts index bd3fc93..da4649b 100644 --- a/types/src/core/fix-headers.d.mts +++ b/types/src/core/fix-headers.d.mts @@ -15,6 +15,7 @@ export type FixHeadersOptions = { configFile?: string; sampleOutput?: boolean; forceAuthorUpdate?: boolean; + forceLastModifiedAuthorUpdate?: boolean; useGpgSignerAuthor?: boolean; enabledDetectors?: string[]; disabledDetectors?: string[]; @@ -28,6 +29,7 @@ export type FixHeadersOptions = { includeFolders?: string[]; excludeFolders?: string[]; includeExtensions?: string[]; + gitignore?: boolean | string | string[]; projectName?: string; language?: string; projectRoot?: string; diff --git a/types/src/detectors/index.d.mts b/types/src/detectors/index.d.mts index 3a932d7..d8aa5d9 100644 --- a/types/src/detectors/index.d.mts +++ b/types/src/detectors/index.d.mts @@ -26,13 +26,14 @@ export function getDetectorById(id: string): (typeof DETECTOR_PROFILES)[number] /** * Resolves comment syntax for a file path using detector-specific templates. * @param {string} filePath - File path. - * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], detectorSyntaxOverrides?: Record }} [options={}] - Runtime options. + * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], detectors?: DetectorProfile[], detectorSyntaxOverrides?: Record }} [options={}] - Runtime options. `detectors` overrides the enabled-detector set (matching {@link detectProjectFromMarkers}). * @returns {{kind: "block" | "line" | "html", linePrefix?: string, lineSeparator?: string, blockStart?: string, blockLinePrefix?: string, blockEnd?: string}} Syntax descriptor. */ export function getCommentSyntaxForFile(filePath: string, options?: { language?: string; enabledDetectors?: string[]; disabledDetectors?: string[]; + detectors?: DetectorProfile[]; detectorSyntaxOverrides?: Record