mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
cd19a73c55
Renders every production file under src/ as a pannable graph in one self-contained HTML file — no external requests, no runtime dependency, layouts precomputed at build time so the viewer never runs a physics simulation on a phone. pnpm depgraph # -> .tmp/depgraph/index.html (+ index.json) pnpm depgraph:test It reuses the layering gate's model (`listSourceFiles`, `resolveImportEdges`, `zoneRank`) rather than extracting its own graph. That matters more than it sounds: a separate extractor with its own resolution behaviour would draw a graph nobody enforces. Because the model is shared, its R6 count reproduces TYPE_INVERSION_BASELINE exactly, which doubles as a self-check. The README now documents WHEN it is productive, because the honest answer is "for three questions, and it misleads on a fourth": - what am I about to break (dependent counts, including the type-only and dynamic edges a grep for `from '...'` misses); - where is the debt concentrated (zone-level counts); - what is wrong that CI does not enforce — ~1300 transitively redundant value edges and 8 type-only/dynamic cycles, both outside the gate by design. The fourth: a cluster's SIZE IS NOT ITS DIFFICULTY. `commands -> client` looked like the obvious win at 28 edges into one file; moving that file down took the gate from 42 to 48, because the vocabulary it holds depends on commands/, metro/, core/ and remote/. The render shows an edge's weight, not whether it can be reversed — so the README pairs every visual question with the numeric query that answers "can this actually move?", verified against the real output rather than written from memory. Also states plainly that `pnpm check:layering` is authoritative and nothing here gates a merge: it is an instrument, not a rule. scripts/depgraph/** joins scripts/layering/**, scripts/perf/** and scripts/maestro-conformance/** in Fallow's ignorePatterns, which is how this repo already treats tooling trees. Worth knowing rather than discovering: that exempts viewer.js from the complexity gate, and its `draw` function would fail it. Two exports added to scripts/layering/model.ts: `zoneRank` (the viewer colours nodes by rank, so an inversion reads as an edge pointing the wrong way down the ramp) and `targetDagZone`, previously module-private. `pnpm check` green, 4488 unit tests. Verified against current main: 898 files, 4627 edges, 25 zones, R6 count matching the gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
416 lines
15 KiB
TypeScript
416 lines
15 KiB
TypeScript
// Deterministic build-time graph layouts.
|
||
//
|
||
// Both layouts are computed here, in Node, and shipped as coordinates. The viewer never
|
||
// runs a physics simulation: a phone renders a static point set with pan/zoom instead of
|
||
// spending its battery re-deriving one, and two people looking at the same graph see the
|
||
// same picture.
|
||
|
||
import type { GraphData, GraphEdge, GraphNode } from './model.ts';
|
||
|
||
export type Point = { x: number; y: number };
|
||
|
||
/** mulberry32 — small, seeded, and stable across Node versions. */
|
||
function seededRandom(seed: number): () => number {
|
||
let state = seed >>> 0;
|
||
return () => {
|
||
state = (state + 0x6d2b79f5) >>> 0;
|
||
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Longest distance from each node to a sink over value edges. The layering gate rejects
|
||
* production value-import cycles (R4), so that subgraph is a DAG and the height is
|
||
* well-defined; the `visiting` guard only exists so a future cycle degrades instead of
|
||
* overflowing the stack.
|
||
*/
|
||
export function computeLevels(
|
||
nodes: readonly GraphNode[],
|
||
edges: readonly GraphEdge[],
|
||
): Map<string, number> {
|
||
const successors = new Map<string, string[]>();
|
||
for (const edge of edges) {
|
||
if (edge.kind !== 'value') continue;
|
||
const list = successors.get(edge.from) ?? [];
|
||
list.push(edge.to);
|
||
successors.set(edge.from, list);
|
||
}
|
||
|
||
const levels = new Map<string, number>();
|
||
const visiting = new Set<string>();
|
||
|
||
const height = (id: string): number => {
|
||
const cached = levels.get(id);
|
||
if (cached !== undefined) return cached;
|
||
if (visiting.has(id)) return 0;
|
||
visiting.add(id);
|
||
let best = 0;
|
||
for (const next of successors.get(id) ?? []) {
|
||
best = Math.max(best, height(next) + 1);
|
||
}
|
||
visiting.delete(id);
|
||
levels.set(id, best);
|
||
return best;
|
||
};
|
||
|
||
for (const node of nodes) height(node.id);
|
||
return levels;
|
||
}
|
||
|
||
type Body = { x: number; y: number; vx: number; vy: number; mass: number };
|
||
|
||
type QuadNode = {
|
||
cx: number;
|
||
cy: number;
|
||
size: number;
|
||
mass: number;
|
||
massX: number;
|
||
massY: number;
|
||
body: Body | null;
|
||
children: (QuadNode | null)[] | null;
|
||
};
|
||
|
||
function makeQuad(cx: number, cy: number, size: number): QuadNode {
|
||
return { cx, cy, size, mass: 0, massX: 0, massY: 0, body: null, children: null };
|
||
}
|
||
|
||
function quadrantOf(quad: QuadNode, body: Body): number {
|
||
return (body.x > quad.cx ? 1 : 0) + (body.y > quad.cy ? 2 : 0);
|
||
}
|
||
|
||
function childQuad(quad: QuadNode, index: number): QuadNode {
|
||
const half = quad.size / 2;
|
||
const offsetX = index % 2 === 0 ? -half / 2 : half / 2;
|
||
const offsetY = index < 2 ? -half / 2 : half / 2;
|
||
return makeQuad(quad.cx + offsetX, quad.cy + offsetY, half);
|
||
}
|
||
|
||
function insertBody(quad: QuadNode, body: Body, depth = 0): void {
|
||
quad.mass += body.mass;
|
||
quad.massX += body.x * body.mass;
|
||
quad.massY += body.y * body.mass;
|
||
|
||
if (quad.children === null && quad.body === null) {
|
||
quad.body = body;
|
||
return;
|
||
}
|
||
|
||
// Bail out of subdividing pathological ties (identical coordinates) instead of
|
||
// recursing until the stack gives up.
|
||
if (depth > 48) return;
|
||
|
||
if (quad.children === null) {
|
||
const existing = quad.body!;
|
||
quad.body = null;
|
||
quad.children = [null, null, null, null];
|
||
const index = quadrantOf(quad, existing);
|
||
quad.children[index] = childQuad(quad, index);
|
||
insertBody(quad.children[index]!, existing, depth + 1);
|
||
}
|
||
|
||
const index = quadrantOf(quad, body);
|
||
quad.children[index] ??= childQuad(quad, index);
|
||
insertBody(quad.children[index]!, body, depth + 1);
|
||
}
|
||
|
||
function applyRepulsion(quad: QuadNode, body: Body, strength: number, theta: number): void {
|
||
if (quad.mass === 0) return;
|
||
const centerX = quad.massX / quad.mass;
|
||
const centerY = quad.massY / quad.mass;
|
||
let dx = body.x - centerX;
|
||
let dy = body.y - centerY;
|
||
let distanceSq = dx * dx + dy * dy;
|
||
|
||
if (quad.body === body && quad.children === null) return;
|
||
|
||
if (quad.children !== null && quad.size * quad.size > theta * theta * distanceSq) {
|
||
for (const child of quad.children) {
|
||
if (child) applyRepulsion(child, body, strength, theta);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (distanceSq < 0.01) {
|
||
// Deterministic nudge so coincident bodies still separate.
|
||
dx = 0.1;
|
||
dy = 0.1;
|
||
distanceSq = 0.02;
|
||
}
|
||
const force = (strength * body.mass * quad.mass) / distanceSq;
|
||
const distance = Math.sqrt(distanceSq);
|
||
body.vx += (dx / distance) * force;
|
||
body.vy += (dy / distance) * force;
|
||
}
|
||
|
||
type GroupPlacement = { center: Point; radius: number };
|
||
|
||
/**
|
||
* Phase one of the cluster layout: place the ~56 folder groups relative to each other by
|
||
* how much they import from each other. Laying out the aggregate first is what makes the
|
||
* file-level result read as separate islands; a single flat simulation over 892 nodes
|
||
* converges to one hairball no matter how the constants are tuned.
|
||
*/
|
||
function placeGroups(graph: GraphData): Map<string, GroupPlacement> {
|
||
const sizes = new Map<string, number>();
|
||
for (const node of graph.nodes) sizes.set(node.group, (sizes.get(node.group) ?? 0) + 1);
|
||
const groups = [...sizes.keys()].sort();
|
||
|
||
const weights = new Map<string, number>();
|
||
const groupOf = new Map(graph.nodes.map((node) => [node.id, node.group]));
|
||
for (const edge of graph.edges) {
|
||
const from = groupOf.get(edge.from);
|
||
const to = groupOf.get(edge.to);
|
||
if (!from || !to || from === to) continue;
|
||
const key = from < to ? `${from} |