mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 07:30:16 +03:00
sync
This commit is contained in:
228
scripts/check-docs.mjs
Normal file
228
scripts/check-docs.mjs
Normal file
@@ -0,0 +1,228 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { dirname, join, relative, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const docsDirectory = join(repositoryRoot, 'docs')
|
||||
const rulesDirectory = join(docsDirectory, 'rules')
|
||||
const ruleCodeSource = 'SLM-[A-Z][A-Z_]{1,31}-[AR]\\d{3}'
|
||||
const ruleHeadingPattern = new RegExp(`^###\\s+(?<code>SLM-(?<group>[A-Z][A-Z_]{1,31})-(?<classification>[AR])(?<number>\\d{3}))$`)
|
||||
const ruleCodePattern = new RegExp(`\\b${ruleCodeSource}\\b`, 'g')
|
||||
const ruleReferencePattern = new RegExp(
|
||||
'\\[`(?<code>' + ruleCodeSource + ')`\\]\\((?<target>[^)\\s]+)\\)',
|
||||
'g',
|
||||
)
|
||||
const ruleTitlePattern = /^>\s+\*\*(?<title>\S(?:.*\S)?)\*\*\s*$/
|
||||
const ruleDescriptionPattern = /^>\s+(?<description>\S(?:.*\S)?)\s*$/
|
||||
const quoteSeparatorPattern = /^>\s*$/
|
||||
|
||||
const getMarkdownFiles = async (directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const files = []
|
||||
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const path = join(directory, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await getMarkdownFiles(path)))
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
files.push(path)
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
const visitMarkdownLines = (lines, visitor) => {
|
||||
let fence = null
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/)
|
||||
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[1]
|
||||
|
||||
if (fence === null) {
|
||||
fence = marker
|
||||
} else if (marker[0] === fence[0] && marker.length >= fence.length) {
|
||||
fence = null
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (fence !== null) {
|
||||
continue
|
||||
}
|
||||
|
||||
visitor(line, index)
|
||||
}
|
||||
}
|
||||
|
||||
const parseRules = async (file) => {
|
||||
const content = await readFile(file, 'utf8')
|
||||
const lines = content.split(/\r?\n/)
|
||||
const rules = []
|
||||
|
||||
visitMarkdownLines(lines, (line, index) => {
|
||||
const match = line.match(ruleHeadingPattern)
|
||||
|
||||
if (match?.groups) {
|
||||
const source = `${relative(repositoryRoot, file)}:${index + 1}`
|
||||
const titleMatch = lines[index + 2]?.match(ruleTitlePattern)
|
||||
const descriptionMatch = lines[index + 4]?.match(ruleDescriptionPattern)
|
||||
|
||||
if (lines[index + 1]?.trim() !== '') {
|
||||
throw new Error(`SLM rule ${match.groups.code} must be followed by a blank line at ${source}`)
|
||||
}
|
||||
|
||||
if (!titleMatch?.groups) {
|
||||
throw new Error(`SLM rule ${match.groups.code} must have a one-line bold title in blockquote at ${source}`)
|
||||
}
|
||||
|
||||
if (!quoteSeparatorPattern.test(lines[index + 3] ?? '')) {
|
||||
throw new Error(`SLM rule ${match.groups.code} must separate title and description with a quoted blank line at ${source}`)
|
||||
}
|
||||
|
||||
if (!descriptionMatch?.groups || /^>/.test(lines[index + 5] ?? '')) {
|
||||
throw new Error(`SLM rule ${match.groups.code} must have a one-line blockquote description at ${source}`)
|
||||
}
|
||||
|
||||
rules.push({
|
||||
code: match.groups.code,
|
||||
classification: match.groups.classification,
|
||||
description: descriptionMatch.groups.description.trim(),
|
||||
file,
|
||||
number: Number(match.groups.number),
|
||||
title: titleMatch.groups.title.trim(),
|
||||
source,
|
||||
})
|
||||
} else if (/^#{1,6}\s+SLM-/.test(line)) {
|
||||
throw new Error(`Invalid SLM rule heading at ${relative(repositoryRoot, file)}:${index + 1}`)
|
||||
}
|
||||
})
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
const parseReferences = async (file) => {
|
||||
const content = await readFile(file, 'utf8')
|
||||
const lines = content.split(/\r?\n/)
|
||||
const references = []
|
||||
|
||||
visitMarkdownLines(lines, (line, index) => {
|
||||
const source = `${relative(repositoryRoot, file)}:${index + 1}`
|
||||
|
||||
if (/^#{1,6}\s+SLM-/.test(line)) {
|
||||
throw new Error(`SLM rule declaration is only allowed in docs/rules: ${source}`)
|
||||
}
|
||||
|
||||
const codeMatches = [...line.matchAll(ruleCodePattern)]
|
||||
const linkMatches = [...line.matchAll(ruleReferencePattern)]
|
||||
|
||||
for (const codeMatch of codeMatches) {
|
||||
const linkMatch = linkMatches.find((candidate) => (
|
||||
candidate.groups?.code === codeMatch[0]
|
||||
&& codeMatch.index >= candidate.index
|
||||
&& codeMatch.index < candidate.index + candidate[0].length
|
||||
))
|
||||
|
||||
if (!linkMatch?.groups) {
|
||||
throw new Error(`SLM rule reference must be a Markdown link at ${source}: ${codeMatch[0]}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const linkMatch of linkMatches) {
|
||||
const { code, target } = linkMatch.groups
|
||||
const hashIndex = target.lastIndexOf('#')
|
||||
|
||||
if (hashIndex < 1 || target.slice(hashIndex + 1) !== code.toLowerCase()) {
|
||||
throw new Error(`Invalid anchor for SLM rule ${code} at ${source}`)
|
||||
}
|
||||
|
||||
references.push({
|
||||
code,
|
||||
file,
|
||||
source,
|
||||
targetFile: resolve(dirname(file), target.slice(0, hashIndex)),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
const printSection = (title, rules) => {
|
||||
console.log(`${title} (${rules.length})`)
|
||||
|
||||
for (const [index, rule] of rules.entries()) {
|
||||
console.log(`${rule.code}: ${rule.title}`)
|
||||
console.log(` ${rule.description}`)
|
||||
|
||||
if (index < rules.length - 1) {
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ruleFiles = await getMarkdownFiles(rulesDirectory)
|
||||
const documentationFiles = (await getMarkdownFiles(docsDirectory))
|
||||
.filter((file) => !ruleFiles.includes(file))
|
||||
const rules = (await Promise.all(ruleFiles.map(parseRules))).flat()
|
||||
const rulesByCode = new Map()
|
||||
const rulesByNumber = new Map()
|
||||
|
||||
for (const rule of rules) {
|
||||
const duplicate = rulesByCode.get(rule.code)
|
||||
|
||||
if (duplicate) {
|
||||
throw new Error(`Duplicate SLM rule ${rule.code}: ${duplicate.source}, ${rule.source}`)
|
||||
}
|
||||
|
||||
rulesByCode.set(rule.code, rule)
|
||||
|
||||
const duplicateNumber = rulesByNumber.get(rule.number)
|
||||
|
||||
if (duplicateNumber) {
|
||||
throw new Error(
|
||||
`Duplicate SLM rule number ${String(rule.number).padStart(3, '0')}: ${duplicateNumber.source}, ${rule.source}`,
|
||||
)
|
||||
}
|
||||
|
||||
rulesByNumber.set(rule.number, rule)
|
||||
}
|
||||
|
||||
const references = (await Promise.all(documentationFiles.map(parseReferences))).flat()
|
||||
const referencedCodes = new Set()
|
||||
|
||||
for (const reference of references) {
|
||||
const rule = rulesByCode.get(reference.code)
|
||||
|
||||
if (!rule) {
|
||||
throw new Error(`Unknown SLM rule ${reference.code} at ${reference.source}`)
|
||||
}
|
||||
|
||||
if (reference.targetFile !== rule.file) {
|
||||
throw new Error(`SLM rule ${reference.code} points to a non-canonical file at ${reference.source}`)
|
||||
}
|
||||
|
||||
referencedCodes.add(reference.code)
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!referencedCodes.has(rule.code)) {
|
||||
throw new Error(`SLM rule ${rule.code} is not referenced by any documentation page`)
|
||||
}
|
||||
}
|
||||
|
||||
rules.sort((left, right) => (
|
||||
left.number - right.number
|
||||
|| left.code.localeCompare(right.code)
|
||||
))
|
||||
|
||||
const automaticRules = rules.filter((rule) => rule.classification === 'A')
|
||||
const reviewRules = rules.filter((rule) => rule.classification === 'R')
|
||||
|
||||
printSection('АВТОМАТИЧЕСКИЕ', automaticRules)
|
||||
console.log()
|
||||
printSection('ДЛЯ РЕВЬЮ', reviewRules)
|
||||
Reference in New Issue
Block a user