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.
273 lines
9.8 KiB
JavaScript
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;
|