Files
slm-design/scripts/check-docs.mjs

119 lines
4.0 KiB
JavaScript

import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
collectMarkdownFiles,
collectRules,
RULE_ID_PATTERN_SOURCE,
RULE_LEVEL_PATTERN_SOURCE,
} from './lib/specification.mjs'
const specificationRoot = fileURLToPath(
new URL('../docs/ru/specification/', import.meta.url),
)
const declarationPattern = new RegExp(
`^\\*\\*(${RULE_ID_PATTERN_SOURCE}) - (${RULE_LEVEL_PATTERN_SOURCE})\\.\\*\\*`,
'gm',
)
const declarationLinePattern = new RegExp(
`^\\*\\*(${RULE_ID_PATTERN_SOURCE}) - (${RULE_LEVEL_PATTERN_SOURCE})\\.\\*\\*`,
)
const referencePattern = new RegExp(`\\b${RULE_ID_PATTERN_SOURCE}\\b`, 'g')
const legacyBaseReferencePattern = /\bSLM-(?!BASE-|ADV-|PRO-)[A-Z][A-Z0-9]*-\d{3}\b/g
function lineNumberAt(content, index) {
return content.slice(0, index).split('\n').length
}
const files = await collectMarkdownFiles(specificationRoot)
const registry = await collectRules(specificationRoot)
const declarations = new Map()
const references = []
const errors = []
const ruleCounts = { BASE: 0, ADV: 0, PRO: 0 }
for (const file of files) {
const content = await readFile(file, 'utf8')
const relativePath = path.relative(specificationRoot, file).split(path.sep).join('/')
const lines = content.split('\n')
for (const [index, line] of lines.entries()) {
if (line.startsWith('**SLM-') && !declarationLinePattern.test(line)) {
errors.push(`${relativePath}:${index + 1}: malformed rule declaration`)
}
}
for (const match of content.matchAll(declarationPattern)) {
const id = match[1]
const location = `${relativePath}:${lineNumberAt(content, match.index)}`
const existingLocation = declarations.get(id)
if (existingLocation) {
errors.push(`${location}: duplicate ${id}; first declared at ${existingLocation}`)
} else {
declarations.set(id, location)
ruleCounts[id.split('-')[1]] += 1
}
if (id.startsWith('SLM-BASE-') && relativePath.startsWith('modes/')) {
errors.push(`${location}: base rule ${id} cannot be declared in an overlay`)
} else if (id.startsWith('SLM-ADV-') && !relativePath.startsWith('modes/advanced/')) {
errors.push(`${location}: ${id} must be declared under modes/advanced`)
} else if (id.startsWith('SLM-PRO-') && !relativePath.startsWith('modes/pro/')) {
errors.push(`${location}: ${id} must be declared under modes/pro`)
}
}
for (const match of content.matchAll(referencePattern)) {
references.push({
id: match[0],
location: `${relativePath}:${lineNumberAt(content, match.index)}`,
})
}
for (const match of content.matchAll(legacyBaseReferencePattern)) {
errors.push(
`${relativePath}:${lineNumberAt(content, match.index)}: legacy base rule ID ${match[0]}`,
)
}
if (relativePath.startsWith('modes/advanced/') && /\bSLM-PRO-[A-Z-]+-\d{3}\b/.test(content)) {
errors.push(`${relativePath}: Advanced overlay references a Pro rule`)
}
if (relativePath.startsWith('modes/pro/') && /\bSLM-ADV-[A-Z-]+-\d{3}\b/.test(content)) {
errors.push(`${relativePath}: Pro overlay references an Advanced rule`)
}
}
for (const reference of references) {
if (!declarations.has(reference.id)) {
errors.push(`${reference.location}: unknown rule reference ${reference.id}`)
}
}
if (registry.length !== declarations.size) {
errors.push(
`rule registry contains ${registry.length} records, but validator found ${declarations.size} declarations`,
)
}
for (const rule of registry) {
if (!declarations.has(rule.id)) {
errors.push(`${rule.relativePath}:${rule.line}: registry contains undeclared rule ${rule.id}`)
}
}
if (errors.length > 0) {
console.error(`Documentation check failed with ${errors.length} error(s):`)
for (const error of errors) console.error(`- ${error}`)
process.exitCode = 1
} else {
console.log(
`Documentation check passed: ${files.length} files, ${declarations.size} rules `
+ `(${ruleCounts.BASE} base, ${ruleCounts.ADV} advanced, ${ruleCounts.PRO} pro), `
+ `${references.length} rule occurrences.`,
)
}