BMAD-METHOD/tools/markdown/check-md-conformance.js

295 lines
8.0 KiB
JavaScript

#!/usr/bin/env node
/**
* MD Conformance Checker (CommonMark-oriented)
*
* Checks .md files for:
* 1) Blank line before/after bullet and numbered lists
* 2) Blank line before/after tables
* 3) Blank line before/after fenced code blocks
* 4) Bullet marker normalization: "-" only (not "*" or "+")
* 5) Code fence language present (fallback should be specified by author)
*
* Usage:
* node tools/markdown/check-md-conformance.js [paths...]
* - If a path is a directory, scans recursively for .md files
* - If a path is a file and ends with .md, scans that file
*
* Exit codes:
* 0 -> No violations
* 1 -> Violations found
*/
const fs = require('node:fs');
const path = require('node:path');
function listMarkdownFiles(targetPath) {
const results = [];
function walk(p) {
const stat = fs.statSync(p);
if (stat.isDirectory()) {
const entries = fs.readdirSync(p);
for (const e of entries) {
if (e === 'node_modules' || e.startsWith('.git')) continue;
walk(path.join(p, e));
}
} else if (stat.isFile() && p.toLowerCase().endsWith('.md')) {
results.push(p);
}
}
walk(targetPath);
return results;
}
function isListLine(line) {
return /^\s*([-*+])\s+/.test(line) || /^\s*\d+\.\s+/.test(line);
}
function isBulletLine(line) {
return /^\s*([-*+])\s+/.test(line);
}
function bulletMarker(line) {
const m = line.match(/^\s*([-*+])\s+/);
return m ? m[1] : null;
}
function isTableLine(line) {
// Simple heuristic: contains a pipe and not a code fence
// We'll treat a group of lines with pipes as a table block
const trimmed = line.trim();
if (trimmed.startsWith('```')) return false;
return /\|/.test(line) && !/^\s*\|\s*$/.test(line);
}
function isFenceStart(line) {
return /^\s*```/.test(line);
}
function fenceLanguage(line) {
const m = line.match(/^\s*```\s*([a-zA-Z0-9_+-]+)?/);
return m ? (m[1] || '') : '';
}
function isBlank(line) {
return /^\s*$/.test(line);
}
function checkFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split(/\r?\n/);
const violations = [];
let inFence = false;
let fenceStartLine = -1;
// Pass 1: fence tracking to avoid interpreting list/table inside code blocks
const excluded = new Array(lines.length).fill(false);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (isFenceStart(line)) {
if (!inFence) {
inFence = true;
fenceStartLine = i;
} else {
// closing fence
inFence = false;
fenceStartLine = -1;
}
excluded[i] = true;
continue;
}
if (inFence) excluded[i] = true;
}
// Pass 2: checks
// 2a) Code fences: language presence and blank lines around
inFence = false;
for (let i = 0; i < lines.length; i++) {
if (excluded[i]) {
if (isFenceStart(lines[i])) {
// Fence boundary
if (!inFence) {
// opening
inFence = true;
// language present?
const lang = fenceLanguage(lines[i]);
if (!lang) {
violations.push({
type: 'fence-language-missing',
line: i + 1,
message: 'Code fence missing language identifier (e.g., ```bash)',
});
}
// blank line before?
const prev = i - 1;
if (prev >= 0 && !isBlank(lines[prev])) {
violations.push({
type: 'fence-blank-before',
line: i + 1,
message: 'Missing blank line before code fence',
});
}
} else {
// closing
inFence = false;
// blank line after?
const next = i + 1;
if (next < lines.length && !isBlank(lines[next])) {
violations.push({
type: 'fence-blank-after',
line: i + 1,
message: 'Missing blank line after code fence',
});
}
}
}
continue;
}
}
// 2b) Lists: blank lines before/after; bullets normalization
// We'll detect contiguous list blocks.
let i = 0;
while (i < lines.length) {
if (excluded[i]) {
i++;
continue;
}
if (isListLine(lines[i])) {
// Start of a list block
const start = i;
// Require immediate previous line to be blank (not previous non-blank)
const prev = start - 1;
if (prev >= 0 && !isBlank(lines[prev])) {
violations.push({ type: 'list-blank-before', line: start + 1, message: 'Missing blank line before list' });
}
// Track bullets normalization
if (isBulletLine(lines[i])) {
const marker = bulletMarker(lines[i]);
if (marker && marker !== '-') {
violations.push({ type: 'bullet-marker', line: i + 1, message: `Use '-' for bullets, found '${marker}'` });
}
}
// Move to end of the list block (stop at first non-list line; do not consume trailing blanks)
let end = start;
while (end < lines.length && isListLine(lines[end])) {
// Also check bullet markers inside block
if (!excluded[end] && isBulletLine(lines[end])) {
const marker = bulletMarker(lines[end]);
if (marker && marker !== '-') {
violations.push({ type: 'bullet-marker', line: end + 1, message: `Use '-' for bullets, found '${marker}'` });
}
}
end++;
}
// Require immediate next line after block to be blank
const next = end;
if (next < lines.length && !isBlank(lines[next])) {
const lastContentLine = end - 1;
violations.push({ type: 'list-blank-after', line: lastContentLine + 1, message: 'Missing blank line after list' });
}
i = end;
continue;
}
i++;
}
// 2c) Tables: detect blocks of lines containing '|' and ensure blank lines around
i = 0;
while (i < lines.length) {
if (excluded[i]) {
i++;
continue;
}
if (isTableLine(lines[i])) {
const start = i;
// scan forward while lines look like table lines
let end = start;
while (end < lines.length && isTableLine(lines[end])) end++;
// Require immediate previous line to be blank
const prev = start - 1;
if (prev >= 0 && !isBlank(lines[prev])) {
violations.push({ type: 'table-blank-before', line: start + 1, message: 'Missing blank line before table' });
}
// Require immediate next line after block to be blank
const next = end;
if (next < lines.length && !isBlank(lines[next])) {
const last = end - 1;
violations.push({ type: 'table-blank-after', line: last + 1, message: 'Missing blank line after table' });
}
i = end;
continue;
}
i++;
}
return violations;
}
function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage: node tools/markdown/check-md-conformance.js [paths...]');
process.exit(2);
}
// Expand inputs to files
const files = [];
for (const p of args) {
const abs = path.resolve(p);
if (!fs.existsSync(abs)) {
console.error(`Path not found: ${abs}`);
continue;
}
const stat = fs.statSync(abs);
if (stat.isDirectory()) {
files.push(...listMarkdownFiles(abs));
} else if (stat.isFile() && abs.toLowerCase().endsWith('.md')) {
files.push(abs);
}
}
const summary = [];
let total = 0;
for (const f of files) {
const violations = checkFile(f);
if (violations.length > 0) {
summary.push({ file: f, violations });
total += violations.length;
}
}
if (summary.length === 0) {
console.log('MD Conformance: PASS (no violations)');
process.exit(0);
}
// Pretty print
console.log(`MD Conformance: FAIL (${total} violation(s) in ${summary.length} file(s))`);
for (const { file, violations } of summary) {
console.log(`\n- ${path.relative(process.cwd(), file)}`);
for (const v of violations) {
console.log(` L${v.line.toString().padStart(4, ' ')} ${v.type} ${v.message}`);
}
}
process.exit(1);
}
if (require.main === module) {
main();
}
module.exports = { checkFile };