blob: 504bb34c740fdc595cf4b729ab878e58d26209a2 [file]
#!/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,
};