From 0e00ba2ec4a0c83f29638e867fa8e1670eb3dfb6 Mon Sep 17 00:00:00 2001 From: joshwheelock Date: Sat, 25 Jul 2026 22:15:47 +0200 Subject: [PATCH] feat: add relation graph auditing --- CHANGELOG.md | 4 + docs/README.md | 2 +- docs/reference/commands/index.md | 2 +- docs/reference/commands/relations.md | 34 +++- docs/reference/index.md | 2 +- .../audit/AmbiguousRelationNodeFinding.ts | 6 + .../audit/AuditRelationsController.ts | 21 +++ .../relations/audit/AuditRelationsRequest.ts | 6 + .../audit/DanglingRelationFinding.ts | 8 + .../relations/audit/IRelationNodeCatalog.ts | 5 + .../audit/InactiveOnlyRelationNodeFinding.ts | 8 + .../audit/IsolatedRelationNodeFinding.ts | 7 + .../relations/audit/RelationAuditPolicy.ts | 172 ++++++++++++++++++ .../relations/audit/RelationAuditResult.ts | 18 ++ .../relations/audit/RelationAuditSummary.ts | 12 ++ .../audit/RelationNodeCatalogEntry.ts | 8 + src/application/host/IApplicationContainer.ts | 4 + src/domain/relations/Constants.ts | 11 ++ .../audit/SqliteRelationNodeCatalog.ts | 47 +++++ src/infrastructure/host/HostBuilder.ts | 12 ++ .../commands/registry/generated-commands.ts | 6 + .../audit/RelationAuditOutputBuilder.ts | 101 ++++++++++ .../relations/audit/relations.audit.ts | 66 +++++++ .../audit/AuditRelationsController.test.ts | 31 ++++ .../audit/RelationAuditPolicy.test.ts | 135 ++++++++++++++ .../audit/SqliteRelationNodeCatalog.test.ts | 80 ++++++++ tests/infrastructure/host/HostBuilder.test.ts | 49 +++++ .../cli/command-compliance.test.ts | 14 ++ .../audit/RelationAuditOutputBuilder.test.ts | 104 +++++++++++ .../relations/audit/relations.audit.test.ts | 71 ++++++++ 30 files changed, 1042 insertions(+), 4 deletions(-) create mode 100644 src/application/context/relations/audit/AmbiguousRelationNodeFinding.ts create mode 100644 src/application/context/relations/audit/AuditRelationsController.ts create mode 100644 src/application/context/relations/audit/AuditRelationsRequest.ts create mode 100644 src/application/context/relations/audit/DanglingRelationFinding.ts create mode 100644 src/application/context/relations/audit/IRelationNodeCatalog.ts create mode 100644 src/application/context/relations/audit/InactiveOnlyRelationNodeFinding.ts create mode 100644 src/application/context/relations/audit/IsolatedRelationNodeFinding.ts create mode 100644 src/application/context/relations/audit/RelationAuditPolicy.ts create mode 100644 src/application/context/relations/audit/RelationAuditResult.ts create mode 100644 src/application/context/relations/audit/RelationAuditSummary.ts create mode 100644 src/application/context/relations/audit/RelationNodeCatalogEntry.ts create mode 100644 src/infrastructure/context/relations/audit/SqliteRelationNodeCatalog.ts create mode 100644 src/presentation/cli/commands/relations/audit/RelationAuditOutputBuilder.ts create mode 100644 src/presentation/cli/commands/relations/audit/relations.audit.ts create mode 100644 tests/application/context/relations/audit/AuditRelationsController.test.ts create mode 100644 tests/application/context/relations/audit/RelationAuditPolicy.test.ts create mode 100644 tests/infrastructure/context/relations/audit/SqliteRelationNodeCatalog.test.ts create mode 100644 tests/infrastructure/host/HostBuilder.test.ts create mode 100644 tests/presentation/cli/commands/relations/audit/RelationAuditOutputBuilder.test.ts create mode 100644 tests/presentation/cli/commands/relations/audit/relations.audit.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index efa168d0..9426033c 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 + +- **Relation graph audits**: Added `jumbo relations audit` with selectable dangling, isolated, inactive-only, ambiguous-ID, and summary checks, optional entity-type filtering, deterministic text output, and a stable structured result for relation coverage diagnostics. + ## [3.19.0] - 2026-07-24 ### Added diff --git a/docs/README.md b/docs/README.md index bcfcc180..543f66bb 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, traverse, remove | +| [Relation Commands](reference/commands/relations.md) | add, list, traverse, path, audit, 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 477513fb..be269a99 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, traverse, remove | +| [Relation Commands](relations.md) | add, list, traverse, path, audit, 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 36722f43..56b419c6 100644 --- a/docs/reference/commands/relations.md +++ b/docs/reference/commands/relations.md @@ -5,7 +5,7 @@ sidebar: order: 12 --- -Add, list, traverse, find paths through, and remove relationships between entities in the knowledge graph — linking goals, components, decisions, and other entities. +Add, list, traverse, find paths through, audit, and remove relationships between entities in the knowledge graph — linking goals, components, decisions, and other entities. --- @@ -162,6 +162,38 @@ If either ID appears under multiple entity types, specify its matching type opti --- +## jumbo relations audit + +Audit relation graph integrity and coverage without modifying relations, projections, or events. By default, the command runs every check. + +### Synopsis + +```bash +> jumbo relations audit [options] +``` + +### Options + +| Option | Description | +|--------|-------------| +| `-c, --check ` | Run one or more checks: `dangling`, `isolated`, `inactive-only`, `ambiguous-id`, or `summary` | +| `--entity-type ` | Filter findings and summary data by relation endpoint entity type | + +The checks report active relations with missing typed endpoints, current entities without an active relation, current entities connected only by inactive relations, IDs shared by multiple entity types, and counts grouped by entity type, relation type, strength, and status. Only explicitly removed projection records are treated as non-current; deprecated, resolved, completed, ended, and deactivated entities remain auditable context. + +Text output uses stable check headings and typed entity IDs. `--format json` returns the requested checks, filter, summary, finding collections, and counts as one structured result. + +### Examples + +```bash +> jumbo relations audit +> jumbo relations audit --check dangling isolated +> jumbo relations audit --check summary --entity-type component +> jumbo relations audit --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 47316c48..d4e184db 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, traverse, remove | +| [Relation Commands](commands/relations.md) | add, list, traverse, path, audit, 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/audit/AmbiguousRelationNodeFinding.ts b/src/application/context/relations/audit/AmbiguousRelationNodeFinding.ts new file mode 100644 index 00000000..bef575f9 --- /dev/null +++ b/src/application/context/relations/audit/AmbiguousRelationNodeFinding.ts @@ -0,0 +1,6 @@ +import { EntityTypeValue } from "../../../../domain/relations/Constants.js"; + +export interface AmbiguousRelationNodeFinding { + readonly entityId: string; + readonly entityTypes: readonly EntityTypeValue[]; +} diff --git a/src/application/context/relations/audit/AuditRelationsController.ts b/src/application/context/relations/audit/AuditRelationsController.ts new file mode 100644 index 00000000..7ef308ea --- /dev/null +++ b/src/application/context/relations/audit/AuditRelationsController.ts @@ -0,0 +1,21 @@ +import { IRelationViewReader } from "../get/IRelationViewReader.js"; +import { AuditRelationsRequest } from "./AuditRelationsRequest.js"; +import { IRelationNodeCatalog } from "./IRelationNodeCatalog.js"; +import { RelationAuditPolicy } from "./RelationAuditPolicy.js"; +import { RelationAuditResult } from "./RelationAuditResult.js"; + +export class AuditRelationsController { + constructor( + private readonly nodeCatalog: IRelationNodeCatalog, + private readonly relationReader: IRelationViewReader, + private readonly policy: RelationAuditPolicy, + ) {} + + async handle(request: AuditRelationsRequest): Promise { + const [nodes, relations] = await Promise.all([ + this.nodeCatalog.findAll(), + this.relationReader.findAll({ status: "all" }), + ]); + return this.policy.audit(nodes, relations, request); + } +} diff --git a/src/application/context/relations/audit/AuditRelationsRequest.ts b/src/application/context/relations/audit/AuditRelationsRequest.ts new file mode 100644 index 00000000..e138e7ca --- /dev/null +++ b/src/application/context/relations/audit/AuditRelationsRequest.ts @@ -0,0 +1,6 @@ +import { EntityTypeValue, RelationAuditCheckValue } from "../../../../domain/relations/Constants.js"; + +export interface AuditRelationsRequest { + readonly checks?: readonly RelationAuditCheckValue[]; + readonly entityType?: EntityTypeValue; +} diff --git a/src/application/context/relations/audit/DanglingRelationFinding.ts b/src/application/context/relations/audit/DanglingRelationFinding.ts new file mode 100644 index 00000000..e0f87dad --- /dev/null +++ b/src/application/context/relations/audit/DanglingRelationFinding.ts @@ -0,0 +1,8 @@ +import { RelationNodeReference } from "../get/RelationNodeReference.js"; + +export interface DanglingRelationFinding { + readonly relationId: string; + readonly from: RelationNodeReference; + readonly to: RelationNodeReference; + readonly missingEndpoints: readonly RelationNodeReference[]; +} diff --git a/src/application/context/relations/audit/IRelationNodeCatalog.ts b/src/application/context/relations/audit/IRelationNodeCatalog.ts new file mode 100644 index 00000000..624491b1 --- /dev/null +++ b/src/application/context/relations/audit/IRelationNodeCatalog.ts @@ -0,0 +1,5 @@ +import { RelationNodeCatalogEntry } from "./RelationNodeCatalogEntry.js"; + +export interface IRelationNodeCatalog { + findAll(): Promise; +} diff --git a/src/application/context/relations/audit/InactiveOnlyRelationNodeFinding.ts b/src/application/context/relations/audit/InactiveOnlyRelationNodeFinding.ts new file mode 100644 index 00000000..124f25f9 --- /dev/null +++ b/src/application/context/relations/audit/InactiveOnlyRelationNodeFinding.ts @@ -0,0 +1,8 @@ +import { EntityTypeValue } from "../../../../domain/relations/Constants.js"; + +export interface InactiveOnlyRelationNodeFinding { + readonly entityType: EntityTypeValue; + readonly entityId: string; + readonly lifecycleState: string; + readonly relationIds: readonly string[]; +} diff --git a/src/application/context/relations/audit/IsolatedRelationNodeFinding.ts b/src/application/context/relations/audit/IsolatedRelationNodeFinding.ts new file mode 100644 index 00000000..de6aa272 --- /dev/null +++ b/src/application/context/relations/audit/IsolatedRelationNodeFinding.ts @@ -0,0 +1,7 @@ +import { EntityTypeValue } from "../../../../domain/relations/Constants.js"; + +export interface IsolatedRelationNodeFinding { + readonly entityType: EntityTypeValue; + readonly entityId: string; + readonly lifecycleState: string; +} diff --git a/src/application/context/relations/audit/RelationAuditPolicy.ts b/src/application/context/relations/audit/RelationAuditPolicy.ts new file mode 100644 index 00000000..a7887be3 --- /dev/null +++ b/src/application/context/relations/audit/RelationAuditPolicy.ts @@ -0,0 +1,172 @@ +import { + EntityType, + EntityTypeValue, + RelationAuditCheck, + RelationAuditCheckValue, +} from "../../../../domain/relations/Constants.js"; +import { RelationView } from "../RelationView.js"; +import { AuditRelationsRequest } from "./AuditRelationsRequest.js"; +import { RelationAuditResult } from "./RelationAuditResult.js"; +import { RelationNodeCatalogEntry } from "./RelationNodeCatalogEntry.js"; + +const ALL_CHECKS = Object.values(RelationAuditCheck); + +export class RelationAuditPolicy { + audit( + catalogEntries: readonly RelationNodeCatalogEntry[], + relations: readonly RelationView[], + request: AuditRelationsRequest, + ): RelationAuditResult { + const requestedChecks = this.normalizeChecks(request.checks); + this.validateEntityType(request.entityType); + + const currentNodes = catalogEntries + .filter((entry) => entry.isCurrent) + .sort(this.compareNodes); + const currentKeys = new Set(currentNodes.map((entry) => this.nodeKey(entry.entityType, entry.entityId))); + const orderedRelations = [...relations].sort((left, right) => left.relationId.localeCompare(right.relationId)); + const activeRelations = orderedRelations.filter((relation) => relation.status === "active"); + + const danglingItems = activeRelations + .map((relation) => { + const from = { entityType: relation.fromEntityType, entityId: relation.fromEntityId }; + const to = { entityType: relation.toEntityType, entityId: relation.toEntityId }; + const missingEndpoints = [from, to].filter( + (endpoint) => !currentKeys.has(this.nodeKey(endpoint.entityType, endpoint.entityId)), + ); + return { relationId: relation.relationId, from, to, missingEndpoints }; + }) + .filter((finding) => finding.missingEndpoints.length > 0) + .filter((finding) => this.relationMatchesFilter(finding.from.entityType, finding.to.entityType, request.entityType)); + + const relationsByNode = new Map(); + for (const relation of orderedRelations) { + this.appendRelation(relationsByNode, relation.fromEntityType, relation.fromEntityId, relation); + this.appendRelation(relationsByNode, relation.toEntityType, relation.toEntityId, relation); + } + + const filteredNodes = currentNodes.filter((node) => !request.entityType || node.entityType === request.entityType); + const isolatedItems = filteredNodes + .filter((node) => !(relationsByNode.get(this.nodeKey(node.entityType, node.entityId)) ?? []) + .some((relation) => relation.status === "active")) + .map((node) => ({ + entityType: node.entityType, + entityId: node.entityId, + lifecycleState: node.lifecycleState, + })); + + const inactiveOnlyItems = filteredNodes + .map((node) => ({ node, relations: relationsByNode.get(this.nodeKey(node.entityType, node.entityId)) ?? [] })) + .filter(({ relations: nodeRelations }) => + nodeRelations.length > 0 && nodeRelations.every((relation) => relation.status !== "active"), + ) + .map(({ node, relations: nodeRelations }) => ({ + entityType: node.entityType, + entityId: node.entityId, + lifecycleState: node.lifecycleState, + relationIds: nodeRelations.map((relation) => relation.relationId).sort(), + })); + + const nodesById = new Map>(); + for (const node of currentNodes) { + const types = nodesById.get(node.entityId) ?? new Set(); + types.add(node.entityType); + nodesById.set(node.entityId, types); + } + const ambiguousIdItems = [...nodesById.entries()] + .map(([entityId, types]) => ({ entityId, entityTypes: [...types].sort() })) + .filter((finding) => finding.entityTypes.length > 1) + .filter((finding) => !request.entityType || finding.entityTypes.includes(request.entityType)) + .sort((left, right) => left.entityId.localeCompare(right.entityId)); + + const summaryNodes = filteredNodes; + const summaryRelations = orderedRelations.filter((relation) => + this.relationMatchesFilter(relation.fromEntityType, relation.toEntityType, request.entityType), + ); + const summary = requestedChecks.includes(RelationAuditCheck.SUMMARY) + ? { + nodes: { + count: summaryNodes.length, + byEntityType: this.countBy(summaryNodes.map((node) => node.entityType)), + }, + relations: { + count: summaryRelations.length, + byRelationType: this.countBy(summaryRelations.map((relation) => relation.relationType)), + byStrength: this.countBy(summaryRelations.map((relation) => relation.strength ?? "unspecified")), + byStatus: this.countBy(summaryRelations.map((relation) => relation.status)), + }, + } + : { + nodes: { count: 0, byEntityType: {} }, + relations: { count: 0, byRelationType: {}, byStrength: {}, byStatus: {} }, + }; + + return { + requestedChecks, + entityType: request.entityType ?? null, + summary, + findings: { + dangling: requestedChecks.includes(RelationAuditCheck.DANGLING) + ? { count: danglingItems.length, items: danglingItems } + : { count: 0, items: [] }, + isolated: requestedChecks.includes(RelationAuditCheck.ISOLATED) + ? { count: isolatedItems.length, items: isolatedItems } + : { count: 0, items: [] }, + inactiveOnly: requestedChecks.includes(RelationAuditCheck.INACTIVE_ONLY) + ? { count: inactiveOnlyItems.length, items: inactiveOnlyItems } + : { count: 0, items: [] }, + ambiguousId: requestedChecks.includes(RelationAuditCheck.AMBIGUOUS_ID) + ? { count: ambiguousIdItems.length, items: ambiguousIdItems } + : { count: 0, items: [] }, + }, + }; + } + + private normalizeChecks(checks: readonly RelationAuditCheckValue[] | undefined): RelationAuditCheckValue[] { + if (!checks || checks.length === 0) return [...ALL_CHECKS]; + const invalid = checks.find((check) => !ALL_CHECKS.includes(check)); + if (invalid) throw new Error(`Audit check must be one of: ${ALL_CHECKS.join(", ")}.`); + return ALL_CHECKS.filter((check) => checks.includes(check)); + } + + private validateEntityType(entityType: EntityTypeValue | undefined): void { + if (entityType && !Object.values(EntityType).includes(entityType)) { + throw new Error(`Entity type must be one of: ${Object.values(EntityType).join(", ")}.`); + } + } + + private appendRelation( + target: Map, + entityType: EntityTypeValue, + entityId: string, + relation: RelationView, + ): void { + const key = this.nodeKey(entityType, entityId); + const current = target.get(key) ?? []; + if (!current.some((item) => item.relationId === relation.relationId)) current.push(relation); + target.set(key, current); + } + + private relationMatchesFilter( + fromEntityType: EntityTypeValue, + toEntityType: EntityTypeValue, + filter: EntityTypeValue | undefined, + ): boolean { + return !filter || fromEntityType === filter || toEntityType === filter; + } + + private countBy(values: readonly string[]): Record { + return [...values].sort().reduce>((counts, value) => { + counts[value] = (counts[value] ?? 0) + 1; + return counts; + }, {}); + } + + private nodeKey(entityType: EntityTypeValue, entityId: string): string { + return `${entityType}\u0000${entityId}`; + } + + private compareNodes(left: RelationNodeCatalogEntry, right: RelationNodeCatalogEntry): number { + return left.entityType.localeCompare(right.entityType) || left.entityId.localeCompare(right.entityId); + } +} diff --git a/src/application/context/relations/audit/RelationAuditResult.ts b/src/application/context/relations/audit/RelationAuditResult.ts new file mode 100644 index 00000000..f898fb73 --- /dev/null +++ b/src/application/context/relations/audit/RelationAuditResult.ts @@ -0,0 +1,18 @@ +import { EntityTypeValue, RelationAuditCheckValue } from "../../../../domain/relations/Constants.js"; +import { AmbiguousRelationNodeFinding } from "./AmbiguousRelationNodeFinding.js"; +import { DanglingRelationFinding } from "./DanglingRelationFinding.js"; +import { InactiveOnlyRelationNodeFinding } from "./InactiveOnlyRelationNodeFinding.js"; +import { IsolatedRelationNodeFinding } from "./IsolatedRelationNodeFinding.js"; +import { RelationAuditSummary } from "./RelationAuditSummary.js"; + +export interface RelationAuditResult { + readonly requestedChecks: readonly RelationAuditCheckValue[]; + readonly entityType: EntityTypeValue | null; + readonly summary: RelationAuditSummary; + readonly findings: { + readonly dangling: { readonly count: number; readonly items: readonly DanglingRelationFinding[] }; + readonly isolated: { readonly count: number; readonly items: readonly IsolatedRelationNodeFinding[] }; + readonly inactiveOnly: { readonly count: number; readonly items: readonly InactiveOnlyRelationNodeFinding[] }; + readonly ambiguousId: { readonly count: number; readonly items: readonly AmbiguousRelationNodeFinding[] }; + }; +} diff --git a/src/application/context/relations/audit/RelationAuditSummary.ts b/src/application/context/relations/audit/RelationAuditSummary.ts new file mode 100644 index 00000000..4eddd8e5 --- /dev/null +++ b/src/application/context/relations/audit/RelationAuditSummary.ts @@ -0,0 +1,12 @@ +export interface RelationAuditSummary { + readonly nodes: { + readonly count: number; + readonly byEntityType: Readonly>; + }; + readonly relations: { + readonly count: number; + readonly byRelationType: Readonly>; + readonly byStrength: Readonly>; + readonly byStatus: Readonly>; + }; +} diff --git a/src/application/context/relations/audit/RelationNodeCatalogEntry.ts b/src/application/context/relations/audit/RelationNodeCatalogEntry.ts new file mode 100644 index 00000000..45ffd635 --- /dev/null +++ b/src/application/context/relations/audit/RelationNodeCatalogEntry.ts @@ -0,0 +1,8 @@ +import { EntityTypeValue } from "../../../../domain/relations/Constants.js"; + +export interface RelationNodeCatalogEntry { + readonly entityType: EntityTypeValue; + readonly entityId: string; + readonly lifecycleState: string; + readonly isCurrent: boolean; +} diff --git a/src/application/host/IApplicationContainer.ts b/src/application/host/IApplicationContainer.ts index 4504249b..9ddf107e 100644 --- a/src/application/host/IApplicationContainer.ts +++ b/src/application/host/IApplicationContainer.ts @@ -208,6 +208,8 @@ import { IRelationRemovedProjector } from "../context/relations/remove/IRelation import { IRelationRemovedReader } from "../context/relations/remove/IRelationRemovedReader.js"; import { IRelationReader } from "../context/relations/IRelationReader.js"; import { IRelationViewReader } from "../context/relations/get/IRelationViewReader.js"; +import { AuditRelationsController } from "../context/relations/audit/AuditRelationsController.js"; +import { IRelationNodeCatalog } from "../context/relations/audit/IRelationNodeCatalog.js"; // Audience Pain Projection Store ports - decomposed by use case import { IAudiencePainAddedProjector } from "../context/audience-pains/add/IAudiencePainAddedProjector.js"; import { IAudiencePainUpdatedProjector } from "../context/audience-pains/update/IAudiencePainUpdatedProjector.js"; @@ -643,6 +645,7 @@ export interface IApplicationContainer { getRelationsController: GetRelationsController; traverseRelationsController: TraverseRelationsController; findRelationPathController: FindRelationPathController; + auditRelationsController: AuditRelationsController; // Relations Category - Event Stores - decomposed by use case relationAddedEventStore: IRelationAddedEventWriter; @@ -652,4 +655,5 @@ export interface IApplicationContainer { relationAddedProjector: IRelationAddedProjector & IRelationAddedReader; relationRemovedProjector: IRelationRemovedProjector & IRelationRemovedReader & IRelationReader; relationViewReader: IRelationViewReader; + relationNodeCatalog: IRelationNodeCatalog; } diff --git a/src/domain/relations/Constants.ts b/src/domain/relations/Constants.ts index ed8787e5..8568e8da 100644 --- a/src/domain/relations/Constants.ts +++ b/src/domain/relations/Constants.ts @@ -35,6 +35,17 @@ export const RelationStatus = { export type RelationStatusValue = typeof RelationStatus[keyof typeof RelationStatus]; +// Read-only relation graph audit checks +export const RelationAuditCheck = { + DANGLING: 'dangling', + ISOLATED: 'isolated', + INACTIVE_ONLY: 'inactive-only', + AMBIGUOUS_ID: 'ambiguous-id', + SUMMARY: 'summary' +} as const; + +export type RelationAuditCheckValue = typeof RelationAuditCheck[keyof typeof RelationAuditCheck]; + // Relation event types export const RelationEventType = { ADDED: 'RelationAddedEvent', diff --git a/src/infrastructure/context/relations/audit/SqliteRelationNodeCatalog.ts b/src/infrastructure/context/relations/audit/SqliteRelationNodeCatalog.ts new file mode 100644 index 00000000..18c4cab5 --- /dev/null +++ b/src/infrastructure/context/relations/audit/SqliteRelationNodeCatalog.ts @@ -0,0 +1,47 @@ +import { Database } from "better-sqlite3"; +import { IRelationNodeCatalog } from "../../../../application/context/relations/audit/IRelationNodeCatalog.js"; +import { RelationNodeCatalogEntry } from "../../../../application/context/relations/audit/RelationNodeCatalogEntry.js"; +import { EntityType, EntityTypeValue } from "../../../../domain/relations/Constants.js"; + +interface CatalogRow { + entityType: string; + entityId: string; + lifecycleState: string; + isCurrent: number; +} + +const SELECT_BY_ENTITY_TYPE: Record = { + [EntityType.SESSION]: `SELECT 'session' entityType, sessionId entityId, status lifecycleState, 1 isCurrent FROM session_views`, + [EntityType.GOAL]: `SELECT 'goal' entityType, goalId entityId, status lifecycleState, 1 isCurrent FROM goal_views`, + [EntityType.DECISION]: `SELECT 'decision' entityType, decisionId entityId, status lifecycleState, 1 isCurrent FROM decision_views`, + [EntityType.ARCHITECTURE]: `SELECT 'architecture' entityType, architectureId entityId, CASE deprecated WHEN 1 THEN 'deprecated' ELSE 'active' END lifecycleState, 1 isCurrent FROM architecture_views`, + [EntityType.COMPONENT]: `SELECT 'component' entityType, componentId entityId, status lifecycleState, status <> 'removed' isCurrent FROM component_views`, + [EntityType.DEPENDENCY]: `SELECT 'dependency' entityType, dependencyId entityId, status lifecycleState, status <> 'removed' isCurrent FROM dependency_views`, + [EntityType.GUIDELINE]: `SELECT 'guideline' entityType, guidelineId entityId, CASE isRemoved WHEN 1 THEN 'removed' ELSE 'active' END lifecycleState, isRemoved = 0 isCurrent FROM guideline_views`, + [EntityType.PROJECT]: `SELECT 'project' entityType, projectId entityId, 'active' lifecycleState, 1 isCurrent FROM project_views`, + [EntityType.AUDIENCE]: `SELECT 'audience' entityType, audienceId entityId, CASE isRemoved WHEN 1 THEN 'removed' ELSE 'active' END lifecycleState, isRemoved = 0 isCurrent FROM audience_views`, + [EntityType.INVARIANT]: `SELECT 'invariant' entityType, invariantId entityId, 'active' lifecycleState, 1 isCurrent FROM invariant_views`, + [EntityType.PAIN]: `SELECT 'pain' entityType, painId entityId, status lifecycleState, status <> 'removed' isCurrent FROM audience_pain_views`, + [EntityType.VALUE]: `SELECT 'value' entityType, valuePropositionId entityId, 'active' lifecycleState, 1 isCurrent FROM value_proposition_views`, + [EntityType.RELATION]: `SELECT 'relation' entityType, relationId entityId, status lifecycleState, status <> 'removed' isCurrent FROM relation_views`, +}; + +export class SqliteRelationNodeCatalog implements IRelationNodeCatalog { + constructor(private readonly db: Database) {} + + async findAll(): Promise { + const query = Object.values(SELECT_BY_ENTITY_TYPE).join(" UNION ALL ") + + " ORDER BY entityType ASC, entityId ASC"; + const rows = this.db.prepare(query).all() as CatalogRow[]; + return rows.map((row) => ({ + entityType: row.entityType as EntityTypeValue, + entityId: row.entityId, + lifecycleState: row.lifecycleState, + isCurrent: row.isCurrent === 1, + })); + } + + getSupportedEntityTypes(): EntityTypeValue[] { + return Object.keys(SELECT_BY_ENTITY_TYPE).sort() as EntityTypeValue[]; + } +} diff --git a/src/infrastructure/host/HostBuilder.ts b/src/infrastructure/host/HostBuilder.ts index 210dd5a6..bf6e15c6 100644 --- a/src/infrastructure/host/HostBuilder.ts +++ b/src/infrastructure/host/HostBuilder.ts @@ -257,6 +257,7 @@ import { SqliteRelationDeactivatedProjector } from "../context/relations/deactiv import { SqliteRelationReactivatedProjector } from "../context/relations/reactivate/SqliteRelationReactivatedProjector.js"; import { SqliteRelationRemovedProjector } from "../context/relations/remove/SqliteRelationRemovedProjector.js"; import { SqliteRelationViewReader } from "../context/relations/get/SqliteRelationViewReader.js"; +import { SqliteRelationNodeCatalog } from "../context/relations/audit/SqliteRelationNodeCatalog.js"; // AudiencePain Projection Stores - decomposed by use case import { SqliteAudiencePainAddedProjector } from "../context/audience-pains/add/SqliteAudiencePainAddedProjector.js"; import { SqliteAudiencePainUpdatedProjector } from "../context/audience-pains/update/SqliteAudiencePainUpdatedProjector.js"; @@ -416,6 +417,8 @@ import { RelationTraversalQueryNormalizer } from "../../application/context/rela 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"; +import { AuditRelationsController } from "../../application/context/relations/audit/AuditRelationsController.js"; +import { RelationAuditPolicy } from "../../application/context/relations/audit/RelationAuditPolicy.js"; // Context import { GoalContextQueryHandler } from "../../application/context/goals/get/GoalContextQueryHandler.js"; import { GoalBacklogPreviewQueryHandler } from "../../application/context/goals/query/GoalBacklogPreviewQueryHandler.js"; @@ -982,6 +985,7 @@ const audiencePainContextReader = new SqliteAudiencePainContextReader(this.db); const relationReactivatedProjector = new SqliteRelationReactivatedProjector(this.db); const relationRemovedProjector = new SqliteRelationRemovedProjector(this.db); const relationViewReader = new SqliteRelationViewReader(this.db); + const relationNodeCatalog = new SqliteRelationNodeCatalog(this.db); // ============================================================ // STEP 4: Create Application Services / Controllers @@ -1833,6 +1837,12 @@ const audiencePainContextReader = new SqliteAudiencePainContextReader(this.db); relationShortestPathFinder ); const findRelationPathController = new FindRelationPathController(findRelationPathGateway); + const relationAuditPolicy = new RelationAuditPolicy(); + const auditRelationsController = new AuditRelationsController( + relationNodeCatalog, + relationViewReader, + relationAuditPolicy, + ); // ============================================================ // STEP 5: Create Projection Handlers (Event Subscribers) @@ -2366,12 +2376,14 @@ const audiencePainContextReader = new SqliteAudiencePainContextReader(this.db); getRelationsController, traverseRelationsController, findRelationPathController, + auditRelationsController, // Relations Category - decomposed by use case relationAddedEventStore, relationRemovedEventStore, relationAddedProjector, relationRemovedProjector, relationViewReader, + relationNodeCatalog, }; } } diff --git a/src/presentation/cli/commands/registry/generated-commands.ts b/src/presentation/cli/commands/registry/generated-commands.ts index 35ea3943..d188e926 100644 --- a/src/presentation/cli/commands/registry/generated-commands.ts +++ b/src/presentation/cli/commands/registry/generated-commands.ts @@ -79,6 +79,7 @@ import { projectShow, metadata as projectShowMeta } from '../../commands/project import { projectStats, metadata as projectStatsMeta } from '../../commands/project/stats/project.stats.js'; 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 { relationsAudit, metadata as relationsAuditMeta } from '../../commands/relations/audit/relations.audit.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'; @@ -451,6 +452,11 @@ export const commands: RegisteredCommand[] = [ metadata: relationAddMeta, handler: relationAdd }, + { + path: 'relations audit', + metadata: relationsAuditMeta, + handler: relationsAudit + }, { path: 'relations list', metadata: relationsListMeta, diff --git a/src/presentation/cli/commands/relations/audit/RelationAuditOutputBuilder.ts b/src/presentation/cli/commands/relations/audit/RelationAuditOutputBuilder.ts new file mode 100644 index 00000000..cf2dc9e1 --- /dev/null +++ b/src/presentation/cli/commands/relations/audit/RelationAuditOutputBuilder.ts @@ -0,0 +1,101 @@ +import { RelationAuditResult } from "../../../../../application/context/relations/audit/RelationAuditResult.js"; +import { RelationAuditCheck } from "../../../../../domain/relations/Constants.js"; +import { TerminalOutput } from "../../../output/TerminalOutput.js"; +import { TerminalOutputBuilder } from "../../../output/TerminalOutputBuilder.js"; +import { Symbols } from "../../../rendering/StyleConfig.js"; + +export class RelationAuditOutputBuilder { + private readonly builder = new TerminalOutputBuilder(); + + build(result: RelationAuditResult): TerminalOutput { + this.builder.reset(); + const lines = ["Relation Graph Audit"]; + if (result.entityType) lines.push(`Entity type: ${result.entityType}`); + + if (result.requestedChecks.includes(RelationAuditCheck.SUMMARY)) { + lines.push("", "Summary"); + lines.push(` Nodes: ${result.summary.nodes.count}`); + this.appendCounts(lines, result.summary.nodes.byEntityType, " "); + lines.push(` Relations: ${result.summary.relations.count}`); + lines.push(" By relation type:"); + this.appendCounts(lines, result.summary.relations.byRelationType, " "); + lines.push(" By strength:"); + this.appendCounts(lines, result.summary.relations.byStrength, " "); + lines.push(" By status:"); + this.appendCounts(lines, result.summary.relations.byStatus, " "); + } + + if (result.requestedChecks.includes(RelationAuditCheck.DANGLING)) { + lines.push("", `Dangling relations (${result.findings.dangling.count})`); + this.appendEmpty(lines, result.findings.dangling.count); + for (const finding of result.findings.dangling.items) { + const missing = finding.missingEndpoints + .map((endpoint) => `${endpoint.entityType}:${endpoint.entityId}`) + .join(", "); + lines.push(` ${finding.relationId}: missing ${missing}`); + } + } + + if (result.requestedChecks.includes(RelationAuditCheck.ISOLATED)) { + lines.push("", `Isolated entities (${result.findings.isolated.count})`); + this.appendEmpty(lines, result.findings.isolated.count); + for (const finding of result.findings.isolated.items) { + lines.push(` ${finding.entityType}:${finding.entityId} [${finding.lifecycleState}]`); + } + } + + if (result.requestedChecks.includes(RelationAuditCheck.INACTIVE_ONLY)) { + lines.push("", `Inactive-only entities (${result.findings.inactiveOnly.count})`); + this.appendEmpty(lines, result.findings.inactiveOnly.count); + for (const finding of result.findings.inactiveOnly.items) { + lines.push( + ` ${finding.entityType}:${finding.entityId} [${finding.lifecycleState}] relations: ${finding.relationIds.join(", ")}`, + ); + } + } + + if (result.requestedChecks.includes(RelationAuditCheck.AMBIGUOUS_ID)) { + lines.push("", `Ambiguous entity IDs (${result.findings.ambiguousId.count})`); + this.appendEmpty(lines, result.findings.ambiguousId.count); + for (const finding of result.findings.ambiguousId.items) { + const typedIds = finding.entityTypes.map((entityType) => `${entityType}:${finding.entityId}`); + lines.push(` ${finding.entityId}: ${typedIds.join(", ")}`); + } + } + + this.builder.addPrompt(lines.join("\n")); + return this.builder.build(); + } + + buildStructuredOutput(result: RelationAuditResult): TerminalOutput { + this.builder.reset(); + this.builder.addData({ + requestedChecks: [...result.requestedChecks], + filter: { entityType: result.entityType }, + summary: result.summary, + findings: result.findings, + }); + 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} Failed to audit relations: ${message}`); + this.builder.addData({ error: "Failed to audit relations", message }); + return this.builder.build(); + } + + private appendCounts(lines: string[], counts: Readonly>, indent: string): void { + const entries = Object.entries(counts); + if (entries.length === 0) { + lines.push(`${indent}None`); + return; + } + for (const [label, count] of entries) lines.push(`${indent}${label}: ${count}`); + } + + private appendEmpty(lines: string[], count: number): void { + if (count === 0) lines.push(" None"); + } +} diff --git a/src/presentation/cli/commands/relations/audit/relations.audit.ts b/src/presentation/cli/commands/relations/audit/relations.audit.ts new file mode 100644 index 00000000..332bfcdc --- /dev/null +++ b/src/presentation/cli/commands/relations/audit/relations.audit.ts @@ -0,0 +1,66 @@ +import { AuditRelationsRequest } from "../../../../../application/context/relations/audit/AuditRelationsRequest.js"; +import { IApplicationContainer } from "../../../../../application/host/IApplicationContainer.js"; +import { + EntityTypeValue, + RelationAuditCheckValue, +} from "../../../../../domain/relations/Constants.js"; +import { Renderer } from "../../../rendering/Renderer.js"; +import { RenderData } from "../../../rendering/types.js"; +import { CommandMetadata } from "../../registry/CommandMetadata.js"; +import { RelationAuditOutputBuilder } from "./RelationAuditOutputBuilder.js"; + +export const metadata: CommandMetadata = { + description: "Audit relation graph coverage and disconnected context", + category: "relations", + options: [ + { + flags: "-c, --check ", + description: "Checks: dangling, isolated, inactive-only, ambiguous-id, summary (all by default)", + }, + { + flags: "--entity-type ", + description: "Filter findings and summary data by relation endpoint entity type", + }, + ], + examples: [ + { command: "jumbo relations audit", description: "Run every relation graph audit check" }, + { command: "jumbo relations audit --check dangling isolated", description: "Find missing endpoints and isolated entities" }, + { command: "jumbo relations audit --check summary --entity-type component", description: "Summarize component graph coverage" }, + { command: "jumbo relations audit --format json", description: "Return the stable audit result as JSON" }, + ], + related: ["relations list", "relations traverse", "relations path"], + requiresProject: true, +}; + +export async function relationsAudit( + options: { check?: string[] | string; entityType?: string }, + container: IApplicationContainer, +): Promise { + const renderer = Renderer.getInstance(); + const outputBuilder = new RelationAuditOutputBuilder(); + + try { + const checks = options.check + ? (Array.isArray(options.check) ? options.check : [options.check]).map((check) => check.toLowerCase()) + : undefined; + const request: AuditRelationsRequest = { + checks: checks as RelationAuditCheckValue[] | undefined, + entityType: options.entityType as EntityTypeValue | undefined, + }; + const result = await container.auditRelationsController.handle(request); + const output = renderer.getConfig().format === "text" + ? outputBuilder.build(result) + : outputBuilder.buildStructuredOutput(result); + const dataSection = output.getSections().find((section) => section.type === "data"); + if (renderer.getConfig().format === "text") renderer.info(output.toHumanReadable()); + else 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/audit/AuditRelationsController.test.ts b/tests/application/context/relations/audit/AuditRelationsController.test.ts new file mode 100644 index 00000000..7403626d --- /dev/null +++ b/tests/application/context/relations/audit/AuditRelationsController.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { AuditRelationsController } from "../../../../../src/application/context/relations/audit/AuditRelationsController.js"; +import { IRelationNodeCatalog } from "../../../../../src/application/context/relations/audit/IRelationNodeCatalog.js"; +import { RelationAuditPolicy } from "../../../../../src/application/context/relations/audit/RelationAuditPolicy.js"; +import { IRelationViewReader } from "../../../../../src/application/context/relations/get/IRelationViewReader.js"; + +describe("AuditRelationsController", () => { + it("queries both read ports and requests all relation lifecycle states", async () => { + const findNodes = jest.fn().mockResolvedValue([]); + const findRelations = jest.fn().mockResolvedValue([]); + const controller = new AuditRelationsController( + { findAll: findNodes }, + { findAll: findRelations, findEndpointTypes: jest.fn() }, + new RelationAuditPolicy(), + ); + + const result = await controller.handle({ checks: ["summary"] }); + + expect(findNodes).toHaveBeenCalledTimes(1); + expect(findRelations).toHaveBeenCalledWith({ status: "all" }); + expect(result.summary).toEqual({ + nodes: { count: 0, byEntityType: {} }, + relations: { + count: 0, + byRelationType: {}, + byStrength: {}, + byStatus: {}, + }, + }); + }); +}); diff --git a/tests/application/context/relations/audit/RelationAuditPolicy.test.ts b/tests/application/context/relations/audit/RelationAuditPolicy.test.ts new file mode 100644 index 00000000..f41f7924 --- /dev/null +++ b/tests/application/context/relations/audit/RelationAuditPolicy.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationAuditPolicy } from "../../../../../src/application/context/relations/audit/RelationAuditPolicy.js"; +import { RelationNodeCatalogEntry } from "../../../../../src/application/context/relations/audit/RelationNodeCatalogEntry.js"; +import { RelationView } from "../../../../../src/application/context/relations/RelationView.js"; + +const node = ( + entityType: RelationNodeCatalogEntry["entityType"], + entityId: string, + lifecycleState = "active", + isCurrent = true, +): RelationNodeCatalogEntry => ({ entityType, entityId, lifecycleState, isCurrent }); + +const relation = ( + relationId: string, + fromEntityType: RelationView["fromEntityType"], + fromEntityId: string, + toEntityType: RelationView["toEntityType"], + toEntityId: string, + status: RelationView["status"], + relationType = "involves", + strength: RelationView["strength"] = null, +): RelationView => ({ + relationId, + fromEntityType, + fromEntityId, + toEntityType, + toEntityId, + relationType, + strength, + description: relationId, + status, + version: 1, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", +}); + +describe("RelationAuditPolicy", () => { + const nodes = [ + node("goal", "g1"), + node("goal", "shared"), + node("component", "shared"), + node("component", "old"), + node("goal", "old-goal"), + node("decision", "alone", "reversed"), + node("dependency", "removed", "removed", false), + ]; + const relations = [ + relation("rel-dangling", "goal", "g1", "component", "missing", "active", "involves", "strong"), + relation("rel-active", "goal", "shared", "component", "shared", "active", "supports"), + relation("rel-inactive", "component", "old", "goal", "old-goal", "deactivated", "supports", "weak"), + relation("rel-removed", "component", "old", "goal", "old-goal", "removed", "requires", "weak"), + ]; + + it("finds every audit category and aggregates all relation dimensions", () => { + const result = new RelationAuditPolicy().audit(nodes, relations, {}); + + expect(result.requestedChecks).toEqual([ + "dangling", "isolated", "inactive-only", "ambiguous-id", "summary", + ]); + expect(result.findings.dangling).toEqual({ + count: 1, + items: [expect.objectContaining({ + relationId: "rel-dangling", + missingEndpoints: [{ entityType: "component", entityId: "missing" }], + })], + }); + expect(result.findings.isolated.items).toEqual([ + { entityType: "component", entityId: "old", lifecycleState: "active" }, + { entityType: "decision", entityId: "alone", lifecycleState: "reversed" }, + { entityType: "goal", entityId: "old-goal", lifecycleState: "active" }, + ]); + expect(result.findings.inactiveOnly.items).toEqual([ + { + entityType: "component", + entityId: "old", + lifecycleState: "active", + relationIds: ["rel-inactive", "rel-removed"], + }, + { + entityType: "goal", + entityId: "old-goal", + lifecycleState: "active", + relationIds: ["rel-inactive", "rel-removed"], + }, + ]); + expect(result.findings.ambiguousId.items).toEqual([ + { entityId: "shared", entityTypes: ["component", "goal"] }, + ]); + expect(result.summary).toEqual({ + nodes: { count: 6, byEntityType: { component: 2, decision: 1, goal: 3 } }, + relations: { + count: 4, + byRelationType: { involves: 1, requires: 1, supports: 2 }, + byStrength: { strong: 1, unspecified: 1, weak: 2 }, + byStatus: { active: 2, deactivated: 1, removed: 1 }, + }, + }); + }); + + it("selects, deduplicates, and stably orders checks while filtering by entity type", () => { + const result = new RelationAuditPolicy().audit(nodes, relations, { + checks: ["summary", "dangling", "summary"], + entityType: "decision", + }); + + expect(result.requestedChecks).toEqual(["dangling", "summary"]); + expect(result.entityType).toBe("decision"); + expect(result.summary.nodes).toEqual({ count: 1, byEntityType: { decision: 1 } }); + expect(result.summary.relations.count).toBe(0); + expect(result.findings.dangling.count).toBe(0); + expect(result.findings.isolated).toEqual({ count: 0, items: [] }); + expect(result.findings.ambiguousId.count).toBe(0); + }); + + it("rejects unknown checks and entity types", () => { + const policy = new RelationAuditPolicy(); + expect(() => policy.audit([], [], { checks: ["cycles" as "summary"] })).toThrow( + "Audit check must be one of", + ); + expect(() => policy.audit([], [], { entityType: "unknown" as "goal" })).toThrow( + "Entity type must be one of", + ); + }); + + it("keeps unrequested summary and finding collections empty", () => { + const result = new RelationAuditPolicy().audit(nodes, relations, { checks: ["dangling"] }); + + expect(result.summary).toEqual({ + nodes: { count: 0, byEntityType: {} }, + relations: { count: 0, byRelationType: {}, byStrength: {}, byStatus: {} }, + }); + expect(result.findings.dangling.count).toBe(1); + expect(result.findings.isolated).toEqual({ count: 0, items: [] }); + }); +}); diff --git a/tests/infrastructure/context/relations/audit/SqliteRelationNodeCatalog.test.ts b/tests/infrastructure/context/relations/audit/SqliteRelationNodeCatalog.test.ts new file mode 100644 index 00000000..6f0691a9 --- /dev/null +++ b/tests/infrastructure/context/relations/audit/SqliteRelationNodeCatalog.test.ts @@ -0,0 +1,80 @@ +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; +import { SqliteRelationNodeCatalog } from "../../../../../src/infrastructure/context/relations/audit/SqliteRelationNodeCatalog.js"; +import { EntityType } from "../../../../../src/domain/relations/Constants.js"; + +describe("SqliteRelationNodeCatalog", () => { + let db: Database.Database; + let catalog: SqliteRelationNodeCatalog; + + beforeEach(() => { + db = new Database(":memory:"); + db.exec(` + CREATE TABLE session_views (sessionId TEXT, status TEXT); + CREATE TABLE goal_views (goalId TEXT, status TEXT); + CREATE TABLE decision_views (decisionId TEXT, status TEXT); + CREATE TABLE architecture_views (architectureId TEXT, deprecated INTEGER); + CREATE TABLE component_views (componentId TEXT, status TEXT); + CREATE TABLE dependency_views (dependencyId TEXT, status TEXT); + CREATE TABLE guideline_views (guidelineId TEXT, isRemoved INTEGER); + CREATE TABLE project_views (projectId TEXT); + CREATE TABLE audience_views (audienceId TEXT, isRemoved INTEGER); + CREATE TABLE invariant_views (invariantId TEXT); + CREATE TABLE audience_pain_views (painId TEXT, status TEXT); + CREATE TABLE value_proposition_views (valuePropositionId TEXT); + CREATE TABLE relation_views (relationId TEXT, status TEXT); + + INSERT INTO session_views VALUES ('session-1', 'ended'); + INSERT INTO goal_views VALUES ('goal-1', 'done'); + INSERT INTO decision_views VALUES ('decision-1', 'reversed'); + INSERT INTO architecture_views VALUES ('architecture-1', 1); + INSERT INTO component_views VALUES ('component-1', 'deprecated'), ('component-removed', 'removed'); + INSERT INTO dependency_views VALUES ('dependency-1', 'deprecated'), ('dependency-removed', 'removed'); + INSERT INTO guideline_views VALUES ('guideline-1', 0), ('guideline-removed', 1); + INSERT INTO project_views VALUES ('project-1'); + INSERT INTO audience_views VALUES ('audience-1', 0), ('audience-removed', 1); + INSERT INTO invariant_views VALUES ('invariant-1'); + INSERT INTO audience_pain_views VALUES ('pain-1', 'resolved'); + INSERT INTO value_proposition_views VALUES ('value-1'); + INSERT INTO relation_views VALUES ('relation-1', 'deactivated'), ('relation-removed', 'removed'); + `); + catalog = new SqliteRelationNodeCatalog(db); + }); + + afterEach(() => db.close()); + + it("has compile-time and runtime coverage for every relation endpoint EntityType", () => { + expect(catalog.getSupportedEntityTypes()).toEqual([...Object.values(EntityType)].sort()); + }); + + it("reads only typed identities and maps each projection lifecycle consistently", async () => { + const entries = await catalog.findAll(); + + expect(entries).toEqual(expect.arrayContaining([ + { entityType: "session", entityId: "session-1", lifecycleState: "ended", isCurrent: true }, + { entityType: "goal", entityId: "goal-1", lifecycleState: "done", isCurrent: true }, + { entityType: "decision", entityId: "decision-1", lifecycleState: "reversed", isCurrent: true }, + { entityType: "architecture", entityId: "architecture-1", lifecycleState: "deprecated", isCurrent: true }, + { entityType: "component", entityId: "component-1", lifecycleState: "deprecated", isCurrent: true }, + { entityType: "dependency", entityId: "dependency-1", lifecycleState: "deprecated", isCurrent: true }, + { entityType: "guideline", entityId: "guideline-1", lifecycleState: "active", isCurrent: true }, + { entityType: "project", entityId: "project-1", lifecycleState: "active", isCurrent: true }, + { entityType: "audience", entityId: "audience-1", lifecycleState: "active", isCurrent: true }, + { entityType: "invariant", entityId: "invariant-1", lifecycleState: "active", isCurrent: true }, + { entityType: "pain", entityId: "pain-1", lifecycleState: "resolved", isCurrent: true }, + { entityType: "value", entityId: "value-1", lifecycleState: "active", isCurrent: true }, + { entityType: "relation", entityId: "relation-1", lifecycleState: "deactivated", isCurrent: true }, + ])); + expect(entries.filter((entry) => !entry.isCurrent).map((entry) => `${entry.entityType}:${entry.entityId}`)) + .toEqual([ + "audience:audience-removed", + "component:component-removed", + "dependency:dependency-removed", + "guideline:guideline-removed", + "relation:relation-removed", + ]); + expect(entries).toEqual([...entries].sort((left, right) => + left.entityType.localeCompare(right.entityType) || left.entityId.localeCompare(right.entityId), + )); + }); +}); diff --git a/tests/infrastructure/host/HostBuilder.test.ts b/tests/infrastructure/host/HostBuilder.test.ts new file mode 100644 index 00000000..b25bc502 --- /dev/null +++ b/tests/infrastructure/host/HostBuilder.test.ts @@ -0,0 +1,49 @@ +import Database from "better-sqlite3"; +import fs from "fs-extra"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { AuditRelationsController } from "../../../src/application/context/relations/audit/AuditRelationsController.js"; +import { HostBuilder } from "../../../src/infrastructure/host/HostBuilder.js"; +import { SqliteRelationNodeCatalog } from "../../../src/infrastructure/context/relations/audit/SqliteRelationNodeCatalog.js"; +import { MigrationRunner } from "../../../src/infrastructure/persistence/MigrationRunner.js"; +import { getNamespaceMigrations } from "../../../src/infrastructure/persistence/migrations.config.js"; + +const currentFile = fileURLToPath(import.meta.url); +const currentDirectory = path.dirname(currentFile); + +describe("HostBuilder relation audit wiring", () => { + let tempDirectory: string; + let db: Database.Database; + + beforeEach(async () => { + tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "jumbo-host-builder-audit-")); + db = new Database(":memory:"); + const infrastructureDirectory = path.resolve(currentDirectory, "../../../src/infrastructure"); + jest.spyOn(console, "error").mockImplementation(() => {}); + new MigrationRunner(db).runNamespaceMigrations(getNamespaceMigrations(infrastructureDirectory)); + }); + + afterEach(async () => { + db.close(); + await fs.remove(tempDirectory); + jest.restoreAllMocks(); + }); + + it("exposes the node catalog and a usable read-only audit controller", async () => { + const container = await new HostBuilder(tempDirectory, db).build(); + + expect(container.relationNodeCatalog).toBeInstanceOf(SqliteRelationNodeCatalog); + expect(container.auditRelationsController).toBeInstanceOf(AuditRelationsController); + await expect(container.auditRelationsController.handle({ checks: ["summary"] })).resolves.toEqual( + expect.objectContaining({ + requestedChecks: ["summary"], + summary: expect.objectContaining({ + nodes: { count: 0, byEntityType: {} }, + relations: expect.objectContaining({ count: 0 }), + }), + }), + ); + }); +}); diff --git a/tests/presentation/cli/command-compliance.test.ts b/tests/presentation/cli/command-compliance.test.ts index 830d8f59..2ee38491 100644 --- a/tests/presentation/cli/command-compliance.test.ts +++ b/tests/presentation/cli/command-compliance.test.ts @@ -119,4 +119,18 @@ describe("Command Compliance", () => { commandPath: "index rebuild", }); }); + + test("relations audit command declares project scope and selectable checks", () => { + const command = commands.find((candidate) => candidate.path === "relations audit"); + + expect(command).toBeDefined(); + expect(command?.metadata.requiresProject).toBe(true); + expect(command?.metadata.options?.map((option) => option.flags)).toEqual([ + "-c, --check ", + "--entity-type ", + ]); + expect(command?.metadata.examples?.map((example) => example.command)).toContain( + "jumbo relations audit --format json", + ); + }); }); diff --git a/tests/presentation/cli/commands/relations/audit/RelationAuditOutputBuilder.test.ts b/tests/presentation/cli/commands/relations/audit/RelationAuditOutputBuilder.test.ts new file mode 100644 index 00000000..fad3f085 --- /dev/null +++ b/tests/presentation/cli/commands/relations/audit/RelationAuditOutputBuilder.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "@jest/globals"; +import { RelationAuditResult } from "../../../../../../src/application/context/relations/audit/RelationAuditResult.js"; +import { RelationAuditOutputBuilder } from "../../../../../../src/presentation/cli/commands/relations/audit/RelationAuditOutputBuilder.js"; + +const result: RelationAuditResult = { + requestedChecks: ["dangling", "isolated", "inactive-only", "ambiguous-id", "summary"], + entityType: null, + summary: { + nodes: { count: 2, byEntityType: { component: 1, goal: 1 } }, + relations: { + count: 2, + byRelationType: { involves: 2 }, + byStrength: { strong: 1, unspecified: 1 }, + byStatus: { active: 1, deactivated: 1 }, + }, + }, + findings: { + dangling: { + count: 1, + items: [{ + relationId: "relation-1", + from: { entityType: "goal", entityId: "goal-1" }, + to: { entityType: "component", entityId: "missing" }, + missingEndpoints: [{ entityType: "component", entityId: "missing" }], + }], + }, + isolated: { + count: 1, + items: [{ entityType: "goal", entityId: "goal-alone", lifecycleState: "todo" }], + }, + inactiveOnly: { + count: 1, + items: [{ + entityType: "component", + entityId: "component-old", + lifecycleState: "deprecated", + relationIds: ["relation-old"], + }], + }, + ambiguousId: { + count: 1, + items: [{ entityId: "shared", entityTypes: ["component", "goal"] }], + }, + }, +}; + +describe("RelationAuditOutputBuilder", () => { + it("uses stable headings and actionable typed IDs in text output", () => { + const text = new RelationAuditOutputBuilder().build(result).toHumanReadable(); + + expect(text).toContain("Summary"); + expect(text).toContain("Dangling relations (1)"); + expect(text).toContain("relation-1: missing component:missing"); + expect(text).toContain("Isolated entities (1)"); + expect(text).toContain("goal:goal-alone [todo]"); + expect(text).toContain("Inactive-only entities (1)"); + expect(text).toContain("component:component-old [deprecated] relations: relation-old"); + expect(text).toContain("Ambiguous entity IDs (1)"); + expect(text).toContain("component:shared, goal:shared"); + }); + + it("emits a stable structured summary and finding collection contract", () => { + const output = new RelationAuditOutputBuilder().buildStructuredOutput(result); + const content = output.getSections().find((section) => section.type === "data")?.content; + + expect(content).toEqual({ + requestedChecks: result.requestedChecks, + filter: { entityType: null }, + summary: result.summary, + findings: result.findings, + }); + expect(Object.keys(content as object)).toEqual(["requestedChecks", "filter", "summary", "findings"]); + }); + + it("renders only requested check headings", () => { + const text = new RelationAuditOutputBuilder().build({ + ...result, + requestedChecks: ["dangling"], + }).toHumanReadable(); + + expect(text).toContain("Dangling relations"); + expect(text).not.toContain("Summary"); + expect(text).not.toContain("Isolated entities"); + }); + + it("keeps unrequested structured finding collections empty and stable", () => { + const selected: RelationAuditResult = { + ...result, + requestedChecks: ["summary"], + findings: { + dangling: { count: 0, items: [] }, + isolated: { count: 0, items: [] }, + inactiveOnly: { count: 0, items: [] }, + ambiguousId: { count: 0, items: [] }, + }, + }; + const content = new RelationAuditOutputBuilder() + .buildStructuredOutput(selected) + .getSections() + .find((section) => section.type === "data")?.content as Record; + + expect(content.findings).toEqual(selected.findings); + }); +}); diff --git a/tests/presentation/cli/commands/relations/audit/relations.audit.test.ts b/tests/presentation/cli/commands/relations/audit/relations.audit.test.ts new file mode 100644 index 00000000..49f56127 --- /dev/null +++ b/tests/presentation/cli/commands/relations/audit/relations.audit.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { AuditRelationsController } from "../../../../../../src/application/context/relations/audit/AuditRelationsController.js"; +import { IApplicationContainer } from "../../../../../../src/application/host/IApplicationContainer.js"; +import { relationsAudit } from "../../../../../../src/presentation/cli/commands/relations/audit/relations.audit.js"; +import { Renderer } from "../../../../../../src/presentation/cli/rendering/Renderer.js"; + +describe("relations.audit command", () => { + let handle: jest.Mock; + let container: Partial; + let consoleSpy: jest.SpiedFunction; + + beforeEach(() => { + Renderer.configure({ format: "text", verbosity: "normal" }); + handle = jest.fn().mockResolvedValue({ + requestedChecks: ["summary"], + entityType: null, + summary: { + nodes: { count: 0, byEntityType: {} }, + relations: { count: 0, byRelationType: {}, byStrength: {}, byStatus: {} }, + }, + findings: { + dangling: { count: 0, items: [] }, + isolated: { count: 0, items: [] }, + inactiveOnly: { count: 0, items: [] }, + ambiguousId: { count: 0, items: [] }, + }, + }); + container = { + auditRelationsController: { handle } as unknown as AuditRelationsController, + }; + consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + Renderer.reset(); + }); + + it("runs every check by default", async () => { + await relationsAudit({}, container as IApplicationContainer); + expect(handle).toHaveBeenCalledWith({ checks: undefined, entityType: undefined }); + }); + + it("passes selected checks and the entity type filter", async () => { + await relationsAudit( + { check: ["dangling", "inactive-only"], entityType: "component" }, + container as IApplicationContainer, + ); + expect(handle).toHaveBeenCalledWith({ + checks: ["dangling", "inactive-only"], + entityType: "component", + }); + }); + + it("normalizes check names case-insensitively", async () => { + await relationsAudit({ check: "ambiguous-ID" }, container as IApplicationContainer); + expect(handle).toHaveBeenCalledWith({ checks: ["ambiguous-id"], entityType: undefined }); + }); + + it("emits exactly one valid JSON object", async () => { + Renderer.configure({ format: "json", verbosity: "normal" }); + await relationsAudit({ check: "summary" }, container as IApplicationContainer); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(consoleSpy.mock.calls[0][0]))).toEqual(expect.objectContaining({ + requestedChecks: ["summary"], + summary: expect.any(Object), + findings: expect.any(Object), + })); + }); +});