281 lines
7.7 KiB
TypeScript
281 lines
7.7 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const repoRoot = path.resolve(__dirname, '../../../..');
|
|
const packagesRoot = path.join(repoRoot, 'packages');
|
|
|
|
type PackageKind =
|
|
| 'racing-domain'
|
|
| 'racing-application'
|
|
| 'racing-infrastructure'
|
|
| 'racing-demo-infrastructure'
|
|
| 'other';
|
|
|
|
interface TsFile {
|
|
filePath: string;
|
|
kind: PackageKind;
|
|
}
|
|
|
|
function classifyFile(filePath: string): PackageKind {
|
|
const normalized = filePath.replace(/\\/g, '/');
|
|
|
|
// Bounded-context domain lives under packages/racing/domain
|
|
if (normalized.includes('/packages/racing/domain/')) {
|
|
return 'racing-domain';
|
|
}
|
|
if (normalized.includes('/packages/racing-application/')) {
|
|
return 'racing-application';
|
|
}
|
|
if (normalized.includes('/packages/racing-infrastructure/')) {
|
|
return 'racing-infrastructure';
|
|
}
|
|
if (normalized.includes('/packages/racing-demo-infrastructure/')) {
|
|
return 'racing-demo-infrastructure';
|
|
}
|
|
|
|
return 'other';
|
|
}
|
|
|
|
function collectTsFiles(dir: string): TsFile[] {
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
const files: TsFile[] = [];
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...collectTsFiles(fullPath));
|
|
} else if (entry.isFile()) {
|
|
if (
|
|
entry.name.endsWith('.ts') ||
|
|
entry.name.endsWith('.tsx')
|
|
) {
|
|
const kind = classifyFile(fullPath);
|
|
if (kind !== 'other') {
|
|
files.push({ filePath: fullPath, kind });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
interface ImportViolation {
|
|
file: string;
|
|
line: number;
|
|
moduleSpecifier: string;
|
|
reason: string;
|
|
}
|
|
|
|
function extractImportModule(line: string): string | null {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith('import')) return null;
|
|
|
|
// Handle: import ... from 'x';
|
|
const fromMatch = trimmed.match(/from\s+['"](.*)['"]/);
|
|
if (fromMatch) {
|
|
return fromMatch[1];
|
|
}
|
|
|
|
// Handle: import 'x';
|
|
const sideEffectMatch = trimmed.match(/^import\s+['"](.*)['"]\s*;?$/);
|
|
if (sideEffectMatch) {
|
|
return sideEffectMatch[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
describe('Package dependency structure for racing slice', () => {
|
|
const tsFiles = collectTsFiles(packagesRoot);
|
|
|
|
it('enforces import boundaries for racing-domain', () => {
|
|
const violations: ImportViolation[] = [];
|
|
|
|
const forbiddenPrefixes = [
|
|
'@gridpilot/racing-application',
|
|
'@gridpilot/racing-infrastructure',
|
|
'@gridpilot/racing-demo-infrastructure',
|
|
'apps/',
|
|
'@/',
|
|
'react',
|
|
'next',
|
|
'electron',
|
|
];
|
|
|
|
for (const { filePath, kind } of tsFiles) {
|
|
if (kind !== 'racing-domain') continue;
|
|
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const lines = content.split(/\r?\n/);
|
|
|
|
lines.forEach((line, index) => {
|
|
const moduleSpecifier = extractImportModule(line);
|
|
if (!moduleSpecifier) return;
|
|
|
|
for (const prefix of forbiddenPrefixes) {
|
|
if (moduleSpecifier === prefix || moduleSpecifier.startsWith(prefix)) {
|
|
violations.push({
|
|
file: filePath,
|
|
line: index + 1,
|
|
moduleSpecifier,
|
|
reason: 'racing-domain must not depend on application, infrastructure, apps, or UI frameworks',
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
const message =
|
|
'Found forbidden imports in racing domain layer (packages/racing/domain):\n' +
|
|
violations
|
|
.map(
|
|
(v) =>
|
|
`- ${v.file}:${v.line} :: import '${v.moduleSpecifier}' // ${v.reason}`,
|
|
)
|
|
.join('\n');
|
|
expect(message).toBe('');
|
|
} else {
|
|
expect(violations).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it('enforces import boundaries for racing-application', () => {
|
|
const violations: ImportViolation[] = [];
|
|
|
|
const forbiddenPrefixes = [
|
|
'@gridpilot/racing-infrastructure',
|
|
'@gridpilot/racing-demo-infrastructure',
|
|
'apps/',
|
|
'@/',
|
|
];
|
|
|
|
const allowedPrefixes = [
|
|
'@gridpilot/racing',
|
|
'@gridpilot/shared-result',
|
|
'@gridpilot/identity',
|
|
];
|
|
|
|
for (const { filePath, kind } of tsFiles) {
|
|
if (kind !== 'racing-application') continue;
|
|
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const lines = content.split(/\r?\n/);
|
|
|
|
lines.forEach((line, index) => {
|
|
const moduleSpecifier = extractImportModule(line);
|
|
if (!moduleSpecifier) return;
|
|
|
|
for (const prefix of forbiddenPrefixes) {
|
|
if (moduleSpecifier === prefix || moduleSpecifier.startsWith(prefix)) {
|
|
violations.push({
|
|
file: filePath,
|
|
line: index + 1,
|
|
moduleSpecifier,
|
|
reason: 'racing-application must not depend on infrastructure or apps',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (moduleSpecifier.startsWith('@gridpilot/')) {
|
|
const isAllowed = allowedPrefixes.some((prefix) =>
|
|
moduleSpecifier === prefix || moduleSpecifier.startsWith(prefix),
|
|
);
|
|
if (!isAllowed) {
|
|
violations.push({
|
|
file: filePath,
|
|
line: index + 1,
|
|
moduleSpecifier,
|
|
reason: 'racing-application should only depend on domain, shared-result, or other domain packages',
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
const message =
|
|
'Found forbidden imports in packages/racing-application:\n' +
|
|
violations
|
|
.map(
|
|
(v) =>
|
|
`- ${v.file}:${v.line} :: import '${v.moduleSpecifier}' // ${v.reason}`,
|
|
)
|
|
.join('\n');
|
|
expect(message).toBe('');
|
|
} else {
|
|
expect(violations).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it('enforces import boundaries for racing infrastructure packages', () => {
|
|
const violations: ImportViolation[] = [];
|
|
|
|
const forbiddenPrefixes = ['apps/', '@/'];
|
|
|
|
const allowedPrefixes = [
|
|
'@gridpilot/racing',
|
|
'@gridpilot/shared-result',
|
|
'@gridpilot/testing-support',
|
|
'@gridpilot/social',
|
|
];
|
|
|
|
for (const { filePath, kind } of tsFiles) {
|
|
if (
|
|
kind !== 'racing-infrastructure' &&
|
|
kind !== 'racing-demo-infrastructure'
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const lines = content.split(/\r?\n/);
|
|
|
|
lines.forEach((line, index) => {
|
|
const moduleSpecifier = extractImportModule(line);
|
|
if (!moduleSpecifier) return;
|
|
|
|
for (const prefix of forbiddenPrefixes) {
|
|
if (moduleSpecifier === prefix || moduleSpecifier.startsWith(prefix)) {
|
|
violations.push({
|
|
file: filePath,
|
|
line: index + 1,
|
|
moduleSpecifier,
|
|
reason: 'racing infrastructure must not depend on apps or @/ aliases',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (moduleSpecifier.startsWith('@gridpilot/')) {
|
|
const isAllowed = allowedPrefixes.some((prefix) =>
|
|
moduleSpecifier === prefix || moduleSpecifier.startsWith(prefix),
|
|
);
|
|
if (!isAllowed) {
|
|
violations.push({
|
|
file: filePath,
|
|
line: index + 1,
|
|
moduleSpecifier,
|
|
reason: 'racing infrastructure should depend only on domain, shared-result, or testing-support',
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
const message =
|
|
'Found forbidden imports in racing infrastructure packages:\n' +
|
|
violations
|
|
.map(
|
|
(v) =>
|
|
`- ${v.file}:${v.line} :: import '${v.moduleSpecifier}' // ${v.reason}`,
|
|
)
|
|
.join('\n');
|
|
expect(message).toBe('');
|
|
} else {
|
|
expect(violations).toEqual([]);
|
|
}
|
|
});
|
|
}); |