Files
pleno-vue/scripts/test-graph/import-resolver.mjs
T
Jeppe B 12a2d73b32 Replace monolithic E2E matrices with dependency-aware component CI
Generate small reusable component partitions with explicit provider dependencies, GitHub-hosted concurrency, fail-closed evidence, and exact mobile store gates. Include the literal-i18n fix from #206 so validation covers the exact post-merge tree.
2026-07-20 23:52:15 +02:00

173 lines
6.3 KiB
JavaScript

import fs from "node:fs/promises";
import path from "node:path";
import { globToRegExp, normalizeGraphPath, sourceOwnersForPath } from "./graph.mjs";
const sourceExtensions = [".vue", ".js", ".ts", ".mjs", ".json"];
export function parseImportSpecifiers(source) {
const specifiers = new Set();
const patterns = [
/\bimport(?:\s+[^"'()]+?\s+from\s+)?["']([^"']+)["']/gu,
/\bexport\s+[^"']*?\s+from\s+["']([^"']+)["']/gu,
/\bimport\(\s*["']([^"']+)["']\s*\)/gu,
/\brequire\(\s*["']([^"']+)["']\s*\)/gu,
];
for (const pattern of patterns) {
for (const match of source.matchAll(pattern)) {
specifiers.add(match[1]);
}
}
return [...specifiers];
}
async function existingFile(candidate) {
const candidates = [
candidate,
...sourceExtensions.map((extension) => `${candidate}${extension}`),
...sourceExtensions.map((extension) => path.join(candidate, `index${extension}`)),
];
for (const file of candidates) {
const stats = await fs.stat(file).catch(() => null);
if (stats?.isFile()) {
return file;
}
}
return null;
}
export async function resolveImportSpecifier({ repositoryRoot, importer, specifier }) {
let candidate;
if (specifier.startsWith("@/")) {
candidate = path.join(repositoryRoot, "src", specifier.slice(2));
} else if (specifier.startsWith("/src/")) {
candidate = path.join(repositoryRoot, specifier.slice(1));
} else if (specifier.startsWith("./") || specifier.startsWith("../")) {
candidate = path.resolve(path.dirname(importer), specifier);
} else {
return null;
}
return existingFile(candidate);
}
async function collectFiles(directory) {
const entries = await fs.readdir(directory, { withFileTypes: true });
const files = [];
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectFiles(entryPath)));
} else if (sourceExtensions.includes(path.extname(entry.name))) {
files.push(entryPath);
}
}
return files;
}
export function validateCompositionImportRules(parsedCatalog, rules) {
for (const rule of rules) {
if (!new Set(["ignore", "invert"]).has(rule.mode)) {
throw new Error(`Composition import rule for ${rule.importer} has invalid mode ${rule.mode}.`);
}
if (sourceOwnersForPath(parsedCatalog, rule.importer).length === 0) {
throw new Error(`Composition import rule importer is not owned: ${rule.importer}.`);
}
if (!Array.isArray(rule.importedPatterns) || rule.importedPatterns.length === 0 || !rule.reason) {
throw new Error(`Composition import rule for ${rule.importer} must include targets and a reason.`);
}
}
}
/**
* Lower-layer files occasionally import a higher-layer implementation as a
* composition hook. In those cases the effective provider-to-consumer edge is
* inverted. Every other first-party import remains a real dependency edge;
* cycles caused by mutually dependent boundaries are collapsed atomically by
* the graph builder instead of being hidden.
*/
export function classifySemanticImport(parsedCatalog, from, to) {
const fromNode = parsedCatalog.nodes.get(from);
const toNode = parsedCatalog.nodes.get(to);
if (fromNode.layer < toNode.layer) {
return {
mode: "invert",
reason:
"A lower-layer provider importing a higher-layer implementation is composition; the consumer edge is inverted.",
};
}
return null;
}
export async function inferImportGraph(repositoryRoot, parsedCatalog, compositionRules = []) {
validateCompositionImportRules(parsedCatalog, compositionRules);
const sourceRoot = path.join(repositoryRoot, "src");
const files = await collectFiles(sourceRoot);
const aggregated = new Map();
const compositionImports = [];
for (const importer of files) {
const relativeImporter = normalizeGraphPath(path.relative(repositoryRoot, importer));
const importerOwners = sourceOwnersForPath(parsedCatalog, relativeImporter);
if (importerOwners.length === 0) {
continue;
}
const source = await fs.readFile(importer, "utf8");
for (const specifier of parseImportSpecifiers(source)) {
const importedFile = await resolveImportSpecifier({ repositoryRoot, importer, specifier });
if (!importedFile) {
continue;
}
const relativeImported = normalizeGraphPath(path.relative(repositoryRoot, importedFile));
const importedOwners = sourceOwnersForPath(parsedCatalog, relativeImported);
const compositionRule = compositionRules.find(
(rule) =>
rule.importer === relativeImporter &&
rule.importedPatterns.some((pattern) => globToRegExp(pattern).test(relativeImported))
);
for (const from of importerOwners) {
for (const to of importedOwners) {
if (from === to) {
continue;
}
const semanticRule = classifySemanticImport(parsedCatalog, from, to);
const effectiveRule = compositionRule || semanticRule;
if (effectiveRule?.mode === "ignore") {
compositionImports.push({
from,
to,
importer: relativeImporter,
imported: relativeImported,
...effectiveRule,
});
continue;
}
const edgeFrom = effectiveRule?.mode === "invert" ? to : from;
const edgeTo = effectiveRule?.mode === "invert" ? from : to;
const key = `${edgeFrom}\0${edgeTo}`;
const edge = aggregated.get(key) || { from: edgeFrom, to: edgeTo, imports: [] };
edge.imports.push({ importer: relativeImporter, imported: relativeImported, specifier });
aggregated.set(key, edge);
if (effectiveRule) {
compositionImports.push({
from: edgeFrom,
to: edgeTo,
importer: relativeImporter,
imported: relativeImported,
...effectiveRule,
});
}
}
}
}
}
return {
dependencies: [...aggregated.values()].sort((left, right) =>
`${left.from}:${left.to}`.localeCompare(`${right.from}:${right.to}`)
),
compositionImports,
};
}
export const inferImportDependencies = async (repositoryRoot, parsedCatalog, compositionRules = []) =>
(await inferImportGraph(repositoryRoot, parsedCatalog, compositionRules)).dependencies;