// 声明 lint:把"只在生成瞬间跑的闸门"变成全生命周期都能用的工具。 // // 为什么(doc/soma 功能强化.md §2.2):同一把尺子应该量三种声明——AI 现产的、 // 人手写的、仓库里存量的。现在闸门只在生成链内部跑,人写的统一声明与历史发布 // 无人看守。把内核复用出来(不复制第二份规则),配 CLI 与编程接口, // 让"声明质量"从一次性检查变成可持续治理。 // // 分级:错误级 = 闸门全集(不修就不能上线);警告级 = 规范建议(中文表头、页面来源、 // 数据逼真度…),不阻断但会在报告里点名。 import { GENERATED_PAGE_MARKER, hasGeneratedPageMarker, validatePageDeclarations, type PageCatalogEntry, type PageDeclarations, type ValidateDeclarationsOptions, } from './PageDeclarationValidator'; export type LintSeverity = 'error' | 'warn'; export interface LintFinding { severity: LintSeverity; /** 规则编号:E## 错误级 / W## 警告级 */ rule: string; message: string; } export interface LintReport { name: string; findings: LintFinding[]; errorCount: number; warnCount: number; passed: boolean; } export interface LintDeclarationOptions extends ValidateDeclarationsOptions {} /** * 这不是 Tailwind 完整语法解析器,只识别生成页骨架最常见的一组页面级 utility。 * 命中后只给人工建议,绝不把普通 Host 的技术选型升级成错误。 */ const TAILWIND_PAGE_UTILITY = /(?:^|\s)(?:min-h-screen|max-w-[\w-]+|mx-auto|grid(?:-cols-[\w-]+)?|space-y-[\w-]+|bg-(?:white|gray|slate)-\d{2,3}|rounded(?:-[\w-]+)?|shadow(?:-[\w-]+)?|p[trblxy]?-[\d.]+)(?:\s|$)/; function hasManualTailwindPageShell(html: string): boolean { for (const match of html.matchAll(/\b(?:class|className)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) { if (TAILWIND_PAGE_UTILITY.test(match[1] ?? match[2] ?? '')) return true; } return false; } /** * 警告级规则:规范建议,不阻断上线。 * * Tailwind 页面来源只认 `GENERATED_PAGE_MARKER="true"`,判据与结构闸门共用 * `hasGeneratedPageMarker`(不以某一个 Tailwind class 猜测来源):生成器已经把 * class 词汇收口并由闸门核过,不能再把它误报成"逃逸"。没有标记(或标记为 false) * 的手写页仍可得到建议性提示;普通 Host 不写 Tailwind 时不受影响。 */ const WARN_RULES: Array<{ rule: string; check: (decl: PageDeclarations, catalog: PageCatalogEntry[]) => string | null }> = [ { rule: 'W03', check: (decl) => { // 只看字面量文案:page.xxx / 行变量.xxx 是绑定路径,不是给用户看的文字 const englishHeaders = (decl.uiYaml.match(/label:\s*['"]?[A-Za-z_][A-Za-z0-9_.]{2,}['"]?/g) ?? []) .filter((line) => !/:\s*['"]?[A-Za-z_][A-Za-z0-9_]*\./.test(line)); return englishHeaders.length > 0 ? `表头/标签疑似英文字段名(${englishHeaders.slice(0, 2).join(', ')}),面向用户的文案应为中文` : null; }, }, { rule: 'W04', // 键名可能带引号(YAML 序列化产物是 "page.rows": []),故用 "?:" 容错 check: (decl) => (/(rows|items|list|data)"?:\s*\[\s*\]/i.test(decl.uiYaml) ? '存在空数组数据(空列表会渲成空白,演示数据应给 3~5 条)' : null), }, { rule: 'W05', check: (decl) => (decl.html.includes('data-soma-host') ? null : 'html 缺少 data-soma-host 边界(错误级已覆盖,此处提示以便人工修复)'), }, { rule: 'W06', check: (decl) => { if (hasGeneratedPageMarker(decl.html) || !hasManualTailwindPageShell(decl.html)) return null; return `检测到未标记的手写 Tailwind 页面壳;若这是 AI 生成页,请保留 ${GENERATED_PAGE_MARKER} 标记并经 BlueprintCompiler 输出` + ';若是 Host 自管页面,此项仅为建议'; }, }, ]; /** * 校验一份声明:错误级复用生成链闸门内核,警告级叠加规范建议。 */ export function lintDeclaration( decl: PageDeclarations, catalog: PageCatalogEntry[], name = '(未命名声明)', options: LintDeclarationOptions = {}, ): LintReport { const findings: LintFinding[] = []; // 错误级:与生成闸门同一把尺子(单一事实源,规则不复制) for (const issue of validatePageDeclarations(decl, catalog, options)) { findings.push({ severity: 'error', rule: 'E01', message: issue }); } // 警告级 for (const rule of WARN_RULES) { const message = rule.check(decl, catalog); if (message) { findings.push({ severity: 'warn', rule: rule.rule, message }); } } const errorCount = findings.filter((f) => f.severity === 'error').length; const warnCount = findings.length - errorCount; return { name, findings, errorCount, warnCount, passed: errorCount === 0 }; } /** 批量校验(声明仓库巡检 / 目录扫描) */ export function lintDeclarations( items: Array<{ name: string; declaration: PageDeclarations; lintOptions?: LintDeclarationOptions }>, catalog: PageCatalogEntry[], options: LintDeclarationOptions = {}, ): { reports: LintReport[]; errorTotal: number; warnTotal: number; passed: boolean } { const reports = items.map((item) => lintDeclaration( item.declaration, catalog, item.name, item.lintOptions ?? options, )); const errorTotal = reports.reduce((sum, r) => sum + r.errorCount, 0); const warnTotal = reports.reduce((sum, r) => sum + r.warnCount, 0); return { reports, errorTotal, warnTotal, passed: errorTotal === 0 }; } /** 人读报告(CLI 输出) */ export function formatLintReport(reports: LintReport[], strict = false): string { const lines: string[] = []; for (const report of reports) { if (report.findings.length === 0) { lines.push(`✓ ${report.name}`); continue; } lines.push(`${report.passed && !strict ? '⚠' : '✗'} ${report.name}`); for (const finding of report.findings) { lines.push(` ${finding.severity === 'error' ? 'E' : 'W'} [${finding.rule}] ${finding.message}`); } } const errorTotal = reports.reduce((sum, r) => sum + r.errorCount, 0); const warnTotal = reports.reduce((sum, r) => sum + r.warnCount, 0); lines.push(''); lines.push(`共 ${reports.length} 份声明:错误 ${errorTotal},警告 ${warnTotal}`); return lines.join('\n'); }