Skip to content
Merged
Show file tree
Hide file tree
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 May 14, 2026
3a8aed0
shard(tick): 0252Z — B-0456 MD032 helper shipped + self-validated
AceHack May 14, 2026
3158f6a
fix(hygiene/check-md032): Codex+Copilot round-1 review (4 findings)
AceHack May 14, 2026
70da2d1
shard(tick): 0317Z — PR #3075 round-1 review: 4 substantive findings
AceHack May 14, 2026
6f20e2d
shard(tick): 0331Z — PR #3073 merged; substrate-hygiene cascade DURAB…
AceHack May 14, 2026
6c436bd
fix(check-md032): address 6 P1 Copilot findings from PR #3075 review
AceHack May 14, 2026
c15613c
fix(check-md032): track matching fence delimiters (Codex P2 #3075)
AceHack May 14, 2026
cc74391
docs(tick): 0101Z — PR #3075 round-2 resolved (8 threads); honor-thos…
AceHack May 14, 2026
9bfdde9
fix(check-md032): include renames in --staged scan (Codex P2 #3075)
AceHack May 14, 2026
0e0563f
docs(tick): 0104Z — PR #3074 MERGED; PR #3075 round-3 sibling-absorbed
AceHack May 14, 2026
579dce9
fix(check-md032): round-4 review fixes (info-string closers, role-ref…
AceHack May 14, 2026
983d00c
docs(tick): 0111Z — PR #3075 round-4 resolved (5 threads)
AceHack May 14, 2026
97c93f1
docs(tick): 0112Z — round-5 duplicate (Codex info-string closer) reso…
AceHack May 14, 2026
f1b1a3e
docs(tick): 0113Z — real-dependency-wait on PR 3075 CI (4 required in…
AceHack May 14, 2026
3ebca13
fix(check-md032): round-6 review fixes (markdownlint ignores, paren o…
AceHack May 14, 2026
27cb70d
Merge remote-tracking branch 'origin/main' into feat/b0456-md032-blan…
AceHack May 14, 2026
740dcd2
docs(tick): 0126Z — PR #3075 round-6 resolved (4 threads + DIRTY merge)
AceHack May 14, 2026
3649327
fix(check-md032): skip YAML front matter (round-7 Codex P2)
AceHack May 14, 2026
73ba37e
docs(tick): 0130Z — PR #3075 round-7 resolved (YAML front-matter skip)
AceHack May 14, 2026
acd8ff8
fix(check-md032): round-8 review fixes (lazy continuations, 9-digit c…
AceHack May 14, 2026
ecc6840
docs(tick): 0137Z — PR #3075 round-8 resolved (4 CommonMark spec-grad…
AceHack May 14, 2026
117fa7b
fix(check-md032): recognize list markers in blockquote context (round…
AceHack May 14, 2026
773cb18
docs(tick): 0143Z — PR #3075 round-9 resolved (blockquote-context lis…
AceHack May 14, 2026
a86a524
fix(check-md032): round-10 review (tilde-fence info strings, fence-ke…
AceHack May 14, 2026
bcfe476
docs(tick): 0148Z — PR #3075 round-10 resolved (tilde-fence relaxatio…
AceHack May 14, 2026
8498772
fix(check-md032): replace in-test require() with module-scope import …
AceHack May 14, 2026
dce1206
docs(tick): 0152Z — PR #3075 round-11 resolved (1 real require fix + …
AceHack May 14, 2026
3dcc785
docs(tick): 0154Z — PR #3075 wait-ci after 11 rounds; threads clear; …
AceHack May 14, 2026
3f3da2b
fix(tick/0143Z): remove `> ` code span (MD038 — spaces inside code sp…
AceHack May 14, 2026
deed8c2
docs(tick): 0158Z — PR #3075 MD038 markdownlint fix in 0143Z shard
AceHack May 14, 2026
7c5f079
fix(check-md032): round-12 (heading terminates list + blockquoted fen…
AceHack May 14, 2026
621c8b7
docs(tick): 0202Z — PR #3075 round-12 resolved (heading-terminates + …
AceHack May 14, 2026
8b666bf
fix(tick/0202Z): remove backtick-rich regex from code span (MD038)
AceHack May 14, 2026
263b7b4
fix(check-md032): --staged reads index blob, not working tree (round-…
AceHack May 14, 2026
b83ddfc
docs(tick): 0211Z — PR #3075 round-13 resolved (staged-blob reads)
AceHack May 14, 2026
1c43065
Merge remote-tracking branch 'origin/main' into feat/b0456-md032-blan…
AceHack May 14, 2026
2855393
docs(tick): 0214Z — DIRTY-gate merge resolved (0154Z add/add); 2nd of…
AceHack May 14, 2026
11fcbd5
fix(check-md032): round-14 (permissive blockquote spacing, fence-bloc…
AceHack May 14, 2026
ad39781
docs(tick): 0222Z — PR #3075 round-14 resolved (5 P1 + 1 stale-snapshot)
AceHack May 14, 2026
9fdb083
fix(check-md032): thematic break + blockquote terminate top-level lis…
AceHack May 14, 2026
cdea58f
docs(tick): 0227Z — PR #3075 round-15 resolved (thematic break + bloc…
AceHack May 14, 2026
6e77bc3
fix(tick/0222Z): remove `> ` code span (MD038 — third hit this PR)
AceHack May 14, 2026
b343a09
fix(check-md032): round-16 (after-list MD032 + blockquote indent cap …
AceHack May 14, 2026
47a7adc
docs(tick): 0239Z — PR #3075 round-16 resolved (after-list MD032 + in…
AceHack May 14, 2026
d172d57
fix(check-md032): blockquoted fence accepts content-indent (round-17 …
AceHack May 14, 2026
adbfe91
fix(check-md032): round-18 (blockquoted thematic break, bare markers,…
AceHack May 14, 2026
5dbeafe
fix(check-md032): skip HTML comment regions (round-19 Codex P2)
AceHack May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions tools/hygiene/check-md032-blanks-around-lists.test.ts
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", () => {
Comment thread
AceHack marked this conversation as resolved.
Comment thread
AceHack marked this conversation as resolved.
Comment thread
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([]);
Comment thread
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([]);
});
});
193 changes: 193 additions & 0 deletions tools/hygiene/check-md032-blanks-around-lists.ts
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);
Comment thread
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;
Comment thread
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;
Comment thread
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;
Comment thread
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.
Comment thread
AceHack marked this conversation as resolved.
Outdated
*/
export function checkFiles(files: string[], repoRoot: string): Md032Finding[] {
Comment thread
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 {
Comment thread
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"],
Comment thread
AceHack marked this conversation as resolved.
Outdated
{ encoding: "utf-8" },
);
if (r.status !== 0) {
console.error(`git diff failed: ${r.stderr ?? ""}`);
return [];
Comment thread
AceHack marked this conversation as resolved.
Outdated
Comment thread
AceHack marked this conversation as resolved.
Outdated
}
return (r.stdout ?? "")
.split("\n")
.filter((line) => line.endsWith(".md"))
.map((line) => `${repoRoot}/${line}`);
Comment thread
AceHack marked this conversation as resolved.
Comment thread
AceHack marked this conversation as resolved.
}

function main(): number {
const repoRoot = resolve(import.meta.dir, "..", "..");
const argv = process.argv.slice(2);
Comment thread
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());
}
Loading