Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions apps/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -189,6 +190,20 @@ const cliCommands = [
handler: withCommandContext(runGraphReadCommand),
showInTopLevelHelp: true,
},
{
key: "graphGet",
path: ["graph", "get"],
usage: "graph get <type> <id>",
handler: runGraphGetCommand,
showInTopLevelHelp: true,
},
{
key: "graphTraverse",
path: ["graph", "traverse"],
usage: "graph traverse <type> <id> [--depth <n>]",
handler: runGraphTraverseCommand,
showInTopLevelHelp: true,
},
{
key: "graphContext",
path: ["graph", "context"],
Expand Down Expand Up @@ -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<void> {
const output = parseGraphContextOutput(args);
const query = args.filter((arg) => arg !== "--debug").join(" ").trim();
async function runGraphContextCommand(args: string[], getContext: CommandContextProvider): Promise<void> {
const options = parseGraphSelectionArgs(args, new Set(["--json", "--debug"]));
const output = options.json || args.includes("--debug") ? "json" : "markdown";
Expand Down Expand Up @@ -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];
Expand Down
13 changes: 13 additions & 0 deletions libs/knowledge-graph/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
145 changes: 145 additions & 0 deletions libs/storage/sqlite/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown>,
} 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<string, unknown>),
}));
}

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<string, Edge[]>();
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<string>();
const collectedEdges = new Map<string, Edge>();
const collectedTypes = new Map<string, GraphObjectType>();

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<T>(table: string, ids: string[]): T[] {
private loadByIds<T>(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[];
Expand Down