Skip to content
Merged
Changes from 1 commit
Commits
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
187 changes: 187 additions & 0 deletions tools/hygiene/check-shard-before-push.ts
Original file line number Diff line number Diff line change
@@ -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 <shard-path>...
// 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 <shard-path>...\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]!;
Comment thread
AceHack marked this conversation as resolved.
// 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;
Comment thread
AceHack marked this conversation as resolved.
Comment on lines +93 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip intra-list continuations in MD032 scan

The custom checkMd032 heuristic flags any - line whose previous line is non-blank/non-structural, but that also matches valid multi-line list items (e.g., - first + indented continuation + - second). In that case markdownlint-cli2 passes, yet this helper sets anyFailed = true and exits 1, creating false blocking results for normal shard formatting. Because this script is intended as a reliable pre-push signal, it should track whether the current bullet is a continuation of an existing list context before reporting MD032.

Useful? React with 👍 / 👎.

findings.push({ file, line: i + 1, paragraph: prev, bullet: cur });
Comment thread
AceHack marked this conversation as resolved.
}
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], {
Comment thread
AceHack marked this conversation as resolved.
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;
}
Comment thread
AceHack marked this conversation as resolved.
Outdated
// 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);
Comment thread
AceHack marked this conversation as resolved.
Outdated

// 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)));
}
Loading