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
50 changes: 50 additions & 0 deletions scripts/dependency-analyzer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Contract Dependency Analyzer (#496)

Maps cross-contract dependencies across the Rust/Soroban workspace and reports
the blast radius of upgrading a given contract.

## Usage

```bash
# Build the graph (JSON + Graphviz DOT)
node --experimental-strip-types scripts/dependency-analyzer/index.ts \
--root stellar-lend --out graph.json --dot graph.dot

# Render the DOT (optional)
dot -Tsvg graph.dot -o graph.svg

# Upgrade-impact report for one contract
node --experimental-strip-types scripts/dependency-analyzer/index.ts \
--root stellar-lend --impact oracle
```

Each crate (`Cargo.toml` `[package].name`) is a node. Edges are detected as:
- **call** — a generated cross-contract client (`<Contract>Client`)
- **import** — `use <crate>` / `mod <crate>` / `<crate>::`
- **library** — workspace crate listed in `[dependencies]`

## Impact analysis

`--impact <contract>` returns the direct and transitive **dependents** (what an
upgrade affects) plus a `riskLevel` derived from the blast radius. The engine
also detects dependency cycles.

## Tests

```bash
node --experimental-strip-types --test scripts/dependency-analyzer/analyzer.test.ts
```

## Covered acceptance criteria

- Parse contract sources + imports to build a dependency graph.
- Detect direct calls, imports, and library (crate) links.
- Impact analysis: which contracts are affected by upgrading a specific contract.
- Cycle detection.
- Output: Graphviz DOT + JSON; per-contract upgrade-impact report with risk level.

## Follow-ups (out of this PR's core)

- Interactive web visualization (`web/dashboard/dependency-graph/`, D3.js/vis.js).
- Changed-interface / storage-slot impact and test-impact mapping.
- CI integration to auto-generate the graph on PRs.
95 changes: 95 additions & 0 deletions scripts/dependency-analyzer/analyzer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
buildGraph,
dependencies,
dependents,
detectCycles,
detectDependencies,
impactReport,
toDOT,
} from "./analyzer.ts";
import { type ModuleSource } from "./types.ts";

function modules(): ModuleSource[] {
return [
// lending calls oracle (client) and imports shared
{
name: "lending",
source: `use shared::types::Asset;\nlet px = OracleClient::new(&env, &id).price();`,
},
// oracle imports shared
{ name: "oracle", source: `use shared::math;\npub fn price() {}` },
// shared depends on nothing
{ name: "shared", source: `pub struct Asset {}` },
// governance calls lending
{ name: "governance", source: `let c = LendingClient::new(&env, &id);` },
];
}

test("detectDependencies finds call and import edges", () => {
const edges = detectDependencies(modules()[0], ["lending", "oracle", "shared", "governance"]);
const oracle = edges.find((e) => e.to === "oracle");
const shared = edges.find((e) => e.to === "shared");
assert.equal(oracle?.kind, "call");
assert.equal(shared?.kind, "import");
// does not depend on itself
assert.equal(edges.some((e) => e.to === "lending"), false);
});

test("detectDependencies includes library deps", () => {
const edges = detectDependencies(modules()[2], ["lending", "oracle", "shared"], ["soroban-sdk"]);
assert.ok(edges.some((e) => e.to === "soroban-sdk" && e.kind === "library"));
});

test("buildGraph assembles nodes and edges", () => {
const g = buildGraph(modules());
assert.ok(g.nodes.includes("lending"));
assert.ok(g.edges.some((e) => e.from === "lending" && e.to === "oracle" && e.kind === "call"));
assert.ok(g.edges.some((e) => e.from === "governance" && e.to === "lending"));
});

test("dependencies returns transitive out-edges", () => {
const g = buildGraph(modules());
// lending -> oracle -> shared, lending -> shared
assert.deepEqual(dependencies(g, "lending"), ["oracle", "shared"]);
});

test("dependents returns transitive in-edges (upgrade impact)", () => {
const g = buildGraph(modules());
// who is affected if shared is upgraded? lending, oracle, and governance (via lending)
assert.deepEqual(dependents(g, "shared"), ["governance", "lending", "oracle"]);
});

test("impactReport classifies risk by blast radius", () => {
const g = buildGraph(modules());
const shared = impactReport(g, "shared");
assert.equal(shared.riskLevel, "high"); // 3 transitive dependents
assert.ok(shared.directDependents.includes("lending"));
assert.ok(shared.directDependents.includes("oracle"));

const governance = impactReport(g, "governance");
assert.equal(governance.riskLevel, "low"); // nothing depends on governance
assert.deepEqual(governance.transitiveDependents, []);
});

test("detectCycles finds a dependency cycle", () => {
const g = buildGraph([
{ name: "a", source: "BClient::new();" },
{ name: "b", source: "AClient::new();" },
]);
const cycles = detectCycles(g);
assert.ok(cycles.length >= 1);
});

test("acyclic graph reports no cycles", () => {
assert.deepEqual(detectCycles(buildGraph(modules())), []);
});

test("toDOT emits a digraph with styled edges", () => {
const dot = toDOT(buildGraph(modules()));
assert.ok(dot.startsWith("digraph contract_dependencies"));
assert.ok(dot.includes('"lending" -> "oracle"'));
assert.ok(dot.includes("style=solid")); // call edge
assert.ok(dot.includes("style=dashed")); // import edge
});
165 changes: 165 additions & 0 deletions scripts/dependency-analyzer/analyzer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* Core dependency-graph analysis for contract upgrade-impact assessment (#496).
*
* Pure functions: detect cross-contract references in source, build a graph,
* compute transitive dependents/dependencies (upgrade impact), detect cycles,
* and emit Graphviz DOT. The interactive web visualization and CI wiring are
* documented follow-ups; this is the analysis engine.
*/

import {
type DependencyEdge,
type DependencyGraph,
type ImpactReport,
type ModuleSource,
} from "./types.ts";

function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

/**
* Detect dependency edges from `module` to other known contracts by scanning
* its source. Heuristics tuned for Rust/Soroban:
* - `import` : `use <C>` / `<C>::` / `mod <C>` (interface/module imports)
* - `call` : `<C>Client` (generated cross-contract client = a direct call)
* - `library` : declared elsewhere (Cargo deps), supplied via `libraryDeps`
*/
export function detectDependencies(
module: ModuleSource,
knownContracts: string[],
libraryDeps: string[] = [],
): DependencyEdge[] {
const edges: DependencyEdge[] = [];
const seen = new Set<string>();

for (const target of knownContracts) {
if (target === module.name) continue;
const t = escapeRegExp(target);
// Soroban generated clients are PascalCase (`OracleClient`) while crate/node
// names are snake_case (`oracle`); compact underscores and match
// case-insensitively so `oracle` detects `OracleClient` and `stellar_lend`
// detects `StellarLendClient`.
const compact = escapeRegExp(target.replace(/_/g, ""));

const callRe = new RegExp(`\\b${compact}Client\\b`, "i");
const importRe = new RegExp(`\\b(use|mod)\\s+${t}\\b|\\b${t}::`);

let kind: DependencyEdge["kind"] | null = null;
if (callRe.test(module.source)) kind = "call";
else if (importRe.test(module.source)) kind = "import";

if (kind) {
const id = `${target}:${kind}`;
if (!seen.has(id)) {
seen.add(id);
edges.push({ from: module.name, to: target, kind });
}
}
}

for (const lib of libraryDeps) {
if (lib === module.name) continue;
edges.push({ from: module.name, to: lib, kind: "library" });
}

return edges;
}

/** Build the full dependency graph from a set of module sources. */
export function buildGraph(
modules: ModuleSource[],
libraryDepsByModule: Record<string, string[]> = {},
): DependencyGraph {
const names = modules.map((m) => m.name);
const nodeSet = new Set(names);
const edges: DependencyEdge[] = [];
for (const m of modules) {
for (const e of detectDependencies(m, names, libraryDepsByModule[m.name] ?? [])) {
edges.push(e);
nodeSet.add(e.to);
}
}
return { nodes: [...nodeSet].sort(), edges };
}

function neighbors(graph: DependencyGraph, node: string, direction: "out" | "in"): string[] {
return graph.edges
.filter((e) => (direction === "out" ? e.from === node : e.to === node))
.map((e) => (direction === "out" ? e.to : e.from));
}

function transitiveClosure(graph: DependencyGraph, start: string, direction: "out" | "in"): string[] {
const visited = new Set<string>();
const stack = [...neighbors(graph, start, direction)];
while (stack.length) {
const n = stack.pop() as string;
if (visited.has(n) || n === start) continue;
visited.add(n);
stack.push(...neighbors(graph, n, direction));
}
return [...visited].sort();
}

/** What `target` depends on (transitively). */
export function dependencies(graph: DependencyGraph, target: string): string[] {
return transitiveClosure(graph, target, "out");
}

/** What depends on `target` (transitively) — i.e. what an upgrade impacts. */
export function dependents(graph: DependencyGraph, target: string): string[] {
return transitiveClosure(graph, target, "in");
}

/** Upgrade-impact report for a contract. */
export function impactReport(graph: DependencyGraph, target: string): ImpactReport {
const direct = [...new Set(neighbors(graph, target, "in"))].sort();
const transitive = dependents(graph, target);
const riskLevel = transitive.length >= 3 ? "high" : transitive.length >= 1 ? "medium" : "low";
return {
target,
directDependents: direct,
transitiveDependents: transitive,
riskLevel,
};
}

/** Detect dependency cycles (each returned array is a cycle path). */
export function detectCycles(graph: DependencyGraph): string[][] {
const cycles: string[][] = [];
const state = new Map<string, number>(); // 0=visiting,1=done
const path: string[] = [];

const visit = (node: string): void => {
state.set(node, 0);
path.push(node);
for (const next of neighbors(graph, node, "out")) {
if (!state.has(next)) visit(next);
else if (state.get(next) === 0) {
const idx = path.indexOf(next);
if (idx >= 0) cycles.push([...path.slice(idx), next]);
}
}
path.pop();
state.set(node, 1);
};

for (const node of graph.nodes) if (!state.has(node)) visit(node);
return cycles;
}

/** Emit Graphviz DOT, with edge styling per dependency kind. */
export function toDOT(graph: DependencyGraph): string {
const style: Record<DependencyEdge["kind"], string> = {
call: "solid",
import: "dashed",
library: "dotted",
};
const lines = ["digraph contract_dependencies {", " rankdir=LR;", " node [shape=box];"];
for (const n of graph.nodes) lines.push(` "${n}";`);
for (const e of graph.edges) {
lines.push(` "${e.from}" -> "${e.to}" [style=${style[e.kind]} label="${e.kind}"];`);
}
lines.push("}");
return lines.join("\n");
}
Loading
Loading