Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/mutation-affected.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ name: Mutation Affected
# ownership instead would run the full ten-shard sweep on 24 of the last 40
# merged PRs.
#
# On a harness diff the matrix is the canary (`LANE_CANARY`, the registry's
# cheapest real sweep — lane sources own no kernel, so derivation alone would
# select nothing) plus any kernel that same diff reaches through the import
# graph (scripts/mutation/ownership.ts): a kernel source, or a test that reaches
# one. Selected modules are sharded like the weekly sweep, so the PR's wall
# clock is one module rather than their sum.
# On a harness diff the matrix is the canary (`LANE_CANARY`, selected by
# observed end-to-end runtime within the job budget — lane sources own no kernel,
# so derivation alone would select nothing) plus any kernel that same diff
# reaches through the import graph (scripts/mutation/ownership.ts): a kernel
# source, or a test that reaches one. Selected modules are sharded like the
# weekly sweep, so the PR's wall clock is one module rather than their sum.

on:
pull_request:
Expand Down
40 changes: 38 additions & 2 deletions scripts/depgraph/import-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// a graph built here, so a kind filter is the only thing that distinguishes their subgraphs.

import { createGraph, type Graph } from '@statelyai/graph';
import type { EdgeKind, GraphEdge } from './model.ts';

export type EdgeKind = 'value' | 'type' | 'dynamic';

/** The subgraph R4 keeps acyclic: what a module needs before it can evaluate. */
export const VALUE_EDGES: ReadonlySet<EdgeKind> = new Set(['value']);
Expand All @@ -14,6 +15,7 @@ export const VALUE_EDGES: ReadonlySet<EdgeKind> = new Set(['value']);
* dynamic edges would cut every handler chain at its root.
*/
export const EXECUTABLE_EDGES: ReadonlySet<EdgeKind> = new Set(['value', 'dynamic']);
export const STATIC_EDGES: ReadonlySet<EdgeKind> = new Set(['value', 'type']);

/**
* Every collapsed edge kind, for the structural question ("does anything reference this file at
Expand All @@ -24,6 +26,21 @@ export const EXECUTABLE_EDGES: ReadonlySet<EdgeKind> = new Set(['value', 'dynami
*/
export const ALL_EDGES: ReadonlySet<EdgeKind> = new Set(['value', 'type', 'dynamic']);

type ImportGraphEdge = { from: string; to: string; kind: EdgeKind };

type ResolvedGraphEdge = {
file: string;
target: string;
dynamic: boolean;
typeOnly: boolean;
};

export function edgeKind(edge: Pick<ResolvedGraphEdge, 'dynamic' | 'typeOnly'>): EdgeKind {
if (edge.dynamic) return 'dynamic';
if (edge.typeOnly) return 'type';
return 'value';
}

/**
* Identity of one file pair, shared by `collapseEdges` and the graph's edge ids. NUL cannot occur
* in a file path, so distinct pairs never share an id.
Expand All @@ -37,7 +54,7 @@ export function importEdgeId(from: string, to: string): string {
* isolated modules still exist as nodes.
*/
export function importGraph(
edges: readonly GraphEdge[],
edges: readonly ImportGraphEdge[],
kinds: ReadonlySet<EdgeKind>,
files: Iterable<string> = [],
): Graph {
Expand All @@ -56,3 +73,22 @@ export function importGraph(
})),
});
}

/** Build a traversal graph from the normalized edges produced by the layering model. */
export function importGraphFromResolvedEdges(
edges: readonly ResolvedGraphEdge[],
kinds: ReadonlySet<EdgeKind>,
files: Iterable<string> = [],
): Graph {
const seen = new Set<string>();
const graphEdges: ImportGraphEdge[] = [];
for (const edge of edges) {
const kind = edgeKind(edge);
if (!kinds.has(kind)) continue;
const id = importEdgeId(edge.file, edge.target);
if (seen.has(id)) continue;
seen.add(id);
graphEdges.push({ from: edge.file, to: edge.target, kind });
}
return importGraph(graphEdges, kinds, files);
}
26 changes: 26 additions & 0 deletions scripts/depgraph/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,32 @@ test('collectCycles separates gate-rejected value cycles from type-only and dyna
);
});

test('collectCycles keeps the value-cycle representative for a chorded three-file SCC', () => {
const edges = resolveImportEdges(
sources({
'src/core/a.ts': "import './b.ts';\nimport './c.ts';",
'src/core/b.ts': "import './c.ts';",
'src/core/c.ts': "import './a.ts';",
}),
);
assert.deepEqual(collectCycles(edges), [
{ kind: 'value', path: ['src/core/a.ts', 'src/core/b.ts', 'src/core/c.ts', 'src/core/a.ts'] },
]);
});

test('collectCycles preserves the first DFS back edge when a chord closes below the SCC root', () => {
const edges = resolveImportEdges(
sources({
'src/core/a.ts': "import './b.ts';",
'src/core/b.ts': "import './c.ts';",
'src/core/c.ts': "import './b.ts';\nimport './a.ts';",
}),
);
assert.deepEqual(collectCycles(edges), [
{ kind: 'value', path: ['src/core/b.ts', 'src/core/c.ts', 'src/core/b.ts'] },
]);
});

test('buildGraph reports zone membership, degrees, and cross-zone edge counts', () => {
const files = sources({
'packages/kernel/src/errors.ts': 'export const fail = 1;\n',
Expand Down
11 changes: 2 additions & 9 deletions scripts/depgraph/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ import {
} from '../layering/model.ts';
import { ARCHITECTURE_OWNERSHIP, matchesDeclaredRoot } from '../layering/architecture-ownership.ts';
import { genPostorder, getSuccessors, getTransitiveReduction } from '@statelyai/graph';
import { importEdgeId, importGraph, VALUE_EDGES } from './import-graph.ts';

export type EdgeKind = 'value' | 'type' | 'dynamic';
import { edgeKind, importEdgeId, importGraph, VALUE_EDGES } from './import-graph.ts';
import type { EdgeKind } from './import-graph.ts';

export const AUTHORITY_LABELS = [
'vocabulary',
Expand Down Expand Up @@ -164,12 +163,6 @@ function countLines(source: string): number {
return lines;
}

function edgeKind(edge: ResolvedImportEdge): EdgeKind {
if (edge.dynamic) return 'dynamic';
if (edge.typeOnly) return 'type';
return 'value';
}

/**
* Deduplicate parsed import edges down to one edge per (from, to) pair, keeping the
* strongest kind. A file that imports both a type and a value from the same module has one
Expand Down
37 changes: 24 additions & 13 deletions scripts/fuzz/envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,45 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { genBFS } from '@statelyai/graph';
import { describe, expect, it } from 'vitest';
import { parseImports } from '../layering/model.ts';
import { importGraphFromResolvedEdges, STATIC_EDGES } from '../depgraph/import-graph.ts';
import { CASE_GENERATION_INPUTS, NON_GENERATING_MODULES } from './envelope.ts';

const FUZZ_DIR = path.dirname(fileURLToPath(import.meta.url));

/** Roots of case generation: the arbitraries, the seeds, the loop, and the violation rule. */
const ROOTS = ['arbitraries.ts', 'generate.ts', 'targets.ts', 'invariant.ts'] as const;

function localImportsOf(file: string): string[] {
const source = fs.readFileSync(path.join(FUZZ_DIR, file), 'utf8');
return [...source.matchAll(/from '\.\/([\w-]+\.ts)'/g)].map((match) => match[1]!);
function generationGraph() {
const sources = new Map(
fs
.readdirSync(FUZZ_DIR)
.filter((file) => file.endsWith('.ts'))
.map((file) => [file, fs.readFileSync(path.join(FUZZ_DIR, file), 'utf8')]),
);
const edges = [];
for (const [file, source] of sources) {
if (file in NON_GENERATING_MODULES) continue;
for (const edge of parseImports(source)) {
if (!edge.spec.startsWith('./')) continue;
const target = path.posix.normalize(path.posix.join(path.posix.dirname(file), edge.spec));
if (!sources.has(target)) continue;
edges.push({ file, target, dynamic: edge.dynamic, typeOnly: edge.typeOnly });
}
}
return importGraphFromResolvedEdges(edges, STATIC_EDGES, sources.keys());
}

const graph = generationGraph();

/**
* Walk stops at a waived module: what a non-generating module imports cannot reach a case either
* (the runner imports the target registry, which would otherwise drag the whole harness in).
*/
function generationClosure(): Set<string> {
const seen = new Set<string>();
const queue = [...ROOTS];
while (queue.length > 0) {
const file = queue.pop()!;
if (seen.has(file)) continue;
seen.add(file);
if (file in NON_GENERATING_MODULES) continue;
queue.push(...localImportsOf(file));
}
return seen;
return new Set([...genBFS(graph, { from: ROOTS })].map(({ id }) => id));
}

describe('configHash coverage', () => {
Expand Down
24 changes: 9 additions & 15 deletions scripts/layering/daemon-platform-runtime-inventory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { genDFS } from '@statelyai/graph';
import { isProductionSourceFile } from './tracked-sources.ts';
import type { LayeringViolation, ResolvedImportEdge } from './model.ts';
import { ALL_EDGES, importGraphFromResolvedEdges } from '../depgraph/import-graph.ts';

// The classified inventory of every production daemon import that reaches the root platform-runtime
// composition modules (#2278, ADR 0022, #2542). R65 already bans daemon imports of concrete platform
Expand Down Expand Up @@ -52,27 +54,19 @@ export function isRootPlatformRuntimeTarget(target: string): boolean {
export function computePlatformMechanicsHubs(
edges: readonly ResolvedImportEdge[],
): ReadonlySet<string> {
const importersByTarget = new Map<string, string[]>();
const roots: string[] = [];
const rootSet = new Set<string>();
for (const edge of edges) {
const importers = importersByTarget.get(edge.target);
if (importers === undefined) importersByTarget.set(edge.target, [edge.file]);
else importers.push(edge.file);
if (isRootPlatformRuntimeTarget(edge.target) && !roots.includes(edge.target)) {
if (isRootPlatformRuntimeTarget(edge.target) && !rootSet.has(edge.target)) {
rootSet.add(edge.target);
roots.push(edge.target);
}
}

const hubs = new Set<string>(roots);
const seen = new Set<string>(roots);
const frontier = [...roots];
while (frontier.length > 0) {
for (const importer of importersByTarget.get(frontier.pop()!) ?? []) {
if (seen.has(importer)) continue;
seen.add(importer);
frontier.push(importer);
if (!importer.startsWith('src/daemon/')) hubs.add(importer);
}
const hubs = new Set<string>();
const graph = importGraphFromResolvedEdges(edges, ALL_EDGES, roots);
for (const { id: importer } of genDFS(graph, { from: roots, direction: 'incoming' })) {
if (!importer.startsWith('src/daemon/')) hubs.add(importer);
}
return hubs;
}
Expand Down
Loading
Loading