From 9151d6cef1092c830ef48c1e27d4dffdabc1105e Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Fri, 15 May 2026 23:44:46 -0400 Subject: [PATCH 1/2] feat(hygiene): bundled pre-push self-check helper for tick shards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the three self-checks I've been running per-tick this session into one command: MD032 paragraph-before-bullet awk-style scan + markdownlint-cli2 (the broad markdown surface including MD038) + audit-tick-shard-relative-paths (the dedicated relative-path audit). Motivation: today's self-bite tick 13 saw an MD038 violation ship to PR #3707 because I didn't run markdownlint before push; the required CI check failed and blocked merge for 70+ min. A bundled helper shortens the local-feedback loop from "push + wait for CI" to "single command + immediate output." DX helper, not a CI gate. CI gates remain authoritative. Usage: bun tools/hygiene/check-shard-before-push.ts ... Exit codes: 0 all checks passed 1 one or more checks failed 64 argument error (missing files, non-files, no args) Local verify: - Clean shard (0334Z.md): exit 0; all 3 checks ok - Bad shard (synthetic MD032 + MD038): exit 1; specific findings printed - Missing file: exit 64 with structured "input not found" - No args: exit 64 with usage - bun --bun tsc --noEmit: exit 0 - markdownlint-cli2 on this file: exit 0 Composes with: audit-tick-shard-relative-paths.ts (the audit it wraps), AUDIT-LIFECYCLE.md (the lifecycle template that motivates pre-push catches), the §33 audit's same pattern. Co-Authored-By: Claude --- tools/hygiene/check-shard-before-push.ts | 187 +++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tools/hygiene/check-shard-before-push.ts diff --git a/tools/hygiene/check-shard-before-push.ts b/tools/hygiene/check-shard-before-push.ts new file mode 100644 index 000000000..b48471ed5 --- /dev/null +++ b/tools/hygiene/check-shard-before-push.ts @@ -0,0 +1,187 @@ +#!/usr/bin/env bun +// check-shard-before-push.ts — bundled pre-push self-checks for tick shards +// +// Run on a single shard (or multiple) before pushing. Wraps three CI gates +// that the shard would otherwise hit only after push: +// +// 1. MD032 paragraph-immediately-followed-by-bullet detection +// (markdownlint catches this; running before push is faster feedback) +// 2. `markdownlint-cli2` for the broad markdown lint surface +// (MD038 no-space-in-code, MD024, etc.) +// 3. `audit-tick-shard-relative-paths` for broken relative-path links +// (the dedicated audit gate — same as CI runs in `--enforce --baseline` +// mode, but here in detect-only-for-this-file mode) +// +// This is a DX helper, not a CI gate. CI gates remain authoritative; this +// script just shortens the local-feedback loop from "push + wait for CI" +// to "single command + immediate output." +// +// Usage: +// +// bun tools/hygiene/check-shard-before-push.ts ... +// bun tools/hygiene/check-shard-before-push.ts docs/hygiene-history/ticks/2026/05/16/0340Z.md +// +// Exit codes: +// +// 0 all checks passed on all input files +// 1 one or more checks failed +// 64 argument error (no files / non-file inputs) +// +// Composes with: audit-tick-shard-relative-paths.ts (relative-path audit), +// audit-md032-plus-linestart.ts (sibling `+`-marker MD032 check), +// AUDIT-LIFECYCLE.md (the lifecycle template that motivates pre-push catches). + +import { existsSync, statSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +// arg parsing + +interface Args { + files: readonly string[]; +} + +function parseArgs(argv: readonly string[]): Args { + if (argv.length === 0) { + process.stderr.write("usage: check-shard-before-push.ts ...\n"); + process.exit(64); + } + return { files: argv }; +} + +// MD032 paragraph-immediately-followed-by-bullet detection. +// The same `awk`-shaped scan I've been running per-tick this session, +// ported to TS for the bundled-helper interface. + +interface Md032Finding { + readonly file: string; + readonly line: number; + readonly paragraph: string; + readonly bullet: string; +} + +function checkMd032(file: string): Md032Finding[] { + const text = readFileSync(file, "utf8"); + const lines = text.split("\n"); + const findings: Md032Finding[] = []; + for (let i = 1; i < lines.length; i++) { + const prev = lines[i - 1]!; + const cur = lines[i]!; + // Trigger: cur starts with "- " bullet AND prev is non-blank + // AND prev is not itself a structural marker (#, >, *, -, |, ```) + if (!cur.startsWith("- ")) continue; + if (prev.trim() === "") continue; + if (/^[#>*\-|`]/.test(prev)) continue; + findings.push({ file, line: i + 1, paragraph: prev, bullet: cur }); + } + return findings; +} + +// markdownlint-cli2 wrapper. +// Returns true on clean exit, false on findings. Stdout/stderr are echoed +// directly so the user sees the same output they'd see from the CI gate. + +function runMarkdownlint(file: string): boolean { + // eslint-disable-next-line sonarjs/no-os-command-from-path -- bun invoked as explicit args array; no shell; no user input beyond the file path which is validated upfront. + const r = spawnSync("bun", ["x", "markdownlint-cli2", file], { + stdio: ["ignore", "inherit", "inherit"], + }); + return r.status === 0; +} + +// relative-path-audit wrapper. +// The audit's `--files` mode is detect-only by default (no --enforce), so +// we get the findings list. Exit 0 with findings == clean; non-zero = +// argument error. + +function runRelativePathAudit(file: string): boolean { + // eslint-disable-next-line sonarjs/no-os-command-from-path -- bun invoked as explicit args array; no shell; no user input beyond the file path which is validated upfront. + const r = spawnSync( + "bun", + [ + "tools/hygiene/audit-tick-shard-relative-paths.ts", + "--files", + file, + ], + { encoding: "utf8" }, + ); + const out = r.stdout ?? ""; + const err = r.stderr ?? ""; + // Output format: "ok: scanned N tick shards; 0 broken relative-path links\n" + // OR "scanned N tick shards; M broken relative-path links:\n\n file:line ...\n" + if (out.includes("0 broken relative-path links")) { + return true; + } + // findings present — print them + process.stdout.write(out); + if (err) process.stderr.write(err); + return false; +} + +// main + +export function main(argv: readonly string[]): 0 | 1 | 64 { + const args = parseArgs(argv); + + // Validate inputs (per the validation pattern from the audit script). + const bad: { path: string; reason: string }[] = []; + for (const f of args.files) { + const abs = resolve(f); + if (!existsSync(abs)) { + bad.push({ path: f, reason: "not found" }); + continue; + } + try { + if (!statSync(abs).isFile()) { + bad.push({ path: f, reason: "not a regular file" }); + } + } catch (e) { + bad.push({ path: f, reason: `stat failed (${(e as Error).message})` }); + } + } + if (bad.length > 0) { + for (const b of bad) process.stderr.write(`input ${b.reason}: ${b.path}\n`); + return 64; + } + + let anyFailed = false; + for (const f of args.files) { + process.stdout.write(`=== ${f}\n`); + + // 1. MD032 + const md032 = checkMd032(f); + if (md032.length === 0) { + process.stdout.write(" ok: MD032 (paragraph-before-bullet)\n"); + } else { + anyFailed = true; + process.stdout.write(` FAIL: MD032 (${md032.length} finding${md032.length === 1 ? "" : "s"}):\n`); + for (const m of md032) { + process.stdout.write(` line ${m.line}: '${m.paragraph.slice(0, 60)}' → '${m.bullet.slice(0, 60)}'\n`); + } + } + + // 2. markdownlint + process.stdout.write(" running: markdownlint-cli2 ...\n"); + if (runMarkdownlint(f)) { + process.stdout.write(" ok: markdownlint\n"); + } else { + anyFailed = true; + process.stdout.write(" FAIL: markdownlint\n"); + } + + // 3. relative-path audit + process.stdout.write(" running: audit-tick-shard-relative-paths ...\n"); + if (runRelativePathAudit(f)) { + process.stdout.write(" ok: relative-path audit\n"); + } else { + anyFailed = true; + process.stdout.write(" FAIL: relative-path audit\n"); + } + } + + return anyFailed ? 1 : 0; +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +} From 38c4b58f8ca519fcaad86e646d28aec0ad047316 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Fri, 15 May 2026 23:50:08 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(pr-3716):=203=20Copilot=20findings=20?= =?UTF-8?q?=E2=80=94=20substring=20fragility,=20fenced-code=20MD032,=20par?= =?UTF-8?q?seArgs=20exit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real findings on PR #3716, fixed: L114 (substring fragility): runRelativePathAudit relied on out.includes( "0 broken relative-path links") as the success signal. Fragile to future wording tweaks AND ignored r.status entirely. Replace with: 1. r.status !== 0 → unambiguous failure (echo stdout+stderr+status) 2. Parse the "N broken relative-path links" count via regex; "0" = ok 3. Unrecognized output format → fail loud, echo, don't silent-pass L69 + L74/L75 + (3 duplicate threads on fenced-code MD032): the checkMd032 scan didn't track fence state, so a `- ` line inside ```...``` was flagged as a false-positive MD032 violation. Add buildCodeFenceFlags (same pattern as audit-tick-shard-relative-paths.ts) and skip lines inside fences. Verified with a synthetic shard containing a bullet inside a code block — was reported FAIL pre-fix, now reports OK. L124 (parseArgs process.exit): parseArgs called process.exit(64) directly on no-args, blocking unit-test of the no-args branch even though main is exported for testability. Replace with a typed ParseResult discriminated union ({ok:true,args} | {ok:false,exitCode:64,message}); main inspects the result and exits itself. Skipped (P2 optional, scope creep): multi-file batching of spawn calls. Markdownlint-cli2 + audit script accept multiple files natively; could reduce wall-clock for N>1 invocations. Filing for future iteration if multi-shard usage materializes. Local verify (5 tests): - Clean shard (0334Z.md): exit 0 - Bad shard (MD032 + MD038): exit 1, both findings printed - Fence-shard (- bullet inside ```text```): exit 0 ← fix verified - No args: exit 64 via main (was via process.exit inside parseArgs) - tsc --noEmit: exit 0 Co-Authored-By: Claude --- tools/hygiene/check-shard-before-push.ts | 66 ++++++++++++++++++++---- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/tools/hygiene/check-shard-before-push.ts b/tools/hygiene/check-shard-before-push.ts index b48471ed5..935167a22 100644 --- a/tools/hygiene/check-shard-before-push.ts +++ b/tools/hygiene/check-shard-before-push.ts @@ -41,12 +41,13 @@ interface Args { files: readonly string[]; } -function parseArgs(argv: readonly string[]): Args { +type ParseResult = { ok: true; args: Args } | { ok: false; exitCode: 64; message: string }; + +function parseArgs(argv: readonly string[]): ParseResult { if (argv.length === 0) { - process.stderr.write("usage: check-shard-before-push.ts ...\n"); - process.exit(64); + return { ok: false, exitCode: 64, message: "usage: check-shard-before-push.ts ..." }; } - return { files: argv }; + return { ok: true, args: { files: argv } }; } // MD032 paragraph-immediately-followed-by-bullet detection. @@ -60,11 +61,31 @@ interface Md032Finding { readonly bullet: string; } +// Per-line "inside fenced code block" tracking. A `- ` line inside ``` ``` ``` +// fences is not a markdown list item — markdownlint correctly ignores it, +// and so must this scanner. Same pattern as audit-tick-shard-relative-paths.ts. +function buildCodeFenceFlags(lines: readonly string[]): boolean[] { + const flags = new Array(lines.length).fill(false); + let inFence = false; + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i]!.trim(); + if (trimmed.startsWith("```")) { + inFence = !inFence; + flags[i] = inFence; // the fence-marker line itself is "inside" the closing-toggle direction + } else { + flags[i] = inFence; + } + } + return flags; +} + function checkMd032(file: string): Md032Finding[] { const text = readFileSync(file, "utf8"); const lines = text.split("\n"); + const fenceFlags = buildCodeFenceFlags(lines); const findings: Md032Finding[] = []; for (let i = 1; i < lines.length; i++) { + if (fenceFlags[i]) continue; // skip lines inside fenced code blocks const prev = lines[i - 1]!; const cur = lines[i]!; // Trigger: cur starts with "- " bullet AND prev is non-blank @@ -107,11 +128,33 @@ function runRelativePathAudit(file: string): boolean { ); const out = r.stdout ?? ""; const err = r.stderr ?? ""; - // Output format: "ok: scanned N tick shards; 0 broken relative-path links\n" - // OR "scanned N tick shards; M broken relative-path links:\n\n file:line ...\n" - if (out.includes("0 broken relative-path links")) { - return true; + + // The audit script in detect-only mode (no --enforce) exits 0 even with + // findings; we need to inspect the output. But ANY non-zero exit (crash, + // argument error, signal) is unambiguously a failure regardless of stdout. + if (r.status !== 0) { + process.stdout.write(out); + if (err) process.stderr.write(err); + process.stderr.write(`audit exited with status ${r.status}\n`); + return false; + } + + // Exit 0 + findings: parse the output. Match the canonical "X broken + // relative-path links" suffix; treat "0 broken" as clean, anything else + // as findings. Tolerate future wording tweaks by allowing whitespace + // variation around the digit. + const FINDINGS_RE = /;\s*(\d+)\s+broken relative-path links/; + const m = out.match(FINDINGS_RE); + if (!m) { + // Unexpected output format — echo and fail loud rather than silent-pass. + process.stdout.write(out); + if (err) process.stderr.write(err); + process.stderr.write("audit produced unrecognized output format\n"); + return false; } + const count = Number(m[1]); + if (count === 0) return true; + // findings present — print them process.stdout.write(out); if (err) process.stderr.write(err); @@ -121,7 +164,12 @@ function runRelativePathAudit(file: string): boolean { // main export function main(argv: readonly string[]): 0 | 1 | 64 { - const args = parseArgs(argv); + const parsed = parseArgs(argv); + if (!parsed.ok) { + process.stderr.write(`${parsed.message}\n`); + return parsed.exitCode; + } + const args = parsed.args; // Validate inputs (per the validation pattern from the audit script). const bad: { path: string; reason: string }[] = [];