Files
pleno-vue/scripts/test-graph/graph.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

273 lines
9.8 KiB
JavaScript

const normalizePath = (value) =>
String(value || "")
.replace(/\\/gu, "/")
.replace(/^\.\//u, "");
export function globToRegExp(glob) {
let expression = "^";
for (let index = 0; index < glob.length; index += 1) {
const character = glob[index];
const next = glob[index + 1];
if (character === "*" && next === "*") {
expression += ".*";
index += 1;
} else if (character === "*") {
expression += "[^/]*";
} else if (character === "?") {
expression += "[^/]";
} else {
expression += character.replace(/[|\\{}()[\]^$+?.]/gu, "\\$&");
}
}
return new RegExp(`${expression}$`, "u");
}
const matchesAny = (file, patterns) => patterns.some((pattern) => globToRegExp(pattern).test(file));
export function parseCatalog(catalog) {
if (!Array.isArray(catalog) || catalog.length === 0) {
throw new Error("The component node catalog must contain at least one node.");
}
const nodes = new Map();
for (const rawNode of catalog) {
if (!/^[a-z][a-z0-9-]*$/u.test(rawNode.id || "")) {
throw new Error(`Invalid component node id: ${rawNode.id || "<empty>"}.`);
}
if (nodes.has(rawNode.id)) {
throw new Error(`Duplicate component node id: ${rawNode.id}.`);
}
if (!Array.isArray(rawNode.sourcePatterns) || rawNode.sourcePatterns.length === 0) {
throw new Error(`Component node ${rawNode.id} must own at least one source pattern.`);
}
if (!Number.isInteger(rawNode.layer) || rawNode.layer < 0) {
throw new Error(`Component node ${rawNode.id} must have a non-negative integer layer.`);
}
const lanes = [...new Set(rawNode.lanes || ["contract"])];
if (lanes.some((lane) => !/^[a-z][a-z0-9-]*$/u.test(lane))) {
throw new Error(`Component node ${rawNode.id} has invalid lanes.`);
}
nodes.set(rawNode.id, {
...rawNode,
priority: Number(rawNode.priority || 0),
sourcePatterns: [...rawNode.sourcePatterns],
testPatterns: [...(rawNode.testPatterns || [])],
lanes,
partitions: { ...(rawNode.partitions || {}) },
dependsOn: [...new Set([...(rawNode.dependsOn || []), ...(rawNode.runtimeDependencies || [])])],
});
for (const [lane, count] of Object.entries(rawNode.partitions || {})) {
if (!lanes.includes(lane) || !Number.isInteger(count) || count < 1) {
throw new Error(`Component node ${rawNode.id} has invalid partition metadata for ${lane}.`);
}
}
}
for (const node of nodes.values()) {
for (const dependency of node.dependsOn) {
if (!nodes.has(dependency)) {
throw new Error(`Component node ${node.id} depends on unknown node ${dependency}.`);
}
if (dependency === node.id) {
throw new Error(`Component node ${node.id} cannot depend on itself.`);
}
}
}
return { nodes };
}
function ownersForPatterns(parsedCatalog, file, field) {
const normalized = normalizePath(file);
const matches = [...parsedCatalog.nodes.values()].filter((node) => matchesAny(normalized, node[field] || []));
if (matches.length === 0) {
return [];
}
const highestPriority = Math.max(...matches.map((node) => node.priority));
return matches
.filter((node) => node.priority === highestPriority)
.map((node) => node.id)
.sort();
}
export const sourceOwnersForPath = (parsedCatalog, file) => ownersForPatterns(parsedCatalog, file, "sourcePatterns");
export const testOwnersForPath = (parsedCatalog, file) => ownersForPatterns(parsedCatalog, file, "testPatterns");
function stronglyConnectedComponents(dependencies) {
let index = 0;
const indices = new Map();
const lowLinks = new Map();
const stack = [];
const onStack = new Set();
const components = [];
const visit = (nodeId) => {
indices.set(nodeId, index);
lowLinks.set(nodeId, index);
index += 1;
stack.push(nodeId);
onStack.add(nodeId);
for (const dependency of dependencies.get(nodeId)) {
if (!indices.has(dependency)) {
visit(dependency);
lowLinks.set(nodeId, Math.min(lowLinks.get(nodeId), lowLinks.get(dependency)));
} else if (onStack.has(dependency)) {
lowLinks.set(nodeId, Math.min(lowLinks.get(nodeId), indices.get(dependency)));
}
}
if (lowLinks.get(nodeId) === indices.get(nodeId)) {
const component = [];
let member;
do {
member = stack.pop();
onStack.delete(member);
component.push(member);
} while (member !== nodeId);
components.push(component.sort());
}
};
for (const nodeId of [...dependencies.keys()].sort()) {
if (!indices.has(nodeId)) visit(nodeId);
}
return components;
}
function stableHash(value) {
let hash = 2166136261;
for (const character of value) {
hash ^= character.codePointAt(0);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
function collapseDependencyCycles(parsedCatalog, rawDependencies) {
const components = stronglyConnectedComponents(rawDependencies);
const originalToExecutionNode = new Map();
const nodes = new Map();
const sccReport = [];
for (const members of components) {
const executionNodeId = members.length === 1 ? members[0] : `atomic-${members[0]}-${stableHash(members.join("|"))}`;
for (const member of members) originalToExecutionNode.set(member, executionNodeId);
if (members.length === 1) {
nodes.set(executionNodeId, { ...parsedCatalog.nodes.get(members[0]), members });
continue;
}
const memberNodes = members.map((member) => parsedCatalog.nodes.get(member));
const lanes = [...new Set(memberNodes.flatMap((member) => member.lanes))].sort();
const partitions = {};
for (const lane of lanes) {
partitions[lane] = Math.max(...memberNodes.map((member) => member.partitions[lane] || 1));
}
nodes.set(executionNodeId, {
id: executionNodeId,
label: `Atomic group: ${members.map((member) => parsedCatalog.nodes.get(member).label).join(", ")}`,
kind: "atomic",
priority: Math.max(...memberNodes.map((member) => member.priority)),
sourcePatterns: memberNodes.flatMap((member) => member.sourcePatterns),
testPatterns: memberNodes.flatMap((member) => member.testPatterns),
lanes,
partitions,
dependsOn: [],
members,
});
sccReport.push({ executionNodeId, members });
}
const dependencies = new Map([...nodes.keys()].map((nodeId) => [nodeId, new Set()]));
for (const [consumer, prerequisites] of rawDependencies) {
const executionConsumer = originalToExecutionNode.get(consumer);
for (const prerequisite of prerequisites) {
const executionPrerequisite = originalToExecutionNode.get(prerequisite);
if (executionConsumer !== executionPrerequisite) dependencies.get(executionConsumer).add(executionPrerequisite);
}
}
return { nodes, dependencies, originalToExecutionNode, sccReport };
}
export function createDependencyGraph(parsedCatalog, inferredDependencies = [], { collapseCycles = false } = {}) {
const rawDependencies = new Map();
for (const nodeId of parsedCatalog.nodes.keys()) {
rawDependencies.set(nodeId, new Set(parsedCatalog.nodes.get(nodeId).dependsOn));
}
for (const edge of inferredDependencies) {
if (!parsedCatalog.nodes.has(edge.from) || !parsedCatalog.nodes.has(edge.to)) {
throw new Error(`Import dependency references an unknown node: ${edge.from} -> ${edge.to}.`);
}
if (edge.from !== edge.to) rawDependencies.get(edge.from).add(edge.to);
}
const collapsed = collapseCycles
? collapseDependencyCycles(parsedCatalog, rawDependencies)
: {
nodes: parsedCatalog.nodes,
dependencies: rawDependencies,
originalToExecutionNode: new Map([...parsedCatalog.nodes.keys()].map((nodeId) => [nodeId, nodeId])),
sccReport: [],
};
const { nodes, dependencies, originalToExecutionNode, sccReport } = collapsed;
const dependents = new Map([...nodes.keys()].map((nodeId) => [nodeId, new Set()]));
for (const [consumer, prerequisites] of dependencies) {
for (const prerequisite of prerequisites) {
dependents.get(prerequisite).add(consumer);
}
}
const graph = { nodes, dependencies, dependents, originalToExecutionNode, sccReport };
graph.topologicalOrder = topologicalSort(graph);
return graph;
}
export function topologicalSort(graph) {
const remaining = new Map([...graph.dependencies].map(([nodeId, dependencies]) => [nodeId, new Set(dependencies)]));
const ordered = [];
while (remaining.size > 0) {
const ready = [...remaining]
.filter(([, dependencies]) => dependencies.size === 0)
.map(([nodeId]) => nodeId)
.sort();
if (ready.length === 0) {
const cycle = [...remaining].map(([nodeId, dependencies]) => `${nodeId}->${[...dependencies].join(",")}`);
throw new Error(`Component dependency graph contains a cycle: ${cycle.join("; ")}`);
}
for (const nodeId of ready) {
ordered.push(nodeId);
remaining.delete(nodeId);
for (const dependencies of remaining.values()) {
dependencies.delete(nodeId);
}
}
}
return ordered;
}
function closure(graph, seedNodeIds, edgeMap) {
const selected = new Set();
const pending = [...seedNodeIds];
while (pending.length > 0) {
const nodeId = pending.pop();
if (selected.has(nodeId)) {
continue;
}
if (!graph.nodes.has(nodeId)) {
throw new Error(`Unknown component node: ${nodeId}.`);
}
selected.add(nodeId);
pending.push(...edgeMap.get(nodeId));
}
return selected;
}
export const prerequisiteClosure = (graph, seedNodeIds) => closure(graph, seedNodeIds, graph.dependencies);
export const dependentClosure = (graph, seedNodeIds) => closure(graph, seedNodeIds, graph.dependents);
export const sortNodeIds = (graph, nodeIds) => {
const selected = new Set(nodeIds);
return graph.topologicalOrder.filter((nodeId) => selected.has(nodeId));
};
export const normalizeGraphPath = normalizePath;