-
Notifications
You must be signed in to change notification settings - Fork 1
feat(hygiene/check-md032): mechanize MD032 blanks-around-lists pre-check (B-0456) #3075
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
AceHack
merged 47 commits into
main
from
feat/b0456-md032-blanks-around-lists-helper-2026-05-14
May 14, 2026
Merged
Changes from 1 commit
Commits
Show all changes
47 commits
Select commit
Hold shift + click to select a range
36b8613
feat(hygiene/check-md032): mechanize MD032 blanks-around-lists pre-ch…
AceHack 3a8aed0
shard(tick): 0252Z — B-0456 MD032 helper shipped + self-validated
AceHack 3158f6a
fix(hygiene/check-md032): Codex+Copilot round-1 review (4 findings)
AceHack 70da2d1
shard(tick): 0317Z — PR #3075 round-1 review: 4 substantive findings
AceHack 6f20e2d
shard(tick): 0331Z — PR #3073 merged; substrate-hygiene cascade DURAB…
AceHack 6c436bd
fix(check-md032): address 6 P1 Copilot findings from PR #3075 review
AceHack c15613c
fix(check-md032): track matching fence delimiters (Codex P2 #3075)
AceHack cc74391
docs(tick): 0101Z — PR #3075 round-2 resolved (8 threads); honor-thos…
AceHack 9bfdde9
fix(check-md032): include renames in --staged scan (Codex P2 #3075)
AceHack 0e0563f
docs(tick): 0104Z — PR #3074 MERGED; PR #3075 round-3 sibling-absorbed
AceHack 579dce9
fix(check-md032): round-4 review fixes (info-string closers, role-ref…
AceHack 983d00c
docs(tick): 0111Z — PR #3075 round-4 resolved (5 threads)
AceHack 97c93f1
docs(tick): 0112Z — round-5 duplicate (Codex info-string closer) reso…
AceHack f1b1a3e
docs(tick): 0113Z — real-dependency-wait on PR 3075 CI (4 required in…
AceHack 3ebca13
fix(check-md032): round-6 review fixes (markdownlint ignores, paren o…
AceHack 27cb70d
Merge remote-tracking branch 'origin/main' into feat/b0456-md032-blan…
AceHack 740dcd2
docs(tick): 0126Z — PR #3075 round-6 resolved (4 threads + DIRTY merge)
AceHack 3649327
fix(check-md032): skip YAML front matter (round-7 Codex P2)
AceHack 73ba37e
docs(tick): 0130Z — PR #3075 round-7 resolved (YAML front-matter skip)
AceHack acd8ff8
fix(check-md032): round-8 review fixes (lazy continuations, 9-digit c…
AceHack ecc6840
docs(tick): 0137Z — PR #3075 round-8 resolved (4 CommonMark spec-grad…
AceHack 117fa7b
fix(check-md032): recognize list markers in blockquote context (round…
AceHack 773cb18
docs(tick): 0143Z — PR #3075 round-9 resolved (blockquote-context lis…
AceHack a86a524
fix(check-md032): round-10 review (tilde-fence info strings, fence-ke…
AceHack bcfe476
docs(tick): 0148Z — PR #3075 round-10 resolved (tilde-fence relaxatio…
AceHack 8498772
fix(check-md032): replace in-test require() with module-scope import …
AceHack dce1206
docs(tick): 0152Z — PR #3075 round-11 resolved (1 real require fix + …
AceHack 3dcc785
docs(tick): 0154Z — PR #3075 wait-ci after 11 rounds; threads clear; …
AceHack 3f3da2b
fix(tick/0143Z): remove `> ` code span (MD038 — spaces inside code sp…
AceHack deed8c2
docs(tick): 0158Z — PR #3075 MD038 markdownlint fix in 0143Z shard
AceHack 7c5f079
fix(check-md032): round-12 (heading terminates list + blockquoted fen…
AceHack 621c8b7
docs(tick): 0202Z — PR #3075 round-12 resolved (heading-terminates + …
AceHack 8b666bf
fix(tick/0202Z): remove backtick-rich regex from code span (MD038)
AceHack 263b7b4
fix(check-md032): --staged reads index blob, not working tree (round-…
AceHack b83ddfc
docs(tick): 0211Z — PR #3075 round-13 resolved (staged-blob reads)
AceHack 1c43065
Merge remote-tracking branch 'origin/main' into feat/b0456-md032-blan…
AceHack 2855393
docs(tick): 0214Z — DIRTY-gate merge resolved (0154Z add/add); 2nd of…
AceHack 11fcbd5
fix(check-md032): round-14 (permissive blockquote spacing, fence-bloc…
AceHack ad39781
docs(tick): 0222Z — PR #3075 round-14 resolved (5 P1 + 1 stale-snapshot)
AceHack 9fdb083
fix(check-md032): thematic break + blockquote terminate top-level lis…
AceHack cdea58f
docs(tick): 0227Z — PR #3075 round-15 resolved (thematic break + bloc…
AceHack 6e77bc3
fix(tick/0222Z): remove `> ` code span (MD038 — third hit this PR)
AceHack b343a09
fix(check-md032): round-16 (after-list MD032 + blockquote indent cap …
AceHack 47a7adc
docs(tick): 0239Z — PR #3075 round-16 resolved (after-list MD032 + in…
AceHack d172d57
fix(check-md032): blockquoted fence accepts content-indent (round-17 …
AceHack adbfe91
fix(check-md032): round-18 (blockquoted thematic break, bare markers,…
AceHack 5dbeafe
fix(check-md032): skip HTML comment regions (round-19 Codex P2)
AceHack File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { findMd032Violations, checkFiles } from "./check-md032-blanks-around-lists.ts"; | ||
|
|
||
| describe("findMd032Violations", () => { | ||
|
AceHack marked this conversation as resolved.
AceHack marked this conversation as resolved.
|
||
| test("clean shard (every list preceded by a blank line)", () => { | ||
| const content = `# Title | ||
|
|
||
| Some prose. | ||
|
|
||
| - item one | ||
| - item two | ||
|
|
||
| More prose. | ||
|
|
||
| 1. numbered | ||
| 2. items | ||
| `; | ||
| expect(findMd032Violations(content)).toEqual([]); | ||
| }); | ||
|
|
||
| test("single bullet violation (label then bullet, no blank)", () => { | ||
| // The 2228Z shard pattern from PR #3044. | ||
| const content = `# Title | ||
|
|
||
| Two PRs in flight, both auto-merge armed: | ||
| - **#3043** B-0444 worktree-field | ||
| - **#3044** lost-row recovery | ||
| `; | ||
| const findings = findMd032Violations(content); | ||
| expect(findings).toHaveLength(1); | ||
| expect(findings[0]?.line).toBe(4); | ||
| expect(findings[0]?.context).toContain("**#3043**"); | ||
| }); | ||
|
|
||
| test("single numbered-list violation (label then 1., no blank)", () => { | ||
| // The 0024Z shard pattern from PR #3065. | ||
| const content = `## Why | ||
|
|
||
| The rate-limit-class failures are NOT my code. They're transient | ||
| infra issues that resolve when: | ||
| 1. The installation-level rate-limit window resets | ||
| 2. CI re-runs on the branch-updated tip | ||
| `; | ||
| const findings = findMd032Violations(content); | ||
| expect(findings).toHaveLength(1); | ||
| expect(findings[0]?.line).toBe(5); | ||
| }); | ||
|
|
||
| test("multiple MD032 risks in one file are all flagged", () => { | ||
| const content = `# Title | ||
|
|
||
| First label: | ||
| - item one | ||
| - item two | ||
|
|
||
| Second label: | ||
| 1. numbered one | ||
| 2. numbered two | ||
| `; | ||
| const findings = findMd032Violations(content); | ||
| expect(findings).toHaveLength(2); | ||
| expect(findings[0]?.line).toBe(4); | ||
| expect(findings[1]?.line).toBe(8); | ||
| }); | ||
|
|
||
| test("shard with no lists is clean", () => { | ||
| const content = `# Title | ||
|
|
||
| Just some prose. | ||
|
|
||
| More prose with no lists at all. | ||
| `; | ||
| expect(findMd032Violations(content)).toEqual([]); | ||
| }); | ||
|
|
||
| test("list-without-preceding-label (list starts a section) is clean", () => { | ||
| // No label sentence before the list — should NOT flag. | ||
| const content = `# Title | ||
|
|
||
| - item one | ||
| - item two | ||
|
|
||
| More prose. | ||
| `; | ||
| expect(findMd032Violations(content)).toEqual([]); | ||
| }); | ||
|
|
||
| test("heading directly followed by list is clean (heading is list-friendly)", () => { | ||
| // Markdown allows a list immediately after a heading without a blank | ||
| // line — markdownlint by default permits this and we follow. | ||
| const content = `## Heading | ||
|
|
||
| # Top-Level | ||
| - item one | ||
| - item two | ||
| `; | ||
| expect(findMd032Violations(content)).toEqual([]); | ||
|
AceHack marked this conversation as resolved.
Outdated
|
||
| }); | ||
|
|
||
| test("nested list (list-item then deeper list) is clean", () => { | ||
| const content = `# Title | ||
|
|
||
| - top item | ||
| - nested item | ||
| - another nested | ||
| - next top item | ||
| `; | ||
| expect(findMd032Violations(content)).toEqual([]); | ||
| }); | ||
|
|
||
| test("plus-space marker is detected (the 0100Z pattern)", () => { | ||
| // PR #3065 hit this: "tick shards\n+ memory + ..." where the second | ||
| // line started with `+ ` which markdown parses as a list marker. | ||
| const content = `# Title | ||
|
|
||
| Total findings this session: | ||
| + memory file mentions | ||
| + backlog row mentions | ||
| `; | ||
| const findings = findMd032Violations(content); | ||
| expect(findings).toHaveLength(1); | ||
| expect(findings[0]?.line).toBe(4); | ||
| }); | ||
|
|
||
| test("asterisk-space marker is detected", () => { | ||
| const content = `# Title | ||
|
|
||
| Items below: | ||
| * asterisk item | ||
| * another | ||
| `; | ||
| const findings = findMd032Violations(content); | ||
| expect(findings).toHaveLength(1); | ||
| expect(findings[0]?.line).toBe(4); | ||
| }); | ||
|
|
||
| test("multi-digit numbered list is detected", () => { | ||
| const content = `# Title | ||
|
|
||
| Steps: | ||
| 12. step twelve | ||
| 13. step thirteen | ||
| `; | ||
| const findings = findMd032Violations(content); | ||
| expect(findings).toHaveLength(1); | ||
| expect(findings[0]?.line).toBe(4); | ||
| }); | ||
| }); | ||
|
|
||
| describe("checkFiles", () => { | ||
| test("unreadable files are skipped without crashing", () => { | ||
| const result = checkFiles(["/nonexistent/path/that/does/not/exist.md"], "/tmp"); | ||
| expect(result).toEqual([]); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| #!/usr/bin/env bun | ||
| // check-md032-blanks-around-lists.ts — pre-CI catch for MD032 violations. | ||
| // | ||
| // MD032 (markdownlint rule): "Lists should be surrounded by blank lines." | ||
| // Common failure mode in tick shards: a label sentence ending with `:` | ||
| // is immediately followed by a list item with no blank line between. | ||
| // | ||
| // Example (BAD — fires MD032): | ||
| // | ||
| // Each row: | ||
| // - id: updated | ||
| // - title: updated | ||
| // | ||
| // Example (GOOD): | ||
| // | ||
| // Each row: | ||
| // | ||
| // - id: updated | ||
| // - title: updated | ||
| // | ||
| // This helper catches the same pattern that markdownlint catches but | ||
| // runs locally (faster feedback) and pre-push (before the CI cycle). | ||
| // | ||
| // Scope: detects the most common pattern — a non-blank, non-list line | ||
| // immediately followed by a list item (`- `, `+ `, `* `, or `1. ` style). | ||
| // Markdownlint catches more variants; this is the minimal helper that | ||
| // catches the 5 recurring failures from the 2026-05-13/14 session. | ||
| // | ||
| // Exit codes: | ||
| // 0 — no findings (or no files processed) | ||
| // 1 — findings present; emits `file:line` for each | ||
| // | ||
| // CLI usage: | ||
| // bun tools/hygiene/check-md032-blanks-around-lists.ts <file1> <file2> ... | ||
| // bun tools/hygiene/check-md032-blanks-around-lists.ts --staged | ||
| // | ||
| // B-0456 — see docs/backlog/P2/B-0456-mechanize-md032-blanks-around-lists-pre-commit-2026-05-14.md | ||
|
|
||
| import { readFileSync } from "node:fs"; | ||
| import { spawnSync } from "node:child_process"; | ||
| import { resolve, relative } from "node:path"; | ||
|
|
||
| export type Md032Finding = { | ||
| file: string; | ||
| line: number; // 1-indexed line where the list starts (after the bad break) | ||
| context: string; // the first 60 chars of the offending list line | ||
|
Comment on lines
+45
to
+46
|
||
| }; | ||
|
|
||
| /** | ||
| * Detect whether a line is a list-item start. | ||
| * | ||
| * Matches the three unordered-list markers (`-`, `+`, `*`) and the | ||
| * ordered-list pattern (`1.`, `12.`, etc.), each followed by at least | ||
| * one space. Markdown's actual grammar is more permissive but these | ||
| * cover every case observed in the 5 session findings. | ||
| */ | ||
| function isListItemStart(line: string): boolean { | ||
| return /^\s*([-+*]|\d+\.)\s+/.test(line); | ||
|
AceHack marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| /** | ||
| * A line is "blank" if it contains only whitespace. | ||
| */ | ||
| function isBlank(line: string): boolean { | ||
| return /^\s*$/.test(line); | ||
| } | ||
|
|
||
| /** | ||
| * A line is "list-friendly leading" if it can come right before a list | ||
| * without an intervening blank. Three valid cases: | ||
| * | ||
| * 1. ATX heading directly above list (markdownlint allows by default). | ||
| * 2. Already a list-item line (the prior bullet — sibling/nested list). | ||
| * 3. An indented continuation of a list item (the prior bullet's body | ||
| * text wraps onto multiple lines — markdown treats indented lines | ||
| * inside a list block as part of the previous item). | ||
| */ | ||
| function isListFriendlyLeading(line: string): boolean { | ||
| // ATX heading: # ## ### etc. (with required space after). | ||
| if (/^#{1,6}\s+/.test(line)) return true; | ||
|
AceHack marked this conversation as resolved.
Outdated
|
||
| // Already a list item — nested or sibling list, both fine. | ||
| if (isListItemStart(line)) return true; | ||
| // Indented continuation of a list-item body (a leading space typically | ||
| // means we're inside a list block, not a new paragraph). | ||
| if (/^\s+\S/.test(line)) return true; | ||
|
AceHack marked this conversation as resolved.
Outdated
|
||
| return false; | ||
|
Comment on lines
+175
to
+197
|
||
| } | ||
|
|
||
| export function findMd032Violations(content: string): { line: number; context: string }[] { | ||
| const lines = content.split(/\r?\n/); | ||
| const findings: { line: number; context: string }[] = []; | ||
| // Walk pairwise: each list-start line is checked against the line before. | ||
| for (let i = 1; i < lines.length; i++) { | ||
| const cur = lines[i] ?? ""; | ||
| if (!isListItemStart(cur)) continue; | ||
| const prev = lines[i - 1] ?? ""; | ||
| if (isBlank(prev) || isListFriendlyLeading(prev)) continue; | ||
|
AceHack marked this conversation as resolved.
Outdated
|
||
| findings.push({ | ||
| line: i + 1, // 1-indexed | ||
| context: cur.slice(0, 60), | ||
| }); | ||
| } | ||
| return findings; | ||
| } | ||
|
|
||
| /** | ||
| * Walk a list of files; collect findings; emit to stderr in | ||
| * `file:line: context` style (matches markdownlint output shape). | ||
| * Returns the total finding count. | ||
|
AceHack marked this conversation as resolved.
Outdated
|
||
| */ | ||
| export function checkFiles(files: string[], repoRoot: string): Md032Finding[] { | ||
|
AceHack marked this conversation as resolved.
Outdated
|
||
| const all: Md032Finding[] = []; | ||
| for (const f of files) { | ||
| let content: string; | ||
| try { | ||
| content = readFileSync(f, "utf-8"); | ||
| } catch { | ||
|
AceHack marked this conversation as resolved.
Outdated
|
||
| // Unreadable — skip silently. The audit-duplicate-row-ids tool's | ||
| // round-2 review pushed in the opposite direction (surface read | ||
| // errors) but here we're a fast pre-push helper; the user will | ||
| // see the broken file at push time or in CI anyway. | ||
| continue; | ||
| } | ||
| const findings = findMd032Violations(content); | ||
| for (const finding of findings) { | ||
| all.push({ | ||
| file: f, | ||
| line: finding.line, | ||
| context: finding.context, | ||
| }); | ||
| } | ||
| } | ||
| return all; | ||
| } | ||
|
|
||
| /** | ||
| * Collect staged `.md` files via `git diff --name-only --cached`. | ||
| */ | ||
| function stagedMarkdownFiles(repoRoot: string): string[] { | ||
| // eslint-disable-next-line sonarjs/no-os-command-from-path -- git invoked as explicit args array; no shell, no user input on the command line. | ||
| const r = spawnSync( | ||
| "git", | ||
| ["-C", repoRoot, "diff", "--name-only", "--cached", "--diff-filter=AM"], | ||
|
AceHack marked this conversation as resolved.
Outdated
|
||
| { encoding: "utf-8" }, | ||
| ); | ||
| if (r.status !== 0) { | ||
| console.error(`git diff failed: ${r.stderr ?? ""}`); | ||
| return []; | ||
|
AceHack marked this conversation as resolved.
Outdated
AceHack marked this conversation as resolved.
Outdated
|
||
| } | ||
| return (r.stdout ?? "") | ||
| .split("\n") | ||
| .filter((line) => line.endsWith(".md")) | ||
| .map((line) => `${repoRoot}/${line}`); | ||
|
AceHack marked this conversation as resolved.
AceHack marked this conversation as resolved.
|
||
| } | ||
|
|
||
| function main(): number { | ||
| const repoRoot = resolve(import.meta.dir, "..", ".."); | ||
| const argv = process.argv.slice(2); | ||
|
AceHack marked this conversation as resolved.
Outdated
|
||
|
|
||
| let files: string[]; | ||
| if (argv.includes("--staged")) { | ||
| files = stagedMarkdownFiles(repoRoot); | ||
| if (files.length === 0) { | ||
| console.log("check-md032: no staged .md files"); | ||
| return 0; | ||
| } | ||
| } else if (argv.length === 0) { | ||
| console.error( | ||
| "Usage: check-md032-blanks-around-lists.ts <file1> <file2> ... | --staged", | ||
| ); | ||
| return 1; | ||
| } else { | ||
| files = argv.map((f) => (f.startsWith("/") ? f : resolve(f))); | ||
| } | ||
|
|
||
| const findings = checkFiles(files, repoRoot); | ||
|
|
||
| if (findings.length === 0) { | ||
| console.log(`check-md032: ${files.length} file(s) scanned, no MD032 findings`); | ||
| return 0; | ||
| } | ||
|
|
||
| console.error(`check-md032: ${findings.length} MD032 finding(s):`); | ||
| for (const f of findings) { | ||
| const rel = f.file.startsWith(repoRoot) ? relative(repoRoot, f.file) : f.file; | ||
| console.error(` ${rel}:${f.line}: ${f.context}`); | ||
| } | ||
| return 1; | ||
| } | ||
|
|
||
| if (import.meta.main) { | ||
| process.exit(main()); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.