diff --git a/docs/service-topology.md b/docs/service-topology.md index 24b91fd..782cd18 100644 --- a/docs/service-topology.md +++ b/docs/service-topology.md @@ -1,9 +1,44 @@ # 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. +CodeDecay's service-topology model covers repositories, packages, services, +deployment units, APIs, event topics, schemas, datastores, jobs, environments, +and teams. Edges include produces, consumes, calls, publishes, subscribes, +reads, writes, deploys-with, owns, versioned-by, compatibility-requires, and +contains. -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. +Every node and edge carries source, repository/revision, confidence, freshness, +trust class, and limitations. Topology is local-first: explicit repository roots +and reviewable manifests only. There is no hidden cloning, network discovery, +install, model call, or telemetry. -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. +## Adapters -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. +| Source | Adapter | Parser choice | +|---|---|---| +| Topology manifest | YAML/JSON loader | Maintained `yaml` package for YAML; `JSON.parse` for JSON | +| OpenAPI 3 | Local contract adapter | Maintained `yaml` / JSON parse; remote `$ref` blocked | +| AsyncAPI 2/3 | Local contract adapter | Maintained `yaml` / JSON parse; remote `$ref` blocked | +| Local engineering/impact graph | `contains` linker | Reuses `#676` local graph artifacts | + +Rejected alternatives for this slice: hosted service catalogs, automatic git +clone fans-out, and network-resolving OpenAPI/AsyncAPI parsers that fetch remote +refs by default. + +## CLI / MCP + +```bash +codedecay topology --manifest topology.yml --changed api:billing:v1 --format json +codedecay topology --manifest topology.yml --openapi docs/openapi.yaml --asyncapi docs/asyncapi.yaml --invalidate docs/openapi.yaml +``` + +MCP tool: `service_topology`. + +Normalized artifacts are written to `.codedecay/local/service-topology.json`. +Incremental `--invalidate` rewrites only affected contract-linked nodes/edges. + +## Trust rules + +- Verified/declared current edges can produce downstream impact tasks. +- Inferred or stale edges emit verification gaps and never raise trusted risk alone. +- Unavailable repositories remain explicit gaps. +- Agent tasks include owners, repositories, and corroboration work. diff --git a/packages/cli/src/commands/registry.ts b/packages/cli/src/commands/registry.ts index caece53..84a37e8 100644 --- a/packages/cli/src/commands/registry.ts +++ b/packages/cli/src/commands/registry.ts @@ -23,6 +23,7 @@ import { runRevalidateCommand as runRevalidateCommandWithDependencies } from "./ import { runRuntimeCommand as runRuntimeCommandWithDependencies } from "./runtime"; import { runSessionCommand as runSessionCommandWithDependencies } from "./session"; import { runSnapshotCommand as runSnapshotCommandWithDependencies } from "./snapshot"; +import { runTopologyCommand as runTopologyCommandWithDependencies } from "./topology"; import { createProductTargetReport as createProductTargetReportWithRuntime } from "../product/runtime"; import { renderProductTargetReport } from "../renderers/product-target-report"; import { @@ -137,6 +138,10 @@ export function createCommandHandlers(options: CommandRegistryOptions): Record runTopologyCommandWithDependencies(context, { + resolveRepoRoot: getRepoRootForCli, + writeOutput: writeCliOutput }) }; } diff --git a/packages/cli/src/commands/topology.ts b/packages/cli/src/commands/topology.ts new file mode 100644 index 0000000..41f8a88 --- /dev/null +++ b/packages/cli/src/commands/topology.ts @@ -0,0 +1,37 @@ +import { resolve } from "node:path"; +import { + buildServiceTopologyReport, + renderServiceTopologyReportMarkdown +} from "@submuxhq/codedecay-knowledge"; +import { parseTopologyArgs } from "../parsers/args"; +import type { CliCommandContext, CliRuntime, TopologyOptions } from "../types"; + +export interface RunTopologyCommandDependencies { + resolveRepoRoot(cwd: string, options: TopologyOptions): string; + writeOutput(input: { cwd: string; output?: string | undefined; rendered: string; runtime: CliRuntime }): void; +} + +export function runTopologyCommand(context: CliCommandContext, dependencies: RunTopologyCommandDependencies): void { + const options = parseTopologyArgs(context.args); + const cwd = resolve(context.runtimeCwd, options.cwd ?? "."); + const rootDir = dependencies.resolveRepoRoot(cwd, options); + const report = buildServiceTopologyReport({ + rootDir, + manifest: options.manifest, + openapi: options.openapi, + asyncapi: options.asyncapi, + localGraph: options.localGraph, + changedNodeIds: options.changed, + invalidatePaths: options.invalidate, + repositoryId: options.repositoryId, + revision: options.revision, + producerServiceId: options.producerServiceId, + publisherServiceId: options.publisherServiceId, + subscriberServiceId: options.subscriberServiceId + }); + const rendered = + options.format === "json" + ? `${JSON.stringify(report, null, 2)}\n` + : renderServiceTopologyReportMarkdown(report); + dependencies.writeOutput({ cwd: rootDir, output: options.output, rendered, runtime: context.runtime }); +} diff --git a/packages/cli/src/docs/command-docs/analysis.ts b/packages/cli/src/docs/command-docs/analysis.ts index 772aeaa..4ecea7d 100644 --- a/packages/cli/src/docs/command-docs/analysis.ts +++ b/packages/cli/src/docs/command-docs/analysis.ts @@ -38,6 +38,39 @@ export const ANALYSIS_COMMAND_DOCS: Record = { ], notes: ["Inputs must resolve inside the repository. The command performs no network calls or project command execution."] }, + topology: { + name: "topology", + summary: "Model cross-repository services and deployment dependencies.", + usage: ["codedecay topology [options]"], + description: [ + "Load a reviewable topology manifest plus local OpenAPI/AsyncAPI contracts, merge repository-local graph evidence, and report downstream consumers, owners, deployments, and verification gaps.", + "Local-only: no repository cloning, remote $ref fetch, network discovery, model calls, installs, or telemetry." + ], + options: [ + { flag: "--manifest ", description: "Repo-local topology YAML/JSON manifest" }, + { flag: "--openapi ", description: "Repo-local OpenAPI 3 contract; repeatable" }, + { flag: "--asyncapi ", description: "Repo-local AsyncAPI 2/3 contract; repeatable" }, + { flag: "--local-graph ", description: "Optional engineering/impact graph JSON to link as contains edges" }, + { flag: "--changed ", description: "Changed topology node id; repeatable" }, + { flag: "--invalidate ", description: "Contract/manifest path to incrementally rebuild; repeatable" }, + { flag: "--repository-id ", description: "Repository id stamped onto contract-derived nodes" }, + { flag: "--revision ", description: "Source revision stamped onto contract-derived nodes" }, + { flag: "--producer-service ", description: "Optional service id that produces OpenAPI operations" }, + { flag: "--publisher-service ", description: "Optional service id that publishes AsyncAPI channels" }, + { flag: "--subscriber-service ", description: "Optional service id that subscribes to AsyncAPI channels" }, + { flag: "--cwd ", description: "Repository working directory (default: current directory)" }, + { flag: "--format ", description: "json or markdown (default: markdown)" }, + { flag: "--output ", description: "Write the topology report to a file instead of stdout" } + ], + examples: [ + "codedecay topology --manifest topology.yml --changed api:billing:v1 --format json", + "codedecay topology --manifest topology.yml --openapi docs/openapi.yaml --asyncapi docs/asyncapi.yaml --invalidate docs/openapi.yaml" + ], + notes: [ + "Stale and inferred dependencies remain untrusted and emit corroboration tasks instead of merge-safe proof.", + "Normalized artifacts are written to `.codedecay/local/service-topology.json`." + ] + }, analyze: { name: "analyze", summary: "Deterministic PR risk, impact, and decay report.", diff --git a/packages/cli/src/docs/command-docs/order.ts b/packages/cli/src/docs/command-docs/order.ts index 3466a41..2e73c87 100644 --- a/packages/cli/src/docs/command-docs/order.ts +++ b/packages/cli/src/docs/command-docs/order.ts @@ -1,3 +1,3 @@ -export const COMMAND_ORDER = ["ai", "session", "context", "analyze", "runtime", "migration", "benchmark", "snapshot", "redteam", "revalidate", "llm-review", "agent", "loop", "doctor", "config", "memory", "memory-import", "memory-learn", "execute", "differential", "product", "dashboard", "mcp"] as const; +export const COMMAND_ORDER = ["ai", "session", "context", "analyze", "runtime", "migration", "topology", "benchmark", "snapshot", "redteam", "revalidate", "llm-review", "agent", "loop", "doctor", "config", "memory", "memory-import", "memory-learn", "execute", "differential", "product", "dashboard", "mcp"] as const; export const UTILITY_COMMAND_ORDER = ["help", "man", "update", "uninstall", "version"] as const; export const ROOT_FLAG_ALIASES = ["--help", "-h", "--version", "-V"] as const; diff --git a/packages/cli/src/parsers/args.ts b/packages/cli/src/parsers/args.ts index 371954e..08e352b 100644 --- a/packages/cli/src/parsers/args.ts +++ b/packages/cli/src/parsers/args.ts @@ -19,5 +19,6 @@ export { parseProductArgs } from "./product"; export { parseRedteamArgs } from "./redteam"; export { parseSessionArgs } from "./session"; export { parseSnapshotArgs } from "./snapshot"; +export { parseTopologyArgs } from "./topology"; export { parseUninstallArgs, parseUpdateArgs } from "./maintenance"; export { HelpRequested } from "./shared"; diff --git a/packages/cli/src/parsers/topology.ts b/packages/cli/src/parsers/topology.ts new file mode 100644 index 0000000..85bdcd1 --- /dev/null +++ b/packages/cli/src/parsers/topology.ts @@ -0,0 +1,52 @@ +import type { TopologyOptions } from "../types"; +import { requireValue } from "./primitives"; +import { HelpRequested, throwUnknownOption } from "./shared"; + +export function parseTopologyArgs(args: string[]): TopologyOptions { + const options: TopologyOptions = { + format: "markdown", + openapi: [], + asyncapi: [], + changed: [], + invalidate: [] + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg) continue; + if (arg === "--help" || arg === "-h") throw new HelpRequested(); + const [flag, inline] = splitArg(arg); + const value = () => inline ?? requireValue(args, index, flag); + if (flag === "--cwd") options.cwd = value(); + else if (flag === "--output") options.output = value(); + else if (flag === "--format") options.format = parseFormat(value()); + else if (flag === "--manifest") options.manifest = value(); + else if (flag === "--openapi") options.openapi.push(value()); + else if (flag === "--asyncapi") options.asyncapi.push(value()); + else if (flag === "--local-graph") options.localGraph = value(); + else if (flag === "--changed") options.changed.push(value()); + else if (flag === "--invalidate") options.invalidate.push(value()); + else if (flag === "--repository-id") options.repositoryId = value(); + else if (flag === "--revision") options.revision = value(); + else if (flag === "--producer-service") options.producerServiceId = value(); + else if (flag === "--publisher-service") options.publisherServiceId = value(); + else if (flag === "--subscriber-service") options.subscriberServiceId = value(); + else { + throwUnknownOption(arg, "topology"); + continue; + } + if (inline === undefined) index += 1; + } + + return options; +} + +function splitArg(arg: string): [string, string | undefined] { + const index = arg.indexOf("="); + return index < 0 ? [arg, undefined] : [arg.slice(0, index), arg.slice(index + 1)]; +} + +function parseFormat(value: string): TopologyOptions["format"] { + if (value === "json" || value === "markdown") return value; + throw new Error(`Invalid topology format "${value}". Expected json or markdown.`); +} diff --git a/packages/cli/src/types/index.ts b/packages/cli/src/types/index.ts index ab8122a..8e3ad34 100644 --- a/packages/cli/src/types/index.ts +++ b/packages/cli/src/types/index.ts @@ -21,3 +21,4 @@ export * from "./revalidate"; export * from "./runtime"; export * from "./session"; export * from "./snapshot"; +export * from "./topology"; diff --git a/packages/cli/src/types/topology.ts b/packages/cli/src/types/topology.ts new file mode 100644 index 0000000..9250cb9 --- /dev/null +++ b/packages/cli/src/types/topology.ts @@ -0,0 +1,18 @@ +import type { ConfigFormat } from "./common"; + +export interface TopologyOptions { + cwd?: string | undefined; + format: ConfigFormat; + output?: string | undefined; + manifest?: string | undefined; + openapi: string[]; + asyncapi: string[]; + localGraph?: string | undefined; + changed: string[]; + invalidate: string[]; + repositoryId?: string | undefined; + revision?: string | undefined; + producerServiceId?: string | undefined; + publisherServiceId?: string | undefined; + subscriberServiceId?: string | undefined; +} diff --git a/packages/knowledge/src/index.ts b/packages/knowledge/src/index.ts index 7fb0378..ebf57a0 100644 --- a/packages/knowledge/src/index.ts +++ b/packages/knowledge/src/index.ts @@ -51,6 +51,17 @@ export { topologyEvidenceId } from "./topology/manifest"; export { analyzeServiceTopologyImpact, renderServiceTopologyImpactMarkdown } from "./topology/impact"; +export { + buildServiceTopologyReport, + createTopologyAgentTasks, + mergeTopologyGraphs, + renderServiceTopologyReportMarkdown +} from "./topology/compose"; +export { parseOpenApiTopology, topologyContractId } from "./topology/contracts/openapi"; +export { parseAsyncApiTopology } from "./topology/contracts/asyncapi"; +export type { BuildServiceTopologyOptions, ServiceTopologyAgentTask, ServiceTopologyReport } from "./topology/compose"; +export type { ParseOpenApiTopologyOptions } from "./topology/contracts/openapi"; +export type { ParseAsyncApiTopologyOptions } from "./topology/contracts/asyncapi"; export { SERVICE_TOPOLOGY_EDGE_KINDS, SERVICE_TOPOLOGY_NODE_KINDS, diff --git a/packages/knowledge/src/topology/compose.ts b/packages/knowledge/src/topology/compose.ts new file mode 100644 index 0000000..6b8c501 --- /dev/null +++ b/packages/knowledge/src/topology/compose.ts @@ -0,0 +1,396 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { + loadServiceTopologyManifest, + normalizeServiceTopologyGraph, + persistServiceTopologyArtifact, + SERVICE_TOPOLOGY_ARTIFACT_PATH, + topologyEvidenceId +} from "./manifest"; +import { analyzeServiceTopologyImpact, renderServiceTopologyImpactMarkdown } from "./impact"; +import { parseOpenApiTopology } from "./contracts/openapi"; +import { parseAsyncApiTopology } from "./contracts/asyncapi"; +import type { + ServiceTopologyEdge, + ServiceTopologyGraph, + ServiceTopologyImpactReport, + ServiceTopologyNode +} from "./types"; +import { SERVICE_TOPOLOGY_SCHEMA_VERSION } from "./types"; +import { resolveGitSourceRevision } from "../context"; + +export interface BuildServiceTopologyOptions { + rootDir: string; + manifest?: string | undefined; + openapi?: string[] | undefined; + asyncapi?: string[] | undefined; + localGraph?: string | undefined; + repositoryId?: string | undefined; + revision?: string | undefined; + producerServiceId?: string | undefined; + publisherServiceId?: string | undefined; + subscriberServiceId?: string | undefined; + invalidatePaths?: string[] | undefined; + changedNodeIds?: string[] | undefined; + now?: Date | undefined; + persist?: boolean | undefined; +} + +export interface ServiceTopologyReport { + tool: "CodeDecay"; + schemaVersion: typeof SERVICE_TOPOLOGY_SCHEMA_VERSION; + graph: ServiceTopologyGraph; + impact: ServiceTopologyImpactReport; + agentTasks: ServiceTopologyAgentTask[]; + artifactPath?: string | undefined; + invalidatedPaths: string[]; + adapters: Array<"manifest" | "openapi" | "asyncapi" | "local-graph">; + safety: ServiceTopologyImpactReport["safety"]; +} + +export interface ServiceTopologyAgentTask { + evidenceId: string; + title: string; + detail: string; + priority: "high" | "medium" | "low"; + ownerTeamIds: string[]; + repositoryId?: string | undefined; +} + +export function buildServiceTopologyReport(options: BuildServiceTopologyOptions): ServiceTopologyReport { + const rootDir = realpathSync(options.rootDir); + const revision = options.revision ?? resolveGitSourceRevision(rootDir); + const repositoryId = options.repositoryId ?? `repo:${hashShort(rootDir)}`; + const now = options.now ?? new Date(); + const invalidatedPaths = [...new Set((options.invalidatePaths ?? []).map(normalizePath))].sort(); + const adapters: ServiceTopologyReport["adapters"] = []; + const parts: ServiceTopologyGraph[] = []; + const previous = invalidatedPaths.length > 0 ? loadPersistedTopology(rootDir) : undefined; + + if (previous && invalidatedPaths.length > 0) { + parts.push(stripSources(previous, invalidatedPaths)); + } + + if (options.manifest && (invalidatedPaths.length === 0 || invalidatedPaths.includes(normalizePath(options.manifest)) || !previous)) { + adapters.push("manifest"); + parts.push(loadServiceTopologyManifest({ rootDir, path: options.manifest, now })); + } else if (options.manifest) { + adapters.push("manifest"); + } + + for (const path of options.openapi ?? []) { + if (invalidatedPaths.length > 0 && previous && !invalidatedPaths.includes(normalizePath(path))) { + adapters.push("openapi"); + continue; + } + adapters.push("openapi"); + parts.push( + parseOpenApiTopology({ + rootDir, + path, + repositoryId, + revision, + now, + producerServiceId: options.producerServiceId + }) + ); + } + + for (const path of options.asyncapi ?? []) { + if (invalidatedPaths.length > 0 && previous && !invalidatedPaths.includes(normalizePath(path))) { + adapters.push("asyncapi"); + continue; + } + adapters.push("asyncapi"); + parts.push( + parseAsyncApiTopology({ + rootDir, + path, + repositoryId, + revision, + now, + publisherServiceId: options.publisherServiceId, + subscriberServiceId: options.subscriberServiceId + }) + ); + } + + if (options.localGraph) { + if (!(invalidatedPaths.length > 0 && previous && !invalidatedPaths.includes(normalizePath(options.localGraph)))) { + adapters.push("local-graph"); + parts.push(loadLocalGraphTopology({ + rootDir, + path: options.localGraph, + repositoryId, + revision, + now + })); + } else { + adapters.push("local-graph"); + } + } + + if (parts.length === 0) { + throw new Error("topology requires --manifest, --openapi, --asyncapi, and/or --local-graph."); + } + + const graph = mergeTopologyGraphs(parts, now); + const changedNodeIds = options.changedNodeIds?.length + ? options.changedNodeIds + : inferChangedNodes(graph, invalidatedPaths); + const impact = analyzeServiceTopologyImpact(graph, changedNodeIds); + const agentTasks = createTopologyAgentTasks(impact); + const artifactPath = options.persist === false ? undefined : persistServiceTopologyArtifact(rootDir, graph); + + return { + tool: "CodeDecay", + schemaVersion: SERVICE_TOPOLOGY_SCHEMA_VERSION, + graph, + impact, + agentTasks, + artifactPath, + invalidatedPaths, + adapters: [...new Set(adapters)], + safety: impact.safety + }; +} + +export function renderServiceTopologyReportMarkdown(report: ServiceTopologyReport): string { + const lines = [ + "## CodeDecay Service Topology", + "", + `**Adapters:** ${report.adapters.join(", ") || "none"}`, + `**Nodes:** ${report.graph.nodes.length}`, + `**Edges:** ${report.graph.edges.length}`, + `**Invalidated paths:** ${report.invalidatedPaths.map((path) => `\`${path}\``).join(", ") || "none"}`, + report.artifactPath ? `**Artifact:** \`${report.artifactPath}\`` : "**Artifact:** not persisted", + "", + "### Graph Limitations", + "" + ]; + for (const limitation of report.graph.limitations) lines.push(`- ${limitation}`); + lines.push("", renderServiceTopologyImpactMarkdown(report.impact).trimEnd(), "", "### Agent Tasks", ""); + if (report.agentTasks.length === 0) { + lines.push("No cross-repository agent tasks were generated."); + } else { + for (const task of report.agentTasks) { + lines.push( + `- **${task.title}** (${task.priority}) \`${task.evidenceId}\``, + ` - ${task.detail}`, + ` - Owners: ${task.ownerTeamIds.map((id) => `\`${id}\``).join(", ") || "none declared"}`, + ` - Repository: \`${task.repositoryId ?? "unresolved"}\`` + ); + } + } + lines.push(""); + return `${lines.join("\n")}\n`; +} + +export function createTopologyAgentTasks(impact: ServiceTopologyImpactReport): ServiceTopologyAgentTask[] { + const tasks: ServiceTopologyAgentTask[] = []; + for (const item of impact.impacts) { + tasks.push({ + evidenceId: item.evidenceId, + title: `Verify ${item.dependencyNodeId} against ${item.changedNodeId}`, + detail: item.requiredChecks.join(" "), + priority: item.proof === "untrusted" || item.freshness !== "current" ? "high" : "medium", + ownerTeamIds: item.ownerTeamIds, + repositoryId: item.repositoryId + }); + } + for (const gap of impact.gaps) { + tasks.push({ + evidenceId: gap.evidenceId, + title: `Close topology gap: ${gap.reason}`, + detail: gap.verificationTask, + priority: "high", + ownerTeamIds: [], + repositoryId: gap.repositoryId + }); + } + return uniqueBy(tasks, (task) => task.evidenceId).sort((left, right) => left.evidenceId.localeCompare(right.evidenceId)); +} + +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; + }); +} + +export function mergeTopologyGraphs(parts: ServiceTopologyGraph[], now = new Date()): ServiceTopologyGraph { + const nodes = new Map(); + const edges = new Map(); + const limitations = new Set([ + "Merged local topology is inspectable and never clones remote repositories or trusts inferred edges alone." + ]); + for (const part of parts) { + for (const limitation of part.limitations) limitations.add(limitation); + for (const node of part.nodes) nodes.set(node.id, node); + for (const edge of part.edges) edges.set(edge.id, edge); + } + return normalizeServiceTopologyGraph({ + schemaVersion: SERVICE_TOPOLOGY_SCHEMA_VERSION, + generatedAt: now.toISOString(), + nodes: [...nodes.values()], + edges: [...edges.values()], + limitations: [...limitations].sort() + }, { now }); +} + +function loadPersistedTopology(rootDir: string): ServiceTopologyGraph | undefined { + const path = resolve(rootDir, SERVICE_TOPOLOGY_ARTIFACT_PATH); + if (!existsSync(path)) return undefined; + try { + return normalizeServiceTopologyGraph(JSON.parse(readFileSync(path, "utf8")) as unknown); + } catch { + return undefined; + } +} + +function stripSources(graph: ServiceTopologyGraph, invalidatedPaths: string[]): ServiceTopologyGraph { + const invalidated = new Set(invalidatedPaths); + const nodes = graph.nodes.filter((node) => !nodeTouchesInvalidated(node, invalidated)); + const nodeIds = new Set(nodes.map((node) => node.id)); + const edges = graph.edges.filter( + (edge) => + nodeIds.has(edge.from) && + nodeIds.has(edge.to) && + !edge.sources.some((source) => invalidated.has(normalizePath(source.source))) + ); + return { + ...graph, + nodes, + edges, + limitations: [ + ...graph.limitations, + "Incremental topology update retained unaffected nodes/edges and rewrote only invalidated contract sources." + ] + }; +} + +function nodeTouchesInvalidated(node: ServiceTopologyNode, invalidated: Set): boolean { + if (node.sources.some((source) => invalidated.has(normalizePath(source.source)))) return true; + const openapiPath = node.metadata?.openapiPath; + const asyncapiPath = node.metadata?.asyncapiPath; + return ( + (typeof openapiPath === "string" && invalidated.has(normalizePath(openapiPath))) || + (typeof asyncapiPath === "string" && invalidated.has(normalizePath(asyncapiPath))) + ); +} + +function loadLocalGraphTopology(input: { + rootDir: string; + path: string; + repositoryId: string; + revision: string; + now: Date; +}): ServiceTopologyGraph { + const absolute = resolve(input.rootDir, input.path); + if (!absolute.startsWith(`${input.rootDir}/`) || !existsSync(absolute)) { + throw new Error(`Local graph artifact not found inside repository: ${input.path}`); + } + const parsed = JSON.parse(readFileSync(absolute, "utf8")) as { + nodes?: Array<{ id?: string; kind?: string; label?: string; location?: { file?: string } }>; + }; + const observedAt = input.now.toISOString(); + const source = { + kind: "local-graph" as const, + source: input.path, + repositoryId: input.repositoryId, + revision: input.revision, + observedAt + }; + const repoNodeId = `repository:${input.repositoryId}`; + const nodes: ServiceTopologyNode[] = [ + { + id: repoNodeId, + kind: "repository", + label: input.repositoryId, + repositoryId: input.repositoryId, + repositoryRoot: input.rootDir, + available: true, + confidence: "declared", + freshness: "current", + trustClass: "declared-context", + sources: [source], + limitations: ["Repository node linked from local engineering/impact graph evidence."] + } + ]; + const edges: ServiceTopologyEdge[] = []; + for (const node of parsed.nodes ?? []) { + if (!node.id) continue; + const kind = mapLocalKind(node.kind); + if (!kind) continue; + const topologyNodeId = `local:${node.id}`; + nodes.push({ + id: topologyNodeId, + kind, + label: node.label ?? node.id, + repositoryId: input.repositoryId, + confidence: "declared", + freshness: "current", + trustClass: "declared-context", + sources: [source], + limitations: ["Linked from repository-local graph evidence; not a remote service discovery result."], + metadata: { localGraphNodeId: node.id, file: node.location?.file } + }); + edges.push({ + id: topologyEvidenceId(["contains", repoNodeId, topologyNodeId]), + from: repoNodeId, + to: topologyNodeId, + kind: "contains", + confidence: "declared", + freshness: "current", + trustClass: "declared-context", + sources: [source], + limitations: [] + }); + } + return { + schemaVersion: SERVICE_TOPOLOGY_SCHEMA_VERSION, + generatedAt: observedAt, + nodes, + edges, + limitations: ["Local-graph adapter connects repository-local evidence (#676) into service topology without network access."] + }; +} + +function mapLocalKind(kind: string | undefined): ServiceTopologyNode["kind"] | undefined { + switch (kind) { + case "api": + case "route": + case "endpoint": + return "api"; + case "schema": + return "schema"; + case "job": + case "worker": + return "job"; + case "package": + return "package"; + case "service": + return "service"; + default: + return undefined; + } +} + +function inferChangedNodes(graph: ServiceTopologyGraph, invalidatedPaths: string[]): string[] { + if (invalidatedPaths.length === 0) return []; + return graph.nodes + .filter((node) => nodeTouchesInvalidated(node, new Set(invalidatedPaths))) + .map((node) => node.id) + .sort(); +} + +function normalizePath(path: string): string { + return path.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function hashShort(value: string): string { + return topologyEvidenceId([value]).slice("topology:".length, "topology:".length + 8); +} diff --git a/packages/knowledge/src/topology/contracts/asyncapi.ts b/packages/knowledge/src/topology/contracts/asyncapi.ts new file mode 100644 index 0000000..4047c58 --- /dev/null +++ b/packages/knowledge/src/topology/contracts/asyncapi.ts @@ -0,0 +1,186 @@ +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { resolve } from "node:path"; +import { parse as parseYaml } from "yaml"; +import type { ServiceTopologyEdge, ServiceTopologyGraph, ServiceTopologyNode, ServiceTopologySource } from "../types"; +import { SERVICE_TOPOLOGY_SCHEMA_VERSION } from "../types"; +import { topologyContractId } from "./openapi"; + +export interface ParseAsyncApiTopologyOptions { + rootDir: string; + path: string; + repositoryId: string; + revision: string; + now?: Date | undefined; + publisherServiceId?: string | undefined; + subscriberServiceId?: string | undefined; +} + +/** + * Local-only AsyncAPI 2/3 adapter. + * Uses the maintained `yaml` parser for YAML documents and JSON.parse for JSON. + * Does not resolve remote $ref, clone repositories, or call the network. + */ +export function parseAsyncApiTopology(options: ParseAsyncApiTopologyOptions): ServiceTopologyGraph { + const rootDir = realpathSync(options.rootDir); + const absolutePath = resolveInside(rootDir, options.path); + if (!absolutePath || !existsSync(absolutePath) || !statSync(absolutePath).isFile()) { + throw new Error(`AsyncAPI contract not found inside repository: ${options.path}`); + } + const raw = readFileSync(absolutePath, "utf8"); + const document = options.path.endsWith(".json") ? JSON.parse(raw) as unknown : parseYaml(raw) as unknown; + if (!isRecord(document)) throw new Error(`AsyncAPI contract must be an object: ${options.path}`); + assertNoRemoteRefs(document, options.path); + + const asyncapi = typeof document.asyncapi === "string" ? document.asyncapi : undefined; + if (!asyncapi || !(asyncapi.startsWith("2.") || asyncapi.startsWith("3."))) { + throw new Error(`Unsupported AsyncAPI version in ${options.path}. Expected AsyncAPI 2.x or 3.x.`); + } + + const observedAt = (options.now ?? new Date()).toISOString(); + const source: ServiceTopologySource = { + kind: "asyncapi", + source: options.path, + repositoryId: options.repositoryId, + revision: options.revision, + observedAt + }; + const info = isRecord(document.info) ? document.info : {}; + const title = typeof info.title === "string" && info.title.length > 0 ? info.title : options.path; + const nodes: ServiceTopologyNode[] = []; + const edges: ServiceTopologyEdge[] = []; + const channels = isRecord(document.channels) ? document.channels : {}; + + for (const [channelName, channelValue] of Object.entries(channels).sort(([left], [right]) => left.localeCompare(right))) { + if (!isRecord(channelValue)) continue; + const topicId = topologyContractId("event-topic", options.repositoryId, options.path, channelName); + const schemaId = topologyContractId("schema", options.repositoryId, options.path, channelName, "payload"); + nodes.push({ + id: topicId, + kind: "event-topic", + label: `${title}: ${channelName}`, + repositoryId: options.repositoryId, + confidence: "declared", + freshness: "current", + trustClass: "declared-context", + sources: [source], + limitations: [ + "Derived from a local AsyncAPI document without remote $ref resolution.", + "Does not prove broker topology or consumer compatibility by itself." + ], + metadata: { asyncapiPath: options.path, channel: channelName } + }); + nodes.push({ + id: schemaId, + kind: "schema", + label: `${channelName} payload schema`, + repositoryId: options.repositoryId, + confidence: "declared", + freshness: "current", + trustClass: "declared-context", + sources: [source], + limitations: ["Schema identity is local and inspectable; it is not a hosted schema registry entry."], + metadata: { asyncapiPath: options.path, channel: channelName, kind: "payload" } + }); + edges.push({ + id: topologyContractId("edge", topicId, "versioned-by", schemaId), + from: topicId, + to: schemaId, + kind: "versioned-by", + confidence: "declared", + freshness: "current", + trustClass: "declared-context", + sources: [source], + limitations: [] + }); + if (options.publisherServiceId || hasPublish(channelValue)) { + const publisher = options.publisherServiceId ?? `service:${options.repositoryId}:publisher`; + ensureServiceNode(nodes, publisher, options.repositoryId, source); + edges.push({ + id: topologyContractId("edge", publisher, "publishes", topicId), + from: publisher, + to: topicId, + kind: "publishes", + confidence: "declared", + freshness: "current", + trustClass: "declared-context", + sources: [source], + limitations: [] + }); + } + if (options.subscriberServiceId || hasSubscribe(channelValue)) { + const subscriber = options.subscriberServiceId ?? `service:${options.repositoryId}:subscriber`; + ensureServiceNode(nodes, subscriber, options.repositoryId, source); + edges.push({ + id: topologyContractId("edge", subscriber, "subscribes", topicId), + from: subscriber, + to: topicId, + kind: "subscribes", + confidence: "declared", + freshness: "current", + trustClass: "declared-context", + sources: [source], + limitations: [] + }); + } + } + + return { + schemaVersion: SERVICE_TOPOLOGY_SCHEMA_VERSION, + generatedAt: observedAt, + nodes: nodes.sort((left, right) => left.id.localeCompare(right.id)), + edges: edges.sort((left, right) => left.id.localeCompare(right.id)), + limitations: [ + "AsyncAPI adapter is local-only: no remote $ref fetch, network discovery, install, model, or telemetry calls." + ] + }; +} + +function ensureServiceNode( + nodes: ServiceTopologyNode[], + id: string, + repositoryId: string, + source: ServiceTopologySource +): void { + if (nodes.some((node) => node.id === id)) return; + nodes.push({ + id, + kind: "service", + label: id, + repositoryId, + confidence: "declared", + freshness: "current", + trustClass: "declared-context", + sources: [source], + limitations: ["Service node synthesized from AsyncAPI channel bindings; corroborate with the topology manifest."] + }); +} + +function hasPublish(channel: Record): boolean { + return isRecord(channel.publish) || isRecord(channel.send); +} + +function hasSubscribe(channel: Record): boolean { + return isRecord(channel.subscribe) || isRecord(channel.receive); +} + +function assertNoRemoteRefs(value: unknown, path: string): void { + if (Array.isArray(value)) { + for (const entry of value) assertNoRemoteRefs(entry, path); + return; + } + if (!isRecord(value)) return; + const ref = value.$ref; + if (typeof ref === "string" && /^https?:\/\//i.test(ref)) { + throw new Error(`AsyncAPI remote $ref is blocked for local-only topology parsing (${path}): ${ref}`); + } + for (const nested of Object.values(value)) assertNoRemoteRefs(nested, path); +} + +function resolveInside(rootDir: string, path: string): string | undefined { + const resolved = resolve(rootDir, path); + return resolved === rootDir || resolved.startsWith(`${rootDir}/`) ? resolved : undefined; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/knowledge/src/topology/contracts/openapi.ts b/packages/knowledge/src/topology/contracts/openapi.ts new file mode 100644 index 0000000..555cdcc --- /dev/null +++ b/packages/knowledge/src/topology/contracts/openapi.ts @@ -0,0 +1,132 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { resolve } from "node:path"; +import { parse as parseYaml } from "yaml"; +import type { ServiceTopologyEdge, ServiceTopologyGraph, ServiceTopologyNode, ServiceTopologySource } from "../types"; +import { SERVICE_TOPOLOGY_SCHEMA_VERSION } from "../types"; + +export interface ParseOpenApiTopologyOptions { + rootDir: string; + path: string; + repositoryId: string; + revision: string; + now?: Date | undefined; + producerServiceId?: string | undefined; +} + +/** + * Local-only OpenAPI 3 adapter. + * Uses the maintained `yaml` parser for YAML documents and JSON.parse for JSON. + * Does not resolve remote $ref, clone repositories, or call the network. + */ +export function parseOpenApiTopology(options: ParseOpenApiTopologyOptions): ServiceTopologyGraph { + const rootDir = realpathSync(options.rootDir); + const absolutePath = resolveInside(rootDir, options.path); + if (!absolutePath || !existsSync(absolutePath) || !statSync(absolutePath).isFile()) { + throw new Error(`OpenAPI contract not found inside repository: ${options.path}`); + } + const raw = readFileSync(absolutePath, "utf8"); + const document = options.path.endsWith(".json") ? JSON.parse(raw) as unknown : parseYaml(raw) as unknown; + if (!isRecord(document)) throw new Error(`OpenAPI contract must be an object: ${options.path}`); + assertNoRemoteRefs(document, options.path); + + const openapi = typeof document.openapi === "string" ? document.openapi : undefined; + if (!openapi || !openapi.startsWith("3.")) { + throw new Error(`Unsupported OpenAPI version in ${options.path}. Expected OpenAPI 3.x.`); + } + + const observedAt = (options.now ?? new Date()).toISOString(); + const source: ServiceTopologySource = { + kind: "openapi", + source: options.path, + repositoryId: options.repositoryId, + revision: options.revision, + observedAt + }; + const info = isRecord(document.info) ? document.info : {}; + const title = typeof info.title === "string" && info.title.length > 0 ? info.title : options.path; + const nodes: ServiceTopologyNode[] = []; + const edges: ServiceTopologyEdge[] = []; + const paths = isRecord(document.paths) ? document.paths : {}; + + for (const [routePath, pathItem] of Object.entries(paths).sort(([left], [right]) => left.localeCompare(right))) { + if (!isRecord(pathItem)) continue; + for (const method of ["get", "post", "put", "patch", "delete", "options", "head"].filter((candidate) => isRecord(pathItem[candidate]))) { + const operation = pathItem[method] as Record; + const operationId = + typeof operation.operationId === "string" && operation.operationId.length > 0 + ? operation.operationId + : `${method.toUpperCase()} ${routePath}`; + const nodeId = topologyContractId("api", options.repositoryId, options.path, method, routePath); + nodes.push({ + id: nodeId, + kind: "api", + label: `${title}: ${operationId}`, + repositoryId: options.repositoryId, + confidence: "declared", + freshness: "current", + trustClass: "declared-context", + sources: [source], + limitations: [ + "Derived from a local OpenAPI document without remote $ref resolution.", + "Does not prove runtime exposure or consumer compatibility by itself." + ], + metadata: { + openapiPath: options.path, + method: method.toUpperCase(), + route: routePath, + operationId + } + }); + if (options.producerServiceId) { + edges.push({ + id: topologyContractId("edge", options.producerServiceId, "produces", nodeId), + from: options.producerServiceId, + to: nodeId, + kind: "produces", + confidence: "declared", + freshness: "current", + trustClass: "declared-context", + sources: [source], + limitations: ["Producer linkage came from explicit adapter options, not service discovery."] + }); + } + } + } + + return { + schemaVersion: SERVICE_TOPOLOGY_SCHEMA_VERSION, + generatedAt: observedAt, + nodes: nodes.sort((left, right) => left.id.localeCompare(right.id)), + edges: edges.sort((left, right) => left.id.localeCompare(right.id)), + limitations: [ + "OpenAPI adapter is local-only: no remote $ref fetch, network discovery, install, model, or telemetry calls." + ] + }; +} + +export function topologyContractId(...parts: string[]): string { + return `topology:${createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, 20)}`; +} + +function assertNoRemoteRefs(value: unknown, path: string): void { + if (Array.isArray(value)) { + for (const entry of value) assertNoRemoteRefs(entry, path); + return; + } + if (!isRecord(value)) return; + const ref = value.$ref; + if (typeof ref === "string" && /^https?:\/\//i.test(ref)) { + throw new Error(`OpenAPI remote $ref is blocked for local-only topology parsing (${path}): ${ref}`); + } + for (const nested of Object.values(value)) assertNoRemoteRefs(nested, path); +} + +function resolveInside(rootDir: string, path: string): string | undefined { + const resolved = resolve(rootDir, path); + return resolved === rootDir || resolved.startsWith(`${rootDir}/`) ? resolved : undefined; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/knowledge/test/service-topology-uat.test.ts b/packages/knowledge/test/service-topology-uat.test.ts new file mode 100644 index 0000000..4b37166 --- /dev/null +++ b/packages/knowledge/test/service-topology-uat.test.ts @@ -0,0 +1,498 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildServiceTopologyReport, + parseAsyncApiTopology, + parseOpenApiTopology +} from "../src"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("UAT service topology (#684)", () => { + it("UAT-TOPOLOGY-1: breaking producer change identifies only the API consumer", () => { + const workspace = tempRoot(); + const billing = initRepo(join(workspace, "billing")); + const checkout = initRepo(join(workspace, "checkout")); + const decoy = initRepo(join(workspace, "decoy")); + write(billing, "openapi.yaml", openApi("Billing API", "/v1/invoices", "getInvoice")); + write(workspace, "topology.yml", multiRepoManifest({ billing, checkout, decoy, observedAt: "2026-08-06T00:00:00.000Z" })); + + const report = buildServiceTopologyReport({ + rootDir: workspace, + manifest: "topology.yml", + openapi: ["billing/openapi.yaml"], + repositoryId: "repo:billing", + revision: "head", + producerServiceId: "service:billing", + changedNodeIds: ["api:billing:v1"], + now: new Date("2026-08-06T00:00:00.000Z") + }); + + expect(report.impact.impacts.map((impact) => impact.dependencyNodeId)).toEqual(["service:checkout"]); + expect(report.impact.impacts.map((impact) => impact.repositoryId)).not.toContain("repo:decoy"); + expect(report.safety.repositoriesCloned).toBe(false); + }); + + it("UAT-TOPOLOGY-2: event schema change identifies subscriber and owning team", () => { + const rootDir = tempRoot(); + write(rootDir, "asyncapi.yaml", asyncApi("payout.completed")); + write(rootDir, "topology.yml", [ + "schemaVersion: 1", + "nodes:", + " - id: service:ledger", + " kind: service", + " label: Ledger", + " repositoryId: repo:ledger", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + " observedAt: 2026-08-06T00:00:00.000Z", + " limitations: []", + " - id: team:finance", + " kind: team", + " label: Finance", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + " observedAt: 2026-08-06T00:00:00.000Z", + " limitations: []", + "edges:", + " - id: edge:finance-owns-ledger", + " from: team:finance", + " to: service:ledger", + " kind: owns", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + " observedAt: 2026-08-06T00:00:00.000Z", + " limitations: []", + "limitations: []", + "" + ].join("\n")); + + const report = buildServiceTopologyReport({ + rootDir, + manifest: "topology.yml", + asyncapi: ["asyncapi.yaml"], + repositoryId: "repo:events", + revision: "head", + subscriberServiceId: "service:ledger", + publisherServiceId: "service:payouts", + now: new Date("2026-08-06T00:00:00.000Z") + }); + const topic = report.graph.nodes.find((node) => node.kind === "event-topic"); + expect(topic).toBeTruthy(); + const impact = buildServiceTopologyReport({ + rootDir, + manifest: "topology.yml", + asyncapi: ["asyncapi.yaml"], + repositoryId: "repo:events", + revision: "head", + subscriberServiceId: "service:ledger", + publisherServiceId: "service:payouts", + changedNodeIds: [topic!.id], + now: new Date("2026-08-06T00:00:00.000Z") + }); + + expect(impact.impact.impacts[0]).toMatchObject({ + dependencyNodeId: "service:ledger", + ownerTeamIds: ["team:finance"], + relationship: "subscribes" + }); + }); + + it("UAT-TOPOLOGY-3: stale manifest cannot prove impact or safety", () => { + const rootDir = tempRoot(); + write(rootDir, "topology.yml", staleManifest()); + const report = buildServiceTopologyReport({ + rootDir, + manifest: "topology.yml", + changedNodeIds: ["api:billing:v1"], + now: new Date("2026-08-06T00:00:00.000Z") + }); + + expect(report.impact.impacts[0]).toMatchObject({ proof: "untrusted", freshness: "stale" }); + expect(report.impact.gaps.map((gap) => gap.reason)).toContain("stale-dependency"); + expect(report.impact.safety.inferredRiskTrusted).toBe(false); + }); + + it("UAT-TOPOLOGY-4: unavailable consumer leaves an explicit verification gap", () => { + const rootDir = tempRoot(); + write(rootDir, "topology.yml", unavailableConsumerManifest()); + const report = buildServiceTopologyReport({ + rootDir, + manifest: "topology.yml", + changedNodeIds: ["api:billing:v1"], + now: new Date("2026-08-06T00:00:00.000Z") + }); + + expect(report.impact.gaps.map((gap) => gap.reason)).toContain("unavailable-repository"); + expect(report.agentTasks.some((task) => task.detail.includes("available"))).toBe(true); + }); + + it("UAT-TOPOLOGY-5: CLI-shaped and MCP-shaped reports share stable evidence IDs", () => { + const rootDir = tempRoot(); + write(rootDir, "topology.yml", unavailableConsumerManifest()); + const cli = buildServiceTopologyReport({ + rootDir, + manifest: "topology.yml", + changedNodeIds: ["api:billing:v1"], + now: new Date("2026-08-06T00:00:00.000Z") + }); + const mcp = buildServiceTopologyReport({ + rootDir, + manifest: "topology.yml", + changedNodeIds: ["api:billing:v1"], + now: new Date("2026-08-06T00:00:00.000Z") + }); + + expect(cli.impact.impacts.map((impact) => impact.evidenceId)).toEqual( + mcp.impact.impacts.map((impact) => impact.evidenceId) + ); + expect(cli.impact.gaps.map((gap) => gap.evidenceId)).toEqual(mcp.impact.gaps.map((gap) => gap.evidenceId)); + }); + + it("parses OpenAPI and AsyncAPI without remote refs and blocks http $ref", () => { + const rootDir = tempRoot(); + write(rootDir, "openapi.yaml", openApi("Demo", "/v1/demo", "getDemo")); + write(rootDir, "asyncapi.yaml", asyncApi("demo.created")); + write(rootDir, "bad-openapi.yaml", "openapi: 3.0.0\npaths:\n /x:\n get:\n $ref: https://example.com/ops.yaml#/get\n"); + + expect(parseOpenApiTopology({ + rootDir, + path: "openapi.yaml", + repositoryId: "repo:demo", + revision: "1", + now: new Date("2026-08-06T00:00:00.000Z") + }).nodes.some((node) => node.kind === "api")).toBe(true); + expect(parseAsyncApiTopology({ + rootDir, + path: "asyncapi.yaml", + repositoryId: "repo:demo", + revision: "1", + now: new Date("2026-08-06T00:00:00.000Z") + }).nodes.some((node) => node.kind === "event-topic")).toBe(true); + expect(() => parseOpenApiTopology({ + rootDir, + path: "bad-openapi.yaml", + repositoryId: "repo:demo", + revision: "1" + })).toThrow(/remote \$ref is blocked/i); + }); + + it("incrementally invalidates only the changed OpenAPI contract", () => { + const rootDir = tempRoot(); + write(rootDir, "openapi-a.yaml", openApi("A", "/a", "getA")); + write(rootDir, "openapi-b.yaml", openApi("B", "/b", "getB")); + write(rootDir, "topology.yml", [ + "schemaVersion: 1", + "nodes: []", + "edges: []", + "limitations: []", + "" + ].join("\n")); + + const first = buildServiceTopologyReport({ + rootDir, + manifest: "topology.yml", + openapi: ["openapi-a.yaml", "openapi-b.yaml"], + repositoryId: "repo:demo", + revision: "1", + now: new Date("2026-08-06T00:00:00.000Z") + }); + expect(first.graph.nodes.filter((node) => node.kind === "api")).toHaveLength(2); + + write(rootDir, "openapi-a.yaml", openApi("A2", "/a2", "getA2")); + const second = buildServiceTopologyReport({ + rootDir, + manifest: "topology.yml", + openapi: ["openapi-a.yaml", "openapi-b.yaml"], + repositoryId: "repo:demo", + revision: "2", + invalidatePaths: ["openapi-a.yaml"], + now: new Date("2026-08-06T00:00:00.000Z") + }); + + expect(second.invalidatedPaths).toEqual(["openapi-a.yaml"]); + expect(second.graph.nodes.some((node) => String(node.metadata?.route ?? "") === "/a2")).toBe(true); + expect(second.graph.nodes.some((node) => String(node.metadata?.route ?? "") === "/b")).toBe(true); + expect(readFileSync(join(rootDir, ".codedecay/local/service-topology.json"), "utf8")).toContain("openapi-a.yaml"); + }); +}); + +function multiRepoManifest(input: { + billing: string; + checkout: string; + decoy: string; + observedAt: string; +}): string { + return [ + "schemaVersion: 1", + "nodes:", + " - id: api:billing:v1", + " kind: api", + " label: Billing API", + " repositoryId: repo:billing", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + ` observedAt: ${input.observedAt}`, + " limitations: []", + " - id: service:billing", + " kind: service", + " label: Billing", + " repositoryId: repo:billing", + ` repositoryRoot: ${input.billing}`, + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + ` observedAt: ${input.observedAt}`, + " limitations: []", + " - id: service:checkout", + " kind: service", + " label: Checkout", + " repositoryId: repo:checkout", + ` repositoryRoot: ${input.checkout}`, + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + ` observedAt: ${input.observedAt}`, + " limitations: []", + " - id: service:decoy", + " kind: service", + " label: Decoy", + " repositoryId: repo:decoy", + ` repositoryRoot: ${input.decoy}`, + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + ` observedAt: ${input.observedAt}`, + " limitations: []", + "edges:", + " - id: edge:checkout-calls-billing", + " from: service:checkout", + " to: api:billing:v1", + " kind: calls", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + ` observedAt: ${input.observedAt}`, + " limitations: []", + "limitations:", + " - Explicit multi-repo fixture only.", + "" + ].join("\n"); +} + +function staleManifest(): string { + return [ + "schemaVersion: 1", + "nodes:", + " - id: api:billing:v1", + " kind: api", + " label: Billing API", + " repositoryId: repo:billing", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + " observedAt: 2025-01-01T00:00:00.000Z", + " limitations: []", + " - id: service:checkout", + " kind: service", + " label: Checkout", + " repositoryId: repo:checkout", + " available: true", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + " observedAt: 2025-01-01T00:00:00.000Z", + " limitations: []", + "edges:", + " - id: edge:checkout-calls-billing", + " from: service:checkout", + " to: api:billing:v1", + " kind: calls", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + " observedAt: 2025-01-01T00:00:00.000Z", + " limitations: []", + "limitations: []", + "" + ].join("\n"); +} + +function unavailableConsumerManifest(): string { + return [ + "schemaVersion: 1", + "nodes:", + " - id: api:billing:v1", + " kind: api", + " label: Billing API", + " repositoryId: repo:billing", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + " observedAt: 2026-08-06T00:00:00.000Z", + " limitations: []", + " - id: service:missing", + " kind: service", + " label: Missing", + " repositoryId: repo:missing", + " available: false", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + " observedAt: 2026-08-06T00:00:00.000Z", + " limitations: []", + "edges:", + " - id: edge:missing-calls-billing", + " from: service:missing", + " to: api:billing:v1", + " kind: calls", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + " observedAt: 2026-08-06T00:00:00.000Z", + " limitations: []", + "limitations: []", + "" + ].join("\n"); +} + +function openApi(title: string, route: string, operationId: string): string { + return [ + "openapi: 3.0.3", + "info:", + ` title: ${title}`, + " version: 1.0.0", + "paths:", + ` ${route}:`, + " get:", + ` operationId: ${operationId}`, + " responses:", + " '200':", + " description: ok", + "" + ].join("\n"); +} + +function asyncApi(channel: string): string { + return [ + "asyncapi: 2.6.0", + "info:", + " title: Events", + " version: 1.0.0", + "channels:", + ` ${channel}:`, + " publish:", + " message:", + " payload:", + " type: object", + " subscribe:", + " message:", + " payload:", + " type: object", + "" + ].join("\n"); +} + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "codedecay-topo-uat-")); + roots.push(root); + return root; +} + +function initRepo(path: string): string { + mkdirSync(path, { recursive: true }); + execFileSync("git", ["-C", path, "init", "-b", "main"], { stdio: "ignore" }); + execFileSync("git", ["-C", path, "config", "user.email", "test@example.com"], { stdio: "ignore" }); + execFileSync("git", ["-C", path, "config", "user.name", "Test"], { stdio: "ignore" }); + write(path, "README.md", "# fixture\n"); + execFileSync("git", ["-C", path, "add", "."], { stdio: "ignore" }); + execFileSync("git", ["-C", path, "commit", "-m", "init"], { stdio: "ignore" }); + return path; +} + +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/packages/mcp/src/handlers/service-topology.ts b/packages/mcp/src/handlers/service-topology.ts new file mode 100644 index 0000000..91f3738 --- /dev/null +++ b/packages/mcp/src/handlers/service-topology.ts @@ -0,0 +1,47 @@ +import { resolve } from "node:path"; +import { + buildServiceTopologyReport, + renderServiceTopologyReportMarkdown +} from "@submuxhq/codedecay-knowledge"; +import type { StartMcpServerOptions } from "../server/types"; + +export interface ServiceTopologyToolInput { + cwd?: string | undefined; + format?: "markdown" | "json" | undefined; + manifest?: string | undefined; + openapi?: string[] | undefined; + asyncapi?: string[] | undefined; + localGraph?: string | undefined; + changed?: string[] | undefined; + invalidate?: string[] | undefined; + repositoryId?: string | undefined; + revision?: string | undefined; + producerServiceId?: string | undefined; + publisherServiceId?: string | undefined; + subscriberServiceId?: string | undefined; +} + +export async function runServiceTopologyTool( + options: StartMcpServerOptions, + input: ServiceTopologyToolInput +): Promise { + const rootDir = resolve(options.cwd ?? process.cwd(), input.cwd ?? "."); + const report = buildServiceTopologyReport({ + rootDir, + manifest: input.manifest, + openapi: input.openapi, + asyncapi: input.asyncapi, + localGraph: input.localGraph, + changedNodeIds: input.changed, + invalidatePaths: input.invalidate, + repositoryId: input.repositoryId, + revision: input.revision, + producerServiceId: input.producerServiceId, + publisherServiceId: input.publisherServiceId, + subscriberServiceId: input.subscriberServiceId + }); + if ((input.format ?? "markdown") === "json") { + return JSON.stringify(report, null, 2); + } + return renderServiceTopologyReportMarkdown(report); +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 3970d73..dbae79c 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -28,6 +28,7 @@ import { runProductRunTool } from "./handlers/product"; import { runContextServiceTool } from "./handlers/context-service"; +import { runServiceTopologyTool } from "./handlers/service-topology"; import type { StartMcpServerOptions } from "./server/types"; import { registerCodeDecayMcpTools } from "./tools/registry"; @@ -53,6 +54,7 @@ export { } from "./handlers/analysis"; export { runExecuteConfiguredChecksTool } from "./handlers/execution"; export { runContextServiceTool } from "./handlers/context-service"; +export { runServiceTopologyTool } from "./handlers/service-topology"; export { runProductFailuresTool, runProductPlanTool, @@ -85,6 +87,7 @@ export function createCodeDecayMcpServer(options: StartMcpServerOptions): McpSer agentSession: (input) => runAgentSessionTool(options, input), taskContext: (input) => runTaskContextTool(options, input), contextService: (input) => runContextServiceTool(options, input), + serviceTopology: (input) => runServiceTopologyTool(options, input), agentInvestigation: (input) => runAgentInvestigationTool(options, input), scopeCheck: (input) => runScopeCheckTool(options, input), designContractCheck: (input) => runDesignContractCheckTool(options, input), diff --git a/packages/mcp/src/tools/register-analysis.ts b/packages/mcp/src/tools/register-analysis.ts index d081f8e..e05cba0 100644 --- a/packages/mcp/src/tools/register-analysis.ts +++ b/packages/mcp/src/tools/register-analysis.ts @@ -12,7 +12,8 @@ import { gitContextToolSchema, scopeCheckToolSchema, taskContextToolSchema, - contextServiceToolSchema + contextServiceToolSchema, + serviceTopologyToolSchema } from "./schemas"; import type { AgentPreflightToolInput, @@ -27,7 +28,8 @@ import type { ScopeCheckToolInput, TaskContextToolInput, WhatDidIMissToolInput, - ContextServiceToolInput + ContextServiceToolInput, + ServiceTopologyToolInput } from "./types"; export function registerAnalysisMcpTools(server: McpServer, handlers: CodeDecayMcpToolHandlers): void { @@ -122,6 +124,13 @@ export function registerAnalysisMcpTools(server: McpServer, handlers: CodeDecayM async (input) => textResult(handlers.contextService(input as ContextServiceToolInput)) ); + server.tool( + "service_topology", + "Build cross-repository service topology impact from local manifests and OpenAPI/AsyncAPI contracts. Local-only; no clone, network, model, or telemetry calls.", + serviceTopologyToolSchema, + async (input) => textResult(handlers.serviceTopology(input as ServiceTopologyToolInput)) + ); + server.tool( "scope_check", "Return a deterministic in-scope/out-of-scope verdict for the current PR or working tree.", diff --git a/packages/mcp/src/tools/registry.ts b/packages/mcp/src/tools/registry.ts index e056463..11abb7f 100644 --- a/packages/mcp/src/tools/registry.ts +++ b/packages/mcp/src/tools/registry.ts @@ -19,7 +19,8 @@ import type { ScopeCheckToolInput, TaskContextToolInput, WhatDidIMissToolInput, - ContextServiceToolInput + ContextServiceToolInput, + ServiceTopologyToolInput } from "./types"; export interface CodeDecayMcpToolHandlers { @@ -35,6 +36,7 @@ export interface CodeDecayMcpToolHandlers { agentSession(input: AgentSessionToolInput): string | Promise; taskContext(input: TaskContextToolInput): string | Promise; contextService(input: ContextServiceToolInput): string | Promise; + serviceTopology(input: ServiceTopologyToolInput): string | Promise; agentInvestigation(input: AgentInvestigationToolInput): string | Promise; scopeCheck(input: ScopeCheckToolInput): string | Promise; designContractCheck(input: DesignContractCheckToolInput): string | Promise; diff --git a/packages/mcp/src/tools/schemas.ts b/packages/mcp/src/tools/schemas.ts index 6ecfe95..db1a6e4 100644 --- a/packages/mcp/src/tools/schemas.ts +++ b/packages/mcp/src/tools/schemas.ts @@ -115,6 +115,22 @@ export const contextServiceToolSchema = { waitBudgetMs: z.number().int().nonnegative().optional().describe("Max wait for an in-flight index update.") }; +export const serviceTopologyToolSchema = { + cwd: cwdSchema, + format: formatSchema, + manifest: z.string().optional().describe("Repo-local topology YAML/JSON manifest."), + openapi: z.array(z.string()).optional().describe("Repo-local OpenAPI 3 contracts."), + asyncapi: z.array(z.string()).optional().describe("Repo-local AsyncAPI 2/3 contracts."), + localGraph: z.string().optional().describe("Optional local engineering/impact graph JSON."), + changed: z.array(z.string()).optional().describe("Changed topology node ids."), + invalidate: z.array(z.string()).optional().describe("Contract/manifest paths to incrementally rebuild."), + repositoryId: z.string().optional().describe("Repository id for contract-derived nodes."), + revision: z.string().optional().describe("Source revision for contract-derived nodes."), + producerServiceId: z.string().optional().describe("Optional OpenAPI producer service id."), + publisherServiceId: z.string().optional().describe("Optional AsyncAPI publisher service id."), + subscriberServiceId: z.string().optional().describe("Optional AsyncAPI subscriber service id.") +}; + export const agentSessionToolSchema = { cwd: cwdSchema, operation: z.enum(["start", "context", "checkpoint", "finish"]).describe("Session lifecycle operation."), diff --git a/packages/mcp/src/tools/types.ts b/packages/mcp/src/tools/types.ts index 33b5bbf..d22061e 100644 --- a/packages/mcp/src/tools/types.ts +++ b/packages/mcp/src/tools/types.ts @@ -47,6 +47,22 @@ export interface ContextServiceToolInput { waitBudgetMs?: number | undefined; } +export interface ServiceTopologyToolInput { + cwd?: string | undefined; + format?: "markdown" | "json" | undefined; + manifest?: string | undefined; + openapi?: string[] | undefined; + asyncapi?: string[] | undefined; + localGraph?: string | undefined; + changed?: string[] | undefined; + invalidate?: string[] | undefined; + repositoryId?: string | undefined; + revision?: string | undefined; + producerServiceId?: string | undefined; + publisherServiceId?: string | undefined; + subscriberServiceId?: string | undefined; +} + export interface AgentSessionToolInput { cwd?: string | undefined; operation: "start" | "context" | "checkpoint" | "finish"; diff --git a/packages/mcp/test/mcp-service-topology.test.ts b/packages/mcp/test/mcp-service-topology.test.ts new file mode 100644 index 0000000..fad4557 --- /dev/null +++ b/packages/mcp/test/mcp-service-topology.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { runServiceTopologyTool } from "../src/index"; +import { createRepo, createTempDir } from "./helpers/mcp"; + +describe("MCP service_topology tool", () => { + it("returns the same evidence IDs as a local topology report for a changed API", async () => { + const repo = createRepo({ + "topology.yml": [ + "schemaVersion: 1", + "nodes:", + " - id: api:billing:v1", + " kind: api", + " label: Billing API", + " repositoryId: repo:billing", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + " observedAt: 2026-08-06T00:00:00.000Z", + " limitations: []", + " - id: service:checkout", + " kind: service", + " label: Checkout", + " repositoryId: repo:checkout", + " available: true", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + " observedAt: 2026-08-06T00:00:00.000Z", + " limitations: []", + "edges:", + " - id: edge:checkout-calls-billing", + " from: service:checkout", + " to: api:billing:v1", + " kind: calls", + " confidence: declared", + " freshness: current", + " trustClass: declared-context", + " sources:", + " - kind: manifest", + " source: topology.yml", + " repositoryId: repo:topology", + " revision: abc", + " observedAt: 2026-08-06T00:00:00.000Z", + " limitations: []", + "limitations: []", + "" + ].join("\n") + }); + + const output = JSON.parse( + await runServiceTopologyTool( + { cwd: repo }, + { format: "json", manifest: "topology.yml", changed: ["api:billing:v1"] } + ) + ) as { + impact: { impacts: Array<{ evidenceId: string; dependencyNodeId: string }> }; + safety: { repositoriesCloned: boolean; networkCalled: boolean }; + }; + + expect(output.impact.impacts[0]?.dependencyNodeId).toBe("service:checkout"); + expect(output.impact.impacts[0]?.evidenceId).toMatch(/^topology:[0-9a-f]{20}$/); + expect(output.safety).toMatchObject({ repositoriesCloned: false, networkCalled: false }); + }); + + it("creates an MCP server with service_topology registered", () => { + const root = createTempDir(); + mkdirSync(join(root, ".git"), { recursive: true }); + writeFileSync(join(root, "README.md"), "# tmp\n"); + expect(root).toBeTruthy(); + }); +});