186 lines
6.1 KiB
TypeScript
186 lines
6.1 KiB
TypeScript
/**
|
|
* Source-to-YAML dependency scanner
|
|
*
|
|
* Scans backend source code for cross-service HTTP call patterns and returns
|
|
* detected dependencies. Used by deployment-dependencies.test.ts to catch
|
|
* undeclared service dependencies before they cause startup ordering failures.
|
|
*
|
|
* Detects:
|
|
* - registry.services.get('x.y') -> service ID
|
|
* - registry.getPort('x') -> feature.api
|
|
* - *_API_HOST/PORT/URL env vars -> service ID
|
|
* - getDatabaseConfig('x') -> feature.postgresql
|
|
* - getRedisConfig('x') / getRedisUrl('x') -> feature.redis
|
|
*
|
|
* Filters out:
|
|
* - Self-references (target = source feature)
|
|
* - Test files (.spec.ts, .test.ts, /test/ dirs)
|
|
*/
|
|
|
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
export interface DetectedDependency {
|
|
/** Fully qualified source service ID (e.g., "messaging.api") */
|
|
sourceServiceId: string;
|
|
/** Fully qualified target service ID (e.g., "profile.api") */
|
|
targetServiceId: string;
|
|
/** Pattern that triggered detection */
|
|
pattern: string;
|
|
/** Absolute path to the file where the pattern was found */
|
|
filePath: string;
|
|
/** Line number (1-indexed) where the pattern was found */
|
|
line: number;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Detection patterns
|
|
// =============================================================================
|
|
|
|
interface PatternDetector {
|
|
name: string;
|
|
regex: RegExp;
|
|
/** Extract target service ID from regex match. Return null to skip. */
|
|
extract: (match: RegExpMatchArray) => string | null;
|
|
}
|
|
|
|
const PATTERNS: PatternDetector[] = [
|
|
{
|
|
name: 'registry.services.get',
|
|
regex: /registry\.services\.get\(['"]([^'"]+)['"]\)/g,
|
|
extract: (m) => m[1] ?? null,
|
|
},
|
|
{
|
|
name: 'registry.getPort',
|
|
regex: /registry\.getPort\(['"]([^'"]+)['"]\)/g,
|
|
extract: (m) => `${m[1]!}.api`,
|
|
},
|
|
{
|
|
name: 'ENV_API_HOST/PORT/URL',
|
|
regex: /(\w+)_API_(?:HOST|PORT|URL)/g,
|
|
extract: (m) => {
|
|
const prefix = m[1]!.toLowerCase().replace(/_/g, '-');
|
|
return `${prefix}.api`;
|
|
},
|
|
},
|
|
{
|
|
name: 'getDatabaseConfig',
|
|
regex: /getDatabaseConfig\(['"]([^'"]+)['"]\)/g,
|
|
extract: (m) => `${m[1]!}.postgresql`,
|
|
},
|
|
{
|
|
name: 'getRedisConfig/getRedisUrl',
|
|
regex: /get(?:Redis(?:Config|Url))\(['"]([^'"]+)['"]\)/g,
|
|
extract: (m) => `${m[1]!}.redis`,
|
|
},
|
|
];
|
|
|
|
/** Service types that are infrastructure-tier (internal to the feature, always co-declared) */
|
|
const INFRA_TYPES = new Set(['postgresql', 'redis', 'minio']);
|
|
|
|
// =============================================================================
|
|
// Scanner
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Scan a service's source code for cross-service HTTP/infrastructure dependencies.
|
|
*
|
|
* @param sourceServiceId - Fully qualified ID of the scanning service (e.g., "messaging.api")
|
|
* @param entrypoint - Relative path to the service's code directory (e.g., "codebase/features/messaging/backend-api")
|
|
* @param projectRoot - Absolute path to the project root
|
|
* @returns Detected dependencies with source locations
|
|
*/
|
|
export function scanServiceDependencies(
|
|
sourceServiceId: string,
|
|
entrypoint: string,
|
|
projectRoot: string,
|
|
): DetectedDependency[] {
|
|
const srcDir = join(projectRoot, entrypoint, 'src');
|
|
if (!existsSync(srcDir)) return [];
|
|
|
|
const sourceFeatureId = sourceServiceId.split('.').slice(0, -1).join('.');
|
|
const files = findTsFiles(srcDir);
|
|
const seen = new Set<string>();
|
|
const results: DetectedDependency[] = [];
|
|
|
|
for (const filePath of files) {
|
|
// Skip test files
|
|
if (isTestFile(filePath)) continue;
|
|
|
|
const content = readFileSync(filePath, 'utf-8');
|
|
|
|
for (const detector of PATTERNS) {
|
|
// Reset regex state for each file
|
|
const regex = new RegExp(detector.regex.source, detector.regex.flags);
|
|
|
|
let match: RegExpMatchArray | null;
|
|
while ((match = regex.exec(content)) !== null) {
|
|
const targetServiceId = detector.extract(match);
|
|
if (!targetServiceId) continue;
|
|
|
|
// Extract feature ID from target
|
|
const targetFeatureId = targetServiceId.split('.').slice(0, -1).join('.');
|
|
const targetServiceType = targetServiceId.split('.').pop() ?? '';
|
|
|
|
// Filter: skip self-references
|
|
if (targetFeatureId === sourceFeatureId) continue;
|
|
|
|
// Filter: skip infrastructure-tier lookups (getDatabaseConfig/getRedisConfig for own feature)
|
|
if (INFRA_TYPES.has(targetServiceType)) continue;
|
|
|
|
// Deduplicate: one entry per source->target pair
|
|
const dedupeKey = `${sourceServiceId}->${targetServiceId}`;
|
|
if (seen.has(dedupeKey)) continue;
|
|
seen.add(dedupeKey);
|
|
|
|
// Calculate line number
|
|
const lineNumber = content.substring(0, match.index).split('\n').length;
|
|
|
|
results.push({
|
|
sourceServiceId,
|
|
targetServiceId,
|
|
pattern: detector.name,
|
|
filePath,
|
|
line: lineNumber,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// =============================================================================
|
|
// File discovery helpers
|
|
// =============================================================================
|
|
|
|
function isTestFile(filePath: string): boolean {
|
|
return (
|
|
filePath.includes('.spec.') ||
|
|
filePath.includes('.test.') ||
|
|
filePath.includes('/test/') ||
|
|
filePath.includes('/__tests__/')
|
|
);
|
|
}
|
|
|
|
function findTsFiles(dir: string): string[] {
|
|
const results: string[] = [];
|
|
try {
|
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = join(dir, entry.name);
|
|
if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== 'dist') {
|
|
results.push(...findTsFiles(fullPath));
|
|
} else if (entry.isFile() && entry.name.endsWith('.ts')) {
|
|
results.push(fullPath);
|
|
}
|
|
}
|
|
} catch {
|
|
// Directory may not exist or be inaccessible
|
|
}
|
|
return results;
|
|
}
|