PR #830 - Code Quality & Markdown Tooling

Tooling Created:
- tools/markdown/check-md-conformance.js - Multi-rule checker with CI exit codes
- tools/markdown/fix-fence-languages.js - Auto-fixer with dry-run support, handles 3+ backtick fences

Configuration Updates:
- eslint.config.mjs - Added .patch/** to global ignores
- package.json - Added 7 markdown check scripts, integrated into pre-release pipeline

Code Quality Fixes:
- 580 Prettier formatting violations fixed across entire codebase
- 11 ESLint violations fixed in markdown tools
This commit is contained in:
Keimpe de Jong 2025-10-29 06:40:39 +00:00
parent 9dfe22151c
commit 49b97b824f
4 changed files with 120 additions and 101 deletions

1
.gitignore vendored
View File

@ -62,6 +62,5 @@ z*/
# Patch archives (exclude from VCS) # Patch archives (exclude from VCS)
.claude/ .claude/
.husky/
.patch/ .patch/
.vscode/ .vscode/

View File

@ -11,6 +11,7 @@ export default [
'dist/**', 'dist/**',
'coverage/**', 'coverage/**',
'**/*.min.js', '**/*.min.js',
'.patch/**',
'test/template-test-generator/**', 'test/template-test-generator/**',
'test/template-test-generator/**/*.js', 'test/template-test-generator/**/*.js',
'test/template-test-generator/**/*.md', 'test/template-test-generator/**/*.md',

View File

@ -1,4 +1,3 @@
#!/usr/bin/env node
/** /**
* MD Conformance Checker (CommonMark-oriented) * MD Conformance Checker (CommonMark-oriented)
* *
@ -67,7 +66,7 @@ function isFenceStart(line) {
function fenceLanguage(line) { function fenceLanguage(line) {
const m = line.match(/^\s*```\s*([a-zA-Z0-9_+-]+)?/); const m = line.match(/^\s*```\s*([a-zA-Z0-9_+-]+)?/);
return m ? (m[1] || '') : ''; return m ? m[1] || '' : '';
} }
function isBlank(line) { function isBlank(line) {
@ -84,17 +83,16 @@ function checkFile(filePath) {
let fenceStartLine = -1; let fenceStartLine = -1;
// Pass 1: fence tracking to avoid interpreting list/table inside code blocks // Pass 1: fence tracking to avoid interpreting list/table inside code blocks
const excluded = new Array(lines.length).fill(false); const excluded = Array.from({ length: lines.length }).fill(false);
for (let i = 0; i < lines.length; i++) { for (const [i, line] of lines.entries()) {
const line = lines[i];
if (isFenceStart(line)) { if (isFenceStart(line)) {
if (!inFence) { if (inFence) {
inFence = true;
fenceStartLine = i;
} else {
// closing fence // closing fence
inFence = false; inFence = false;
fenceStartLine = -1; fenceStartLine = -1;
} else {
inFence = true;
fenceStartLine = i;
} }
excluded[i] = true; excluded[i] = true;
continue; continue;
@ -109,7 +107,19 @@ function checkFile(filePath) {
if (excluded[i]) { if (excluded[i]) {
if (isFenceStart(lines[i])) { if (isFenceStart(lines[i])) {
// Fence boundary // Fence boundary
if (!inFence) { if (inFence) {
// 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',
});
}
} else {
// opening // opening
inFence = true; inFence = true;
// language present? // language present?
@ -130,18 +140,6 @@ function checkFile(filePath) {
message: 'Missing blank line before code fence', 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; continue;
@ -211,8 +209,7 @@ function checkFile(filePath) {
const start = i; const start = i;
// scan forward while lines look like table lines // scan forward while lines look like table lines
let end = start; let end = start;
while (end < lines.length && isTableLine(lines[end])) end++; while (end < lines.length && !excluded[end] && isTableLine(lines[end])) end++;
// Require immediate previous line to be blank // Require immediate previous line to be blank
const prev = start - 1; const prev = start - 1;
if (prev >= 0 && !isBlank(lines[prev])) { if (prev >= 0 && !isBlank(lines[prev])) {
@ -292,3 +289,16 @@ if (require.main === module) {
} }
module.exports = { checkFile }; module.exports = { checkFile };
{
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 };

View File

@ -1,4 +1,3 @@
#!/usr/bin/env node
/** /**
* Fix Fence Languages - Add language identifiers to code fences * Fix Fence Languages - Add language identifiers to code fences
* *
@ -14,6 +13,7 @@
* Exit codes: * Exit codes:
* 0 -> No issues found or all fixed successfully * 0 -> No issues found or all fixed successfully
* 1 -> Issues found (dry-run mode) or errors during fix * 1 -> Issues found (dry-run mode) or errors during fix
* 2 -> Invalid usage (missing file arguments)
*/ */
const fs = require('node:fs'); const fs = require('node:fs');
@ -26,19 +26,17 @@ const DRY_RUN = process.argv.includes('--dry-run');
*/ */
function detectLanguage(content) { function detectLanguage(content) {
const trimmed = content.trim(); const trimmed = content.trim();
// Empty fence // Empty fence
if (!trimmed) return 'text'; if (!trimmed) return 'text';
// YAML detection // YAML detection
if (/^[a-zA-Z_][a-zA-Z0-9_-]*:\s*/.test(trimmed) || if (/^[a-zA-Z_][a-zA-Z0-9_-]*:\s*/.test(trimmed) || /^---\s*$/m.test(trimmed)) {
/^---\s*$/m.test(trimmed)) {
return 'yaml'; return 'yaml';
} }
// JSON detection // JSON detection
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try { try {
JSON.parse(trimmed); JSON.parse(trimmed);
return 'json'; return 'json';
@ -46,36 +44,36 @@ function detectLanguage(content) {
// Not valid JSON, continue // Not valid JSON, continue
} }
} }
// Shell/Bash detection // Shell/Bash detection
if (/^(npm|yarn|pnpm|git|node|npx|cd|mkdir|rm|cp|mv|ls|cat|echo|export|source|\$)\s/.test(trimmed) || if (
/^\$/.test(trimmed) || /^(npm|yarn|pnpm|git|node|npx|cd|mkdir|rm|cp|mv|ls|cat|echo|export|source|\$)\s/.test(trimmed) ||
/^#!\/bin\/(ba)?sh/.test(trimmed)) { /^\$/.test(trimmed) ||
/^#!\/bin\/(ba)?sh/.test(trimmed)
) {
return 'bash'; return 'bash';
} }
// JavaScript/TypeScript detection // JavaScript/TypeScript detection
if (/^(import|export|const|let|var|function|class|async|await)\s/.test(trimmed) || if (/^(import|export|const|let|var|function|class|async|await)\s/.test(trimmed) || /^\/\//.test(trimmed) || /^\/\*/.test(trimmed)) {
/^\/\//.test(trimmed) ||
/^\/\*/.test(trimmed)) {
return 'javascript'; return 'javascript';
} }
// XML/HTML detection // XML/HTML detection
if (/^<[a-zA-Z][^>]*>/.test(trimmed)) { if (/^<[a-zA-Z][^>]*>/.test(trimmed)) {
return 'xml'; return 'xml';
} }
// Markdown detection (for nested examples) // Markdown detection (for nested examples)
if (/^#{1,6}\s/.test(trimmed) || /^\[.*\]\(.*\)/.test(trimmed)) { if (/^#{1,6}\s/.test(trimmed) || /^\[.*\]\(.*\)/.test(trimmed)) {
return 'markdown'; return 'markdown';
} }
// Flow/diagram detection (arrows, boxes) // Flow/diagram detection (arrows, boxes)
if (/[→↓←↑]/.test(trimmed) || /[┌┐└┘├┤┬┴┼─│]/.test(trimmed)) { if (/[→↓←↑]/.test(trimmed) || /[┌┐└┘├┤┬┴┼─│]/.test(trimmed)) {
return 'text'; return 'text';
} }
// Default to text for unknown content // Default to text for unknown content
return 'text'; return 'text';
} }
@ -93,47 +91,51 @@ function fixFile(filePath) {
// Track any outer fence (of any backtick length >=3) to avoid touching nested content // Track any outer fence (of any backtick length >=3) to avoid touching nested content
const fenceStack = []; const fenceStack = [];
// State for a target triple-backtick fence without language that we intend to fix // State for a target fence (3+ backticks) without language that we intend to fix
let fixing = false; let fixing = false;
let fixFenceStart = -1; let fixFenceStart = -1;
let fixOpenIndent = ''; let fixOpenIndent = '';
let fixOpenTicks = '';
let fixOpenLen = 0;
let fenceContent = []; let fenceContent = [];
const newLines = []; const newLines = [];
for (let i = 0; i < lines.length; i++) { for (const [i, line] of lines.entries()) {
const line = lines[i];
// If we are currently fixing a fence (collecting content until closing ```) // If we are currently fixing a fence (collecting content until closing ```)
if (fixing) { if (fixing) {
const closeMatch = line.match(/^(\s*)(`{3})(\s*)$/); const closeMatch = line.match(/^(\s*)(`+)(\s*)$/);
if (closeMatch) { if (closeMatch) {
// Closing the target fence const closeTicks = closeMatch[2] || '';
const language = detectLanguage(fenceContent.join('\n')); // Only treat as closing if the number of backticks is >= opening length
const fixedOpenLine = `${fixOpenIndent}\`\`\`${language}`; if (closeTicks.length >= fixOpenLen) {
// Closing the target fence
const language = detectLanguage(fenceContent.join('\n'));
const fixedOpenLine = `${fixOpenIndent}\`\`\`${language}`;
newLines.push(fixedOpenLine); newLines.push(fixedOpenLine, ...fenceContent, line);
newLines.push(...fenceContent);
newLines.push(line);
fixes.push({ fixes.push({
line: fixFenceStart + 1, line: fixFenceStart + 1,
original: '```', original: fixOpenTicks,
fixed: fixedOpenLine, fixed: fixedOpenLine,
detectedLanguage: language, detectedLanguage: language,
contentPreview: fenceContent.slice(0, 2).join('\n').substring(0, 60) + '...' contentPreview: fenceContent.slice(0, 2).join('\n').slice(0, 60) + '...',
}); });
modified = true; modified = true;
fixing = false; fixing = false;
fixFenceStart = -1; fixFenceStart = -1;
fixOpenIndent = ''; fixOpenIndent = '';
fenceContent = []; fixOpenTicks = '';
continue; fixOpenLen = 0;
} else { fenceContent = [];
fenceContent.push(line); continue;
continue; }
} }
// Not a valid closing line yet; keep collecting content
fenceContent.push(line);
continue;
} }
// Not currently fixing; detect any fence line (opening or closing) // Not currently fixing; detect any fence line (opening or closing)
@ -141,37 +143,44 @@ function fixFile(filePath) {
if (fenceLineMatch) { if (fenceLineMatch) {
const indent = fenceLineMatch[1] || ''; const indent = fenceLineMatch[1] || '';
const ticks = fenceLineMatch[2] || ''; const ticks = fenceLineMatch[2] || '';
const rest = (fenceLineMatch[3] || '').trim(); const ticksLen = ticks.length;
const hasLanguage = rest.length > 0; // simplistic but effective for our cases const rest = fenceLineMatch[3] || '';
const restTrim = rest.trim();
const hasLanguage = restTrim.length > 0; // simplistic but effective for our cases
// Determine if this is a closing fence for the current outer fence // Determine if this is a closing fence for the current outer fence
if (fenceStack.length > 0 && fenceStack[fenceStack.length - 1].ticks === ticks) { if (fenceStack.length > 0) {
// Closing existing fence scope const top = fenceStack.at(-1);
fenceStack.pop(); if (restTrim === '' && ticksLen >= top.ticks.length) {
newLines.push(line); // Closing existing fence scope
continue; fenceStack.pop();
newLines.push(line);
continue;
}
} }
// If inside any outer fence, don't attempt to fix nested fences // If inside any outer fence, don't attempt to fix nested fences
if (fenceStack.length > 0) { if (fenceStack.length > 0) {
newLines.push(line); // Start a nested fence scope
// Start a nested fence scope if this appears to be an opening fence
fenceStack.push({ ticks }); fenceStack.push({ ticks });
newLines.push(line);
continue; continue;
} }
// Outside any fence // Outside any fence
if (ticks === '```' && !hasLanguage) { if (ticksLen >= 3 && restTrim === '') {
// Target: opening triple backtick without language; begin fixing mode // Opening fence without language (3+ backticks): begin fixing mode
fixing = true; fixing = true;
fixFenceStart = i; fixFenceStart = i;
fixOpenIndent = indent; fixOpenIndent = indent;
fixOpenTicks = ticks;
fixOpenLen = ticksLen;
fenceContent = []; fenceContent = [];
// Do not push the original opening line; we'll emit the fixed one at close // Do not push the original opening line; we'll emit the fixed one at close
continue; continue;
} }
// Any other fence (with language or more backticks): treat as an outer fence start // Any other fence: treat as an outer fence start
fenceStack.push({ ticks }); fenceStack.push({ ticks });
newLines.push(line); newLines.push(line);
continue; continue;
@ -187,7 +196,7 @@ function fixFile(filePath) {
filePath, filePath,
fixes: [], fixes: [],
modified: false, modified: false,
newContent: content newContent: content,
}; };
} }
@ -195,7 +204,7 @@ function fixFile(filePath) {
filePath, filePath,
fixes, fixes,
modified, modified,
newContent: newLines.join('\n') + (content.endsWith('\n') ? '\n' : '') newContent: newLines.join('\n') + (content.endsWith('\n') ? '\n' : ''),
}; };
} }
@ -203,67 +212,67 @@ function fixFile(filePath) {
* Main execution * Main execution
*/ */
function main() { function main() {
const args = process.argv.slice(2).filter(arg => arg !== '--dry-run'); const args = process.argv.slice(2).filter((arg) => arg !== '--dry-run');
if (args.length === 0) { if (args.length === 0) {
console.error('Usage: node tools/markdown/fix-fence-languages.js [--dry-run] <file1> [file2...]'); console.error('Usage: node tools/markdown/fix-fence-languages.js [--dry-run] <file1> [file2...]');
process.exit(2); process.exit(2);
} }
const results = []; const results = [];
let totalFixes = 0; let totalFixes = 0;
for (const filePath of args) { for (const filePath of args) {
const absPath = path.resolve(filePath); const absPath = path.resolve(filePath);
if (!fs.existsSync(absPath)) { if (!fs.existsSync(absPath)) {
console.error(`File not found: ${absPath}`); console.error(`File not found: ${absPath}`);
continue; continue;
} }
if (!absPath.toLowerCase().endsWith('.md')) { if (!absPath.toLowerCase().endsWith('.md')) {
console.error(`Skipping non-markdown file: ${absPath}`); console.error(`Skipping non-markdown file: ${absPath}`);
continue; continue;
} }
const result = fixFile(absPath); const result = fixFile(absPath);
if (result.fixes.length > 0) { if (result.fixes.length > 0) {
results.push(result); results.push(result);
totalFixes += result.fixes.length; totalFixes += result.fixes.length;
} }
} }
// Print results // Print results
if (results.length === 0) { if (results.length === 0) {
console.log('✓ No fence language issues found'); console.log('✓ No fence language issues found');
process.exit(0); process.exit(0);
} }
if (DRY_RUN) { if (DRY_RUN) {
console.log(`\n🔍 DRY RUN: Found ${totalFixes} fence(s) without language in ${results.length} file(s)\n`); console.log(`\n🔍 DRY RUN: Found ${totalFixes} fence(s) without language in ${results.length} file(s)\n`);
} else { } else {
console.log(`\n🔧 Fixing ${totalFixes} fence(s) in ${results.length} file(s)\n`); console.log(`\n🔧 Fixing ${totalFixes} fence(s) in ${results.length} file(s)\n`);
} }
for (const result of results) { for (const result of results) {
console.log(`📄 ${path.relative(process.cwd(), result.filePath)}`); console.log(`📄 ${path.relative(process.cwd(), result.filePath)}`);
for (const fix of result.fixes) { for (const fix of result.fixes) {
console.log(` L${fix.line.toString().padStart(4, ' ')} ${fix.original.trim() || '```'}`); console.log(` L${fix.line.toString().padStart(4, ' ')} ${fix.original.trim() || '```'}`);
console.log(`\`\`\`${fix.detectedLanguage}`); console.log(`\`\`\`${fix.detectedLanguage}`);
console.log(` Content: ${fix.contentPreview}`); console.log(` Content: ${fix.contentPreview}`);
} }
console.log(''); console.log('');
// Apply fixes if not dry-run // Apply fixes if not dry-run
if (!DRY_RUN) { if (!DRY_RUN) {
fs.writeFileSync(result.filePath, result.newContent, 'utf8'); fs.writeFileSync(result.filePath, result.newContent, 'utf8');
console.log(` ✓ Fixed and saved\n`); console.log(` ✓ Fixed and saved\n`);
} }
} }
if (DRY_RUN) { if (DRY_RUN) {
console.log('💡 Run without --dry-run to apply these fixes\n'); console.log('💡 Run without --dry-run to apply these fixes\n');
process.exit(1); process.exit(1);