| #!/usr/bin/env node | |
| const path = require('path'); | |
| const { buildManifest } = require('./manifest'); | |
| const { ensureDirForFile, getChangedFiles, parseArgs } = require('./lib'); | |
| const { | |
| extractFencedCodeBlocks, | |
| filterFindings, | |
| findSection, | |
| hasMarkdownTableWithColumns, | |
| makeFinding, | |
| parseMarkdownSections, | |
| readMarkdownEntry, | |
| severityForEntry, | |
| textOutsideFencedCode, | |
| } = require('./doc-quality-utils'); | |
| const fs = require('fs'); | |
| const REQUIRED_SECTIONS = [ | |
| { label: 'Description', aliases: ['Description', '描述'] }, | |
| { label: 'Syntax', aliases: ['Syntax', '语法'] }, | |
| { label: 'Parameters', aliases: ['Parameters', 'Parameter', '参数'] }, | |
| { label: 'Return Value', aliases: ['Return Value', 'Return Values', '返回值'] }, | |
| { label: 'Example', aliases: ['Example', 'Examples', '示例'] }, | |
| ]; | |
| const PARAMETER_TABLE_COLUMNS = [ | |
| ['Parameter', '参数'], | |
| ['Description', '描述'], | |
| ]; | |
| const RETURN_TYPE_RE = /\b(?:type|boolean|bool|tinyint|smallint|int|integer|bigint|largeint|float|double|decimal|string|char|varchar|text|date|datetime|time|json|array|map|struct|bitmap|hll|quantile_state|variant|ip|ipv4|ipv6|number|numeric)\b|类型/i; | |
| const NULL_CONDITION_RE = /\bnull\b|空值|空/i; | |
| function isSqlFunctionEntry(entry) { | |
| return entry.doc_type === 'sql_function'; | |
| } | |
| function validateRequiredSections(entry, sections, severity) { | |
| const findings = []; | |
| const found = REQUIRED_SECTIONS.map((section) => ({ | |
| ...section, | |
| matched: findSection(sections, section.aliases), | |
| })); | |
| const missing = found.filter((section) => !section.matched); | |
| if (missing.length > 0) { | |
| findings.push( | |
| makeFinding( | |
| entry, | |
| severity, | |
| 'sql-function-section-order', | |
| 1, | |
| `SQL function docs must contain sections in this order: ${REQUIRED_SECTIONS.map((section) => section.label).join(', ')}. Missing: ${missing.map((section) => section.label).join(', ')}.`, | |
| ), | |
| ); | |
| return findings; | |
| } | |
| for (let index = 1; index < found.length; index += 1) { | |
| if (found[index].matched.line <= found[index - 1].matched.line) { | |
| findings.push( | |
| makeFinding( | |
| entry, | |
| severity, | |
| 'sql-function-section-order', | |
| found[index].matched.line, | |
| `SQL function sections are out of order. Expected: ${REQUIRED_SECTIONS.map((section) => section.label).join(', ')}.`, | |
| ), | |
| ); | |
| break; | |
| } | |
| } | |
| return findings; | |
| } | |
| function validateSyntax(entry, section, severity) { | |
| if (!section) { | |
| return []; | |
| } | |
| const blocks = extractFencedCodeBlocks(section.content); | |
| const prose = textOutsideFencedCode(section.content).trim(); | |
| const hasSingleSqlBlock = blocks.length === 1 && blocks[0].lang === 'sql' && prose.length === 0; | |
| if (hasSingleSqlBlock) { | |
| return []; | |
| } | |
| return [ | |
| makeFinding( | |
| entry, | |
| severity, | |
| 'sql-function-syntax-sql-code-block', | |
| section.line, | |
| 'Syntax section must contain only one fenced sql code block.', | |
| ), | |
| ]; | |
| } | |
| function validateParameters(entry, section, severity) { | |
| if (!section || hasMarkdownTableWithColumns(section.content, PARAMETER_TABLE_COLUMNS)) { | |
| return []; | |
| } | |
| return [ | |
| makeFinding( | |
| entry, | |
| severity, | |
| 'sql-function-parameters-table', | |
| section.line, | |
| 'Parameters section must be a Markdown table with Parameter and Description columns.', | |
| ), | |
| ]; | |
| } | |
| function validateReturnValue(entry, section, severity) { | |
| if (!section) { | |
| return []; | |
| } | |
| const findings = []; | |
| const text = textOutsideFencedCode(section.content).replace(/\s+/g, ' ').trim(); | |
| if (!RETURN_TYPE_RE.test(text)) { | |
| findings.push( | |
| makeFinding( | |
| entry, | |
| severity, | |
| 'sql-function-return-type', | |
| section.line, | |
| 'Return Value section must state the returned type.', | |
| ), | |
| ); | |
| } | |
| if (!NULL_CONDITION_RE.test(text)) { | |
| findings.push( | |
| makeFinding( | |
| entry, | |
| severity, | |
| 'sql-function-return-null', | |
| section.line, | |
| 'Return Value section must document NULL return conditions, including when the function never returns NULL.', | |
| ), | |
| ); | |
| } | |
| return findings; | |
| } | |
| function validateExamples(entry, section, severity) { | |
| if (!section) { | |
| return []; | |
| } | |
| const blocks = extractFencedCodeBlocks(section.content); | |
| const findings = []; | |
| const sqlBlocks = blocks.filter((block) => block.lang === 'sql'); | |
| if (sqlBlocks.length === 0) { | |
| findings.push( | |
| makeFinding( | |
| entry, | |
| severity, | |
| 'sql-function-example-output', | |
| section.line, | |
| 'Example section must include at least one fenced sql query followed by a fenced text output block.', | |
| ), | |
| ); | |
| return findings; | |
| } | |
| for (let index = 0; index < blocks.length; index += 1) { | |
| if (blocks[index].lang !== 'sql') { | |
| continue; | |
| } | |
| const nextBlock = blocks[index + 1]; | |
| if (!nextBlock || nextBlock.lang !== 'text') { | |
| findings.push( | |
| makeFinding( | |
| entry, | |
| severity, | |
| 'sql-function-example-output', | |
| section.line + blocks[index].startLine, | |
| 'Every fenced sql query in Example must be followed by a fenced text block with expected output.', | |
| ), | |
| ); | |
| } | |
| } | |
| return findings; | |
| } | |
| function lintSqlFunctionDocs(options = {}) { | |
| const rootDir = options.rootDir || process.cwd(); | |
| const manifest = options.manifest || buildManifest({ rootDir }); | |
| const findings = []; | |
| for (const entry of manifest.entries.filter(isSqlFunctionEntry)) { | |
| const markdown = readMarkdownEntry(rootDir, entry); | |
| if (!markdown) { | |
| continue; | |
| } | |
| const severity = severityForEntry(entry); | |
| const sections = parseMarkdownSections(markdown.content); | |
| const syntaxSection = findSection(sections, REQUIRED_SECTIONS[1].aliases); | |
| const parametersSection = findSection(sections, REQUIRED_SECTIONS[2].aliases); | |
| const returnValueSection = findSection(sections, REQUIRED_SECTIONS[3].aliases); | |
| const exampleSection = findSection(sections, REQUIRED_SECTIONS[4].aliases); | |
| findings.push( | |
| ...validateRequiredSections(entry, sections, severity), | |
| ...validateSyntax(entry, syntaxSection, severity), | |
| ...validateParameters(entry, parametersSection, severity), | |
| ...validateReturnValue(entry, returnValueSection, severity), | |
| ...validateExamples(entry, exampleSection, severity), | |
| ); | |
| } | |
| return findings; | |
| } | |
| 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 manifest = buildManifest({ rootDir }); | |
| const findings = filterFindings(lintSqlFunctionDocs({ rootDir, manifest }), changedFiles); | |
| const output = JSON.stringify({ schema_version: 1, findings }, null, 2); | |
| if (args.output) { | |
| const outputPath = path.resolve(rootDir, args.output); | |
| ensureDirForFile(outputPath); | |
| fs.writeFileSync(outputPath, `${output}\n`, 'utf8'); | |
| } else { | |
| process.stdout.write(`${output}\n`); | |
| } | |
| if (args['fail-on-findings'] && findings.length > 0) { | |
| process.exitCode = 1; | |
| } | |
| } | |
| if (require.main === module) { | |
| runCli(); | |
| } | |
| module.exports = { | |
| REQUIRED_SECTIONS, | |
| lintSqlFunctionDocs, | |
| }; |