From 5801db13dd2420e47474071f73f2fd95eac739e9 Mon Sep 17 00:00:00 2001 From: joshwheelock Date: Thu, 23 Jul 2026 01:18:35 +0200 Subject: [PATCH] feat: add bounded relation traversal --- CHANGELOG.md | 8 + docs/README.md | 2 +- docs/reference/commands/index.md | 2 +- docs/reference/commands/relations.md | 49 +++- docs/reference/index.md | 2 +- .../relations/get/GetRelationsRequest.ts | 12 +- .../relations/get/IRelationViewReader.ts | 12 +- .../relations/get/LocalGetRelationsGateway.ts | 33 +++ .../relations/get/RelationDirection.ts | 1 + .../relations/get/RelationListFilter.ts | 15 ++ .../relations/get/RelationNodeReference.ts | 6 + .../relations/get/RelationStatusFilter.ts | 1 + .../traverse/ITraverseRelationsGateway.ts | 6 + .../traverse/LocalTraverseRelationsGateway.ts | 194 +++++++++++++++ .../traverse/RelationTraversalNode.ts | 5 + .../traverse/RelationTraversalResult.ts | 13 + .../traverse/TraverseRelationsController.ts | 11 + .../traverse/TraverseRelationsRequest.ts | 15 ++ src/application/host/IApplicationContainer.ts | 2 + .../relations/get/SqliteRelationViewReader.ts | 106 ++++++++- src/infrastructure/host/HostBuilder.ts | 5 + .../commands/registry/generated-commands.ts | 6 + .../list/RelationListOutputBuilder.ts | 22 +- .../commands/relations/list/relations.list.ts | 64 +++-- .../traverse/RelationTraverseOutputBuilder.ts | 107 +++++++++ .../relations/traverse/relations.traverse.ts | 84 +++++++ .../get/GetRelationsController.test.ts | 14 +- .../get/LocalGetRelationsGateway.test.ts | 58 +++++ .../relations/get/RelationDirection.test.ts | 9 + .../relations/get/RelationListFilter.test.ts | 19 ++ .../get/RelationNodeReference.test.ts | 9 + .../get/RelationStatusFilter.test.ts | 9 + .../ITraverseRelationsGateway.test.ts | 23 ++ .../LocalTraverseRelationsGateway.test.ts | 222 ++++++++++++++++++ .../traverse/RelationTraversalNode.test.ts | 9 + .../traverse/RelationTraversalResult.test.ts | 20 ++ .../TraverseRelationsController.test.ts | 24 ++ .../traverse/TraverseRelationsRequest.test.ts | 20 ++ .../get/LocalGetRelationsGateway.test.ts | 67 ------ .../get/SqliteRelationViewReader.test.ts | 111 +++++++++ .../list/RelationListOutputBuilder.test.ts | 59 +++++ .../relations/list/relations.list.test.ts | 25 ++ .../RelationTraverseOutputBuilder.test.ts | 77 ++++++ .../traverse/relations.traverse.test.ts | 83 +++++++ 44 files changed, 1528 insertions(+), 113 deletions(-) create mode 100644 src/application/context/relations/get/RelationDirection.ts create mode 100644 src/application/context/relations/get/RelationListFilter.ts create mode 100644 src/application/context/relations/get/RelationNodeReference.ts create mode 100644 src/application/context/relations/get/RelationStatusFilter.ts create mode 100644 src/application/context/relations/traverse/ITraverseRelationsGateway.ts create mode 100644 src/application/context/relations/traverse/LocalTraverseRelationsGateway.ts create mode 100644 src/application/context/relations/traverse/RelationTraversalNode.ts create mode 100644 src/application/context/relations/traverse/RelationTraversalResult.ts create mode 100644 src/application/context/relations/traverse/TraverseRelationsController.ts create mode 100644 src/application/context/relations/traverse/TraverseRelationsRequest.ts create mode 100644 src/presentation/cli/commands/relations/traverse/RelationTraverseOutputBuilder.ts create mode 100644 src/presentation/cli/commands/relations/traverse/relations.traverse.ts create mode 100644 tests/application/context/relations/get/LocalGetRelationsGateway.test.ts create mode 100644 tests/application/context/relations/get/RelationDirection.test.ts create mode 100644 tests/application/context/relations/get/RelationListFilter.test.ts create mode 100644 tests/application/context/relations/get/RelationNodeReference.test.ts create mode 100644 tests/application/context/relations/get/RelationStatusFilter.test.ts create mode 100644 tests/application/context/relations/traverse/ITraverseRelationsGateway.test.ts create mode 100644 tests/application/context/relations/traverse/LocalTraverseRelationsGateway.test.ts create mode 100644 tests/application/context/relations/traverse/RelationTraversalNode.test.ts create mode 100644 tests/application/context/relations/traverse/RelationTraversalResult.test.ts create mode 100644 tests/application/context/relations/traverse/TraverseRelationsController.test.ts create mode 100644 tests/application/context/relations/traverse/TraverseRelationsRequest.test.ts delete mode 100644 tests/infrastructure/context/relations/get/LocalGetRelationsGateway.test.ts create mode 100644 tests/infrastructure/context/relations/get/SqliteRelationViewReader.test.ts create mode 100644 tests/presentation/cli/commands/relations/list/RelationListOutputBuilder.test.ts create mode 100644 tests/presentation/cli/commands/relations/traverse/RelationTraverseOutputBuilder.test.ts create mode 100644 tests/presentation/cli/commands/relations/traverse/relations.traverse.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 982e5e17..3be24079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Bounded relation traversal**: Added `jumbo relations traverse` with deterministic breadth-first traversal, typed root inference, directed graph semantics, depth and edge limits, relation filters, hop-grouped text output, and stable structured output. + +### Changed + +- **Relation list filters**: Expanded `jumbo relations list` with direction, relation type, related entity type, strength, and entity ID filters while preserving active-only status as the default. + ## [3.17.0] - 2026-07-16 ### Changed diff --git a/docs/README.md b/docs/README.md index 928ef5f0..bcfcc180 100644 --- a/docs/README.md +++ b/docs/README.md @@ -54,7 +54,7 @@ Memory and context orchestration for coding agents. | [Audience Commands](reference/commands/audiences.md) | add, list, update, remove | | [Audience Pain Commands](reference/commands/audience-pains.md) | add, list, update | | [Value Proposition Commands](reference/commands/value-propositions.md) | add, list, update, remove | -| [Relation Commands](reference/commands/relations.md) | add, list, remove | +| [Relation Commands](reference/commands/relations.md) | add, list, traverse, remove | | [Work Commands](reference/commands/work.md) | pause, resume | | [Maintenance Commands](reference/commands/maintenance.md) | heal, evolve | | [Worker Commands](reference/commands/worker.md) | view | diff --git a/docs/reference/commands/index.md b/docs/reference/commands/index.md index beae8b05..477513fb 100644 --- a/docs/reference/commands/index.md +++ b/docs/reference/commands/index.md @@ -24,7 +24,7 @@ Complete reference for all Jumbo CLI commands. | [Audience Commands](audiences.md) | add, list, update, remove | | [Audience Pain Commands](audience-pains.md) | add, list, update | | [Value Proposition Commands](value-propositions.md) | add, list, update, remove | -| [Relation Commands](relations.md) | add, list, remove | +| [Relation Commands](relations.md) | add, list, traverse, remove | | [Work Commands](work.md) | pause, resume | | [Maintenance Commands](maintenance.md) | heal, evolve | | [Telemetry Commands](telemetry.md) | status, enable, disable | diff --git a/docs/reference/commands/relations.md b/docs/reference/commands/relations.md index 99d72270..9854729e 100644 --- a/docs/reference/commands/relations.md +++ b/docs/reference/commands/relations.md @@ -5,7 +5,7 @@ sidebar: order: 12 --- -Add, list, and remove relationships between entities in the knowledge graph — linking goals, components, decisions, and other entities. +Add, list, traverse, and remove relationships between entities in the knowledge graph — linking goals, components, decisions, and other entities. --- @@ -67,8 +67,12 @@ List all knowledge graph relations. | Option | Description | |--------|-------------| | `--entity-type ` | Filter by entity type, e.g. `goal`, `decision`, `component` | -| `--entity-id ` | Filter by entity ID (requires `--entity-type`) | -| `-s, --status ` | Filter by status: `active`, `removed`, `all` (default: `active`) | +| `--entity-id ` | Filter by entity ID | +| `-d, --direction ` | Filter relative to the entity: `in`, `out`, or `both` (default: `both`) | +| `--relation-type ` | Filter by relation type | +| `--related-entity-type ` | Filter by the type at the opposite endpoint | +| `--strength ` | Filter by strength: `strong`, `medium`, or `weak` | +| `-s, --status ` | Filter by status: `active`, `deactivated`, `removed`, or `all` (default: `active`) | ### Examples @@ -76,11 +80,50 @@ List all knowledge graph relations. > jumbo relations list > jumbo relations list --entity-type goal > jumbo relations list --entity-type component --entity-id comp_abc123 +> jumbo relations list --entity-id comp_abc123 --direction out --relation-type requires +> jumbo relations list --related-entity-type decision --strength strong > jumbo relations list --status all ``` --- +## jumbo relations traverse + +Traverse a bounded portion of the relation graph from one entity. Traversal uses deterministic breadth-first search and preserves every relation's original direction. + +### Synopsis + +```bash +> jumbo relations traverse --id [options] +``` + +### Options + +| Option | Description | +|--------|-------------| +| `-i, --id ` | Entity ID at the traversal root (required) | +| `--entity-type ` | Root entity type; inferred when the ID identifies one endpoint type | +| `--depth ` | Traversal depth from `1` through `5` (default: `1`) | +| `-d, --direction ` | Traversal direction: `in`, `out`, or `both` (default: `both`) | +| `--relation-type ` | Filter by relation type | +| `--related-entity-type ` | Filter each expansion by the opposite endpoint type | +| `--strength ` | Filter by strength: `strong`, `medium`, or `weak` | +| `-s, --status ` | Filter by status: `active`, `deactivated`, `removed`, or `all` (default: `active`) | +| `--limit ` | Maximum distinct edges from `1` through `1000` (default: `100`) | + +If an ID appears under multiple entity types, specify `--entity-type`. Results include the resolved root, distinct nodes with their minimum hop distance, directed edges, reached depth, limit, and truncation state. Text output groups results by hop; `--format json` returns the stable structured graph result. + +### Examples + +```bash +> jumbo relations traverse --id goal_abc123 +> jumbo relations traverse --id goal_abc123 --entity-type goal --depth 3 +> jumbo relations traverse --id comp_abc123 --direction out --relation-type requires --limit 250 +> jumbo relations traverse --id comp_abc123 --depth 2 --format json +``` + +--- + ## jumbo relation remove Remove a relation from the knowledge graph. diff --git a/docs/reference/index.md b/docs/reference/index.md index 48c0b3bc..47316c48 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -23,7 +23,7 @@ Complete command documentation. | [Audience Commands](commands/audiences.md) | add, list, update, remove | | [Audience Pain Commands](commands/audience-pains.md) | add, list, update | | [Value Proposition Commands](commands/value-propositions.md) | add, list, update, remove | -| [Relation Commands](commands/relations.md) | add, list, remove | +| [Relation Commands](commands/relations.md) | add, list, traverse, remove | | [Work Commands](commands/work.md) | pause, resume | | [Maintenance Commands](commands/maintenance.md) | heal, evolve | | [Worker Commands](commands/worker.md) | view | diff --git a/src/application/context/relations/get/GetRelationsRequest.ts b/src/application/context/relations/get/GetRelationsRequest.ts index 1d640e5e..219a4cbe 100644 --- a/src/application/context/relations/get/GetRelationsRequest.ts +++ b/src/application/context/relations/get/GetRelationsRequest.ts @@ -1,7 +1,15 @@ -import { EntityTypeValue } from "../../../../domain/relations/Constants.js"; +import { EntityTypeValue, RelationStrengthValue } from "../../../../domain/relations/Constants.js"; +import { RelationDirection } from "./RelationDirection.js"; +import { RelationStatusFilter } from "./RelationStatusFilter.js"; +import { RelationNodeReference } from "./RelationNodeReference.js"; export interface GetRelationsRequest { + readonly entity?: RelationNodeReference; readonly entityType?: EntityTypeValue; readonly entityId?: string; - readonly status: "active" | "deactivated" | "removed" | "all"; + readonly direction?: RelationDirection; + readonly relationType?: string; + readonly relatedEntityType?: EntityTypeValue; + readonly strength?: RelationStrengthValue; + readonly status: RelationStatusFilter; } diff --git a/src/application/context/relations/get/IRelationViewReader.ts b/src/application/context/relations/get/IRelationViewReader.ts index 8d0c4255..419b798b 100644 --- a/src/application/context/relations/get/IRelationViewReader.ts +++ b/src/application/context/relations/get/IRelationViewReader.ts @@ -3,14 +3,9 @@ * Used by ListRelationsQueryHandler to retrieve relation list with optional filtering. */ -import { RelationView } from "../RelationView.js"; import { EntityTypeValue } from "../../../../domain/relations/Constants.js"; - -export interface RelationListFilter { - entityType?: EntityTypeValue; - entityId?: string; - status?: "active" | "deactivated" | "removed" | "all"; -} +import { RelationView } from "../RelationView.js"; +import { RelationListFilter } from "./RelationListFilter.js"; export interface IRelationViewReader { /** @@ -19,4 +14,7 @@ export interface IRelationViewReader { * @returns Array of relation views ordered by creation date */ findAll(filter?: RelationListFilter): Promise; + + /** Returns the distinct projection endpoint types associated with an entity ID. */ + findEndpointTypes(entityId: string): Promise; } diff --git a/src/application/context/relations/get/LocalGetRelationsGateway.ts b/src/application/context/relations/get/LocalGetRelationsGateway.ts index e4825b1e..33d116d2 100644 --- a/src/application/context/relations/get/LocalGetRelationsGateway.ts +++ b/src/application/context/relations/get/LocalGetRelationsGateway.ts @@ -2,6 +2,7 @@ import { IGetRelationsGateway } from "./IGetRelationsGateway.js"; import { GetRelationsRequest } from "./GetRelationsRequest.js"; import { GetRelationsResponse } from "./GetRelationsResponse.js"; import { IRelationViewReader } from "./IRelationViewReader.js"; +import { EntityType, RelationStrength } from "../../../../domain/relations/Constants.js"; export class LocalGetRelationsGateway implements IGetRelationsGateway { constructor( @@ -9,11 +10,43 @@ export class LocalGetRelationsGateway implements IGetRelationsGateway { ) {} async getRelations(request: GetRelationsRequest): Promise { + this.validate(request); + const entity = request.entity ?? ( + request.entityType && request.entityId + ? { entityType: request.entityType, entityId: request.entityId } + : undefined + ); const relations = await this.relationViewReader.findAll({ + entity, entityType: request.entityType, entityId: request.entityId, + direction: request.direction, + relationType: request.relationType, + relatedEntityType: request.relatedEntityType, + strength: request.strength, status: request.status, }); return { relations }; } + + private validate(request: GetRelationsRequest): void { + if (request.direction && !["in", "out", "both"].includes(request.direction)) { + throw new Error("Direction must be one of: in, out, both."); + } + if (!["active", "deactivated", "removed", "all"].includes(request.status)) { + throw new Error("Status must be one of: active, deactivated, removed, all."); + } + if (request.entityType && !Object.values(EntityType).includes(request.entityType)) { + throw new Error(`Entity type must be one of: ${Object.values(EntityType).join(", ")}.`); + } + if (request.entity && !Object.values(EntityType).includes(request.entity.entityType)) { + throw new Error(`Entity type must be one of: ${Object.values(EntityType).join(", ")}.`); + } + if (request.relatedEntityType && !Object.values(EntityType).includes(request.relatedEntityType)) { + throw new Error(`Related entity type must be one of: ${Object.values(EntityType).join(", ")}.`); + } + if (request.strength && !Object.values(RelationStrength).includes(request.strength)) { + throw new Error("Strength must be one of: strong, medium, weak."); + } + } } diff --git a/src/application/context/relations/get/RelationDirection.ts b/src/application/context/relations/get/RelationDirection.ts new file mode 100644 index 00000000..65f19d00 --- /dev/null +++ b/src/application/context/relations/get/RelationDirection.ts @@ -0,0 +1 @@ +export type RelationDirection = "in" | "out" | "both"; diff --git a/src/application/context/relations/get/RelationListFilter.ts b/src/application/context/relations/get/RelationListFilter.ts new file mode 100644 index 00000000..b34dab51 --- /dev/null +++ b/src/application/context/relations/get/RelationListFilter.ts @@ -0,0 +1,15 @@ +import { EntityTypeValue, RelationStrengthValue } from "../../../../domain/relations/Constants.js"; +import { RelationDirection } from "./RelationDirection.js"; +import { RelationStatusFilter } from "./RelationStatusFilter.js"; +import { RelationNodeReference } from "./RelationNodeReference.js"; + +export interface RelationListFilter { + readonly entity?: RelationNodeReference; + readonly entityType?: EntityTypeValue; + readonly entityId?: string; + readonly direction?: RelationDirection; + readonly relationType?: string; + readonly relatedEntityType?: EntityTypeValue; + readonly strength?: RelationStrengthValue; + readonly status?: RelationStatusFilter; +} diff --git a/src/application/context/relations/get/RelationNodeReference.ts b/src/application/context/relations/get/RelationNodeReference.ts new file mode 100644 index 00000000..dd40af5d --- /dev/null +++ b/src/application/context/relations/get/RelationNodeReference.ts @@ -0,0 +1,6 @@ +import { EntityTypeValue } from "../../../../domain/relations/Constants.js"; + +export interface RelationNodeReference { + readonly entityType: EntityTypeValue; + readonly entityId: string; +} diff --git a/src/application/context/relations/get/RelationStatusFilter.ts b/src/application/context/relations/get/RelationStatusFilter.ts new file mode 100644 index 00000000..a3b996c7 --- /dev/null +++ b/src/application/context/relations/get/RelationStatusFilter.ts @@ -0,0 +1 @@ +export type RelationStatusFilter = "active" | "deactivated" | "removed" | "all"; diff --git a/src/application/context/relations/traverse/ITraverseRelationsGateway.ts b/src/application/context/relations/traverse/ITraverseRelationsGateway.ts new file mode 100644 index 00000000..9c0824e2 --- /dev/null +++ b/src/application/context/relations/traverse/ITraverseRelationsGateway.ts @@ -0,0 +1,6 @@ +import { RelationTraversalResult } from "./RelationTraversalResult.js"; +import { TraverseRelationsRequest } from "./TraverseRelationsRequest.js"; + +export interface ITraverseRelationsGateway { + traverse(request: TraverseRelationsRequest): Promise; +} diff --git a/src/application/context/relations/traverse/LocalTraverseRelationsGateway.ts b/src/application/context/relations/traverse/LocalTraverseRelationsGateway.ts new file mode 100644 index 00000000..c5577751 --- /dev/null +++ b/src/application/context/relations/traverse/LocalTraverseRelationsGateway.ts @@ -0,0 +1,194 @@ +import { + EntityType, + EntityTypeValue, + RelationStrength, +} from "../../../../domain/relations/Constants.js"; +import { RelationView } from "../RelationView.js"; +import { IRelationViewReader } from "../get/IRelationViewReader.js"; +import { RelationDirection } from "../get/RelationDirection.js"; +import { RelationStatusFilter } from "../get/RelationStatusFilter.js"; +import { RelationNodeReference } from "../get/RelationNodeReference.js"; +import { ITraverseRelationsGateway } from "./ITraverseRelationsGateway.js"; +import { RelationTraversalNode } from "./RelationTraversalNode.js"; +import { RelationTraversalResult } from "./RelationTraversalResult.js"; +import { TraverseRelationsRequest } from "./TraverseRelationsRequest.js"; + +type NormalizedTraversalRequest = { + entityId: string; + entityType?: EntityTypeValue; + depth: number; + direction: RelationDirection; + relationType?: string; + relatedEntityType?: EntityTypeValue; + strength?: "strong" | "medium" | "weak"; + status: RelationStatusFilter; + limit: number; +}; + +export class LocalTraverseRelationsGateway implements ITraverseRelationsGateway { + constructor(private readonly relationViewReader: IRelationViewReader) {} + + async traverse(request: TraverseRelationsRequest): Promise { + const normalized = this.normalize(request); + const root = await this.resolveRoot(normalized.entityId, normalized.entityType); + const visited = new Set([this.nodeKey(root)]); + const nodes = new Map(); + const edges = new Map(); + let frontier: RelationNodeReference[] = [root]; + let truncated = false; + + traversal: + for (let distance = 1; distance <= normalized.depth && frontier.length > 0; distance++) { + const next = new Map(); + + for (const current of [...frontier].sort((left, right) => this.compareNodes(left, right))) { + const adjacent = await this.relationViewReader.findAll({ + entity: current, + direction: normalized.direction, + relationType: normalized.relationType, + relatedEntityType: normalized.relatedEntityType, + strength: normalized.strength, + status: normalized.status, + }); + + for (const edge of [...adjacent].sort((left, right) => this.compareEdges(left, right))) { + if (edges.has(edge.relationId)) continue; + + edges.set(edge.relationId, edge); + const related = this.relatedNode(current, edge, normalized.direction); + if (related) { + const relatedKey = this.nodeKey(related); + if (!visited.has(relatedKey)) { + visited.add(relatedKey); + const traversalNode: RelationTraversalNode = { ...related, distance }; + nodes.set(relatedKey, traversalNode); + next.set(relatedKey, related); + } + } + + if (edges.size === normalized.limit) { + truncated = true; + break traversal; + } + } + } + + frontier = [...next.values()]; + } + + const stableNodes = [...nodes.values()].sort((left, right) => + left.distance - right.distance || this.compareNodes(left, right) + ); + const stableEdges = [...edges.values()].sort((left, right) => this.compareEdges(left, right)); + + return { + root, + nodes: stableNodes, + edges: stableEdges, + requestedDepth: normalized.depth, + reachedDepth: stableNodes.reduce((maximum, node) => Math.max(maximum, node.distance), 0), + limit: normalized.limit, + truncated, + }; + } + + private normalize(request: TraverseRelationsRequest): NormalizedTraversalRequest { + const entityId = request.entityId?.trim(); + if (!entityId) throw new Error("Entity ID must be provided."); + + const depth = request.depth ?? 1; + if (!Number.isInteger(depth) || depth < 1 || depth > 5) { + throw new Error("Depth must be an integer from 1 through 5."); + } + + const limit = request.limit ?? 100; + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { + throw new Error("Limit must be an integer from 1 through 1000."); + } + + const direction = request.direction ?? "both"; + if (!["in", "out", "both"].includes(direction)) { + throw new Error("Direction must be one of: in, out, both."); + } + + const status = request.status ?? "active"; + if (!["active", "deactivated", "removed", "all"].includes(status)) { + throw new Error("Status must be one of: active, deactivated, removed, all."); + } + + if (request.entityType) this.assertEntityType(request.entityType, "Entity type"); + if (request.relatedEntityType) this.assertEntityType(request.relatedEntityType, "Related entity type"); + if (request.strength && !Object.values(RelationStrength).includes(request.strength)) { + throw new Error("Strength must be one of: strong, medium, weak."); + } + + return { + ...request, + entityId, + depth, + direction, + status, + limit, + }; + } + + private async resolveRoot( + entityId: string, + explicitType: EntityTypeValue | undefined + ): Promise { + if (explicitType) return { entityType: explicitType, entityId }; + + const candidates = await this.relationViewReader.findEndpointTypes(entityId); + if (candidates.length === 1) return { entityType: candidates[0], entityId }; + if (candidates.length === 0) { + throw new Error( + `No endpoint type can be inferred for entity ID '${entityId}'. Specify --entity-type explicitly.` + ); + } + + throw new Error( + `Entity ID '${entityId}' matches multiple endpoint types: ${[...candidates].sort().join(", ")}. ` + + "Specify --entity-type explicitly." + ); + } + + private relatedNode( + current: RelationNodeReference, + edge: RelationView, + direction: RelationDirection + ): RelationNodeReference | undefined { + const isFrom = edge.fromEntityType === current.entityType && edge.fromEntityId === current.entityId; + const isTo = edge.toEntityType === current.entityType && edge.toEntityId === current.entityId; + + if (isFrom && direction !== "in") { + return { entityType: edge.toEntityType, entityId: edge.toEntityId }; + } + if (isTo && direction !== "out") { + return { entityType: edge.fromEntityType, entityId: edge.fromEntityId }; + } + return undefined; + } + + private assertEntityType(value: EntityTypeValue, label: string): void { + if (!Object.values(EntityType).includes(value)) { + throw new Error(`${label} must be one of: ${Object.values(EntityType).join(", ")}.`); + } + } + + private nodeKey(node: RelationNodeReference): string { + return `${node.entityType}\u0000${node.entityId}`; + } + + private compareNodes(left: RelationNodeReference, right: RelationNodeReference): number { + return left.entityType.localeCompare(right.entityType) || left.entityId.localeCompare(right.entityId); + } + + private compareEdges(left: RelationView, right: RelationView): number { + return left.createdAt.localeCompare(right.createdAt) + || left.relationId.localeCompare(right.relationId) + || left.fromEntityType.localeCompare(right.fromEntityType) + || left.fromEntityId.localeCompare(right.fromEntityId) + || left.toEntityType.localeCompare(right.toEntityType) + || left.toEntityId.localeCompare(right.toEntityId); + } +} diff --git a/src/application/context/relations/traverse/RelationTraversalNode.ts b/src/application/context/relations/traverse/RelationTraversalNode.ts new file mode 100644 index 00000000..1b5e7469 --- /dev/null +++ b/src/application/context/relations/traverse/RelationTraversalNode.ts @@ -0,0 +1,5 @@ +import { RelationNodeReference } from "../get/RelationNodeReference.js"; + +export interface RelationTraversalNode extends RelationNodeReference { + readonly distance: number; +} diff --git a/src/application/context/relations/traverse/RelationTraversalResult.ts b/src/application/context/relations/traverse/RelationTraversalResult.ts new file mode 100644 index 00000000..c4791aa4 --- /dev/null +++ b/src/application/context/relations/traverse/RelationTraversalResult.ts @@ -0,0 +1,13 @@ +import { RelationView } from "../RelationView.js"; +import { RelationNodeReference } from "../get/RelationNodeReference.js"; +import { RelationTraversalNode } from "./RelationTraversalNode.js"; + +export interface RelationTraversalResult { + readonly root: RelationNodeReference; + readonly nodes: RelationTraversalNode[]; + readonly edges: RelationView[]; + readonly requestedDepth: number; + readonly reachedDepth: number; + readonly limit: number; + readonly truncated: boolean; +} diff --git a/src/application/context/relations/traverse/TraverseRelationsController.ts b/src/application/context/relations/traverse/TraverseRelationsController.ts new file mode 100644 index 00000000..2f371e1a --- /dev/null +++ b/src/application/context/relations/traverse/TraverseRelationsController.ts @@ -0,0 +1,11 @@ +import { ITraverseRelationsGateway } from "./ITraverseRelationsGateway.js"; +import { RelationTraversalResult } from "./RelationTraversalResult.js"; +import { TraverseRelationsRequest } from "./TraverseRelationsRequest.js"; + +export class TraverseRelationsController { + constructor(private readonly gateway: ITraverseRelationsGateway) {} + + async handle(request: TraverseRelationsRequest): Promise { + return this.gateway.traverse(request); + } +} diff --git a/src/application/context/relations/traverse/TraverseRelationsRequest.ts b/src/application/context/relations/traverse/TraverseRelationsRequest.ts new file mode 100644 index 00000000..3dd64dbd --- /dev/null +++ b/src/application/context/relations/traverse/TraverseRelationsRequest.ts @@ -0,0 +1,15 @@ +import { EntityTypeValue, RelationStrengthValue } from "../../../../domain/relations/Constants.js"; +import { RelationDirection } from "../get/RelationDirection.js"; +import { RelationStatusFilter } from "../get/RelationStatusFilter.js"; + +export interface TraverseRelationsRequest { + readonly entityId: string; + readonly entityType?: EntityTypeValue; + readonly depth?: number; + readonly direction?: RelationDirection; + readonly relationType?: string; + readonly relatedEntityType?: EntityTypeValue; + readonly strength?: RelationStrengthValue; + readonly status?: RelationStatusFilter; + readonly limit?: number; +} diff --git a/src/application/host/IApplicationContainer.ts b/src/application/host/IApplicationContainer.ts index d23fb800..58988b38 100644 --- a/src/application/host/IApplicationContainer.ts +++ b/src/application/host/IApplicationContainer.ts @@ -328,6 +328,7 @@ import { IValuePropositionRemovedEventWriter } from "../context/value-propositio import { AddRelationController } from "../context/relations/add/AddRelationController.js"; import { RemoveRelationController } from "../context/relations/remove/RemoveRelationController.js"; import { GetRelationsController } from "../context/relations/get/GetRelationsController.js"; +import { TraverseRelationsController } from "../context/relations/traverse/TraverseRelationsController.js"; import { AddGuidelineController } from "../context/guidelines/add/AddGuidelineController.js"; import { UpdateGuidelineController } from "../context/guidelines/update/UpdateGuidelineController.js"; import { RemoveGuidelineController } from "../context/guidelines/remove/RemoveGuidelineController.js"; @@ -639,6 +640,7 @@ export interface IApplicationContainer { addRelationController: AddRelationController; removeRelationController: RemoveRelationController; getRelationsController: GetRelationsController; + traverseRelationsController: TraverseRelationsController; // Relations Category - Event Stores - decomposed by use case relationAddedEventStore: IRelationAddedEventWriter; diff --git a/src/infrastructure/context/relations/get/SqliteRelationViewReader.ts b/src/infrastructure/context/relations/get/SqliteRelationViewReader.ts index 99fac999..b8365167 100644 --- a/src/infrastructure/context/relations/get/SqliteRelationViewReader.ts +++ b/src/infrastructure/context/relations/get/SqliteRelationViewReader.ts @@ -6,7 +6,9 @@ */ import { Database } from "better-sqlite3"; -import { IRelationViewReader, RelationListFilter } from "../../../../application/context/relations/get/IRelationViewReader.js"; +import { EntityTypeValue } from "../../../../domain/relations/Constants.js"; +import { IRelationViewReader } from "../../../../application/context/relations/get/IRelationViewReader.js"; +import { RelationListFilter } from "../../../../application/context/relations/get/RelationListFilter.js"; import { RelationView } from "../../../../application/context/relations/RelationView.js"; import { RelationRecord } from "../RelationRecord.js"; import { RelationRecordMapper } from "../RelationRecordMapper.js"; @@ -30,24 +32,104 @@ export class SqliteRelationViewReader implements IRelationViewReader { } // If status === 'all', no filter applied - // Apply entity filter (matches either source or target) - if (filter?.entityType && filter?.entityId) { - query += " AND ((fromEntityType = ? AND fromEntityId = ?) OR (toEntityType = ? AND toEntityId = ?))"; - params.push(filter.entityType, filter.entityId, filter.entityType, filter.entityId); - } else if (filter?.entityType) { - query += " AND (fromEntityType = ? OR toEntityType = ?)"; - params.push(filter.entityType, filter.entityType); - } else if (filter?.entityId) { - query += " AND (fromEntityId = ? OR toEntityId = ?)"; - params.push(filter.entityId, filter.entityId); + this.appendEndpointFilter(filter, params, (clause) => { + query += ` AND ${clause}`; + }); + + if (filter?.relationType) { + query += " AND relationType = ?"; + params.push(filter.relationType); + } + + if (filter?.strength) { + query += " AND strength = ?"; + params.push(filter.strength); } - query += " ORDER BY createdAt ASC"; + query += " ORDER BY createdAt ASC, relationId ASC"; const rows = this.db.prepare(query).all(...params); return rows.map((row) => this.mapper.toView(this.mapRowToRecord(row as Record))); } + async findEndpointTypes(entityId: string): Promise { + const rows = this.db.prepare(` + SELECT entityType + FROM ( + SELECT fromEntityType AS entityType FROM relation_views WHERE fromEntityId = ? + UNION + SELECT toEntityType AS entityType FROM relation_views WHERE toEntityId = ? + ) + ORDER BY entityType ASC + `).all(entityId, entityId) as Array<{ entityType: EntityTypeValue }>; + + return rows.map((row) => row.entityType); + } + + private appendEndpointFilter( + filter: RelationListFilter | undefined, + params: (string | number)[], + append: (clause: string) => void + ): void { + const entityType = filter?.entity?.entityType ?? filter?.entityType; + const entityId = filter?.entity?.entityId ?? filter?.entityId; + const from = this.endpointClause("from", entityType, entityId); + const to = this.endpointClause("to", entityType, entityId); + + if (!from && !to) { + if (filter?.relatedEntityType) { + append("(fromEntityType = ? OR toEntityType = ?)"); + params.push(filter.relatedEntityType, filter.relatedEntityType); + } + return; + } + + const direction = filter?.direction ?? "both"; + if (direction === "out") { + append(`${from}${filter?.relatedEntityType ? " AND toEntityType = ?" : ""}`); + this.pushEndpointParams(params, entityType, entityId); + if (filter?.relatedEntityType) params.push(filter.relatedEntityType); + return; + } + + if (direction === "in") { + append(`${to}${filter?.relatedEntityType ? " AND fromEntityType = ?" : ""}`); + this.pushEndpointParams(params, entityType, entityId); + if (filter?.relatedEntityType) params.push(filter.relatedEntityType); + return; + } + + const outgoing = `${from}${filter?.relatedEntityType ? " AND toEntityType = ?" : ""}`; + const incoming = `${to}${filter?.relatedEntityType ? " AND fromEntityType = ?" : ""}`; + append(`((${outgoing}) OR (${incoming}))`); + this.pushEndpointParams(params, entityType, entityId); + if (filter?.relatedEntityType) params.push(filter.relatedEntityType); + this.pushEndpointParams(params, entityType, entityId); + if (filter?.relatedEntityType) params.push(filter.relatedEntityType); + } + + private endpointClause( + endpoint: "from" | "to", + entityType: EntityTypeValue | undefined, + entityId: string | undefined + ): string | undefined { + if (entityType && entityId) { + return `${endpoint}EntityType = ? AND ${endpoint}EntityId = ?`; + } + if (entityType) return `${endpoint}EntityType = ?`; + if (entityId) return `${endpoint}EntityId = ?`; + return undefined; + } + + private pushEndpointParams( + params: (string | number)[], + entityType: EntityTypeValue | undefined, + entityId: string | undefined + ): void { + if (entityType) params.push(entityType); + if (entityId) params.push(entityId); + } + private mapRowToRecord(row: Record): RelationRecord { return { id: row.relationId as string, diff --git a/src/infrastructure/host/HostBuilder.ts b/src/infrastructure/host/HostBuilder.ts index 0adfc052..354331d1 100644 --- a/src/infrastructure/host/HostBuilder.ts +++ b/src/infrastructure/host/HostBuilder.ts @@ -408,6 +408,8 @@ import { LocalRemoveRelationGateway } from "../../application/context/relations/ import { RemoveRelationController } from "../../application/context/relations/remove/RemoveRelationController.js"; import { GetRelationsController } from "../../application/context/relations/get/GetRelationsController.js"; import { LocalGetRelationsGateway } from "../../application/context/relations/get/LocalGetRelationsGateway.js"; +import { LocalTraverseRelationsGateway } from "../../application/context/relations/traverse/LocalTraverseRelationsGateway.js"; +import { TraverseRelationsController } from "../../application/context/relations/traverse/TraverseRelationsController.js"; // Context import { GoalContextQueryHandler } from "../../application/context/goals/get/GoalContextQueryHandler.js"; import { GoalBacklogPreviewQueryHandler } from "../../application/context/goals/query/GoalBacklogPreviewQueryHandler.js"; @@ -1805,6 +1807,8 @@ const audiencePainContextReader = new SqliteAudiencePainContextReader(this.db); const getRelationsController = new GetRelationsController( getRelationsGateway ); + const traverseRelationsGateway = new LocalTraverseRelationsGateway(relationViewReader); + const traverseRelationsController = new TraverseRelationsController(traverseRelationsGateway); // ============================================================ // STEP 5: Create Projection Handlers (Event Subscribers) @@ -2336,6 +2340,7 @@ const audiencePainContextReader = new SqliteAudiencePainContextReader(this.db); addRelationController, removeRelationController, getRelationsController, + traverseRelationsController, // Relations Category - decomposed by use case relationAddedEventStore, relationRemovedEventStore, diff --git a/src/presentation/cli/commands/registry/generated-commands.ts b/src/presentation/cli/commands/registry/generated-commands.ts index 29a9cde1..73dec0c1 100644 --- a/src/presentation/cli/commands/registry/generated-commands.ts +++ b/src/presentation/cli/commands/registry/generated-commands.ts @@ -81,6 +81,7 @@ import { projectUpdate, metadata as projectUpdateMeta } from '../../commands/pro import { relationAdd, metadata as relationAddMeta } from '../../commands/relations/add/relation.add.js'; import { relationsList, metadata as relationsListMeta } from '../../commands/relations/list/relations.list.js'; import { relationRemove, metadata as relationRemoveMeta } from '../../commands/relations/remove/relation.remove.js'; +import { relationsTraverse, metadata as relationsTraverseMeta } from '../../commands/relations/traverse/relations.traverse.js'; import { search, metadata as searchMeta } from '../../commands/search/search.js'; import { sessionCompact, metadata as sessionCompactMeta } from '../../commands/sessions/compact/session.compact.js'; import { sessionEnd, metadata as sessionEndMeta } from '../../commands/sessions/end/session.end.js'; @@ -459,6 +460,11 @@ export const commands: RegisteredCommand[] = [ metadata: relationRemoveMeta, handler: relationRemove }, + { + path: 'relations traverse', + metadata: relationsTraverseMeta, + handler: relationsTraverse + }, { path: 'search', metadata: searchMeta, diff --git a/src/presentation/cli/commands/relations/list/RelationListOutputBuilder.ts b/src/presentation/cli/commands/relations/list/RelationListOutputBuilder.ts index 71130523..7bf36798 100644 --- a/src/presentation/cli/commands/relations/list/RelationListOutputBuilder.ts +++ b/src/presentation/cli/commands/relations/list/RelationListOutputBuilder.ts @@ -3,12 +3,21 @@ import { TerminalOutput } from '../../../output/TerminalOutput.js'; import { RelationView } from '../../../../../application/context/relations/RelationView.js'; import { Colors, BrandColors, Symbols } from '../../../rendering/StyleConfig.js'; import { heading, contentLine, metaField, wrapContent } from '../../../rendering/OutputLayout.js'; +import { GetRelationsRequest } from '../../../../../application/context/relations/get/GetRelationsRequest.js'; export class RelationListOutputBuilder { private builder = new TerminalOutputBuilder(); build(relations: RelationView[], filter?: string): TerminalOutput { this.builder.reset(); + if (relations.length === 0) { + const filterLabel = filter ? ` involving ${filter}` : ""; + this.builder.addPrompt( + Colors.muted(`No relations found${filterLabel}. Use 'jumbo relation add' to add one.`) + ); + return this.builder.build(); + } + const filterLabel = filter ? ` (${filter})` : ""; const lines: string[] = []; @@ -32,11 +41,19 @@ export class RelationListOutputBuilder { return this.builder.build(); } - buildStructuredOutput(relations: RelationView[], filter?: { entityType?: string | null; entityId?: string | null; status?: string }): TerminalOutput { + buildStructuredOutput(relations: RelationView[], filter: GetRelationsRequest): TerminalOutput { this.builder.reset(); this.builder.addData({ count: relations.length, - filter: filter ?? null, + filter: { + entityType: filter.entity?.entityType ?? filter.entityType ?? null, + entityId: filter.entity?.entityId ?? filter.entityId ?? null, + direction: filter.direction ?? "both", + relationType: filter.relationType ?? null, + relatedEntityType: filter.relatedEntityType ?? null, + strength: filter.strength ?? null, + status: filter.status, + }, relations: relations.map((r) => ({ relationId: r.relationId, fromEntityType: r.fromEntityType, @@ -58,6 +75,7 @@ export class RelationListOutputBuilder { this.builder.reset(); this.builder.addPrompt(`${Symbols.cross} ${Colors.error("Failed to list relations")}`); this.builder.addData({ + error: "Failed to list relations", message: error instanceof Error ? error.message : error, }); return this.builder.build(); diff --git a/src/presentation/cli/commands/relations/list/relations.list.ts b/src/presentation/cli/commands/relations/list/relations.list.ts index 4a5f8bbd..4c22d15d 100644 --- a/src/presentation/cli/commands/relations/list/relations.list.ts +++ b/src/presentation/cli/commands/relations/list/relations.list.ts @@ -8,9 +8,11 @@ import { CommandMetadata } from "../../registry/CommandMetadata.js"; import { IApplicationContainer } from "../../../../../application/host/IApplicationContainer.js"; import { Renderer } from "../../../rendering/Renderer.js"; import { RenderData } from "../../../rendering/types.js"; -import { EntityTypeValue } from "../../../../../domain/relations/Constants.js"; +import { EntityTypeValue, RelationStrengthValue } from "../../../../../domain/relations/Constants.js"; import { GetRelationsRequest } from "../../../../../application/context/relations/get/GetRelationsRequest.js"; import { RelationListOutputBuilder } from './RelationListOutputBuilder.js'; +import { RelationDirection } from "../../../../../application/context/relations/get/RelationDirection.js"; +import { RelationStatusFilter } from "../../../../../application/context/relations/get/RelationStatusFilter.js"; export const metadata: CommandMetadata = { description: "List all knowledge graph relations", @@ -22,7 +24,23 @@ export const metadata: CommandMetadata = { }, { flags: "--entity-id ", - description: "Filter by entity ID (requires --entity-type)", + description: "Filter by entity ID", + }, + { + flags: "-d, --direction ", + description: "Filter relative to the entity: in, out, or both (default)", + }, + { + flags: "--relation-type ", + description: "Filter by relation type", + }, + { + flags: "--related-entity-type ", + description: "Filter by the type at the opposite endpoint", + }, + { + flags: "--strength ", + description: "Filter by strength: strong, medium, or weak", }, { flags: "-s, --status ", @@ -42,25 +60,34 @@ export const metadata: CommandMetadata = { }; export async function relationsList( - options: { entityType?: string; entityId?: string; status?: string }, + options: { + entityType?: string; + entityId?: string; + direction?: string; + relationType?: string; + relatedEntityType?: string; + strength?: string; + status?: string; + }, container: IApplicationContainer ) { const renderer = Renderer.getInstance(); try { const request: GetRelationsRequest = { + entity: options.entityType && options.entityId + ? { entityType: options.entityType as EntityTypeValue, entityId: options.entityId } + : undefined, entityType: options.entityType as EntityTypeValue | undefined, entityId: options.entityId, - status: (options.status as "active" | "deactivated" | "removed" | "all") ?? "active", + direction: options.direction as RelationDirection | undefined, + relationType: options.relationType, + relatedEntityType: options.relatedEntityType as EntityTypeValue | undefined, + strength: options.strength as RelationStrengthValue | undefined, + status: (options.status as RelationStatusFilter | undefined) ?? "active", }; const { relations } = await container.getRelationsController.handle(request); - if (relations.length === 0) { - const filterMsg = options.entityType ? ` involving ${options.entityType}` : ""; - renderer.info(`No relations found${filterMsg}. Use 'jumbo relation add' to add one.`); - return; - } - const config = renderer.getConfig(); const outputBuilder = new RelationListOutputBuilder(); @@ -71,17 +98,22 @@ export async function relationsList( const output = outputBuilder.build(relations, filterLabel); renderer.info(output.toHumanReadable()); } else { - const output = outputBuilder.buildStructuredOutput(relations, { - entityType: options.entityType ?? null, - entityId: options.entityId ?? null, - status: options.status ?? "active", - }); + const output = outputBuilder.buildStructuredOutput(relations, request); const sections = output.getSections(); const dataSection = sections.find(s => s.type === "data"); if (dataSection) renderer.data(dataSection.content as RenderData); } } catch (error) { - renderer.error("Failed to list relations", error instanceof Error ? error : String(error)); + const output = new RelationListOutputBuilder().buildFailureError( + error instanceof Error ? error : String(error) + ); + const config = renderer.getConfig(); + if (config.format === "text") { + renderer.error(output.toHumanReadable()); + } else { + const dataSection = output.getSections().find((section) => section.type === "data"); + if (dataSection) renderer.data(dataSection.content as RenderData); + } process.exit(1); } } diff --git a/src/presentation/cli/commands/relations/traverse/RelationTraverseOutputBuilder.ts b/src/presentation/cli/commands/relations/traverse/RelationTraverseOutputBuilder.ts new file mode 100644 index 00000000..77567b95 --- /dev/null +++ b/src/presentation/cli/commands/relations/traverse/RelationTraverseOutputBuilder.ts @@ -0,0 +1,107 @@ +import { RelationTraversalResult } from "../../../../../application/context/relations/traverse/RelationTraversalResult.js"; +import { RelationView } from "../../../../../application/context/relations/RelationView.js"; +import { TerminalOutput } from "../../../output/TerminalOutput.js"; +import { TerminalOutputBuilder } from "../../../output/TerminalOutputBuilder.js"; +import { contentLine, heading, metaField } from "../../../rendering/OutputLayout.js"; +import { BrandColors, Colors, Symbols } from "../../../rendering/StyleConfig.js"; + +export class RelationTraverseOutputBuilder { + private readonly builder = new TerminalOutputBuilder(); + + build(result: RelationTraversalResult): TerminalOutput { + this.builder.reset(); + const lines = [ + "", + heading(`Relation traversal from ${this.nodeLabel(result.root.entityType, result.root.entityId)}`), + metaField("Depth", Colors.muted(`${result.reachedDepth}/${result.requestedDepth}`), 11), + metaField("Edges", Colors.muted(`${result.edges.length}/${result.limit}`), 11), + metaField("Truncated", Colors.muted(String(result.truncated)), 11), + ]; + + if (result.edges.length === 0) { + lines.push(""); + lines.push(contentLine(Colors.muted("No matching relations were found."))); + } else { + const distances = new Map([ + [this.nodeKey(result.root.entityType, result.root.entityId), 0], + ...result.nodes.map((node) => [ + this.nodeKey(node.entityType, node.entityId), + node.distance, + ] as [string, number]), + ]); + + for (let hop = 1; hop <= result.reachedDepth; hop++) { + const nodes = result.nodes.filter((node) => node.distance === hop); + const edges = result.edges.filter((edge) => this.edgeHop(edge, distances) === hop); + lines.push(""); + lines.push(heading(`Hop ${hop}`)); + for (const node of nodes) { + lines.push(contentLine(BrandColors.accentCyan(this.nodeLabel(node.entityType, node.entityId)))); + } + for (const edge of edges) { + const strength = edge.strength ? `, ${edge.strength}` : ""; + lines.push(contentLine( + `${this.nodeLabel(edge.fromEntityType, edge.fromEntityId)} ` + + `--[${edge.relationType}${strength}]--> ` + + this.nodeLabel(edge.toEntityType, edge.toEntityId) + )); + } + } + } + + this.builder.addPrompt(lines.join("\n")); + return this.builder.build(); + } + + buildStructuredOutput(result: RelationTraversalResult): TerminalOutput { + this.builder.reset(); + this.builder.addData({ + root: { ...result.root }, + nodes: result.nodes.map((node) => ({ ...node })), + edges: result.edges.map((edge) => this.edgeData(edge)), + requestedDepth: result.requestedDepth, + reachedDepth: result.reachedDepth, + limit: result.limit, + truncated: result.truncated, + }); + return this.builder.build(); + } + + buildFailureError(error: Error | string): TerminalOutput { + this.builder.reset(); + const message = error instanceof Error ? error.message : error; + this.builder.addPrompt(`${Symbols.cross} ${Colors.error("Failed to traverse relations")}: ${message}`); + this.builder.addData({ error: "Failed to traverse relations", message }); + return this.builder.build(); + } + + private edgeData(edge: RelationView): Record { + return { + relationId: edge.relationId, + fromEntityType: edge.fromEntityType, + fromEntityId: edge.fromEntityId, + toEntityType: edge.toEntityType, + toEntityId: edge.toEntityId, + relationType: edge.relationType, + strength: edge.strength, + description: edge.description, + status: edge.status, + createdAt: edge.createdAt, + updatedAt: edge.updatedAt, + }; + } + + private edgeHop(edge: RelationView, distances: Map): number { + const fromDistance = distances.get(this.nodeKey(edge.fromEntityType, edge.fromEntityId)) ?? 0; + const toDistance = distances.get(this.nodeKey(edge.toEntityType, edge.toEntityId)) ?? 0; + return Math.max(fromDistance, toDistance, 1); + } + + private nodeKey(entityType: string, entityId: string): string { + return `${entityType}\u0000${entityId}`; + } + + private nodeLabel(entityType: string, entityId: string): string { + return `${entityType}:${entityId}`; + } +} diff --git a/src/presentation/cli/commands/relations/traverse/relations.traverse.ts b/src/presentation/cli/commands/relations/traverse/relations.traverse.ts new file mode 100644 index 00000000..86f22f4f --- /dev/null +++ b/src/presentation/cli/commands/relations/traverse/relations.traverse.ts @@ -0,0 +1,84 @@ +import { EntityTypeValue, RelationStrengthValue } from "../../../../../domain/relations/Constants.js"; +import { IApplicationContainer } from "../../../../../application/host/IApplicationContainer.js"; +import { RelationDirection } from "../../../../../application/context/relations/get/RelationDirection.js"; +import { RelationStatusFilter } from "../../../../../application/context/relations/get/RelationStatusFilter.js"; +import { TraverseRelationsRequest } from "../../../../../application/context/relations/traverse/TraverseRelationsRequest.js"; +import { Renderer } from "../../../rendering/Renderer.js"; +import { RenderData } from "../../../rendering/types.js"; +import { CommandMetadata } from "../../registry/CommandMetadata.js"; +import { RelationTraverseOutputBuilder } from "./RelationTraverseOutputBuilder.js"; + +export const metadata: CommandMetadata = { + description: "Traverse bounded relation context from an entity", + category: "relations", + requiredOptions: [ + { flags: "-i, --id ", description: "Entity ID at the traversal root" }, + ], + options: [ + { flags: "--entity-type ", description: "Root entity type (inferred when the ID is unique)" }, + { flags: "--depth ", description: "Traversal depth from 1 through 5", default: 1 }, + { flags: "-d, --direction ", description: "Direction: in, out, or both", default: "both" }, + { flags: "--relation-type ", description: "Filter by relation type" }, + { flags: "--related-entity-type ", description: "Filter each expansion by opposite endpoint type" }, + { flags: "--strength ", description: "Filter by strength: strong, medium, or weak" }, + { flags: "-s, --status ", description: "Filter by status: active, deactivated, removed, or all", default: "active" }, + { flags: "--limit ", description: "Maximum number of distinct edges from 1 through 1000", default: 100 }, + ], + examples: [ + { command: "jumbo relations traverse --id goal_123", description: "Inspect the immediate active context" }, + { command: "jumbo relations traverse --id goal_123 --entity-type goal --depth 3", description: "Traverse three hops from a typed root" }, + { command: "jumbo relations traverse --id comp_123 --direction out --relation-type requires --format json", description: "Return filtered outgoing context as JSON" }, + ], + related: ["relations list", "relation add", "relation remove"], + requiresProject: true, +}; + +export async function relationsTraverse( + options: { + id: string; + entityType?: string; + depth?: string; + direction?: string; + relationType?: string; + relatedEntityType?: string; + strength?: string; + status?: string; + limit?: string; + }, + container: IApplicationContainer +): Promise { + const renderer = Renderer.getInstance(); + const outputBuilder = new RelationTraverseOutputBuilder(); + + try { + const request: TraverseRelationsRequest = { + entityId: options.id, + entityType: options.entityType as EntityTypeValue | undefined, + depth: Number(options.depth ?? 1), + direction: (options.direction as RelationDirection | undefined) ?? "both", + relationType: options.relationType, + relatedEntityType: options.relatedEntityType as EntityTypeValue | undefined, + strength: options.strength as RelationStrengthValue | undefined, + status: (options.status as RelationStatusFilter | undefined) ?? "active", + limit: Number(options.limit ?? 100), + }; + const result = await container.traverseRelationsController.handle(request); + + if (renderer.getConfig().format === "text") { + renderer.info(outputBuilder.build(result).toHumanReadable()); + } else { + const output = outputBuilder.buildStructuredOutput(result); + const dataSection = output.getSections().find((section) => section.type === "data"); + if (dataSection) renderer.data(dataSection.content as RenderData); + } + } catch (error) { + const output = outputBuilder.buildFailureError(error instanceof Error ? error : String(error)); + if (renderer.getConfig().format === "text") { + renderer.error(output.toHumanReadable()); + } else { + const dataSection = output.getSections().find((section) => section.type === "data"); + if (dataSection) renderer.data(dataSection.content as RenderData); + } + process.exit(1); + } +} diff --git a/tests/application/context/relations/get/GetRelationsController.test.ts b/tests/application/context/relations/get/GetRelationsController.test.ts index 8a8340b9..ad5c66f6 100644 --- a/tests/application/context/relations/get/GetRelationsController.test.ts +++ b/tests/application/context/relations/get/GetRelationsController.test.ts @@ -44,11 +44,23 @@ describe("GetRelationsController", () => { it("should pass all filter fields through to gateway", async () => { mockGateway.getRelations.mockResolvedValue({ relations: [] }); - await controller.handle({ entityType: "goal", entityId: "goal_123", status: "all" }); + await controller.handle({ + entityType: "goal", + entityId: "goal_123", + direction: "in", + relationType: "requires", + relatedEntityType: "component", + strength: "medium", + status: "all", + }); expect(mockGateway.getRelations).toHaveBeenCalledWith({ entityType: "goal", entityId: "goal_123", + direction: "in", + relationType: "requires", + relatedEntityType: "component", + strength: "medium", status: "all", }); }); diff --git a/tests/application/context/relations/get/LocalGetRelationsGateway.test.ts b/tests/application/context/relations/get/LocalGetRelationsGateway.test.ts new file mode 100644 index 00000000..047fba31 --- /dev/null +++ b/tests/application/context/relations/get/LocalGetRelationsGateway.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { LocalGetRelationsGateway } from "../../../../../src/application/context/relations/get/LocalGetRelationsGateway.js"; +import { IRelationViewReader } from "../../../../../src/application/context/relations/get/IRelationViewReader.js"; + +describe("LocalGetRelationsGateway", () => { + it("passes every read filter to the projection reader", async () => { + const reader = { + findAll: jest.fn().mockResolvedValue([]), + findEndpointTypes: jest.fn(), + }; + const gateway = new LocalGetRelationsGateway(reader); + + await gateway.getRelations({ + entityType: "goal", + entityId: "goal_123", + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "strong", + status: "all", + }); + + expect(reader.findAll).toHaveBeenCalledWith({ + entity: { entityType: "goal", entityId: "goal_123" }, + entityType: "goal", + entityId: "goal_123", + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "strong", + status: "all", + }); + }); + + it("returns an empty result without changing the query", async () => { + const reader = { + findAll: jest.fn().mockResolvedValue([]), + findEndpointTypes: jest.fn(), + }; + + await expect(new LocalGetRelationsGateway(reader).getRelations({ status: "active" })) + .resolves.toEqual({ relations: [] }); + }); + + it("rejects invalid runtime filter values before reading", async () => { + const reader = { + findAll: jest.fn().mockResolvedValue([]), + findEndpointTypes: jest.fn(), + }; + const gateway = new LocalGetRelationsGateway(reader); + + await expect(gateway.getRelations({ + status: "active", + direction: "sideways" as "both", + })).rejects.toThrow("Direction must be one of"); + expect(reader.findAll).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/application/context/relations/get/RelationDirection.test.ts b/tests/application/context/relations/get/RelationDirection.test.ts new file mode 100644 index 00000000..ee05de11 --- /dev/null +++ b/tests/application/context/relations/get/RelationDirection.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationDirection } from "../../../../../src/application/context/relations/get/RelationDirection.js"; + +describe("RelationDirection", () => { + it("models the three directed graph read modes", () => { + const values: RelationDirection[] = ["in", "out", "both"]; + expect(values).toEqual(["in", "out", "both"]); + }); +}); diff --git a/tests/application/context/relations/get/RelationListFilter.test.ts b/tests/application/context/relations/get/RelationListFilter.test.ts new file mode 100644 index 00000000..c76c0d9a --- /dev/null +++ b/tests/application/context/relations/get/RelationListFilter.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationListFilter } from "../../../../../src/application/context/relations/get/RelationListFilter.js"; + +describe("RelationListFilter", () => { + it("combines the complete typed relation read filter contract", () => { + const filter: RelationListFilter = { + entity: { entityType: "goal", entityId: "goal_1" }, + entityType: "goal", + entityId: "goal_1", + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "strong", + status: "active", + }; + + expect(filter).toEqual(expect.objectContaining({ direction: "out", status: "active" })); + }); +}); diff --git a/tests/application/context/relations/get/RelationNodeReference.test.ts b/tests/application/context/relations/get/RelationNodeReference.test.ts new file mode 100644 index 00000000..85ff602b --- /dev/null +++ b/tests/application/context/relations/get/RelationNodeReference.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationNodeReference } from "../../../../../src/application/context/relations/get/RelationNodeReference.js"; + +describe("RelationNodeReference", () => { + it("uses entity type plus entity ID as canonical node identity", () => { + const reference: RelationNodeReference = { entityType: "goal", entityId: "shared" }; + expect(reference).toEqual({ entityType: "goal", entityId: "shared" }); + }); +}); diff --git a/tests/application/context/relations/get/RelationStatusFilter.test.ts b/tests/application/context/relations/get/RelationStatusFilter.test.ts new file mode 100644 index 00000000..fed76eaa --- /dev/null +++ b/tests/application/context/relations/get/RelationStatusFilter.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationStatusFilter } from "../../../../../src/application/context/relations/get/RelationStatusFilter.js"; + +describe("RelationStatusFilter", () => { + it("models individual projection states and the unfiltered mode", () => { + const values: RelationStatusFilter[] = ["active", "deactivated", "removed", "all"]; + expect(values).toHaveLength(4); + }); +}); diff --git a/tests/application/context/relations/traverse/ITraverseRelationsGateway.test.ts b/tests/application/context/relations/traverse/ITraverseRelationsGateway.test.ts new file mode 100644 index 00000000..52b3307f --- /dev/null +++ b/tests/application/context/relations/traverse/ITraverseRelationsGateway.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "@jest/globals"; +import { ITraverseRelationsGateway } from "../../../../../src/application/context/relations/traverse/ITraverseRelationsGateway.js"; + +describe("ITraverseRelationsGateway", () => { + it("defines a replaceable asynchronous traversal boundary", async () => { + const gateway: ITraverseRelationsGateway = { + async traverse(request) { + return { + root: { entityType: request.entityType ?? "goal", entityId: request.entityId }, + nodes: [], + edges: [], + requestedDepth: request.depth ?? 1, + reachedDepth: 0, + limit: request.limit ?? 100, + truncated: false, + }; + }, + }; + + await expect(gateway.traverse({ entityId: "goal_1" })) + .resolves.toEqual(expect.objectContaining({ reachedDepth: 0 })); + }); +}); diff --git a/tests/application/context/relations/traverse/LocalTraverseRelationsGateway.test.ts b/tests/application/context/relations/traverse/LocalTraverseRelationsGateway.test.ts new file mode 100644 index 00000000..0c0ed692 --- /dev/null +++ b/tests/application/context/relations/traverse/LocalTraverseRelationsGateway.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { EntityTypeValue } from "../../../../../src/domain/relations/Constants.js"; +import { RelationView } from "../../../../../src/application/context/relations/RelationView.js"; +import { IRelationViewReader } from "../../../../../src/application/context/relations/get/IRelationViewReader.js"; +import { RelationListFilter } from "../../../../../src/application/context/relations/get/RelationListFilter.js"; +import { LocalTraverseRelationsGateway } from "../../../../../src/application/context/relations/traverse/LocalTraverseRelationsGateway.js"; + +function relation( + relationId: string, + fromEntityType: EntityTypeValue, + fromEntityId: string, + toEntityType: EntityTypeValue, + toEntityId: string, + createdAt = `2026-01-01T00:00:${relationId.slice(-2).padStart(2, "0")}.000Z` +): RelationView { + return { + relationId, + fromEntityType, + fromEntityId, + toEntityType, + toEntityId, + relationType: "involves", + strength: "strong", + description: relationId, + status: "active", + version: 1, + createdAt, + updatedAt: createdAt, + }; +} + +function graphReader(edges: RelationView[]): IRelationViewReader { + return { + async findAll(filter?: RelationListFilter): Promise { + const entityType = filter?.entity?.entityType ?? filter?.entityType; + const entityId = filter?.entity?.entityId ?? filter?.entityId; + return edges.filter((edge) => { + const fromMatches = edge.fromEntityType === entityType && edge.fromEntityId === entityId; + const toMatches = edge.toEntityType === entityType && edge.toEntityId === entityId; + if (filter?.direction === "out") return fromMatches; + if (filter?.direction === "in") return toMatches; + return fromMatches || toMatches; + }); + }, + async findEndpointTypes(entityId: string): Promise { + return [...new Set(edges.flatMap((edge) => [ + ...(edge.fromEntityId === entityId ? [edge.fromEntityType] : []), + ...(edge.toEntityId === entityId ? [edge.toEntityType] : []), + ]))].sort(); + }, + }; +} + +describe("LocalTraverseRelationsGateway", () => { + it("uses deterministic BFS, minimum distances, cycle prevention, and edge deduplication", async () => { + const edges = [ + relation("rel_04", "component", "D", "decision", "C"), + relation("rel_02", "component", "B", "decision", "C"), + relation("rel_05", "decision", "C", "component", "B"), + relation("rel_03", "goal", "A", "component", "D"), + relation("rel_01", "goal", "A", "component", "B"), + ]; + + const result = await new LocalTraverseRelationsGateway(graphReader(edges)).traverse({ + entityType: "goal", + entityId: "A", + depth: 3, + }); + + expect(result.nodes).toEqual([ + { entityType: "component", entityId: "B", distance: 1 }, + { entityType: "component", entityId: "D", distance: 1 }, + { entityType: "decision", entityId: "C", distance: 2 }, + ]); + expect(result.edges.map((edge) => edge.relationId)).toEqual([ + "rel_01", "rel_02", "rel_03", "rel_04", "rel_05", + ]); + expect(result.reachedDepth).toBe(2); + expect(result.truncated).toBe(false); + }); + + it.each([ + ["out", ["outgoing"]], + ["in", ["incoming"]], + ["both", ["incoming", "outgoing"]], + ] as const)("applies %s direction before expansion", async (direction, expectedIds) => { + const edges = [ + relation("incoming", "goal", "A", "component", "B", "2026-01-01T00:00:01.000Z"), + relation("outgoing", "component", "B", "decision", "C", "2026-01-01T00:00:02.000Z"), + ]; + + const result = await new LocalTraverseRelationsGateway(graphReader(edges)).traverse({ + entityType: "component", + entityId: "B", + direction, + }); + + expect(result.edges.map((edge) => edge.relationId)).toEqual(expectedIds); + }); + + it("forwards all filters on every frontier read", async () => { + const reader = { + findAll: jest.fn().mockResolvedValue([]), + findEndpointTypes: jest.fn(), + }; + + await new LocalTraverseRelationsGateway(reader).traverse({ + entityType: "goal", + entityId: "goal_1", + depth: 2, + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "weak", + status: "deactivated", + limit: 17, + }); + + expect(reader.findAll).toHaveBeenCalledWith({ + entity: { entityType: "goal", entityId: "goal_1" }, + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "weak", + status: "deactivated", + }); + }); + + it("infers a unique endpoint type for ID-only input", async () => { + const result = await new LocalTraverseRelationsGateway(graphReader([ + relation("rel_01", "goal", "shared", "component", "component_1"), + ])).traverse({ entityId: "shared" }); + + expect(result.root).toEqual({ entityType: "goal", entityId: "shared" }); + }); + + it("reports every candidate when ID-only input is ambiguous", async () => { + const gateway = new LocalTraverseRelationsGateway(graphReader([ + relation("rel_01", "goal", "shared", "component", "component_1"), + relation("rel_02", "decision", "shared", "component", "component_2"), + ])); + + await expect(gateway.traverse({ entityId: "shared" })) + .rejects.toThrow("decision, goal"); + }); + + it("requests an explicit type when no endpoint type can be inferred", async () => { + const gateway = new LocalTraverseRelationsGateway(graphReader([])); + + await expect(gateway.traverse({ entityId: "unknown" })) + .rejects.toThrow("Specify --entity-type explicitly"); + }); + + it.each([0, 1.5, 6])("rejects invalid depth %s", async (depth) => { + const gateway = new LocalTraverseRelationsGateway(graphReader([])); + await expect(gateway.traverse({ entityType: "goal", entityId: "A", depth })) + .rejects.toThrow("Depth must be an integer from 1 through 5"); + }); + + it.each([0, 1.5, 1001])("rejects invalid limit %s", async (limit) => { + const gateway = new LocalTraverseRelationsGateway(graphReader([])); + await expect(gateway.traverse({ entityType: "goal", entityId: "A", limit })) + .rejects.toThrow("Limit must be an integer from 1 through 1000"); + }); + + it("returns deterministic partial results and marks truncation at the edge limit", async () => { + const edges = [ + relation("rel_03", "goal", "A", "decision", "C"), + relation("rel_01", "goal", "A", "component", "B"), + relation("rel_02", "goal", "A", "dependency", "D"), + ]; + + const result = await new LocalTraverseRelationsGateway(graphReader(edges)).traverse({ + entityType: "goal", + entityId: "A", + limit: 2, + }); + + expect(result.edges.map((edge) => edge.relationId)).toEqual(["rel_01", "rel_02"]); + expect(result.nodes).toEqual([ + { entityType: "component", entityId: "B", distance: 1 }, + { entityType: "dependency", entityId: "D", distance: 1 }, + ]); + expect(result.truncated).toBe(true); + }); + + it("treats entity type plus entity ID as node identity", async () => { + const edges = [ + relation("rel_01", "goal", "same", "component", "same"), + relation("rel_02", "component", "same", "decision", "done"), + ]; + + const result = await new LocalTraverseRelationsGateway(graphReader(edges)).traverse({ + entityType: "goal", + entityId: "same", + depth: 2, + direction: "out", + }); + + expect(result.nodes).toEqual([ + { entityType: "component", entityId: "same", distance: 1 }, + { entityType: "decision", entityId: "done", distance: 2 }, + ]); + }); + + it("uses the documented defaults", async () => { + const reader = { + findAll: jest.fn().mockResolvedValue([]), + findEndpointTypes: jest.fn() + .mockResolvedValue(["goal"]), + }; + + const result = await new LocalTraverseRelationsGateway(reader).traverse({ entityId: "A" }); + + expect(result).toMatchObject({ requestedDepth: 1, limit: 100, truncated: false }); + expect(reader.findAll).toHaveBeenCalledWith(expect.objectContaining({ + entity: { entityType: "goal", entityId: "A" }, + direction: "both", + status: "active", + })); + }); +}); diff --git a/tests/application/context/relations/traverse/RelationTraversalNode.test.ts b/tests/application/context/relations/traverse/RelationTraversalNode.test.ts new file mode 100644 index 00000000..1fc2b1f2 --- /dev/null +++ b/tests/application/context/relations/traverse/RelationTraversalNode.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationTraversalNode } from "../../../../../src/application/context/relations/traverse/RelationTraversalNode.js"; + +describe("RelationTraversalNode", () => { + it("extends canonical node identity with minimum hop distance", () => { + const node: RelationTraversalNode = { entityType: "component", entityId: "component_1", distance: 2 }; + expect(node.distance).toBe(2); + }); +}); diff --git a/tests/application/context/relations/traverse/RelationTraversalResult.test.ts b/tests/application/context/relations/traverse/RelationTraversalResult.test.ts new file mode 100644 index 00000000..bd95adfd --- /dev/null +++ b/tests/application/context/relations/traverse/RelationTraversalResult.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationTraversalResult } from "../../../../../src/application/context/relations/traverse/RelationTraversalResult.js"; + +describe("RelationTraversalResult", () => { + it("contains the root, graph collections, and traversal metadata", () => { + const result: RelationTraversalResult = { + root: { entityType: "goal", entityId: "goal_1" }, + nodes: [], + edges: [], + requestedDepth: 3, + reachedDepth: 0, + limit: 100, + truncated: false, + }; + + expect(Object.keys(result)).toEqual([ + "root", "nodes", "edges", "requestedDepth", "reachedDepth", "limit", "truncated", + ]); + }); +}); diff --git a/tests/application/context/relations/traverse/TraverseRelationsController.test.ts b/tests/application/context/relations/traverse/TraverseRelationsController.test.ts new file mode 100644 index 00000000..683f31eb --- /dev/null +++ b/tests/application/context/relations/traverse/TraverseRelationsController.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { ITraverseRelationsGateway } from "../../../../../src/application/context/relations/traverse/ITraverseRelationsGateway.js"; +import { TraverseRelationsController } from "../../../../../src/application/context/relations/traverse/TraverseRelationsController.js"; + +describe("TraverseRelationsController", () => { + it("delegates the typed traversal request and returns its result", async () => { + const result = { + root: { entityType: "goal" as const, entityId: "goal_1" }, + nodes: [], + edges: [], + requestedDepth: 1, + reachedDepth: 0, + limit: 100, + truncated: false, + }; + const gateway = { + traverse: jest.fn().mockResolvedValue(result), + }; + const request = { entityId: "goal_1", direction: "both" as const }; + + await expect(new TraverseRelationsController(gateway).handle(request)).resolves.toBe(result); + expect(gateway.traverse).toHaveBeenCalledWith(request); + }); +}); diff --git a/tests/application/context/relations/traverse/TraverseRelationsRequest.test.ts b/tests/application/context/relations/traverse/TraverseRelationsRequest.test.ts new file mode 100644 index 00000000..ed121621 --- /dev/null +++ b/tests/application/context/relations/traverse/TraverseRelationsRequest.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "@jest/globals"; +import { TraverseRelationsRequest } from "../../../../../src/application/context/relations/traverse/TraverseRelationsRequest.js"; + +describe("TraverseRelationsRequest", () => { + it("models typed root, bounds, direction, and edge filters", () => { + const request: TraverseRelationsRequest = { + entityId: "goal_1", + entityType: "goal", + depth: 5, + direction: "both", + relationType: "requires", + relatedEntityType: "component", + strength: "weak", + status: "all", + limit: 1000, + }; + + expect(request).toEqual(expect.objectContaining({ depth: 5, limit: 1000 })); + }); +}); diff --git a/tests/infrastructure/context/relations/get/LocalGetRelationsGateway.test.ts b/tests/infrastructure/context/relations/get/LocalGetRelationsGateway.test.ts deleted file mode 100644 index a2a73aec..00000000 --- a/tests/infrastructure/context/relations/get/LocalGetRelationsGateway.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect, beforeEach, jest } from "@jest/globals"; -import { LocalGetRelationsGateway } from "../../../../../src/application/context/relations/get/LocalGetRelationsGateway.js"; -import { IRelationViewReader } from "../../../../../src/application/context/relations/get/IRelationViewReader.js"; -import { RelationView } from "../../../../../src/application/context/relations/RelationView.js"; - -describe("LocalGetRelationsGateway", () => { - let gateway: LocalGetRelationsGateway; - let mockReader: jest.Mocked; - - beforeEach(() => { - mockReader = { - findAll: jest.fn(), - } as jest.Mocked; - - gateway = new LocalGetRelationsGateway(mockReader); - }); - - it("should return relations from the view reader", async () => { - const mockRelations: RelationView[] = [ - { - relationId: "rel_123", - fromEntityType: "goal", - fromEntityId: "goal_456", - toEntityType: "decision", - toEntityId: "dec_789", - relationType: "requires", - strength: "strong", - description: "Goal requires this decision", - status: "active", - version: 1, - createdAt: "2025-01-01T10:00:00Z", - updatedAt: "2025-01-01T10:00:00Z", - }, - ]; - - mockReader.findAll.mockResolvedValue(mockRelations); - - const response = await gateway.getRelations({ status: "active" }); - - expect(response.relations).toEqual(mockRelations); - expect(mockReader.findAll).toHaveBeenCalledWith({ - entityType: undefined, - entityId: undefined, - status: "active", - }); - }); - - it("should pass entity filter to the view reader", async () => { - mockReader.findAll.mockResolvedValue([]); - - await gateway.getRelations({ entityType: "goal", entityId: "goal_123", status: "all" }); - - expect(mockReader.findAll).toHaveBeenCalledWith({ - entityType: "goal", - entityId: "goal_123", - status: "all", - }); - }); - - it("should return empty relations array when none exist", async () => { - mockReader.findAll.mockResolvedValue([]); - - const response = await gateway.getRelations({ status: "all" }); - - expect(response.relations).toEqual([]); - }); -}); diff --git a/tests/infrastructure/context/relations/get/SqliteRelationViewReader.test.ts b/tests/infrastructure/context/relations/get/SqliteRelationViewReader.test.ts new file mode 100644 index 00000000..775a472e --- /dev/null +++ b/tests/infrastructure/context/relations/get/SqliteRelationViewReader.test.ts @@ -0,0 +1,111 @@ +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; +import { SqliteRelationViewReader } from "../../../../../src/infrastructure/context/relations/get/SqliteRelationViewReader.js"; + +describe("SqliteRelationViewReader", () => { + let db: Database.Database; + let reader: SqliteRelationViewReader; + + beforeEach(() => { + db = new Database(":memory:"); + db.exec(` + CREATE TABLE relation_views ( + relationId TEXT PRIMARY KEY, + fromEntityType TEXT NOT NULL, + fromEntityId TEXT NOT NULL, + toEntityType TEXT NOT NULL, + toEntityId TEXT NOT NULL, + relationType TEXT NOT NULL, + strength TEXT, + description TEXT NOT NULL, + status TEXT NOT NULL, + version INTEGER NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + reader = new SqliteRelationViewReader(db); + }); + + afterEach(() => db.close()); + + function insert( + relationId: string, + fromType: string, + fromId: string, + toType: string, + toId: string, + relationType: string, + strength: string | null, + status: string, + createdAt: string + ): void { + db.prepare(` + INSERT INTO relation_views ( + relationId, fromEntityType, fromEntityId, toEntityType, toEntityId, + relationType, strength, description, status, version, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + `).run( + relationId, fromType, fromId, toType, toId, + relationType, strength, relationId, status, createdAt, createdAt + ); + } + + it("queries incoming, outgoing, and both directions relative to a typed reference", async () => { + insert("rel_out", "goal", "root", "component", "c1", "involves", "strong", "active", "2026-01-01"); + insert("rel_in", "decision", "d1", "goal", "root", "supports", "weak", "active", "2026-01-02"); + insert("rel_other", "goal", "other", "component", "c2", "involves", "strong", "active", "2026-01-03"); + + await expect(reader.findAll({ entity: { entityType: "goal", entityId: "root" }, direction: "out" })) + .resolves.toEqual([expect.objectContaining({ relationId: "rel_out" })]); + await expect(reader.findAll({ entityType: "goal", entityId: "root", direction: "in" })) + .resolves.toEqual([expect.objectContaining({ relationId: "rel_in" })]); + expect((await reader.findAll({ entityType: "goal", entityId: "root", direction: "both" })) + .map((edge) => edge.relationId)).toEqual(["rel_out", "rel_in"]); + }); + + it("combines relation, related entity, strength, and status filters", async () => { + insert("match", "goal", "root", "component", "c1", "requires", "strong", "deactivated", "2026-01-01"); + insert("wrong-type", "goal", "root", "decision", "d1", "requires", "strong", "deactivated", "2026-01-02"); + insert("wrong-relation", "goal", "root", "component", "c2", "involves", "strong", "deactivated", "2026-01-03"); + insert("wrong-strength", "goal", "root", "component", "c3", "requires", "weak", "deactivated", "2026-01-04"); + insert("wrong-status", "goal", "root", "component", "c4", "requires", "strong", "active", "2026-01-05"); + + const result = await reader.findAll({ + entityType: "goal", + entityId: "root", + direction: "out", + relatedEntityType: "component", + relationType: "requires", + strength: "strong", + status: "deactivated", + }); + + expect(result.map((edge) => edge.relationId)).toEqual(["match"]); + }); + + it("defaults to active status and permits all statuses explicitly", async () => { + insert("active", "goal", "root", "component", "c1", "involves", null, "active", "2026-01-01"); + insert("removed", "goal", "root", "component", "c2", "involves", null, "removed", "2026-01-02"); + + expect((await reader.findAll()).map((edge) => edge.relationId)).toEqual(["active"]); + expect((await reader.findAll({ status: "all" })).map((edge) => edge.relationId)) + .toEqual(["active", "removed"]); + }); + + it("uses relation ID as a stable tie-breaker", async () => { + insert("rel_b", "goal", "root", "component", "b", "involves", null, "active", "same"); + insert("rel_a", "goal", "root", "component", "a", "involves", null, "active", "same"); + + expect((await reader.findAll()).map((edge) => edge.relationId)).toEqual(["rel_a", "rel_b"]); + }); + + it("resolves distinct endpoint types without applying relation status", async () => { + insert("rel_1", "goal", "shared", "component", "c1", "involves", null, "removed", "2026-01-01"); + insert("rel_2", "decision", "shared", "component", "c2", "involves", null, "active", "2026-01-02"); + insert("rel_3", "goal", "g1", "component", "shared", "involves", null, "active", "2026-01-03"); + + await expect(reader.findEndpointTypes("shared")) + .resolves.toEqual(["component", "decision", "goal"]); + }); +}); diff --git a/tests/presentation/cli/commands/relations/list/RelationListOutputBuilder.test.ts b/tests/presentation/cli/commands/relations/list/RelationListOutputBuilder.test.ts new file mode 100644 index 00000000..ef07ba3a --- /dev/null +++ b/tests/presentation/cli/commands/relations/list/RelationListOutputBuilder.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationView } from "../../../../../../src/application/context/relations/RelationView.js"; +import { RelationListOutputBuilder } from "../../../../../../src/presentation/cli/commands/relations/list/RelationListOutputBuilder.js"; + +const edge: RelationView = { + relationId: "rel_1", + fromEntityType: "goal", + fromEntityId: "goal_1", + toEntityType: "component", + toEntityId: "component_1", + relationType: "requires", + strength: "strong", + description: "Required component", + status: "active", + version: 1, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", +}; + +describe("RelationListOutputBuilder", () => { + it("renders typed endpoints with the original directed orientation", () => { + const text = new RelationListOutputBuilder().build([edge]).toHumanReadable(); + + expect(text).toContain("goal:goal_1 --[requires]--> component:component_1"); + }); + + it("owns the empty-result message", () => { + const text = new RelationListOutputBuilder().build([], "goal:goal_1").toHumanReadable(); + + expect(text).toContain("No relations found involving goal:goal_1"); + }); + + it("returns every filter and relation field in structured output", () => { + const output = new RelationListOutputBuilder().buildStructuredOutput([edge], { + entityType: "goal", + entityId: "goal_1", + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "strong", + status: "active", + }); + const content = output.getSections().find((section) => section.type === "data")?.content; + + expect(content).toEqual(expect.objectContaining({ + count: 1, + filter: { + entityType: "goal", + entityId: "goal_1", + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "strong", + status: "active", + }, + relations: [expect.objectContaining({ relationId: "rel_1" })], + })); + }); +}); diff --git a/tests/presentation/cli/commands/relations/list/relations.list.test.ts b/tests/presentation/cli/commands/relations/list/relations.list.test.ts index 789d50a2..296896d0 100644 --- a/tests/presentation/cli/commands/relations/list/relations.list.test.ts +++ b/tests/presentation/cli/commands/relations/list/relations.list.test.ts @@ -57,8 +57,13 @@ describe("relations.list command", () => { await relationsList({}, mockContainer as IApplicationContainer); expect(mockController.handle).toHaveBeenCalledWith({ + entity: undefined, entityType: undefined, entityId: undefined, + direction: undefined, + relationType: undefined, + relatedEntityType: undefined, + strength: undefined, status: "active", }); expect(consoleSpy).toHaveBeenCalled(); @@ -143,4 +148,24 @@ describe("relations.list command", () => { expect.objectContaining({ status: "deactivated" }) ); }); + + it("passes direction, relation, related entity, and strength filters", async () => { + mockController.handle.mockResolvedValue({ relations: [] }); + + await relationsList({ + entityType: "goal", + entityId: "goal_1", + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "strong", + }, mockContainer as IApplicationContainer); + + expect(mockController.handle).toHaveBeenCalledWith(expect.objectContaining({ + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "strong", + })); + }); }); diff --git a/tests/presentation/cli/commands/relations/traverse/RelationTraverseOutputBuilder.test.ts b/tests/presentation/cli/commands/relations/traverse/RelationTraverseOutputBuilder.test.ts new file mode 100644 index 00000000..b6055328 --- /dev/null +++ b/tests/presentation/cli/commands/relations/traverse/RelationTraverseOutputBuilder.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationTraversalResult } from "../../../../../../src/application/context/relations/traverse/RelationTraversalResult.js"; +import { RelationTraverseOutputBuilder } from "../../../../../../src/presentation/cli/commands/relations/traverse/RelationTraverseOutputBuilder.js"; + +const result: RelationTraversalResult = { + root: { entityType: "goal", entityId: "goal_1" }, + nodes: [ + { entityType: "component", entityId: "component_1", distance: 1 }, + { entityType: "decision", entityId: "decision_1", distance: 2 }, + ], + edges: [ + { + relationId: "rel_1", + fromEntityType: "goal", + fromEntityId: "goal_1", + toEntityType: "component", + toEntityId: "component_1", + relationType: "requires", + strength: "strong", + description: "first hop", + status: "active", + version: 1, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + { + relationId: "rel_2", + fromEntityType: "decision", + fromEntityId: "decision_1", + toEntityType: "component", + toEntityId: "component_1", + relationType: "supports", + strength: null, + description: "second hop incoming edge", + status: "active", + version: 1, + createdAt: "2026-01-02", + updatedAt: "2026-01-02", + }, + ], + requestedDepth: 3, + reachedDepth: 2, + limit: 100, + truncated: false, +}; + +describe("RelationTraverseOutputBuilder", () => { + it("groups discoveries by hop and preserves directed arrows", () => { + const text = new RelationTraverseOutputBuilder().build(result).toHumanReadable(); + + expect(text).toContain("Hop 1"); + expect(text).toContain("Hop 2"); + expect(text).toContain("goal:goal_1 --[requires, strong]--> component:component_1"); + expect(text).toContain("decision:decision_1 --[supports]--> component:component_1"); + }); + + it("returns the stable traversal JSON contract", () => { + const output = new RelationTraverseOutputBuilder().buildStructuredOutput(result); + const content = output.getSections().find((section) => section.type === "data")?.content; + + expect(content).toEqual({ + root: { entityType: "goal", entityId: "goal_1" }, + nodes: result.nodes, + edges: [ + expect.objectContaining({ relationId: "rel_1", fromEntityType: "goal", toEntityType: "component" }), + expect.objectContaining({ relationId: "rel_2", fromEntityType: "decision", toEntityType: "component" }), + ], + requestedDepth: 3, + reachedDepth: 2, + limit: 100, + truncated: false, + }); + expect(Object.keys(content as object)).toEqual([ + "root", "nodes", "edges", "requestedDepth", "reachedDepth", "limit", "truncated", + ]); + }); +}); diff --git a/tests/presentation/cli/commands/relations/traverse/relations.traverse.test.ts b/tests/presentation/cli/commands/relations/traverse/relations.traverse.test.ts new file mode 100644 index 00000000..db2e7668 --- /dev/null +++ b/tests/presentation/cli/commands/relations/traverse/relations.traverse.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { IApplicationContainer } from "../../../../../../src/application/host/IApplicationContainer.js"; +import { TraverseRelationsController } from "../../../../../../src/application/context/relations/traverse/TraverseRelationsController.js"; +import { relationsTraverse } from "../../../../../../src/presentation/cli/commands/relations/traverse/relations.traverse.js"; +import { Renderer } from "../../../../../../src/presentation/cli/rendering/Renderer.js"; + +describe("relations.traverse command", () => { + let handle: jest.Mock; + let container: Partial; + let consoleSpy: jest.SpiedFunction; + + beforeEach(() => { + Renderer.configure({ format: "text", verbosity: "normal" }); + handle = jest.fn().mockResolvedValue({ + root: { entityType: "goal", entityId: "goal_1" }, + nodes: [], + edges: [], + requestedDepth: 1, + reachedDepth: 0, + limit: 100, + truncated: false, + }); + container = { + traverseRelationsController: { handle } as unknown as TraverseRelationsController, + }; + consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + Renderer.reset(); + }); + + it("uses traversal defaults", async () => { + await relationsTraverse({ id: "goal_1" }, container as IApplicationContainer); + + expect(handle).toHaveBeenCalledWith({ + entityId: "goal_1", + entityType: undefined, + depth: 1, + direction: "both", + relationType: undefined, + relatedEntityType: undefined, + strength: undefined, + status: "active", + limit: 100, + }); + }); + + it("parses and passes every CLI filter", async () => { + await relationsTraverse({ + id: "goal_1", + entityType: "goal", + depth: "5", + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "weak", + status: "all", + limit: "1000", + }, container as IApplicationContainer); + + expect(handle).toHaveBeenCalledWith(expect.objectContaining({ + entityType: "goal", + depth: 5, + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "weak", + status: "all", + limit: 1000, + })); + }); + + it("emits one structured result in JSON mode", async () => { + Renderer.configure({ format: "json", verbosity: "normal" }); + + await relationsTraverse({ id: "goal_1" }, container as IApplicationContainer); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(() => JSON.parse(String(consoleSpy.mock.calls[0][0]))).not.toThrow(); + }); +});