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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Common CLI options:
- `--json`
- `--sample-output`
- `--force-author-update`
- `--force-last-modified-author-update`
- `--use-gpg-signer-author`
- `--cwd <path>`
- `--input <path>`
Expand Down Expand Up @@ -103,6 +104,7 @@ Important options:
- `authorEmail?: string`
- `company?: string` - appends to `@Author` as `Name <Company>`
- `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)
Expand Down
6 changes: 5 additions & 1 deletion src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> Working directory for project detection\n --input <path> Single file or folder input\n --include-folder <path> Include folder (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 --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> Override company name\n --copyright-start-year <year> Override copyright start year\n --config <path> 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 <path> Working directory for project detection\n --input <path> Single file or folder input\n --include-folder <path> Include folder (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 --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> Override company name\n --copyright-start-year <year> Override copyright start year\n --config <path> 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.
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 29 additions & 4 deletions src/core/fix-headers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { toDatePayload } from "../utils/time.mjs";
* configFile?: string,
* sampleOutput?: boolean,
* forceAuthorUpdate?: boolean,
* forceLastModifiedAuthorUpdate?: boolean,
* useGpgSignerAuthor?: boolean,
* enabledDetectors?: string[],
* disabledDetectors?: string[],
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions tests/cli.test.vitest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
71 changes: 68 additions & 3 deletions tests/core-edge.test.vitest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -204,28 +204,65 @@ 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: <original@example.com>\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: <original@example.com>\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: <original@example.com>");
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: <original@example.com>\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
});

const updated = await readFile(join(workspace, "src", "one.mjs"), "utf8");
expect(updated).toContain("@Author: Original Author");
expect(updated).toContain("@Email: <original@example.com>");
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);
}
Expand Down Expand Up @@ -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: <original@example.com>\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");

Expand Down
24 changes: 22 additions & 2 deletions types/src/constants.d.mts
Original file line number Diff line number Diff line change
@@ -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<string>} */
/**
* Folders skipped at ANY depth β€” vendored / VCS directories that are never source and can
* legitimately nest (hoisted `node_modules`, submodule `.git`).
* @type {Set<string>}
*/
export const ALWAYS_IGNORE_FOLDERS: Set<string>;
/**
* 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<string>}
*/
export const ROOT_IGNORE_FOLDERS: Set<string>;
/**
* 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<string>}
*/
export const DEFAULT_IGNORE_FOLDERS: Set<string>;
export { DETECTOR_PROFILES, getAllowedExtensions, getEnabledDetectors } from "./detectors/index.mjs";
7 changes: 5 additions & 2 deletions types/src/core/file-discovery.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<projectRoot>/.gitignore`.
* @returns {Promise<string[]>} Absolute file paths.
*/
export function discoverFiles(options: {
Expand All @@ -19,4 +21,5 @@ export function discoverFiles(options: {
disabledDetectors?: string[];
includeFolders?: string[];
excludeFolders?: string[];
gitignore?: boolean | string | string[];
}): Promise<string[]>;
2 changes: 2 additions & 0 deletions types/src/core/fix-headers.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type FixHeadersOptions = {
configFile?: string;
sampleOutput?: boolean;
forceAuthorUpdate?: boolean;
forceLastModifiedAuthorUpdate?: boolean;
useGpgSignerAuthor?: boolean;
enabledDetectors?: string[];
disabledDetectors?: string[];
Expand All @@ -28,6 +29,7 @@ export type FixHeadersOptions = {
includeFolders?: string[];
excludeFolders?: string[];
includeExtensions?: string[];
gitignore?: boolean | string | string[];
projectName?: string;
language?: string;
projectRoot?: string;
Expand Down
3 changes: 2 additions & 1 deletion types/src/detectors/index.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { linePrefix?: string, lineSeparator?: string, blockStart?: string, blockLinePrefix?: string, blockEnd?: string }> }} [options={}] - Runtime options.
* @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], detectors?: DetectorProfile[], detectorSyntaxOverrides?: Record<string, { linePrefix?: string, lineSeparator?: string, blockStart?: string, blockLinePrefix?: string, blockEnd?: string }> }} [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<string, {
linePrefix?: string;
lineSeparator?: string;
Expand Down
Loading