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.
173 lines
6.3 KiB
JavaScript
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;
|