platform-tooling/run/core/source-dependency-scanner.ts
2026-03-02 21:06:53 -08:00

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;
}