From 082977f2aa872364976a9533857c75d8576dac55 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Sat, 3 Oct 2026 17:09:37 -0700 Subject: [PATCH] fix: never write a comment into files that cannot carry one; force-only Markdown headers A file is now given a header only when an enabled detector handles its extension. Everything else is skipped and left unchanged, even when it is named with --input or its extension is added through includeExtensions: strict .json (a comment broke package.json with ERR_INVALID_PACKAGE_CONFIG), files with no extension, extensions no enabled detector handles, and a disabled detector's extensions. Before, all of these fell back to a JS /** */ block. Markdown gets a new force-only `markdown` detector (.md, .markdown). It is used only when forced with --force-detector markdown (forcedDetectors: ["markdown"] in the API or a config file); naming a .md file with --input does not force it. A forced header is an HTML comment placed below any YAML front matter, and re-runs update it in place. Skipped files are reported in the result's `skipped` ({ file, reason }) and `filesSkipped`, not in filesScanned/changes; the CLI summary adds `skipped=` and prints a `skipped: ()` line per file. forcedDetectors rejects unknown ids and ids that do not need forcing. The README gains a Supported file types table that matches the detectors, and documents --force-detector / forcedDetectors. Fixes #122 Fixes #120 --- README.md | 46 +++- src/cli.mjs | 27 +- src/core/file-discovery.mjs | 4 +- src/core/fix-headers.mjs | 77 +++--- src/detect/project.mjs | 3 +- src/detectors/index.mjs | 80 +++++- src/detectors/markdown.mjs | 69 ++++++ src/header/parser.mjs | 6 +- src/header/syntax.mjs | 2 +- src/header/template.mjs | 2 +- tests/branches.test.vitest.mjs | 2 +- tests/non-js-formats.test.vitest.mjs | 352 +++++++++++++++++++++++++++ types/src/core/file-discovery.d.mts | 2 + types/src/core/fix-headers.d.mts | 12 + types/src/detect/project.d.mts | 5 +- types/src/detectors/index.d.mts | 47 +++- types/src/detectors/markdown.d.mts | 21 ++ types/src/header/parser.d.mts | 6 +- types/src/header/syntax.d.mts | 3 +- types/src/header/template.d.mts | 3 +- 20 files changed, 695 insertions(+), 74 deletions(-) create mode 100644 src/detectors/markdown.mjs create mode 100644 tests/non-js-formats.test.vitest.mjs create mode 100644 types/src/detectors/markdown.d.mts diff --git a/README.md b/README.md index ed0f942..297a539 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,12 @@ Multi-language source header normalizer for Node.js projects. ## Features - Finds the project each file belongs to from its manifest (`package.json`, `pyproject.toml` / `setup.cfg` / `setup.py`, `composer.json`, `Cargo.toml`, `go.mod`), whatever the file's type -- `@Project` is the name from that project's manifest, so a CSS, HTML, YAML or JSON file in a Python, PHP, Rust or Go project gets that project's name too; the folder name is used only when no manifest provides one. Override it with `projectName`. See [Project name and root](#project-name-and-root) +- `@Project` is the name from that project's manifest, so a CSS, HTML, YAML or JSONC file in a Python, PHP, Rust or Go project gets that project's name too; the folder name is used only when no manifest provides one. Override it with `projectName`. See [Project name and root](#project-name-and-root) - Auto-detects author and email from git config/commit history - Supports per-run overrides for every detected value - Supports folder inclusion and exclusion configuration; skips only what the project's ignore files (everything git honours) or your own exclusions say - Supports monorepos: every file resolves its own project from the nearest manifest in its parent tree +- Writes each header in the file's own comment syntax, and skips files that cannot carry one (strict JSON, plain text); Markdown gets a header only when you force it. See [Supported file types](#supported-file-types) - Supports per-detector syntax overrides for line and block comment tokens - Config files can use `extends` to build on a shared config (an https URL, an npm package path or a file path), so one organisation-wide config serves every repository. See [Shared configs](#shared-configs) - Supports both ESM and CJS consumers @@ -83,12 +84,13 @@ 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 ` +- `--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 - `--include-folder ` (repeatable) - `--include-folder-non-recursive ` (repeatable) - include only that folder's own files, not its subfolders - `--exclude-folder ` (repeatable) - `--include-extension ` (repeatable) - `--enable-detector ` / `--disable-detector ` (repeatable) +- `--force-detector ` (repeatable) - turn on a force-only detector; `--force-detector markdown` gives `.md` / `.markdown` files an HTML-comment header (see [Supported file types](#supported-file-types)) - `--project-name ` - `--author-name ` / `--author-email ` - `--company-name ` - the `@Copyright` holder for every file, instead of the one the manifests provide (see [Copyright holder](#copyright-holder)) @@ -113,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 +- `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)) - `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 @@ -124,8 +126,9 @@ Important options: - `sampleOutput?: boolean` - include a `sample` for each changed file: previous/new header text, a unified `diff`, per-field `issues`, and `detectedValues` (see [Sample output](#sample-output)) - `configFile?: string` - load JSON options from file (resolved from `cwd`). The file may use `extends` to build on shared configs by URL, npm package path or file path (see [Shared configs](#shared-configs)); options passed in the call win over everything from files - `includeExtensions?: string[]` - file extensions to process -- `enabledDetectors?: string[]` - detector ids to enable (defaults to all) +- `enabledDetectors?: string[]` - detector ids to enable (defaults to every detector that is not force-only) - `disabledDetectors?: string[]` - detector ids to disable +- `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` - override detector comment syntax tokens - `includeFolders?: Array` - 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 @@ -229,7 +232,7 @@ A manifest claims its folder as soon as it exists and can be read, even when it For each file: 1. **Project root.** Walk up from the file's folder. The nearest folder that at least one driver claims is the project root. `@Filename` is the file's path relative to it, and git history (`@Date`, `@Last modified time`) is looked up from it. -2. **Order.** When several drivers claim that folder, the file's native driver is read first (a `.py` file reads `python` first), then the fixed order `node`, `python`, `php`, `rust`, `go`. Language-neutral files (CSS, HTML, YAML, JSON, …) use the fixed order. +2. **Order.** When several drivers claim that folder, the file's native driver is read first (a `.py` file reads `python` first), then the fixed order `node`, `python`, `php`, `rust`, `go`. Language-neutral files (CSS, HTML, YAML, JSONC, …) use the fixed order. 3. **Per-field fallback.** Each value is taken from the first driver in that order whose manifest provides it. In a folder with a nameless `package.json` and a named `pyproject.toml`, every file gets the `pyproject.toml` name. 4. **Climbing.** A value no driver at the project root provides is looked up the same way in each ancestor folder a driver claims, up to and including the scan root (`projectRoot`, else `cwd`), never above it. A nameless sub-package therefore takes the name of the repository around it. Only values climb: the project root, and with it `@Filename` and the git lookups, stays the nearest claimed folder. 5. **Folder name.** When nothing up to the scan root provides a name, `@Project` is the project root's folder name. @@ -403,9 +406,40 @@ $ fix-headers --timezone America/Los_Angeles --convert-timezone The `@Date` instant is unchanged; `@Last modified time` is restamped with the time of the run, in the zone. +## Supported file types + +Each file type is handled by a detector, which decides the header's comment syntax. A file is given a header only when an enabled detector handles its extension: + +| Detector | Extensions | Header comment | Default | +| ---------- | ----------------------------- | -------------- | ------------------------------------------ | +| `node` | `.js .mjs .cjs .ts .tsx .jsx` | `/** … */` | on | +| `json` | `.jsonc .json5 .jsonv` | `/** … */` | on | +| `css` | `.css` | `/* … */` | on | +| `html` | `.html .htm` | `` | on | +| `yaml` | `.yaml .yml` | `# …` | on | +| `python` | `.py` | `# …` | on | +| `php` | `.php` | `/** … */` | on | +| `rust` | `.rs` | `/** … */` | on | +| `go` | `.go` | `/** … */` | on | +| `markdown` | `.md .markdown` | `` | off; only with `--force-detector markdown` | + +Every other file is skipped and left byte-for-byte unchanged, including when you name it with `--input` or add its extension with `includeExtensions`: + +- **Strict JSON (`.json`)** has no comment syntax, so it never gets a header. A comment would make `package.json` and every other JSON file invalid. +- **Files with no extension, or an extension no enabled detector handles** (`.txt`, `.toml`, a disabled detector's extensions, …) are skipped instead of being given a guessed comment. +- **Markdown** is skipped unless the `markdown` detector is forced with `--force-detector markdown` (`forcedDetectors: ["markdown"]` in the API or a config file). Naming a `.md` file with `--input` does not force it. A forced header is an HTML comment, which Markdown renderers do not display; it goes below any YAML front matter (`---` … `---`), and later runs update it in place. Forcing applies to discovery too, so a repo-wide run with the option also stamps `README.md` and changelogs; to stamp one file, pass the option together with `--input `. `.mdx` is not covered, because MDX does not accept HTML comments. + +A skipped file is listed in the result's `skipped` array as `{ file, reason }` and counted in `filesSkipped`, not in `filesScanned` or `changes`. The CLI adds `skipped=` to its summary and prints one `skipped: ()` line per file: + +```sh +$ fix-headers --input package.json +fix-headers complete: scanned=0, updated=0, skipped=1, dryRun=false +skipped: package.json (no enabled detector handles .json files) +``` + ## Which files are processed -By default every file with a supported extension 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. 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: - the project's ignore files ignore them, or - you exclude them with `excludeFolders` / `--exclude-folder`. diff --git a/src/cli.mjs b/src/cli.mjs index 28d19a7..b709a36 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 --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 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`; /** * Converts CLI flag token to camelCase key. @@ -54,7 +54,8 @@ export function parseCliArgs(argv) { "exclude-folder": "excludeFolders", "include-extension": "includeExtensions", "enable-detector": "enabledDetectors", - "disable-detector": "disabledDetectors" + "disable-detector": "disabledDetectors", + "force-detector": "forcedDetectors" }; const scalarMap = { cwd: "cwd", @@ -320,6 +321,19 @@ function printChangedFiles(stdout, result) { } } +/** + * Prints each file that was skipped because its format cannot carry the header comment. + * @param {(message: string) => void} stdout - Standard output writer. + * @param {{skipped?: Array<{file?: string, reason?: string}>}} report - Runner result object. + * @returns {void} + */ +function printSkipped(stdout, report) { + const skipped = Array.isArray(report.skipped) ? report.skipped : []; + for (const entry of skipped) { + stdout(`skipped: ${entry?.file || ""} (${entry?.reason || "no reason given"})`); + } +} + /** * Prints the optional per-file detail sections selected by the CLI flags. * @param {(message: string) => void} stdout - Standard output writer. @@ -410,10 +424,15 @@ export async function runCli(argv, deps = {}) { } if (result && typeof result === "object") { - const report = /** @type {{filesScanned?: number, filesUpdated?: number, dryRun?: boolean}} */ (result); + const report = + /** @type {{filesScanned?: number, filesUpdated?: number, filesSkipped?: number, skipped?: Array<{file?: string, reason?: string}>, dryRun?: boolean}} */ ( + result + ); + const skippedCount = Number(report.filesSkipped) > 0 ? `, skipped=${report.filesSkipped}` : ""; stdout( - `fix-headers complete: scanned=${report.filesScanned ?? 0}, updated=${report.filesUpdated ?? 0}, dryRun=${report.dryRun === true}` + `fix-headers complete: scanned=${report.filesScanned ?? 0}, updated=${report.filesUpdated ?? 0}${skippedCount}, dryRun=${report.dryRun === true}` ); + printSkipped(stdout, report); printDetails(stdout, result, finalOptions, parsed.diff); } else { printDetails(stdout, result, finalOptions, parsed.diff); diff --git a/src/core/file-discovery.mjs b/src/core/file-discovery.mjs index 5193e0e..07a3508 100644 --- a/src/core/file-discovery.mjs +++ b/src/core/file-discovery.mjs @@ -29,7 +29,8 @@ import { createIgnoreFilter } from "./ignore-rules.mjs"; * @param {{ * includeExtensions?: string[], * enabledDetectors?: string[], - * disabledDetectors?: string[] + * disabledDetectors?: string[], + * forcedDetectors?: string[] * }} options - Extension options. * @returns {Set} Effective extension set. */ @@ -154,6 +155,7 @@ function buildExclusionMatcher(projectRoot, excludeFolders) { * includeExtensions?: string[], * enabledDetectors?: string[], * disabledDetectors?: string[], + * forcedDetectors?: string[], * includeFolders?: IncludeFolderEntry[], * excludeFolders?: string[], * gitignore?: boolean | string | string[] diff --git a/src/core/fix-headers.mjs b/src/core/fix-headers.mjs index 498dae6..072b9cd 100644 --- a/src/core/fix-headers.mjs +++ b/src/core/fix-headers.mjs @@ -18,6 +18,7 @@ import { relative, resolve } from "node:path"; import { applyConfigOption } from "../config/load.mjs"; import { DEFAULT_HEADER_MARGIN, DEFAULT_HEADER_SPACING, resolveLayoutCount } from "../constants.mjs"; import { discoverFiles } from "./file-discovery.mjs"; +import { getHeaderSkipReason, resolveForcedDetectors } from "../detectors/index.mjs"; import { resolveProjectMetadata } from "../detect/project.mjs"; import { checkHeaderDates, @@ -53,6 +54,7 @@ import { assertTimeZone, toDatePayload } from "../utils/time.mjs"; * useGpgSignerAuthor?: boolean, * enabledDetectors?: string[], * disabledDetectors?: string[], + * forcedDetectors?: string[], * detectorSyntaxOverrides?: Record, * includeFolders?: Array, * excludeFolders?: string[], @@ -104,6 +106,12 @@ import { assertTimeZone, toDatePayload } from "../utils/time.mjs"; * `"created-date"` (the year of the file's `@Date`). * * `metadata.copyrightStartYear` is the `copyrightStartYear` option, or null when it is not set. + * + * Files whose format cannot carry the header comment are not processed: each is listed in + * `skipped` as `{ file, reason }` and counted in `filesSkipped`, not in `filesScanned` or + * `changes`. That covers strict `.json`, files with no extension or an extension no enabled + * detector handles (for example one added through `includeExtensions`), and Markdown unless + * `forcedDetectors` includes `"markdown"`. * @typedef {{ * metadata: { * projectName: string, @@ -120,6 +128,8 @@ import { assertTimeZone, toDatePayload } from "../utils/time.mjs"; * detectedProjects: string[], * filesScanned: number, * filesUpdated: number, + * filesSkipped: number, + * skipped: Array<{file: string, reason: string}>, * dryRun: boolean, * check: boolean, * filesWithDateDrift?: number, @@ -247,6 +257,7 @@ export async function fixHeaders(options = {}) { "convertTimezone requires timezone: set timezone (CLI --timezone ) to the IANA zone to convert header dates into" ); } + const forcedDetectors = resolveForcedDetectors(effectiveOptions.forcedDetectors); const scanRoot = resolve(effectiveOptions.projectRoot || effectiveOptions.cwd || process.cwd()); const metadata = await resolveProjectMetadata({ ...effectiveOptions, @@ -284,6 +295,7 @@ export async function fixHeaders(options = {}) { language: metadata.language, enabledDetectors: effectiveOptions.enabledDetectors, disabledDetectors: effectiveOptions.disabledDetectors, + forcedDetectors, includeFolders: effectiveOptions.includeFolders, excludeFolders: effectiveOptions.excludeFolders, includeExtensions: effectiveOptions.includeExtensions, @@ -298,6 +310,7 @@ export async function fixHeaders(options = {}) { language: metadata.language, enabledDetectors: effectiveOptions.enabledDetectors, disabledDetectors: effectiveOptions.disabledDetectors, + forcedDetectors, includeFolders: effectiveOptions.includeFolders, excludeFolders: effectiveOptions.excludeFolders, includeExtensions: effectiveOptions.includeExtensions, @@ -308,12 +321,26 @@ export async function fixHeaders(options = {}) { const currentYear = new Date().getFullYear(); /** @type {FixHeadersResult["changes"]} */ const changes = []; + /** @type {FixHeadersResult["skipped"]} */ + const skipped = []; const detectedProjects = new Set(); let filesUpdated = 0; let filesWithDateDrift = 0; let dateAdvisories = 0; for (const filePath of files) { + // A file whose format cannot carry the header comment (strict JSON, Markdown that is not + // forced, an extension no enabled detector handles) is skipped, never given a JS comment. + const skipReason = getHeaderSkipReason(filePath, { + enabledDetectors: effectiveOptions.enabledDetectors, + disabledDetectors: effectiveOptions.disabledDetectors, + forcedDetectors + }); + if (skipReason !== null) { + skipped.push({ file: relative(scanRoot, filePath), reason: skipReason }); + continue; + } + const fileMetadata = await resolveProjectMetadata({ ...effectiveOptions, cwd: scanRoot, @@ -323,14 +350,16 @@ export async function fixHeaders(options = {}) { detectedProjects.add(`${fileMetadata.language}:${fileMetadata.projectRoot}`); const relativePath = relative(scanRoot, filePath); const original = await readFile(filePath, "utf8"); - const existingHeader = findProjectHeader(original, filePath, { + const syntaxOptions = { language: fileMetadata.language, enabledDetectors: effectiveOptions.enabledDetectors, disabledDetectors: effectiveOptions.disabledDetectors, + forcedDetectors, detectorSyntaxOverrides: effectiveOptions.detectorSyntaxOverrides, spacing: effectiveOptions.spacing, margin: effectiveOptions.margin - }); + }; + const existingHeader = findProjectHeader(original, filePath, syntaxOptions); const existingHeaderText = existingHeader ? original.slice(existingHeader.start, existingHeader.end) : ""; const existingIdentity = existingHeaderText.length > 0 ? extractHeaderAuthorIdentity(existingHeaderText) : {}; const existingLastModifiedIdentity = existingHeaderText.length > 0 ? extractHeaderLastModifiedIdentity(existingHeaderText) : {}; @@ -374,14 +403,7 @@ export async function fixHeaders(options = {}) { const comparisonHeader = buildHeader({ absoluteFilePath: filePath, language: fileMetadata.language, - syntaxOptions: { - language: fileMetadata.language, - enabledDetectors: effectiveOptions.enabledDetectors, - disabledDetectors: effectiveOptions.disabledDetectors, - detectorSyntaxOverrides: effectiveOptions.detectorSyntaxOverrides, - spacing: effectiveOptions.spacing, - margin: effectiveOptions.margin - }, + syntaxOptions, projectRoot: fileMetadata.projectRoot, projectName: fileMetadata.projectName, createdByName: shouldForceAuthorUpdate ? fileMetadata.authorName : existingIdentity.authorName || fileMetadata.authorName, @@ -401,14 +423,7 @@ export async function fixHeaders(options = {}) { currentYear }); - const comparisonReplacement = replaceOrInsertHeader(original, comparisonHeader, filePath, { - language: fileMetadata.language, - enabledDetectors: effectiveOptions.enabledDetectors, - disabledDetectors: effectiveOptions.disabledDetectors, - detectorSyntaxOverrides: effectiveOptions.detectorSyntaxOverrides, - spacing: effectiveOptions.spacing, - margin: effectiveOptions.margin - }); + const comparisonReplacement = replaceOrInsertHeader(original, comparisonHeader, filePath, syntaxOptions); const needsUpdate = comparisonReplacement.changed; const finalLastModifiedAt = needsUpdate ? formatDate(toZone(toDatePayload(new Date()))) : comparisonLastModifiedAt; const lastModifiedAtSource = needsUpdate ? "current-time-on-change" : comparisonLastModifiedAtSource; @@ -417,14 +432,7 @@ export async function fixHeaders(options = {}) { ? buildHeader({ absoluteFilePath: filePath, language: fileMetadata.language, - syntaxOptions: { - language: fileMetadata.language, - enabledDetectors: effectiveOptions.enabledDetectors, - disabledDetectors: effectiveOptions.disabledDetectors, - detectorSyntaxOverrides: effectiveOptions.detectorSyntaxOverrides, - spacing: effectiveOptions.spacing, - margin: effectiveOptions.margin - }, + syntaxOptions, projectRoot: fileMetadata.projectRoot, projectName: fileMetadata.projectName, createdByName: shouldForceAuthorUpdate ? fileMetadata.authorName : existingIdentity.authorName || fileMetadata.authorName, @@ -445,16 +453,7 @@ export async function fixHeaders(options = {}) { }) : comparisonHeader; - const replacement = needsUpdate - ? replaceOrInsertHeader(original, header, filePath, { - language: fileMetadata.language, - enabledDetectors: effectiveOptions.enabledDetectors, - disabledDetectors: effectiveOptions.disabledDetectors, - detectorSyntaxOverrides: effectiveOptions.detectorSyntaxOverrides, - spacing: effectiveOptions.spacing, - margin: effectiveOptions.margin - }) - : comparisonReplacement; + const replacement = needsUpdate ? replaceOrInsertHeader(original, header, filePath, syntaxOptions) : comparisonReplacement; /** @type {FixHeadersResult["changes"][number]} */ const changeEntry = { file: relativePath, @@ -521,11 +520,13 @@ export async function fixHeaders(options = {}) { return { metadata, detectedProjects: Array.from(detectedProjects), - filesScanned: files.length, + filesScanned: files.length - skipped.length, filesUpdated, + filesSkipped: skipped.length, dryRun, check, ...(check ? { filesWithDateDrift, dateAdvisories } : {}), - changes + changes, + skipped }; } diff --git a/src/detect/project.mjs b/src/detect/project.mjs index f042a58..b9e9488 100644 --- a/src/detect/project.mjs +++ b/src/detect/project.mjs @@ -79,7 +79,7 @@ function formatAuthorNameWithCompany(authorName, company) { * folder's name. The copyright holder (`companyName`) comes from the manifests' authors the same * way, and is null when none of them provides one. * @param {string} cwd - Starting directory (a file's folder, or the scan root). - * @param {{ detectors?: { id: string, extensions: string[] }[], enabledDetectors?: string[], disabledDetectors?: string[], preferredExtension?: string, drivers?: import("../drivers/index.mjs").ManifestDriver[], scanRoot?: string }} [options={}] - Detection options. `scanRoot` bounds how far values missing from the nearest manifests are looked up in ancestor folders; without it they aren't. + * @param {{ detectors?: { id: string, extensions: string[] }[], enabledDetectors?: string[], disabledDetectors?: string[], forcedDetectors?: string[], preferredExtension?: string, drivers?: import("../drivers/index.mjs").ManifestDriver[], scanRoot?: string }} [options={}] - Detection options. `scanRoot` bounds how far values missing from the nearest manifests are looked up in ancestor folders; without it they aren't. * @returns {Promise<{ * language: string, * rootDir: string, @@ -123,6 +123,7 @@ export async function detectProjectFromMarkers(cwd, options = {}) { * targetFilePath?: string, * enabledDetectors?: string[], * disabledDetectors?: string[], + * forcedDetectors?: string[], * projectName?: string, * language?: string, * projectRoot?: string, diff --git a/src/detectors/index.mjs b/src/detectors/index.mjs index e395aeb..04647d7 100644 --- a/src/detectors/index.mjs +++ b/src/detectors/index.mjs @@ -18,6 +18,7 @@ import { detector as cssDetector } from "./css.mjs"; import { detector as goDetector } from "./go.mjs"; import { detector as htmlDetector } from "./html.mjs"; import { detector as jsonDetector } from "./json.mjs"; +import { detector as markdownDetector } from "./markdown.mjs"; import { detector as nodeDetector } from "./node.mjs"; import { detector as phpDetector } from "./php.mjs"; import { detector as pythonDetector } from "./python.mjs"; @@ -34,12 +35,14 @@ import { detector as yamlDetector } from "./yaml.mjs"; * id: string, * extensions: string[], * enabledByDefault: boolean, + * requiresForce?: boolean, * resolveCommentSyntax: (filePath: string) => ({kind: "block" | "line" | "html", linePrefix?: string, lineSeparator?: string, blockStart?: string, blockLinePrefix?: string, blockEnd?: string} | null), * resolvePreservedPrefix?: (filePath: string, content: string) => string * }} DetectorProfile * A file-type detector: which extensions it handles and the comment syntax (and preserved * leading prefix) of those files. Which project a file belongs to is resolved separately, - * from the manifest drivers in `src/drivers/`. + * from the manifest drivers in `src/drivers/`. A detector with `requiresForce: true` is used only + * when its id is listed in the `forcedDetectors` option (see {@link getEnabledDetectors}). */ /** @@ -54,6 +57,7 @@ export const DETECTOR_PROFILES = /** @type {DetectorProfile[]} */ ([ goDetector, htmlDetector, jsonDetector, + markdownDetector, nodeDetector, phpDetector, pythonDetector, @@ -95,15 +99,49 @@ function applySyntaxOverride(syntax, override) { } /** - * Gets enabled detector profiles based on include/exclude options. - * @param {{ enabledDetectors?: string[], disabledDetectors?: string[] }} [options={}] - Runtime options. + * Validates the `forcedDetectors` option: each id must name a force-only detector + * (`requiresForce: true`). Forcing a detector that is used without forcing, or one that does + * not exist, is reported instead of silently doing nothing. + * @param {unknown} value - Option value. + * @returns {string[]} De-duplicated detector ids (empty when unset). + */ +export function resolveForcedDetectors(value) { + if (value === undefined || value === null) { + return []; + } + if (!Array.isArray(value) || value.some((id) => typeof id !== "string")) { + throw new Error(`forcedDetectors must be an array of detector ids, got ${JSON.stringify(value)}`); + } + const forceOnly = DETECTOR_PROFILES.filter((detector) => detector.requiresForce === true).map((detector) => detector.id); + for (const id of value) { + if (!detectorMap.has(id)) { + throw new Error(`forcedDetectors: unknown detector "${id}" (force-only detectors: ${forceOnly.join(", ")})`); + } + if (!forceOnly.includes(id)) { + throw new Error(`forcedDetectors: "${id}" does not need forcing (force-only detectors: ${forceOnly.join(", ")})`); + } + } + return Array.from(new Set(value)); +} + +/** + * Gets enabled detector profiles based on include/exclude options. A force-only detector + * (`requiresForce: true`) is enabled only when `forcedDetectors` names it, and then even when + * `enabledDetectors` does not; `disabledDetectors` still turns it off. Listing it in + * `enabledDetectors` alone does not force it. + * @param {{ enabledDetectors?: string[], disabledDetectors?: string[], forcedDetectors?: string[] }} [options={}] - Runtime options. * @returns {typeof DETECTOR_PROFILES} Enabled detector list. */ export function getEnabledDetectors(options = {}) { const explicitEnabled = new Set(Array.isArray(options.enabledDetectors) ? options.enabledDetectors : []); const explicitDisabled = new Set(Array.isArray(options.disabledDetectors) ? options.disabledDetectors : []); + const forced = new Set(Array.isArray(options.forcedDetectors) ? options.forcedDetectors : []); return DETECTOR_PROFILES.filter((detector) => { + if (detector.requiresForce === true) { + return forced.has(detector.id) && !explicitDisabled.has(detector.id); + } + if (explicitEnabled.size > 0) { return explicitEnabled.has(detector.id); } @@ -118,7 +156,7 @@ export function getEnabledDetectors(options = {}) { /** * Gets allowed file extensions for enabled detectors. - * @param {{ enabledDetectors?: string[], disabledDetectors?: string[], includeExtensions?: string[] }} [options={}] - Runtime options. + * @param {{ enabledDetectors?: string[], disabledDetectors?: string[], forcedDetectors?: string[], includeExtensions?: string[] }} [options={}] - Runtime options. * @returns {Set} Allowed extensions. */ export function getAllowedExtensions(options = {}) { @@ -130,6 +168,36 @@ export function getAllowedExtensions(options = {}) { return new Set(detectors.flatMap((detector) => detector.extensions)); } +/** + * Says why a file cannot be given a header, or returns null when it can. A file gets a header + * only when an enabled detector handles its extension and supplies a comment syntax; anything + * else (strict `.json`, `.txt`, extensionless files, a disabled detector's extensions, Markdown + * that is not forced) is skipped rather than given a comment its format cannot carry. + * @param {string} filePath - File path. + * @param {{ enabledDetectors?: string[], disabledDetectors?: string[], forcedDetectors?: string[] }} [options={}] - Runtime options. + * @returns {string | null} Skip reason, or null when the file can carry a header. + */ +export function getHeaderSkipReason(filePath, options = {}) { + const extension = extname(filePath).toLowerCase(); + const handled = getEnabledDetectors(options).some( + (detector) => detector.extensions.includes(extension) && detector.resolveCommentSyntax(filePath) !== null + ); + if (handled) { + return null; + } + if (extension.length === 0) { + return "the file has no extension, so its comment syntax is unknown"; + } + const forced = new Set(Array.isArray(options.forcedDetectors) ? options.forcedDetectors : []); + const forceOnly = DETECTOR_PROFILES.find( + (detector) => detector.requiresForce === true && detector.extensions.includes(extension) && !forced.has(detector.id) + ); + if (forceOnly) { + return `${extension} files get a header only when forced (--force-detector ${forceOnly.id} / forcedDetectors: ["${forceOnly.id}"])`; + } + return `no enabled detector handles ${extension} files`; +} + /** * Gets a detector by id. * @param {string} id - Detector id. @@ -142,7 +210,7 @@ export function getDetectorById(id) { /** * Resolves comment syntax for a file path using detector-specific templates. * @param {string} filePath - File path. - * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], detectors?: DetectorProfile[], detectorSyntaxOverrides?: Record, spacing?: number, margin?: number }} [options={}] - Runtime options. `detectors` overrides the enabled-detector set (matching {@link detectProjectFromMarkers}). + * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], forcedDetectors?: string[], detectors?: DetectorProfile[], detectorSyntaxOverrides?: Record, spacing?: number, margin?: number }} [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, options = {}) { @@ -167,7 +235,7 @@ export function getCommentSyntaxForFile(filePath, options = {}) { * Resolves detector-specific leading content that must be preserved above inserted headers. * @param {string} filePath - File path. * @param {string} content - Full file content. - * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[] }} [options={}] - Runtime options. + * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], forcedDetectors?: string[] }} [options={}] - Runtime options. * @returns {string} Preserved prefix (possibly empty). */ export function getPreservedPrefixForFile(filePath, content, options = {}) { diff --git a/src/detectors/markdown.mjs b/src/detectors/markdown.mjs new file mode 100644 index 0000000..79cecf0 --- /dev/null +++ b/src/detectors/markdown.mjs @@ -0,0 +1,69 @@ +/** + * + * @Project: @cldmv/fix-headers + * @Filename: /src/detectors/markdown.mjs + * @Date: 2026-10-03T17:04:18-07:00 (1791072258) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-03T17:08:31-07:00 (1791072511) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + */ + +import { extname } from "node:path"; + +/** + * @fileoverview Markdown detector implementation. Force-only: Markdown files get a header + * only when this detector is named in `forcedDetectors` (CLI `--force-detector markdown`), + * and then as an HTML comment, which Markdown renderers do not display. + * @module fix-headers/detectors/markdown + */ + +// `.mdx` is left out on purpose: MDX 2 rejects HTML comments. +const extensions = [".md", ".markdown"]; + +/** + * Resolves HTML comment syntax for Markdown files. + * @param {string} filePath - File path. + * @returns {{kind: "html", blockStart: string, blockLinePrefix: string, blockEnd: string} | null} Syntax descriptor. + */ +function resolveMarkdownCommentSyntax(filePath) { + const extension = extname(filePath).toLowerCase(); + if (extensions.includes(extension)) { + return { + kind: "html", + blockStart: "" + }; + } + return null; +} + +/** + * Resolves the YAML front matter block (`---` … `---`) at the top of a Markdown file, which + * has to stay first for static site generators to read it. + * @param {string} _filePath - File path. + * @param {string} content - File content. + * @returns {string} Preserved prefix. + */ +function resolveMarkdownPreservedPrefix(_filePath, content) { + const frontMatterMatch = content.match(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/); + return frontMatterMatch ? frontMatterMatch[0] : ""; +} + +export const detector = { + id: "markdown", + extensions, + enabledByDefault: false, + requiresForce: true, + resolvePreservedPrefix(filePath, content) { + return resolveMarkdownPreservedPrefix(filePath, content); + }, + resolveCommentSyntax(filePath) { + return resolveMarkdownCommentSyntax(filePath); + } +}; diff --git a/src/header/parser.mjs b/src/header/parser.mjs index ef1e8fa..1a21601 100644 --- a/src/header/parser.mjs +++ b/src/header/parser.mjs @@ -35,7 +35,7 @@ function escapeRegex(text) { * Splits detector-defined preserved prefix from file content when present. * @param {string} filePath - File path used for detector selection. * @param {string} content - File content. - * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], detectorSyntaxOverrides?: Record, spacing?: number, margin?: number }} [syntaxOptions={}] - Syntax/detector options. + * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], forcedDetectors?: string[], detectorSyntaxOverrides?: Record, spacing?: number, margin?: number }} [syntaxOptions={}] - Syntax/detector options. * @returns {{prefix: string, body: string}} Shebang prefix and remaining body. */ function splitPreservedPrefix(filePath, content, syntaxOptions = {}) { @@ -87,7 +87,7 @@ function matchHeaderSegment(content, syntax) { * Finds the first top-level project header block in a file. * @param {string} content - File content. * @param {string} [filePath=""] - File path used for syntax selection. - * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], detectorSyntaxOverrides?: Record, spacing?: number, margin?: number }} [syntaxOptions={}] - Syntax resolution options. + * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], forcedDetectors?: string[], detectorSyntaxOverrides?: Record, spacing?: number, margin?: number }} [syntaxOptions={}] - Syntax resolution options. * @returns {{start: number, end: number} | null} Header location. */ export function findProjectHeader(content, filePath = "", syntaxOptions = {}) { @@ -129,7 +129,7 @@ export function findProjectHeader(content, filePath = "", syntaxOptions = {}) { * @param {string} content - Original file content. * @param {string} newHeader - Generated header text. * @param {string} [filePath=""] - File path used for syntax selection. - * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], detectorSyntaxOverrides?: Record, spacing?: number, margin?: number }} [syntaxOptions={}] - Syntax resolution options. + * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], forcedDetectors?: string[], detectorSyntaxOverrides?: Record, spacing?: number, margin?: number }} [syntaxOptions={}] - Syntax resolution options. * @returns {{nextContent: string, changed: boolean}} Updated content result. */ export function replaceOrInsertHeader(content, newHeader, filePath = "", syntaxOptions = {}) { diff --git a/src/header/syntax.mjs b/src/header/syntax.mjs index 7990243..d6a5b54 100644 --- a/src/header/syntax.mjs +++ b/src/header/syntax.mjs @@ -37,7 +37,7 @@ import { DEFAULT_HEADER_SPACING, resolveLayoutCount } from "../constants.mjs"; /** * Resolves comment syntax for a file path based on extension. * @param {string} filePath - Absolute or relative file path. - * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], detectorSyntaxOverrides?: Record, spacing?: number }} [options={}] - Syntax resolution options. `spacing` is the number of empty comment lines just inside the header's opening and closing (default 1). + * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], forcedDetectors?: string[], detectorSyntaxOverrides?: Record, spacing?: number }} [options={}] - Syntax resolution options. `spacing` is the number of empty comment lines just inside the header's opening and closing (default 1). * @returns {HeaderSyntax} Header syntax descriptor. */ export function getHeaderSyntaxForFile(filePath, options = {}) { diff --git a/src/header/template.mjs b/src/header/template.mjs index 93b840a..fba41c1 100644 --- a/src/header/template.mjs +++ b/src/header/template.mjs @@ -26,7 +26,7 @@ import { getHeaderSyntaxForFile, renderHeaderLines } from "./syntax.mjs"; * @param {{ * absoluteFilePath: string, * language?: string, - * syntaxOptions?: { language?: string, enabledDetectors?: string[], disabledDetectors?: string[], detectorSyntaxOverrides?: Record, spacing?: number, margin?: number }, + * syntaxOptions?: { language?: string, enabledDetectors?: string[], disabledDetectors?: string[], forcedDetectors?: string[], detectorSyntaxOverrides?: Record, spacing?: number, margin?: number }, * projectRoot: string, * createdByName?: string, * createdByEmail?: string, diff --git a/tests/branches.test.vitest.mjs b/tests/branches.test.vitest.mjs index 3ef01b5..40e3651 100644 --- a/tests/branches.test.vitest.mjs +++ b/tests/branches.test.vitest.mjs @@ -28,7 +28,7 @@ import { cleanupWorkspace, createWorkspace, writeWorkspaceFile } from "./helpers describe("branch coverage helpers", () => { it("supports detector enable/disable option paths", () => { const enabledDefault = getEnabledDetectors(); - expect(enabledDefault.length).toBe(DETECTOR_PROFILES.length); + expect(enabledDefault.length).toBe(DETECTOR_PROFILES.filter((detector) => detector.requiresForce !== true).length); const enabledOnly = getEnabledDetectors({ enabledDetectors: ["node"] }); expect(enabledOnly.map((item) => item.id)).toEqual(["node"]); diff --git a/tests/non-js-formats.test.vitest.mjs b/tests/non-js-formats.test.vitest.mjs new file mode 100644 index 0000000..156181e --- /dev/null +++ b/tests/non-js-formats.test.vitest.mjs @@ -0,0 +1,352 @@ +/** + * + * @Project: @cldmv/fix-headers + * @Filename: /tests/non-js-formats.test.vitest.mjs + * @Date: 2026-10-03T17:03:42-07:00 (1791072222) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-03T17:08:31-07:00 (1791072511) + * ----- + * @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 { detector as markdownDetector } from "../src/detectors/markdown.mjs"; +import { + DETECTOR_PROFILES, + getAllowedExtensions, + getEnabledDetectors, + getHeaderSkipReason, + resolveForcedDetectors +} from "../src/detectors/index.mjs"; +import { cleanupWorkspace, createWorkspace, writeWorkspaceFile } from "./helpers/workspace.mjs"; + +/** + * @fileoverview Files whose format cannot carry the header's comment are never given one: + * strict JSON never, Markdown only when the `markdown` detector is forced (then as an HTML + * comment). Covers #120 and #122. + */ + +const IDENTITY = { + authorName: "Format Tester", + authorEmail: "format@example.com", + companyName: "Catalyzed Motivation Inc." +}; + +const PACKAGE_JSON = `${JSON.stringify({ name: "non-js-formats", version: "1.0.0" }, null, 2)}\n`; +const MARKDOWN = "# Title\n\nSome text.\n"; + +/** + * Creates a workspace holding a package.json, a Markdown file, a JSON file and one JS file. + * @param {string} name - Workspace name. + * @returns {Promise} Workspace path. + */ +async function createFormatsWorkspace(name) { + const workspace = await createWorkspace(name); + await writeWorkspaceFile(join(workspace, "package.json"), PACKAGE_JSON); + await writeWorkspaceFile(join(workspace, "doc.md"), MARKDOWN); + await writeWorkspaceFile(join(workspace, "docs", "notes.markdown"), MARKDOWN); + await writeWorkspaceFile(join(workspace, "data", "config.json"), '{ "a": 1 }\n'); + await writeWorkspaceFile(join(workspace, "src", "one.mjs"), "export const one = true;\n"); + return workspace; +} + +/** + * Collects CLI output lines. + * @returns {{ lines: string[], stdout: (message: string) => void }} Collector. + */ +function collect() { + const lines = []; + return { lines, stdout: (message) => lines.push(message) }; +} + +describe("formats that cannot carry the header comment", () => { + it("a repo-wide run leaves Markdown and JSON untouched", async () => { + const workspace = await createFormatsWorkspace("formats-repo-wide"); + try { + const result = await fixHeaders({ cwd: workspace, ...IDENTITY }); + + expect(result.changes.map((change) => change.file)).toEqual([join("src", "one.mjs")]); + expect(result.filesSkipped).toBe(0); + expect(result.skipped).toEqual([]); + expect(await readFile(join(workspace, "package.json"), "utf8")).toBe(PACKAGE_JSON); + expect(await readFile(join(workspace, "doc.md"), "utf8")).toBe(MARKDOWN); + expect(await readFile(join(workspace, "docs", "notes.markdown"), "utf8")).toBe(MARKDOWN); + expect(await readFile(join(workspace, "data", "config.json"), "utf8")).toBe('{ "a": 1 }\n'); + expect(await readFile(join(workspace, "src", "one.mjs"), "utf8")).toMatch(/^\/\*\*\n/); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("skips a Markdown file named by input unless the markdown detector is forced", async () => { + const workspace = await createFormatsWorkspace("formats-md-input"); + try { + const result = await fixHeaders({ cwd: workspace, ...IDENTITY, input: "doc.md" }); + + expect(result.filesScanned).toBe(0); + expect(result.filesUpdated).toBe(0); + expect(result.filesSkipped).toBe(1); + expect(result.changes).toEqual([]); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0].file).toBe("doc.md"); + expect(result.skipped[0].reason).toMatch(/forcedDetectors: \["markdown"\]/); + expect(await readFile(join(workspace, "doc.md"), "utf8")).toBe(MARKDOWN); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("never writes a comment into package.json or any other .json file", async () => { + const workspace = await createFormatsWorkspace("formats-json-input"); + try { + for (const input of ["package.json", "data/config.json"]) { + const result = await fixHeaders({ cwd: workspace, ...IDENTITY, input, forcedDetectors: ["markdown"] }); + expect(result.filesUpdated).toBe(0); + expect(result.filesSkipped).toBe(1); + expect(result.skipped[0].reason).toMatch(/no enabled detector handles \.json files/); + } + + const directory = await fixHeaders({ cwd: workspace, ...IDENTITY, input: "data", includeExtensions: [".json"] }); + expect(directory.filesUpdated).toBe(0); + expect(directory.skipped.map((entry) => entry.file)).toEqual([join("data", "config.json")]); + + const packageJson = await readFile(join(workspace, "package.json"), "utf8"); + expect(packageJson).toBe(PACKAGE_JSON); + expect(JSON.parse(packageJson).name).toBe("non-js-formats"); + expect(JSON.parse(await readFile(join(workspace, "data", "config.json"), "utf8"))).toEqual({ a: 1 }); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("skips files with no extension, unknown extensions and disabled detectors' extensions", async () => { + const workspace = await createFormatsWorkspace("formats-unknown"); + try { + await writeWorkspaceFile(join(workspace, "bin", "tool"), "#!/usr/bin/env node\nconsole.log(1);\n"); + await writeWorkspaceFile(join(workspace, "notes.txt"), "hello\n"); + await writeWorkspaceFile(join(workspace, "ci.yml"), "name: ci\n"); + + const extensionless = await fixHeaders({ cwd: workspace, ...IDENTITY, input: "bin/tool" }); + expect(extensionless.skipped[0].reason).toMatch(/no extension/); + + const unknown = await fixHeaders({ cwd: workspace, ...IDENTITY, input: "notes.txt" }); + expect(unknown.skipped[0].reason).toMatch(/no enabled detector handles \.txt files/); + + const disabled = await fixHeaders({ cwd: workspace, ...IDENTITY, input: "ci.yml", disabledDetectors: ["yaml"] }); + expect(disabled.skipped[0].reason).toMatch(/no enabled detector handles \.yml files/); + + expect(await readFile(join(workspace, "bin", "tool"), "utf8")).toBe("#!/usr/bin/env node\nconsole.log(1);\n"); + expect(await readFile(join(workspace, "notes.txt"), "utf8")).toBe("hello\n"); + expect(await readFile(join(workspace, "ci.yml"), "utf8")).toBe("name: ci\n"); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("writes an HTML-comment header into forced Markdown and updates it in place on re-runs", async () => { + const workspace = await createFormatsWorkspace("formats-md-forced"); + try { + const first = await fixHeaders({ cwd: workspace, ...IDENTITY, input: "doc.md", forcedDetectors: ["markdown"] }); + expect(first.filesUpdated).toBe(1); + expect(first.filesSkipped).toBe(0); + + const written = await readFile(join(workspace, "doc.md"), "utf8"); + expect(written).toMatch(/^\n\n\n# Title\n\nSome text\.\n$/); + expect(written).not.toContain("/**"); + + const second = await fixHeaders({ cwd: workspace, ...IDENTITY, input: "doc.md", forcedDetectors: ["markdown"] }); + expect(second.filesUpdated).toBe(0); + expect(await readFile(join(workspace, "doc.md"), "utf8")).toBe(written); + + const renamed = await fixHeaders({ + cwd: workspace, + ...IDENTITY, + input: "doc.md", + forcedDetectors: ["markdown"], + projectName: "renamed-project" + }); + expect(renamed.filesUpdated).toBe(1); + const rewritten = await readFile(join(workspace, "doc.md"), "utf8"); + expect(rewritten.match(/\n\n\n# Title\n/); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("keeps Markdown front matter above a forced header", async () => { + const workspace = await createWorkspace("formats-md-front-matter"); + try { + await writeWorkspaceFile(join(workspace, "package.json"), PACKAGE_JSON); + await writeWorkspaceFile(join(workspace, "page.md"), "---\ntitle: Page\n---\n# Page\n"); + + await fixHeaders({ cwd: workspace, ...IDENTITY, input: "page.md", forcedDetectors: ["markdown"] }); + const once = await readFile(join(workspace, "page.md"), "utf8"); + expect(once).toMatch(/^---\ntitle: Page\n---\n\n\n\n# Page\n$/); + + await fixHeaders({ cwd: workspace, ...IDENTITY, input: "page.md", forcedDetectors: ["markdown"] }); + expect(await readFile(join(workspace, "page.md"), "utf8")).toBe(once); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("includes Markdown in discovery once forced, and a disabled detector stays off even when forced", async () => { + const workspace = await createFormatsWorkspace("formats-md-discovery"); + try { + const forced = await fixHeaders({ cwd: workspace, ...IDENTITY, dryRun: true, forcedDetectors: ["markdown"] }); + expect(forced.changes.map((change) => change.file).sort()).toEqual( + ["doc.md", join("docs", "notes.markdown"), join("src", "one.mjs")].sort() + ); + + const disabled = await fixHeaders({ + cwd: workspace, + ...IDENTITY, + input: "doc.md", + forcedDetectors: ["markdown"], + disabledDetectors: ["markdown"] + }); + expect(disabled.filesSkipped).toBe(1); + expect(disabled.skipped[0].reason).toMatch(/no enabled detector handles \.md files/); + expect(await readFile(join(workspace, "doc.md"), "utf8")).toBe(MARKDOWN); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("rejects forcedDetectors values that are not force-only detectors", async () => { + expect(resolveForcedDetectors(undefined)).toEqual([]); + expect(resolveForcedDetectors(null)).toEqual([]); + expect(resolveForcedDetectors(["markdown", "markdown"])).toEqual(["markdown"]); + expect(() => resolveForcedDetectors("markdown")).toThrow(/forcedDetectors must be an array of detector ids/); + expect(() => resolveForcedDetectors([1])).toThrow(/forcedDetectors must be an array of detector ids/); + expect(() => resolveForcedDetectors(["md"])).toThrow(/unknown detector "md"/); + expect(() => resolveForcedDetectors(["node"])).toThrow(/"node" does not need forcing/); + await expect(fixHeaders({ cwd: process.cwd(), forcedDetectors: ["json"], dryRun: true })).rejects.toThrow( + /"json" does not need forcing/ + ); + }); +}); + +describe("markdown detector and force-only registry helpers", () => { + it("resolves an HTML comment for .md and .markdown only", () => { + expect(markdownDetector.requiresForce).toBe(true); + expect(markdownDetector.enabledByDefault).toBe(false); + expect(markdownDetector.resolveCommentSyntax("/repo/README.md")).toEqual({ + kind: "html", + blockStart: "" + }); + expect(markdownDetector.resolveCommentSyntax("/repo/notes.MARKDOWN")?.kind).toBe("html"); + expect(markdownDetector.resolveCommentSyntax("/repo/page.mdx")).toBeNull(); + }); + + it("preserves YAML front matter only when it is closed", () => { + expect(markdownDetector.resolvePreservedPrefix("a.md", "---\ntitle: x\n---\n# A\n")).toBe("---\ntitle: x\n---\n"); + expect(markdownDetector.resolvePreservedPrefix("a.md", "---\r\ntitle: x\r\n---\r\nbody")).toBe("---\r\ntitle: x\r\n---\r\n"); + expect(markdownDetector.resolvePreservedPrefix("a.md", "---\ntitle: x\n---")).toBe("---\ntitle: x\n---"); + expect(markdownDetector.resolvePreservedPrefix("a.md", "---\nnot closed\n")).toBe(""); + expect(markdownDetector.resolvePreservedPrefix("a.md", "# A\n---\n")).toBe(""); + }); + + it("keeps force-only detectors out of the enabled set and extensions until forced", () => { + const defaults = getEnabledDetectors(); + expect(defaults.map((detector) => detector.id)).not.toContain("markdown"); + expect(defaults).toHaveLength(DETECTOR_PROFILES.filter((detector) => detector.requiresForce !== true).length); + expect(getEnabledDetectors({ enabledDetectors: ["markdown"] }).map((detector) => detector.id)).toEqual([]); + expect(getEnabledDetectors({ enabledDetectors: ["node"], forcedDetectors: ["markdown"] }).map((detector) => detector.id)).toEqual([ + "markdown", + "node" + ]); + expect(getAllowedExtensions().has(".md")).toBe(false); + expect(getAllowedExtensions({ forcedDetectors: ["markdown"] }).has(".md")).toBe(true); + }); + + it("explains why a file gets no header", () => { + expect(getHeaderSkipReason("/repo/a.mjs")).toBeNull(); + expect(getHeaderSkipReason("/repo/a.md", { forcedDetectors: ["markdown"] })).toBeNull(); + expect(getHeaderSkipReason("/repo/a.md")).toMatch(/--force-detector markdown/); + expect(getHeaderSkipReason("/repo/package.json")).toMatch(/no enabled detector handles \.json files/); + expect(getHeaderSkipReason("/repo/Makefile")).toMatch(/no extension/); + }); +}); + +describe("cli: formats that cannot carry the header comment", () => { + it("parses --force-detector as a repeatable, de-duplicated list", () => { + const parsed = parseCliArgs(["--force-detector", "markdown", "--force-detector", "markdown"]); + expect(parsed.options.forcedDetectors).toEqual(["markdown"]); + }); + + it("reports a Markdown or JSON file named by --input as skipped and leaves it byte-identical", async () => { + const workspace = await createFormatsWorkspace("formats-cli-skip"); + try { + for (const input of ["doc.md", "package.json"]) { + const { lines, stdout } = collect(); + const code = await runCli(["--cwd", workspace, "--input", input, "--author-name", "A", "--author-email", "a@example.com"], { + stdout + }); + expect(code).toBe(0); + expect(lines[0]).toBe("fix-headers complete: scanned=0, updated=0, skipped=1, dryRun=false"); + expect(lines[1]).toMatch(new RegExp(`^skipped: ${input.replace(".", "\\.")} \\(`)); + expect(lines.some((line) => line.startsWith("updated:"))).toBe(false); + } + expect(await readFile(join(workspace, "doc.md"), "utf8")).toBe(MARKDOWN); + expect(await readFile(join(workspace, "package.json"), "utf8")).toBe(PACKAGE_JSON); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("prints placeholders for skipped entries a custom runner leaves incomplete", async () => { + const { lines, stdout } = collect(); + const runner = async () => ({ filesScanned: 0, filesUpdated: 0, filesSkipped: 2, skipped: [null, {}], dryRun: true }); + expect(await runCli([], { runner, stdout })).toBe(0); + expect(lines).toEqual([ + "fix-headers complete: scanned=0, updated=0, skipped=2, dryRun=true", + "skipped: (no reason given)", + "skipped: (no reason given)" + ]); + }); + + it("writes an HTML comment into Markdown with --force-detector markdown", async () => { + const workspace = await createFormatsWorkspace("formats-cli-forced"); + try { + const args = [ + "--cwd", + workspace, + "--input", + "doc.md", + "--force-detector", + "markdown", + "--author-name", + "A", + "--author-email", + "a@x.dev" + ]; + const first = collect(); + expect(await runCli([...args, "--verbose"], { stdout: first.stdout })).toBe(0); + expect(first.lines).toEqual(["fix-headers complete: scanned=1, updated=1, dryRun=false", "updated: doc.md"]); + + const written = await readFile(join(workspace, "doc.md"), "utf8"); + expect(written.startsWith("