diff --git a/CHANGELOG.md b/CHANGELOG.md index 349d47dc..4ee73663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Shortest relation paths**: Added `jumbo relations path` with typed or uniquely inferred endpoints, deterministic bounded breadth-first search, direction and relation filters, provenance-preserving text output, and a stable structured result for connected or disconnected memories. + ## [3.18.0] - 2026-07-23 ### Added diff --git a/docs/reference/commands/relations.md b/docs/reference/commands/relations.md index 9854729e..36722f43 100644 --- a/docs/reference/commands/relations.md +++ b/docs/reference/commands/relations.md @@ -5,7 +5,7 @@ sidebar: order: 12 --- -Add, list, traverse, and remove relationships between entities in the knowledge graph — linking goals, components, decisions, and other entities. +Add, list, traverse, find paths through, and remove relationships between entities in the knowledge graph — linking goals, components, decisions, and other entities. --- @@ -124,6 +124,44 @@ If an ID appears under multiple entity types, specify `--entity-type`. Results i --- +## jumbo relations path + +Find one deterministic, unweighted shortest path between two relation endpoints. The query uses bounded breadth-first search, preserves each relation's original direction and description, and treats strength as filter metadata rather than path cost. + +### Synopsis + +```bash +> jumbo relations path --from-id --to-id [options] +``` + +### Options + +| Option | Description | +|--------|-------------| +| `--from-id ` | Starting entity ID (required) | +| `--to-id ` | Destination entity ID (required) | +| `--from-type ` | Starting entity type; inferred when the ID identifies one endpoint type | +| `--to-type ` | Destination entity type; inferred when the ID identifies one endpoint type | +| `--max-depth ` | Maximum path depth from `1` through `5` (default: `5`) | +| `-d, --direction ` | Traversal direction: `in`, `out`, or `both` (default: `both`) | +| `--relation-type ` | Filter by relation type | +| `--entity-type ` | Filter each traversed opposite endpoint by entity type | +| `--strength ` | Filter by strength: `strong`, `medium`, or `weak` | +| `-s, --status ` | Filter by status: `active`, `deactivated`, `removed`, or `all` (default: `active`) | + +If either ID appears under multiple entity types, specify its matching type option. A disconnected pair is a successful query with `found: false` and empty path data. Text output renders typed endpoint IDs with arrows that retain original relation orientation; `--format json` returns resolved endpoints, hop count, ordered nodes and edges, and query metadata. + +### Examples + +```bash +> jumbo relations path --from-type goal --from-id goal_abc123 --to-type dependency --to-id dep_def456 +> jumbo relations path --from-id goal_abc123 --to-id comp_def456 --direction out --max-depth 3 +> jumbo relations path --from-id goal_abc123 --to-id comp_def456 --relation-type involves --strength strong +> jumbo relations path --from-id goal_abc123 --to-id comp_def456 --format json +``` + +--- + ## jumbo relation remove Remove a relation from the knowledge graph. diff --git a/src/application/context/relations/path/FindRelationPathController.ts b/src/application/context/relations/path/FindRelationPathController.ts new file mode 100644 index 00000000..f7234d70 --- /dev/null +++ b/src/application/context/relations/path/FindRelationPathController.ts @@ -0,0 +1,11 @@ +import { FindRelationPathRequest } from "./FindRelationPathRequest.js"; +import { IFindRelationPathGateway } from "./IFindRelationPathGateway.js"; +import { RelationPathResult } from "./RelationPathResult.js"; + +export class FindRelationPathController { + constructor(private readonly gateway: IFindRelationPathGateway) {} + + async handle(request: FindRelationPathRequest): Promise { + return this.gateway.findPath(request); + } +} diff --git a/src/application/context/relations/path/FindRelationPathRequest.ts b/src/application/context/relations/path/FindRelationPathRequest.ts new file mode 100644 index 00000000..d73624cb --- /dev/null +++ b/src/application/context/relations/path/FindRelationPathRequest.ts @@ -0,0 +1,19 @@ +import { + EntityTypeValue, + RelationStrengthValue, +} from "../../../../domain/relations/Constants.js"; +import { RelationDirection } from "../get/RelationDirection.js"; +import { RelationStatusFilter } from "../get/RelationStatusFilter.js"; + +export interface FindRelationPathRequest { + readonly fromEntityId: string; + readonly fromEntityType?: EntityTypeValue; + readonly toEntityId: string; + readonly toEntityType?: EntityTypeValue; + readonly maxDepth?: number; + readonly direction?: RelationDirection; + readonly relationType?: string; + readonly relatedEntityType?: EntityTypeValue; + readonly strength?: RelationStrengthValue; + readonly status?: RelationStatusFilter; +} diff --git a/src/application/context/relations/path/IFindRelationPathGateway.ts b/src/application/context/relations/path/IFindRelationPathGateway.ts new file mode 100644 index 00000000..eefe3248 --- /dev/null +++ b/src/application/context/relations/path/IFindRelationPathGateway.ts @@ -0,0 +1,6 @@ +import { FindRelationPathRequest } from "./FindRelationPathRequest.js"; +import { RelationPathResult } from "./RelationPathResult.js"; + +export interface IFindRelationPathGateway { + findPath(request: FindRelationPathRequest): Promise; +} diff --git a/src/application/context/relations/path/LocalFindRelationPathGateway.ts b/src/application/context/relations/path/LocalFindRelationPathGateway.ts new file mode 100644 index 00000000..e5895a76 --- /dev/null +++ b/src/application/context/relations/path/LocalFindRelationPathGateway.ts @@ -0,0 +1,44 @@ +import { RelationNodeReferenceResolver } from "../traverse/RelationNodeReferenceResolver.js"; +import { RelationTraversalQueryNormalizer } from "../traverse/RelationTraversalQueryNormalizer.js"; +import { FindRelationPathRequest } from "./FindRelationPathRequest.js"; +import { IFindRelationPathGateway } from "./IFindRelationPathGateway.js"; +import { RelationPathResult } from "./RelationPathResult.js"; +import { RelationShortestPathFinder } from "./RelationShortestPathFinder.js"; + +export class LocalFindRelationPathGateway implements IFindRelationPathGateway { + constructor( + private readonly nodeResolver: RelationNodeReferenceResolver, + private readonly queryNormalizer: RelationTraversalQueryNormalizer, + private readonly shortestPathFinder: RelationShortestPathFinder, + ) {} + + async findPath( + request: FindRelationPathRequest, + ): Promise { + const query = this.queryNormalizer.normalize( + { + depth: request.maxDepth, + direction: request.direction, + relationType: request.relationType, + relatedEntityType: request.relatedEntityType, + strength: request.strength, + status: request.status, + }, + 5, + ); + const from = await this.nodeResolver.resolve( + request.fromEntityId, + request.fromEntityType, + "--from-type", + "From entity", + ); + const to = await this.nodeResolver.resolve( + request.toEntityId, + request.toEntityType, + "--to-type", + "To entity", + ); + + return this.shortestPathFinder.find(from, to, query); + } +} diff --git a/src/application/context/relations/path/RelationPathQueryMetadata.ts b/src/application/context/relations/path/RelationPathQueryMetadata.ts new file mode 100644 index 00000000..b3638e2f --- /dev/null +++ b/src/application/context/relations/path/RelationPathQueryMetadata.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 RelationPathQueryMetadata { + readonly maxDepth: number; + readonly direction: RelationDirection; + readonly relationType?: string; + readonly entityType?: EntityTypeValue; + readonly strength?: RelationStrengthValue; + readonly status: RelationStatusFilter; +} diff --git a/src/application/context/relations/path/RelationPathResult.ts b/src/application/context/relations/path/RelationPathResult.ts new file mode 100644 index 00000000..bfaaa157 --- /dev/null +++ b/src/application/context/relations/path/RelationPathResult.ts @@ -0,0 +1,13 @@ +import { RelationView } from "../RelationView.js"; +import { RelationNodeReference } from "../get/RelationNodeReference.js"; +import { RelationPathQueryMetadata } from "./RelationPathQueryMetadata.js"; + +export interface RelationPathResult { + readonly found: boolean; + readonly from: RelationNodeReference; + readonly to: RelationNodeReference; + readonly hopCount: number; + readonly nodes: RelationNodeReference[]; + readonly edges: RelationView[]; + readonly query: RelationPathQueryMetadata; +} diff --git a/src/application/context/relations/path/RelationShortestPathFinder.ts b/src/application/context/relations/path/RelationShortestPathFinder.ts new file mode 100644 index 00000000..e02f44ed --- /dev/null +++ b/src/application/context/relations/path/RelationShortestPathFinder.ts @@ -0,0 +1,180 @@ +import { RelationView } from "../RelationView.js"; +import { IRelationViewReader } from "../get/IRelationViewReader.js"; +import { RelationNodeReference } from "../get/RelationNodeReference.js"; +import { RelationTraversalPolicy } from "../traverse/RelationTraversalPolicy.js"; +import { RelationTraversalQuery } from "../traverse/RelationTraversalQuery.js"; +import { RelationPathQueryMetadata } from "./RelationPathQueryMetadata.js"; +import { RelationPathResult } from "./RelationPathResult.js"; + +type PathPredecessor = { + readonly previous: RelationNodeReference; + readonly edge: RelationView; +}; + +type PathTransition = { + readonly node: RelationNodeReference; + readonly edge: RelationView; +}; + +export class RelationShortestPathFinder { + constructor( + private readonly relationViewReader: IRelationViewReader, + private readonly traversalPolicy = new RelationTraversalPolicy(), + ) {} + + async find( + from: RelationNodeReference, + to: RelationNodeReference, + query: RelationTraversalQuery, + ): Promise { + const queryMetadata = this.toQueryMetadata(query); + if ( + this.traversalPolicy.nodeKey(from) === this.traversalPolicy.nodeKey(to) + ) { + return this.foundResult(from, to, [from], [], queryMetadata); + } + + const visited = new Set([this.traversalPolicy.nodeKey(from)]); + const predecessors = new Map(); + let frontier: RelationNodeReference[] = [from]; + + for (let depth = 1; depth <= query.depth && frontier.length > 0; depth++) { + const next = new Map(); + const stableFrontier = [...frontier].sort((left, right) => + this.traversalPolicy.compareNodes(left, right), + ); + + for (const current of stableFrontier) { + const adjacent = await this.relationViewReader.findAll({ + entity: current, + direction: query.direction, + relationType: query.relationType, + relatedEntityType: query.relatedEntityType, + strength: query.strength, + status: query.status, + }); + const transitions = this.stableTransitions(current, adjacent, query); + + for (const transition of transitions) { + const key = this.traversalPolicy.nodeKey(transition.node); + if (visited.has(key)) continue; + + visited.add(key); + predecessors.set(key, { previous: current, edge: transition.edge }); + + if (key === this.traversalPolicy.nodeKey(to)) { + const path = this.reconstruct(from, to, predecessors); + return this.foundResult( + from, + to, + path.nodes, + path.edges, + queryMetadata, + ); + } + + next.set(key, transition.node); + } + } + + frontier = [...next.values()]; + } + + return { + found: false, + from, + to, + hopCount: 0, + nodes: [], + edges: [], + query: queryMetadata, + }; + } + + private stableTransitions( + current: RelationNodeReference, + adjacent: RelationView[], + query: RelationTraversalQuery, + ): PathTransition[] { + return adjacent + .map((edge): PathTransition | undefined => { + const node = this.traversalPolicy.relatedNode( + current, + edge, + query.direction, + ); + return node ? { node, edge } : undefined; + }) + .filter( + (transition): transition is PathTransition => transition !== undefined, + ) + .sort( + (left, right) => + this.traversalPolicy.compareNodes(left.node, right.node) || + this.traversalPolicy.compareEdges(left.edge, right.edge), + ); + } + + private reconstruct( + from: RelationNodeReference, + to: RelationNodeReference, + predecessors: Map, + ): { nodes: RelationNodeReference[]; edges: RelationView[] } { + const reversedNodes: RelationNodeReference[] = [to]; + const reversedEdges: RelationView[] = []; + let cursor = to; + + while ( + this.traversalPolicy.nodeKey(cursor) !== + this.traversalPolicy.nodeKey(from) + ) { + const predecessor = predecessors.get( + this.traversalPolicy.nodeKey(cursor), + ); + if (!predecessor) { + throw new Error( + "Relation path reconstruction encountered an incomplete predecessor chain.", + ); + } + reversedEdges.push(predecessor.edge); + reversedNodes.push(predecessor.previous); + cursor = predecessor.previous; + } + + return { + nodes: reversedNodes.reverse(), + edges: reversedEdges.reverse(), + }; + } + + private foundResult( + from: RelationNodeReference, + to: RelationNodeReference, + nodes: RelationNodeReference[], + edges: RelationView[], + query: RelationPathQueryMetadata, + ): RelationPathResult { + return { + found: true, + from, + to, + hopCount: edges.length, + nodes, + edges, + query, + }; + } + + private toQueryMetadata( + query: RelationTraversalQuery, + ): RelationPathQueryMetadata { + return { + maxDepth: query.depth, + direction: query.direction, + relationType: query.relationType, + entityType: query.relatedEntityType, + strength: query.strength, + status: query.status, + }; + } +} diff --git a/src/application/context/relations/traverse/LocalTraverseRelationsGateway.ts b/src/application/context/relations/traverse/LocalTraverseRelationsGateway.ts index c5577751..4bb45a8f 100644 --- a/src/application/context/relations/traverse/LocalTraverseRelationsGateway.ts +++ b/src/application/context/relations/traverse/LocalTraverseRelationsGateway.ts @@ -1,37 +1,34 @@ -import { - EntityType, - EntityTypeValue, - RelationStrength, -} from "../../../../domain/relations/Constants.js"; +import { EntityTypeValue } 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 { RelationNodeReferenceResolver } from "./RelationNodeReferenceResolver.js"; import { RelationTraversalNode } from "./RelationTraversalNode.js"; +import { RelationTraversalPolicy } from "./RelationTraversalPolicy.js"; +import { RelationTraversalQuery } from "./RelationTraversalQuery.js"; +import { RelationTraversalQueryNormalizer } from "./RelationTraversalQueryNormalizer.js"; import { RelationTraversalResult } from "./RelationTraversalResult.js"; import { TraverseRelationsRequest } from "./TraverseRelationsRequest.js"; -type NormalizedTraversalRequest = { +type NormalizedTraversalRequest = RelationTraversalQuery & { 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) {} + constructor( + private readonly relationViewReader: IRelationViewReader, + private readonly nodeResolver = new RelationNodeReferenceResolver(relationViewReader), + private readonly queryNormalizer = new RelationTraversalQueryNormalizer(), + private readonly traversalPolicy = new RelationTraversalPolicy() + ) {} 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 visited = new Set([this.traversalPolicy.nodeKey(root)]); const nodes = new Map(); const edges = new Map(); let frontier: RelationNodeReference[] = [root]; @@ -41,7 +38,9 @@ export class LocalTraverseRelationsGateway implements ITraverseRelationsGateway 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))) { + for (const current of [...frontier].sort((left, right) => + this.traversalPolicy.compareNodes(left, right) + )) { const adjacent = await this.relationViewReader.findAll({ entity: current, direction: normalized.direction, @@ -51,13 +50,19 @@ export class LocalTraverseRelationsGateway implements ITraverseRelationsGateway status: normalized.status, }); - for (const edge of [...adjacent].sort((left, right) => this.compareEdges(left, right))) { + for (const edge of [...adjacent].sort((left, right) => + this.traversalPolicy.compareEdges(left, right) + )) { if (edges.has(edge.relationId)) continue; edges.set(edge.relationId, edge); - const related = this.relatedNode(current, edge, normalized.direction); + const related = this.traversalPolicy.relatedNode( + current, + edge, + normalized.direction + ); if (related) { - const relatedKey = this.nodeKey(related); + const relatedKey = this.traversalPolicy.nodeKey(related); if (!visited.has(relatedKey)) { visited.add(relatedKey); const traversalNode: RelationTraversalNode = { ...related, distance }; @@ -77,9 +82,12 @@ export class LocalTraverseRelationsGateway implements ITraverseRelationsGateway } const stableNodes = [...nodes.values()].sort((left, right) => - left.distance - right.distance || this.compareNodes(left, right) + left.distance - right.distance || + this.traversalPolicy.compareNodes(left, right) + ); + const stableEdges = [...edges.values()].sort((left, right) => + this.traversalPolicy.compareEdges(left, right) ); - const stableEdges = [...edges.values()].sort((left, right) => this.compareEdges(left, right)); return { root, @@ -96,38 +104,16 @@ export class LocalTraverseRelationsGateway implements ITraverseRelationsGateway 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."); - } + const query = this.queryNormalizer.normalize(request); return { - ...request, + ...query, entityId, - depth, - direction, - status, + entityType: request.entityType, limit, }; } @@ -136,59 +122,6 @@ export class LocalTraverseRelationsGateway implements ITraverseRelationsGateway 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); + return this.nodeResolver.resolve(entityId, explicitType); } } diff --git a/src/application/context/relations/traverse/RelationNodeReferenceResolver.ts b/src/application/context/relations/traverse/RelationNodeReferenceResolver.ts new file mode 100644 index 00000000..46b71e65 --- /dev/null +++ b/src/application/context/relations/traverse/RelationNodeReferenceResolver.ts @@ -0,0 +1,48 @@ +import { + EntityType, + EntityTypeValue, +} from "../../../../domain/relations/Constants.js"; +import { IRelationViewReader } from "../get/IRelationViewReader.js"; +import { RelationNodeReference } from "../get/RelationNodeReference.js"; + +export class RelationNodeReferenceResolver { + constructor(private readonly relationViewReader: IRelationViewReader) {} + + async resolve( + entityId: string, + explicitType?: EntityTypeValue, + typeOption = "--entity-type", + endpointLabel = "Entity", + ): Promise { + const normalizedEntityId = entityId?.trim(); + if (!normalizedEntityId) { + throw new Error(`${endpointLabel} ID must be provided.`); + } + + if (explicitType) { + if (!Object.values(EntityType).includes(explicitType)) { + throw new Error( + `${endpointLabel} type must be one of: ${Object.values(EntityType).join(", ")}.`, + ); + } + return { entityType: explicitType, entityId: normalizedEntityId }; + } + + const candidates = + await this.relationViewReader.findEndpointTypes(normalizedEntityId); + if (candidates.length === 1) { + return { entityType: candidates[0], entityId: normalizedEntityId }; + } + if (candidates.length === 0) { + throw new Error( + `No endpoint type can be inferred for entity ID '${normalizedEntityId}'. ` + + `Specify ${typeOption} explicitly.`, + ); + } + + throw new Error( + `Entity ID '${normalizedEntityId}' matches multiple endpoint types: ` + + `${[...candidates].sort().join(", ")}. Specify ${typeOption} explicitly.`, + ); + } +} diff --git a/src/application/context/relations/traverse/RelationTraversalPolicy.ts b/src/application/context/relations/traverse/RelationTraversalPolicy.ts new file mode 100644 index 00000000..8fee41de --- /dev/null +++ b/src/application/context/relations/traverse/RelationTraversalPolicy.ts @@ -0,0 +1,51 @@ +import { RelationView } from "../RelationView.js"; +import { RelationDirection } from "../get/RelationDirection.js"; +import { RelationNodeReference } from "../get/RelationNodeReference.js"; + +export class RelationTraversalPolicy { + 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; + } + + nodeKey(node: RelationNodeReference): string { + return `${node.entityType}\u0000${node.entityId}`; + } + + compareNodes( + left: RelationNodeReference, + right: RelationNodeReference, + ): number { + return ( + left.entityType.localeCompare(right.entityType) || + left.entityId.localeCompare(right.entityId) + ); + } + + 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/RelationTraversalQuery.ts b/src/application/context/relations/traverse/RelationTraversalQuery.ts new file mode 100644 index 00000000..bd9981bc --- /dev/null +++ b/src/application/context/relations/traverse/RelationTraversalQuery.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 RelationTraversalQuery { + readonly depth: number; + readonly direction: RelationDirection; + readonly relationType?: string; + readonly relatedEntityType?: EntityTypeValue; + readonly strength?: RelationStrengthValue; + readonly status: RelationStatusFilter; +} diff --git a/src/application/context/relations/traverse/RelationTraversalQueryNormalizer.ts b/src/application/context/relations/traverse/RelationTraversalQueryNormalizer.ts new file mode 100644 index 00000000..28cc187f --- /dev/null +++ b/src/application/context/relations/traverse/RelationTraversalQueryNormalizer.ts @@ -0,0 +1,71 @@ +import { + EntityType, + EntityTypeValue, + RelationStrength, + RelationStrengthValue, +} from "../../../../domain/relations/Constants.js"; +import { RelationDirection } from "../get/RelationDirection.js"; +import { RelationStatusFilter } from "../get/RelationStatusFilter.js"; +import { RelationTraversalQuery } from "./RelationTraversalQuery.js"; + +type RelationTraversalQueryInput = { + readonly depth?: number; + readonly direction?: RelationDirection; + readonly relationType?: string; + readonly relatedEntityType?: EntityTypeValue; + readonly strength?: RelationStrengthValue; + readonly status?: RelationStatusFilter; +}; + +export class RelationTraversalQueryNormalizer { + normalize( + request: RelationTraversalQueryInput, + defaultDepth = 1, + ): RelationTraversalQuery { + const depth = request.depth ?? defaultDepth; + if (!Number.isInteger(depth) || depth < 1 || depth > 5) { + throw new Error("Depth must be an integer from 1 through 5."); + } + + const direction = request.direction ?? "both"; + if (!(["in", "out", "both"] as string[]).includes(direction)) { + throw new Error("Direction must be one of: in, out, both."); + } + + const status = request.status ?? "active"; + if ( + !(["active", "deactivated", "removed", "all"] as string[]).includes( + status, + ) + ) { + throw new Error( + "Status must be one of: active, deactivated, removed, all.", + ); + } + + 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."); + } + + return { + depth, + direction, + relationType: request.relationType, + relatedEntityType: request.relatedEntityType, + strength: request.strength, + status, + }; + } +} diff --git a/src/application/host/IApplicationContainer.ts b/src/application/host/IApplicationContainer.ts index 58988b38..4504249b 100644 --- a/src/application/host/IApplicationContainer.ts +++ b/src/application/host/IApplicationContainer.ts @@ -329,6 +329,7 @@ import { AddRelationController } from "../context/relations/add/AddRelationContr 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 { FindRelationPathController } from "../context/relations/path/FindRelationPathController.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"; @@ -641,6 +642,7 @@ export interface IApplicationContainer { removeRelationController: RemoveRelationController; getRelationsController: GetRelationsController; traverseRelationsController: TraverseRelationsController; + findRelationPathController: FindRelationPathController; // Relations Category - Event Stores - decomposed by use case relationAddedEventStore: IRelationAddedEventWriter; diff --git a/src/infrastructure/host/HostBuilder.ts b/src/infrastructure/host/HostBuilder.ts index 354331d1..210dd5a6 100644 --- a/src/infrastructure/host/HostBuilder.ts +++ b/src/infrastructure/host/HostBuilder.ts @@ -410,6 +410,12 @@ import { GetRelationsController } from "../../application/context/relations/get/ 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"; +import { RelationNodeReferenceResolver } from "../../application/context/relations/traverse/RelationNodeReferenceResolver.js"; +import { RelationTraversalPolicy } from "../../application/context/relations/traverse/RelationTraversalPolicy.js"; +import { RelationTraversalQueryNormalizer } from "../../application/context/relations/traverse/RelationTraversalQueryNormalizer.js"; +import { FindRelationPathController } from "../../application/context/relations/path/FindRelationPathController.js"; +import { LocalFindRelationPathGateway } from "../../application/context/relations/path/LocalFindRelationPathGateway.js"; +import { RelationShortestPathFinder } from "../../application/context/relations/path/RelationShortestPathFinder.js"; // Context import { GoalContextQueryHandler } from "../../application/context/goals/get/GoalContextQueryHandler.js"; import { GoalBacklogPreviewQueryHandler } from "../../application/context/goals/query/GoalBacklogPreviewQueryHandler.js"; @@ -1807,8 +1813,26 @@ const audiencePainContextReader = new SqliteAudiencePainContextReader(this.db); const getRelationsController = new GetRelationsController( getRelationsGateway ); - const traverseRelationsGateway = new LocalTraverseRelationsGateway(relationViewReader); + const relationNodeReferenceResolver = new RelationNodeReferenceResolver(relationViewReader); + const relationTraversalQueryNormalizer = new RelationTraversalQueryNormalizer(); + const relationTraversalPolicy = new RelationTraversalPolicy(); + const traverseRelationsGateway = new LocalTraverseRelationsGateway( + relationViewReader, + relationNodeReferenceResolver, + relationTraversalQueryNormalizer, + relationTraversalPolicy + ); const traverseRelationsController = new TraverseRelationsController(traverseRelationsGateway); + const relationShortestPathFinder = new RelationShortestPathFinder( + relationViewReader, + relationTraversalPolicy + ); + const findRelationPathGateway = new LocalFindRelationPathGateway( + relationNodeReferenceResolver, + relationTraversalQueryNormalizer, + relationShortestPathFinder + ); + const findRelationPathController = new FindRelationPathController(findRelationPathGateway); // ============================================================ // STEP 5: Create Projection Handlers (Event Subscribers) @@ -2341,6 +2365,7 @@ const audiencePainContextReader = new SqliteAudiencePainContextReader(this.db); removeRelationController, getRelationsController, traverseRelationsController, + findRelationPathController, // 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 73dec0c1..35ea3943 100644 --- a/src/presentation/cli/commands/registry/generated-commands.ts +++ b/src/presentation/cli/commands/registry/generated-commands.ts @@ -80,6 +80,7 @@ import { projectStats, metadata as projectStatsMeta } from '../../commands/proje import { projectUpdate, metadata as projectUpdateMeta } from '../../commands/project/update/project.update.js'; 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 { relationsPath, metadata as relationsPathMeta } from '../../commands/relations/path/relations.path.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'; @@ -455,6 +456,11 @@ export const commands: RegisteredCommand[] = [ metadata: relationsListMeta, handler: relationsList }, + { + path: 'relations path', + metadata: relationsPathMeta, + handler: relationsPath + }, { path: 'relation remove', metadata: relationRemoveMeta, diff --git a/src/presentation/cli/commands/relations/path/RelationPathOutputBuilder.ts b/src/presentation/cli/commands/relations/path/RelationPathOutputBuilder.ts new file mode 100644 index 00000000..c807ec6c --- /dev/null +++ b/src/presentation/cli/commands/relations/path/RelationPathOutputBuilder.ts @@ -0,0 +1,134 @@ +import { RelationPathResult } from "../../../../../application/context/relations/path/RelationPathResult.js"; +import { RelationView } from "../../../../../application/context/relations/RelationView.js"; +import { RelationNodeReference } from "../../../../../application/context/relations/get/RelationNodeReference.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 RelationPathOutputBuilder { + private readonly builder = new TerminalOutputBuilder(); + + build(result: RelationPathResult): TerminalOutput { + this.builder.reset(); + const lines = [ + "", + heading( + `Relation path from ${this.nodeLabel(result.from)} to ${this.nodeLabel(result.to)}`, + ), + metaField("Found", Colors.muted(String(result.found)), 11), + metaField("Hops", Colors.muted(String(result.hopCount)), 11), + metaField("Direction", Colors.muted(result.query.direction), 11), + metaField("Max depth", Colors.muted(String(result.query.maxDepth)), 11), + ]; + + if (!result.found) { + lines.push(""); + lines.push( + contentLine(Colors.muted("No matching connection was found.")), + ); + } else if (result.hopCount === 0) { + lines.push(""); + lines.push( + contentLine(BrandColors.accentCyan(this.nodeLabel(result.from))), + ); + } else { + for (let index = 0; index < result.edges.length; index++) { + lines.push(""); + lines.push( + contentLine( + this.connectionLine( + result.nodes[index], + result.nodes[index + 1], + result.edges[index], + ), + ), + ); + } + } + + this.builder.addPrompt(lines.join("\n")); + return this.builder.build(); + } + + buildStructuredOutput(result: RelationPathResult): TerminalOutput { + this.builder.reset(); + this.builder.addData({ + found: result.found, + endpoints: { + from: { ...result.from }, + to: { ...result.to }, + }, + hopCount: result.hopCount, + nodes: result.nodes.map((node) => ({ ...node })), + edges: result.edges.map((edge) => this.edgeData(edge)), + query: { + maxDepth: result.query.maxDepth, + direction: result.query.direction, + relationType: result.query.relationType ?? null, + entityType: result.query.entityType ?? null, + strength: result.query.strength ?? null, + status: result.query.status, + }, + }); + 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 find relation path")}: ${message}`, + ); + this.builder.addData({ error: "Failed to find relation path", message }); + return this.builder.build(); + } + + private connectionLine( + current: RelationNodeReference, + next: RelationNodeReference, + edge: RelationView, + ): string { + const relation = edge.strength + ? `${edge.relationType}, ${edge.strength}` + : edge.relationType; + const followsOriginalDirection = + edge.fromEntityType === current.entityType && + edge.fromEntityId === current.entityId && + edge.toEntityType === next.entityType && + edge.toEntityId === next.entityId; + const connection = followsOriginalDirection + ? `${this.nodeLabel(current)} --[${relation}]--> ${this.nodeLabel(next)}` + : `${this.nodeLabel(current)} <--[${relation}]-- ${this.nodeLabel(next)}`; + + return `${connection} ${Colors.muted(`— ${edge.description}`)}`; + } + + 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 nodeLabel(node: RelationNodeReference): string { + return `${node.entityType}:${node.entityId}`; + } +} diff --git a/src/presentation/cli/commands/relations/path/relations.path.ts b/src/presentation/cli/commands/relations/path/relations.path.ts new file mode 100644 index 00000000..56f2fcc3 --- /dev/null +++ b/src/presentation/cli/commands/relations/path/relations.path.ts @@ -0,0 +1,128 @@ +import { FindRelationPathRequest } from "../../../../../application/context/relations/path/FindRelationPathRequest.js"; +import { IApplicationContainer } from "../../../../../application/host/IApplicationContainer.js"; +import { + EntityTypeValue, + RelationStrengthValue, +} from "../../../../../domain/relations/Constants.js"; +import { RelationDirection } from "../../../../../application/context/relations/get/RelationDirection.js"; +import { RelationStatusFilter } from "../../../../../application/context/relations/get/RelationStatusFilter.js"; +import { Renderer } from "../../../rendering/Renderer.js"; +import { RenderData } from "../../../rendering/types.js"; +import { CommandMetadata } from "../../registry/CommandMetadata.js"; +import { RelationPathOutputBuilder } from "./RelationPathOutputBuilder.js"; + +export const metadata: CommandMetadata = { + description: "Find one bounded shortest path between two relation endpoints", + category: "relations", + requiredOptions: [ + { flags: "--from-id ", description: "Starting entity ID" }, + { flags: "--to-id ", description: "Destination entity ID" }, + ], + options: [ + { + flags: "--from-type ", + description: "Starting entity type (inferred when the ID is unique)", + }, + { + flags: "--to-type ", + description: "Destination entity type (inferred when the ID is unique)", + }, + { + flags: "--max-depth ", + description: "Maximum path depth from 1 through 5", + default: 5, + }, + { + flags: "-d, --direction ", + description: "Direction: in, out, or both", + default: "both", + }, + { flags: "--relation-type ", description: "Filter by relation type" }, + { + flags: "--entity-type ", + description: "Filter each traversed opposite endpoint by entity 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", + }, + ], + examples: [ + { + command: + "jumbo relations path --from-type goal --from-id goal_123 --to-type dependency --to-id dep_456", + description: "Explain the shortest connection between two typed memories", + }, + { + command: + "jumbo relations path --from-id goal_123 --to-id comp_456 --direction out --max-depth 3 --format json", + description: + "Resolve unique endpoint types and return an outgoing path as JSON", + }, + ], + related: ["relations traverse", "relations list", "relation add"], + requiresProject: true, +}; + +export async function relationsPath( + options: { + fromId: string; + fromType?: string; + toId: string; + toType?: string; + maxDepth?: string; + direction?: string; + relationType?: string; + entityType?: string; + strength?: string; + status?: string; + }, + container: IApplicationContainer, +): Promise { + const renderer = Renderer.getInstance(); + const outputBuilder = new RelationPathOutputBuilder(); + + try { + const request: FindRelationPathRequest = { + fromEntityId: options.fromId, + fromEntityType: options.fromType as EntityTypeValue | undefined, + toEntityId: options.toId, + toEntityType: options.toType as EntityTypeValue | undefined, + maxDepth: Number(options.maxDepth ?? 5), + direction: (options.direction as RelationDirection | undefined) ?? "both", + relationType: options.relationType, + relatedEntityType: options.entityType as EntityTypeValue | undefined, + strength: options.strength as RelationStrengthValue | undefined, + status: (options.status as RelationStatusFilter | undefined) ?? "active", + }; + const result = await container.findRelationPathController.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/path/FindRelationPathController.test.ts b/tests/application/context/relations/path/FindRelationPathController.test.ts new file mode 100644 index 00000000..d98a372a --- /dev/null +++ b/tests/application/context/relations/path/FindRelationPathController.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { FindRelationPathController } from "../../../../../src/application/context/relations/path/FindRelationPathController.js"; +import { IFindRelationPathGateway } from "../../../../../src/application/context/relations/path/IFindRelationPathGateway.js"; + +describe("FindRelationPathController", () => { + it("delegates the complete path request and returns the gateway result", async () => { + const result = { + found: false, + from: { entityType: "goal" as const, entityId: "a" }, + to: { entityType: "component" as const, entityId: "b" }, + hopCount: 0, + nodes: [], + edges: [], + query: { + maxDepth: 5, + direction: "both" as const, + status: "active" as const, + }, + }; + const gateway = { + findPath: jest + .fn() + .mockResolvedValue(result), + }; + const request = { fromEntityId: "a", toEntityId: "b" }; + + await expect( + new FindRelationPathController(gateway).handle(request), + ).resolves.toBe(result); + expect(gateway.findPath).toHaveBeenCalledWith(request); + }); +}); diff --git a/tests/application/context/relations/path/FindRelationPathRequest.test.ts b/tests/application/context/relations/path/FindRelationPathRequest.test.ts new file mode 100644 index 00000000..ad95e9c2 --- /dev/null +++ b/tests/application/context/relations/path/FindRelationPathRequest.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "@jest/globals"; +import { FindRelationPathRequest } from "../../../../../src/application/context/relations/path/FindRelationPathRequest.js"; + +describe("FindRelationPathRequest", () => { + it("models typed or ID-only endpoints with bounded traversal filters", () => { + const request: FindRelationPathRequest = { + fromEntityId: "goal_1", + fromEntityType: "goal", + toEntityId: "component_1", + maxDepth: 5, + direction: "both", + relationType: "involves", + relatedEntityType: "component", + strength: "strong", + status: "active", + }; + + expect(request).toEqual( + expect.objectContaining({ + fromEntityType: "goal", + maxDepth: 5, + }), + ); + }); +}); diff --git a/tests/application/context/relations/path/IFindRelationPathGateway.test.ts b/tests/application/context/relations/path/IFindRelationPathGateway.test.ts new file mode 100644 index 00000000..93430f87 --- /dev/null +++ b/tests/application/context/relations/path/IFindRelationPathGateway.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "@jest/globals"; +import { IFindRelationPathGateway } from "../../../../../src/application/context/relations/path/IFindRelationPathGateway.js"; + +describe("IFindRelationPathGateway", () => { + it("defines a replaceable asynchronous shortest-path boundary", async () => { + const gateway: IFindRelationPathGateway = { + async findPath(request) { + return { + found: false, + from: { + entityType: request.fromEntityType ?? "goal", + entityId: request.fromEntityId, + }, + to: { + entityType: request.toEntityType ?? "component", + entityId: request.toEntityId, + }, + hopCount: 0, + nodes: [], + edges: [], + query: { + maxDepth: request.maxDepth ?? 5, + direction: "both", + status: "active", + }, + }; + }, + }; + + await expect( + gateway.findPath({ fromEntityId: "a", toEntityId: "b" }), + ).resolves.toEqual(expect.objectContaining({ found: false })); + }); +}); diff --git a/tests/application/context/relations/path/LocalFindRelationPathGateway.test.ts b/tests/application/context/relations/path/LocalFindRelationPathGateway.test.ts new file mode 100644 index 00000000..be4d286a --- /dev/null +++ b/tests/application/context/relations/path/LocalFindRelationPathGateway.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { LocalFindRelationPathGateway } from "../../../../../src/application/context/relations/path/LocalFindRelationPathGateway.js"; +import { RelationPathResult } from "../../../../../src/application/context/relations/path/RelationPathResult.js"; +import { RelationShortestPathFinder } from "../../../../../src/application/context/relations/path/RelationShortestPathFinder.js"; +import { RelationNodeReferenceResolver } from "../../../../../src/application/context/relations/traverse/RelationNodeReferenceResolver.js"; +import { RelationTraversalQueryNormalizer } from "../../../../../src/application/context/relations/traverse/RelationTraversalQueryNormalizer.js"; + +describe("LocalFindRelationPathGateway", () => { + it("normalizes filters, resolves both endpoints, and delegates to shortest-path search", async () => { + const query = { + depth: 4, + direction: "out" as const, + status: "active" as const, + }; + const from = { entityType: "goal" as const, entityId: "from" }; + const to = { entityType: "component" as const, entityId: "to" }; + const result: RelationPathResult = { + found: false, + from, + to, + hopCount: 0, + nodes: [], + edges: [], + query: { maxDepth: 4, direction: "out", status: "active" }, + }; + const nodeResolver = { + resolve: jest + .fn() + .mockResolvedValueOnce(from) + .mockResolvedValueOnce(to), + } as unknown as RelationNodeReferenceResolver; + const queryNormalizer = { + normalize: jest + .fn() + .mockReturnValue(query), + } as unknown as RelationTraversalQueryNormalizer; + const shortestPathFinder = { + find: jest + .fn() + .mockResolvedValue(result), + } as unknown as RelationShortestPathFinder; + + const request = { + fromEntityId: "from", + toEntityId: "to", + maxDepth: 4, + direction: "out" as const, + }; + const gateway = new LocalFindRelationPathGateway( + nodeResolver, + queryNormalizer, + shortestPathFinder, + ); + + await expect(gateway.findPath(request)).resolves.toBe(result); + expect(queryNormalizer.normalize).toHaveBeenCalledWith( + expect.objectContaining({ + depth: 4, + direction: "out", + }), + 5, + ); + expect(nodeResolver.resolve).toHaveBeenNthCalledWith( + 1, + "from", + undefined, + "--from-type", + "From entity", + ); + expect(nodeResolver.resolve).toHaveBeenNthCalledWith( + 2, + "to", + undefined, + "--to-type", + "To entity", + ); + expect(shortestPathFinder.find).toHaveBeenCalledWith(from, to, query); + }); + + it("reuses traversal validation for the maximum depth", async () => { + const reader = { + findAll: jest.fn().mockResolvedValue([]), + findEndpointTypes: jest.fn().mockResolvedValue([]), + }; + const resolver = new RelationNodeReferenceResolver(reader); + const normalizer = new RelationTraversalQueryNormalizer(); + const finder = new RelationShortestPathFinder(reader); + + await expect( + new LocalFindRelationPathGateway(resolver, normalizer, finder).findPath({ + fromEntityId: "from", + fromEntityType: "goal", + toEntityId: "to", + toEntityType: "component", + maxDepth: 6, + }), + ).rejects.toThrow("Depth must be an integer from 1 through 5"); + }); +}); diff --git a/tests/application/context/relations/path/RelationPathQueryMetadata.test.ts b/tests/application/context/relations/path/RelationPathQueryMetadata.test.ts new file mode 100644 index 00000000..460bfc0b --- /dev/null +++ b/tests/application/context/relations/path/RelationPathQueryMetadata.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationPathQueryMetadata } from "../../../../../src/application/context/relations/path/RelationPathQueryMetadata.js"; + +describe("RelationPathQueryMetadata", () => { + it("reports direction, bound, and unweighted edge filters", () => { + const metadata: RelationPathQueryMetadata = { + maxDepth: 3, + direction: "out", + relationType: "requires", + entityType: "component", + strength: "weak", + status: "all", + }; + + expect(metadata).toEqual( + expect.objectContaining({ + maxDepth: 3, + strength: "weak", + }), + ); + }); +}); diff --git a/tests/application/context/relations/path/RelationPathResult.test.ts b/tests/application/context/relations/path/RelationPathResult.test.ts new file mode 100644 index 00000000..bc7b932a --- /dev/null +++ b/tests/application/context/relations/path/RelationPathResult.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationPathResult } from "../../../../../src/application/context/relations/path/RelationPathResult.js"; + +describe("RelationPathResult", () => { + it("contains stable endpoints, ordered path data, hop count, and query metadata", () => { + const result: RelationPathResult = { + found: false, + from: { entityType: "goal", entityId: "goal_1" }, + to: { entityType: "component", entityId: "component_1" }, + hopCount: 0, + nodes: [], + edges: [], + query: { maxDepth: 5, direction: "both", status: "active" }, + }; + + expect(Object.keys(result)).toEqual([ + "found", + "from", + "to", + "hopCount", + "nodes", + "edges", + "query", + ]); + }); +}); diff --git a/tests/application/context/relations/path/RelationShortestPathFinder.test.ts b/tests/application/context/relations/path/RelationShortestPathFinder.test.ts new file mode 100644 index 00000000..a8a1c8c4 --- /dev/null +++ b/tests/application/context/relations/path/RelationShortestPathFinder.test.ts @@ -0,0 +1,267 @@ +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 { RelationNodeReference } from "../../../../../src/application/context/relations/get/RelationNodeReference.js"; +import { RelationShortestPathFinder } from "../../../../../src/application/context/relations/path/RelationShortestPathFinder.js"; +import { RelationTraversalQuery } from "../../../../../src/application/context/relations/traverse/RelationTraversalQuery.js"; + +function relation( + relationId: string, + fromEntityType: EntityTypeValue, + fromEntityId: string, + toEntityType: EntityTypeValue, + toEntityId: string, + overrides: Partial = {}, +): RelationView { + return { + relationId, + fromEntityType, + fromEntityId, + toEntityType, + toEntityId, + relationType: "involves", + strength: "strong", + description: `Description for ${relationId}`, + status: "active", + version: 1, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + ...overrides, + }; +} + +function graphReader(edges: RelationView[]): IRelationViewReader { + return { + async findAll(filter?: RelationListFilter): Promise { + const current = filter?.entity; + return edges.filter((edge) => { + if (!current) return true; + const outgoing = + edge.fromEntityType === current.entityType && + edge.fromEntityId === current.entityId; + const incoming = + edge.toEntityType === current.entityType && + edge.toEntityId === current.entityId; + const directionMatches = + filter.direction === "out" + ? outgoing + : filter.direction === "in" + ? incoming + : outgoing || incoming; + const oppositeType = outgoing ? edge.toEntityType : edge.fromEntityType; + return ( + directionMatches && + (!filter.relationType || edge.relationType === filter.relationType) && + (!filter.relatedEntityType || + oppositeType === filter.relatedEntityType) && + (!filter.strength || edge.strength === filter.strength) && + (filter.status === "all" || + edge.status === (filter.status ?? "active")) + ); + }); + }, + async findEndpointTypes(): Promise { + return []; + }, + }; +} + +const from: RelationNodeReference = { entityType: "goal", entityId: "A" }; +const to: RelationNodeReference = { entityType: "decision", entityId: "D" }; + +function query( + overrides: Partial = {}, +): RelationTraversalQuery { + return { depth: 5, direction: "both", status: "active", ...overrides }; +} + +describe("RelationShortestPathFinder", () => { + it("returns direct paths with original RelationView orientation and descriptions", async () => { + const direct = relation("rel_direct", "goal", "A", "decision", "D"); + + const result = await new RelationShortestPathFinder( + graphReader([direct]), + ).find(from, to, query({ direction: "out" })); + + expect(result).toMatchObject({ found: true, hopCount: 1 }); + expect(result.nodes).toEqual([from, to]); + expect(result.edges).toEqual([ + expect.objectContaining({ + relationId: "rel_direct", + fromEntityType: "goal", + description: "Description for rel_direct", + }), + ]); + }); + + it("finds the shortest multi-hop path while handling cycles", async () => { + const edges = [ + relation("rel_ab", "goal", "A", "component", "B"), + relation("rel_bc", "component", "B", "component", "C"), + relation("rel_ca", "component", "C", "goal", "A"), + relation("rel_cd", "component", "C", "decision", "D"), + relation("rel_long", "component", "B", "dependency", "E"), + relation("rel_ed", "dependency", "E", "decision", "D"), + ]; + + const result = await new RelationShortestPathFinder( + graphReader(edges), + ).find(from, to, query({ direction: "out" })); + + expect(result.nodes.map((node) => node.entityId)).toEqual([ + "A", + "B", + "C", + "D", + ]); + expect(result.edges.map((edge) => edge.relationId)).toEqual([ + "rel_ab", + "rel_bc", + "rel_cd", + ]); + expect(result.hopCount).toBe(3); + }); + + it("respects incoming, outgoing, and both directions", async () => { + const edge = relation("rel", "goal", "A", "component", "B"); + const reverseFrom = { entityType: "component" as const, entityId: "B" }; + const reverseTo = { entityType: "goal" as const, entityId: "A" }; + const finder = new RelationShortestPathFinder(graphReader([edge])); + + await expect( + finder.find(reverseFrom, reverseTo, query({ direction: "in" })), + ).resolves.toEqual(expect.objectContaining({ found: true })); + await expect( + finder.find(reverseFrom, reverseTo, query({ direction: "out" })), + ).resolves.toEqual(expect.objectContaining({ found: false })); + await expect( + finder.find(reverseFrom, reverseTo, query({ direction: "both" })), + ).resolves.toEqual(expect.objectContaining({ found: true })); + }); + + it("forwards all canonical projection filters on every expansion", async () => { + const reader = { + findAll: jest.fn().mockResolvedValue([]), + findEndpointTypes: jest.fn(), + }; + + await new RelationShortestPathFinder(reader).find( + from, + to, + query({ + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "weak", + status: "deactivated", + }), + ); + + expect(reader.findAll).toHaveBeenCalledWith({ + entity: from, + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "weak", + status: "deactivated", + }); + }); + + it("returns empty path data for disconnected nodes and exhausted depth", async () => { + const edges = [ + relation("rel_ab", "goal", "A", "component", "B"), + relation("rel_bd", "component", "B", "decision", "D"), + ]; + const finder = new RelationShortestPathFinder(graphReader(edges)); + + const bounded = await finder.find( + from, + to, + query({ depth: 1, direction: "out" }), + ); + const disconnected = await new RelationShortestPathFinder( + graphReader([]), + ).find(from, to, query()); + + expect(bounded).toMatchObject({ + found: false, + hopCount: 0, + nodes: [], + edges: [], + }); + expect(disconnected).toMatchObject({ + found: false, + hopCount: 0, + nodes: [], + edges: [], + }); + }); + + it("selects equal-length paths by stable node ordering and parallel edges by relation order", async () => { + const edges = [ + relation("rel_ac", "goal", "A", "component", "C", { + createdAt: "2025-01-01", + }), + relation("rel_ab_b", "goal", "A", "component", "B"), + relation("rel_ab_a", "goal", "A", "component", "B"), + relation("rel_cd", "component", "C", "decision", "D"), + relation("rel_bd", "component", "B", "decision", "D"), + ]; + + const result = await new RelationShortestPathFinder( + graphReader(edges), + ).find(from, to, query({ direction: "out" })); + + expect(result.nodes.map((node) => node.entityId)).toEqual(["A", "B", "D"]); + expect(result.edges.map((edge) => edge.relationId)).toEqual([ + "rel_ab_a", + "rel_bd", + ]); + }); + + it("treats relation strength only as filter metadata, not path cost", async () => { + const edges = [ + relation("weak_direct", "goal", "A", "decision", "D", { + strength: "weak", + }), + relation("strong_1", "goal", "A", "component", "B", { + strength: "strong", + }), + relation("strong_2", "component", "B", "decision", "D", { + strength: "strong", + }), + ]; + + const unfiltered = await new RelationShortestPathFinder( + graphReader(edges), + ).find(from, to, query({ direction: "out" })); + const strongOnly = await new RelationShortestPathFinder( + graphReader(edges), + ).find(from, to, query({ direction: "out", strength: "strong" })); + + expect(unfiltered.edges.map((edge) => edge.relationId)).toEqual([ + "weak_direct", + ]); + expect(strongOnly.edges.map((edge) => edge.relationId)).toEqual([ + "strong_1", + "strong_2", + ]); + }); + + it("returns a zero-hop path when both typed endpoints are identical", async () => { + const result = await new RelationShortestPathFinder(graphReader([])).find( + from, + from, + query(), + ); + + expect(result).toMatchObject({ + found: true, + hopCount: 0, + nodes: [from], + edges: [], + }); + }); +}); diff --git a/tests/application/context/relations/traverse/RelationNodeReferenceResolver.test.ts b/tests/application/context/relations/traverse/RelationNodeReferenceResolver.test.ts new file mode 100644 index 00000000..8c48ec20 --- /dev/null +++ b/tests/application/context/relations/traverse/RelationNodeReferenceResolver.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { IRelationViewReader } from "../../../../../src/application/context/relations/get/IRelationViewReader.js"; +import { RelationNodeReferenceResolver } from "../../../../../src/application/context/relations/traverse/RelationNodeReferenceResolver.js"; + +function readerWithTypes( + types: Awaited>, +): IRelationViewReader { + return { + findAll: jest.fn().mockResolvedValue([]), + findEndpointTypes: jest + .fn() + .mockResolvedValue(types), + }; +} + +describe("RelationNodeReferenceResolver", () => { + it("returns and trims an explicitly typed reference without projection inference", async () => { + const reader = readerWithTypes([]); + + await expect( + new RelationNodeReferenceResolver(reader).resolve(" goal_1 ", "goal"), + ).resolves.toEqual({ entityType: "goal", entityId: "goal_1" }); + expect(reader.findEndpointTypes).not.toHaveBeenCalled(); + }); + + it("infers the single projection endpoint type for ID-only input", async () => { + await expect( + new RelationNodeReferenceResolver(readerWithTypes(["component"])).resolve( + "shared", + ), + ).resolves.toEqual({ entityType: "component", entityId: "shared" }); + }); + + it("reports sorted ambiguity using the endpoint-specific option", async () => { + await expect( + new RelationNodeReferenceResolver( + readerWithTypes(["goal", "decision"]), + ).resolve("shared", undefined, "--from-type", "From entity"), + ).rejects.toThrow("decision, goal. Specify --from-type explicitly"); + }); + + it("rejects missing IDs, unknown inference, and invalid explicit types", async () => { + const resolver = new RelationNodeReferenceResolver(readerWithTypes([])); + + await expect(resolver.resolve(" ")).rejects.toThrow( + "Entity ID must be provided", + ); + await expect( + resolver.resolve("unknown", undefined, "--to-type"), + ).rejects.toThrow("Specify --to-type explicitly"); + await expect(resolver.resolve("known", "widget" as "goal")).rejects.toThrow( + "Entity type must be one of", + ); + }); +}); diff --git a/tests/application/context/relations/traverse/RelationTraversalPolicy.test.ts b/tests/application/context/relations/traverse/RelationTraversalPolicy.test.ts new file mode 100644 index 00000000..19d9c0fd --- /dev/null +++ b/tests/application/context/relations/traverse/RelationTraversalPolicy.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationView } from "../../../../../src/application/context/relations/RelationView.js"; +import { RelationTraversalPolicy } from "../../../../../src/application/context/relations/traverse/RelationTraversalPolicy.js"; + +const edge: RelationView = { + relationId: "rel_b", + fromEntityType: "goal", + fromEntityId: "goal_1", + toEntityType: "component", + toEntityId: "component_1", + relationType: "involves", + strength: "strong", + description: "Goal involves component", + status: "active", + version: 1, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", +}; + +describe("RelationTraversalPolicy", () => { + const policy = new RelationTraversalPolicy(); + + it("selects adjacent typed nodes according to traversal direction", () => { + expect( + policy.relatedNode( + { entityType: "goal", entityId: "goal_1" }, + edge, + "out", + ), + ).toEqual({ entityType: "component", entityId: "component_1" }); + expect( + policy.relatedNode( + { entityType: "component", entityId: "component_1" }, + edge, + "in", + ), + ).toEqual({ entityType: "goal", entityId: "goal_1" }); + expect( + policy.relatedNode( + { entityType: "goal", entityId: "goal_1" }, + edge, + "in", + ), + ).toBeUndefined(); + }); + + it("uses typed node identity and stable node ordering", () => { + expect(policy.nodeKey({ entityType: "goal", entityId: "same" })).not.toBe( + policy.nodeKey({ entityType: "component", entityId: "same" }), + ); + expect( + policy.compareNodes( + { entityType: "component", entityId: "b" }, + { entityType: "goal", entityId: "a" }, + ), + ).toBeLessThan(0); + }); + + it("orders equal-time relations by relation ID", () => { + expect( + policy.compareEdges({ ...edge, relationId: "rel_a" }, edge), + ).toBeLessThan(0); + }); +}); diff --git a/tests/application/context/relations/traverse/RelationTraversalQuery.test.ts b/tests/application/context/relations/traverse/RelationTraversalQuery.test.ts new file mode 100644 index 00000000..c9647215 --- /dev/null +++ b/tests/application/context/relations/traverse/RelationTraversalQuery.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationTraversalQuery } from "../../../../../src/application/context/relations/traverse/RelationTraversalQuery.js"; + +describe("RelationTraversalQuery", () => { + it("models normalized traversal bounds and projection filters", () => { + const query: RelationTraversalQuery = { + depth: 5, + direction: "both", + relationType: "requires", + relatedEntityType: "component", + strength: "strong", + status: "all", + }; + + expect(query).toEqual(expect.objectContaining({ depth: 5, status: "all" })); + }); +}); diff --git a/tests/application/context/relations/traverse/RelationTraversalQueryNormalizer.test.ts b/tests/application/context/relations/traverse/RelationTraversalQueryNormalizer.test.ts new file mode 100644 index 00000000..8198e919 --- /dev/null +++ b/tests/application/context/relations/traverse/RelationTraversalQueryNormalizer.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationTraversalQueryNormalizer } from "../../../../../src/application/context/relations/traverse/RelationTraversalQueryNormalizer.js"; + +describe("RelationTraversalQueryNormalizer", () => { + const normalizer = new RelationTraversalQueryNormalizer(); + + it("applies traversal defaults and preserves every projection filter", () => { + expect( + normalizer.normalize({ + relationType: "requires", + relatedEntityType: "component", + strength: "weak", + }), + ).toEqual({ + depth: 1, + direction: "both", + relationType: "requires", + relatedEntityType: "component", + strength: "weak", + status: "active", + }); + }); + + it("supports a caller-specific default depth", () => { + expect(normalizer.normalize({}, 5).depth).toBe(5); + }); + + it.each([0, 1.5, 6])("rejects invalid depth %s", (depth) => { + expect(() => normalizer.normalize({ depth })).toThrow( + "Depth must be an integer from 1 through 5", + ); + }); + + it("rejects invalid direction, status, entity type, and strength values", () => { + expect(() => + normalizer.normalize({ direction: "sideways" as "both" }), + ).toThrow("Direction must be one of"); + expect(() => normalizer.normalize({ status: "stale" as "active" })).toThrow( + "Status must be one of", + ); + expect(() => + normalizer.normalize({ relatedEntityType: "widget" as "goal" }), + ).toThrow("Related entity type must be one of"); + expect(() => + normalizer.normalize({ strength: "critical" as "strong" }), + ).toThrow("Strength must be one of"); + }); +}); diff --git a/tests/infrastructure/context/relations/get/SqliteRelationViewReader.test.ts b/tests/infrastructure/context/relations/get/SqliteRelationViewReader.test.ts index 775a472e..d46c6c61 100644 --- a/tests/infrastructure/context/relations/get/SqliteRelationViewReader.test.ts +++ b/tests/infrastructure/context/relations/get/SqliteRelationViewReader.test.ts @@ -108,4 +108,22 @@ describe("SqliteRelationViewReader", () => { await expect(reader.findEndpointTypes("shared")) .resolves.toEqual(["component", "decision", "goal"]); }); + + it("retains original edge orientation and description for incoming path expansion", async () => { + insert("rel_in", "goal", "goal_1", "component", "component_1", "involves", "strong", "active", "2026-01-01"); + + const result = await reader.findAll({ + entity: { entityType: "component", entityId: "component_1" }, + direction: "in", + }); + + expect(result).toEqual([expect.objectContaining({ + relationId: "rel_in", + fromEntityType: "goal", + fromEntityId: "goal_1", + toEntityType: "component", + toEntityId: "component_1", + description: "rel_in", + })]); + }); }); diff --git a/tests/presentation/cli/commands/registry/RequiresProjectMetadata.test.ts b/tests/presentation/cli/commands/registry/RequiresProjectMetadata.test.ts index 2e719332..2df84bfc 100644 --- a/tests/presentation/cli/commands/registry/RequiresProjectMetadata.test.ts +++ b/tests/presentation/cli/commands/registry/RequiresProjectMetadata.test.ts @@ -42,6 +42,13 @@ describe("requiresProject metadata", () => { expect(command?.metadata.requiresProject).toBe(true); }); + it("includes relations path as an explicitly project-scoped generated command", () => { + const command = commands.find((c) => c.path === "relations path"); + + expect(command).toBeDefined(); + expect(command?.metadata.requiresProject).toBe(true); + }); + it("registers goal approve and no longer registers goal qualify", () => { const paths = commands.map((c) => c.path); diff --git a/tests/presentation/cli/commands/relations/path/RelationPathOutputBuilder.test.ts b/tests/presentation/cli/commands/relations/path/RelationPathOutputBuilder.test.ts new file mode 100644 index 00000000..f79afd95 --- /dev/null +++ b/tests/presentation/cli/commands/relations/path/RelationPathOutputBuilder.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationPathResult } from "../../../../../../src/application/context/relations/path/RelationPathResult.js"; +import { RelationPathOutputBuilder } from "../../../../../../src/presentation/cli/commands/relations/path/RelationPathOutputBuilder.js"; + +const result: RelationPathResult = { + found: true, + from: { entityType: "goal", entityId: "goal_1" }, + to: { entityType: "decision", entityId: "decision_1" }, + hopCount: 2, + nodes: [ + { entityType: "goal", entityId: "goal_1" }, + { entityType: "component", entityId: "component_1" }, + { entityType: "decision", entityId: "decision_1" }, + ], + edges: [ + { + relationId: "rel_1", + fromEntityType: "goal", + fromEntityId: "goal_1", + toEntityType: "component", + toEntityId: "component_1", + relationType: "requires", + strength: "strong", + description: "Goal requires the component", + 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: "Decision supports the component", + status: "active", + version: 1, + createdAt: "2026-01-02", + updatedAt: "2026-01-02", + }, + ], + query: { + maxDepth: 5, + direction: "both", + relationType: "requires", + status: "active", + }, +}; + +describe("RelationPathOutputBuilder", () => { + it("renders typed nodes in path order with original directed edge orientation", () => { + const text = new RelationPathOutputBuilder() + .build(result) + .toHumanReadable(); + + expect(text).toContain( + "goal:goal_1 --[requires, strong]--> component:component_1", + ); + expect(text).toContain( + "component:component_1 <--[supports]-- decision:decision_1", + ); + expect(text).toContain("Goal requires the component"); + expect(text).toContain("Decision supports the component"); + }); + + it("returns the stable path JSON schema with complete RelationView descriptions", () => { + const output = new RelationPathOutputBuilder().buildStructuredOutput( + result, + ); + const content = output + .getSections() + .find((section) => section.type === "data")?.content; + + expect(content).toEqual({ + found: true, + endpoints: { + from: result.from, + to: result.to, + }, + hopCount: 2, + nodes: result.nodes, + edges: [ + expect.objectContaining({ + relationId: "rel_1", + description: "Goal requires the component", + }), + expect.objectContaining({ + relationId: "rel_2", + description: "Decision supports the component", + }), + ], + query: { + maxDepth: 5, + direction: "both", + relationType: "requires", + entityType: null, + strength: null, + status: "active", + }, + }); + expect(Object.keys(content as object)).toEqual([ + "found", + "endpoints", + "hopCount", + "nodes", + "edges", + "query", + ]); + }); + + it("renders disconnected results as a successful empty path response", () => { + const disconnected: RelationPathResult = { + ...result, + found: false, + hopCount: 0, + nodes: [], + edges: [], + }; + const output = new RelationPathOutputBuilder().buildStructuredOutput( + disconnected, + ); + const content = output + .getSections() + .find((section) => section.type === "data")?.content; + + expect(content).toEqual( + expect.objectContaining({ + found: false, + hopCount: 0, + nodes: [], + edges: [], + }), + ); + }); +}); diff --git a/tests/presentation/cli/commands/relations/path/relations.path.test.ts b/tests/presentation/cli/commands/relations/path/relations.path.test.ts new file mode 100644 index 00000000..74aa67e9 --- /dev/null +++ b/tests/presentation/cli/commands/relations/path/relations.path.test.ts @@ -0,0 +1,139 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from "@jest/globals"; +import { FindRelationPathController } from "../../../../../../src/application/context/relations/path/FindRelationPathController.js"; +import { IApplicationContainer } from "../../../../../../src/application/host/IApplicationContainer.js"; +import { + metadata, + relationsPath, +} from "../../../../../../src/presentation/cli/commands/relations/path/relations.path.js"; +import { Renderer } from "../../../../../../src/presentation/cli/rendering/Renderer.js"; + +describe("relations.path command", () => { + let handle: jest.Mock; + let container: Partial; + let consoleSpy: jest.SpiedFunction; + + beforeEach(() => { + Renderer.configure({ format: "text", verbosity: "normal" }); + handle = jest.fn().mockResolvedValue({ + found: false, + from: { entityType: "goal", entityId: "from" }, + to: { entityType: "component", entityId: "to" }, + hopCount: 0, + nodes: [], + edges: [], + query: { maxDepth: 5, direction: "both", status: "active" }, + }); + container = { + findRelationPathController: { + handle, + } as unknown as FindRelationPathController, + }; + consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + Renderer.reset(); + }); + + it("declares typed endpoints, graph filters, and explicit project scope", () => { + expect(metadata.requiresProject).toBe(true); + expect(metadata.requiredOptions?.map((option) => option.flags)).toEqual([ + "--from-id ", + "--to-id ", + ]); + expect(metadata.options?.map((option) => option.flags)).toEqual( + expect.arrayContaining([ + "--from-type ", + "--to-type ", + "--max-depth ", + "-d, --direction ", + "--relation-type ", + "--entity-type ", + "--strength ", + "-s, --status ", + ]), + ); + }); + + it("uses ID-only endpoint and traversal defaults", async () => { + await relationsPath( + { fromId: "from", toId: "to" }, + container as IApplicationContainer, + ); + + expect(handle).toHaveBeenCalledWith({ + fromEntityId: "from", + fromEntityType: undefined, + toEntityId: "to", + toEntityType: undefined, + maxDepth: 5, + direction: "both", + relationType: undefined, + relatedEntityType: undefined, + strength: undefined, + status: "active", + }); + }); + + it("parses typed endpoints and every traversal filter", async () => { + await relationsPath( + { + fromId: "from", + fromType: "goal", + toId: "to", + toType: "component", + maxDepth: "3", + direction: "out", + relationType: "requires", + entityType: "component", + strength: "weak", + status: "all", + }, + container as IApplicationContainer, + ); + + expect(handle).toHaveBeenCalledWith( + expect.objectContaining({ + fromEntityType: "goal", + toEntityType: "component", + maxDepth: 3, + direction: "out", + relationType: "requires", + relatedEntityType: "component", + strength: "weak", + status: "all", + }), + ); + }); + + it("emits exactly one complete structured result in JSON mode", async () => { + Renderer.configure({ format: "json", verbosity: "normal" }); + + await relationsPath( + { fromId: "from", toId: "to" }, + container as IApplicationContainer, + ); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + const output = JSON.parse(String(consoleSpy.mock.calls[0][0])) as Record< + string, + unknown + >; + expect(output).toEqual( + expect.objectContaining({ + found: false, + endpoints: expect.any(Object), + nodes: [], + edges: [], + }), + ); + }); +});