From eee67f33a785d59f3be0de98de26b1ea9b6ccd6c Mon Sep 17 00:00:00 2001 From: kunaldhongade Date: Sun, 2 Aug 2026 18:58:05 +0530 Subject: [PATCH 1/2] feat(knowledge): add service topology foundation --- docs/service-topology.md | 9 + packages/knowledge/package.json | 3 +- packages/knowledge/src/index.ts | 27 ++ packages/knowledge/src/topology/impact.ts | 115 ++++++++ packages/knowledge/src/topology/manifest.ts | 228 ++++++++++++++++ packages/knowledge/src/topology/types.ts | 116 +++++++++ .../knowledge/test/service-topology.test.ts | 246 ++++++++++++++++++ pnpm-lock.yaml | 3 + 8 files changed, 746 insertions(+), 1 deletion(-) create mode 100644 docs/service-topology.md create mode 100644 packages/knowledge/src/topology/impact.ts create mode 100644 packages/knowledge/src/topology/manifest.ts create mode 100644 packages/knowledge/src/topology/types.ts create mode 100644 packages/knowledge/test/service-topology.test.ts diff --git a/docs/service-topology.md b/docs/service-topology.md new file mode 100644 index 0000000..24b91fd --- /dev/null +++ b/docs/service-topology.md @@ -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. diff --git a/packages/knowledge/package.json b/packages/knowledge/package.json index bbf91d9..4706a17 100644 --- a/packages/knowledge/package.json +++ b/packages/knowledge/package.json @@ -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" diff --git a/packages/knowledge/src/index.ts b/packages/knowledge/src/index.ts index 5304b18..cd07528 100644 --- a/packages/knowledge/src/index.ts +++ b/packages/knowledge/src/index.ts @@ -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, diff --git a/packages/knowledge/src/topology/impact.ts b/packages/knowledge/src/topology/impact.ts new file mode 100644 index 0000000..6907f16 --- /dev/null +++ b/packages/knowledge/src/topology/impact.ts @@ -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(items: T[], key: (item: T) => string): T[] { + const seen = new Set(); + return items.filter((item) => { + const value = key(item); + if (seen.has(value)) return false; + seen.add(value); + return true; + }); +} diff --git a/packages/knowledge/src/topology/manifest.ts b/packages/knowledge/src/topology/manifest.ts new file mode 100644 index 0000000..6f01464 --- /dev/null +++ b/packages/knowledge/src/topology/manifest.ts @@ -0,0 +1,228 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { + SERVICE_TOPOLOGY_EDGE_KINDS, + SERVICE_TOPOLOGY_NODE_KINDS, + SERVICE_TOPOLOGY_SCHEMA_VERSION, + type ServiceTopologyEdge, + type ServiceTopologyGraph, + type ServiceTopologyNode, + type ServiceTopologySource +} from "./types"; + +export const SERVICE_TOPOLOGY_ARTIFACT_PATH = ".codedecay/local/service-topology.json"; + +export interface LoadServiceTopologyOptions { + rootDir: string; + path: string; + now?: Date | undefined; + staleAfterDays?: number | undefined; +} + +export function loadServiceTopologyManifest(options: LoadServiceTopologyOptions): ServiceTopologyGraph { + const rootDir = realpathSync(options.rootDir); + const manifestPath = resolveReadableInside(rootDir, options.path); + if (!manifestPath || !existsSync(manifestPath)) { + throw new Error(`Topology manifest not found inside repository: ${options.path}`); + } + const raw = readFileSync(manifestPath, "utf8"); + const parsed = options.path.endsWith(".json") ? JSON.parse(raw) as unknown : parseYaml(raw) as unknown; + return normalizeServiceTopologyGraph(parsed, { + manifestPath, + now: options.now ?? new Date(), + staleAfterDays: options.staleAfterDays ?? 30 + }); +} + +export function persistServiceTopologyArtifact( + rootDir: string, + graph: ServiceTopologyGraph, + artifactPath = SERVICE_TOPOLOGY_ARTIFACT_PATH +): string { + const resolvedRoot = realpathSync(rootDir); + const outputPath = resolveWritableInside(resolvedRoot, artifactPath); + if (!outputPath) throw new Error(`Topology artifact path must stay inside repository: ${artifactPath}`); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify(graph, null, 2)}\n`, "utf8"); + return artifactPath; +} + +export function normalizeServiceTopologyGraph( + value: unknown, + options: { manifestPath?: string | undefined; now?: Date | undefined; staleAfterDays?: number | undefined } = {} +): ServiceTopologyGraph { + const input = record(value, "topology manifest"); + if (input.schemaVersion !== SERVICE_TOPOLOGY_SCHEMA_VERSION) { + throw new Error(`Unsupported topology schemaVersion: ${String(input.schemaVersion)}`); + } + const now = options.now ?? new Date(); + const staleAfterMs = (options.staleAfterDays ?? 30) * 86_400_000; + const nodes = array(input.nodes, "nodes").map((node, index) => normalizeNode(node, index, options.manifestPath, now, staleAfterMs)); + const edges = array(input.edges, "edges").map((edge, index) => normalizeEdge(edge, index, options.manifestPath, now, staleAfterMs)); + assertUnique(nodes.map((node) => node.id), "node"); + assertUnique(edges.map((edge) => edge.id), "edge"); + const nodeIds = new Set(nodes.map((node) => node.id)); + for (const edge of edges) { + if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) { + throw new Error(`Topology edge ${edge.id} references a missing node.`); + } + } + return { + schemaVersion: SERVICE_TOPOLOGY_SCHEMA_VERSION, + generatedAt: optionalString(input.generatedAt), + nodes: nodes.sort((left, right) => left.id.localeCompare(right.id)), + edges: edges.sort((left, right) => left.id.localeCompare(right.id)), + limitations: stringArray(input.limitations, "limitations") + }; +} + +function normalizeNode(value: unknown, index: number, manifestPath: string | undefined, now: Date, staleAfterMs: number): ServiceTopologyNode { + const input = record(value, `nodes[${index}]`); + const kind = enumValue(input.kind, SERVICE_TOPOLOGY_NODE_KINDS, `nodes[${index}].kind`); + const sources = normalizeSources(input.sources, `nodes[${index}].sources`, manifestPath); + const freshness = normalizeFreshness(input.freshness, sources, now, staleAfterMs); + const repositoryRoot = normalizeRepositoryRoot(input.repositoryRoot, manifestPath); + return { + id: requiredString(input.id, `nodes[${index}].id`), + kind, + label: requiredString(input.label, `nodes[${index}].label`), + repositoryId: optionalString(input.repositoryId), + repositoryRoot, + available: typeof input.available === "boolean" ? input.available : repositoryRoot ? existsSync(repositoryRoot) : undefined, + confidence: confidence(input.confidence, `nodes[${index}].confidence`), + freshness, + trustClass: trustClass(input.trustClass, freshness, `nodes[${index}].trustClass`), + sources, + limitations: stringArray(input.limitations, `nodes[${index}].limitations`), + metadata: isRecord(input.metadata) ? input.metadata : undefined + }; +} + +function normalizeEdge(value: unknown, index: number, manifestPath: string | undefined, now: Date, staleAfterMs: number): ServiceTopologyEdge { + const input = record(value, `edges[${index}]`); + const sources = normalizeSources(input.sources, `edges[${index}].sources`, manifestPath); + const freshness = normalizeFreshness(input.freshness, sources, now, staleAfterMs); + return { + id: requiredString(input.id, `edges[${index}].id`), + from: requiredString(input.from, `edges[${index}].from`), + to: requiredString(input.to, `edges[${index}].to`), + kind: enumValue(input.kind, SERVICE_TOPOLOGY_EDGE_KINDS, `edges[${index}].kind`), + confidence: confidence(input.confidence, `edges[${index}].confidence`), + freshness, + trustClass: trustClass(input.trustClass, freshness, `edges[${index}].trustClass`), + sources, + limitations: stringArray(input.limitations, `edges[${index}].limitations`) + }; +} + +function normalizeSources(value: unknown, label: string, manifestPath: string | undefined): ServiceTopologySource[] { + const values = array(value, label); + if (values.length === 0) throw new Error(`${label} must include at least one source.`); + return values.map((source, index) => { + const input = record(source, `${label}[${index}]`); + return { + kind: enumValue(input.kind, ["manifest", "openapi", "asyncapi", "protobuf", "package-manager", "service-catalog", "local-graph"] as const, `${label}[${index}].kind`), + source: requiredString(input.source, `${label}[${index}].source`) || manifestPath || "manifest", + repositoryId: requiredString(input.repositoryId, `${label}[${index}].repositoryId`), + revision: requiredString(input.revision, `${label}[${index}].revision`), + observedAt: optionalString(input.observedAt) + }; + }); +} + +function normalizeFreshness(value: unknown, sources: ServiceTopologySource[], now: Date, staleAfterMs: number): "current" | "stale" | "unknown" { + if (value === "stale" || value === "unknown") return value; + if (value !== "current") throw new Error(`Invalid topology freshness: ${String(value)}`); + const observations = sources.map((source) => source.observedAt).filter((item): item is string => Boolean(item)); + if (observations.length === 0) return "unknown"; + const timestamps = observations.map((item) => new Date(item).getTime()); + if (timestamps.some((timestamp) => !Number.isFinite(timestamp))) return "unknown"; + return timestamps.some((timestamp) => now.getTime() - timestamp > staleAfterMs) ? "stale" : "current"; +} + +function trustClass(value: unknown, freshness: "current" | "stale" | "unknown", label: string): ServiceTopologyNode["trustClass"] { + const normalized = enumValue(value, ["current-revision-fact", "declared-context", "untrusted-inference", "stale-context"] as const, label); + return freshness === "current" ? normalized : "stale-context"; +} + +function confidence(value: unknown, label: string): ServiceTopologyNode["confidence"] { + return enumValue(value, ["verified", "declared", "inferred"] as const, label); +} + +function normalizeRepositoryRoot(value: unknown, manifestPath: string | undefined): string | undefined { + const root = optionalString(value); + if (!root) return undefined; + if (isAbsolute(root)) return realpathIfAvailable(root); + return manifestPath ? realpathIfAvailable(resolve(dirname(manifestPath), root)) : root; +} + +function resolveInside(rootDir: string, path: string): string | undefined { + const resolved = resolve(rootDir, path); + return resolved === rootDir || resolved.startsWith(`${rootDir}/`) ? resolved : undefined; +} + +function resolveReadableInside(rootDir: string, path: string): string | undefined { + const lexicalPath = resolveInside(rootDir, path); + if (!lexicalPath || !existsSync(lexicalPath)) return undefined; + const realPath = realpathSync(lexicalPath); + return resolveInside(rootDir, realPath); +} + +function resolveWritableInside(rootDir: string, path: string): string | undefined { + const outputPath = resolveInside(rootDir, path); + if (!outputPath) return undefined; + let existingParent = dirname(outputPath); + while (!existsSync(existingParent) && existingParent !== dirname(existingParent)) existingParent = dirname(existingParent); + const realParent = realpathSync(existingParent); + return resolveInside(rootDir, realParent) ? outputPath : undefined; +} + +function realpathIfAvailable(path: string): string { + try { return realpathSync(path); } catch { return path; } +} + +function assertUnique(ids: string[], kind: string): void { + const seen = new Set(); + for (const id of ids) { + if (seen.has(id)) throw new Error(`Duplicate topology ${kind} id: ${id}`); + seen.add(id); + } +} + +function enumValue(value: unknown, allowed: T, label: string): T[number] { + if (typeof value !== "string" || !allowed.includes(value)) throw new Error(`Invalid ${label}: ${String(value)}`); + return value as T[number]; +} + +function record(value: unknown, label: string): Record { + if (!isRecord(value)) throw new Error(`${label} must be an object.`); + return value; +} + +function array(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array.`); + return value; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) throw new Error(`${label} must be a non-empty string.`); + return value.trim(); +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function stringArray(value: unknown, label: string): string[] { + return array(value ?? [], label).map((item, index) => requiredString(item, `${label}[${index}]`)); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function topologyEvidenceId(parts: string[]): string { + return `topology:${createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, 20)}`; +} diff --git a/packages/knowledge/src/topology/types.ts b/packages/knowledge/src/topology/types.ts new file mode 100644 index 0000000..7146969 --- /dev/null +++ b/packages/knowledge/src/topology/types.ts @@ -0,0 +1,116 @@ +export const SERVICE_TOPOLOGY_SCHEMA_VERSION = 1 as const; + +export const SERVICE_TOPOLOGY_NODE_KINDS = [ + "repository", + "package", + "service", + "deployment-unit", + "api", + "event-topic", + "schema", + "datastore", + "job", + "environment", + "team" +] as const; + +export const SERVICE_TOPOLOGY_EDGE_KINDS = [ + "produces", + "consumes", + "calls", + "publishes", + "subscribes", + "reads", + "writes", + "deploys-with", + "owns", + "versioned-by", + "compatibility-requires", + "contains" +] as const; + +export type ServiceTopologyNodeKind = (typeof SERVICE_TOPOLOGY_NODE_KINDS)[number]; +export type ServiceTopologyEdgeKind = (typeof SERVICE_TOPOLOGY_EDGE_KINDS)[number]; +export type ServiceTopologyConfidence = "verified" | "declared" | "inferred"; +export type ServiceTopologyFreshness = "current" | "stale" | "unknown"; +export type ServiceTopologyTrustClass = "current-revision-fact" | "declared-context" | "untrusted-inference" | "stale-context"; + +export interface ServiceTopologySource { + kind: "manifest" | "openapi" | "asyncapi" | "protobuf" | "package-manager" | "service-catalog" | "local-graph"; + source: string; + repositoryId: string; + revision: string; + observedAt?: string | undefined; +} + +export interface ServiceTopologyNode { + id: string; + kind: ServiceTopologyNodeKind; + label: string; + repositoryId?: string | undefined; + repositoryRoot?: string | undefined; + available?: boolean | undefined; + confidence: ServiceTopologyConfidence; + freshness: ServiceTopologyFreshness; + trustClass: ServiceTopologyTrustClass; + sources: ServiceTopologySource[]; + limitations: string[]; + metadata?: Record | undefined; +} + +export interface ServiceTopologyEdge { + id: string; + from: string; + to: string; + kind: ServiceTopologyEdgeKind; + confidence: ServiceTopologyConfidence; + freshness: ServiceTopologyFreshness; + trustClass: ServiceTopologyTrustClass; + sources: ServiceTopologySource[]; + limitations: string[]; +} + +export interface ServiceTopologyGraph { + schemaVersion: typeof SERVICE_TOPOLOGY_SCHEMA_VERSION; + generatedAt?: string | undefined; + nodes: ServiceTopologyNode[]; + edges: ServiceTopologyEdge[]; + limitations: string[]; +} + +export interface ServiceTopologyImpact { + evidenceId: string; + changedNodeId: string; + dependencyNodeId: string; + repositoryId?: string | undefined; + deploymentUnitIds: string[]; + ownerTeamIds: string[]; + relationship: ServiceTopologyEdgeKind; + proof: "verified" | "declared" | "untrusted"; + freshness: ServiceTopologyFreshness; + requiredChecks: string[]; + limitations: string[]; +} + +export interface ServiceTopologyGap { + evidenceId: string; + nodeId: string; + repositoryId?: string | undefined; + reason: "unavailable-repository" | "stale-dependency" | "unresolved-consumer" | "inferred-dependency"; + verificationTask: string; +} + +export interface ServiceTopologyImpactReport { + tool: "CodeDecay"; + schemaVersion: typeof SERVICE_TOPOLOGY_SCHEMA_VERSION; + changedNodeIds: string[]; + impacts: ServiceTopologyImpact[]; + gaps: ServiceTopologyGap[]; + safety: { + repositoriesCloned: false; + networkCalled: false; + commandsExecuted: false; + telemetrySent: false; + inferredRiskTrusted: false; + }; +} diff --git a/packages/knowledge/test/service-topology.test.ts b/packages/knowledge/test/service-topology.test.ts new file mode 100644 index 0000000..cd503af --- /dev/null +++ b/packages/knowledge/test/service-topology.test.ts @@ -0,0 +1,246 @@ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + analyzeServiceTopologyImpact, + loadServiceTopologyManifest, + normalizeServiceTopologyGraph, + persistServiceTopologyArtifact, + renderServiceTopologyImpactMarkdown, + type ServiceTopologyGraph +} from "../src"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("cross-repository service topology", () => { + it("finds only the declared downstream API consumer, deployment, and owner", () => { + const graph = normalizeServiceTopologyGraph(topologyFixture()); + const report = analyzeServiceTopologyImpact(graph, ["api:billing:v1"]); + + expect(report.impacts).toEqual([ + expect.objectContaining({ + changedNodeId: "api:billing:v1", + dependencyNodeId: "service:checkout", + repositoryId: "repo:checkout", + deploymentUnitIds: ["deployment:checkout"], + ownerTeamIds: ["team:payments"], + relationship: "calls", + proof: "declared", + freshness: "current" + }) + ]); + expect(report.impacts.map((impact) => impact.dependencyNodeId)).not.toContain("service:decoy"); + expect(report.gaps).toEqual([]); + expect(report.safety).toEqual({ + repositoriesCloned: false, + networkCalled: false, + commandsExecuted: false, + telemetrySent: false, + inferredRiskTrusted: false + }); + expect(renderServiceTopologyImpactMarkdown(report)).toContain("Check service:checkout against the changed api:billing:v1 contract"); + }); + + it("keeps stale, inferred, unavailable, and unresolved consumers as explicit gaps", () => { + const fixture = topologyFixture(); + fixture.nodes.push(node("service:legacy", "service", { repositoryId: "repo:legacy", available: false })); + fixture.edges.push(edge("edge:legacy-calls-api", "service:legacy", "api:billing:v1", "calls", { + confidence: "inferred", + freshness: "current", + observedAt: "2025-01-01T00:00:00.000Z" + })); + const graph = normalizeServiceTopologyGraph(fixture, { now: new Date("2026-08-02T00:00:00.000Z"), staleAfterDays: 30 }); + const report = analyzeServiceTopologyImpact(graph, ["api:billing:v1"]); + const legacy = report.impacts.find((impact) => impact.dependencyNodeId === "service:legacy"); + + expect(legacy).toMatchObject({ proof: "untrusted", freshness: "stale" }); + expect(report.gaps.map((gap) => gap.reason)).toEqual(expect.arrayContaining([ + "unavailable-repository", + "stale-dependency", + "inferred-dependency" + ])); + expect(report.gaps.every((gap) => gap.verificationTask.length > 0)).toBe(true); + }); + + it("loads a reviewable YAML manifest without resolving roots outside the configured repository", () => { + const rootDir = tempRoot(); + write(rootDir, "topology.yml", [ + "schemaVersion: 1", + "nodes:", + " - id: repo:local", + " kind: repository", + " label: Local repository", + " repositoryRoot: .", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:local", + " revision: abc123", + " observedAt: 2026-08-02T00:00:00.000Z", + " limitations: []", + " - id: repo:missing", + " kind: repository", + " label: Missing repository", + " repositoryRoot: ./missing", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:local", + " revision: abc123", + " observedAt: 2026-08-02T00:00:00.000Z", + " limitations: []", + "edges: []", + "limitations: []", + "" + ].join("\n")); + + const graph = loadServiceTopologyManifest({ rootDir, path: "topology.yml", now: new Date("2026-08-02T00:00:00.000Z") }); + + expect(graph.nodes.find((node) => node.id === "repo:local")).toMatchObject({ repositoryRoot: realpathSync(rootDir), freshness: "current", available: true }); + expect(graph.nodes.find((node) => node.id === "repo:missing")).toMatchObject({ available: false }); + expect(() => loadServiceTopologyManifest({ rootDir, path: "../topology.yml" })).toThrow(/not found inside repository/i); + }); + + it("rejects duplicate IDs, missing edge targets, and source-free evidence", () => { + const duplicate = topologyFixture(); + duplicate.nodes.push({ ...duplicate.nodes[0] as FixtureNode }); + expect(() => normalizeServiceTopologyGraph(duplicate)).toThrow(/duplicate topology node id/i); + + const missing = topologyFixture(); + missing.edges.push(edge("edge:missing", "service:checkout", "api:missing", "calls")); + expect(() => normalizeServiceTopologyGraph(missing)).toThrow(/references a missing node/i); + + const sourceFree = topologyFixture(); + sourceFree.nodes[0] = { ...sourceFree.nodes[0], sources: [] }; + expect(() => normalizeServiceTopologyGraph(sourceFree)).toThrow(/must include at least one source/i); + }); + + it("keeps stable evidence IDs regardless of manifest ordering", () => { + const first = normalizeServiceTopologyGraph(topologyFixture()); + const reversedInput = topologyFixture(); + reversedInput.nodes.reverse(); + reversedInput.edges.reverse(); + const second = normalizeServiceTopologyGraph(reversedInput); + + expect(analyzeServiceTopologyImpact(first, ["api:billing:v1"]).impacts.map((impact) => impact.evidenceId)).toEqual( + analyzeServiceTopologyImpact(second, ["api:billing:v1"]).impacts.map((impact) => impact.evidenceId) + ); + }); + + it("persists an inspectable artifact only inside the repository", () => { + const rootDir = tempRoot(); + const graph = normalizeServiceTopologyGraph(topologyFixture()); + + const artifactPath = persistServiceTopologyArtifact(rootDir, graph); + + expect(artifactPath).toBe(".codedecay/local/service-topology.json"); + expect(JSON.parse(readFileSync(join(rootDir, artifactPath), "utf8"))).toMatchObject({ schemaVersion: 1 }); + expect(() => persistServiceTopologyArtifact(rootDir, graph, "../topology.json")).toThrow(/must stay inside repository/i); + }); + + it("does not read or write through symlinks that escape the repository", () => { + const rootDir = tempRoot(); + const outsideDir = tempRoot(); + write(outsideDir, "topology.yml", "schemaVersion: 1\nnodes: []\nedges: []\nlimitations: []\n"); + symlinkSync(join(outsideDir, "topology.yml"), join(rootDir, "escaped.yml")); + symlinkSync(outsideDir, join(rootDir, "escaped-output")); + const graph = normalizeServiceTopologyGraph(topologyFixture()); + + expect(() => loadServiceTopologyManifest({ rootDir, path: "escaped.yml" })).toThrow(/not found inside repository/i); + expect(() => persistServiceTopologyArtifact(rootDir, graph, "escaped-output/topology.json")).toThrow(/must stay inside repository/i); + }); + + it("never treats missing or malformed observation timestamps as current proof", () => { + const fixture = topologyFixture(); + fixture.edges[0] = { + ...fixture.edges[0], + confidence: "verified", + sources: [{ kind: "manifest", source: "topology.yml", repositoryId: "repo:topology", revision: "abc123", observedAt: "not-a-date" }] + }; + const graph = normalizeServiceTopologyGraph(fixture); + const report = analyzeServiceTopologyImpact(graph, ["api:billing:v1"]); + + expect(graph.edges.find((edge) => edge.id === "edge:checkout-calls-billing")).toMatchObject({ freshness: "unknown", trustClass: "stale-context" }); + expect(report.impacts[0]).toMatchObject({ proof: "untrusted", freshness: "unknown" }); + expect(report.gaps.map((gap) => gap.reason)).toContain("stale-dependency"); + }); +}); + +type FixtureNode = Record; +type FixtureEdge = Record; + +function topologyFixture(): { schemaVersion: 1; nodes: FixtureNode[]; edges: FixtureEdge[]; limitations: string[] } { + return { + schemaVersion: 1, + nodes: [ + node("api:billing:v1", "api", { repositoryId: "repo:billing" }), + node("service:checkout", "service", { repositoryId: "repo:checkout", available: true }), + node("deployment:checkout", "deployment-unit", { repositoryId: "repo:checkout" }), + node("team:payments", "team"), + node("service:decoy", "service", { repositoryId: "repo:decoy", available: true }) + ], + edges: [ + edge("edge:checkout-calls-billing", "service:checkout", "api:billing:v1", "calls"), + edge("edge:checkout-deploys", "service:checkout", "deployment:checkout", "deploys-with"), + edge("edge:payments-owns-checkout", "team:payments", "service:checkout", "owns") + ], + limitations: ["Only explicitly configured repositories are represented."] + }; +} + +function node(id: string, kind: string, extra: Record = {}): FixtureNode { + return { + id, + kind, + label: id, + confidence: "declared", + freshness: "current", + trustClass: "declared-context", + sources: [{ kind: "manifest", source: "topology.yml", repositoryId: String(extra.repositoryId ?? "repo:topology"), revision: "abc123", observedAt: "2026-08-02T00:00:00.000Z" }], + limitations: [], + ...extra + }; +} + +function edge( + id: string, + from: string, + to: string, + kind: string, + extra: { confidence?: string; freshness?: string; observedAt?: string } = {} +): FixtureEdge { + return { + id, + from, + to, + kind, + confidence: extra.confidence ?? "declared", + freshness: extra.freshness ?? "current", + trustClass: "declared-context", + sources: [{ kind: "manifest", source: "topology.yml", repositoryId: "repo:topology", revision: "abc123", observedAt: extra.observedAt ?? "2026-08-02T00:00:00.000Z" }], + limitations: [] + }; +} + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "codedecay-topology-")); + roots.push(root); + return root; +} + +function write(root: string, path: string, content: string): void { + const absolute = join(root, path); + mkdirSync(dirname(absolute), { recursive: true }); + writeFileSync(absolute, content, "utf8"); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 921ee16..6d70205 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 + yaml: + specifier: ^2.8.1 + version: 2.9.0 packages/llm: dependencies: From 9c9fb2fc0b1d0a6bddcf7d60a1e57d3c4f977672 Mon Sep 17 00:00:00 2001 From: kunaldhongade Date: Sun, 2 Aug 2026 19:02:36 +0530 Subject: [PATCH 2/2] fix(judge-lab): align browser acceptance with current UI --- judge-lab/app/globals.css | 4 +++- judge-lab/app/judge-lab.tsx | 26 ++++++++++++++++++-------- judge-lab/tests/judge-lab.spec.ts | 4 +++- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/judge-lab/app/globals.css b/judge-lab/app/globals.css index 085d35b..dfffa3a 100644 --- a/judge-lab/app/globals.css +++ b/judge-lab/app/globals.css @@ -577,7 +577,7 @@ a:focus-visible { text-transform: uppercase; } -.use-grid pre { +.command-snippet { background: #0b0b0a; border: 1px solid var(--line); color: #c8c4b9; @@ -587,7 +587,9 @@ a:focus-visible { margin: 20px 0; overflow-x: auto; padding: 18px; + resize: none; white-space: pre; + width: 100%; } .scenario-picker { diff --git a/judge-lab/app/judge-lab.tsx b/judge-lab/app/judge-lab.tsx index c391577..7ac31b5 100644 --- a/judge-lab/app/judge-lab.tsx +++ b/judge-lab/app/judge-lab.tsx @@ -261,10 +261,15 @@ export function JudgeLab({ engineVersion, sourceCommit, scenarios }: JudgeLabPro
TRY ON YOUR REPO

Run the CLI where your code lives.

-
-              {`npx @submuxhq/codedecay@0.4.1 redteam --base main --head HEAD --format markdown
-npx @submuxhq/codedecay@0.4.1 agent --base main --head HEAD`}
-            
+