mirror of
https://github.com/cita-777/metapi.git
synced 2026-11-01 03:53:43 +08:00
374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
import { lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { dirname, extname, relative, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import process from 'node:process';
|
|
|
|
type ReportFormat = 'text' | 'markdown';
|
|
type Severity = 'violation' | 'tracked_debt';
|
|
|
|
type DriftFinding = {
|
|
ruleId: string;
|
|
severity: Severity;
|
|
file: string;
|
|
line: number;
|
|
message: string;
|
|
excerpt: string;
|
|
};
|
|
|
|
type DriftReport = {
|
|
root: string;
|
|
generatedAt: string;
|
|
violations: DriftFinding[];
|
|
trackedDebt: DriftFinding[];
|
|
};
|
|
|
|
type RuleSpec = {
|
|
id: string;
|
|
description: string;
|
|
fileFilter: (file: string) => boolean;
|
|
lineMatch: (line: string, file: string) => boolean;
|
|
message: string | ((file: string, line: string) => string);
|
|
allowlistedFiles?: Set<string>;
|
|
};
|
|
|
|
type RunOptions = {
|
|
root: string;
|
|
};
|
|
|
|
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']);
|
|
const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.less']);
|
|
|
|
// Keep this empty: proxy-core helpers now have neutral owners under services.
|
|
// Any future route-layer import is a new violation, not tracked debt.
|
|
const ROUTES_PROXY_IMPORT_ALLOWLIST = new Set<string>();
|
|
|
|
// TokensPanel now lives under the tokens domain, so no top-level page import
|
|
// debt remains. Keep this ratchet empty so a new page-to-page dependency is a
|
|
// visible violation that cannot be hidden by an allowlist.
|
|
const TOP_LEVEL_PAGE_IMPORT_ALLOWLIST = new Set<string>();
|
|
|
|
// Third-party interaction primitives are intentionally hidden behind the
|
|
// Metapi-owned components/ui boundary. Keep this list small and explicit so a
|
|
// future UI dependency cannot silently leak into routed pages or domain
|
|
// components. The rule is source-based by design: it catches static imports,
|
|
// require calls and dynamic imports without trying to resolve the module graph.
|
|
const WEB_UI_IMPORT_PATTERN = /(?:\bfrom\s+|\bimport\s*(?:\(|\s)|\brequire\s*\()["'](?:@radix-ui\/|@mui\/|@chakra-ui\/|@headlessui\/|@mantine\/|@ariakit\/|antd(?:\/|["']))/;
|
|
const WEB_STYLE_RUNTIME_IMPORT_PATTERN = /(?:from\s+|import\s*\(|require\s*\()["'](?:styled-components|@emotion\/(?:react|styled)|@vanilla-extract\/|[^"']+\.module\.(?:css|scss|less))["']/;
|
|
|
|
function isWebUiWrapperFile(file: string): boolean {
|
|
return file.startsWith('src/web/components/ui/');
|
|
}
|
|
|
|
function normalizeRelativePath(root: string, fullPath: string): string {
|
|
return relative(root, fullPath).replaceAll('\\', '/');
|
|
}
|
|
|
|
function walkFiles(root: string, currentDir = root): string[] {
|
|
const entries = readdirSync(currentDir).sort((left, right) => left.localeCompare(right, 'en'));
|
|
const files: string[] = [];
|
|
|
|
for (const entry of entries) {
|
|
if (entry === '.git' || entry === 'node_modules' || entry === 'dist' || entry === 'coverage') {
|
|
continue;
|
|
}
|
|
|
|
const fullPath = resolve(currentDir, entry);
|
|
const stat = lstatSync(fullPath);
|
|
if (stat.isSymbolicLink()) {
|
|
continue;
|
|
}
|
|
if (stat.isDirectory()) {
|
|
files.push(...walkFiles(root, fullPath));
|
|
continue;
|
|
}
|
|
|
|
const extension = extname(entry);
|
|
if (SOURCE_EXTENSIONS.has(extension) || STYLE_EXTENSIONS.has(extension)) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
function readWorkspaceLines(root: string, file: string): Array<{ lineNumber: number; text: string }> {
|
|
const source = readFileSync(resolve(root, file), 'utf8').replaceAll('\r\n', '\n');
|
|
return source.split('\n').map((text, index) => ({
|
|
lineNumber: index + 1,
|
|
text,
|
|
}));
|
|
}
|
|
|
|
function isNonTestSource(file: string): boolean {
|
|
return !file.endsWith('.test.ts')
|
|
&& !file.endsWith('.test.tsx')
|
|
&& !file.endsWith('.test.js')
|
|
&& !file.endsWith('.test.jsx');
|
|
}
|
|
|
|
function isTopLevelPageFile(file: string): boolean {
|
|
return /^src\/web\/pages\/[^/]+\.(ts|tsx|js|jsx)$/.test(file)
|
|
&& isNonTestSource(file);
|
|
}
|
|
|
|
function createRules(): RuleSpec[] {
|
|
return [
|
|
{
|
|
id: 'transformers-route-blind',
|
|
description: 'Transformers must not import route-layer proxy helpers',
|
|
fileFilter: (file) => file.startsWith('src/server/transformers/')
|
|
&& isNonTestSource(file),
|
|
lineMatch: (line) => /from\s+['"][^'"]*routes\/proxy\//.test(line),
|
|
message: 'transformer imports route-layer proxy code',
|
|
},
|
|
{
|
|
id: 'proxy-surface-body-read',
|
|
description: 'Proxy-core surfaces should use readRuntimeResponseText() for whole-body reads',
|
|
fileFilter: (file) => file.startsWith('src/server/proxy-core/surfaces/')
|
|
&& isNonTestSource(file),
|
|
lineMatch: (line) => /\.text\(/.test(line),
|
|
message: 'proxy-core surface reads a full upstream body via .text()',
|
|
},
|
|
{
|
|
id: 'proxy-core-routes-proxy-import',
|
|
description: 'Proxy-core imports from routes/proxy are tracked debt and should not grow',
|
|
fileFilter: (file) => file.startsWith('src/server/proxy-core/')
|
|
&& isNonTestSource(file),
|
|
lineMatch: (line) => /from\s+['"][^'"]*routes\/proxy\//.test(line),
|
|
message: 'proxy-core imports a helper from routes/proxy',
|
|
allowlistedFiles: ROUTES_PROXY_IMPORT_ALLOWLIST,
|
|
},
|
|
{
|
|
id: 'web-page-to-page-import',
|
|
description: 'Top-level route pages should not import other top-level route pages',
|
|
fileFilter: (file) => isTopLevelPageFile(file),
|
|
lineMatch: (line, file) => {
|
|
const match = line.match(/from\s+['"]\.\/([^/'"]+?)(?:\.(?:js|ts|tsx|jsx))?['"]/);
|
|
if (!match) return false;
|
|
const importedPage = match[1];
|
|
const currentPage = file.replace(/^src\/web\/pages\//, '').replace(/\.(ts|tsx|js|jsx)$/, '');
|
|
return importedPage !== currentPage;
|
|
},
|
|
message: (file, line) => {
|
|
const match = line.match(/from\s+['"](\.\/[^'"]+)['"]/);
|
|
const imported = match?.[1] ?? 'another page file';
|
|
return `top-level page imports ${imported}`;
|
|
},
|
|
allowlistedFiles: TOP_LEVEL_PAGE_IMPORT_ALLOWLIST,
|
|
},
|
|
{
|
|
id: 'web-ui-third-party-import',
|
|
description: 'Web pages and domain components must use the Metapi-owned UI wrapper boundary',
|
|
fileFilter: (file) => file.startsWith('src/web/')
|
|
&& isNonTestSource(file)
|
|
&& !isWebUiWrapperFile(file),
|
|
lineMatch: (line) => WEB_UI_IMPORT_PATTERN.test(line),
|
|
message: 'web source imports a third-party UI primitive outside src/web/components/ui',
|
|
},
|
|
{
|
|
id: 'web-style-runtime-import',
|
|
description: 'Web production code must use the shared CSS token/class entrypoint',
|
|
fileFilter: (file) => file.startsWith('src/web/') && isNonTestSource(file),
|
|
lineMatch: (line) => WEB_STYLE_RUNTIME_IMPORT_PATTERN.test(line),
|
|
message: 'web source imports a page-specific style runtime or CSS module',
|
|
},
|
|
{
|
|
id: 'web-tailwind-entry',
|
|
description: 'Tailwind must remain a single shared entrypoint',
|
|
fileFilter: (file) => file.startsWith('src/web/')
|
|
&& STYLE_EXTENSIONS.has(extname(file))
|
|
&& file !== 'src/web/index.css',
|
|
lineMatch: (line) => /@import\s+["']tailwindcss["']/.test(line),
|
|
message: 'web stylesheet imports Tailwind outside src/web/index.css',
|
|
},
|
|
];
|
|
}
|
|
|
|
export function runRepoDriftCheck(options: Partial<RunOptions> = {}): DriftReport {
|
|
const root = resolve(options.root ?? process.cwd());
|
|
const report: DriftReport = {
|
|
root,
|
|
generatedAt: new Date().toISOString(),
|
|
violations: [],
|
|
trackedDebt: [],
|
|
};
|
|
|
|
const files = walkFiles(root).map((file) => normalizeRelativePath(root, file));
|
|
const rules = createRules();
|
|
|
|
for (const rule of rules) {
|
|
for (const file of files) {
|
|
if (!rule.fileFilter(file)) continue;
|
|
|
|
for (const { lineNumber, text } of readWorkspaceLines(root, file)) {
|
|
if (!rule.lineMatch(text, file)) continue;
|
|
|
|
const finding: DriftFinding = {
|
|
ruleId: rule.id,
|
|
severity: rule.allowlistedFiles?.has(file) ? 'tracked_debt' : 'violation',
|
|
file,
|
|
line: lineNumber,
|
|
message: typeof rule.message === 'function' ? rule.message(file, text) : rule.message,
|
|
excerpt: text.trim(),
|
|
};
|
|
|
|
if (finding.severity === 'tracked_debt') {
|
|
report.trackedDebt.push(finding);
|
|
} else {
|
|
report.violations.push(finding);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return report;
|
|
}
|
|
|
|
function formatFindingText(finding: DriftFinding): string {
|
|
return `- [${finding.ruleId}] ${finding.file}:${finding.line} ${finding.message}\n ${finding.excerpt}`;
|
|
}
|
|
|
|
function formatFindingMarkdownRow(finding: DriftFinding): string {
|
|
const escapedMessage = finding.message.replaceAll('|', '\\|');
|
|
const escapedExcerpt = finding.excerpt.replaceAll('|', '\\|');
|
|
return `| \`${finding.ruleId}\` | \`${finding.file}:${finding.line}\` | ${escapedMessage} | \`${escapedExcerpt}\` |`;
|
|
}
|
|
|
|
export function formatRepoDriftReport(report: DriftReport, format: ReportFormat = 'text'): string {
|
|
if (format === 'markdown') {
|
|
const lines: string[] = [
|
|
'# Repo Drift Report',
|
|
'',
|
|
`- Root: \`${report.root}\``,
|
|
`- Generated at: \`${report.generatedAt}\``,
|
|
`- Violations: **${report.violations.length}**`,
|
|
`- Tracked debt: **${report.trackedDebt.length}**`,
|
|
'',
|
|
];
|
|
|
|
if (report.violations.length > 0) {
|
|
lines.push('## Violations', '', '| Rule | Location | Message | Excerpt |', '| --- | --- | --- | --- |');
|
|
for (const finding of report.violations) {
|
|
lines.push(formatFindingMarkdownRow(finding));
|
|
}
|
|
lines.push('');
|
|
} else {
|
|
lines.push('## Violations', '', 'No new violations found.', '');
|
|
}
|
|
|
|
if (report.trackedDebt.length > 0) {
|
|
lines.push('## Tracked Debt', '', '| Rule | Location | Message | Excerpt |', '| --- | --- | --- | --- |');
|
|
for (const finding of report.trackedDebt) {
|
|
lines.push(formatFindingMarkdownRow(finding));
|
|
}
|
|
lines.push('');
|
|
} else {
|
|
lines.push('## Tracked Debt', '', 'No tracked debt entries were observed.', '');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
const lines: string[] = [
|
|
`Repo drift report for ${report.root}`,
|
|
`Generated at ${report.generatedAt}`,
|
|
'',
|
|
`Violations: ${report.violations.length}`,
|
|
];
|
|
|
|
if (report.violations.length > 0) {
|
|
lines.push(...report.violations.map(formatFindingText));
|
|
} else {
|
|
lines.push('- none');
|
|
}
|
|
|
|
lines.push('', `Tracked debt: ${report.trackedDebt.length}`);
|
|
if (report.trackedDebt.length > 0) {
|
|
lines.push(...report.trackedDebt.map(formatFindingText));
|
|
} else {
|
|
lines.push('- none');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
type CliOptions = {
|
|
format: ReportFormat;
|
|
output?: string;
|
|
reportOnly: boolean;
|
|
root?: string;
|
|
};
|
|
|
|
function parseCliOptions(argv: string[]): CliOptions {
|
|
const options: CliOptions = {
|
|
format: 'text',
|
|
reportOnly: false,
|
|
};
|
|
|
|
const readRequiredValue = (flag: string, index: number): string => {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith('--')) {
|
|
throw new Error(`${flag} requires a value`);
|
|
}
|
|
return value;
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === '--format') {
|
|
const value = readRequiredValue(arg, index);
|
|
if (value !== 'text' && value !== 'markdown') {
|
|
throw new Error(`--format must be one of: text, markdown`);
|
|
}
|
|
options.format = value;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === '--output') {
|
|
options.output = readRequiredValue(arg, index);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === '--report-only') {
|
|
options.reportOnly = true;
|
|
continue;
|
|
}
|
|
if (arg === '--root') {
|
|
options.root = readRequiredValue(arg, index);
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function maybeWriteReport(outputPath: string | undefined, contents: string): void {
|
|
if (!outputPath) return;
|
|
const resolved = resolve(outputPath);
|
|
mkdirSync(dirname(resolved), { recursive: true });
|
|
writeFileSync(resolved, contents);
|
|
}
|
|
|
|
const isMainModule = (() => {
|
|
try {
|
|
return process.argv[1] != null && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
if (isMainModule) {
|
|
try {
|
|
const options = parseCliOptions(process.argv.slice(2));
|
|
const report = runRepoDriftCheck({ root: options.root });
|
|
const contents = formatRepoDriftReport(report, options.format);
|
|
maybeWriteReport(options.output, contents);
|
|
console.log(contents);
|
|
process.exit(report.violations.length > 0 && !options.reportOnly ? 1 : 0);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(message);
|
|
process.exit(1);
|
|
}
|
|
}
|