| #!/usr/bin/env node | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const { buildManifest } = require('./manifest'); | |
| const { lintFrontmatter } = require('./lint-frontmatter'); | |
| const { lintMarkdownStructure } = require('./lint-markdown-structure'); | |
| const { lintSidebar } = require('./lint-sidebar'); | |
| const { lintLinks } = require('./lint-links'); | |
| const { lintSeo } = require('./lint-seo'); | |
| const { lintSqlFunctionDocs } = require('./lint-sql-function-docs'); | |
| const { lintFeatureDocs } = require('./lint-feature-docs'); | |
| const { lintI18nSync } = require('./lint-i18n-sync'); | |
| const { ensureDirForFile, getChangedFiles, getChangedRecords, parseArgs } = require('./lib'); | |
| function filterFindings(findings, changedFiles) { | |
| if (!changedFiles || changedFiles.length === 0) { | |
| return findings; | |
| } | |
| const changed = new Set(changedFiles); | |
| return findings.filter((finding) => { | |
| const related = finding.related_paths || []; | |
| return ( | |
| changed.has(finding.path) || | |
| changed.has(finding.path.replace(/^\.\//, '')) || | |
| related.some((relatedPath) => changed.has(relatedPath)) | |
| ); | |
| }); | |
| } | |
| function runChecks(options = {}) { | |
| const rootDir = options.rootDir || process.cwd(); | |
| const changedFiles = options.changedFiles || null; | |
| const changedRecords = options.changedRecords || []; | |
| const manifest = buildManifest({ rootDir }); | |
| const findings = filterFindings( | |
| [ | |
| ...lintFrontmatter({ rootDir, manifest }), | |
| ...lintMarkdownStructure({ rootDir, manifest }), | |
| ...lintSidebar({ rootDir, manifest }), | |
| ...lintLinks({ rootDir, manifest, changedFiles: changedFiles || [], changedRecords }), | |
| ...lintSeo({ rootDir, manifest }), | |
| ...lintSqlFunctionDocs({ rootDir, manifest }), | |
| ...lintFeatureDocs({ rootDir, manifest }), | |
| ...lintI18nSync({ rootDir, manifest, changedFiles }), | |
| ], | |
| changedFiles, | |
| ); | |
| const bySeverity = findings.reduce((acc, finding) => { | |
| acc[finding.severity] = (acc[finding.severity] || 0) + 1; | |
| return acc; | |
| }, {}); | |
| return { | |
| schema_version: 1, | |
| generated_at: new Date().toISOString(), | |
| summary: { | |
| entries: manifest.entries.length, | |
| findings: findings.length, | |
| by_severity: bySeverity, | |
| changed_only: Boolean(changedFiles), | |
| }, | |
| manifest, | |
| findings, | |
| }; | |
| } | |
| function escapeAnnotation(value) { | |
| return String(value) | |
| .replace(/%/g, '%25') | |
| .replace(/\r/g, '%0D') | |
| .replace(/\n/g, '%0A') | |
| .replace(/:/g, '%3A') | |
| .replace(/,/g, '%2C'); | |
| } | |
| function printGithubAnnotations(findings) { | |
| for (const finding of findings) { | |
| const level = finding.severity === 'error' | |
| ? 'error' | |
| : finding.severity === 'info' | |
| ? 'notice' | |
| : 'warning'; | |
| const file = escapeAnnotation(finding.path); | |
| const line = finding.line || 1; | |
| const title = escapeAnnotation(finding.rule); | |
| const message = escapeAnnotation(`${finding.message} Owner: ${finding.owner || 'unknown'}`); | |
| process.stdout.write(`::${level} file=${file},line=${line},title=${title}::${message}\n`); | |
| } | |
| } | |
| function runCli() { | |
| const args = parseArgs(process.argv.slice(2)); | |
| const rootDir = args.root ? path.resolve(args.root) : process.cwd(); | |
| const changedFiles = args.changed ? getChangedFiles(rootDir) : args.files ? args.files.split(',') : null; | |
| const changedRecords = args.changed ? getChangedRecords(rootDir) : []; | |
| const report = runChecks({ rootDir, changedFiles, changedRecords }); | |
| const format = args.format || 'github'; | |
| if (format === 'github') { | |
| printGithubAnnotations(report.findings); | |
| process.stdout.write( | |
| `Docs governance report: ${report.summary.findings} findings across ${report.summary.entries} manifest entries.\n`, | |
| ); | |
| } else { | |
| process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); | |
| } | |
| if (args.output) { | |
| const outputPath = path.resolve(rootDir, args.output); | |
| ensureDirForFile(outputPath); | |
| fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); | |
| } | |
| if (args['fail-on-findings'] && report.findings.length > 0) { | |
| process.exitCode = 1; | |
| } | |
| } | |
| if (require.main === module) { | |
| runCli(); | |
| } | |
| module.exports = { | |
| escapeAnnotation, | |
| printGithubAnnotations, | |
| runChecks, | |
| }; |