import { promises as fs } from "node:fs"; import path from "node:path"; export function normalizeRepoPath(repoRoot, filePath) { return path.relative(repoRoot, filePath).split(path.sep).join("/"); } export function resolveRepoSpecifier(repoRoot, specifier, importerFile) { if (specifier.startsWith(".")) { return normalizeRepoPath(repoRoot, path.resolve(path.dirname(importerFile), specifier)); } if (specifier.startsWith("/")) { return normalizeRepoPath(repoRoot, specifier); } return null; } export function visitModuleSpecifiers(ts, sourceFile, visit) { function walk(node) { if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { visit({ kind: "import", node, specifier: node.moduleSpecifier.text, specifierNode: node.moduleSpecifier, }); } else if ( ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) ) { visit({ kind: "export", node, specifier: node.moduleSpecifier.text, specifierNode: node.moduleSpecifier, }); } else if ( ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0]) ) { visit({ kind: "dynamic-import", node, specifier: node.arguments[0].text, specifierNode: node.arguments[0], }); } ts.forEachChild(node, walk); } walk(sourceFile); } export function diffInventoryEntries(expected, actual, compareEntries) { const expectedKeys = new Set(expected.map((entry) => JSON.stringify(entry))); const actualKeys = new Set(actual.map((entry) => JSON.stringify(entry))); return { missing: expected .filter((entry) => !actualKeys.has(JSON.stringify(entry))) .toSorted(compareEntries), unexpected: actual .filter((entry) => !expectedKeys.has(JSON.stringify(entry))) .toSorted(compareEntries), }; } export function writeLine(stream, text) { stream.write(`${text}\n`); } export async function collectTypeScriptInventory(params) { const inventory = []; for (const filePath of params.files) { const source = await fs.readFile(filePath, "utf8"); const sourceFile = params.ts.createSourceFile( filePath, source, params.ts.ScriptTarget.Latest, true, params.scriptKind ?? params.ts.ScriptKind.TS, ); inventory.push(...params.collectEntries(sourceFile, filePath)); } return inventory.toSorted(params.compareEntries); } export async function runBaselineInventoryCheck(params) { const streams = params.io ?? { stdout: process.stdout, stderr: process.stderr }; const json = params.argv.includes("--json"); const actual = await params.collectActual(); const expected = await params.readExpected(); const { missing, unexpected } = params.diffInventory(expected, actual); const matchesBaseline = missing.length === 0 && unexpected.length === 0; if (json) { writeLine(streams.stdout, JSON.stringify(actual, null, 2)); } else { writeLine(streams.stdout, params.formatInventoryHuman(actual)); writeLine( streams.stdout, matchesBaseline ? `Baseline matches (${actual.length} entries).` : `Baseline mismatch (${unexpected.length} unexpected, ${missing.length} missing).`, ); if (!matchesBaseline) { if (unexpected.length > 0) { writeLine(streams.stderr, "Unexpected entries:"); for (const entry of unexpected) { writeLine(streams.stderr, `- ${params.formatEntry(entry)}`); } } if (missing.length > 0) { writeLine(streams.stderr, "Missing baseline entries:"); for (const entry of missing) { writeLine(streams.stderr, `- ${params.formatEntry(entry)}`); } } } } return matchesBaseline ? 0 : 1; }