2026-07-30 09:19:59 +03:00
|
|
|
import { readdir, readFile } from 'node:fs/promises'
|
|
|
|
|
import { dirname, join, relative, resolve } from 'node:path'
|
|
|
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
|
|
2026-08-10 09:12:22 +03:00
|
|
|
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}))$`)
|
2026-07-30 09:19:59 +03:00
|
|
|
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) {
|
2026-08-10 09:12:22 +03:00
|
|
|
const source = `${relative(repositoryRoot, file)}:${index + 1}`
|
2026-07-30 09:19:59 +03:00
|
|
|
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,
|
|
|
|
|
})
|
2026-08-10 09:12:22 +03:00
|
|
|
} else if (/^#{1,6}\s+SLM-/.test(line)) {
|
|
|
|
|
throw new Error(`Invalid SLM rule heading at ${relative(repositoryRoot, file)}:${index + 1}`)
|
2026-07-30 09:19:59 +03:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return rules
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parseReferences = async (file) => {
|
|
|
|
|
const content = await readFile(file, 'utf8')
|
|
|
|
|
const lines = content.split(/\r?\n/)
|
|
|
|
|
const references = []
|
|
|
|
|
|
|
|
|
|
visitMarkdownLines(lines, (line, index) => {
|
2026-08-10 09:12:22 +03:00
|
|
|
const source = `${relative(repositoryRoot, file)}:${index + 1}`
|
2026-07-30 09:19:59 +03:00
|
|
|
|
2026-08-10 09:12:22 +03:00
|
|
|
if (/^#{1,6}\s+SLM-/.test(line)) {
|
|
|
|
|
throw new Error(`SLM rule declaration is only allowed in docs/rules: ${source}`)
|
2026-07-30 09:19:59 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-10 09:12:22 +03:00
|
|
|
const documentationFiles = (await getMarkdownFiles(docsDirectory))
|
2026-07-30 09:19:59 +03:00
|
|
|
.filter((file) => !ruleFiles.includes(file))
|
|
|
|
|
const rules = (await Promise.all(ruleFiles.map(parseRules))).flat()
|
|
|
|
|
const rulesByCode = new Map()
|
2026-08-10 09:12:22 +03:00
|
|
|
const rulesByNumber = new Map()
|
2026-07-30 09:19:59 +03:00
|
|
|
|
|
|
|
|
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)
|
2026-07-30 13:22:45 +03:00
|
|
|
|
2026-08-10 09:12:22 +03:00
|
|
|
const duplicateNumber = rulesByNumber.get(rule.number)
|
2026-07-30 13:22:45 +03:00
|
|
|
|
|
|
|
|
if (duplicateNumber) {
|
|
|
|
|
throw new Error(
|
2026-08-10 09:12:22 +03:00
|
|
|
`Duplicate SLM rule number ${String(rule.number).padStart(3, '0')}: ${duplicateNumber.source}, ${rule.source}`,
|
2026-07-30 13:22:45 +03:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 09:12:22 +03:00
|
|
|
rulesByNumber.set(rule.number, rule)
|
2026-07-30 09:19:59 +03:00
|
|
|
}
|
|
|
|
|
|
2026-08-10 09:12:22 +03:00
|
|
|
const references = (await Promise.all(documentationFiles.map(parseReferences))).flat()
|
2026-07-30 09:19:59 +03:00
|
|
|
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)) {
|
2026-08-10 09:12:22 +03:00
|
|
|
throw new Error(`SLM rule ${rule.code} is not referenced by any documentation page`)
|
2026-07-30 09:19:59 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rules.sort((left, right) => (
|
2026-08-10 09:12:22 +03:00
|
|
|
left.number - right.number
|
2026-07-30 09:19:59 +03:00
|
|
|
|| 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)
|