diff --git a/README.md b/README.md index 262cdc7..d4c1acb 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,19 @@ A curated collection of highly robust, custom-built Node/Python automation scrip 1. [auto-classify-projects.js](#1-auto-classify-projectsjs) 2. [sync-profile-readme.js](#2-sync-profile-readmejs) 3. [oss-contributor-log.py](#3-oss-contributor-logpy) +4. [markdown-toc.js](#4-markdown-tocjs) +5. [link-check.js](#5-link-checkjs) +6. [frontmatter-lint.js](#6-frontmatter-lintjs) +7. [heading-lint.js](#7-heading-lintjs) +8. [table-fmt.js](#8-table-fmtjs) +9. [code-fence-lint.js](#9-code-fence-lintjs) +10. [image-alt-lint.js](#10-image-alt-lintjs) +11. [list-lint.js](#11-list-lintjs) +12. [commit-lint.js](#12-commit-lintjs) +13. [changelog-lint.js](#13-changelog-lintjs) +14. [whitespace-lint.js](#14-whitespace-lintjs) +15. [link-text-lint.js](#15-link-text-lintjs) +16. [secret-scan.js](#16-secret-scanjs) --- @@ -75,5 +88,557 @@ python3 oss-contributor-log.py show --- +## 4. `markdown-toc.js` +> **Generate a GitHub-accurate table of contents from any Markdown file's headings.** + +A zero-dependency Node script that reads a Markdown file, extracts its headings, and builds a table of contents with anchor links that actually resolve on GitHub. Print it to stdout, or inject it straight into the file between `` markers. + +### ⚑ Key Features: +* **GitHub-accurate slugs**: Mirrors GitHub's real anchor algorithm β€” punctuation stripped, spaces hyphenated without collapsing, duplicate headings disambiguated (`#getting-started`, `#getting-started-1`). The links work, not just look like they should. +* **Code-fence aware**: Skips `#` lines inside fenced code blocks (```` ``` ```` / `~~~`), so a commented shell command never sneaks into your TOC. +* **Idempotent `--write`**: Updates the block between `` and `` in place. Run it on every commit β€” it replaces, never duplicates. No markers? It drops them in right after your H1. +* **CI-friendly `--check`**: Verifies the TOC is current without touching the file. Exit `0` (up to date), `1` (stale β€” run `--write`), or `2` (no markers). Drop it in a CI step or pre-commit hook so a forgotten TOC update fails the build. +* **Level control**: `--min-level` / `--max-level` to skip the H1 title and ignore deep sub-headings. +* **Zero dependencies**: Pure Node `fs`. Nothing to install. + +### πŸš€ Usage: +```bash +# Print a TOC to stdout (default: levels 2–4, skipping the H1 title) +node markdown-toc.js README.md + +# Inject/update the TOC block inside the file +node markdown-toc.js README.md --write + +# Only H2 and H3 +node markdown-toc.js docs/guide.md --min-level 2 --max-level 3 --write + +# CI / pre-commit: fail if the TOC is stale (exit 1), missing markers (exit 2), else pass (exit 0) +node markdown-toc.js README.md --check +``` + +Add `` and `` where you want it, then wire `--write` into a pre-commit hook to keep it fresh automatically β€” or `--check` into CI so a stale TOC fails the build. + +### πŸ“¦ Reusable functions: +The script also exports its internals (`slugify`, `extractHeadings`, `buildToc`, `injectToc`) so you can `require()` it in your own tooling. + +--- + +## 5. `link-check.js` +> **Find broken local links in Markdown before they embarrass you in a README.** + +A zero-dependency Node script that catches the two link failures README maintainers hit most: relative file links pointing at a path that no longer exists, and in-page `#anchor` links to a heading you renamed or deleted. It validates anchors with GitHub's real slug algorithm β€” the same one `markdown-toc.js` uses β€” so `#section` links resolve exactly the way they will on github.com. + +### ⚑ Key Features: +* **Dead-file detection**: Resolves every relative `[text](path)` and `![alt](path)` against the filesystem (relative to the Markdown file itself) and flags anything missing. Handles URL-encoded paths and `file.md#fragment` forms. +* **Anchor validation**: For `#anchor` links β€” both same-file and `other.md#anchor` β€” it builds the target file's heading slugs and confirms the anchor actually exists. Also honors explicit `` / `id="..."` anchors. +* **No false positives from code**: Links inside fenced code blocks (```` ``` ````/`~~~`) and inline `` `code spans` `` are ignored, so a sample command never reads as a broken link. +* **Network-free by default**: External URLs (`http`, `https`, `mailto`, `tel`) are never fetched β€” the check is deterministic and safe for CI. Pass `--external` to list them for a manual eyeball. +* **CI / pre-commit ready**: Exit `0` (all local links resolve), `1` (broken links found), or `2` (usage error). Multiple files per run. +* **Zero dependencies**: Pure Node `fs` + `path`. + +### πŸš€ Usage: +```bash +# Check one file +node link-check.js README.md + +# Check several at once +node link-check.js README.md docs/*.md + +# Also list external URLs (never fetched, just printed) +node link-check.js README.md --external + +# Machine-readable report +node link-check.js README.md --json + +# CI: fail the build on any broken link +node link-check.js README.md docs/guide.md +``` + +Pairs naturally with `markdown-toc.js`: generate the TOC, then verify every link in it (and everywhere else) still resolves. Wire both into a pre-commit hook and your docs stop rotting. + +### πŸ“¦ Reusable functions: +Exports `slugify`, `cleanText`, `parseMarkdown`, `classify`, and `checkFile` for use in your own tooling via `require()`. + +--- + +## 6. `frontmatter-lint.js` +> **Catch broken YAML frontmatter before it breaks your site build or your content tracker.** + +A zero-dependency Node script that lints the `---` frontmatter block at the top of Markdown files. It's the third leg of the docs-quality set (`link-check.js` for links, `markdown-toc.js` for the TOC, this for metadata) β€” built for content repos where a typo'd `date` or a `tags` field that's secretly a string silently breaks a static-site build or a posting pipeline. + +### ⚑ Key Features: +* **Required-key checks**: Flags missing or empty keys (default `title,date`, configurable via `--require`). An empty `title: ""` fails too, not just an absent one. +* **Type validation**: `--date-keys` must be a real `YYYY-MM-DD` calendar date (rejects `2026-13-45`); `--bool-keys` must be a true boolean (catches the classic `draft: "true"` string); `--list` keys must be an actual array (catches `tags: ai, llm` that should be `[ai, llm]`). +* **Practical YAML subset**: Parses scalars, quoted strings, flow lists (`[a, b]`), block lists (`- item`), booleans, numbers, and dates. Duplicate keys, unterminated blocks, and unsupported nested maps are reported instead of silently mis-parsed. +* **Whole-tree mode**: `--dir ` recursively lints every `.md`/`.markdown` file (skips `.git`/`node_modules`). `--allow-missing` lets files without frontmatter pass. +* **CI / pre-commit ready**: Exit `0` (all clean), `1` (lint problems), or `2` (usage/IO error). `--json` for machine-readable output, `--quiet` to print only failures. +* **Zero dependencies**: Pure Node `fs` + `path`. Network-free and deterministic. + +### πŸš€ Usage: +```bash +# Lint one post +node frontmatter-lint.js post.md + +# Lint a whole content directory with a custom required set +node frontmatter-lint.js --dir blog --require title,date,tags,draft + +# Treat tags AND categories as lists; draft AND featured as booleans +node frontmatter-lint.js post.md --list tags,categories --bool-keys draft,featured + +# CI: fail the build on any frontmatter problem +node frontmatter-lint.js --dir content --require title,date --allow-missing +``` + +### πŸ“¦ Reusable functions: +Exports `extractFrontmatter`, `parseFrontmatter`, `coerceScalar`, `isValidIsoDate`, and `lintFile` for use in your own tooling via `require()`. + +--- + +## 7. `heading-lint.js` +> **Catch the heading-structure bugs that silently break your README's anchors and table of contents.** + +A zero-dependency Node script that lints the heading *structure* of Markdown files. It's the upstream companion to `link-check.js`: link-check tells you a `#anchor` is broken; heading-lint tells you **why** β€” almost always a duplicate heading or a skipped level. It uses the **same** GitHub slug algorithm and the **same** code-fence skipping as `markdown-toc.js` and `link-check.js`, so its idea of a "duplicate anchor" matches exactly what GitHub (and those tools) compute. + +### ⚑ Key Features: +* **Duplicate-slug detection**: Two headings that resolve to the same GitHub anchor (e.g. two `## Setup`s). GitHub silently renames the second to `setup-1`, so a `[link](#setup)` lands on the wrong heading β€” the #1 cause of "the link looks right but jumps to the wrong place." +* **Skipped-level detection**: Flags outline jumps like H2 β†’ H4 (skipping H3) that break the document structure and most TOC generators. +* **H1 hygiene**: Flags multiple H1s (`--allow-multiple-h1` to disable) and missing H1 (`--no-require-h1` to disable). `--require-h1` additionally requires the *first* heading to be the H1. +* **Empty-heading detection**: Catches a bare marker (`## `) with no text. +* **CI / pre-commit ready**: Exit `0` (clean), `1` (problems), `2` (usage/IO error). `--json` for machine-readable output, `--quiet` to print only failures. +* **Zero dependencies**: Pure Node `fs`. Network-free and deterministic. + +### πŸš€ Usage: +```bash +# Lint one file +node heading-lint.js README.md + +# Lint several, machine-readable +node heading-lint.js README.md docs/*.md --json + +# A partial/included doc that legitimately has no top-level H1 +node heading-lint.js CHANGELOG.md --no-require-h1 + +# CI: fail the build on any heading problem +node heading-lint.js README.md +``` + +### πŸ• Dogfood note (honest output): +Run it on *this* README and it reports the repeated `⚑ Key Features:` / `πŸš€ Usage:` / `πŸ“¦ Reusable functions:` subsection labels as `duplicate-slug`. That's the tool working correctly β€” those headings really do collide into the same anchors. They're harmless *here* only because the Table of Contents links to the numbered section titles (`#1-auto-classify-projectsjs`), never the bare labels. The moment anyone writes `[see usage](#-usage)`, it would resolve to the first one. The lesson the tool is teaching: decorative repeated headings are a latent anchor bug β€” make subsection headings unique if anything links to them. + +### πŸ“¦ Reusable functions: +Exports `slugify`, `cleanText`, `extractHeadings`, and `lintHeadings` for use in your own tooling via `require()`. + +--- + +## 8. `table-fmt.js` +> **`gofmt` for your Markdown tables β€” align every column so the raw source is readable and diffs stay clean.** + +A zero-dependency Node script that reformats GitHub-Flavored Markdown tables: it pads every column to an even width and rewrites the delimiter row to honor each column's alignment. The rendered HTML is unchanged β€” this only fixes the source whitespace, the single most tedious thing to maintain by hand after you edit a cell. It's the formatting companion to the lint family above: those tell you something is wrong; this one quietly fixes it. + +### ⚑ Key Features: +* **Column alignment**: Pads each column to its widest cell (min width 3) and justifies body cells **left** (`:---`), **right** (`---:`), **center** (`:--:`), or default per the delimiter row. +* **Ragged-row repair**: Pads short rows with empty cells and drops extras to the header's column count β€” exactly how GitHub renders a ragged table. +* **Code-fence aware**: Tables inside fenced blocks (```` ``` ```` / `~~~`) are left untouched, using the same fence-skipping as the other scripts here. +* **Idempotent**: Formatting twice produces byte-identical output β€” safe to run in a loop or a hook. +* **CI / pre-commit ready**: `--check` exits `1` if any file isn't already formatted; `--write` fixes in place; `--json` for machine-readable reports. Reads stdin with `-`. +* **Zero dependencies**: Pure Node `fs`. Network-free and deterministic. + +### πŸš€ Usage: +```bash +# Print a formatted copy to stdout +node table-fmt.js README.md + +# Rewrite files in place +node table-fmt.js README.md docs/*.md --write + +# CI: fail if any table isn't aligned +node table-fmt.js *.md --check + +# Pipe through it +cat doc.md | node table-fmt.js - +``` + +### ⚠️ Honest limitation: +Column width is measured in Unicode **code points**, not terminal display columns. Wide CJK characters and emoji take two cells in a monospace editor, so a table full of them can look slightly off even when the tool considers it aligned. ASCII tables β€” the common case β€” align exactly. + +### πŸ“¦ Reusable functions: +Exports `splitCells`, `isDelimiterRow`, `alignmentOf`, `formatTables`, and `formatOneTable` for use in your own tooling via `require()`. + +--- + +## 9. `code-fence-lint.js` +> **Catch the unclosed code fence that silently turns the rest of your README into one grey blob.** + +A zero-dependency Node script that lints fenced code blocks in Markdown. Its headline catch is the **unclosed fence** β€” a ```` ``` ```` that opens but never closes, swallowing everything after it into a single code block on GitHub. It tracks fences the same way the rest of the lint family does, but goes deeper: it understands fence *length* and *character*, so a ```` ```` ```` (4-backtick) block legitimately containing ```` ``` ```` lines, and a ```` ``` ```` block containing `~~~` lines, are parsed correctly instead of false-flagged. + +### ⚑ Key Features: +* **Unclosed-fence detection**: Flags any fence that opens but never closes before end of file β€” the #1 cause of "why is half my README a code block?" Always an error (exit `1`). +* **Length & character aware**: A closing fence must use the **same** character (`` ` `` or `~`) and be **at least as long** as the opener. This makes longer outer fences (```` ```` ````) that wrap shorter inner fences (```` ``` ````) work, and catches the mismatched case where the only candidate closer was too short to actually close the block. +* **Missing-language warnings**: An opening fence with no language tag (```` ``` ```` vs ```` ```js ````) is reported as a **warning** (no syntax highlighting). Promote it to an error with `--strict-language`, or silence it entirely with `--no-require-language`. +* **Tildes too**: `~~~` fences are handled exactly like backtick fences, including the cross-character rule (a `~~~` line inside a ```` ``` ```` block is content, not a close). +* **No false positives from inline code**: Only a line that *starts* (after ≀3 spaces, CommonMark-style) with a fence run counts β€” inline `` `code` `` is never mistaken for a fence. Fences inside list items work. +* **CI / pre-commit ready**: Exit `0` (clean β€” warnings alone don't fail), `1` (errors), or `2` (usage/IO error). `--json` for machine-readable output, `--quiet` to print only failures. +* **Zero dependencies**: Pure Node `fs`. Network-free and deterministic. + +### πŸš€ Usage: +```bash +# Lint one file +node code-fence-lint.js README.md + +# Lint several, machine-readable +node code-fence-lint.js README.md docs/*.md --json + +# Make a missing language tag fail the build, not just warn +node code-fence-lint.js README.md --strict-language + +# Don't care about language tags β€” only catch unclosed fences +node code-fence-lint.js README.md --no-require-language + +# CI: fail the build on any unclosed/mismatched fence +node code-fence-lint.js README.md +``` + +### ⚠️ Honest limitation: +There's no full CommonMark block parser. A ```` ``` ```` that lives inside an *indented* (4-space) code block or an unusually-nested blockquote may be read as a real fence. The common cases β€” top-level fences and fences inside list items β€” are handled correctly. + +### πŸ“¦ Reusable functions: +Exports `matchFence` and `lintFences` for use in your own tooling via `require()`. + +--- + +## 10. `image-alt-lint.js` +> **The README picture that says nothing to a screen reader β€” or to Google Image search.** + +A zero-dependency Node script that lints **image alt text** in Markdown and inline HTML β€” the accessibility (and SEO) gap that no link checker or heading linter catches. `![](diagram.png)` renders a picture for sighted readers and *silence* for everyone using a screen reader; this finds those before they ship. It's the a11y companion to `link-check.js`: link-check tells you an image URL is dead, this tells you a *live* image has no usable description. + +### ⚑ Key Features: +* **Missing & empty alt (errors)**: Flags `![](src)`, `![ ](src)` (whitespace-only), and `` with **no `alt` attribute at all** β€” the hard accessibility failures. Exit `1`. +* **Weak alt (warnings)**: Catches alt text that's just a **filename** (`![diagram.png](diagram.png)`) or **placeholder boilerplate** (`image`, `photo`, `screenshot here`, `alt text`, `TODO`…). Promote warnings to errors with `--strict`. +* **Markdown *and* HTML**: Handles `![alt](src)`, reference-style `![alt][ref]`, and `` tags (single/double/bare-quoted attributes) β€” READMEs use all three. +* **Knows decorative from broken**: A `` with `role="presentation"`, `role="none"`, or `aria-hidden="true"` is skipped. With `--allow-empty`, an *explicitly* empty alt (`![]()` / `alt=""`, the WCAG decorative pattern) passes β€” but a totally **missing** `` alt attribute still fails, because that's the real bug. +* **No false positives**: Skips fenced code blocks (```` ``` ````/`~~~`) and inline `` `code` `` spans the same way the rest of the lint family does, so an image written *as an example* never trips it. Per-line opt-out with a trailing ``. +* **CI / pre-commit ready**: Exit `0` (no errors β€” warnings alone don't fail unless `--strict`), `1` (errors), or `2` (usage/IO error). `--json` for machine-readable output, `--quiet` to print only failures. +* **Zero dependencies**: Pure Node `fs`. Network-free and deterministic. + +### πŸš€ Usage: +```bash +# Lint one file +node image-alt-lint.js README.md + +# Lint several, machine-readable +node image-alt-lint.js README.md docs/*.md --json + +# Make weak alt text (filenames, placeholders) fail the build too +node image-alt-lint.js README.md --strict + +# Treat explicit empty alt as decorative-ok (still fails a missing alt) +node image-alt-lint.js README.md --allow-empty + +# Only Markdown images, ignore tags +node image-alt-lint.js README.md --no-html +``` + +### ⚠️ Honest limitation: +Images are matched per line, so a `` tag split across multiple lines isn't fully parsed. The common cases β€” Markdown images and single-line `` tags β€” are handled. Alt text containing a literal `]` can confuse the Markdown matcher (rare in practice). + +### πŸ“¦ Reusable functions: +Exports `lintImages`, `classifyAlt`, `htmlAttr`, `basename`, and `stripInlineCode` for use in your own tooling via `require()`. + +--- + +## 11. `list-lint.js` +> **The bullet you switched from `-` to `*` mid-list, and the ordered list that jumps `1, 2, 4`.** + +A zero-dependency Node script that lints **Markdown list consistency** β€” the churn-and-render problems the other linters don't look at. Mixed bullet markers and a broken number sequence render *fine* on GitHub, so nobody notices until a formatter rewrites every marker and a one-line change shows up as a 40-line diff. This catches it first. It's the list-focused sibling of `code-fence-lint.js` and `table-fmt.js`: same fence-skipping, same CLI, same CI exit codes. + +### ⚑ Key Features: +* **`inconsistent-bullet` (error)**: Within a single list, flags an unordered item whose marker (`-` / `*` / `+`) differs from the first one used at that level. Scoped to *one list block* β€” a repo that uses `-` in one section and `*` in another is never penalized; only mixing **within** a list is. (markdownlint MD004.) +* **`ordered-numbering` (error)**: Flags an ordered list that's neither clean lazy numbering (all `1.`, which GitHub renumbers for you) nor a real incrementing run from its start. `1, 2, 4` or `1, 3, 2` is the hand-edit that dropped or duplicated a step. (MD029.) +* **`odd-indent` (warning)**: Flags a list item indented by an **odd** number of spaces (1, 3, 5…) β€” the typo that can silently flip a nested item into a sibling or a stray paragraph. Promote with `--strict-indent`, silence with `--no-indent`. (Related to MD005/MD007.) +* **No false positives**: Tracks fenced code blocks (```` ``` ````/`~~~`) and skips them, so a `-` in a code sample is never a bullet. Loose lists (blank lines between items) and lists separated by prose are handled correctly. +* **CI / pre-commit ready**: Exit `0` (no errors β€” warnings alone don't fail), `1` (errors), `2` (usage/IO). `--json` for machine output, `--quiet` to print only failures. Each rule individually toggleable. +* **Zero dependencies**: Pure Node `fs`. Network-free and deterministic. + +### πŸš€ Usage: +```bash +# Lint one file +node list-lint.js README.md + +# Lint several, machine-readable +node list-lint.js docs/*.md --json + +# Make an odd indent fail the build too +node list-lint.js README.md --strict-indent + +# Turn individual rules off +node list-lint.js README.md --no-indent --no-ordered +``` + +### ⚠️ Honest limitation: +No full CommonMark list parser β€” marker consistency and numbering are keyed by leading-space width, which is the common case; exotic nesting inside blockquotes or 4-space indented code may be read loosely. Ordered "start" is taken from the first item, so a list that deliberately starts at 5 and increments is accepted. + +### πŸ“¦ Reusable functions: +Exports `lintText` for use in your own tooling via `require()`. + +--- + +## 12. `commit-lint.js` +> **The `Fixed stuff.` commit that quietly drops out of your changelog.** + +A zero-dependency Node script that lints **git commit messages** against [Conventional Commits](https://www.conventionalcommits.org/) β€” the format `semantic-release`, changelog generators, and auto-versioning tools depend on. The other scripts here lint *files*; this one lints *history*. A single `Feat:` (wrong case) or `feature:` (wrong word) silently falls out of the generated changelog and breaks the next version bump β€” this catches it at commit time, before it's baked into `main`. + +### ⚑ Key Features: +* **`header-format` (error)**: The first line must parse as `type(scope)?!?: description`. No colon, an empty description, or a body glued to the header without a blank line is flagged β€” the structural problems that make a message un-parseable. +* **`type` (error)**: The type must be lowercase and one of `feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert`. `Feat` and `feature` are both flagged. Override the set with `--types feat,fix,docs`. +* **`blank-line` (error)**: A body must be separated from the header by exactly one blank line (line 2), or `git log` and every parser misreads it as one long subject. +* **`subject-length` (warning)**: Header over `--max-subject` (default 72) chars β€” the length that truncates in `git log --oneline`, GitHub, and email. +* **`subject-style` (warning)**: Description ending in a period or starting with a capital letter (the commitlint "imperative, lowercase" house style). Acronyms like `API` are **not** flagged. Silence with `--no-style`. +* **Breaking changes accepted, never flagged**: both `feat!: …` and a `BREAKING CHANGE:` footer are recognized. +* **Hook-ready**: point it straight at `.git/COMMIT_EDITMSG` from a `commit-msg` hook β€” it strips git's `#` comment lines and the verbose-commit scissors (`# ------ >8 ------`) diff section first. Git-generated `Merge`/`Revert` messages are skipped by default (`--lint-merges` to check them). +* **Three input modes**: message files (`node commit-lint.js .git/COMMIT_EDITMSG`), `--stdin`, or a whole range (`--range origin/main..HEAD`) to lint every commit in a PR. +* **CI-ready**: exit `0` (no errors β€” warnings alone don't fail), `1` (errors), `2` (usage/IO/git). `--json` for machine output, `--quiet` to print only failures. +* **Zero dependencies**: pure Node `fs` + `git` for the `--range` mode. Network-free. + +### πŸš€ Usage: +```bash +# As a commit-msg hook (.git/hooks/commit-msg): +# #!/bin/sh +# exec node path/to/commit-lint.js "$1" + +# Lint a single message file +node commit-lint.js .git/COMMIT_EDITMSG + +# Lint every commit in a PR before you push +node commit-lint.js --range origin/main..HEAD + +# Pipe a message in +echo "feat: add token refresh" | node commit-lint.js --stdin + +# Stricter subject cap, only allow two types +node commit-lint.js --range HEAD~10..HEAD --max-subject 50 --types feat,fix +``` + +### πŸ• Dogfood note (honest output): +This repo's own history predates the convention (commits like `Add list-lint.js: …`), so `node commit-lint.js --range HEAD~5..HEAD` flags them as malformed headers. That's the script working correctly β€” not a bug. History isn't rewritten here; the linter is meant to gate *new* commits going forward, which is exactly how you'd adopt it on a real project. + +### ⚠️ Honest limitation: +Not a full `commitlint`. It checks header grammar, type, structure, and length hygiene β€” not a scope enum, footer/issue-reference syntax, or the semver implication of a type. That covers where most bad commits actually go wrong; reach for `commitlint` if you need the config-driven rule engine. + +### πŸ“¦ Reusable functions: +Exports `lintMessage(message, opts)` and `stripGitCruft(raw)` for use in your own tooling via `require()`. + +--- + +## 13. `changelog-lint.js` +> **The `## v1.2.0` heading that your release tooling silently skips.** + +A zero-dependency Node script that lints a `CHANGELOG.md` against the [Keep a Changelog](https://keepachangelog.com/) convention. `commit-lint.js` lints *history* and the rest lint *any* Markdown; this one lints the **one file** release automation actually parses. A version heading written `## v1.2.0` instead of `## [1.2.0] - 2025-06-01`, or a change group typo'd `### Fixes` instead of `### Fixed`, drops that section out of every parser that reads it β€” GitHub Release notes, `semantic-release`, changelog aggregators. This catches it before you tag. + +### ⚑ Key Features: +* **`version-format` (error)**: A release heading must be `## [x.y.z] - YYYY-MM-DD` (or `## [Unreleased]`). Catches `## 1.2.0`, `## [1.2.0]` with no date, `## v1.2.0 - …`, missing brackets. An optional trailing ` [YANKED]` is allowed. +* **`invalid-version` (error)**: The bracketed version must be valid [SemVer](https://semver.org/) β€” `[1.2]`, `[1.2.0.0]`, and `[01.2.0]` are flagged (official SemVer 2.0.0 grammar, pre-release and build metadata supported). +* **`invalid-date` (error)**: The date must be a real ISO `YYYY-MM-DD` calendar date β€” `2025-13-40` and `2025-02-30` are rejected, not just regex-matched. +* **`bad-change-type` (error)**: An `###` group under a version must be one of the six Keep a Changelog sections β€” `Added, Changed, Deprecated, Removed, Fixed, Security`. `### Fixes`, `### New`, `### Notes` are the classic drift. +* **`duplicate-version` (error)**: The same version number in two headings β€” the copy-paste-a-section-and-forget-to-bump mistake. +* **`no-unreleased` (warning)**: No `## [Unreleased]` section β€” the recommended landing spot for the next change. +* **`version-order` (warning)**: Releases not listed newest-first by SemVer precedence, or `[Unreleased]` not on top. +* **`empty-section` (warning)**: A version or change group with no entries beneath it (a version whose only content is its `###` groups is *not* flagged). +* **Fenced code aware**: a `## 9.9.9` inside a ` ``` ` block is a sample, not a heading β€” never flagged. +* **CI-ready**: exit `0` (no errors β€” warnings alone don't fail), `1` (errors), `2` (usage/IO). `--json`, `--quiet`, `--strict` (promote warnings to errors), and a `--no-` flag per rule. +* **Zero dependencies**: pure Node `fs`. Network-free. + +### πŸš€ Usage: +```bash +# Lint the changelog +node changelog-lint.js CHANGELOG.md + +# Machine-readable, or fail on warnings too +node changelog-lint.js CHANGELOG.md --json +node changelog-lint.js CHANGELOG.md --strict + +# Turn off the "newest first" and "needs Unreleased" opinions +node changelog-lint.js CHANGELOG.md --no-order --no-unreleased +``` + +### ⚠️ Honest limitation: +It checks **structure**, not prose β€” it won't tell you an entry is badly written or that a shipped change went unlogged. It also skips the bottom link-reference definitions (`[1.2.0]: …/compare/…`); that's `link-check.js`'s job. It validates the skeleton every tool relies on, and leaves the writing to you. + +### πŸ“¦ Reusable functions: +Exports `lintText(text, opts)`, plus `parseSemver`, `compareSemver`, and `isValidDate` for use in your own tooling via `require()`. + +--- + +## 14. `whitespace-lint.js` +> **The invisible diff noise every other linter ignores β€” and the one that can safely fix itself.** + +A zero-dependency Node script that lints (and repairs) whitespace *hygiene* in any text file. The rest of the family checks what a document **says**; this one checks the invisible bytes around it β€” trailing spaces, hard tabs, CRLF line endings, a missing final newline, runs of blank lines. None of them change the rendered output; all of them create diff churn, merge conflicts, and "why did my whole file change?" pull requests. Because every rule is mechanical, `--fix` can repair all of them safely and idempotently. + +### ⚑ Key Features: +* **`trailing-whitespace` (error, MD009)**: A line ends in spaces/tabs. Exception: exactly **two** trailing spaces is a Markdown hard line break β€” allowed by default, forbid it with `--no-md-breaks`. +* **`hard-tab` (error, MD010)**: A tab used for indentation *outside* a fenced code block (tabs render at an unpredictable width and misalign nested lists). Tabs **inside** code fences are left alone β€” often significant, e.g. a pasted Makefile β€” unless you pass `--tabs-in-code`. +* **`crlf` (error)**: A line ends in `\r\n` or a bare `\r`. Windows checkouts sneak these in and make every line look changed on a Unix diff. +* **`final-newline` (error)**: The file doesn't end with exactly one newline β€” covers both "no newline at end of file" (GitHub's telltale marker) and "extra blank lines at EOF." +* **`multiple-blanks` (warning, MD012)**: More than N consecutive blank lines (`--max-blanks N`, default 1). Warning by default; promote with `--strict-blanks`. +* **`--fix`**: Repairs every enabled rule in place β€” strips trailing space, expands indent tabs to spaces, normalizes to LF, collapses blank runs, and lands exactly one final newline. **Idempotent**: running it twice changes nothing the second time. +* **Fenced code aware**: the hard-tab and blank-run rules skip ` ``` `/`~~~` blocks by default (code has its own whitespace rules); trailing-whitespace, CRLF, and final-newline are checked everywhere because they're never intentional. +* **Works on any text file**, not just Markdown β€” point it at `.js`, `.py`, `.yml`, whatever. The Markdown-specific logic simply won't trigger where there are no fences or hard breaks. +* **CI-ready**: exit `0` (clean, or `--fix` repaired everything), `1` (findings), `2` (usage/IO). `--json` for machine output, and a `--no-` flag per rule. +* **Zero dependencies**: pure Node `fs`. Network-free. + +### πŸš€ Usage: +```bash +# Lint one or many files +node whitespace-lint.js README.md +node whitespace-lint.js src/*.js docs/*.md --json + +# Repair in place (safe, idempotent) +node whitespace-lint.js *.md --fix + +# Tighten or loosen the opinions +node whitespace-lint.js post.md --no-md-breaks # a 2-space break is an error too +node whitespace-lint.js post.md --max-blanks 2 # allow up to 2 blank lines +node whitespace-lint.js post.md --no-crlf --no-tab # turn rules off +``` + +### ⚠️ Honest limitation: +`--fix` collapses blank-line runs globally rather than re-parsing fences during the rewrite, so a code sample that deliberately embeds three blank lines will be trimmed to your `--max-blanks` too β€” rare, but pass `--no-blanks` if a doc relies on it. Tab-to-space fixing uses 2 spaces per indent tab; if your project standard is 4, run your formatter after. + +### πŸ“¦ Reusable functions: +Exports `lintContent(raw, opts)`, `fixContent(raw, opts)`, `parseArgs`, `detectEol`, and `fenceMarker` for use in your own tooling via `require()`. + +--- + +## 15. `link-text-lint.js` +> **The "click here" link that says nothing to a screen reader β€” or to Google.** + +A zero-dependency Node script that lints **link text** in Markdown and inline HTML β€” the accessibility (and SEO) gap that a link checker can't see. `link-check.js` tells you a URL is *dead*; this tells you a *working* link has text that says *nothing*. Screen-reader users routinely pull up a list of every link on the page and navigate by that list, out of context β€” a page of ten "click here" links is ten identical, meaningless entries. Search engines read link text as a relevance signal for the destination, so "here" and "read more" throw that away too. It's the link-side companion to `image-alt-lint.js`: one flags images that describe nothing to a listener, this flags links that do. + +### ⚑ Key Features: +* **Empty & whitespace text (errors)**: Flags `[](url)`, `[ ](url)`, and `` with no text β€” a screen reader announces the raw URL or nothing. Exit `1`. +* **Non-descriptive text (warning)**: Catches the WCAG 2.4.4 classics β€” `click here`, `here`, `this`, `this link`, `read more`, `learn more`, `more`, `link`, `download`… β€” text that's meaningless away from its sentence. Promote to errors with `--strict`. +* **Raw-URL text (warning)**: Flags a link whose visible text is just a long URL (`[https://ex.com/a/b/c](https://…)`) β€” a listener hears the whole thing read aloud. Short bare domains (`example.com`) read fine and pass. +* **Markdown *and* HTML**: Handles `[text](url)`, reference-style `[text][ref]`, and `text`. Ignores images (`![alt](src)` β€” that's `image-alt-lint.js`'s job) and idiomatic angle-bracket autolinks (``). +* **Unwraps formatting**: bold link text like `[**here**](https://example.com)` and code-styled text like `` [`cmd`](https://example.com) `` are normalized before checking, so emphasis and inline-code link text are judged by their rendered words β€” and a real code-styled link is *not* mistaken for empty. +* **No false positives**: Skips fenced code blocks and links written *inside* an inline `` `code` `` span (a literal `` `[here](x)` `` example never trips it). Per-line opt-out with a trailing ``. +* **CI / pre-commit ready**: Exit `0` (no errors β€” warnings alone don't fail unless `--strict`), `1` (errors), or `2` (usage/IO error). `--json` for machine output, `--quiet` to print only failures, `--no-html` for Markdown-only. +* **Zero dependencies**: Pure Node `fs`. Network-free and deterministic. + +### πŸš€ Usage: +```bash +# Lint one file +node link-text-lint.js README.md + +# Lint several, machine-readable +node link-text-lint.js README.md docs/*.md --json + +# Make non-descriptive / raw-URL link text fail the build too +node link-text-lint.js README.md --strict + +# Only Markdown links, ignore tags +node link-text-lint.js README.md --no-html +``` + +### ⚠️ Honest limitation: +Links are matched per line, so an `` tag whose opening tag and text span multiple lines isn't fully parsed. The common cases β€” Markdown links and single-line `` β€” are handled. The non-descriptive word list is English; localize the `NONDESCRIPTIVE` set for other languages. + +### πŸ“¦ Reusable functions: +Exports `lintLinks`, `classifyText`, `plainText`, `normalizeText`, `htmlAttr`, and `codeSpanRanges` for use in your own tooling via `require()`. + +--- + +## 16. `secret-scan.js` +> **The one commit you can't take back: a live credential.** + +A zero-dependency Node scanner for hardcoded secrets β€” API keys, tokens, private keys β€” in your working tree. This is the *security* check the doc linters aren't: once a real key lands in git history it's public forever, and the fix is to **rotate the key, not `git rm` it**. So you catch it *before* the commit, with no install and no network call. It uses the same prefix-anchored patterns the heavyweight scanners (gitleaks, trufflehog) ship, so it recognizes real credential shapes rather than guessing. + +### ⚑ Key Features: +* **Prefix-anchored, high-confidence rules (errors)**: AWS access key IDs (`AKIA…`), GitHub tokens (`ghp_`/`gho_`/`ghu_`/`ghs_`/`ghr_` and fine-grained `github_pat_`), Slack tokens (`xoxb-…`) and webhooks (`hooks.slack.com/services/…`), Google API keys (`AIza…`), Stripe secret/restricted keys (`sk_live_`/`rk_live_`), npm tokens (`npm_…`), and `-----BEGIN … PRIVATE KEY-----` blocks. These are specific enough to act on β€” they fail the run. +* **Lower-confidence shapes (warnings)**: JWT-shaped strings (often not secret) and generic `secret = "…"` / `api_key = "…"` assignments whose value clears an entropy bar. Advisory by default; promote to failures with `--strict`. +* **Entropy + placeholder filtering**: The generic rule computes Shannon entropy so a random token trips it but a low-entropy string doesn't. Obvious placeholders β€” `EXAMPLE`, `your_token_here`, ``, `${VAR}`, `{{...}}`, repeated-char runs β€” are filtered out, which is why AWS's own docs value `AKIAIOSFODNN7EXAMPLE` never fires. +* **Redacted output**: Every finding shows only the first/last few characters (`ghp_…Vt6y (40 chars)`), so the scanner's own report never becomes the leak. +* **Tree-aware**: Point it at a directory and it walks the tree, skipping `.git`, `node_modules`, `vendor`, build output, binaries, and files over 2 MB (plus a NUL-byte binary sniff). +* **CI / pre-commit ready**: Exit `0` (clean), `1` (secret found β€” or a warning under `--strict`), `2` (usage/IO error). `--json` for machine output, `--quiet` to suppress the success line. +* **Zero dependencies**: Pure Node `fs`/`path`. Deterministic, network-free. + +### πŸš€ Usage: +```bash +# Scan a whole tree +node secret-scan.js . + +# Scan specific files (e.g. a staged .env you didn't mean to add) +node secret-scan.js src/config.js .env + +# Make JWTs and generic high-entropy assignments fail too +node secret-scan.js . --strict + +# Machine-readable +node secret-scan.js . --json +``` + +### ⚠️ Honest limitation: +It scans the **working tree**, not git history β€” so it stops a secret from being *added*, but won't find one already buried in past commits (run it in a fresh clone, or over `git log -p` output, for that). Prefix-anchored rules can't catch a bespoke in-house token with no fixed shape; the entropy rule is the conservative fallback. Tune the `RULES` and entropy `THRESHOLDS` for a noisier or stricter codebase. + +### πŸ“¦ Reusable functions: +Exports `RULES`, `shannonEntropy`, `redact`, `isPlaceholder`, `scanText`, `scanFile`, and `scanPaths` for use in your own tooling via `require()`. + +--- + +## πŸ” Wire the docs checks into CI / pre-commit + +The linters are most useful when they run automatically β€” so docs rot fails a build instead of +sitting unnoticed. `examples/check-docs.sh` wires **the whole suite** into one command; the +[`examples/`](examples/) folder has copy-paste artifacts to drop it into CI or a git hook: + +| File | What it is | How to use | +|------|-----------|-----------| +| [`examples/check-docs.sh`](examples/check-docs.sh) | One command that runs the whole suite over every tracked Markdown file β€” links, code fences, image alt text, and frontmatter by default; heading structure, table alignment, and marker-based TOCs opt-in. Exit `0`/`1`. | `bash examples/check-docs.sh` | +| [`examples/docs-check.yml`](examples/docs-check.yml) | GitHub Actions workflow β€” runs the checks on every push/PR that touches Markdown. | Copy to `.github/workflows/docs-check.yml` | +| [`examples/git-pre-commit`](examples/git-pre-commit) | Pre-commit hook β€” blocks a commit that introduces a broken link (checks only staged Markdown). | `cp examples/git-pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit` | +| [`examples/git-pre-commit-secrets`](examples/git-pre-commit-secrets) | Pre-commit hook β€” blocks a commit that adds a hardcoded secret (scans only staged files with `secret-scan.js`). | `cp examples/git-pre-commit-secrets .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit` | + +```bash +# Try it right now, on this repo: +bash examples/check-docs.sh +# β†’ Linting N Markdown file(s)… +# βœ“ All docs checks passed. +``` + +**Seven checks run by default** β€” `link-check`, `code-fence-lint`, `image-alt-lint`, +`link-text-lint`, `frontmatter-lint` (`--allow-missing`, so plain docs pass and only files that +*have* frontmatter get validated), `list-lint`, and `whitespace-lint`. These fail only on genuine +defects: a dead link, an unclosed fence, a missing `alt`, an empty/whitespace link text, malformed +frontmatter, a list that mixes bullet markers / botches its numbering, or trailing whitespace / +hard tabs / CRLF / a missing final newline. (`list-lint`'s odd-indent, `whitespace-lint`'s +multiple-blank-lines, and `link-text-lint`'s non-descriptive / raw-URL rules are non-failing +warnings.) + +**Two checks are opt-in** β€” `heading-lint` and `table-fmt --check` β€” because they can flag +deliberate style (repeated sub-headings across sections, hand-aligned tables), not bugs. Turn them +on for a strict pass: + +```bash +CHECK_HEADINGS=1 CHECK_TABLES=1 bash examples/check-docs.sh +``` + +Every check is individually toggleable (`1` = run, `0` = skip): +`CHECK_LINKS`, `CHECK_FENCES`, `CHECK_ALT`, `CHECK_LINK_TEXT`, `CHECK_FRONTMATTER`, `CHECK_LISTS`, `CHECK_WHITESPACE`, `CHECK_HEADINGS`, `CHECK_TABLES`. +For example, `CHECK_ALT=0 bash examples/check-docs.sh` skips the alt-text check. + +To also verify a marker-based table of contents, list the files that use `` markers: + +```bash +TOC_FILES="README.md docs/guide.md" bash examples/check-docs.sh +``` + +The **changelog check runs automatically** when the repo has a `CHANGELOG.md` β€” `check-docs.sh` +auto-detects it and lints it with `changelog-lint.js`. Point it elsewhere with +`CHANGELOG_FILE=docs/CHANGELOG.md`, or skip it with `CHANGELOG_FILE=`. + +> **Note:** the TOC check is opt-in per file because `markdown-toc.js --check` keys off the literal +> `` markers β€” a doc that only *mentions* those strings in prose (like this README) would +> read as having a TOC it doesn't. The default checks have no such caveat and run on everything. + +--- + ## πŸ“œ License MIT β€” Do whatever you want with these scripts! diff --git a/changelog-lint.js b/changelog-lint.js new file mode 100644 index 0000000..537ffc9 --- /dev/null +++ b/changelog-lint.js @@ -0,0 +1,486 @@ +#!/usr/bin/env node +/** + * changelog-lint.js + * + * Lint a CHANGELOG.md against the "Keep a Changelog" convention + * (https://keepachangelog.com/en/1.1.0/). The rest of the zero-dep lint family + * in this repo checks generic Markdown (link-check.js, heading-lint.js, + * list-lint.js, …) or commit history (commit-lint.js); this one checks the ONE + * file whose structure downstream tooling actually parses β€” release automation, + * GitHub Release notes, and humans skimming "what changed." A malformed version + * heading (`## v1.2.0` instead of `## [1.2.0] - 2025-01-02`) or a typo'd change + * group (`### Fixes` instead of `### Fixed`) silently drops a section out of every + * parser that reads it. Same fence-skipping, same CLI shape, same CI-friendly exit + * codes as its siblings. + * + * Checks (each rule can be toggled off): + * + * 1. version-format (error) An H2 that looks like a release heading but isn't + * `## [x.y.z] - YYYY-MM-DD` (or `## [Unreleased]`). + * Catches `## 1.2.0`, `## [1.2.0]` (no date), + * `## v1.2.0 - 2025-01-02`, missing brackets, etc. + * An optional ` [YANKED]` suffix is allowed. + * 2. invalid-version (error) The bracketed version isn't valid SemVer + * (e.g. `[1.2]`, `[1.2.0.0]`, `[01.2.0]`). + * 3. invalid-date (error) The date isn't a real ISO `YYYY-MM-DD` calendar + * date (e.g. `2025-13-40`, `2025-2-3`, `01/02/2025`). + * 4. bad-change-type (error) An H3 under a version that isn't one of the six + * Keep a Changelog groups: Added, Changed, + * Deprecated, Removed, Fixed, Security. `### Fixes`, + * `### New`, `### Notes` are the classic drift. + * 5. duplicate-version(error) The same version number appears in two headings. + * 6. no-unreleased (warn) No `## [Unreleased]` section. Keep a Changelog + * recommends one at the top so there's always a place + * to add the next change. + * 7. version-order (warn) Released versions aren't newest-first by SemVer + * precedence, and/or `[Unreleased]` isn't the first + * version section. Descending order is the convention. + * 8. empty-section (warn) A version or change-group heading with no entries + * beneath it before the next heading. + * + * Deliberately NOT checked (kept honest, not over-claimed): + * - Whether entry text is well-written, or whether every code change is logged. + * - Link-reference definitions at the bottom (`[1.2.0]: https://…/compare/…`); + * that's link-check.js's job, not this linter's. + * - The top-level `# Changelog` title / intro prose β€” style, not structure. + * + * Zero dependencies. Network-free. Works on any Node >= 14. + * + * Usage: + * node changelog-lint.js CHANGELOG.md # lint one file + * node changelog-lint.js CHANGELOG.md --json # machine-readable report + * node changelog-lint.js CHANGELOG.md --quiet # only print problems + * node changelog-lint.js CHANGELOG.md --no-order --no-unreleased + * node changelog-lint.js --help + * + * Exit codes (CI / pre-commit friendly): + * 0 no errors (warnings may be present) + * 1 at least one error-level problem found + * 2 usage / file-read error + * + * Also require()-able: + * const { lintText, parseSemver, isValidDate } = require('./changelog-lint.js'); + * const problems = lintText(changelogString, { order: 'off' }); + */ + +'use strict'; + +const fs = require('fs'); + +// ---- rule severities ------------------------------------------------------- + +const DEFAULTS = { + format: 'error', // version-format + version: 'error', // invalid-version + date: 'error', // invalid-date + changetype: 'error', // bad-change-type + duplicate: 'error', // duplicate-version + unreleased: 'warn', // no-unreleased + order: 'warn', // version-order + empty: 'warn', // empty-section +}; + +// The six canonical change groups (Keep a Changelog 1.1.0), compared case-insensitively. +const CHANGE_TYPES = new Set(['added', 'changed', 'deprecated', 'removed', 'fixed', 'security']); + +// Official SemVer 2.0.0 regex (semver.org), anchored. +const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +// An H2 version heading: `## [X] - DATE` with an optional trailing ` [YANKED]`. +// Group 1 = bracket contents (version or "Unreleased"), group 2 = date text (may be undefined). +const VERSION_HEADING_RE = /^##\s+\[([^\]]+)\](?:\s*-\s*(.+?))?\s*(\[YANKED\])?\s*$/i; + +// Any H2 at all (to catch release-ish headings that don't match the strict form). +const H2_RE = /^##\s+(?!#)(.*\S)\s*$/; +// Any H3 (change-group) heading. +const H3_RE = /^###\s+(?!#)(.*\S)\s*$/; + +// ---- helpers (exported for reuse/testing) ---------------------------------- + +/** Parse a SemVer string into comparable parts, or null if invalid. */ +function parseSemver(v) { + const m = SEMVER_RE.exec(v); + if (!m) return null; + return { + major: parseInt(m[1], 10), + minor: parseInt(m[2], 10), + patch: parseInt(m[3], 10), + prerelease: m[4] || null, // presence lowers precedence vs. same x.y.z release + raw: v, + }; +} + +/** SemVer precedence compare (ignores build metadata, per spec). >0 if a>b. */ +function compareSemver(a, b) { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + if (a.patch !== b.patch) return a.patch - b.patch; + // A version WITH a pre-release has LOWER precedence than one without. + if (a.prerelease && !b.prerelease) return -1; + if (!a.prerelease && b.prerelease) return 1; + if (!a.prerelease && !b.prerelease) return 0; + // Both have pre-release: dot-separated identifier comparison. + const pa = a.prerelease.split('.'); + const pb = b.prerelease.split('.'); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + if (pa[i] === undefined) return -1; + if (pb[i] === undefined) return 1; + const na = /^\d+$/.test(pa[i]); + const nb = /^\d+$/.test(pb[i]); + if (na && nb) { + const d = parseInt(pa[i], 10) - parseInt(pb[i], 10); + if (d !== 0) return d; + } else if (na !== nb) { + return na ? -1 : 1; // numeric identifiers are lower than alphanumeric + } else if (pa[i] !== pb[i]) { + return pa[i] < pb[i] ? -1 : 1; + } + } + return 0; +} + +/** True if `s` is a real ISO calendar date, exactly `YYYY-MM-DD`. */ +function isValidDate(s) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); + if (!m) return false; + const [y, mo, d] = [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]; + if (mo < 1 || mo > 12 || d < 1 || d > 31) return false; + // Reject impossible days (e.g. Feb 30, Apr 31) via round-trip through Date. + const dt = new Date(Date.UTC(y, mo - 1, d)); + return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d; +} + +// ---- core linter ----------------------------------------------------------- + +/** + * Lint a Keep-a-Changelog document. + * @param {string} text + * @param {object} [opts] per-rule severities: {format,version,date,changetype, + * duplicate,unreleased,order,empty} each 'error'|'warn'|'off' + * @returns {Array<{line:number, rule:string, severity:string, message:string}>} + */ +function lintText(text, opts) { + const sev = Object.assign({}, DEFAULTS, opts || {}); + const lines = text.split(/\r?\n/); + const problems = []; + + // Pass 1: flag fenced-code lines so a `## something` in a sample isn't a heading. + const inFence = fenceMask(lines); + + // Pass 2: collect headings + track content presence for empty-section. + // A "section" is a heading (H2 version or H3 change-group); it's non-empty if any + // non-blank, non-heading line appears before the next heading of any level. + const versions = []; // {line, name, semver|null, isUnreleased} + const sections = []; // {line, kind:'version'|'change', label, hasContent} + let current = null; // the innermost open section awaiting content + let currentVersion = null; // the open H2 version section (parent of change groups) + + const closeSection = () => { + if (current) sections.push(current); + current = null; + }; + + for (let i = 0; i < lines.length; i++) { + if (inFence[i]) { continue; } + const raw = lines[i]; + const ln = i + 1; + + const h2 = raw.match(H2_RE); + const h3 = raw.match(H3_RE); + + if (h2) { + closeSection(); + lintVersionHeading(raw, ln, sev, problems, versions); + current = { line: ln, kind: 'version', label: h2[1], hasContent: false }; + currentVersion = current; + continue; + } + if (h3) { + closeSection(); + // A change-group subsection counts as content for its parent version. + if (currentVersion) currentVersion.hasContent = true; + // bad-change-type + const label = h3[1].trim(); + if (sev.changetype !== 'off' && !CHANGE_TYPES.has(label.toLowerCase())) { + problems.push({ + line: ln, + rule: 'bad-change-type', + severity: sev.changetype, + message: `change group "### ${label}" is not one of Added/Changed/Deprecated/Removed/Fixed/Security`, + }); + } + current = { line: ln, kind: 'change', label, hasContent: false }; + continue; + } + // A top-level H1 or any other heading closes the current section too. + if (/^#\s/.test(raw) || /^#{4,}\s/.test(raw)) { closeSection(); currentVersion = null; continue; } + + // Content line? + if (current && raw.trim() !== '') current.hasContent = true; + } + closeSection(); + + // empty-section + if (sev.empty !== 'off') { + for (const s of sections) { + if (!s.hasContent) { + problems.push({ + line: s.line, + rule: 'empty-section', + severity: sev.empty, + message: `${s.kind === 'version' ? 'version' : 'change-group'} heading "${s.label}" has no entries beneath it`, + }); + } + } + } + + // duplicate-version + if (sev.duplicate !== 'off') { + const seen = new Map(); // normalized version string -> first line + for (const v of versions) { + if (v.isUnreleased) continue; + const key = v.semver ? v.semver.raw : v.name; + if (seen.has(key)) { + problems.push({ + line: v.line, + rule: 'duplicate-version', + severity: sev.duplicate, + message: `version [${key}] already declared at line ${seen.get(key)}`, + }); + } else { + seen.set(key, v.line); + } + } + } + + // no-unreleased + if (sev.unreleased !== 'off') { + if (!versions.some((v) => v.isUnreleased)) { + problems.push({ + line: 1, + rule: 'no-unreleased', + severity: sev.unreleased, + message: 'no "## [Unreleased]" section found (Keep a Changelog recommends one at the top)', + }); + } + } + + // version-order: Unreleased must be first (if present); releases descending. + if (sev.order !== 'off') { + let sawReleased = false; + for (const v of versions) { + if (v.isUnreleased) { + if (sawReleased) { + problems.push({ + line: v.line, + rule: 'version-order', + severity: sev.order, + message: '[Unreleased] should be the first version section, above released versions', + }); + } + } else { + sawReleased = true; + } + } + const released = versions.filter((v) => !v.isUnreleased && v.semver); + for (let k = 1; k < released.length; k++) { + if (compareSemver(released[k].semver, released[k - 1].semver) > 0) { + problems.push({ + line: released[k].line, + rule: 'version-order', + severity: sev.order, + message: `version [${released[k].semver.raw}] is newer than [${released[k - 1].semver.raw}] above it β€” list newest first`, + }); + } + } + } + + problems.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule)); + return problems; +} + +// Lint a single H2 heading; record it in `versions` when it parses as a release. +function lintVersionHeading(raw, ln, sev, problems, versions) { + const vm = raw.match(VERSION_HEADING_RE); + if (vm) { + const inner = vm[1].trim(); + const dateText = vm[2] ? vm[2].trim() : null; + + if (/^unreleased$/i.test(inner)) { + versions.push({ line: ln, name: 'Unreleased', semver: null, isUnreleased: true }); + return; + } + + // Released heading: needs both a valid SemVer AND a valid date. + const semver = parseSemver(inner); + if (sev.version !== 'off' && !semver) { + problems.push({ + line: ln, + rule: 'invalid-version', + severity: sev.version, + message: `"[${inner}]" is not valid SemVer (expected MAJOR.MINOR.PATCH)`, + }); + } + if (dateText === null) { + if (sev.format !== 'off') { + problems.push({ + line: ln, + rule: 'version-format', + severity: sev.format, + message: `release heading is missing a date β€” use "## [${inner}] - YYYY-MM-DD"`, + }); + } + } else if (sev.date !== 'off' && !isValidDate(dateText)) { + problems.push({ + line: ln, + rule: 'invalid-date', + severity: sev.date, + message: `date "${dateText}" is not a valid ISO date (YYYY-MM-DD)`, + }); + } + versions.push({ line: ln, name: inner, semver, isUnreleased: false }); + return; + } + + // An H2 that isn't the strict form. Only flag it if it *looks like* a release + // heading (mentions a version-ish token or a date) β€” a plain "## Notes" H2 in a + // changelog isn't our business. + const h2body = raw.replace(/^##\s+/, '').trim(); + const looksLikeRelease = /\bv?\d+\.\d+/.test(h2body) || /\d{4}-\d{2}-\d{2}/.test(h2body) || /^unreleased$/i.test(h2body); + if (sev.format !== 'off' && looksLikeRelease) { + problems.push({ + line: ln, + rule: 'version-format', + severity: sev.format, + message: `version heading "## ${h2body}" doesn't match "## [x.y.z] - YYYY-MM-DD" (or "## [Unreleased]")`, + }); + } +} + +// Return a boolean[] marking which lines sit inside a fenced code block. +function fenceMask(lines) { + const mask = new Array(lines.length).fill(false); + let fenceChar = null; + let fenceLen = 0; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^ {0,3}(`{3,}|~{3,})/); + if (fenceChar === null) { + if (m) { fenceChar = m[1][0]; fenceLen = m[1].length; mask[i] = true; } + } else { + mask[i] = true; + const cm = lines[i].match(/^ {0,3}(`{3,}|~{3,})\s*$/); + if (cm && cm[1][0] === fenceChar && cm[1].length >= fenceLen) { fenceChar = null; fenceLen = 0; } + } + } + return mask; +} + +// ---- CLI ------------------------------------------------------------------- + +function parseArgs(argv) { + const files = []; + const opts = { json: false, quiet: false, sev: Object.assign({}, DEFAULTS) }; + for (const a of argv) { + switch (a) { + case '--help': + case '-h': opts.help = true; break; + case '--json': opts.json = true; break; + case '--quiet': opts.quiet = true; break; + case '--no-format': opts.sev.format = 'off'; break; + case '--no-version': opts.sev.version = 'off'; break; + case '--no-date': opts.sev.date = 'off'; break; + case '--no-changetype': opts.sev.changetype = 'off'; break; + case '--no-duplicate': opts.sev.duplicate = 'off'; break; + case '--no-unreleased': opts.sev.unreleased = 'off'; break; + case '--no-order': opts.sev.order = 'off'; break; + case '--no-empty': opts.sev.empty = 'off'; break; + case '--strict': // promote all warnings to errors + opts.sev.unreleased = 'error'; opts.sev.order = 'error'; opts.sev.empty = 'error'; break; + default: + if (a.startsWith('-')) { opts.badFlag = a; } + else files.push(a); + } + } + opts.files = files; + return opts; +} + +const HELP = `changelog-lint.js β€” lint a Keep-a-Changelog CHANGELOG.md (zero dependencies) + +Usage: + node changelog-lint.js [more.md …] [options] + +Checks: + version-format H2 not "## [x.y.z] - YYYY-MM-DD" / "## [Unreleased]" (error) + invalid-version bracketed version isn't valid SemVer (error) + invalid-date date isn't a real ISO YYYY-MM-DD (error) + bad-change-type H3 not Added/Changed/Deprecated/Removed/Fixed/Security (error) + duplicate-version same version declared twice (error) + no-unreleased no "## [Unreleased]" section (warn) + version-order releases not newest-first / Unreleased not on top (warn) + empty-section a heading with no entries beneath it (warn) + +Options: + --json machine-readable report + --quiet print only problems (no per-file "ok" lines) + --strict treat every warning as an error (affects exit code) + --no-format --no-version --no-date --no-changetype + --no-duplicate --no-unreleased --no-order --no-empty turn a rule off + -h, --help this help + +Exit codes: 0 = no errors, 1 = errors found, 2 = usage/read error`; + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { console.log(HELP); process.exit(0); } + if (opts.badFlag) { console.error(`Unknown option: ${opts.badFlag}\n`); console.error(HELP); process.exit(2); } + if (opts.files.length === 0) { console.error('No files given.\n'); console.error(HELP); process.exit(2); } + + const report = []; + let errorCount = 0; + let warnCount = 0; + let readError = false; + + for (const file of opts.files) { + let text; + try { + text = fs.readFileSync(file, 'utf8'); + } catch (e) { + console.error(`Cannot read ${file}: ${e.message}`); + readError = true; + continue; + } + const problems = lintText(text, opts.sev); + for (const p of problems) { + if (p.severity === 'error') errorCount++; + else if (p.severity === 'warn') warnCount++; + } + report.push({ file, problems }); + } + + if (opts.json) { + console.log(JSON.stringify({ report, errorCount, warnCount }, null, 2)); + } else { + for (const { file, problems } of report) { + if (problems.length === 0) { + if (!opts.quiet) console.log(`βœ“ ${file}`); + continue; + } + console.log(`βœ— ${file}`); + for (const p of problems) { + const tag = p.severity === 'error' ? 'error' : 'warn '; + console.log(` ${tag} ${file}:${p.line} [${p.rule}] ${p.message}`); + } + } + if (!opts.quiet) { + console.log(`\n${errorCount} error${errorCount === 1 ? '' : 's'}, ${warnCount} warning${warnCount === 1 ? '' : 's'}.`); + } + } + + if (readError) process.exit(2); + process.exit(errorCount > 0 ? 1 : 0); +} + +if (require.main === module) main(); + +module.exports = { lintText, parseSemver, compareSemver, isValidDate }; diff --git a/code-fence-lint.js b/code-fence-lint.js new file mode 100644 index 0000000..07f8cb5 --- /dev/null +++ b/code-fence-lint.js @@ -0,0 +1,277 @@ +#!/usr/bin/env node +/** + * code-fence-lint.js + * + * Lint fenced code blocks in Markdown files β€” the structural mistakes that wreck + * how a README renders on GitHub. The classic one: an unclosed fence that turns the + * entire rest of your document into a giant grey code block. This is the formatting + * companion to the rest of the lint family (link-check.js, heading-lint.js, + * frontmatter-lint.js): it tracks fenced blocks the same way they do, then reports + * the ones that are broken. + * + * Checks (each rule can be toggled off): + * + * 1. unclosed-fence A code fence that opens but never closes (an odd number of + * matching fences). On GitHub this swallows everything after it + * into one code block. ALWAYS an error. + * 2. missing-language An opening ``` / ~~~ with no language identifier (```\n vs + * ```js\n). No syntax highlighting, and many linters/renderers + * prefer an explicit info string. WARNING by default β€” promote + * to an error with --strict-language, silence with + * --no-require-language. + * 3. mismatched-fence A closing fence SHORTER than the one that opened the block, + * where detectable. A block opened with ```` (4 backticks) is + * only closed by 4+ backticks; a bare ``` inside it is content, + * not a close. We flag the case where the block never closes + * because the only candidate closers were too short. (Reported + * as part of unclosed-fence with an explanatory message.) + * + * How fences are matched (CommonMark-aligned, pragmatically): + * - A fence is a line whose first non-space run is 3+ backticks OR 3+ tildes. + * - The opening fence's character (` or ~) and length set the block. A closing + * fence must use the SAME character and be AT LEAST as long, and carry no info + * string. This is what lets a ```` block legitimately contain ``` lines, and a + * ``` block legitimately contain ~~~ lines β€” they don't close each other. + * - Up to 3 leading spaces of indentation are allowed (CommonMark). This is what + * makes fences inside list items work; deeply-indented ``` inside an indented + * code context is a known limitation (see below). + * - Inline code (single/double backticks within a line, like `x`) is never a + * fence β€” only a line that STARTS (after ≀3 spaces) with the fence run counts. + * + * Known limitations (honest, not over-claimed): + * - No full CommonMark block parser. A ``` that is itself inside an *indented* + * (4-space) code block, or inside a blockquote with unusual nesting, may be + * read as a real fence. The common cases β€” top-level fences and fences inside + * list items β€” are handled correctly. + * - "Missing language" only inspects the OPENING fence's info string; it can't + * know whether a blank info string was intentional (e.g. plain text output). + * That's exactly why it's a warning, not an error, by default. + * + * Zero dependencies. Network-free. Works on any Node >= 14. + * + * Usage: + * node code-fence-lint.js README.md # lint one file + * node code-fence-lint.js README.md docs/*.md # lint several + * node code-fence-lint.js README.md --json # machine-readable report + * node code-fence-lint.js README.md --strict-language # missing-language is an error + * node code-fence-lint.js README.md --no-require-language # don't report missing-language + * node code-fence-lint.js --help + * + * Exit codes (CI / pre-commit friendly): + * 0 no problems (warnings alone do not fail the build) + * 1 one or more errors found (unclosed/mismatched fences, or missing-language + * under --strict-language) + * 2 usage error (no files, missing file, bad flag) + */ + +'use strict'; + +const fs = require('fs'); + +function printHelp() { + console.log(`code-fence-lint.js β€” lint fenced code blocks in Markdown + +Usage: + node code-fence-lint.js [more.md ...] [options] + +Options: + --json Emit a JSON report instead of human-readable text. + --quiet Only print files with problems (nothing on a clean file). + --strict-language Treat a missing language tag as an ERROR (fails the build), + not just a warning. + --no-require-language Don't report fences missing a language tag at all. + --help Show this help. + +Checks: unclosed-fence (error), missing-language (warning by default), + mismatched-fence (error, reported via unclosed-fence). + +Exit codes: + 0 clean (warnings alone do not fail) + 1 error(s) found + 2 usage error + +Examples: + node code-fence-lint.js README.md + node code-fence-lint.js README.md docs/*.md --json + node code-fence-lint.js README.md --strict-language`); +} + +/** + * Match a fence line. Returns null if the line is not a fence, otherwise + * { char, len, info } where: + * char '`' or '~' + * len number of fence characters in the opening run + * info the info string after the run, trimmed (language tag etc.) + * + * CommonMark: up to 3 leading spaces; the fence is a run of >= 3 identical + * backticks or tildes. A backtick fence's info string may not itself contain a + * backtick (that would be inline code, not a fence). + */ +function matchFence(line) { + const m = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/); + if (!m) return null; + const run = m[2]; + const char = run[0]; + const info = m[3].trim(); + // A backtick info string containing a backtick isn't a valid opening fence. + if (char === '`' && info.indexOf('`') !== -1) return null; + return { char, len: run.length, info }; +} + +/** + * Walk a Markdown document and report fenced-code-block problems. + * + * Returns an array of problem objects: { rule, severity, line, message }. + * rule 'unclosed-fence' | 'missing-language' + * severity 'error' | 'warning' + * + * `opts.requireLanguage` (default true) toggles missing-language reporting. + * `opts.strictLanguage` (default false) makes missing-language an error. + */ +function lintFences(markdown, opts) { + const o = Object.assign({ requireLanguage: true, strictLanguage: false }, opts); + const lines = markdown.split('\n'); + const problems = []; + + // Active fence state: null when outside a code block, otherwise the open fence's + // { char, len, info, line }. + let open = null; + + for (let i = 0; i < lines.length; i++) { + const fence = matchFence(lines[i]); + if (!fence) continue; + + if (open === null) { + // This fence OPENS a block. + open = { char: fence.char, len: fence.len, info: fence.info, line: i + 1 }; + + if (o.requireLanguage && fence.info === '') { + const severity = o.strictLanguage ? 'error' : 'warning'; + problems.push({ + rule: 'missing-language', + severity, + line: i + 1, + message: `Code fence opened with no language tag (${fence.char.repeat(fence.len)} with no info string). Add a language (e.g. \`${fence.char.repeat(fence.len)}js\`) for syntax highlighting.`, + }); + } + continue; + } + + // We're inside a block. A line is a CLOSING fence only if it uses the SAME + // character, is AT LEAST as long as the opener, and carries no info string. + if (fence.char === open.char && fence.len >= open.len && fence.info === '') { + open = null; // block closed cleanly + continue; + } + // Otherwise this fence line is CONTENT of the open block (a shorter same-char + // fence, a different-char fence, or a closer with a stray info string). Ignore. + } + + // End of file with a block still open: unclosed. + if (open !== null) { + const opener = `${open.char.repeat(open.len)}${open.info ? ' ' + open.info : ''}`; + let message = `Code fence opened at line ${open.line} (${opener.trim()}) is never closed before end of file. On GitHub this turns the rest of the document into one code block.`; + if (open.len > 3) { + message += ` Note: this block opened with ${open.len} ${open.char === '`' ? 'backticks' : 'tildes'}, so only a fence of ${open.len}+ ${open.char === '`' ? 'backticks' : 'tildes'} closes it β€” a shorter ${open.char.repeat(3)} inside it counts as content, not a close.`; + } + problems.push({ + rule: 'unclosed-fence', + severity: 'error', + line: open.line, + message, + }); + } + + return problems; +} + +function parseArgs(argv) { + const opts = { + json: false, + quiet: false, + requireLanguage: true, + strictLanguage: false, + help: false, + }; + const files = []; + for (const arg of argv) { + switch (arg) { + case '--json': opts.json = true; break; + case '--quiet': opts.quiet = true; break; + case '--strict-language': opts.strictLanguage = true; opts.requireLanguage = true; break; + case '--no-require-language': opts.requireLanguage = false; break; + case '--help': case '-h': opts.help = true; break; + default: + if (arg.startsWith('-')) { opts.badFlag = arg; return { opts, files }; } + files.push(arg); + } + } + return { opts, files }; +} + +function main() { + const { opts, files } = parseArgs(process.argv.slice(2)); + + if (opts.help) { printHelp(); process.exit(0); } + if (opts.badFlag) { console.error(`Unknown flag: ${opts.badFlag}\n`); printHelp(); process.exit(2); } + if (files.length === 0) { console.error('Error: no Markdown files given.\n'); printHelp(); process.exit(2); } + + const report = []; + let totalErrors = 0; + let totalWarnings = 0; + let usageError = false; + + for (const file of files) { + let markdown; + try { + markdown = fs.readFileSync(file, 'utf8'); + } catch (e) { + console.error(`Error: cannot read ${file} (${e.code || e.message})`); + usageError = true; + continue; + } + const problems = lintFences(markdown, opts); + const errors = problems.filter((p) => p.severity === 'error').length; + const warnings = problems.filter((p) => p.severity === 'warning').length; + totalErrors += errors; + totalWarnings += warnings; + report.push({ file, problems, errors, warnings }); + } + + if (opts.json) { + console.log(JSON.stringify({ + ok: totalErrors === 0 && !usageError, + usageError, + errors: totalErrors, + warnings: totalWarnings, + files: report, + }, null, 2)); + } else { + for (const { file, problems, errors, warnings } of report) { + if (problems.length === 0) { + if (!opts.quiet) console.log(`βœ“ ${file} β€” fenced code blocks OK`); + continue; + } + const counts = []; + if (errors) counts.push(`${errors} error(s)`); + if (warnings) counts.push(`${warnings} warning(s)`); + console.log(`βœ— ${file} β€” ${counts.join(', ')}:`); + for (const p of problems) { + const tag = p.severity === 'error' ? 'error' : 'warn'; + console.log(` [${tag}: ${p.rule}] line ${p.line}: ${p.message}`); + } + } + if (!opts.quiet && totalErrors === 0 && totalWarnings === 0) { + console.log('\nAll fenced code blocks check out.'); + } else if (totalErrors === 0 && totalWarnings > 0) { + console.log(`\n${totalWarnings} warning(s), no errors.`); + } + } + + if (usageError) process.exit(2); + process.exit(totalErrors === 0 ? 0 : 1); +} + +if (require.main === module) main(); + +module.exports = { matchFence, lintFences }; diff --git a/commit-lint.js b/commit-lint.js new file mode 100755 index 0000000..e59e578 --- /dev/null +++ b/commit-lint.js @@ -0,0 +1,400 @@ +#!/usr/bin/env node +/** + * commit-lint.js + * + * Lint git commit messages against the Conventional Commits convention + * (https://www.conventionalcommits.org/) β€” the same shape tools like semantic-release + * and changelog generators rely on. This is the commit-message companion to the + * zero-dep lint family in this repo (link-check.js, heading-lint.js, list-lint.js, …): + * same CLI shape, same CI-friendly exit codes, same "flag, don't rewrite" stance. + * + * A conventional commit header looks like: + * + * type(optional-scope)!: short description + * ^^^^ ^^^^^^^^^^^^^^^ ^ ^^^^^^^^^^^^^^^^^ + * | | | └─ the summary (required, non-empty) + * | | └──── optional "!" = breaking change + * | └──────────────────── optional scope in parens + * └───────────────────────── type: feat, fix, docs, … (required, lowercase) + * + * Checks (each rule can be toggled off): + * + * 1. header-format The first line must parse as `type(scope)?!?: description`. + * A message with no colon, an empty description, or a malformed + * header is flagged. This is the check that makes a message + * machine-readable at all. ERROR by default. + * 2. type The type must be one of the allowed set (feat, fix, docs, + * style, refactor, perf, test, build, ci, chore, revert) and + * lowercase. `Feat`, `feature`, or `fixed` are flagged β€” a typo'd + * type silently drops the commit out of the changelog. Override + * the set with --types. ERROR by default. + * 3. blank-line If the message has a body, exactly one blank line must separate + * the header from it. `git log` and most parsers treat line 2 as + * the required separator; a body glued to the header is misread as + * one long subject. ERROR by default. + * 4. subject-length The header line should be at most --max-subject chars (default + * 72). Long subjects truncate in `git log --oneline`, GitHub, and + * email. WARNING by default. + * 5. subject-style The description shouldn't end with a period and shouldn't start + * with a capital letter (the commitlint default "imperative, + * lower-case" house style). WARNING by default; silence with + * --no-style. + * 6. body-length Body lines should wrap at --max-body chars (default 100). + * A URL or a long code token on its own line is exempt. WARNING + * by default. + * + * Breaking changes are recognized two ways (both accepted, never flagged): a `!` + * before the colon in the header, or a `BREAKING CHANGE:` / `BREAKING-CHANGE:` footer. + * + * Honest limitations (not over-claimed): + * - Not a full commitlint. It does not validate a scope enum, footer/issue-reference + * syntax, or the semver implication of a type β€” it checks the header grammar, + * type, structure, and length hygiene, which is where most bad commits go wrong. + * - The comment lines git adds to COMMIT_EDITMSG (lines starting with `#`) and an + * optional trailing `# ------ >8 ------` scissors section are stripped before + * linting, so it's safe to point straight at `.git/COMMIT_EDITMSG` from a + * commit-msg hook. + * - A `Merge ...` or `Revert "..."` auto-message from git is recognized and skipped + * by default (they aren't conventional and aren't yours to fix); --lint-merges + * forces them to be checked. + * + * Zero dependencies. Network-free. Works on any Node >= 14. + * + * Usage: + * node commit-lint.js .git/COMMIT_EDITMSG # lint one message file (commit-msg hook) + * node commit-lint.js msg1.txt msg2.txt # lint several message files + * node commit-lint.js --stdin # lint a message piped in + * node commit-lint.js --range origin/main..HEAD # lint every commit in a git range + * node commit-lint.js --range HEAD~5..HEAD --json + * node commit-lint.js --types feat,fix,docs,chore --max-subject 50 msg.txt + * node commit-lint.js --help + * + * As a commit-msg hook (.git/hooks/commit-msg): + * #!/bin/sh + * exec node path/to/commit-lint.js "$1" + * + * Exit codes (CI / hook friendly): + * 0 no errors (warnings may be present) + * 1 at least one error-level problem found + * 2 usage / file-read / git error + * + * Also require()-able: + * const { lintMessage } = require('./commit-lint.js'); + * const problems = lintMessage('feat: add thing', { style: 'warn' }); + */ + +'use strict'; + +const fs = require('fs'); +const { execFileSync } = require('child_process'); + +// ---- rule severities ------------------------------------------------------- + +const DEFAULTS = { + header: 'error', // header-format + type: 'error', // unknown/miscased type + blank: 'error', // blank-line separator before body + subject: 'warn', // subject-length + style: 'warn', // subject-style (trailing period / leading capital) + body: 'warn', // body-length +}; + +const DEFAULT_TYPES = [ + 'feat', 'fix', 'docs', 'style', 'refactor', + 'perf', 'test', 'build', 'ci', 'chore', 'revert', +]; + +const DEFAULT_MAX_SUBJECT = 72; +const DEFAULT_MAX_BODY = 100; + +// Header grammar: type, optional (scope), optional !, ": ", description. +// Capture groups: 1=type, 2=scope (with parens), 3=bang, 4=description +const HEADER_RE = /^([a-zA-Z]+)(\([^)]*\))?(!)?: (.*)$/; +// A message that at least has a "word:" prefix, so we can tell "no colon at all" +// from "colon but malformed". +const HAS_COLON_RE = /^[^\s:]+.*: /; + +// ---- helpers --------------------------------------------------------------- + +/** + * Strip the parts git adds that aren't part of the authored message: + * - comment lines beginning with `#` + * - everything after a `# ------------------------ >8 ------------------------` + * scissors line (verbose-commit diff section) + * Returns the cleaned message with trailing blank lines trimmed. + */ +function stripGitCruft(raw) { + const lines = raw.replace(/\r\n?/g, '\n').split('\n'); + const out = []; + for (const line of lines) { + if (/^#\s*-+\s*>8\s*-+/.test(line)) break; // scissors: drop the rest + if (/^#/.test(line)) continue; // git comment line + out.push(line); + } + // trim trailing blank lines + while (out.length && out[out.length - 1].trim() === '') out.pop(); + return out.join('\n'); +} + +function isMergeOrRevertAuto(header) { + return /^Merge /.test(header) || /^Revert "/.test(header); +} + +// URL or a single long unbroken token β€” not something you can wrap, so exempt. +function isUnwrappable(line) { + const t = line.trim(); + if (/https?:\/\/\S+/.test(t)) return true; + return !/\s/.test(t) && t.length > 0; // one long token, no spaces +} + +// ---- core linter ----------------------------------------------------------- + +/** + * Lint a single commit message. Returns an array of problem objects: + * { line, rule, severity: 'error'|'warn', message } + * + * opts: + * header/type/blank/subject/style/body : 'error' | 'warn' | 'off' (per-rule) + * types : string[] allowed types (default DEFAULT_TYPES) + * maxSubject : number (default 72) + * maxBody : number (default 100) + * lintMerges : boolean also check Merge/Revert auto-messages (default false) + */ +function lintMessage(message, opts = {}) { + const cfg = { ...DEFAULTS, ...opts }; + const types = opts.types || DEFAULT_TYPES; + const maxSubject = opts.maxSubject || DEFAULT_MAX_SUBJECT; + const maxBody = opts.maxBody || DEFAULT_MAX_BODY; + const problems = []; + const push = (line, rule, sev, msg) => { + if (sev === 'off') return; + problems.push({ line, rule, severity: sev, message: msg }); + }; + + const clean = stripGitCruft(message); + if (clean.trim() === '') { + push(1, 'header-format', cfg.header, 'empty commit message'); + return problems; + } + + const lines = clean.split('\n'); + const header = lines[0]; + + if (!opts.lintMerges && isMergeOrRevertAuto(header)) { + return problems; // git-generated merge/revert message, not the author's to fix + } + + // --- header grammar + type --- + const m = HEADER_RE.exec(header); + if (!m) { + if (!HAS_COLON_RE.test(header)) { + push(1, 'header-format', cfg.header, + `header must be "type(scope)?: description" β€” no "type: " prefix found in "${header}"`); + } else { + push(1, 'header-format', cfg.header, + `malformed header "${header}" β€” expected "type(scope)?!?: description" (needs a space after the colon and a non-empty description)`); + } + } else { + const type = m[1]; + const description = m[4]; + if (type !== type.toLowerCase()) { + push(1, 'type', cfg.type, `type "${type}" must be lowercase ("${type.toLowerCase()}")`); + } else if (!types.includes(type)) { + push(1, 'type', cfg.type, + `unknown type "${type}" β€” allowed: ${types.join(', ')}`); + } + if (description.trim() === '') { + push(1, 'header-format', cfg.header, 'description after the colon is empty'); + } else { + // subject-style: trailing period + leading capital + if (/\.$/.test(description)) { + push(1, 'subject-style', cfg.style, 'description should not end with a period'); + } + if (/^[A-Z][a-z]/.test(description)) { + push(1, 'subject-style', cfg.style, + `description should start lowercase ("${description[0].toLowerCase()}${description.slice(1)}")`); + } + } + } + + // --- subject length (whole header line) --- + if (header.length > maxSubject) { + push(1, 'subject-length', cfg.subject, + `header is ${header.length} chars (max ${maxSubject})`); + } + + // --- blank line before body --- + if (lines.length > 1) { + if (lines[1].trim() !== '') { + push(2, 'blank-line', cfg.blank, + 'body must be separated from the header by a blank line (line 2 is not blank)'); + } + // --- body line length --- + for (let i = 2; i < lines.length; i++) { + const line = lines[i]; + if (line.length > maxBody && !isUnwrappable(line)) { + push(i + 1, 'body-length', cfg.body, + `body line is ${line.length} chars (max ${maxBody})`); + } + } + } + + return problems; +} + +// ---- CLI ------------------------------------------------------------------- + +function parseArgs(argv) { + const opts = { + files: [], json: false, quiet: false, help: false, badFlag: null, + stdin: false, range: null, lintMerges: false, + types: null, maxSubject: DEFAULT_MAX_SUBJECT, maxBody: DEFAULT_MAX_BODY, + ruleOverrides: {}, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + switch (a) { + case '--json': opts.json = true; break; + case '--quiet': opts.quiet = true; break; + case '--stdin': opts.stdin = true; break; + case '--lint-merges': opts.lintMerges = true; break; + case '--help': case '-h': opts.help = true; break; + case '--range': opts.range = argv[++i]; break; + case '--types': opts.types = (argv[++i] || '').split(',').map(s => s.trim()).filter(Boolean); break; + case '--max-subject': opts.maxSubject = parseInt(argv[++i], 10) || DEFAULT_MAX_SUBJECT; break; + case '--max-body': opts.maxBody = parseInt(argv[++i], 10) || DEFAULT_MAX_BODY; break; + case '--no-subject': opts.ruleOverrides.subject = 'off'; break; + case '--no-style': opts.ruleOverrides.style = 'off'; break; + case '--no-body': opts.ruleOverrides.body = 'off'; break; + default: + if (a.startsWith('-')) { opts.badFlag = a; } + else opts.files.push(a); + } + } + return opts; +} + +const HELP = `commit-lint.js β€” lint git commit messages against Conventional Commits (zero-dep) + +Usage: + node commit-lint.js lint one or more commit-message files + node commit-lint.js --stdin lint a message piped on stdin + node commit-lint.js --range A..B lint every commit in a git range + +Options: + --range lint commits from \`git log A..B\` (subject + body each) + --stdin read a single message from stdin + --types comma-separated allowed types (default: ${DEFAULT_TYPES.join(',')}) + --max-subject max header length before warning (default ${DEFAULT_MAX_SUBJECT}) + --max-body max body line length before warning (default ${DEFAULT_MAX_BODY}) + --no-subject turn off the subject-length check + --no-style turn off the trailing-period / leading-capital check + --no-body turn off the body line-length check + --lint-merges also check git-generated Merge/Revert messages (skipped by default) + --json machine-readable report + --quiet print only problems (no "ok" lines) + --help this text + +Exit codes: 0 = no errors, 1 = error-level problem(s), 2 = usage/read/git error + +As a commit-msg hook (.git/hooks/commit-msg): + #!/bin/sh + exec node path/to/commit-lint.js "$1"`; + +// Read commit messages (subject + body) for a git range. Uses a record separator +// so multi-line bodies survive. Returns [{ label, message }]. +function readRange(range) { + const SEP = '\x1e'; // ASCII record separator, won't appear in a commit message + const out = execFileSync( + 'git', ['log', '--format=%H%n%B' + SEP, range], + { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 } + ); + return out.split(SEP) + .map(chunk => chunk.replace(/^\n/, '').replace(/\n$/, '')) + .filter(c => c.trim() !== '') + .map(chunk => { + const nl = chunk.indexOf('\n'); + const hash = (nl === -1 ? chunk : chunk.slice(0, nl)).trim(); + const message = nl === -1 ? '' : chunk.slice(nl + 1); + return { label: hash.slice(0, 8), message }; + }); +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { console.log(HELP); process.exit(0); } + if (opts.badFlag) { console.error(`Unknown option: ${opts.badFlag}\n`); console.error(HELP); process.exit(2); } + + const lintOpts = { + types: opts.types, maxSubject: opts.maxSubject, maxBody: opts.maxBody, + lintMerges: opts.lintMerges, ...opts.ruleOverrides, + }; + + // Gather the messages to lint: --stdin | --range | files + let items = []; // { label, message } + let inputError = false; + + if (opts.stdin) { + const msg = fs.readFileSync(0, 'utf8'); + items.push({ label: '', message: msg }); + } else if (opts.range) { + try { + items = readRange(opts.range); + if (items.length === 0) { console.error(`No commits in range ${opts.range}`); process.exit(2); } + } catch (e) { + console.error(`git error for range "${opts.range}": ${e.message.split('\n')[0]}`); + process.exit(2); + } + } else if (opts.files.length > 0) { + for (const f of opts.files) { + try { + items.push({ label: f, message: fs.readFileSync(f, 'utf8') }); + } catch (e) { + console.error(`Cannot read ${f}: ${e.message}`); + inputError = true; + } + } + } else { + console.error('No input: give a message file, --stdin, or --range A..B\n'); + console.error(HELP); + process.exit(2); + } + + // Lint each and report. + const report = []; + let errorCount = 0; + let warnCount = 0; + for (const { label, message } of items) { + const problems = lintMessage(message, lintOpts); + errorCount += problems.filter(p => p.severity === 'error').length; + warnCount += problems.filter(p => p.severity === 'warn').length; + report.push({ commit: label, problems }); + } + + if (opts.json) { + console.log(JSON.stringify({ errorCount, warnCount, results: report }, null, 2)); + process.exit(inputError ? 2 : errorCount > 0 ? 1 : 0); + } + + for (const { commit, problems } of report) { + if (problems.length === 0) { + if (!opts.quiet) console.log(`βœ… ${commit}: ok`); + continue; + } + console.log(`\n${commit}:`); + for (const p of problems) { + const icon = p.severity === 'error' ? 'β›”' : '⚠️'; + console.log(` ${icon} line ${p.line} [${p.rule}] ${p.message}`); + } + } + + if (!opts.quiet || errorCount > 0 || warnCount > 0) { + console.log(`\n${errorCount} error(s), ${warnCount} warning(s) across ${items.length} commit(s).`); + } + if (inputError) process.exit(2); + process.exit(errorCount > 0 ? 1 : 0); +} + +if (require.main === module) main(); + +module.exports = { lintMessage, stripGitCruft }; diff --git a/examples/check-docs.sh b/examples/check-docs.sh new file mode 100755 index 0000000..67d705c --- /dev/null +++ b/examples/check-docs.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# +# check-docs.sh β€” run the whole zero-dep Markdown linting suite in one command. +# +# Wires together the linters in this repo so CI (or a pre-commit hook) can +# call ONE thing instead of many: +# +# link-check.js broken local links / dead anchors (default: ON) +# code-fence-lint.js unclosed / mismatched code fences (default: ON) +# image-alt-lint.js images missing or weak alt text (a11y) (default: ON) +# link-text-lint.js empty / non-descriptive link text (a11y) (default: ON) +# frontmatter-lint.js bad YAML frontmatter (files that have it) (default: ON) +# list-lint.js mixed bullets / broken ordered numbering (default: ON) +# whitespace-lint.js trailing spaces / tabs / CRLF / EOF newline (default: ON) +# heading-lint.js heading structure / duplicate anchors (default: OFF β€” opt-in) +# table-fmt.js GFM tables not aligned (default: OFF β€” opt-in) +# markdown-toc.js block out of date (opt-in via TOC_FILES) +# changelog-lint.js CHANGELOG.md not Keep-a-Changelog shape (auto: ON when a +# CHANGELOG.md exists) +# +# Why some are OFF by default: heading-lint flags duplicate heading *slugs*, and +# table-fmt enforces one specific alignment β€” both are legitimate, but a repo can +# reasonably repeat sub-headings across sections or hand-format its tables. Those +# checks fail on style choices, not bugs, so they're opt-in. The seven ON-by-default +# checks only fail on genuine defects (a dead link, an unclosed fence, a missing +# alt attribute, an empty/whitespace link text, malformed frontmatter, a list that +# mixes bullet markers or botches its numbering, trailing whitespace / hard tabs / +# CRLF / a missing final newline), so they're safe to gate every build. (list-lint's +# odd-indent, whitespace-lint's multiple-blank-lines, and link-text-lint's +# non-descriptive / raw-URL link-text rules are non-failing warnings, so they never +# break a build on their own.) +# +# Every check is individually toggleable with an env var (1 = run, 0 = skip): +# +# CHECK_LINKS CHECK_FENCES CHECK_ALT CHECK_LINK_TEXT CHECK_FRONTMATTER CHECK_LISTS +# CHECK_WHITESPACE CHECK_HEADINGS CHECK_TABLES +# +# Exit codes: 0 = everything passed, 1 = a check failed. Safe for CI and pre-commit. +# +# Usage: +# examples/check-docs.sh # the four safe checks over the repo +# CHECK_HEADINGS=1 CHECK_TABLES=1 examples/check-docs.sh # strict: run everything +# CHECK_ALT=0 examples/check-docs.sh # skip the alt-text check +# TOC_FILES="README.md" examples/check-docs.sh # also verify README's TOC +# CHANGELOG_FILE=docs/CHANGELOG.md examples/check-docs.sh # lint a changelog elsewhere +# CHANGELOG_FILE= examples/check-docs.sh # skip the changelog check +# SCRIPTS_DIR=. examples/check-docs.sh # if the scripts live somewhere custom +# +# Requires: node, git. Zero npm dependencies. + +set -euo pipefail + +# Where the linter scripts live. Default: repo root (one level up from examples/). +SCRIPTS_DIR="${SCRIPTS_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" + +# Per-check toggles. Defaults: the four defect-only checks ON, the two style checks OFF. +CHECK_LINKS="${CHECK_LINKS:-1}" +CHECK_FENCES="${CHECK_FENCES:-1}" +CHECK_ALT="${CHECK_ALT:-1}" +CHECK_LINK_TEXT="${CHECK_LINK_TEXT:-1}" +CHECK_FRONTMATTER="${CHECK_FRONTMATTER:-1}" +CHECK_LISTS="${CHECK_LISTS:-1}" +CHECK_WHITESPACE="${CHECK_WHITESPACE:-1}" +CHECK_HEADINGS="${CHECK_HEADINGS:-0}" +CHECK_TABLES="${CHECK_TABLES:-0}" + +# Files with markers whose TOC should be verified. Space-separated. +# Leave empty to skip the TOC check entirely. +TOC_FILES="${TOC_FILES:-}" + +# Changelog to lint against Keep-a-Changelog. Auto-detects a repo-root CHANGELOG.md +# (case-insensitive) when unset; set to a path to point elsewhere, or empty to skip. +if [ -z "${CHANGELOG_FILE+set}" ]; then + CHANGELOG_FILE="$(git ls-files | grep -iE '(^|/)CHANGELOG\.(md|markdown)$' | head -n1 || true)" +fi + +fail=0 + +# Collect every tracked Markdown file (skip vendored trees). +mapfile -t md_files < <(git ls-files '*.md' '*.markdown' | grep -vE '(^|/)(node_modules|vendor)/' || true) + +if [ "${#md_files[@]}" -eq 0 ]; then + echo "No Markdown files tracked by git β€” nothing to check." + exit 0 +fi + +echo "Linting ${#md_files[@]} Markdown file(s)…" + +# run_check