diff --git a/apps/cli/main.ts b/apps/cli/main.ts index 4869881..1a214d3 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -5,6 +5,7 @@ import { isatty } from "node:tty"; import { basename, dirname, join, resolve } from "node:path"; import { tmpdir } from "node:os"; import type { ClaimAnchorAuditResult, RepoRef } from "../../libs/knowledge-graph/service.js"; +import type { GraphObjectType } from "../../libs/knowledge-graph/schema.js"; import { envVarSource, loadRepoEnv, type LoadedRepoEnv } from "../../libs/env/load-local-env.js"; import { ensureGreplicaConfig, @@ -189,6 +190,20 @@ const cliCommands = [ handler: withCommandContext(runGraphReadCommand), showInTopLevelHelp: true, }, + { + key: "graphGet", + path: ["graph", "get"], + usage: "graph get ", + handler: runGraphGetCommand, + showInTopLevelHelp: true, + }, + { + key: "graphTraverse", + path: ["graph", "traverse"], + usage: "graph traverse [--depth ]", + handler: runGraphTraverseCommand, + showInTopLevelHelp: true, + }, { key: "graphContext", path: ["graph", "context"], @@ -472,6 +487,36 @@ async function runGraphReadCommand(args: string[], getContext: CommandContextPro printSection("Edges", graph.edges, (item) => `${field(item, "from_type")}:${field(item, "from_id")} -[${field(item, "kind")}]-> ${field(item, "to_type")}:${field(item, "to_id")}`); } +function runGraphGetCommand(args: string[]): void { + const type = requireGraphObjectType(args[0], usage("graphGet")); + const id = requireFile(args[1], usage("graphGet")); + const { repo, service } = createCommandContext(); + const obj = service.lookupObject(repo, type, id); + if (obj === undefined) { + console.log(`No ${type} found with id '${id}'`); + process.exitCode = 1; + return; + } + console.log(JSON.stringify(obj, null, 2)); +} + +function runGraphTraverseCommand(args: string[]): void { + const type = requireGraphObjectType(args[0], usage("graphTraverse")); + const id = requireFile(args[1], usage("graphTraverse")); + const depth = parseGraphTraverseDepth(args); + const { repo, service } = createCommandContext(); + const graph = service.traverseGraph(repo, type, id, depth); + console.log(`Traversal from ${type}:${id} (depth=${depth}):`); + printSection("Components", graph.components, (item) => `${named(item)} ${anchor(item)}`.trim()); + printSection("Flows", graph.flows, named); + printSection("Claims", graph.claims, (item) => `${field(item, "kind")}: ${field(item, "text")}`); + printSection("Sources", graph.sources, (item) => `${field(item, "kind")}: ${field(item, "title") || field(item, "ref")}`); + printSection("Edges", graph.edges, (item) => `${field(item, "from_type")}:${field(item, "from_id")} -[${field(item, "kind")}]-> ${field(item, "to_type")}:${field(item, "to_id")}`); +} + +async function runGraphContextCommand(args: string[]): Promise { + const output = parseGraphContextOutput(args); + const query = args.filter((arg) => arg !== "--debug").join(" ").trim(); async function runGraphContextCommand(args: string[], getContext: CommandContextProvider): Promise { const options = parseGraphSelectionArgs(args, new Set(["--json", "--debug"])); const output = options.json || args.includes("--debug") ? "json" : "markdown"; @@ -1250,6 +1295,34 @@ function requireFile(file: string | undefined, usage: string): string { return file; } +function requireGraphObjectType(value: string | undefined, usage: string): GraphObjectType { + const validTypes = ["component", "flow", "claim", "edge", "source"] as const; + if (value === undefined || !validTypes.includes(value as GraphObjectType)) { + throw new Error(`Invalid graph object type '${value}'. Expected one of: ${validTypes.join(", ")}.\n${usage}`); + } + return value as GraphObjectType; +} + +function parseGraphTraverseDepth(args: string[]): number { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--depth") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`Missing value for --depth.\n${usage("graphTraverse")}`); + const depth = Number.parseInt(value, 10); + if (Number.isNaN(depth) || depth < 1) throw new Error(`Invalid --depth value '${value}'; expected a positive integer.\n${usage("graphTraverse")}`); + return depth; + } + if (arg.startsWith("--depth=")) { + const value = arg.slice("--depth=".length); + const depth = Number.parseInt(value, 10); + if (Number.isNaN(depth) || depth < 1) throw new Error(`Invalid --depth value '${value}'; expected a positive integer.\n${usage("graphTraverse")}`); + return depth; + } + } + return 1; // default depth +} + function parseRequiredOption(args: string[], name: string, usage: string): string { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; diff --git a/libs/knowledge-graph/service.ts b/libs/knowledge-graph/service.ts index 010b91b..801de6c 100644 --- a/libs/knowledge-graph/service.ts +++ b/libs/knowledge-graph/service.ts @@ -211,6 +211,19 @@ export class KnowledgeGraphService { }; } + lookupObject(input: RepoRef, type: GraphObjectType, id: string): Component | Flow | Claim | Source | Edge | undefined { + this.requireRepo(input); + return this.repository.getObjectById(type, id); + } + + traverseGraph( + input: RepoRef, + type: GraphObjectType, + id: string, + maxDepth: number = 1, + ): GraphReadResult { + const initialized = this.requireRepo(input); + return this.repository.getRelatedObjects(initialized.repo_id, type, id, maxDepth); private async validateNormalizedProposal( input: RepoRef, repoId: string, diff --git a/libs/storage/sqlite/repository.ts b/libs/storage/sqlite/repository.ts index 1b65410..9efd894 100644 --- a/libs/storage/sqlite/repository.ts +++ b/libs/storage/sqlite/repository.ts @@ -552,6 +552,151 @@ export class SqliteRepository implements GraphReadRepository { return deserializeEdges(rows); } + getObjectById(type: GraphObjectType, id: string): Component | Flow | Claim | Source | Edge | undefined { + const table = tableForType(type); + const row = this.db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(id) as Record | undefined; + if (!row) return undefined; + + switch (type) { + case "component": { + const comp = row as ComponentRow; + return { id: comp.id, name: comp.name, code_anchor: comp.code_anchor ?? undefined } as Component; + } + case "flow": + return row as Flow; + case "claim": { + const claim = row as ClaimRow; + return { + id: claim.id, + kind: claim.kind, + text: claim.text, + truth: claim.truth, + intent: claim.intent, + code_anchors: claim.code_anchors === null ? undefined : JSON.parse(claim.code_anchors) as Claim["code_anchors"], + } as Claim; + } + case "source": + return row as Source; + case "edge": { + const edge = row as EdgeRow; + return { + ...edge, + metadata: edge.metadata === null ? undefined : JSON.parse(edge.metadata) as Record, + } as Edge; + } + } + } + + getEdgesForObject(type: GraphObjectType, id: string): Edge[] { + const rows = this.db + .prepare( + `SELECT * FROM edges WHERE (from_type = ? AND from_id = ?) OR (to_type = ? AND to_id = ?)`, + ) + .all(type, id, type, id) as EdgeRow[]; + return rows.map((row) => ({ + ...row, + metadata: row.metadata === null ? undefined : (JSON.parse(row.metadata) as Record), + })); + } + + getRelatedObjects( + repoId: string, + seedType: GraphObjectType, + seedId: string, + maxDepth: number, + ): { + components: Component[]; + flows: Flow[]; + claims: Claim[]; + sources: Source[]; + edges: Edge[]; + } { + const scopeIds = this.currentScopeIds(repoId); + const memberships = this.membershipsForScopes(scopeIds); + const allEdges = this.loadEdges(selectIds(memberships, "edge")); + const active = activeSubjectKeys(memberships, allEdges); + + // Build index of edges by connected object for efficient traversal + const edgesByObject = new Map(); + for (const edge of allEdges) { + if (!active.has(subjectKey("edge", edge.id))) continue; + const fromKey = subjectKey(edge.from_type, edge.from_id); + const toKey = subjectKey(edge.to_type, edge.to_id); + if (!edgesByObject.has(fromKey)) edgesByObject.set(fromKey, []); + if (!edgesByObject.has(toKey)) edgesByObject.set(toKey, []); + edgesByObject.get(fromKey)!.push(edge); + edgesByObject.get(toKey)!.push(edge); + } + + // BFS traversal from seed object + const visited = new Set(); + const collectedEdges = new Map(); + const collectedTypes = new Map(); + + const queue: Array<{ type: GraphObjectType; id: string; depth: number }> = [ + { type: seedType, id: seedId, depth: 0 }, + ]; + visited.add(subjectKey(seedType, seedId)); + + while (queue.length > 0) { + const current = queue.shift()!; + if (current.depth >= maxDepth) continue; + + const edges = this.getEdgesForObject(current.type, current.id); + for (const edge of edges) { + if (!active.has(subjectKey("edge", edge.id))) continue; + if (!collectedEdges.has(edge.id)) { + collectedEdges.set(edge.id, edge); + } + + // Determine neighbor + const neighborType = edge.from_type === current.type && edge.from_id === current.id + ? edge.to_type + : edge.from_type; + const neighborId = edge.from_type === current.type && edge.from_id === current.id + ? edge.to_id + : edge.from_id; + const neighborKey = subjectKey(neighborType, neighborId); + + if (!visited.has(neighborKey) && neighborType !== "source") { + visited.add(neighborKey); + collectedTypes.set(neighborKey, neighborType); + queue.push({ type: neighborType, id: neighborId, depth: current.depth + 1 }); + } + } + } + + // Collect all objects by type + const componentIds: string[] = []; + const flowIds: string[] = []; + const claimIds: string[] = []; + const sourceIds: string[] = []; + + for (const [key, objType] of collectedTypes) { + const id = key.slice(key.indexOf(":") + 1); + switch (objType) { + case "component": componentIds.push(id); break; + case "flow": flowIds.push(id); break; + case "claim": claimIds.push(id); break; + case "source": sourceIds.push(id); break; + } + } + + // Also include sources referenced by collected edges + for (const edge of collectedEdges.values()) { + if (edge.to_type === "source") sourceIds.push(edge.to_id); + } + + return { + components: this.loadComponents(componentIds), + flows: this.loadFlows(flowIds), + claims: this.loadClaims(claimIds), + sources: this.loadSources([...new Set(sourceIds)]), + edges: [...collectedEdges.values()], + }; + } + + private loadByIds(table: string, ids: string[]): T[] { private loadByIds(repoId: string, table: string, ids: string[]): T[] { if (ids.length === 0) return []; return this.db.prepare(`SELECT * FROM ${table} WHERE repo_id = ? AND id IN (${placeholders(ids)})`).all(repoId, ...ids) as T[];