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
9 changes: 9 additions & 0 deletions docs/service-topology.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Cross-repository service topology

CodeDecay's service-topology foundation models explicitly configured repositories, packages, services, deployment units, APIs, event topics, schemas, datastores, jobs, environments, and teams. It performs no repository cloning, network discovery, command execution, model calls, or telemetry.

Topology manifests use schema version `1` and may be JSON or YAML. Every node and edge has stable IDs, confidence, freshness, trust class, limitations, and at least one source containing a repository ID and revision. Local repository roots are explicit; missing roots remain visible as unavailable partial checkouts.

Dependency analysis follows declared consumer relationships to changed contracts and reports connected deployment units and owners. Stale or inferred relationships produce verification gaps and never become trusted evidence by themselves. Normalized artifacts are written to `.codedecay/local/service-topology.json` and remain inspectable.

This foundation does not yet expose CLI or MCP commands and does not yet parse OpenAPI or asynchronous contracts. Those adapters should use maintained OSS parsers and feed this model rather than creating a second topology engine.
4 changes: 3 additions & 1 deletion judge-lab/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ a:focus-visible {
text-transform: uppercase;
}

.use-grid pre {
.command-snippet {
background: #0b0b0a;
border: 1px solid var(--line);
color: #c8c4b9;
Expand All @@ -587,7 +587,9 @@ a:focus-visible {
margin: 20px 0;
overflow-x: auto;
padding: 18px;
resize: none;
white-space: pre;
width: 100%;
}

.scenario-picker {
Expand Down
26 changes: 18 additions & 8 deletions judge-lab/app/judge-lab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,10 +261,15 @@ export function JudgeLab({ engineVersion, sourceCommit, scenarios }: JudgeLabPro
<article>
<span className="card-label">TRY ON YOUR REPO</span>
<h3>Run the CLI where your code lives.</h3>
<pre>
<code>{`npx @submuxhq/codedecay@0.4.1 redteam --base main --head HEAD --format markdown
npx @submuxhq/codedecay@0.4.1 agent --base main --head HEAD`}</code>
</pre>
<textarea
className="command-snippet"
aria-label="Local CLI commands"
readOnly
rows={2}
spellCheck={false}
value={`npx @submuxhq/codedecay@0.4.1 redteam --base main --head HEAD --format markdown
npx @submuxhq/codedecay@0.4.1 agent --base main --head HEAD`}
/>
<p>
This is the real product path: local diff, local checks, no hidden upload, and
agent-readable tasks for Codex, Claude Code, Cursor, or MCP clients.
Expand All @@ -273,12 +278,17 @@ npx @submuxhq/codedecay@0.4.1 agent --base main --head HEAD`}</code>
<article>
<span className="card-label">AUTOMATE IN CI</span>
<h3>Add it before merge.</h3>
<pre>
<code>{`- uses: SubmuxHQ/CodeDecay/.github/actions/codedecay@v0.4.1
<textarea
className="command-snippet"
aria-label="GitHub Action configuration"
readOnly
rows={4}
spellCheck={false}
value={`- uses: SubmuxHQ/CodeDecay/.github/actions/codedecay@v0.4.1
with:
base: main
head: HEAD`}</code>
</pre>
head: HEAD`}
/>
<p>
The GitHub Action turns the same merge-safety checks into review evidence before a
risky AI-generated change lands.
Expand Down
4 changes: 3 additions & 1 deletion judge-lab/tests/judge-lab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ test("one click runs live auth analysis and exposes exact evidence links", async
page.getByRole("heading", { name: /Find what your coding agent missed/i }),
).toBeVisible();

await page.getByRole("button", { name: "Red-team the risky PR" }).click();
const runButton = page.getByRole("button", { name: "Watch CodeDecay catch it" });
await expect(runButton).toBeEnabled();
await runButton.click();
const result = page.getByTestId("analysis-result");
await expect(result).toBeVisible();
await expect(result.getByText("LIVE EXECUTION")).toBeVisible();
Expand Down
3 changes: 2 additions & 1 deletion packages/knowledge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"dependencies": {
"@submuxhq/codedecay-core": "workspace:*",
"@submuxhq/codedecay-memory": "workspace:*",
"chokidar": "^4.0.3"
"chokidar": "^4.0.3",
"yaml": "^2.8.1"
},
"scripts": {
"build": "tsup src/index.ts --format esm --dts --clean --tsconfig ../../tsconfig.base.json"
Expand Down
27 changes: 27 additions & 0 deletions packages/knowledge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,33 @@ export {
CONTEXT_SERVICE_STATE_PATH,
LocalContextService
} from "./service";
export {
SERVICE_TOPOLOGY_ARTIFACT_PATH,
loadServiceTopologyManifest,
normalizeServiceTopologyGraph,
persistServiceTopologyArtifact,
topologyEvidenceId
} from "./topology/manifest";
export { analyzeServiceTopologyImpact, renderServiceTopologyImpactMarkdown } from "./topology/impact";
export {
SERVICE_TOPOLOGY_EDGE_KINDS,
SERVICE_TOPOLOGY_NODE_KINDS,
SERVICE_TOPOLOGY_SCHEMA_VERSION
} from "./topology/types";
export type {
ServiceTopologyConfidence,
ServiceTopologyEdge,
ServiceTopologyEdgeKind,
ServiceTopologyFreshness,
ServiceTopologyGap,
ServiceTopologyGraph,
ServiceTopologyImpact,
ServiceTopologyImpactReport,
ServiceTopologyNode,
ServiceTopologyNodeKind,
ServiceTopologySource,
ServiceTopologyTrustClass
} from "./topology/types";
export type {
ContextInvalidationReason,
ContextServiceBuildInput,
Expand Down
115 changes: 115 additions & 0 deletions packages/knowledge/src/topology/impact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type {
ServiceTopologyEdge,
ServiceTopologyGap,
ServiceTopologyGraph,
ServiceTopologyImpact,
ServiceTopologyImpactReport,
ServiceTopologyNode
} from "./types";
import { SERVICE_TOPOLOGY_SCHEMA_VERSION } from "./types";
import { topologyEvidenceId } from "./manifest";

const DEPENDENCY_EDGES = new Set(["consumes", "calls", "subscribes", "reads", "compatibility-requires"]);

export function analyzeServiceTopologyImpact(graph: ServiceTopologyGraph, changedNodeIds: string[]): ServiceTopologyImpactReport {
const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
const normalizedChanged = [...new Set(changedNodeIds)].sort();
const impacts: ServiceTopologyImpact[] = [];
const gaps: ServiceTopologyGap[] = [];

for (const changedNodeId of normalizedChanged) {
if (!nodes.has(changedNodeId)) {
gaps.push(gap(changedNodeId, undefined, "unresolved-consumer", `Map changed contract ${changedNodeId} to an explicit topology node.`));
continue;
}
for (const edge of graph.edges.filter((item) => item.to === changedNodeId && DEPENDENCY_EDGES.has(item.kind))) {
const dependency = nodes.get(edge.from);
if (!dependency) continue;
impacts.push(createImpact(graph, changedNodeId, dependency, edge));
appendGaps(gaps, dependency, edge);
}
}

return {
tool: "CodeDecay",
schemaVersion: SERVICE_TOPOLOGY_SCHEMA_VERSION,
changedNodeIds: normalizedChanged,
impacts: uniqueBy(impacts, (impact) => impact.evidenceId).sort((left, right) => left.evidenceId.localeCompare(right.evidenceId)),
gaps: uniqueBy(gaps, (item) => item.evidenceId).sort((left, right) => left.evidenceId.localeCompare(right.evidenceId)),
safety: {
repositoriesCloned: false,
networkCalled: false,
commandsExecuted: false,
telemetrySent: false,
inferredRiskTrusted: false
}
};
}

export function renderServiceTopologyImpactMarkdown(report: ServiceTopologyImpactReport): string {
const lines = ["## CodeDecay Cross-Repository Impact", "", `Changed topology nodes: ${report.changedNodeIds.length}`, "", "### Downstream Dependencies", ""];
if (report.impacts.length === 0) lines.push("No known downstream dependency matched. This does not prove that external consumers do not exist.", "");
for (const impact of report.impacts) {
lines.push(
`- **${impact.dependencyNodeId}** via \`${impact.relationship}\` (${impact.proof}, ${impact.freshness})`,
` - Repository: \`${impact.repositoryId ?? "unresolved"}\``,
` - Deployment units: ${impact.deploymentUnitIds.map((id) => `\`${id}\``).join(", ") || "none declared"}`,
` - Owners: ${impact.ownerTeamIds.map((id) => `\`${id}\``).join(", ") || "none declared"}`,
` - Required checks: ${impact.requiredChecks.join(" ")}`
);
}
lines.push("", "### Verification Gaps", "");
if (report.gaps.length === 0) lines.push("No topology verification gaps were recorded.");
for (const item of report.gaps) lines.push(`- \`${item.reason}\` for \`${item.nodeId}\`: ${item.verificationTask}`);
lines.push("", "### Safety", "", "- No repositories cloned, commands run, network calls made, or telemetry sent.", "- Inferred and stale dependencies remain untrusted until corroborated.", "");
return `${lines.join("\n")}\n`;
}

function createImpact(graph: ServiceTopologyGraph, changedNodeId: string, dependency: ServiceTopologyNode, edge: ServiceTopologyEdge): ServiceTopologyImpact {
const related = graph.edges.filter((item) => item.from === dependency.id || item.to === dependency.id);
const deploymentUnitIds = connectedNodeIds(graph, related, dependency.id, "deployment-unit");
const ownerTeamIds = connectedNodeIds(graph, related, dependency.id, "team");
const proof = edge.freshness !== "current" || edge.confidence === "inferred" ? "untrusted" : edge.confidence;
return {
evidenceId: topologyEvidenceId([changedNodeId, dependency.id, edge.id]),
changedNodeId,
dependencyNodeId: dependency.id,
repositoryId: dependency.repositoryId,
deploymentUnitIds,
ownerTeamIds,
relationship: edge.kind,
proof,
freshness: edge.freshness,
requiredChecks: [
`Check ${dependency.id} against the changed ${changedNodeId} contract at its current revision.`,
...(dependency.available === false ? [`Make repository ${dependency.repositoryId ?? dependency.id} available or record an explicit owner decision.`] : [])
],
limitations: [...edge.limitations, ...dependency.limitations]
};
}

function appendGaps(gaps: ServiceTopologyGap[], dependency: ServiceTopologyNode, edge: ServiceTopologyEdge): void {
if (dependency.available === false) gaps.push(gap(dependency.id, dependency.repositoryId, "unavailable-repository", `Make ${dependency.repositoryId ?? dependency.id} available and run its configured compatibility checks.`));
if (edge.freshness !== "current") gaps.push(gap(dependency.id, dependency.repositoryId, "stale-dependency", `Refresh topology evidence for ${edge.id} before using it for a merge decision.`));
if (edge.confidence === "inferred") gaps.push(gap(dependency.id, dependency.repositoryId, "inferred-dependency", `Corroborate ${edge.id} with a current contract, package manifest, or runtime check.`));
if (!dependency.repositoryId) gaps.push(gap(dependency.id, undefined, "unresolved-consumer", `Declare the repository and owner for ${dependency.id}.`));
}

function gap(nodeId: string, repositoryId: string | undefined, reason: ServiceTopologyGap["reason"], verificationTask: string): ServiceTopologyGap {
return { evidenceId: topologyEvidenceId(["gap", reason, nodeId, repositoryId ?? "unknown"]), nodeId, repositoryId, reason, verificationTask };
}

function connectedNodeIds(graph: ServiceTopologyGraph, edges: ServiceTopologyEdge[], nodeId: string, kind: ServiceTopologyNode["kind"]): string[] {
const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
return [...new Set(edges.flatMap((edge) => [edge.from, edge.to]).filter((id) => id !== nodeId && nodes.get(id)?.kind === kind))].sort();
}

function uniqueBy<T>(items: T[], key: (item: T) => string): T[] {
const seen = new Set<string>();
return items.filter((item) => {
const value = key(item);
if (seen.has(value)) return false;
seen.add(value);
return true;
});
}
Loading
Loading