UNPKG

archunit

Version:

ArchUnit TypeScript is an architecture testing library, to specify and assert architecture rules in your TypeScript app

256 lines 14.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.extractGraph = exports.clearGraphCache = exports.getProjectFiles = exports.guessLocationOfTsconfig = void 0; const typescript_1 = __importDefault(require("typescript")); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const errors_1 = require("../error/errors"); const path_utils_1 = require("../util/path-utils"); const ts_helpers_1 = require("@zerollup/ts-helpers"); const import_kinds_helper_1 = require("../util/import-kinds-helper"); const __1 = require(".."); // Constant to control whether node_modules files should be excluded from the graph // TODO: introduce a .archignore file instead a la .gitignore const EXCLUDE_NODE_MODULES = true; const EXCLUDE_DIST = true; // Logger instance for debugging graph extraction const logger = __1.sharedLogger; const guessLocationOfTsconfig = (options) => { logger?.debug(options?.logging, 'Starting tsconfig.json discovery from current directory'); const result = guessLocationOfTsconfigRecursively('.'); if (result) { logger?.debug(options?.logging, `Found tsconfig.json at: ${result}`); } else { logger?.warn(options?.logging, 'No tsconfig.json found in current directory tree'); } return result; }; exports.guessLocationOfTsconfig = guessLocationOfTsconfig; const guessLocationOfTsconfigRecursively = (pathName, options) => { logger?.debug(options?.logging, `Searching for tsconfig.json in directory: ${path_1.default.resolve(pathName)}`); const dir = fs_1.default.readdirSync(pathName); logger?.debug(options?.logging, `Directory contents: ${dir.join(', ')}`); // First check if tsconfig exists in the current directory const tsconfigFileName = dir.find((fileName) => path_1.default.basename(fileName) === 'tsconfig.json'); if (tsconfigFileName) { const resolvedPath = path_1.default.resolve(pathName, 'tsconfig.json'); logger?.debug(options?.logging, `Found tsconfig.json at: ${resolvedPath}`); return resolvedPath; } // If not, go up one level const levelUp = path_1.default.resolve(pathName, '..'); logger?.debug(options?.logging, `tsconfig.json not found, moving up to: ${levelUp}`); // Stop if we've reached the filesystem root if (path_1.default.relative(levelUp, pathName) === '') { logger?.debug(options?.logging, 'Reached filesystem root, stopping search'); return undefined; } // Continue recursively return guessLocationOfTsconfigRecursively(levelUp); }; const getProjectFiles = (rootDir, compilerHost, config, options) => { logger?.debug(options?.logging, `Getting project files from root directory: ${rootDir}`); logger?.debug(options?.logging, `Include patterns: ${JSON.stringify(config.include)}`); logger?.debug(options?.logging, `Exclude patterns: ${JSON.stringify(config.exclude)}`); if (!compilerHost.readDirectory) { const error = 'compiler host is missing readDirectory method'; logger?.error(options?.logging, error); throw new errors_1.TechnicalError(error); } const files = compilerHost.readDirectory(rootDir, ['ts', 'tsx'], // TODO: include js config.exclude ?? [], config.include ?? []); if (!files) { const error = 'compiler could not resolve project files'; logger?.error(options?.logging, error); throw new errors_1.TechnicalError(error); } logger?.info(options?.logging, `Found ${files.length} TypeScript files in project`); logger?.debug(options?.logging, `Project files: ${files.slice(0, 10).join(', ')}${files.length > 10 ? `... and ${files.length - 10} more` : ''}`); return files; }; exports.getProjectFiles = getProjectFiles; const graphCache = new Map(); const clearGraphCache = (options) => { const cacheSize = graphCache.size; graphCache.clear(); logger?.debug(options?.logging, `Cleared graph cache (previously contained ${cacheSize} entries)`); }; exports.clearGraphCache = clearGraphCache; const extractGraph = async (configFileName, options) => { logger?.debug(options?.logging, `Starting graph extraction with config: ${configFileName || 'auto-detected'}`); if (options?.clearCache) { logger?.debug(options?.logging, 'Clearing graph cache'); (0, exports.clearGraphCache)(); } const cachedResult = graphCache.get(configFileName); if (cachedResult) { logger?.debug(options?.logging, 'Using cached graph extraction result'); return cachedResult; } logger?.debug(options?.logging, 'No cached result found, computing new graph'); const computedResult = extractGraphUncached(configFileName, options); graphCache.set(configFileName, computedResult); const result = await computedResult; logger?.info(options?.logging, `Graph extraction completed with ${result.length} edges`); return result; }; exports.extractGraph = extractGraph; const extractGraphUncached = async (configFileName, options) => { logger?.debug(options?.logging, 'Starting uncached graph extraction'); const configFile = configFileName ?? (0, exports.guessLocationOfTsconfig)(options); if (!configFile) { const error = 'Could not find configuration path'; logger?.error(options?.logging, error); throw new errors_1.TechnicalError(error); } logger?.info(options?.logging, `Using TypeScript config file: ${configFile}`); const config = typescript_1.default.readConfigFile(configFile, (path) => { logger?.debug(options?.logging, `Reading config file: ${path}`); return fs_1.default.readFileSync(path).toString(); }); if (config.error) { logger?.error(options?.logging, `Invalid config file: ${config.error.messageText}`); throw new errors_1.TechnicalError('invalid config path'); } logger?.debug(options?.logging, 'Successfully parsed TypeScript configuration'); const parsedConfig = config.config; logger?.debug(options?.logging, `Compiler options: ${JSON.stringify(parsedConfig, null, 2).slice(0, 500)}...`); const rootDir = path_1.default.dirname(path_1.default.resolve(configFile)); logger?.info(options?.logging, `Project root directory: ${rootDir}`); const compilerHost = typescript_1.default.createCompilerHost(parsedConfig); logger?.debug(options?.logging, 'Created TypeScript compiler host'); const files = (0, exports.getProjectFiles)(rootDir, compilerHost, config?.config); logger?.debug(options?.logging, 'Creating TypeScript program'); const program = typescript_1.default.createProgram({ rootNames: files ?? [], options: parsedConfig, host: compilerHost, }); const sourceFiles = program.getSourceFiles(); logger?.info(options?.logging, `TypeScript program created with ${sourceFiles.length} source files`); // Filter out files from node_modules for logging purposes const projectFiles = sourceFiles.filter((sf) => !sf.fileName.includes('node_modules')); const nodeModulesFiles = sourceFiles.filter((sf) => sf.fileName.includes('node_modules')); logger?.debug(options?.logging, `Project source files: ${projectFiles.length}`); logger?.debug(options?.logging, `Node modules files: ${nodeModulesFiles.length}`); if (projectFiles.length === 0) { logger?.warn(options?.logging, 'No project source files found - this might indicate a configuration issue'); } const imports = []; let processedFiles = 0; let skippedImports = 0; let erroredImports = 0; logger?.debug(options?.logging, 'Starting import analysis'); for (const sourceFile of program.getSourceFiles()) { const isProjectFile = !sourceFile.fileName.includes('node_modules'); if (isProjectFile) { processedFiles++; logger?.debug(options?.logging, `Processing file ${processedFiles}/${projectFiles.length}: ${path_1.default.relative(rootDir, sourceFile.fileName)}`); } typescript_1.default.forEachChild(sourceFile, (x) => { if (!typescript_1.default.isImportDeclaration(x)) { return; } try { const normalizedSourceFileName = path_1.default.relative(rootDir, sourceFile.fileName); const specifier = x.moduleSpecifier; const module = specifier['text']; if (module === undefined) { logger?.debug(options?.logging, `Skipping import with undefined module specifier in ${normalizedSourceFileName}`); skippedImports++; return; } logger?.debug(options?.logging, `Processing import: "${module}" in ${normalizedSourceFileName}`); const resolver = new ts_helpers_1.ImportPathsResolver( // eslint-disable-next-line @typescript-eslint/no-explicit-any parsedConfig.compilerOptions); const suggestion = resolver.getImportSuggestions(module, path_1.default.dirname(normalizedSourceFileName)); const bestGuess = suggestion !== undefined ? suggestion[0] : undefined; if (bestGuess && bestGuess !== module) { logger?.debug(options?.logging, `Import path resolved from "${module}" to "${bestGuess}"`); } // TODO Might use some module resolution cache in future const resolvedModule = typescript_1.default.resolveModuleName(bestGuess ?? module, sourceFile.fileName, parsedConfig, compilerHost).resolvedModule; if (resolvedModule === undefined) { logger?.debug(options?.logging, `Could not resolve module "${module}" from ${normalizedSourceFileName}`); skippedImports++; return; } const { resolvedFileName, isExternalLibraryImport } = resolvedModule; const normalizedTargetFileName = path_1.default.relative(rootDir, resolvedFileName); logger?.debug(options?.logging, `Resolved "${module}" to: ${normalizedTargetFileName} (external: ${isExternalLibraryImport})`); // Skip node_modules files if configured so if (EXCLUDE_NODE_MODULES && normalizedTargetFileName.startsWith('node_modules')) { logger?.debug(options?.logging, `Excluding node_modules file: ${normalizedTargetFileName}`); skippedImports++; return; } // Skip dist files if configured so if (EXCLUDE_DIST && normalizedTargetFileName.startsWith('dist')) { logger?.debug(options?.logging, `Excluding dist file: ${normalizedTargetFileName}`); skippedImports++; return; } const importKinds = (0, import_kinds_helper_1.determineImportKinds)(x); const edge = { source: (0, path_utils_1.normalizeWindowsPaths)(normalizedSourceFileName), target: (0, path_utils_1.normalizeWindowsPaths)(normalizedTargetFileName), external: isExternalLibraryImport ?? false, importKinds: importKinds, }; imports.push(edge); if (imports.length % 100 === 0) { logger?.debug(options?.logging, `Processed ${imports.length} imports so far...`); } } catch (importError) { // Skip this import if there's an error processing it erroredImports++; logger?.warn(options?.logging, `Error processing import in ${path_1.default.relative(rootDir, sourceFile.fileName)}: ${importError}`); } }); } // Add self-referencing edges for all project files to ensure they appear in the graph // This is crucial for files that don't import other project files logger?.debug(options?.logging, 'Adding self-referencing edges for all project files'); for (const sourceFile of projectFiles) { const normalizedFileName = (0, path_utils_1.normalizeWindowsPaths)(path_1.default.relative(rootDir, sourceFile.fileName)); // Skip node_modules files if configured so //console.log('resolvedFileName:', resolvedFileName); //console.log('normalizedTargetFileName:', normalizedTargetFileName); if (EXCLUDE_NODE_MODULES && normalizedFileName.startsWith('node_modules')) { logger?.debug(options?.logging, `Excluding node_modules file: ${normalizedFileName}`); skippedImports++; continue; } // Skip dist files if configured so if (EXCLUDE_DIST && normalizedFileName.startsWith('dist')) { logger?.debug(options?.logging, `Excluding dist file: ${normalizedFileName}`); skippedImports++; continue; } // Add self-referencing edge for every project file const selfEdge = { source: normalizedFileName, target: normalizedFileName, external: false, importKinds: [], // Self-reference has no import kinds }; imports.push(selfEdge); } if (projectFiles.length === 0) { logger?.warn(options?.logging, 'No project files found - this might indicate a configuration or file discovery issue'); } logger?.info(options?.logging, `Import analysis completed:`); logger?.info(options?.logging, ` - Total edges found: ${imports.length}`); logger?.info(options?.logging, ` - Files processed: ${processedFiles}`); logger?.info(options?.logging, ` - Imports skipped: ${skippedImports}`); logger?.info(options?.logging, ` - Import errors: ${erroredImports}`); return imports; }; //# sourceMappingURL=extract-graph.js.map