diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6ad95d9..5bba9a65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,16 @@ jobs: - name: Apply Prisma migrations run: pnpm --dir apps/api exec prisma migrate deploy --config prisma.config.ts --schema prisma/schema.prisma + - name: Domain pack governance + run: pnpm verify:domain-packs + + - name: Worker boundary check (no api imports) + run: | + if grep -rE 'from.*(@ba-helper/api|apps/api/)' apps/worker/src/; then + echo "BOUNDARY VIOLATION: worker must not import from apps/api" + exit 1 + fi + - name: Lint run: pnpm lint diff --git a/AGENTS.md b/AGENTS.md index 870f3c5e..a51ee47c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,23 +51,36 @@ Read: ui-tech-stack.md, ui-design.md, css-ownership.md For Prisma/state/API changes: Update docs + contracts + tests before completion. -## Current Delivery Focus +## v0.1 Status: Foundation Complete -Work is currently focused on: +The following foundational capabilities are complete and should not be re-implemented: ```text -1. Scan pipeline atomicity and snapshot publication safety -2. Evidence quality scoring and weak/missing evidence detection -3. Impact precision evaluation packs -4. Review coverage gates -5. Report trust UX and provenance visibility -6. Snapshot drift/freshness and public beta hardening +✅ Scan pipeline atomicity and snapshot publication safety +✅ Evidence quality scoring and weak/missing evidence detection +✅ Impact precision evaluation packs +✅ Review coverage gates (deterministic gate IDs) +✅ Report trust UX and provenance visibility +✅ Snapshot drift/freshness and public beta hardening +✅ Worker/API boundary separation (AiModule, PrismaModule worker-local) +✅ Domain-pack governance in CI +✅ Typed worker error classification ``` Do not add new domains as the center of gravity. Domain packs are controlled terminology/risk/QA hint layers. Evidence is the source of truth and human review is the final authority. +## Post-v0.1 Backlog (Parking Lot — Do Not Implement Without Explicit Scope) + +```text +- Extract infrastructure repository classes from apps/api to shared backend package +- Move RunScanJobUseCase, RunDocumentJobUseCase to @ba-helper/application +- Multi-tenant organizationId boundary +- Private repository OAuth integration +- Scanner maturity gates for non-NestJS frameworks +``` + ## Instruction Loading And Workflow This file is the repository-level instruction source. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index b9bc9798..eb4b2e0d 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -10,6 +10,8 @@ COPY apps/web/package.json apps/web/package.json COPY packages/contracts/package.json packages/contracts/package.json COPY packages/shared/package.json packages/shared/package.json COPY packages/analyzer/package.json packages/analyzer/package.json +COPY packages/backend-runtime/package.json packages/backend-runtime/package.json +COPY packages/application/package.json packages/application/package.json RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ sh -c "pnpm config set store-dir /pnpm/store && if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile --ignore-scripts; else pnpm install --ignore-scripts; fi" @@ -19,12 +21,16 @@ ENV CI=true COPY --from=deps /app/node_modules /app/node_modules COPY --from=deps /app/apps/api/node_modules /app/apps/api/node_modules COPY --from=deps /app/packages/analyzer/node_modules /app/packages/analyzer/node_modules +COPY --from=deps /app/packages/backend-runtime/node_modules /app/packages/backend-runtime/node_modules +COPY --from=deps /app/packages/application/node_modules /app/packages/application/node_modules COPY . . WORKDIR /app/apps/api RUN /app/apps/api/node_modules/.bin/prisma generate --schema /app/apps/api/prisma/schema.prisma RUN /app/node_modules/.bin/tsc -p /app/packages/contracts/tsconfig.json \ && /app/node_modules/.bin/tsc -p /app/packages/shared/tsconfig.json \ && /app/node_modules/.bin/tsc -p /app/packages/analyzer/tsconfig.json \ + && /app/node_modules/.bin/tsc -p /app/packages/application/tsconfig.json \ + && /app/node_modules/.bin/tsc -p /app/packages/backend-runtime/tsconfig.json \ && /app/node_modules/.bin/tsc -p /app/apps/api/tsconfig.build.json FROM base AS runtime @@ -35,6 +41,8 @@ WORKDIR /app COPY --from=build /app/node_modules /app/node_modules COPY --from=build /app/apps/api/node_modules /app/apps/api/node_modules COPY --from=build /app/packages/analyzer/node_modules /app/packages/analyzer/node_modules +COPY --from=build /app/packages/backend-runtime/node_modules /app/packages/backend-runtime/node_modules +COPY --from=build /app/packages/application/node_modules /app/packages/application/node_modules COPY --from=build /app/apps/api/prisma /app/apps/api/prisma COPY --from=build /app/apps/api/prisma.config.ts /app/apps/api/prisma.config.ts COPY --from=build /app/packages /app/packages diff --git a/apps/api/package.json b/apps/api/package.json index ad03d096..6eff4358 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,6 +20,7 @@ "@anthropic-ai/sdk": "^0.99.0", "@ba-helper/analyzer": "workspace:*", "@ba-helper/application": "workspace:*", + "@ba-helper/backend-runtime": "workspace:*", "@ba-helper/contracts": "workspace:*", "@google/generative-ai": "^0.24.1", "@nestjs/bullmq": "11.0.4", diff --git a/apps/api/prisma/migrations/20260627170000_domain_pack_first_class_provenance/migration.sql b/apps/api/prisma/migrations/20260627170000_domain_pack_first_class_provenance/migration.sql index 3d5c6b33..967e2abb 100644 --- a/apps/api/prisma/migrations/20260627170000_domain_pack_first_class_provenance/migration.sql +++ b/apps/api/prisma/migrations/20260627170000_domain_pack_first_class_provenance/migration.sql @@ -52,7 +52,7 @@ WHERE "metadata" ? 'selectedDomainPack'; -- Backfill multi-repo runs from the first child analysis. v1 creates child -- analyses with the same explicit run-level selection when a run-level pack is -- requested; mixed or legacy runs retain conservative defaults. -UPDATE "MultiRepoAnalysisRun" AS run +UPDATE "MultiRepoAnalysisRun" SET "requestedDomainPackId" = child."requestedDomainPackId", "resolvedDomainPackId" = child."resolvedDomainPackId", @@ -62,22 +62,22 @@ SET "domainPackResolvedAt" = child."domainPackResolvedAt", "domainPackManifestDigest" = child."domainPackManifestDigest", "domainPackRegistryVersion" = child."domainPackRegistryVersion" -FROM LATERAL ( - SELECT - analysis."requestedDomainPackId", - analysis."resolvedDomainPackId", - analysis."resolvedDomainPackVersion", - analysis."resolvedDomainPackStatus", - analysis."domainPackSelectedBy", - analysis."domainPackResolvedAt", - analysis."domainPackManifestDigest", - analysis."domainPackRegistryVersion" - FROM "ImpactAnalysis" AS analysis - WHERE analysis."multiRepoRunId" = run."id" - ORDER BY analysis."createdAt" ASC - LIMIT 1 +FROM ( + SELECT DISTINCT ON ("multiRepoRunId") + "multiRepoRunId", + "requestedDomainPackId", + "resolvedDomainPackId", + "resolvedDomainPackVersion", + "resolvedDomainPackStatus", + "domainPackSelectedBy", + "domainPackResolvedAt", + "domainPackManifestDigest", + "domainPackRegistryVersion" + FROM "ImpactAnalysis" + ORDER BY "multiRepoRunId", "createdAt" ASC ) AS child -WHERE child."resolvedDomainPackId" IS NOT NULL; +WHERE "MultiRepoAnalysisRun"."id" = child."multiRepoRunId" + AND child."resolvedDomainPackId" IS NOT NULL; -- CreateIndex CREATE INDEX "ImpactAnalysis_resolvedDomainPackId_resolvedDomainPackVersion_idx" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a426fa6f..2eb51f4c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -147,6 +147,7 @@ enum DocumentType { enum DocumentStatus { DRAFT APPROVED + /// @description STALE is a projection-only state derived at runtime. It is never persisted to the database. STALE } @@ -157,6 +158,12 @@ enum DocumentJobStatus { FAILED } +enum LocalizationStatus { + QUEUED + COMPLETED + FAILED +} + enum ClarificationStatus { OPEN ANSWERED @@ -678,11 +685,42 @@ model GeneratedDocument { updatedAt DateTime @updatedAt reviewedSnapshots ReviewedReportSnapshot[] documentJobs DocumentJob[] + localizedArtifacts LocalizedReportArtifact[] @@unique([impactAnalysisId, type, status]) @@index([impactAnalysisId, status]) } +model LocalizedReportArtifact { + id String @id @default(uuid()) + sourceDocumentId String + sourceDocument GeneratedDocument @relation(fields: [sourceDocumentId], references: [id], onDelete: Cascade) + + locale String // BCP-47 locale tag, e.g. "vi-VN" + sourceLocale String @default("en") + localizationStatus LocalizationStatus + + contentMarkdown String? + sourceContentHash String // Computed from canonical JSON serialization + + // Metadata for tracing and debugging + glossaryVersion String? + provider String? + model String? + translationPromptVersion String? + structuralValidatorVersion String? + fieldPolicyVersion String? + + errorCode String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([sourceDocumentId, locale]) + @@index([sourceDocumentId]) + @@index([locale]) + @@index([localizationStatus]) +} + model DocumentJob { id String @id @default(uuid()) analysisId String @map("analysis_id") diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b866bd1b..153853fa 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -16,6 +16,7 @@ import { QueueModule } from './modules/queue/queue.module'; import { AiModule } from './modules/ai/ai.module'; import { SystemModule } from './modules/system/system.module'; import { ClarificationModule } from './modules/clarification/clarification.module'; +import { ApiLocalizationModule } from './modules/localization/localization.module'; import { AuthModule } from './modules/auth/auth.module'; import { JwtAuthGuard } from './modules/auth/application/jwt-auth.guard'; @@ -42,6 +43,7 @@ import { PublicBetaRateLimitPolicy } from './shared/rate-limit/public-beta-rate- QueueModule, SystemModule, ClarificationModule, + ApiLocalizationModule, AiModule.forRoot(), ], providers: [ diff --git a/apps/api/src/modules/document/application/queries/get-final-reviewed-report.usecase.spec.ts b/apps/api/src/modules/document/application/queries/get-final-reviewed-report.usecase.spec.ts index 4bc13104..3730945d 100644 --- a/apps/api/src/modules/document/application/queries/get-final-reviewed-report.usecase.spec.ts +++ b/apps/api/src/modules/document/application/queries/get-final-reviewed-report.usecase.spec.ts @@ -6,8 +6,6 @@ describe('GetFinalReviewedReportUseCase', () => { let getReviewCompletionMock: any; let getLatestSnapshotMock: any; let prismaMock: any; - let contextAdapterMock: any; - let reportBuilderMock: any; beforeEach(() => { getReviewCompletionMock = { @@ -27,19 +25,10 @@ describe('GetFinalReviewedReportUseCase', () => { findUnique: jest.fn(), }, }; - contextAdapterMock = { - buildContext: jest.fn(), - }; - reportBuilderMock = { - build: jest.fn(), - }; - useCase = new GetFinalReviewedReportUseCase( getReviewCompletionMock as any, getLatestSnapshotMock as any, prismaMock as any, - contextAdapterMock as any, - reportBuilderMock as any, ); }); @@ -144,78 +133,116 @@ describe('GetFinalReviewedReportUseCase', () => { expect(result.locale).toBe('en'); }); - it('renders localized markdown from the reviewed snapshot after default document readiness is satisfied', async () => { - const mockCompletion = { - isComplete: true, - blockingReasons: [], - }; - const mockSnapshot = { + it('throws LOCALIZED_REPORT_NOT_READY when localized artifact is missing', async () => { + getReviewCompletionMock.execute.mockResolvedValue({ isComplete: true, blockingReasons: [] }); + getLatestSnapshotMock.execute.mockResolvedValue({ id: 'snap-1', analysisId: 'analysis-123', approvedDocumentId: 'doc-1', markdown: null, - createdAt: new Date('2026-01-01T00:00:00.000Z'), - reviewDecisionsSnapshot: [], - evidenceQualitySummarySnapshot: {}, - evaluationContextSnapshot: null, - createdByUserId: null, - }; - const mockAnalysis = { id: 'analysis-123' }; - const mockContext = { locale: 'vi' }; - - getReviewCompletionMock.execute.mockResolvedValue(mockCompletion); - getLatestSnapshotMock.execute.mockResolvedValue(mockSnapshot); + createdAt: new Date(), + }); prismaMock.generatedDocument.findUnique.mockResolvedValue({ id: 'doc-1', - content: '# English persisted document', + content: '# English', }); - prismaMock.impactAnalysis.findUnique.mockResolvedValue(mockAnalysis); - contextAdapterMock.buildContext.mockResolvedValue(mockContext); - reportBuilderMock.build.mockReturnValue('# Bao cao tieng Viet\n```ts\nconsole.log("raw evidence");\n```'); - - const result = await useCase.execute('analysis-123', { locale: 'vi' }); + prismaMock.localizedReportArtifact = { + findUnique: jest.fn().mockResolvedValue(null), + }; - expect(result.locale).toBe('vi'); - expect(result.markdown).toContain('# Bao cao tieng Viet'); - expect(result.markdown).toContain('console.log("raw evidence");'); - expect(contextAdapterMock.buildContext).toHaveBeenCalledWith(mockSnapshot, mockAnalysis, 'vi'); - expect(reportBuilderMock.build).toHaveBeenCalledWith(mockContext); + await expect(useCase.execute('analysis-123', { locale: 'vi-VN' as any })).rejects.toMatchObject({ + code: 'LOCALIZED_REPORT_NOT_READY', + }); }); - it('throws a document readiness error when the async document job is still queued', async () => { - getReviewCompletionMock.execute.mockResolvedValue({ - isComplete: true, - blockingReasons: [], + it('throws LOCALIZED_REPORT_FAILED when localization failed', async () => { + getReviewCompletionMock.execute.mockResolvedValue({ isComplete: true, blockingReasons: [] }); + getLatestSnapshotMock.execute.mockResolvedValue({ + id: 'snap-1', + analysisId: 'analysis-123', + approvedDocumentId: 'doc-1', + markdown: null, + createdAt: new Date(), + }); + prismaMock.generatedDocument.findUnique.mockResolvedValue({ id: 'doc-1', content: '# English' }); + prismaMock.localizedReportArtifact = { + findUnique: jest.fn().mockResolvedValue({ localizationStatus: 'FAILED' }), + }; + + await expect(useCase.execute('analysis-123', { locale: 'vi-VN' as any })).rejects.toMatchObject({ + code: 'LOCALIZED_REPORT_FAILED', }); + }); + + it('throws LOCALIZED_REPORT_OUT_OF_SYNC when hashes do not match', async () => { + getReviewCompletionMock.execute.mockResolvedValue({ isComplete: true, blockingReasons: [] }); getLatestSnapshotMock.execute.mockResolvedValue({ id: 'snap-1', - approvedDocumentId: null, + analysisId: 'analysis-123', + approvedDocumentId: 'doc-1', markdown: null, - createdAt: new Date('2026-01-01T00:00:00.000Z'), - reviewDecisionsSnapshot: [], - evidenceQualitySummarySnapshot: {}, - evaluationContextSnapshot: null, - createdByUserId: null, + createdAt: new Date(), }); - prismaMock.documentJob.findFirst.mockResolvedValue({ - id: 'job-1', - status: 'QUEUED', - failedAt: null, - error: null, + prismaMock.generatedDocument.findUnique.mockResolvedValue({ id: 'doc-1', content: '# English' }); + prismaMock.localizedReportArtifact = { + findUnique: jest.fn().mockResolvedValue({ + localizationStatus: 'COMPLETED', + contentMarkdown: '# Vietnamese', + sourceContentHash: 'old-hash', + }), + }; + prismaMock.impactAnalysis.findUnique.mockResolvedValue({ id: 'analysis-123', snapshot: { id: 'snap-1' }, requirementRevision: { id: 'rev-1' } }); + prismaMock.baInsight = { findMany: jest.fn().mockResolvedValue([]) }; + prismaMock.traceabilityLink = { findMany: jest.fn().mockResolvedValue([]) }; + prismaMock.reviewNote = { findMany: jest.fn().mockResolvedValue([]) }; + prismaMock.clarificationItem = { findMany: jest.fn().mockResolvedValue([]) }; + + await expect(useCase.execute('analysis-123', { locale: 'vi-VN' as any })).rejects.toMatchObject({ + code: 'LOCALIZED_REPORT_OUT_OF_SYNC', }); + }); - await expect(useCase.execute('analysis-123')).rejects.toMatchObject({ - code: 'DOCUMENT_JOB_NOT_READY', - details: { - documentJobId: 'job-1', - status: 'QUEUED', - }, + it('returns localized markdown when artifact is completed and hashes match', async () => { + getReviewCompletionMock.execute.mockResolvedValue({ isComplete: true, blockingReasons: [] }); + getLatestSnapshotMock.execute.mockResolvedValue({ + id: 'snap-1', + analysisId: 'analysis-123', + approvedDocumentId: 'doc-1', + markdown: null, + createdAt: new Date(), }); + prismaMock.generatedDocument.findUnique.mockResolvedValue({ id: 'doc-1', content: '# English' }); + prismaMock.impactAnalysis.findUnique.mockResolvedValue({ id: 'analysis-123', snapshot: { id: 'snap-1' }, requirementRevision: { id: 'rev-1' } }); + prismaMock.baInsight = { findMany: jest.fn().mockResolvedValue([]) }; + prismaMock.traceabilityLink = { findMany: jest.fn().mockResolvedValue([]) }; + prismaMock.reviewNote = { findMany: jest.fn().mockResolvedValue([]) }; + prismaMock.clarificationItem = { findMany: jest.fn().mockResolvedValue([]) }; + + // Compute expected hash for an empty context to mock correctly + const { computeCanonicalReportHash } = require('@ba-helper/backend-runtime'); + const mockContext = { + analysis: { id: 'analysis-123', snapshot: { id: 'snap-1' }, requirementRevision: { id: 'rev-1' } }, + locale: 'en', + insights: [], + traceabilityLinks: [], + reviewNotes: [], + clarifications: [], + }; + const expectedHash = computeCanonicalReportHash(mockContext); + + prismaMock.localizedReportArtifact = { + findUnique: jest.fn().mockResolvedValue({ + localizationStatus: 'COMPLETED', + contentMarkdown: '# Vietnamese Report', + sourceContentHash: expectedHash, + }), + }; + + const result = await useCase.execute('analysis-123', { locale: 'vi-VN' as any }); + expect(result.markdown).toBe('# Vietnamese Report'); }); it('does not call retrieval/analysis generation dependencies', async () => { - // Verified implicitly because the only dependencies are getReviewCompletion and getLatestSnapshot - // There are no retrieval or LLM dependencies injected. expect(useCase).toBeDefined(); }); }); diff --git a/apps/api/src/modules/document/application/queries/get-final-reviewed-report.usecase.ts b/apps/api/src/modules/document/application/queries/get-final-reviewed-report.usecase.ts index 590049d4..f7a979d5 100644 --- a/apps/api/src/modules/document/application/queries/get-final-reviewed-report.usecase.ts +++ b/apps/api/src/modules/document/application/queries/get-final-reviewed-report.usecase.ts @@ -5,10 +5,11 @@ import { GetReviewCompletionUseCase } from '../../../traceability/application/ge import { GetLatestReviewedReportSnapshotUseCase } from './get-latest-reviewed-report-snapshot.usecase'; import { FinalReviewedReportResponse } from '@ba-helper/contracts'; import { PrismaService } from '../../../prisma/prisma.service'; -import { ReviewedSnapshotReportContextAdapter } from '../render/reviewed-snapshot-report-context.adapter'; -import { MarkdownImpactReportBuilder } from '../render/markdown-impact-report.builder'; + import { DEFAULT_REPORT_LOCALE, ReportLocale } from '../render/report-localization'; import { buildReportReviewCoverageSummaryFromSnapshot } from '../report-review-coverage.summary'; +import { computeCanonicalReportHash } from '@ba-helper/backend-runtime'; +import { MarkdownReportRenderContext } from '../markdown-impact-report.types'; @Injectable() export class GetFinalReviewedReportUseCase { @@ -16,8 +17,6 @@ export class GetFinalReviewedReportUseCase { private readonly getReviewCompletion: GetReviewCompletionUseCase, private readonly getLatestSnapshot: GetLatestReviewedReportSnapshotUseCase, private readonly prisma: PrismaService, - private readonly contextAdapter: ReviewedSnapshotReportContextAdapter, - private readonly reportBuilder: MarkdownImpactReportBuilder, ) {} async execute( @@ -69,13 +68,91 @@ export class GetFinalReviewedReportUseCase { analysisId: string; approvedDocumentId: string | null; markdown: string | null; + reviewDecisionsSnapshot: any; + evidenceQualitySummarySnapshot: any; }, locale: ReportLocale) { const defaultMarkdown = await this.resolveDefaultSnapshotMarkdown(snapshot); if (locale === DEFAULT_REPORT_LOCALE) { return defaultMarkdown; } - return this.renderLocalizedSnapshotMarkdown(snapshot, locale); + if (!snapshot.approvedDocumentId) { + throw new AppError('LOCALIZED_REPORT_NOT_READY', 'The canonical report has not been approved yet.'); + } + + const localized = await this.prisma.localizedReportArtifact.findUnique({ + where: { + sourceDocumentId_locale: { + sourceDocumentId: snapshot.approvedDocumentId, + locale, + } + } + }); + + if (!localized) { + throw new AppError('LOCALIZED_REPORT_NOT_READY', `Localized report for ${locale} is not ready yet.`); + } + + if (localized.localizationStatus === 'FAILED') { + throw new AppError('LOCALIZED_REPORT_FAILED', `Localization failed for ${locale}. Please retry.`); + } + + if (localized.localizationStatus !== 'COMPLETED' || !localized.contentMarkdown) { + throw new AppError('LOCALIZED_REPORT_NOT_READY', `Localization for ${locale} is still in progress.`); + } + + // Verify sourceContentHash + const analysis = await this.prisma.impactAnalysis.findUnique({ + where: { id: snapshot.analysisId }, + include: { + snapshot: { include: { repository: true, profile: true } }, + sourceTarget: true, + requirementRevision: true, + } + }); + + if (!analysis) { + throw new AppError('IMPACT_ANALYSIS_NOT_FOUND', 'Impact analysis not found.'); + } + + const insights = await this.prisma.baInsight.findMany({ + where: { impactAnalysisId: snapshot.analysisId }, + include: { evidenceLinks: { include: { evidence: true } } } + }); + + const traceabilityLinks = await this.prisma.traceabilityLink.findMany({ + where: { impactAnalysisId: snapshot.analysisId }, + include: { artifact: true, evidenceLinks: { include: { evidence: true } } } + }); + + const reviewNotes = await this.prisma.reviewNote.findMany({ + where: { impactAnalysisId: snapshot.analysisId } + }); + + const clarifications = await this.prisma.clarificationItem.findMany({ + where: { impactAnalysisId: snapshot.analysisId } + }); + + const canonicalContext: MarkdownReportRenderContext = { + analysis: analysis as any, + locale: 'en', + insights: insights as any, + traceabilityLinks: traceabilityLinks as any, + reviewNotes, + hasUnreviewedItems: false, + dependencyEdges: [], + clarifications: clarifications as any, + reviewDecisions: [], + reviewDecisionsSnapshot: snapshot.reviewDecisionsSnapshot, + evidenceQualitySummarySnapshot: snapshot.evidenceQualitySummarySnapshot, + }; + + const currentHash = computeCanonicalReportHash(canonicalContext); + if (localized.sourceContentHash !== currentHash) { + throw new AppError('LOCALIZED_REPORT_OUT_OF_SYNC', `Localized report for ${locale} is out of sync. Please regenerate.`); + } + + return localized.contentMarkdown; } private async resolveDefaultSnapshotMarkdown(snapshot: { @@ -129,25 +206,4 @@ export class GetFinalReviewedReportUseCase { ); } - private async renderLocalizedSnapshotMarkdown(snapshot: { - id: string; - analysisId: string; - }, locale: ReportLocale) { - const analysis = await this.prisma.impactAnalysis.findUnique({ - where: { id: snapshot.analysisId }, - include: { - snapshot: { include: { repository: true, profile: true } }, - sourceTarget: true, - requirementRevision: { include: { requirement: true } }, - insights: true, - }, - }); - - if (!analysis) { - throw new AppError('IMPACT_ANALYSIS_NOT_FOUND', 'Impact analysis not found.'); - } - - const context = await this.contextAdapter.buildContext(snapshot, analysis, locale); - return this.reportBuilder.build(context); - } } diff --git a/apps/api/src/modules/document/domain/document-status.invariant.spec.ts b/apps/api/src/modules/document/domain/document-status.invariant.spec.ts new file mode 100644 index 00000000..b5932358 --- /dev/null +++ b/apps/api/src/modules/document/domain/document-status.invariant.spec.ts @@ -0,0 +1,24 @@ +import { DocumentStatus } from '@prisma/client'; + +describe('DocumentStatus Invariants', () => { + it('PR-7: STALE must remain a projection-only state', () => { + // This test serves as a documentation invariant. + // If DocumentStatus enum changes, this ensures we remember STALE is projection-only. + // In our architecture, application code should never write DocumentStatus.STALE to the database. + + const validPersistedStates: DocumentStatus[] = [ + DocumentStatus.DRAFT, + DocumentStatus.APPROVED, + ]; + + expect(validPersistedStates).not.toContain(DocumentStatus.STALE); + + // Stale is dynamically computed by checking if the snapshot has drifted + // from the target's latest observed commit. + const projectionOnlyStates: DocumentStatus[] = [ + DocumentStatus.STALE, + ]; + + expect(projectionOnlyStates).toContain(DocumentStatus.STALE); + }); +}); diff --git a/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.spec.ts index 83073322..8d92edcb 100644 --- a/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.spec.ts +++ b/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.spec.ts @@ -7,7 +7,7 @@ import type { PrismaService } from '../../../prisma/prisma.service'; import type { EventLogService } from '../../../event-log/application/event-log.service'; import type { QueueService } from '../../../queue/queue.service'; import { Prisma } from '@prisma/client'; -import { DomainPackRegistry } from '../../../domain-pack/application/domain-pack.registry'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; describe('CreateImpactAnalysisUseCase', () => { let useCase: CreateImpactAnalysisUseCase; diff --git a/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.ts b/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.ts index 49f6563d..aeef0871 100644 --- a/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.ts +++ b/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.ts @@ -7,7 +7,7 @@ import { ImpactAnalysisPolicy } from '../../domain/impact-analysis.policy'; import { EventLogService } from '../../../event-log/application/event-log.service'; import { PrismaService } from '../../../prisma/prisma.service'; import { QueueService } from '../../../queue/queue.service'; -import { DomainPackRegistry } from '../../../domain-pack/application/domain-pack.registry'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; import type { ResolvedDomainPackSelection } from '@ba-helper/contracts'; diff --git a/apps/api/src/modules/impact-analysis/application/lifecycle/run-impact-analysis.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/lifecycle/run-impact-analysis.usecase.spec.ts index 931fff0e..5ddae07f 100644 --- a/apps/api/src/modules/impact-analysis/application/lifecycle/run-impact-analysis.usecase.spec.ts +++ b/apps/api/src/modules/impact-analysis/application/lifecycle/run-impact-analysis.usecase.spec.ts @@ -10,9 +10,9 @@ import type { EvidenceRepository } from '../../../evidence/infrastructure/eviden import type { InsightRepository } from '../../../insight/infrastructure/insight.repository'; import type { TraceabilityRepository } from '../../../traceability/infrastructure/traceability.repository'; import type { LlmProvider } from '../../../ai/domain/llm-provider.interface'; -import type { HybridRetrievalService } from '../../../retrieval/application/hybrid-retrieval.service'; +import type { HybridRetrievalService } from '@ba-helper/backend-runtime'; import { AppError } from '@ba-helper/shared'; -import type { DomainPackRegistry } from '../../../domain-pack/application/domain-pack.registry'; +import type { DomainPackRegistry } from '@ba-helper/backend-runtime'; describe('RunImpactAnalysisUseCase', () => { let useCase: RunImpactAnalysisUseCase; diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.spec.ts index 6f92879e..76c9d23e 100644 --- a/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.spec.ts +++ b/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.spec.ts @@ -1,5 +1,5 @@ import { CreateMultiRepoImpactAnalysesUseCase } from './create-multi-repo-impact-analyses.usecase'; -import { DomainPackRegistry } from '../../../domain-pack/application/domain-pack.registry'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; describe('CreateMultiRepoImpactAnalysesUseCase domain pack selection', () => { it('copies run-level explicit healthcare selection to all child analyses', async () => { diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.ts index 4e3cab2c..2fcebefc 100644 --- a/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.ts +++ b/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.ts @@ -6,7 +6,7 @@ import { CreateImpactAnalysisUseCase } from '../lifecycle/create-impact-analysis import { RequirementRepository } from '../../../requirement/infrastructure/requirement.repository'; import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository'; import { MultiRepoAnalysisRunRepository } from '../../infrastructure/multi-repo-analysis-run.repository'; -import { DomainPackRegistry } from '../../../domain-pack/application/domain-pack.registry'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; import type { ResolvedDomainPackSelection } from '@ba-helper/contracts'; type PlannedRepositoryAnalysis = { diff --git a/apps/api/src/modules/impact-analysis/application/review/get-review-coverage.usecase.ts b/apps/api/src/modules/impact-analysis/application/review/get-review-coverage.usecase.ts index 62d6c35f..b90bf12b 100644 --- a/apps/api/src/modules/impact-analysis/application/review/get-review-coverage.usecase.ts +++ b/apps/api/src/modules/impact-analysis/application/review/get-review-coverage.usecase.ts @@ -2,7 +2,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { ProjectPermissionService } from '../../../project/application/project-permission.service'; import { RequestUser, ReviewCoverageResponse, ReviewCoverageGate, ReviewCoverageStatus } from '@ba-helper/contracts'; -import { randomUUID } from 'crypto'; @Injectable() export class GetReviewCoverageUseCase { @@ -89,7 +88,7 @@ export class GetReviewCoverageUseCase { if (hasMissingOrRejectedDecision) { gates.push({ - gateId: randomUUID(), + gateId: `coverage-gate:${runId}:REVIEW_DECISION:FAIL`, category: 'REVIEW_DECISION', status: 'FAIL', title: 'Pending or Rejected Analyses', @@ -105,7 +104,7 @@ export class GetReviewCoverageUseCase { summary.blockingGates++; } else { gates.push({ - gateId: randomUUID(), + gateId: `coverage-gate:${runId}:REVIEW_DECISION:PASS`, category: 'REVIEW_DECISION', status: 'PASS', title: 'All Analyses Accepted', @@ -217,7 +216,7 @@ export class GetReviewCoverageUseCase { if (hasUncoveredArtifacts) { gates.push({ - gateId: randomUUID(), + gateId: `coverage-gate:${runId}:EVIDENCE_COVERAGE:WARN`, category: 'EVIDENCE_COVERAGE', status: 'WARN', title: 'Impacted Artifacts Missing Evidence', @@ -231,7 +230,7 @@ export class GetReviewCoverageUseCase { summary.warningGates++; } else { gates.push({ - gateId: randomUUID(), + gateId: `coverage-gate:${runId}:EVIDENCE_COVERAGE:PASS`, category: 'EVIDENCE_COVERAGE', status: 'PASS', title: 'All Artifacts Have Evidence', @@ -246,7 +245,7 @@ export class GetReviewCoverageUseCase { if (hasUncoveredRisks) { gates.push({ - gateId: randomUUID(), + gateId: `coverage-gate:${runId}:QA_COVERAGE:WARN`, category: 'QA_COVERAGE', status: 'WARN', title: 'Risks Missing QA Scenarios', @@ -260,7 +259,7 @@ export class GetReviewCoverageUseCase { summary.warningGates++; } else { gates.push({ - gateId: randomUUID(), + gateId: `coverage-gate:${runId}:QA_COVERAGE:PASS`, category: 'QA_COVERAGE', status: 'PASS', title: 'All Risks Covered by QA', @@ -275,7 +274,7 @@ export class GetReviewCoverageUseCase { if (hasRepoWithRisksButNoQa) { gates.push({ - gateId: randomUUID(), + gateId: `coverage-gate:${runId}:RISK_COVERAGE:WARN`, category: 'RISK_COVERAGE', status: 'WARN', title: 'Repositories with Risks but No QA', @@ -291,7 +290,7 @@ export class GetReviewCoverageUseCase { summary.warningGates++; } else { gates.push({ - gateId: randomUUID(), + gateId: `coverage-gate:${runId}:RISK_COVERAGE:PASS`, category: 'RISK_COVERAGE', status: 'PASS', title: 'No Repositories with Uncovered Risks', @@ -306,7 +305,7 @@ export class GetReviewCoverageUseCase { if (hasAcceptedWithZeroArtifacts) { gates.push({ - gateId: randomUUID(), + gateId: `coverage-gate:${runId}:REPOSITORY_READINESS:WARN`, category: 'REPOSITORY_READINESS', status: 'WARN', title: 'Accepted Analysis with Zero Artifacts', @@ -322,7 +321,7 @@ export class GetReviewCoverageUseCase { summary.warningGates++; } else { gates.push({ - gateId: randomUUID(), + gateId: `coverage-gate:${runId}:REPOSITORY_READINESS:PASS`, category: 'REPOSITORY_READINESS', status: 'PASS', title: 'All Accepted Analyses Have Impact', diff --git a/apps/api/src/modules/impact-analysis/impact-analysis.module.ts b/apps/api/src/modules/impact-analysis/impact-analysis.module.ts index 2eed4dc6..15d932a8 100644 --- a/apps/api/src/modules/impact-analysis/impact-analysis.module.ts +++ b/apps/api/src/modules/impact-analysis/impact-analysis.module.ts @@ -67,8 +67,8 @@ import { QueueModule } from '../queue/queue.module'; import { QueueService } from '../queue/queue.service'; import { AiModule } from '../ai/ai.module'; import { LlmProvider } from '../ai/domain/llm-provider.interface'; -import { RetrievalModule } from '../retrieval/retrieval.module'; -import { HybridRetrievalService } from '../retrieval/application/hybrid-retrieval.service'; +import { RetrievalModule } from '@ba-helper/backend-runtime'; +import { HybridRetrievalService } from '@ba-helper/backend-runtime'; import { ProjectRepository } from '../project/infrastructure/project.repository'; import { GraphModule } from '../graph/graph.module'; import { ClarificationModule } from '../clarification/clarification.module'; @@ -77,8 +77,8 @@ import { ProjectModule } from '../project/project.module'; import { RepositoryModule } from '../repository/repository.module'; import { GetAnalysisDriftFreshnessUseCase } from './application/queries/get-analysis-drift-freshness.usecase'; import { GetAnalysisWorkspaceUseCase } from './application/queries/get-analysis-workspace.usecase'; -import { DomainPackModule } from '../domain-pack/domain-pack.module'; -import { DomainPackRegistry } from '../domain-pack/application/domain-pack.registry'; +import { DomainPackModule } from '@ba-helper/backend-runtime'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; import { EventLogPortAdapter } from '../event-log/infrastructure/event-log-port.adapter'; @Module({ diff --git a/apps/api/src/modules/localization/localization.controller.ts b/apps/api/src/modules/localization/localization.controller.ts new file mode 100644 index 00000000..69159342 --- /dev/null +++ b/apps/api/src/modules/localization/localization.controller.ts @@ -0,0 +1,213 @@ +import { Controller, Post, Get, Param, Body, UseGuards, NotFoundException, InternalServerErrorException } from '@nestjs/common'; +import { ReportLocalizationService, MarkdownReportRenderContext, computeCanonicalReportHash } from '@ba-helper/backend-runtime'; +import { GenerateLocalizedReportRequest, generateLocalizedReportRequestSchema, LocalizedReportArtifact, SupportedReportLocale, LocalizationStatusResponse } from '@ba-helper/contracts'; +import { PrismaService } from '../prisma/prisma.service'; +import { ProjectPermissionService } from '../project/application/project-permission.service'; +import { RolesGuard } from '../auth/application/roles.guard'; +import { JwtAuthGuard } from '../auth/application/jwt-auth.guard'; +import { CurrentUser } from '../auth/api/current-user.decorator'; +import { RequestUser } from '@ba-helper/contracts'; + +@Controller('api/v1/analyses/:analysisId/localization') +@UseGuards(JwtAuthGuard, RolesGuard) +export class LocalizationController { + constructor( + private readonly localizationService: ReportLocalizationService, + private readonly prisma: PrismaService, + private readonly permissions: ProjectPermissionService, + ) {} + + @Post() + async generateLocalizedReport( + @Param('analysisId') analysisId: string, + @Body() body: unknown + ): Promise { + const input = generateLocalizedReportRequestSchema.parse(body); + const analysis = await this.prisma.impactAnalysis.findUnique({ + where: { id: analysisId }, + include: { + snapshot: { include: { repository: true, profile: true } }, + sourceTarget: true, + requirementRevision: true, + } + }); + + if (!analysis) { + throw new NotFoundException('Analysis not found'); + } + + const document = await this.prisma.generatedDocument.findFirst({ + where: { + impactAnalysisId: analysisId, + type: 'IMPACT_REPORT', + status: 'APPROVED', + } + }); + + if (!document) { + throw new NotFoundException('Approved impact report not found for localization'); + } + + // We also need the snapshot data to reconstruct canonical context + const reviewedSnapshot = await this.prisma.reviewedReportSnapshot.findFirst({ + where: { approvedDocumentId: document.id } + }); + + if (!reviewedSnapshot) { + throw new NotFoundException('Reviewed snapshot missing for approved document'); + } + + // Fetch the rest of the dependencies to rebuild the MarkdownRenderContext + const insights = await this.prisma.baInsight.findMany({ + where: { impactAnalysisId: analysisId }, + include: { evidenceLinks: { include: { evidence: true } } } + }); + + const traceabilityLinks = await this.prisma.traceabilityLink.findMany({ + where: { impactAnalysisId: analysisId }, + include: { artifact: true, evidenceLinks: { include: { evidence: true } } } + }); + + const reviewNotes = await this.prisma.reviewNote.findMany({ + where: { impactAnalysisId: analysisId } + }); + + const clarifications = await this.prisma.clarificationItem.findMany({ + where: { impactAnalysisId: analysisId } + }); + + // We don't reconstruct everything because translatable extraction only cares about Insights, Clarifications, ReviewNotes. + const canonicalContext: MarkdownReportRenderContext = { + analysis: analysis as any, + locale: 'en', + insights: insights as any, + traceabilityLinks: traceabilityLinks as any, + reviewNotes, + hasUnreviewedItems: false, + dependencyEdges: [], + clarifications: clarifications as any, + reviewDecisions: [], + reviewDecisionsSnapshot: reviewedSnapshot.reviewDecisionsSnapshot as any, + evidenceQualitySummarySnapshot: reviewedSnapshot.evidenceQualitySummarySnapshot as any, + }; + + const localizedArtifact = await this.localizationService.localizeReport( + document.id, + canonicalContext, + input.locale + ); + + if (localizedArtifact.localizationStatus === 'FAILED') { + throw new InternalServerErrorException({ + message: 'Localization failed', + errorCode: localizedArtifact.errorCode, + }); + } + + return localizedArtifact; + } + + @Get(':locale/status') + async getLocalizationStatus( + @Param('analysisId') analysisId: string, + @Param('locale') locale: SupportedReportLocale, + @CurrentUser() actor: RequestUser + ): Promise { + await this.permissions.assertCanReadAnalysis(actor, analysisId); + + const analysis = await this.prisma.impactAnalysis.findUnique({ + where: { id: analysisId }, + include: { + snapshot: { include: { repository: true, profile: true } }, + sourceTarget: true, + requirementRevision: true, + } + }); + + if (!analysis) { + throw new NotFoundException('Analysis not found'); + } + + const document = await this.prisma.generatedDocument.findFirst({ + where: { + impactAnalysisId: analysisId, + type: 'IMPACT_REPORT', + status: 'APPROVED', + } + }); + + if (!document) { + return { status: 'SOURCE_NOT_READY' }; + } + + const localized = await this.prisma.localizedReportArtifact.findUnique({ + where: { + sourceDocumentId_locale: { + sourceDocumentId: document.id, + locale, + } + } + }); + + if (!localized) { + return { status: 'NOT_TRANSLATED' }; + } + + if (localized.localizationStatus === 'QUEUED') { + return { status: 'QUEUED' }; + } + + if (localized.localizationStatus === 'FAILED') { + return { status: 'FAILED' }; + } + + // Check if out of sync + const reviewedSnapshot = await this.prisma.reviewedReportSnapshot.findFirst({ + where: { approvedDocumentId: document.id } + }); + + if (!reviewedSnapshot) { + return { status: 'SOURCE_NOT_READY' }; + } + + const insights = await this.prisma.baInsight.findMany({ + where: { impactAnalysisId: analysisId }, + include: { evidenceLinks: { include: { evidence: true } } } + }); + + const traceabilityLinks = await this.prisma.traceabilityLink.findMany({ + where: { impactAnalysisId: analysisId }, + include: { artifact: true, evidenceLinks: { include: { evidence: true } } } + }); + + const reviewNotes = await this.prisma.reviewNote.findMany({ + where: { impactAnalysisId: analysisId } + }); + + const clarifications = await this.prisma.clarificationItem.findMany({ + where: { impactAnalysisId: analysisId } + }); + + const canonicalContext: MarkdownReportRenderContext = { + analysis: analysis as any, + locale: 'en', + insights: insights as any, + traceabilityLinks: traceabilityLinks as any, + reviewNotes, + hasUnreviewedItems: false, + dependencyEdges: [], + clarifications: clarifications as any, + reviewDecisions: [], + reviewDecisionsSnapshot: reviewedSnapshot.reviewDecisionsSnapshot as any, + evidenceQualitySummarySnapshot: reviewedSnapshot.evidenceQualitySummarySnapshot as any, + }; + + const currentHash = computeCanonicalReportHash(canonicalContext); + + if (localized.sourceContentHash !== currentHash) { + return { status: 'OUT_OF_SYNC' }; + } + + return { status: 'READY' }; + } +} diff --git a/apps/api/src/modules/localization/localization.module.ts b/apps/api/src/modules/localization/localization.module.ts new file mode 100644 index 00000000..c22f398a --- /dev/null +++ b/apps/api/src/modules/localization/localization.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { LocalizationController } from './localization.controller'; +import { LocalizationModule } from '@ba-helper/backend-runtime'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ProjectModule } from '../project/project.module'; + +@Module({ + imports: [LocalizationModule, PrismaModule, ProjectModule], + controllers: [LocalizationController], +}) +export class ApiLocalizationModule {} diff --git a/apps/api/src/modules/retrieval/retrieval.module.ts b/apps/api/src/modules/retrieval/retrieval.module.ts deleted file mode 100644 index 50bf2fe9..00000000 --- a/apps/api/src/modules/retrieval/retrieval.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HybridRetrievalService } from './application/hybrid-retrieval.service'; -import { EmbeddingModule } from '../embedding/embedding.module'; -import { ArtifactModule } from '../artifact/artifact.module'; -import { GraphModule } from '../graph/graph.module'; -import { PrismaModule } from '../prisma/prisma.module'; -import { DomainPackModule } from '../domain-pack/domain-pack.module'; - -@Module({ - imports: [EmbeddingModule, ArtifactModule, GraphModule, PrismaModule, DomainPackModule], - providers: [HybridRetrievalService], - exports: [HybridRetrievalService], -}) -export class RetrievalModule {} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 99a9fc5d..13d388a8 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -10,6 +10,8 @@ COPY apps/web/package.json apps/web/package.json COPY packages/contracts/package.json packages/contracts/package.json COPY packages/shared/package.json packages/shared/package.json COPY packages/analyzer/package.json packages/analyzer/package.json +COPY packages/backend-runtime/package.json packages/backend-runtime/package.json +COPY packages/application/package.json packages/application/package.json RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ sh -c "pnpm config set store-dir /pnpm/store && if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile --ignore-scripts; else pnpm install --ignore-scripts; fi" @@ -18,6 +20,8 @@ RUN corepack enable ENV CI=true COPY --from=deps /app/node_modules /app/node_modules COPY --from=deps /app/apps/web/node_modules /app/apps/web/node_modules +COPY --from=deps /app/packages/backend-runtime/node_modules /app/packages/backend-runtime/node_modules +COPY --from=deps /app/packages/application/node_modules /app/packages/application/node_modules COPY . . WORKDIR /app/apps/web RUN /app/node_modules/.bin/tsc -p /app/packages/contracts/tsconfig.json \ @@ -30,6 +34,8 @@ RUN addgroup -S app && adduser -S app -G app WORKDIR /app COPY --from=build /app/node_modules /app/node_modules COPY --from=build /app/apps/web/node_modules /app/apps/web/node_modules +COPY --from=build /app/packages/backend-runtime/node_modules /app/packages/backend-runtime/node_modules +COPY --from=build /app/packages/application/node_modules /app/packages/application/node_modules COPY --from=build /app/packages /app/packages COPY --from=build /app/apps/web/.next /app/apps/web/.next COPY --from=build /app/apps/web/public /app/apps/web/public diff --git a/apps/web/src/app/(app)/analyses/[analysisId]/_components/diff/review-decision-form.tsx b/apps/web/src/app/(app)/analyses/[analysisId]/_components/diff/review-decision-form.tsx index 741e529c..a768028d 100644 --- a/apps/web/src/app/(app)/analyses/[analysisId]/_components/diff/review-decision-form.tsx +++ b/apps/web/src/app/(app)/analyses/[analysisId]/_components/diff/review-decision-form.tsx @@ -4,7 +4,8 @@ import { Button } from "@/components/ui/button" import { Textarea } from "@/components/ui/textarea" import { useAuth } from "@/hooks/use-auth" import { useCreateReviewDecision } from "@/hooks/api/use-analyses" -import { ImpactAnalysisDetailResponse } from "@ba-helper/contracts" +import { ImpactAnalysisDetailResponse, ProjectRole } from "@ba-helper/contracts" +import { canReview as canReviewPermission } from "@/lib/permissions" interface ReviewDecisionFormProps { analysisId: string @@ -18,7 +19,7 @@ export function ReviewDecisionForm({ analysisId, analysis }: ReviewDecisionFormP const [decision, setDecision] = useState<"ACCEPTED" | "REJECTED" | "NEEDS_MORE_CLARIFICATION">("ACCEPTED") const [note, setNote] = useState("") - const canReview = analysis.status === "COMPLETED" && user?.role !== "VIEWER" + const canReview = Boolean(analysis.capabilities.canReview) && canReviewPermission((user?.role as unknown) as ProjectRole ?? null) if (!canReview) return null diff --git a/apps/web/src/components/workspace/analysis/analysis-localization-trigger.tsx b/apps/web/src/components/workspace/analysis/analysis-localization-trigger.tsx new file mode 100644 index 00000000..7043009a --- /dev/null +++ b/apps/web/src/components/workspace/analysis/analysis-localization-trigger.tsx @@ -0,0 +1,122 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { useGenerateLocalizedReport, useLocalizationStatus } from "@/hooks/api/use-localization" +import { SupportedReportLocale } from "@ba-helper/contracts" +import { toast } from "sonner" +import { Loader2, CheckCircle2, XCircle, RefreshCw, AlertCircle } from "lucide-react" +import Link from "next/link" +import { useQueryClient } from "@tanstack/react-query" + +export function AnalysisLocalizationTrigger({ + analysisId, + canExport, +}: { + analysisId: string + canExport: boolean +}) { + const [selectedLocale, setSelectedLocale] = useState("vi-VN") + const generateLocalization = useGenerateLocalizedReport(analysisId) + const { data: statusData, isLoading: isLoadingStatus } = useLocalizationStatus(analysisId, selectedLocale) + const queryClient = useQueryClient() + + const handleGenerate = async () => { + try { + await generateLocalization.mutateAsync({ locale: selectedLocale }) + toast.success(`Localization requested for ${selectedLocale}`) + queryClient.invalidateQueries({ queryKey: ["localization", analysisId, "status", selectedLocale] }) + } catch (error) { + toast.error("Failed to request localization", { + description: error instanceof Error ? error.message : "Unknown error", + }) + } + } + + if (!canExport) return null + + const status = statusData?.status ?? "SOURCE_NOT_READY" + + return ( +
+
+
+ Localization +
+ {isLoadingStatus ? ( + + ) : status === "READY" ? ( +
+ Ready +
+ ) : status === "QUEUED" ? ( +
+ Queued +
+ ) : status === "FAILED" ? ( +
+ Failed +
+ ) : status === "OUT_OF_SYNC" ? ( +
+ Out of sync +
+ ) : ( +
+ Not translated +
+ )} +
+
+ + +
+ {status === "READY" ? ( + <> + + + + ) : ( + + )} +
+
+
+ ) +} diff --git a/apps/web/src/components/workspace/analysis/review-report-tab.tsx b/apps/web/src/components/workspace/analysis/review-report-tab.tsx index 7d28ca85..2ae81e5b 100644 --- a/apps/web/src/components/workspace/analysis/review-report-tab.tsx +++ b/apps/web/src/components/workspace/analysis/review-report-tab.tsx @@ -4,6 +4,7 @@ import type { AnalysisWorkspaceResponse } from "@ba-helper/contracts" import Link from "next/link" import { Button } from "@/components/ui/button" import { FinalizeAnalysisDialog } from "./finalize-analysis-dialog" +import { AnalysisLocalizationTrigger } from "./analysis-localization-trigger" import { useReviewInsight, useReviewTraceabilityLink } from "@/hooks/api/use-analyses" import { driftStatusLabels, @@ -152,6 +153,11 @@ export function ReviewReportTab({ {labels.openReport} ) : null} + + ) diff --git a/apps/web/src/hooks/api/use-localization.ts b/apps/web/src/hooks/api/use-localization.ts new file mode 100644 index 00000000..b4d5f89b --- /dev/null +++ b/apps/web/src/hooks/api/use-localization.ts @@ -0,0 +1,37 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { GenerateLocalizedReportRequest, LocalizedReportArtifact } from '@ba-helper/contracts'; +import { api } from '@/lib/api-client'; + +export const localizationKeys = { + all: ['localization'] as const, + byAnalysis: (analysisId: string) => [...localizationKeys.all, analysisId] as const, + status: (analysisId: string, locale: string) => [...localizationKeys.byAnalysis(analysisId), 'status', locale] as const, +}; + +export function useGenerateLocalizedReport(analysisId: string) { + return useMutation({ + mutationFn: async (data: GenerateLocalizedReportRequest) => { + const response = await api.post( + `/v1/analyses/${analysisId}/localization`, + data + ); + return response.data; + }, + }); +} + +export function useLocalizationStatus(analysisId: string, locale: string) { + return useQuery({ + queryKey: localizationKeys.status(analysisId, locale), + queryFn: async () => { + const response = await api.get<{ status: 'READY' | 'NOT_TRANSLATED' | 'QUEUED' | 'FAILED' | 'OUT_OF_SYNC' | 'SOURCE_NOT_READY' }>( + `/v1/analyses/${analysisId}/localization/${locale}/status` + ); + return response.data; + }, + refetchInterval: (query) => { + // Auto-poll if it's queued + return query.state.data?.status === 'QUEUED' ? 3000 : false; + } + }); +} diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile index d478c629..1b349c50 100644 --- a/apps/worker/Dockerfile +++ b/apps/worker/Dockerfile @@ -10,6 +10,8 @@ COPY apps/web/package.json apps/web/package.json COPY packages/contracts/package.json packages/contracts/package.json COPY packages/shared/package.json packages/shared/package.json COPY packages/analyzer/package.json packages/analyzer/package.json +COPY packages/backend-runtime/package.json packages/backend-runtime/package.json +COPY packages/application/package.json packages/application/package.json RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ sh -c "pnpm config set store-dir /pnpm/store && if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile --ignore-scripts; else pnpm install --ignore-scripts; fi" @@ -20,6 +22,8 @@ COPY --from=deps /app/node_modules /app/node_modules COPY --from=deps /app/apps/api/node_modules /app/apps/api/node_modules COPY --from=deps /app/apps/worker/node_modules /app/apps/worker/node_modules COPY --from=deps /app/packages/analyzer/node_modules /app/packages/analyzer/node_modules +COPY --from=deps /app/packages/backend-runtime/node_modules /app/packages/backend-runtime/node_modules +COPY --from=deps /app/packages/application/node_modules /app/packages/application/node_modules COPY . . WORKDIR /app/apps/worker # worker needs apps/api prisma generated @@ -27,6 +31,8 @@ RUN /app/apps/api/node_modules/.bin/prisma generate --schema /app/apps/api/prism RUN /app/node_modules/.bin/tsc -p /app/packages/contracts/tsconfig.json \ && /app/node_modules/.bin/tsc -p /app/packages/shared/tsconfig.json \ && /app/node_modules/.bin/tsc -p /app/packages/analyzer/tsconfig.json \ + && /app/node_modules/.bin/tsc -p /app/packages/application/tsconfig.json \ + && /app/node_modules/.bin/tsc -p /app/packages/backend-runtime/tsconfig.json \ && /app/node_modules/.bin/tsc -p /app/apps/worker/tsconfig.build.json FROM base AS runtime @@ -39,6 +45,8 @@ COPY --from=build /app/node_modules /app/node_modules COPY --from=build /app/apps/api/node_modules /app/apps/api/node_modules COPY --from=build /app/apps/worker/node_modules /app/apps/worker/node_modules COPY --from=build /app/packages/analyzer/node_modules /app/packages/analyzer/node_modules +COPY --from=build /app/packages/backend-runtime/node_modules /app/packages/backend-runtime/node_modules +COPY --from=build /app/packages/application/node_modules /app/packages/application/node_modules COPY --from=build /app/apps/api/prisma /app/apps/api/prisma COPY --from=build /app/apps/api/prisma.config.ts /app/apps/api/prisma.config.ts COPY --from=build /app/packages /app/packages diff --git a/apps/worker/src/ai/ai-config.ts b/apps/worker/src/ai/ai-config.ts new file mode 100644 index 00000000..60f52f70 --- /dev/null +++ b/apps/worker/src/ai/ai-config.ts @@ -0,0 +1,5 @@ +// Worker-local AI config token. +// AiConfig type lives in @ba-helper/shared; this file only defines the DI token. +export type { AiConfig } from '@ba-helper/shared'; + +export const AI_CONFIG_TOKEN = Symbol('AI_CONFIG'); diff --git a/apps/worker/src/ai/ai.module.ts b/apps/worker/src/ai/ai.module.ts new file mode 100644 index 00000000..e5b0b3d0 --- /dev/null +++ b/apps/worker/src/ai/ai.module.ts @@ -0,0 +1,49 @@ +import { Module, DynamicModule } from '@nestjs/common'; +import { AiConfig, resolveAiConfig } from '@ba-helper/shared'; +import { LlmProviderPort } from '@ba-helper/application'; +import { AI_CONFIG_TOKEN } from './ai-config'; +import { FakeLlmProvider } from './fake-ai.provider'; +import { OpenAiLlmProvider } from './openai.provider'; +import { AnthropicLlmProvider } from './anthropic.provider'; +import { GoogleLlmProvider } from './google.provider'; +import { DeepseekLlmProvider } from './deepseek.provider'; + +/** + * Worker-local AiModule — mirrors @ba-helper/api/modules/ai/ai.module.ts + * but does NOT import from @ba-helper/api. Providers use @ba-helper/application and + * @ba-helper/shared directly. + */ +@Module({}) +export class AiModule { + static forRoot(config?: Partial): DynamicModule { + const envConfig = resolveAiConfig(process.env); + + const resolvedConfig: AiConfig = { + ...envConfig, + ...config, + }; + + return { + module: AiModule, + global: true, + providers: [ + { provide: AI_CONFIG_TOKEN, useValue: resolvedConfig }, + { + provide: LlmProviderPort, + useFactory: (cfg: AiConfig) => { + switch (cfg.provider) { + case 'openai': return new OpenAiLlmProvider(cfg); + case 'anthropic': return new AnthropicLlmProvider(cfg); + case 'google': return new GoogleLlmProvider(cfg); + case 'deepseek': return new DeepseekLlmProvider(cfg); + case 'fake': + default: return new FakeLlmProvider(); + } + }, + inject: [AI_CONFIG_TOKEN], + }, + ], + exports: [LlmProviderPort, AI_CONFIG_TOKEN], + }; + } +} diff --git a/apps/worker/src/ai/anthropic.provider.ts b/apps/worker/src/ai/anthropic.provider.ts new file mode 100644 index 00000000..9902dbdd --- /dev/null +++ b/apps/worker/src/ai/anthropic.provider.ts @@ -0,0 +1,87 @@ +import { Injectable, Inject } from '@nestjs/common'; +import Anthropic from '@anthropic-ai/sdk'; +import { AppError } from '@ba-helper/shared'; +import { z } from 'zod'; +import { LlmProviderPort, LlmRequest, LlmResult } from '@ba-helper/application'; +import { AiConfig, AI_CONFIG_TOKEN } from './ai-config'; +import { parseStructuredLlmOutput } from './structured-output'; +import { AiPolicy } from '@ba-helper/shared'; + +@Injectable() +export class AnthropicLlmProvider extends LlmProviderPort { + readonly providerName = 'anthropic'; + private readonly client: Anthropic; + + constructor(@Inject(AI_CONFIG_TOKEN) private config: AiConfig) { + super(); + this.client = new Anthropic({ apiKey: this.config.apiKey }); + } + + async generateStructured( + request: LlmRequest, + schema: z.ZodSchema, + ): Promise> { + const model = request.options?.model ?? this.config.defaultModel; + const start = Date.now(); + + const safeUserPrompt = this.config.redactSecrets + ? AiPolicy.redactPayload(request.userPrompt).redactedPayload + : request.userPrompt; + + let response; + try { + response = await this.client.messages.create({ + model, + max_tokens: request.options?.maxTokens ?? this.config.maxTokens, + system: request.systemPrompt, + messages: [{ role: 'user', content: safeUserPrompt }], + }); + } catch (error: any) { + const msg = error?.message?.toLowerCase() || ''; + if (msg.includes('429') || msg.includes('rate limit') || msg.includes('quota') || error?.status === 429) { + throw new AppError('AI_PROVIDER_RATE_LIMITED', 'You have exceeded your AI provider rate limits. Please try again later or check your API quota.'); + } + if (msg.includes('timeout') || msg.includes('abort') || msg.includes('network error') || msg.includes('fetch failed')) { + throw new AppError('AI_PROVIDER_TIMEOUT', 'The AI provider timed out. Try analyzing again.'); + } + if ( + msg.includes('503') || + msg.includes('500') || + msg.includes('502') || + msg.includes('overload') || + msg.includes('unavailable') || + error?.status >= 500 + ) { + throw new AppError( + 'AI_PROVIDER_UNAVAILABLE', + 'Anthropic is temporarily unavailable or overloaded. Retry the analysis later or switch provider/model.' + ); + } + throw error; + } + + const content = response.content[0]; + const rawText = content.type === 'text' ? content.text : undefined; + + const { data, parseMode, rawLength, jsonLength } = parseStructuredLlmOutput({ + rawText, + schema, + allowJsonExtraction: true, + }); + + return { + data, + metadata: { + provider: 'anthropic', + model, + promptVersion: '', + durationMs: Date.now() - start, + inputTokens: response.usage?.input_tokens, + outputTokens: response.usage?.output_tokens, + parseMode, + rawLength, + jsonLength, + }, + }; + } +} diff --git a/apps/worker/src/ai/deepseek.provider.ts b/apps/worker/src/ai/deepseek.provider.ts new file mode 100644 index 00000000..2cec2266 --- /dev/null +++ b/apps/worker/src/ai/deepseek.provider.ts @@ -0,0 +1,88 @@ +import { Injectable, Inject } from '@nestjs/common'; +import OpenAI from 'openai'; +import { AppError } from '@ba-helper/shared'; +import { z } from 'zod'; +import { LlmProviderPort, LlmRequest, LlmResult } from '@ba-helper/application'; +import { AiConfig, AI_CONFIG_TOKEN } from './ai-config'; +import { parseStructuredLlmOutput } from './structured-output'; + +@Injectable() +export class DeepseekLlmProvider extends LlmProviderPort { + readonly providerName = 'deepseek'; + private readonly client: OpenAI; + + constructor(@Inject(AI_CONFIG_TOKEN) private config: AiConfig) { + super(); + this.client = new OpenAI({ + baseURL: this.config.baseUrl, + apiKey: this.config.apiKey, + }); + } + + async generateStructured( + request: LlmRequest, + schema: z.ZodSchema, + ): Promise> { + const model = request.options?.model ?? this.config.defaultModel; + const start = Date.now(); + + let response; + try { + response = await this.client.chat.completions.create({ + model, + temperature: request.options?.temperature ?? this.config.temperature, + max_tokens: request.options?.maxTokens ?? this.config.maxTokens, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: request.systemPrompt }, + { role: 'user', content: request.userPrompt }, + ], + }); + } catch (error: any) { + const msg = error?.message?.toLowerCase() || ''; + if (msg.includes('429') || msg.includes('rate limit') || msg.includes('quota') || error?.status === 429) { + throw new AppError('AI_PROVIDER_RATE_LIMITED', 'You have exceeded your AI provider rate limits. Please try again later or check your API quota.'); + } + if (msg.includes('timeout') || msg.includes('abort') || msg.includes('network error') || msg.includes('fetch failed')) { + throw new AppError('AI_PROVIDER_TIMEOUT', 'The AI provider timed out. Try analyzing again.'); + } + if ( + msg.includes('503') || + msg.includes('500') || + msg.includes('502') || + msg.includes('overload') || + msg.includes('unavailable') || + error?.status >= 500 + ) { + throw new AppError( + 'AI_PROVIDER_UNAVAILABLE', + 'DeepSeek is temporarily unavailable or overloaded. Retry the analysis later or switch provider/model.' + ); + } + throw error; + } + + // DeepSeek might return extra text around JSON + const rawText = response.choices[0].message.content; + const { data, parseMode, rawLength, jsonLength } = parseStructuredLlmOutput({ + rawText, + schema, + allowJsonExtraction: true, + }); + + return { + data, + metadata: { + provider: 'deepseek', + model, + promptVersion: '', + durationMs: Date.now() - start, + inputTokens: response.usage?.prompt_tokens, + outputTokens: response.usage?.completion_tokens, + parseMode, + rawLength, + jsonLength, + }, + }; + } +} diff --git a/apps/worker/src/ai/fake-ai.provider.ts b/apps/worker/src/ai/fake-ai.provider.ts new file mode 100644 index 00000000..5cd2884b --- /dev/null +++ b/apps/worker/src/ai/fake-ai.provider.ts @@ -0,0 +1,189 @@ +import { Injectable } from '@nestjs/common'; +import { z } from 'zod'; +import { LlmProviderPort, LlmRequest, LlmResult } from '@ba-helper/application'; +import { parseStructuredLlmOutput } from './structured-output'; + +@Injectable() +export class FakeLlmProvider extends LlmProviderPort { + readonly providerName = 'fake'; + + async generateStructured( + request: LlmRequest, + schema: z.ZodSchema, + ): Promise> { + const start = Date.now(); + + const isOrderInventory = request.userPrompt.includes('InventoryService.releaseReservation') || + request.userPrompt.includes('OrderService.cancelOrder'); + + let mockData: any; + + if (isOrderInventory) { + mockData = { + insights: [ + { + insightKey: 'claim:cancel-order-route', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'Order cancellation endpoint exists.', + description: 'OrderController.cancelOrder provides the entrypoint for cancellation.', + evidenceKeys: ['api:order.controller.cancelOrder'], + }, + { + insightKey: 'claim:cancel-order-service', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'OrderService handles cancellation logic.', + description: 'OrderService.cancelOrder contains the business logic for cancelling an order.', + evidenceKeys: ['service-method:order.service.cancelOrder'], + }, + { + insightKey: 'claim:release-inventory', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'Inventory reservation is released.', + description: 'InventoryService.releaseReservation is called to release reserved stock.', + evidenceKeys: ['service-method:inventory.service.releaseReservation'], + } + ].filter(insight => + insight.evidenceKeys.length === 0 || + insight.evidenceKeys.some(key => request.userPrompt.includes(key)) + ), + unknowns: [ + { + insightKey: 'unknown:refund-payment', + description: 'Refund or payment behavior is missing.', + reasoning: 'No explicit refund or payment artifact was found in the context.', + }, + { + insightKey: 'unknown:shipment-boundary', + description: 'Shipment boundary behavior is missing.', + reasoning: 'It is not explicitly confirmed what happens if the order is already shipped.', + } + ], + qaScenarios: [ + { + scenarioKey: 'qa:cancel-before-shipment', + description: 'Verify order can be cancelled before shipment.', + priority: 'HIGH', + }, + { + scenarioKey: 'qa:cancel-after-shipment', + description: 'Reject cancellation after shipment started.', + priority: 'HIGH', + }, + { + scenarioKey: 'qa:duplicate-cancel', + description: 'Verify idempotency on duplicate cancel.', + priority: 'MEDIUM', + }, + { + scenarioKey: 'qa:inventory-release-fail', + description: 'Test inventory release failure handling.', + priority: 'MEDIUM', + }, + { + scenarioKey: 'qa:happy-path-release', + description: 'Reserved inventory is successfully released when cancellation succeeds.', + priority: 'HIGH', + } + ] + }; + } else { + mockData = { + insights: [ + { + insightKey: 'claim:cancel-route', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'The system exposes an API route for cancelling a booking.', + description: 'The system exposes an API route for cancelling a booking.', + evidenceKeys: ['api:booking.controller.cancel'], + }, + { + insightKey: 'claim:cancel-refund', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'Cancellation triggers a refund operation.', + description: 'Cancellation triggers a refund operation.', + evidenceKeys: ['service-method:payment.service.refund'], + }, + { + insightKey: 'claim:release-slot', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'Cancellation releases the booked slot.', + description: 'Cancellation releases the booked slot.', + evidenceKeys: ['service-method:slot.service.releaseSlot'], + }, + { + insightKey: 'claim:notify-owner', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'Cancellation notifies the booking owner.', + description: 'Cancellation notifies the booking owner.', + evidenceKeys: ['service-method:notification.service.notifyOwner'], + }, + ].filter(insight => + insight.evidenceKeys.length === 0 || + insight.evidenceKeys.some(key => request.userPrompt.includes(key)) + ), + unknowns: [ + { + insightKey: 'unknown:refund-percentage', + description: 'Refund percentage is not confirmed from code evidence.', + reasoning: 'No explicit refund percentage or refund policy artifact was found.', + }, + { + insightKey: 'unknown:refund-deadline', + description: 'Refund deadline is not confirmed from code evidence.', + reasoning: 'No explicit refund deadline was found in the evidence scope.', + }, + { + insightKey: 'unknown:who-may-cancel', + description: 'Who may cancel a booking is not confirmed from code evidence.', + reasoning: 'No authorization or role checks were found in the cancellation flow.', + }, + { + insightKey: 'unknown:owner-approval', + description: 'Owner approval requirements are not confirmed from code evidence.', + reasoning: 'No approval or confirmation step was found in the cancellation flow.', + }, + { + insightKey: 'unknown:slot-reopen', + description: 'Slot re-open policy is not confirmed from code evidence.', + reasoning: 'Slot release is called, but no policy for rebooking timing was found.', + }, + ], + }; + } + + const rawText = JSON.stringify(mockData); + + const { data, parseMode, rawLength, jsonLength } = parseStructuredLlmOutput({ + rawText, + schema, + allowJsonExtraction: false, + }); + + return { + data, + metadata: { + provider: 'fake', + model: 'fake-deterministic', + promptVersion: 'test', + durationMs: Date.now() - start, + parseMode, + rawLength, + jsonLength, + }, + }; + } +} diff --git a/apps/worker/src/ai/google.provider.ts b/apps/worker/src/ai/google.provider.ts new file mode 100644 index 00000000..e30ff6cb --- /dev/null +++ b/apps/worker/src/ai/google.provider.ts @@ -0,0 +1,119 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { GoogleGenerativeAI } from '@google/generative-ai'; +import { z } from 'zod'; +import { LlmProviderPort, LlmRequest, LlmResult, AiOutputError } from '@ba-helper/application'; +import { AiConfig, AI_CONFIG_TOKEN } from './ai-config'; +import { AiPolicy, AppError } from '@ba-helper/shared'; +import { parseStructuredLlmOutput } from './structured-output'; + +@Injectable() +export class GoogleLlmProvider extends LlmProviderPort { + readonly providerName = 'google'; + private readonly client: GoogleGenerativeAI; + + constructor(@Inject(AI_CONFIG_TOKEN) private config: AiConfig) { + super(); + const apiKey = this.config.apiKey; + + if (!apiKey) { + throw new AppError( + 'AI_PROVIDER_AUTH_FAILED', + 'Google LLM provider requires GOOGLE_API_KEY, GEMINI_API_KEY, or GOOGLE_AI_API_KEY to be set.', + ); + } + + this.client = new GoogleGenerativeAI(apiKey); + } + + async generateStructured( + request: LlmRequest, + schema: z.ZodSchema, + ): Promise> { + const model = request.options?.model ?? this.config.defaultModel; + const promptVersion = request.options?.promptVersion ?? ''; + const start = Date.now(); + + // Invariant #11: Redact secrets from evidence before sending to real provider + const safeUserPrompt = this.config.redactSecrets + ? AiPolicy.redactPayload(request.userPrompt).redactedPayload + : request.userPrompt; + + const genModel = this.client.getGenerativeModel({ model }); + + let result; + try { + result = await genModel.generateContent({ + systemInstruction: request.systemPrompt, + contents: [{ role: 'user', parts: [{ text: safeUserPrompt }] }], + generationConfig: { + responseMimeType: 'application/json', + temperature: request.options?.temperature ?? this.config.temperature, + maxOutputTokens: request.options?.maxTokens ?? this.config.maxTokens, + }, + }); + } catch (error: any) { + const msg = error?.message?.toLowerCase() || ''; + if (msg.includes('429') || msg.includes('rate limit') || msg.includes('quota')) { + throw new AppError('AI_PROVIDER_RATE_LIMITED', 'You have exceeded your AI provider rate limits. Please try again later or check your API quota.'); + } + if (msg.includes('timeout') || msg.includes('abort') || msg.includes('network error') || msg.includes('fetch failed')) { + throw new AppError('AI_PROVIDER_TIMEOUT', 'The AI provider timed out. Try analyzing again.'); + } + if ( + msg.includes('503') || + msg.includes('500') || + msg.includes('502') || + msg.includes('overload') || + msg.includes('unavailable') + ) { + throw new AppError( + 'AI_PROVIDER_UNAVAILABLE', + 'Gemini is temporarily unavailable or overloaded. Retry the analysis later or switch provider/model.' + ); + } + throw error; + } + + const rawText = result.response.text(); + const finishReason = result.response.candidates?.[0]?.finishReason; + if (finishReason && finishReason !== 'STOP') { + new Logger('GoogleLlmProvider').warn(`Unexpected finishReason: ${finishReason}`); + } + + if (finishReason === 'MAX_TOKENS') { + throw new AiOutputError( + 'AI_OUTPUT_TRUNCATED', + 'Google LLM output was truncated before a complete structured response was produced.', + { + provider: 'google', + model, + finishReason, + parseMode: 'raw', + maxTokens: request.options?.maxTokens ?? this.config.maxTokens, + temperature: request.options?.temperature ?? this.config.temperature, + }, + ); + } + + const { data, parseMode, rawLength, jsonLength } = parseStructuredLlmOutput({ + rawText, + schema, + allowJsonExtraction: true, + }); + + return { + data, + metadata: { + provider: 'google', + model, + promptVersion, + durationMs: Date.now() - start, + parseMode, + rawLength, + jsonLength, + inputTokens: result.response.usageMetadata?.promptTokenCount, + outputTokens: result.response.usageMetadata?.candidatesTokenCount, + }, + }; + } +} diff --git a/apps/worker/src/ai/openai.provider.ts b/apps/worker/src/ai/openai.provider.ts new file mode 100644 index 00000000..bb5ef3a5 --- /dev/null +++ b/apps/worker/src/ai/openai.provider.ts @@ -0,0 +1,89 @@ +import { Injectable, Inject } from '@nestjs/common'; +import OpenAI from 'openai'; +import { AppError } from '@ba-helper/shared'; +import { z } from 'zod'; +import { LlmProviderPort, LlmRequest, LlmResult } from '@ba-helper/application'; +import { AiConfig, AI_CONFIG_TOKEN } from './ai-config'; +import { parseStructuredLlmOutput } from './structured-output'; +import { AiPolicy } from '@ba-helper/shared'; + +@Injectable() +export class OpenAiLlmProvider extends LlmProviderPort { + readonly providerName = 'openai'; + private readonly client: OpenAI; + + constructor(@Inject(AI_CONFIG_TOKEN) private config: AiConfig) { + super(); + this.client = new OpenAI({ apiKey: this.config.apiKey }); + } + + async generateStructured( + request: LlmRequest, + schema: z.ZodSchema, + ): Promise> { + const model = request.options?.model ?? this.config.defaultModel; + const start = Date.now(); + + const safeUserPrompt = this.config.redactSecrets + ? AiPolicy.redactPayload(request.userPrompt).redactedPayload + : request.userPrompt; + + let response; + try { + response = await this.client.chat.completions.create({ + model, + temperature: request.options?.temperature ?? this.config.temperature, + max_tokens: request.options?.maxTokens ?? this.config.maxTokens, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: request.systemPrompt }, + { role: 'user', content: safeUserPrompt }, + ], + }); + } catch (error: any) { + const msg = error?.message?.toLowerCase() || ''; + if (msg.includes('429') || msg.includes('rate limit') || msg.includes('quota') || error?.status === 429) { + throw new AppError('AI_PROVIDER_RATE_LIMITED', 'You have exceeded your AI provider rate limits. Please try again later or check your API quota.'); + } + if (msg.includes('timeout') || msg.includes('abort') || msg.includes('network error') || msg.includes('fetch failed')) { + throw new AppError('AI_PROVIDER_TIMEOUT', 'The AI provider timed out. Try analyzing again.'); + } + if ( + msg.includes('503') || + msg.includes('500') || + msg.includes('502') || + msg.includes('overload') || + msg.includes('unavailable') || + error?.status >= 500 + ) { + throw new AppError( + 'AI_PROVIDER_UNAVAILABLE', + 'OpenAI is temporarily unavailable or overloaded. Retry the analysis later or switch provider/model.' + ); + } + throw error; + } + + const rawText = response.choices[0].message.content; + const { data, parseMode, rawLength, jsonLength } = parseStructuredLlmOutput({ + rawText, + schema, + allowJsonExtraction: true, + }); + + return { + data, + metadata: { + provider: 'openai', + model, + promptVersion: '', + durationMs: Date.now() - start, + inputTokens: response.usage?.prompt_tokens, + outputTokens: response.usage?.completion_tokens, + parseMode, + rawLength, + jsonLength, + }, + }; + } +} diff --git a/apps/worker/src/ai/structured-output.ts b/apps/worker/src/ai/structured-output.ts new file mode 100644 index 00000000..c7defc1a --- /dev/null +++ b/apps/worker/src/ai/structured-output.ts @@ -0,0 +1,165 @@ +import type { z } from 'zod'; +import { AiOutputError } from '@ba-helper/application'; + +export interface ParseStructuredLlmOutputParams { + rawText: string | null | undefined; + schema: z.ZodSchema; + allowJsonExtraction?: boolean; +} + +export interface ParseStructuredLlmOutputResult { + data: T; + parseMode: 'raw' | 'extracted'; + rawLength: number; + jsonLength: number; +} + +const MARKDOWN_JSON_FENCE = /^```(?:json)?\s*([\s\S]*?)\s*```$/i; + +function stripMarkdownJsonFence(rawText: string): string { + const fenced = rawText.trim().match(MARKDOWN_JSON_FENCE); + return fenced ? fenced[1].trim() : rawText; +} + +function findMatchingClosingIndex( + input: string, + startIndex: number, + opening: '{' | '[', + closing: '}' | ']', +): number { + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = startIndex; index < input.length; index += 1) { + const char = input[index]; + + if (escaped) { + escaped = false; + continue; + } + + if (char === '\\') { + escaped = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (char === opening) { + depth += 1; + continue; + } + + if (char === closing) { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + + return -1; +} + +function extractTopLevelJson(rawText: string): string | null { + const normalized = stripMarkdownJsonFence(rawText).trim(); + const objectIndex = normalized.indexOf('{'); + const arrayIndex = normalized.indexOf('['); + + if (objectIndex === -1 && arrayIndex === -1) { + return null; + } + + const startIndex = + objectIndex === -1 + ? arrayIndex + : arrayIndex === -1 + ? objectIndex + : Math.min(objectIndex, arrayIndex); + const opening = normalized[startIndex] as '{' | '['; + const closing = opening === '{' ? '}' : ']'; + const endIndex = findMatchingClosingIndex(normalized, startIndex, opening, closing); + + if (endIndex === -1) { + return null; + } + + return normalized.slice(startIndex, endIndex + 1).trim(); +} + +export function parseStructuredLlmOutput({ + rawText, + schema, + allowJsonExtraction = true, +}: ParseStructuredLlmOutputParams): ParseStructuredLlmOutputResult { + if (!rawText || rawText.trim().length === 0) { + throw new AiOutputError('AI_EMPTY_RESPONSE', 'LLM returned an empty response.'); + } + + const rawLength = rawText.length; + const normalizedRaw = stripMarkdownJsonFence(rawText).trim(); + let jsonString = normalizedRaw; + let parseMode: 'raw' | 'extracted' = 'raw'; + let rawJsonObj: unknown; + + try { + rawJsonObj = JSON.parse(jsonString); + } catch { + if (!allowJsonExtraction) { + throw new AiOutputError('AI_JSON_PARSE_FAILED', 'Failed to parse JSON and extraction is disabled.', { + rawText, + }); + } + + const extractedJson = extractTopLevelJson(rawText); + if (!extractedJson) { + throw new AiOutputError('AI_JSON_PARSE_FAILED', 'Failed to parse JSON and could not extract a complete top-level JSON payload.', { + rawText, + }); + } + + jsonString = extractedJson; + try { + rawJsonObj = JSON.parse(jsonString); + parseMode = 'extracted'; + } catch { + throw new AiOutputError('AI_JSON_PARSE_FAILED', 'Failed to parse extracted JSON payload.', { + rawText, + extractedText: jsonString, + }); + } + } + + const jsonLength = jsonString.length; + const parsed = schema.safeParse(rawJsonObj); + + if (!parsed.success) { + throw new AiOutputError( + 'AI_OUTPUT_SCHEMA_VALIDATION_FAILED', + 'AI output does not match expected schema.', + { + errors: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + code: issue.code, + message: issue.message, + })), + rawJsonObj, + }, + ); + } + + return { + data: parsed.data, + parseMode, + rawLength, + jsonLength, + }; +} diff --git a/apps/worker/src/app.module.ts b/apps/worker/src/app.module.ts index 44acab70..7b463f53 100644 --- a/apps/worker/src/app.module.ts +++ b/apps/worker/src/app.module.ts @@ -4,8 +4,8 @@ import { ImpactAnalysisWorkerModule } from './impact-analysis/impact-analysis.wo import { ScanJobWorkerModule } from './scan-job/scan-job.worker.module'; import { EmbeddingWorkerModule } from './embedding/embedding.worker.module'; import { DocumentJobWorkerModule } from './document-job/document-job.worker.module'; -import { AiModule } from '../../api/src/modules/ai/ai.module'; -import { requireEnv } from '../../api/src/bootstrap/runtime-config'; +import { AiModule } from './ai/ai.module'; +import { requireEnv } from '@ba-helper/shared'; @Module({ imports: [ diff --git a/apps/worker/src/document-job/document-job.processor.ts b/apps/worker/src/document-job/document-job.processor.ts index 2d08a73a..764e6b07 100644 --- a/apps/worker/src/document-job/document-job.processor.ts +++ b/apps/worker/src/document-job/document-job.processor.ts @@ -1,6 +1,7 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Job } from 'bullmq'; -import { RunDocumentJobUseCase } from '../../../api/src/modules/document/application/jobs/run-document-job.usecase'; +// v0.1 constraint: RunDocumentJobUseCase lives in apps/api until extracted to a shared package. +import { RunDocumentJobUseCase } from '@ba-helper/backend-runtime'; @Processor('document-job') export class DocumentJobWorker extends WorkerHost { diff --git a/apps/worker/src/document-job/document-job.worker.module.ts b/apps/worker/src/document-job/document-job.worker.module.ts index bbd9d2ae..d57db71d 100644 --- a/apps/worker/src/document-job/document-job.worker.module.ts +++ b/apps/worker/src/document-job/document-job.worker.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; -import { DocumentApplicationModule } from '../../../api/src/modules/document/document-application.module'; +// v0.1 constraint: DocumentApplicationModule lives in apps/api until extracted to a shared package. +// It has no HTTP controllers — only application use cases and repositories. +import { DocumentRuntimeModule } from '@ba-helper/backend-runtime'; import { DocumentJobWorker } from './document-job.processor'; @Module({ - imports: [DocumentApplicationModule], + imports: [DocumentRuntimeModule], providers: [DocumentJobWorker], }) export class DocumentJobWorkerModule {} diff --git a/apps/worker/src/embedding/embedding.worker.module.ts b/apps/worker/src/embedding/embedding.worker.module.ts index 9678760a..ab6851d2 100644 --- a/apps/worker/src/embedding/embedding.worker.module.ts +++ b/apps/worker/src/embedding/embedding.worker.module.ts @@ -1,13 +1,16 @@ import { Module } from '@nestjs/common'; import { EmbeddingProcessor } from './embedding.processor'; -import { PrismaModule } from '../../../api/src/modules/prisma/prisma.module'; +import { PrismaModule } from '../prisma/prisma.module'; import { EmbeddingChunkRepository } from './infrastructure/embedding-chunk.repository'; import { PrismaEmbeddingSnapshotRepository } from './infrastructure/prisma-embedding-snapshot.repository'; import { FakeEmbeddingProvider } from './infrastructure/fake-embedding.provider'; import { OpenAiEmbeddingProvider } from './infrastructure/openai-embedding.provider'; import { GoogleEmbeddingProvider } from './infrastructure/google-embedding.provider'; import { EmbedSnapshotArtifactsUseCase, EmbeddingProviderPort } from '@ba-helper/application'; -import { resolveEmbeddingConfig } from '@ba-helper/shared'; +import { EmbeddingConfig, resolveEmbeddingConfig } from '@ba-helper/shared'; + +/** DI token for the resolved embedding runtime configuration. */ +const EMBEDDING_CONFIG = Symbol('EMBEDDING_CONFIG'); @Module({ imports: [PrismaModule], @@ -15,6 +18,10 @@ import { resolveEmbeddingConfig } from '@ba-helper/shared'; EmbeddingProcessor, EmbeddingChunkRepository, PrismaEmbeddingSnapshotRepository, + { + provide: EMBEDDING_CONFIG, + useFactory: (): EmbeddingConfig => resolveEmbeddingConfig(process.env), + }, { provide: EmbedSnapshotArtifactsUseCase, useFactory: (chunkRepo, provider, snapshotRepo) => new EmbedSnapshotArtifactsUseCase(chunkRepo, provider, snapshotRepo), @@ -22,8 +29,7 @@ import { resolveEmbeddingConfig } from '@ba-helper/shared'; }, { provide: EmbeddingProviderPort, - useFactory: () => { - const config = resolveEmbeddingConfig(process.env); + useFactory: (config: EmbeddingConfig) => { if (process.env.NODE_ENV === 'production' && config.provider === 'fake') { throw new Error('FakeEmbeddingProvider is forbidden in production. Please set EMBEDDING_PROVIDER.'); } @@ -35,6 +41,7 @@ import { resolveEmbeddingConfig } from '@ba-helper/shared'; } return new FakeEmbeddingProvider(); }, + inject: [EMBEDDING_CONFIG], }, ], }) diff --git a/apps/worker/src/embedding/infrastructure/embedding-chunk.repository.ts b/apps/worker/src/embedding/infrastructure/embedding-chunk.repository.ts index 399de92d..71ef8bab 100644 --- a/apps/worker/src/embedding/infrastructure/embedding-chunk.repository.ts +++ b/apps/worker/src/embedding/infrastructure/embedding-chunk.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../../../api/src/modules/prisma/prisma.service'; +import { PrismaService } from '../../prisma/prisma.service'; import { Prisma } from '@prisma/client'; import type { EmbeddingChunkRepositoryPort, SimilarChunk } from '@ba-helper/application'; diff --git a/apps/worker/src/embedding/infrastructure/prisma-embedding-snapshot.repository.ts b/apps/worker/src/embedding/infrastructure/prisma-embedding-snapshot.repository.ts index 02ee80d9..9ce27f61 100644 --- a/apps/worker/src/embedding/infrastructure/prisma-embedding-snapshot.repository.ts +++ b/apps/worker/src/embedding/infrastructure/prisma-embedding-snapshot.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../../../api/src/modules/prisma/prisma.service'; +import { PrismaService } from '../../prisma/prisma.service'; import type { EmbeddingSnapshotRepositoryPort, ArtifactBasic, ArtifactWithEvidenceBasic, SnapshotWithRepositoryBasic } from '@ba-helper/application'; import type { DiagnosticItem, SnapshotIndexStatus } from '@ba-helper/contracts'; import { Prisma } from '@prisma/client'; diff --git a/apps/worker/src/impact-analysis/impact-analysis.processor.ts b/apps/worker/src/impact-analysis/impact-analysis.processor.ts index def94259..c3f2fa48 100644 --- a/apps/worker/src/impact-analysis/impact-analysis.processor.ts +++ b/apps/worker/src/impact-analysis/impact-analysis.processor.ts @@ -1,10 +1,13 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; import { Job, UnrecoverableError } from 'bullmq'; import { RunImpactAnalysisUseCase } from '@ba-helper/application'; -import { AiOutputError } from '../../../api/src/modules/ai/domain/ai.errors'; +import { classifyWorkerError } from './job-error-classifier'; @Processor('impact-analysis') export class ImpactAnalysisProcessor extends WorkerHost { + private readonly logger = new Logger(ImpactAnalysisProcessor.name); + constructor(private readonly runAnalysis: RunImpactAnalysisUseCase) { super(); } @@ -15,25 +18,27 @@ export class ImpactAnalysisProcessor extends WorkerHost { analysisId: job.data.analysisId, expandGraph: true, }); - } catch (e: any) { - console.error(`ImpactAnalysisProcessor failed for job ${job.id}:`, e); + } catch (e: unknown) { + const recoverability = classifyWorkerError(e); + const errorCode = (e instanceof Error && 'code' in e) ? (e as any).code : undefined; + const errorMessage = e instanceof Error ? e.message : String(e); + + this.logger.error( + JSON.stringify({ + event: 'IMPACT_ANALYSIS_JOB_FAILED', + jobId: job.id, + analysisId: job.data.analysisId, + attemptsMade: job.attemptsMade, + errorCode, + errorMessage, + recoverability, + }), + ); - if (e instanceof AiOutputError) { - if ( - e.code === 'AI_JSON_PARSE_FAILED' || - e.code === 'AI_OUTPUT_SCHEMA_INVALID' || - e.code === 'AI_OUTPUT_SCHEMA_VALIDATION_FAILED' || - e.code === 'AI_OUTPUT_TRUNCATED' - ) { - if (job.attemptsMade >= 1) { - throw new UnrecoverableError(`Unrecoverable Schema Error: ${e.message}`); - } - } - } else { - const msg = (e.message || '').toLowerCase(); - if (msg.includes('auth') || msg.includes('key') || msg.includes('quota') || msg.includes('not found') || msg.includes('forbidden')) { - throw new UnrecoverableError(`Unrecoverable Provider Error: ${e.message}`); - } + if (recoverability === 'UNRECOVERABLE') { + throw new UnrecoverableError( + `[${errorCode ?? 'UNKNOWN'}] ${errorMessage}`, + ); } throw e; diff --git a/apps/worker/src/impact-analysis/impact-analysis.worker.module.ts b/apps/worker/src/impact-analysis/impact-analysis.worker.module.ts index b3ee286e..d94356eb 100644 --- a/apps/worker/src/impact-analysis/impact-analysis.worker.module.ts +++ b/apps/worker/src/impact-analysis/impact-analysis.worker.module.ts @@ -1,24 +1,128 @@ import { Module } from '@nestjs/common'; -import { PrismaModule } from '../../../api/src/modules/prisma/prisma.module'; -import { PrismaService } from '../../../api/src/modules/prisma/prisma.service'; import { ImpactAnalysisProcessor } from './impact-analysis.processor'; -import { AiModule } from '../../../api/src/modules/ai/ai.module'; -import { LlmProvider } from '../../../api/src/modules/ai/domain/llm-provider.interface'; -import { RetrievalModule } from '../../../api/src/modules/retrieval/retrieval.module'; -import { HybridRetrievalService } from '../../../api/src/modules/retrieval/application/hybrid-retrieval.service'; -import { DomainPackModule } from '../../../api/src/modules/domain-pack/domain-pack.module'; -import { ImpactAnalysisModule } from '../../../api/src/modules/impact-analysis/impact-analysis.module'; -import { RunImpactAnalysisUseCase } from '@ba-helper/application'; +import { + RunImpactAnalysisUseCase, + ImpactEvidenceCollectionStep, + ImpactDiagnosticPropagationStep, + ImpactAiReasoningStep, + LlmProviderPort, +} from '@ba-helper/application'; +// v0.1: use API PrismaModule/PrismaService so repository constructors receive the correct type. +// Worker-local PrismaModule is only for modules with worker-only infrastructure (embedding). +import { PrismaModule } from '@ba-helper/backend-runtime'; +import { PrismaService } from '@ba-helper/backend-runtime'; +// Infrastructure repositories imported from apps/api (no controller leak — pure infrastructure) +import { ImpactAnalysisRepository } from '@ba-helper/backend-runtime'; +import { InsightRepository } from '@ba-helper/backend-runtime'; +import { ArtifactRepository } from '@ba-helper/backend-runtime'; +import { EvidenceRepository } from '@ba-helper/backend-runtime'; +import { TraceabilityRepository } from '@ba-helper/backend-runtime'; +import { EventLogRepository } from '@ba-helper/backend-runtime'; +import { EventLogService } from '@ba-helper/backend-runtime'; +import { EventLogPortAdapter } from '@ba-helper/backend-runtime'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; +import { RetrievalModule } from '@ba-helper/backend-runtime'; +import { HybridRetrievalService } from '@ba-helper/backend-runtime'; + +/** + * Worker-scoped ImpactAnalysis module. + * Wires RunImpactAnalysisUseCase without loading API HTTP controllers. + * LlmProviderPort is provided globally by AiModule.forRoot() in app.module.ts. + * + * Infrastructure repository classes are imported from apps/api paths. + * NOTE: This is a known v0.1 constraint. Post-v0.1, these should be moved to + * packages/application or a shared backend-infrastructure package. + */ @Module({ - imports: [PrismaModule, AiModule, RetrievalModule, DomainPackModule, ImpactAnalysisModule], + imports: [PrismaModule, RetrievalModule], providers: [ ImpactAnalysisProcessor, + DomainPackRegistry, + { + provide: EventLogRepository, + useFactory: (prisma: PrismaService) => new EventLogRepository(prisma), + inject: [PrismaService], + }, + { + provide: EventLogService, + useFactory: (repo: EventLogRepository) => new EventLogService(repo), + inject: [EventLogRepository], + }, + { + provide: ImpactAnalysisRepository, + useFactory: (prisma: PrismaService) => new ImpactAnalysisRepository(prisma), + inject: [PrismaService], + }, + { + provide: InsightRepository, + useFactory: (prisma: PrismaService) => new InsightRepository(prisma), + inject: [PrismaService], + }, + { + provide: ArtifactRepository, + useFactory: (prisma: PrismaService) => new ArtifactRepository(prisma), + inject: [PrismaService], + }, + { + provide: EvidenceRepository, + useFactory: (prisma: PrismaService) => new EvidenceRepository(prisma), + inject: [PrismaService], + }, + { + provide: TraceabilityRepository, + useFactory: (prisma: PrismaService) => new TraceabilityRepository(prisma), + inject: [PrismaService], + }, + { + provide: ImpactEvidenceCollectionStep, + useFactory: ( + artifactRepo: ArtifactRepository, + evidenceRepo: EvidenceRepository, + traceabilityRepo: TraceabilityRepository, + retrievalService: HybridRetrievalService, + ) => new ImpactEvidenceCollectionStep(artifactRepo, evidenceRepo, traceabilityRepo, retrievalService), + inject: [ArtifactRepository, EvidenceRepository, TraceabilityRepository, HybridRetrievalService], + }, + { + provide: ImpactDiagnosticPropagationStep, + useFactory: () => new ImpactDiagnosticPropagationStep(), + }, + { + provide: ImpactAiReasoningStep, + useFactory: (llmProvider: LlmProviderPort) => new ImpactAiReasoningStep(llmProvider), + inject: [LlmProviderPort], + }, { provide: RunImpactAnalysisUseCase, - useExisting: RunImpactAnalysisUseCase, + useFactory: ( + impactRepo: ImpactAnalysisRepository, + insightRepo: InsightRepository, + domainPackRegistry: DomainPackRegistry, + evidenceStep: ImpactEvidenceCollectionStep, + diagnosticStep: ImpactDiagnosticPropagationStep, + aiReasoningStep: ImpactAiReasoningStep, + eventLogService: EventLogService, + ) => + new RunImpactAnalysisUseCase( + impactRepo, + insightRepo, + domainPackRegistry, + evidenceStep, + diagnosticStep, + aiReasoningStep, + new EventLogPortAdapter(eventLogService), + ), + inject: [ + ImpactAnalysisRepository, + InsightRepository, + DomainPackRegistry, + ImpactEvidenceCollectionStep, + ImpactDiagnosticPropagationStep, + ImpactAiReasoningStep, + EventLogService, + ], }, ], }) export class ImpactAnalysisWorkerModule {} - diff --git a/apps/worker/src/impact-analysis/job-error-classifier.ts b/apps/worker/src/impact-analysis/job-error-classifier.ts new file mode 100644 index 00000000..4ff09f0d --- /dev/null +++ b/apps/worker/src/impact-analysis/job-error-classifier.ts @@ -0,0 +1,47 @@ +import { AppError } from '@ba-helper/shared'; +import { AiOutputError } from '@ba-helper/application'; + +/** Typed classification result for worker job failures. */ +export type JobErrorRecoverability = 'RETRYABLE' | 'UNRECOVERABLE'; + +/** + * Classifies a worker job error into RETRYABLE or UNRECOVERABLE. + * + * Priority: typed error codes → AppError codes → fallback heuristic. + * No string-matching on error messages except as explicit fallback boundary. + */ +export function classifyWorkerError(error: unknown): JobErrorRecoverability { + // 1. Typed AI output errors — schema/parse failures are unrecoverable on retry + if (error instanceof AiOutputError) { + const unrecoverableAiCodes = new Set([ + 'AI_JSON_PARSE_FAILED', + 'AI_OUTPUT_SCHEMA_INVALID', + 'AI_OUTPUT_SCHEMA_VALIDATION_FAILED', + 'AI_OUTPUT_TRUNCATED', + 'AI_EMPTY_RESPONSE', + ]); + if (unrecoverableAiCodes.has(error.code)) { + return 'UNRECOVERABLE'; + } + return 'RETRYABLE'; + } + + // 2. Typed AppError codes — known unrecoverable states + if (error instanceof AppError) { + const unrecoverableAppCodes = new Set([ + 'AI_PROVIDER_AUTH_FAILED', + 'IMPACT_ANALYSIS_NOT_FOUND', + 'SCAN_JOB_NOT_FOUND', + 'UNSUPPORTED_FRAMEWORK', + 'UNSUPPORTED_DOMAIN_PACK', + 'UNSUPPORTED_DOMAIN_PACK_VERSION', + ]); + if (unrecoverableAppCodes.has(error.code)) { + return 'UNRECOVERABLE'; + } + return 'RETRYABLE'; + } + + // 3. Fallback: treat as retryable unless we can positively identify it as unrecoverable + return 'RETRYABLE'; +} diff --git a/apps/worker/src/prisma/prisma.module.ts b/apps/worker/src/prisma/prisma.module.ts new file mode 100644 index 00000000..ec0ce329 --- /dev/null +++ b/apps/worker/src/prisma/prisma.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/worker/src/prisma/prisma.service.ts b/apps/worker/src/prisma/prisma.service.ts new file mode 100644 index 00000000..ba1a6ce1 --- /dev/null +++ b/apps/worker/src/prisma/prisma.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { Pool } from 'pg'; +import { requireEnv } from '@ba-helper/shared'; + +@Injectable() +export class PrismaService extends PrismaClient { + private readonly pool: Pool; + + constructor() { + const pool = new Pool({ + connectionString: requireEnv('DATABASE_URL', 'postgresql://localhost/ba_helper'), + }); + const adapter = new PrismaPg(pool); + super({ adapter } as ConstructorParameters[0]); + this.pool = pool; + } + + async onModuleInit(): Promise { + await this.$connect(); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + await this.pool.end(); + } +} diff --git a/apps/worker/src/scan-job/scan-job.processor.ts b/apps/worker/src/scan-job/scan-job.processor.ts index cfbac4b3..cc0a84b7 100644 --- a/apps/worker/src/scan-job/scan-job.processor.ts +++ b/apps/worker/src/scan-job/scan-job.processor.ts @@ -1,6 +1,7 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Job } from 'bullmq'; -import { RunScanJobUseCase } from '../../../api/src/modules/scanner/application/run-scan-job.usecase'; +// v0.1 constraint: RunScanJobUseCase lives in apps/api until extracted to a shared package. +import { RunScanJobUseCase } from '@ba-helper/backend-runtime'; @Processor('scan-job') export class ScanJobProcessor extends WorkerHost { diff --git a/apps/worker/src/scan-job/scan-job.worker.module.ts b/apps/worker/src/scan-job/scan-job.worker.module.ts index f820bf01..62d85f37 100644 --- a/apps/worker/src/scan-job/scan-job.worker.module.ts +++ b/apps/worker/src/scan-job/scan-job.worker.module.ts @@ -1,11 +1,89 @@ import { Module } from '@nestjs/common'; -import { ScannerModule } from '../../../api/src/modules/scanner/scanner.module'; import { ScanJobProcessor } from './scan-job.processor'; +import { RunScanJobUseCase } from '@ba-helper/backend-runtime'; +import { RunScanJobPersistenceStep } from '@ba-helper/backend-runtime'; +import { ScanJobRepository } from '@ba-helper/backend-runtime'; +import { RepositoryRepository } from '@ba-helper/backend-runtime'; +import { ArtifactRepository } from '@ba-helper/backend-runtime'; +import { EvidenceRepository } from '@ba-helper/backend-runtime'; +import { GraphRepository } from '@ba-helper/backend-runtime'; +import { EventLogRepository } from '@ba-helper/backend-runtime'; +import { EventLogService } from '@ba-helper/backend-runtime'; +import { QueueModule } from '@ba-helper/backend-runtime'; +import { QueueService } from '@ba-helper/backend-runtime'; +// v0.1: use API PrismaModule/PrismaService so repository constructors receive the correct type. +// Worker-local PrismaModule is only for modules with worker-only infrastructure (embedding). +import { PrismaModule } from '@ba-helper/backend-runtime'; +import { PrismaService } from '@ba-helper/backend-runtime'; +/** + * Worker-scoped ScanJob module. + * Wires RunScanJobUseCase without loading ScannerModule (which includes the HTTP controller). + * Infrastructure classes are imported from apps/api paths (v0.1 constraint). + * + * NOTE: Post-v0.1, infrastructure classes should be extracted to + * a shared backend-infrastructure package. + */ @Module({ - imports: [ScannerModule], + imports: [PrismaModule, QueueModule], providers: [ ScanJobProcessor, + { + provide: ScanJobRepository, + useFactory: (prisma: PrismaService) => new ScanJobRepository(prisma), + inject: [PrismaService], + }, + { + provide: RepositoryRepository, + useFactory: (prisma: PrismaService) => new RepositoryRepository(prisma), + inject: [PrismaService], + }, + { + provide: ArtifactRepository, + useFactory: (prisma: PrismaService) => new ArtifactRepository(prisma), + inject: [PrismaService], + }, + { + provide: EvidenceRepository, + useFactory: (prisma: PrismaService) => new EvidenceRepository(prisma), + inject: [PrismaService], + }, + { + provide: GraphRepository, + useFactory: (prisma: PrismaService) => new GraphRepository(prisma), + inject: [PrismaService], + }, + { + provide: EventLogRepository, + useFactory: (prisma: PrismaService) => new EventLogRepository(prisma), + inject: [PrismaService], + }, + { + provide: EventLogService, + useFactory: (repo: EventLogRepository) => new EventLogService(repo), + inject: [EventLogRepository], + }, + { + provide: RunScanJobPersistenceStep, + useFactory: ( + prisma: PrismaService, + artifactRepo: ArtifactRepository, + graphRepo: GraphRepository, + evidenceRepo: EvidenceRepository, + scanJobRepo: ScanJobRepository, + ) => new RunScanJobPersistenceStep(prisma, artifactRepo, graphRepo, evidenceRepo, scanJobRepo), + inject: [PrismaService, ArtifactRepository, GraphRepository, EvidenceRepository, ScanJobRepository], + }, + { + provide: RunScanJobUseCase, + useFactory: ( + scanJobRepo: ScanJobRepository, + eventLogService: EventLogService, + queueService: QueueService, + persistenceStep: RunScanJobPersistenceStep, + ) => new RunScanJobUseCase(scanJobRepo, eventLogService, queueService, persistenceStep), + inject: [ScanJobRepository, EventLogService, QueueService, RunScanJobPersistenceStep], + }, ], }) export class ScanJobWorkerModule {} diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json index c2a3dbe2..9cd0f7d7 100644 --- a/apps/worker/tsconfig.json +++ b/apps/worker/tsconfig.json @@ -6,7 +6,6 @@ }, "include": [ "src/**/*.ts", - "../api/src/**/*.ts", "../../packages/*/src/**/*.ts", "../../types.d.ts" ] diff --git a/docker-compose.yml b/docker-compose.yml index c2b79e39..44782c01 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,4 @@ +name: ba-helper services: postgres: image: pgvector/pgvector:pg16 diff --git a/docs/agent/localization-adr.md b/docs/agent/localization-adr.md new file mode 100644 index 00000000..7ea71479 --- /dev/null +++ b/docs/agent/localization-adr.md @@ -0,0 +1,542 @@ +# Post-v0.1 Backlog: Localized Report Rendering + +## Status + +APPROVED as post-v0.1 backlog design. +DO NOT implement before v0.1 tag. + +This is not part of v0.1 debt closure. It must only be implemented after the v0.1 boundary hardening is verified on the remote branch, including real `@ba-helper/backend-runtime` extraction, removal of `@ba-helper/api/*`, removal of worker imports from `apps/api/src`, and CI architecture checks. + +--- + +## 1. Core Invariant + +Analysis remains English-only and evidence-backed. + +Localization is a derived presentation artifact over a canonical English approved report. It must not mutate canonical report data, create new insights, create evidence, change traceability, or invoke scanner/retrieval/impact-analysis use cases. + +Canonical source: + +English approved report / canonical structured report context. This is the technical/audit source for review, traceability, and export provenance. + +Localized report: + +A derived presentation artifact for a target locale such as `vi-VN`, `ja-JP`, or another supported BCP-47 locale. It is not the canonical review source. + +--- + +## 2. Implementation Boundary + +Localization transforms structured report context, validates invariants, then renders localized Markdown. + +It must not blindly translate final Markdown, because Markdown translation can corrupt code fences, tables, anchors, file paths, evidence formatting, and traceability references. + +Correct pipeline: + +```txt +CanonicalReportContext EN +-> select translatable fields +-> translate selected text fields +-> validate structural invariants +-> LocalizedReportContext vi-VN +-> MarkdownImpactReportBuilder +-> LocalizedReportArtifact.contentMarkdown +``` + +Wrong pipeline: + +```txt +English Markdown +-> LLM translate entire Markdown +-> save translated Markdown +``` + +The second approach is forbidden. + +--- + +## 3. Component Design + +Primary service name: + +```txt +ReportLocalizationService +``` + +Alternative acceptable names: + +```txt +LocalizedReportRenderer +ReportLocalizationRenderer +``` + +Do not call it `TranslationWorkerService` unless the implementation is actually queue-backed. If async processing is later needed, add a separate processor: + +```txt +ReportLocalizationJobProcessor +``` + +Responsibilities of `ReportLocalizationService`: + +```txt +1. Load canonical English approved report context. +2. Validate target locale against supported locale registry. +3. Load domain glossary for target locale. +4. Select only translatable fields from structured report context. +5. Call translation provider with strict schema. +6. Validate returned localized context structurally. +7. Render localized Markdown via MarkdownImpactReportBuilder. +8. Save LocalizedReportArtifact. +9. Mark FAILED and fallback to English if validation fails. +``` + +It must not call: + +```txt +RunScanJobUseCase +RunImpactAnalysisUseCase +retrieval services +scanner/analyzer services +evidence collection steps +AI reasoning steps +``` + +Localization is not analysis. + +--- + +## 4. Field Policy + +### Never translate + +These fields must remain byte-for-byte or value-equivalent unchanged: + +```txt +id +analysisId +runId +snapshotId +repositoryId +artifactId +insightId +evidenceId +artifactKey +insightKey +evidenceKey +filePath +startLine +endLine +commitSha +symbolName +className +methodName +functionName +API route +HTTP method +enum +status +certainty +linkType +review decision +artifact reference +evidence reference +traceability link +``` + +### Quote or snippet policy + +```txt +- Source code snippets: never translate. +- File paths, symbols, routes, method names: never translate. +- Requirement/business text quotes: preserve original quote. +- Never replace original quoteOrSnippet. +- Optionally add localizedExplanation beside the original quote. +``` + +Correct example: + +```ts +{ + quoteOrSnippet: "return paymentService.refund(booking.id)", + localizedExplanation: "Đoạn code này cho thấy luồng hủy có gọi xử lý hoàn tiền." +} +``` + +Wrong example: + +```ts +{ + quoteOrSnippet: "Trả về thao tác hoàn tiền cho booking..." +} +``` + +### Translate allowed + +Only human-facing presentation fields may be translated: + +```txt +report title +section heading +human summary +risk description +unknown description +QA scenario description +recommended action +business explanation +review note label text +localizedExplanation +``` + +### Translate with glossary constraint + +Domain terms must follow glossary: + +```txt +cancellation +refund +booking +deposit +contract +tenant +landlord +inventory reservation +shipment +payment +approval +rejection +reservation +``` + +Glossary must be domain-aware and locale-aware. + +--- + +## 5. Locale Registry Policy + +Use BCP-47 locale tags. + +Example registry: + +```ts +export const supportedReportLocales = ['en', 'vi-VN', 'ja-JP'] as const; + +export type SupportedReportLocale = typeof supportedReportLocales[number]; +``` + +Rules: + +```txt +1. Unsupported locale requests are rejected before localization starts. +2. Locale must be validated before creating a localization job/artifact. +3. English canonical report is always available. +4. Target locale cannot overwrite English canonical content. +``` + +--- + +## 6. Glossary Policy + +Domain-specific localization must fail closed if glossary is unavailable. + +Rule: + +```txt +No glossary, no localized domain-specific report. +``` + +Failure code: + +```txt +GLOSSARY_NOT_AVAILABLE +``` + +Generic UI/report labels may use static locale dictionaries, but domain-specific report content requires glossary. + +Examples: + +```txt +"Summary" -> can use static dictionary +"Cancellation policy" -> requires domain glossary +"Refund after booking cancellation" -> requires domain glossary +``` + +--- + +## 7. Database Design + +Localized reports must be stored as derived artifacts, separate from canonical generated documents. + +Recommended model: + +```prisma +model LocalizedReportArtifact { + id String @id @default(uuid()) + + sourceDocumentId String + sourceDocument GeneratedDocument @relation(fields: [sourceDocumentId], references: [id], onDelete: Cascade) + + locale String // BCP-47 locale tag, e.g. "vi-VN" + sourceLocale String @default("en") + localizationStatus LocalizationStatus + + contentMarkdown String? + sourceContentHash String // Hash of canonical structured report context + + glossaryVersion String? + provider String? + model String? + translationPromptVersion String? + structuralValidatorVersion String? + fieldPolicyVersion String? + + errorCode String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([sourceDocumentId, locale]) + @@index([sourceDocumentId]) + @@index([locale]) + @@index([localizationStatus]) +} + +enum LocalizationStatus { + QUEUED + COMPLETED + FAILED +} +``` + +If Prisma requires a back-reference, add this to `GeneratedDocument`: + +```prisma +localizedArtifacts LocalizedReportArtifact[] +``` + +--- + +## 8. Source Content Hash + +`sourceContentHash` must be computed from a canonical JSON serialization of `CanonicalReportContext`. + +It must not be computed from: + +```txt +localized Markdown +non-deterministic object order +translated text +rendered HTML +client-side output +``` + +Recommended rule: + +```txt +Hash source = canonical structured report context, not final Markdown. +``` + +If the canonical report context changes, existing localized artifacts whose `sourceContentHash` no longer matches must be considered stale and must not be served as current. + +--- + +## 9. Localization Status Invariants + +Application/service layer must enforce: + +```txt +If localizationStatus = COMPLETED: + contentMarkdown must be non-null. + +If localizationStatus = FAILED: + contentMarkdown must be null or ignored. + errorCode should be present. + +If localizationStatus = QUEUED: + contentMarkdown must be null. +``` + +These can be enforced through service tests if Prisma/DB check constraints are not used. + +--- + +## 10. Translation Prompt Constraint + +The translation LLM must receive a narrow task. It must not analyze code or infer new business logic. + +Base instruction: + +```txt +You are localizing a finalized technical impact report. Do not infer new risks, do not add evidence, do not remove evidence, do not change IDs, keys, file paths, code symbols, enum values, statuses, line numbers, or snippets. Translate only fields explicitly marked as translatable. +``` + +Additional required instruction: + +```txt +Preserve all IDs, artifact keys, evidence keys, file paths, code symbols, HTTP methods, enum values, certainty values, review decisions, and traceability references exactly. Do not create, remove, reorder, or merge array items. Return output that matches the provided schema exactly. +``` + +--- + +## 11. Structural Validation + +The validator must enforce: + +```txt +1. Same IDs. +2. Same artifact/evidence references. +3. Same file paths. +4. Same code symbols. +5. Same commit SHAs. +6. Same line numbers. +7. Same enum/status/certainty/linkType values. +8. Same array cardinality. +9. Same ordering unless explicitly allowed. +10. No new risks. +11. No new unknowns. +12. No new QA scenarios. +13. No new evidence. +14. No removed evidence. +15. No changed traceability links. +16. Raw source code snippets unchanged. +17. quoteOrSnippet preserved. +18. Only allowed fields are localized. +``` + +If validation fails: + +```txt +localizationStatus = FAILED +errorCode = STRUCTURAL_VALIDATION_FAILED +fallback to English canonical report +do not save invalid localized Markdown as completed +``` + +--- + +## 12. Failure Behavior + +Localization failure must not block canonical export. + +Rules: + +```txt +1. Canonical English report remains available. +2. Failed localization marks artifact/job as FAILED. +3. User sees fallback English report. +4. Failure is visible through status/errorCode. +5. Failed localization does not mutate canonical report. +6. Failed localization does not rerun analysis. +``` + +Common failure codes: + +```txt +UNSUPPORTED_LOCALE +GLOSSARY_NOT_AVAILABLE +TRANSLATION_PROVIDER_FAILED +TRANSLATION_OUTPUT_INVALID +STRUCTURAL_VALIDATION_FAILED +SOURCE_DOCUMENT_NOT_FOUND +SOURCE_DOCUMENT_NOT_APPROVED +SOURCE_HASH_MISMATCH +``` + +--- + +## 13. Acceptance Criteria + +1. Localized report preserves all IDs and artifact/evidence references. +2. File paths, code symbols, commit SHAs, and line numbers are unchanged. +3. Raw code snippets are unchanged. +4. Array cardinality is unchanged. +5. English canonical report remains available. +6. Localized report is marked as derived from English source. +7. Failed localization does not block canonical report export. +8. Glossary version is stored in metadata. +9. Output schema validation strictly enforces structural invariants. +10. Localized artifact stores `sourceContentHash` and `sourceDocumentId`. +11. Localization never calls scanner, retrieval, or impact-analysis use cases. +12. Localization prompt/output cannot create new risks, unknowns, QA scenarios, or evidence links. +13. Unsupported locale is rejected before localization starts. +14. Localization operates on structured report context, not raw full Markdown. +15. Existing localized artifact becomes stale if `sourceContentHash` no longer matches canonical source. +16. Glossary absence follows explicit policy: fail closed by default. +17. Code fences, Markdown tables, anchors, and evidence formatting are generated by the report builder, not by direct LLM translation of Markdown. +18. If `localizationStatus = COMPLETED`, `contentMarkdown` is non-null. +19. If `localizationStatus = FAILED`, invalid localized Markdown is not served as completed output. +20. Translation metadata includes provider, model, glossaryVersion, translationPromptVersion, structuralValidatorVersion, and fieldPolicyVersion. + +--- + +## 14. Suggested Post-v0.1 Implementation Order + +Only start after v0.1 tag and verified boundary hardening. + +Recommended phase name: + +```txt +v0.2-localized-report-rendering +``` + +Implementation sequence: + +```txt +1. Add ADR/backlog design note for Localized Report Rendering. +2. Add supported locale registry. +3. Add glossary lookup contract for report localization. +4. Add LocalizedReportArtifact schema and migration. +5. Add sourceContentHash canonicalization helper. +6. Add field-selection policy for CanonicalReportContext. +7. Add ReportLocalizationService skeleton. +8. Add translation provider port. +9. Add fake deterministic translation provider for tests. +10. Add structural validator. +11. Add Markdown rendering from LocalizedReportContext. +12. Add persistence flow for LocalizedReportArtifact. +13. Add failure behavior and fallback to English. +14. Add API endpoint only after service tests pass. +15. Add UI selector only after API contract is stable. +``` + +--- + +## 15. Tests Required + +Minimum tests: + +```txt +1. Unsupported locale is rejected. +2. Missing glossary fails closed. +3. Source code snippets are unchanged. +4. File paths are unchanged. +5. Artifact IDs are unchanged. +6. Evidence IDs are unchanged. +7. Array lengths are unchanged. +8. New risk added by translation output is rejected. +9. Removed evidence is rejected. +10. Changed line number is rejected. +11. Changed enum/status/certainty is rejected. +12. Failed localization does not block English report. +13. Completed localization requires contentMarkdown. +14. Stale localized artifact detected by sourceContentHash mismatch. +15. Markdown builder renders localized context without corrupting code fences. +``` + +--- + +## 16. Final Decision + +This backlog item is approved as a post-v0.1 design. + +Do not implement before v0.1 tag. + +Before this begins, the repository must already have: + +```txt +1. Real @ba-helper/backend-runtime extraction on remote. +2. No @ba-helper/api/* alias. +3. No worker include/import from apps/api/src. +4. CI architecture verification. +5. API, worker, backend-runtime builds in CI. +6. Review coverage contract parsing. +7. v0.1 release tag. +``` diff --git a/docs/agent/project-memory.md b/docs/agent/project-memory.md index 7aae7e52..de45020d 100644 --- a/docs/agent/project-memory.md +++ b/docs/agent/project-memory.md @@ -52,16 +52,24 @@ The project already has a strong trust layer: * TypeScript/NestJS extraction * Java Spring pilot extraction -## Current biggest gaps +## v0.1 Closed Foundations -The biggest missing pieces are: +The following gaps have been closed and are part of the v0.1 foundation: -1. Scan pipeline atomicity: do not publish or index snapshots until artifacts, edges, evidence, diagnostics, and job linkage persist safely -2. Evidence quality: distinguish strong source evidence, weak source evidence, structural inference, domain-hint-only support, missing evidence, and conflicting evidence -3. Impact precision evaluation: measure expected artifact hits, false positives, missing critical artifacts, evidenced insight ratio, unknown quality, and QA usefulness -4. Review coverage: make it clear what has been reviewed before a report is trusted or finalized -5. Report trust UX: show evidenced/inferred/unknown/stale/reviewed/provenance state clearly -6. Drift and re-analysis lifecycle: warning when old analysis may no longer be trustworthy +1. **Scan pipeline atomicity**: snapshots only published when artifacts, edges, evidence, diagnostics, and job linkage persist safely ✅ +2. **Evidence quality**: strong source evidence, weak source evidence, structural inference, domain-hint-only support, missing evidence, and conflicting evidence are tracked ✅ +3. **Impact precision evaluation**: expected artifact hits, false positives, missing critical artifacts, evidenced insight ratio, unknown quality, and QA usefulness are measured ✅ +4. **Review coverage**: deterministic coverage gates with stable IDs, clear reviewed-before-trusted semantics ✅ +5. **Report trust UX**: evidenced/inferred/unknown/stale/reviewed/provenance state shown clearly ✅ +6. **Drift and re-analysis lifecycle**: stale analysis warnings when old analysis may no longer be trustworthy ✅ + +## Post-v0.1 Backlog (Do Not Implement Without Explicit Scope) + +- Extract infrastructure repository classes from `apps/api` to a shared backend package +- Multi-tenant `organizationId` boundary enforcement +- Private repository OAuth integration +- Scanner maturity gates for non-NestJS frameworks +- Full Java Spring Boot scan parity ## Engineering principle diff --git a/errors.txt b/errors.txt deleted file mode 100644 index 8636e070..00000000 --- a/errors.txt +++ /dev/null @@ -1,24 +0,0 @@ -src/modules/document/api/document.controller.ts(1,46): error TS2300: Duplicate identifier 'Res'. -src/modules/document/api/document.controller.ts(18,10): error TS2300: Duplicate identifier 'Res'. -src/modules/document/application/commands/create-reviewed-report-snapshot.usecase.ts(63,11): error TS2322: Type 'null' is not assignable to type 'string'. -src/modules/document/application/commands/create-reviewed-report-snapshot.usecase.ts(66,11): error TS2322: Type 'EvaluationContext | null' is not assignable to type 'InputJsonValue | NullableJsonNullValueInput | undefined'. - Type 'null' is not assignable to type 'InputJsonValue | NullableJsonNullValueInput | undefined'. -src/modules/document/application/commands/enqueue-document-job.usecase.ts(94,13): error TS2322: Type 'null' is not assignable to type 'InputJsonValue | NullableJsonNullValueInput | undefined'. -src/modules/document/document.module.ts(24,35): error TS2307: Cannot find module './worker/document-job.worker' or its corresponding type declarations. -src/modules/impact-analysis/api/impact-analysis.controller.ts(413,58): error TS2345: Argument of type '{ analysisId: string; acknowledgeUnreviewed: boolean; }' is not assignable to parameter of type '{ analysisId: string; acknowledgeUnreviewed: boolean; userId: string; }'. - Property 'userId' is missing in type '{ analysisId: string; acknowledgeUnreviewed: boolean; }' but required in type '{ analysisId: string; acknowledgeUnreviewed: boolean; userId: string; }'. -src/modules/impact-analysis/application/lifecycle/finalize-impact-analysis.usecase.spec.ts(98,7): error TS2554: Expected 5 arguments, but got 11. -src/modules/impact-analysis/application/lifecycle/finalize-impact-analysis.usecase.spec.ts(162,42): error TS2345: Argument of type '{ analysisId: string; acknowledgeUnreviewed: boolean; }' is not assignable to parameter of type '{ analysisId: string; acknowledgeUnreviewed: boolean; userId: string; }'. - Property 'userId' is missing in type '{ analysisId: string; acknowledgeUnreviewed: boolean; }' but required in type '{ analysisId: string; acknowledgeUnreviewed: boolean; userId: string; }'. -src/modules/impact-analysis/application/lifecycle/finalize-impact-analysis.usecase.spec.ts(197,27): error TS2345: Argument of type '{ analysisId: string; acknowledgeUnreviewed: boolean; }' is not assignable to parameter of type '{ analysisId: string; acknowledgeUnreviewed: boolean; userId: string; }'. -src/modules/impact-analysis/application/lifecycle/finalize-impact-analysis.usecase.spec.ts(216,34): error TS2345: Argument of type '{ analysisId: string; acknowledgeUnreviewed: boolean; }' is not assignable to parameter of type '{ analysisId: string; acknowledgeUnreviewed: boolean; userId: string; }'. -src/modules/impact-analysis/application/lifecycle/finalize-impact-analysis.usecase.spec.ts(230,34): error TS2345: Argument of type '{ analysisId: string; acknowledgeUnreviewed: boolean; }' is not assignable to parameter of type '{ analysisId: string; acknowledgeUnreviewed: boolean; userId: string; }'. -src/modules/impact-analysis/application/lifecycle/finalize-impact-analysis.usecase.spec.ts(239,34): error TS2345: Argument of type '{ analysisId: string; acknowledgeUnreviewed: boolean; }' is not assignable to parameter of type '{ analysisId: string; acknowledgeUnreviewed: boolean; userId: string; }'. -src/modules/impact-analysis/application/lifecycle/finalize-impact-analysis.usecase.spec.ts(257,27): error TS2345: Argument of type '{ analysisId: string; acknowledgeUnreviewed: true; }' is not assignable to parameter of type '{ analysisId: string; acknowledgeUnreviewed: boolean; userId: string; }'. - Property 'userId' is missing in type '{ analysisId: string; acknowledgeUnreviewed: true; }' but required in type '{ analysisId: string; acknowledgeUnreviewed: boolean; userId: string; }'. -src/modules/impact-analysis/application/review/create-analysis-review-decision.usecase.spec.ts(74,15): error TS2554: Expected 11 arguments, but got 10. -src/modules/impact-analysis/worker/document-job.worker.ts(4,45): error TS2307: Cannot find module '../application/render/markdown-impact-report.builder' or its corresponding type declarations. -src/modules/impact-analysis/worker/document-job.worker.ts(12,36): error TS2307: Cannot find module '../infrastructure/document.repository' or its corresponding type declarations. -/home/diphungthinh/Desktop/BA_helper_test/apps/api: -[ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL] @ba-helper/api@0.1.0 typecheck: `tsc --noEmit` -Exit status 2 diff --git a/fix-imports.js b/fix-imports.js deleted file mode 100644 index 928f8f32..00000000 --- a/fix-imports.js +++ /dev/null @@ -1,85 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); - -const mapping = { - 'new-analysis-dialog': 'analysis/new-analysis/new-analysis-dialog', - 'analysis-header': 'analysis/analysis-header', - 'analysis-progress': 'analysis/analysis-progress', - 'scan-diagnostics-panel': 'analysis/scan-diagnostics-panel', - 'finalize-analysis-dialog': 'analysis/finalize-analysis-dialog', - 'finalize-dialog': 'analysis/report/finalize-dialog', - 'e2e-timeline': 'analysis/e2e-timeline', - 'impact-analysis-workspace': 'analysis/impact-analysis-workspace', - 'affected-artifact-card': 'analysis/affected-artifact-card', - 'clarification-widget': 'analysis/clarification/clarification-widget', - 'impact-matrix-table': 'matrix/impact-matrix-table', - 'matrix-artifact-detail-card': 'matrix/matrix-artifact-detail-card', - 'matrix-row-detail-drawer': 'matrix/matrix-row-detail-drawer', - 'connect-repo-dialog': 'repository/connect-repo-dialog', - 'scan-job-progress': 'repository/scan-job-progress', - 'new-requirement-dialog': 'requirement/new-requirement-dialog', - 'decision-note-form': 'review/decision-note-form', - 'review-action-panel': 'review/review-action-panel', - 'review-queue-panel': 'review/review-queue-panel', - 'insight-card': 'shared/insight/insight-card', - 'insight-filter-bar': 'shared/insight/insight-filter-bar', - 'insight-list': 'shared/insight/insight-list', - 'qa-coverage-badge': 'shared/qa/qa-coverage-badge', - 'qa-coverage-panel': 'shared/qa/qa-coverage-panel', - 'retrieval-signals': 'shared/retrieval/retrieval-signals', - 'retrieval-suggestion': 'shared/retrieval/retrieval-suggestion', - 'code-evidence-block': 'shared/retrieval/code-evidence-block', - 'evidence-inspector': 'shared/retrieval/evidence-inspector', - 'back-button': 'shared/back-button', - 'data-list': 'shared/data-list', - 'page-header': 'shared/page-header', - 'panel': 'shared/panel', - 'mermaid-renderer': 'shared/mermaid-renderer' -}; - -const findFiles = (dir) => { - let results = []; - const list = fs.readdirSync(dir); - list.forEach(file => { - file = path.join(dir, file); - const stat = fs.statSync(file); - if (stat && stat.isDirectory()) { - results = results.concat(findFiles(file)); - } else if (file.endsWith('.tsx') || file.endsWith('.ts')) { - results.push(file); - } - }); - return results; -}; - -const files = findFiles('apps/web/src'); - -files.forEach(file => { - let content = fs.readFileSync(file, 'utf8'); - let changed = false; - - for (const [oldName, newPath] of Object.entries(mapping)) { - // Replace exact absolute imports - const regex1 = new RegExp(`"@/components/workspace/${oldName}"`, 'g'); - if (regex1.test(content)) { - content = content.replace(regex1, `"@/components/workspace/${newPath}"`); - changed = true; - } - - // Replace relative imports, more tricky but let's do a simple regex for "../workspace/oldName" and "./oldName" - // Since everything was in `workspace`, any import to oldName could be `../workspace/oldName` - // Actually we can just regex for `/(['"])((\\.?\\.\\/)*)(workspace\\/)?${oldName}(['"])/` - // Wait, replacing relative paths with absolute is safer - const relRegex = new RegExp(`(['"])(\\.?\\.\\/)+(workspace\\/)?${oldName}(['"])`, 'g'); - if (relRegex.test(content)) { - content = content.replace(relRegex, `"@/components/workspace/${newPath}"`); - changed = true; - } - } - - if (changed) { - fs.writeFileSync(file, content); - console.log(`Updated ${file}`); - } -}); diff --git a/jest.ci.config.ts b/jest.ci.config.ts index a687d5c3..7335eef5 100644 --- a/jest.ci.config.ts +++ b/jest.ci.config.ts @@ -24,6 +24,7 @@ const config: Config = { '^@ba-helper/shared$': '/packages/shared/src/index.ts', '^@ba-helper/analyzer$': '/packages/analyzer/src/index.ts', '^@ba-helper/application$': '/packages/application/src/index.ts', + '^@ba-helper/backend-runtime$': '/packages/backend-runtime/src/index.ts', }, testPathIgnorePatterns: [ '/node_modules/', diff --git a/jest.config.ts b/jest.config.ts index fd3504e6..65ef0a77 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -24,6 +24,8 @@ const config: Config = { '^@ba-helper/shared$': '/packages/shared/src/index.ts', '^@ba-helper/analyzer$': '/packages/analyzer/src/index.ts', '^@ba-helper/application$': '/packages/application/src/index.ts', + '^@ba-helper/backend-runtime$': '/packages/backend-runtime/src/index.ts', + '^@ba-helper/backend-runtime$': '/packages/backend-runtime/src/index.ts', }, testPathIgnorePatterns: ['/node_modules/', '/dist/', '/build/', '/tests/fixtures/'], }; diff --git a/jest.e2e.config.ts b/jest.e2e.config.ts index 4e5675ee..7f3a731c 100644 --- a/jest.e2e.config.ts +++ b/jest.e2e.config.ts @@ -21,6 +21,9 @@ const config: Config = { '^@ba-helper/contracts$': '/packages/contracts/src/index.ts', '^@ba-helper/shared$': '/packages/shared/src/index.ts', '^@ba-helper/analyzer$': '/packages/analyzer/src/index.ts', + '^@ba-helper/backend-runtime$': '/packages/backend-runtime/src/index.ts', + '^@ba-helper/application$': '/packages/application/src/index.ts', + '^@ba-helper/backend-runtime$': '/packages/backend-runtime/src/index.ts', }, testEnvironment: 'node', testPathIgnorePatterns: ['/node_modules/', '/dist/', '/build/', '/tests/fixtures/'], diff --git a/package.json b/package.json index 27a18ea2..2040e879 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "zod": "3.23.8" }, "dependencies": { + "@ba-helper/backend-runtime": "workspace:*", "@ba-helper/contracts": "workspace:*", "react": "19.2.4", "react-dom": "19.2.4" diff --git a/packages/application/src/impact-analysis/application/run-impact-analysis.usecase.ts b/packages/application/src/impact-analysis/application/run-impact-analysis.usecase.ts index b3520da1..6b53b731 100644 --- a/packages/application/src/impact-analysis/application/run-impact-analysis.usecase.ts +++ b/packages/application/src/impact-analysis/application/run-impact-analysis.usecase.ts @@ -221,7 +221,9 @@ export class RunImpactAnalysisUseCase { ? e.code : e instanceof AiOutputError ? e.code - : 'UNKNOWN_ANALYSIS_ERROR'; + : (e instanceof Error && 'code' in e) + ? String((e as any).code) + : 'UNKNOWN_ANALYSIS_ERROR'; const errorMessage = e instanceof Error ? e.message : String(e); const errorDetails = e instanceof AiOutputError diff --git a/packages/backend-runtime/package.json b/packages/backend-runtime/package.json new file mode 100644 index 00000000..e09387e8 --- /dev/null +++ b/packages/backend-runtime/package.json @@ -0,0 +1,31 @@ +{ + "name": "@ba-helper/backend-runtime", + "version": "0.1.0", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "lint": "eslint \"src/**/*.ts\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@ba-helper/application": "workspace:*", + "@ba-helper/analyzer": "workspace:*", + "@ba-helper/contracts": "workspace:*", + "@nestjs/bullmq": "^10.1.1", + "bullmq": "^5.7.8", + "@anthropic-ai/sdk": "^0.99.0", + "@google/generative-ai": "^0.24.1", + "@prisma/adapter-pg": "7.8.0", + "openai": "^6.39.0", + "@ba-helper/shared": "workspace:*" + }, + "devDependencies": { + "@nestjs/common": "10.3.9", + "@nestjs/core": "10.3.9", + "@prisma/client": "7.8.0", + "zod": "^3.23.8", + "typescript": "^5.4.5" + } +} diff --git a/packages/backend-runtime/src/ai/ai.module.spec.ts b/packages/backend-runtime/src/ai/ai.module.spec.ts new file mode 100644 index 00000000..d2874c26 --- /dev/null +++ b/packages/backend-runtime/src/ai/ai.module.spec.ts @@ -0,0 +1,13 @@ +import { resolveAiProvider } from '@ba-helper/shared'; + +describe('resolveAiProvider', () => { + it('normalizes whitespace and casing', () => { + expect(resolveAiProvider(' Google ')).toBe('google'); + }); + + it('fails fast on unsupported provider names', () => { + expect(() => resolveAiProvider('gemeni')).toThrow( + 'Unsupported AI_PROVIDER "gemeni"', + ); + }); +}); diff --git a/packages/backend-runtime/src/ai/ai.module.ts b/packages/backend-runtime/src/ai/ai.module.ts new file mode 100644 index 00000000..88b620d1 --- /dev/null +++ b/packages/backend-runtime/src/ai/ai.module.ts @@ -0,0 +1,45 @@ +import { Module, DynamicModule } from '@nestjs/common'; +import { AiConfig, resolveAiConfig } from '@ba-helper/shared'; +import { LlmProvider } from './domain/llm-provider.interface'; +import { AI_CONFIG_TOKEN } from './domain/ai-config'; +import { FakeLlmProvider } from './infrastructure/fake-ai.provider'; +import { OpenAiLlmProvider } from './infrastructure/openai.provider'; +import { AnthropicLlmProvider } from './infrastructure/anthropic.provider'; +import { GoogleLlmProvider } from './infrastructure/google.provider'; +import { DeepseekLlmProvider } from './infrastructure/deepseek.provider'; + +@Module({}) +export class AiModule { + static forRoot(config?: Partial): DynamicModule { + const envConfig = resolveAiConfig(process.env); + + // Allow manual overrides via config parameter + const resolvedConfig: AiConfig = { + ...envConfig, + ...config, + }; + + return { + module: AiModule, + global: true, // available everywhere without re-importing + providers: [ + { provide: AI_CONFIG_TOKEN, useValue: resolvedConfig }, + { + provide: LlmProvider, + useFactory: (cfg: AiConfig) => { + switch (cfg.provider) { + case 'openai': return new OpenAiLlmProvider(cfg); + case 'anthropic': return new AnthropicLlmProvider(cfg); + case 'google': return new GoogleLlmProvider(cfg); + case 'deepseek': return new DeepseekLlmProvider(cfg); + case 'fake': + default: return new FakeLlmProvider(); + } + }, + inject: [AI_CONFIG_TOKEN], + }, + ], + exports: [LlmProvider, AI_CONFIG_TOKEN], + }; + } +} diff --git a/packages/backend-runtime/src/ai/application/ai.service.ts b/packages/backend-runtime/src/ai/application/ai.service.ts new file mode 100644 index 00000000..5fff0768 --- /dev/null +++ b/packages/backend-runtime/src/ai/application/ai.service.ts @@ -0,0 +1,25 @@ +import { AppError } from '@ba-helper/shared'; +import { impactAnalysisAiSchema, type ImpactAnalysisAiResponse } from '../domain/ai.schema'; + +export class AiService { + validateResponse(params: { + response: unknown; + allowedEvidenceKeys: string[]; + }): ImpactAnalysisAiResponse { + const parsed = impactAnalysisAiSchema.parse(params.response); + + const allowed = new Set(params.allowedEvidenceKeys); + const invalid = parsed.insights.flatMap((insight) => + (insight.evidenceKeys || []).filter((key: string) => !allowed.has(key)), + ); + + if (invalid.length > 0) { + throw new AppError( + 'INVALID_AI_EVIDENCE_REFERENCE', + 'AI response referenced evidence outside the retrieved bundle.', + ); + } + + return parsed; + } +} diff --git a/packages/backend-runtime/src/ai/application/evidence-pack.formatter.spec.ts b/packages/backend-runtime/src/ai/application/evidence-pack.formatter.spec.ts new file mode 100644 index 00000000..505931f4 --- /dev/null +++ b/packages/backend-runtime/src/ai/application/evidence-pack.formatter.spec.ts @@ -0,0 +1,20 @@ +import { EvidencePackFormatter } from './evidence-pack.formatter'; + +describe('EvidencePackFormatter', () => { + it('wraps repository content with untrusted content fences', () => { + const formatted = EvidencePackFormatter.format([ + { + artifactKey: 'service-method:booking.service.cancelBooking', + symbolName: 'BookingService.cancelBooking', + filePath: 'src/booking/booking.service.ts', + artifactType: 'SERVICE_METHOD', + excerpt: 'return this.paymentService.refund();', + retrievalMethod: 'LEXICAL', + }, + ]); + + expect(formatted).toContain('UNTRUSTED_REPOSITORY_CONTENT_START'); + expect(formatted).toContain('UNTRUSTED_REPOSITORY_CONTENT_END'); + expect(formatted).toContain('artifactKey: service-method:booking.service.cancelBooking'); + }); +}); diff --git a/packages/backend-runtime/src/ai/application/evidence-pack.formatter.ts b/packages/backend-runtime/src/ai/application/evidence-pack.formatter.ts new file mode 100644 index 00000000..9c2187fd --- /dev/null +++ b/packages/backend-runtime/src/ai/application/evidence-pack.formatter.ts @@ -0,0 +1,3 @@ +// Compat re-export: moved to @ba-helper/application +export { EvidencePackFormatter } from '@ba-helper/application'; +export type { EvidenceCandidate } from '@ba-helper/application'; diff --git a/packages/backend-runtime/src/ai/domain/ai-config.ts b/packages/backend-runtime/src/ai/domain/ai-config.ts new file mode 100644 index 00000000..c0ef70dd --- /dev/null +++ b/packages/backend-runtime/src/ai/domain/ai-config.ts @@ -0,0 +1,3 @@ +export type { AiConfig } from '@ba-helper/shared'; + +export const AI_CONFIG_TOKEN = Symbol('AI_CONFIG'); diff --git a/packages/backend-runtime/src/ai/domain/ai.errors.ts b/packages/backend-runtime/src/ai/domain/ai.errors.ts new file mode 100644 index 00000000..90062442 --- /dev/null +++ b/packages/backend-runtime/src/ai/domain/ai.errors.ts @@ -0,0 +1,3 @@ +// Compat re-export: moved to @ba-helper/application +export { AiOutputError } from '@ba-helper/application'; +export type { AiOutputErrorCode } from '@ba-helper/application'; diff --git a/packages/backend-runtime/src/ai/domain/ai.schema.ts b/packages/backend-runtime/src/ai/domain/ai.schema.ts new file mode 100644 index 00000000..f968d921 --- /dev/null +++ b/packages/backend-runtime/src/ai/domain/ai.schema.ts @@ -0,0 +1,3 @@ +// Compat re-export: moved to @ba-helper/application +export { impactAnalysisAiSchema } from '@ba-helper/application'; +export type { ImpactAnalysisAiResponse } from '@ba-helper/application'; diff --git a/packages/backend-runtime/src/ai/domain/llm-provider.interface.ts b/packages/backend-runtime/src/ai/domain/llm-provider.interface.ts new file mode 100644 index 00000000..8e242840 --- /dev/null +++ b/packages/backend-runtime/src/ai/domain/llm-provider.interface.ts @@ -0,0 +1,12 @@ +// Compat re-export: LlmProvider definition moved to @ba-helper/application +// as LlmProviderPort. Keep LlmProvider alias for backward compat. +export { + LlmProviderPort, + LlmProviderPort as LlmProvider, +} from '@ba-helper/application'; +export type { + LlmRequest, + LlmRequestOptions, + LlmCallMetadata, + LlmResult, +} from '@ba-helper/application'; diff --git a/packages/backend-runtime/src/ai/domain/prompt-registry.ts b/packages/backend-runtime/src/ai/domain/prompt-registry.ts new file mode 100644 index 00000000..8acde6a2 --- /dev/null +++ b/packages/backend-runtime/src/ai/domain/prompt-registry.ts @@ -0,0 +1,2 @@ +// Compat re-export: moved to @ba-helper/application +export { renderPrompt } from '@ba-helper/application'; diff --git a/packages/backend-runtime/src/ai/infrastructure/anthropic.provider.ts b/packages/backend-runtime/src/ai/infrastructure/anthropic.provider.ts new file mode 100644 index 00000000..c85d0c39 --- /dev/null +++ b/packages/backend-runtime/src/ai/infrastructure/anthropic.provider.ts @@ -0,0 +1,87 @@ +import { Injectable, Inject } from '@nestjs/common'; +import Anthropic from '@anthropic-ai/sdk'; +import { AppError } from '@ba-helper/shared'; +import { z } from 'zod'; +import { LlmProvider, LlmRequest, LlmResult } from '../domain/llm-provider.interface'; +import { AiConfig, AI_CONFIG_TOKEN } from '../domain/ai-config'; +import { parseStructuredLlmOutput } from './structured-output'; +import { AiPolicy } from '@ba-helper/shared'; + +@Injectable() +export class AnthropicLlmProvider extends LlmProvider { + readonly providerName = 'anthropic'; + private readonly client: Anthropic; + + constructor(@Inject(AI_CONFIG_TOKEN) private config: AiConfig) { + super(); + this.client = new Anthropic({ apiKey: this.config.apiKey }); + } + + async generateStructured( + request: LlmRequest, + schema: z.ZodSchema, + ): Promise> { + const model = request.options?.model ?? this.config.defaultModel; + const start = Date.now(); + + const safeUserPrompt = this.config.redactSecrets + ? AiPolicy.redactPayload(request.userPrompt).redactedPayload + : request.userPrompt; + + let response; + try { + response = await this.client.messages.create({ + model, + max_tokens: request.options?.maxTokens ?? this.config.maxTokens, + system: request.systemPrompt, + messages: [{ role: 'user', content: safeUserPrompt }], + }); + } catch (error: any) { + const msg = error?.message?.toLowerCase() || ''; + if (msg.includes('429') || msg.includes('rate limit') || msg.includes('quota') || error?.status === 429) { + throw new AppError('AI_PROVIDER_RATE_LIMITED', 'You have exceeded your AI provider rate limits. Please try again later or check your API quota.'); + } + if (msg.includes('timeout') || msg.includes('abort') || msg.includes('network error') || msg.includes('fetch failed')) { + throw new AppError('AI_PROVIDER_TIMEOUT', 'The AI provider timed out. Try analyzing again.'); + } + if ( + msg.includes('503') || + msg.includes('500') || + msg.includes('502') || + msg.includes('overload') || + msg.includes('unavailable') || + error?.status >= 500 + ) { + throw new AppError( + 'AI_PROVIDER_UNAVAILABLE', + 'Anthropic is temporarily unavailable or overloaded. Retry the analysis later or switch provider/model.' + ); + } + throw error; + } + + const content = response.content[0]; + const rawText = content.type === 'text' ? content.text : undefined; + + const { data, parseMode, rawLength, jsonLength } = parseStructuredLlmOutput({ + rawText, + schema, + allowJsonExtraction: true, // Anthropic doesn't force JSON natively yet + }); + + return { + data, + metadata: { + provider: 'anthropic', + model, + promptVersion: '', + durationMs: Date.now() - start, + inputTokens: response.usage?.input_tokens, + outputTokens: response.usage?.output_tokens, + parseMode, + rawLength, + jsonLength, + }, + }; + } +} diff --git a/packages/backend-runtime/src/ai/infrastructure/deepseek.provider.ts b/packages/backend-runtime/src/ai/infrastructure/deepseek.provider.ts new file mode 100644 index 00000000..c975b440 --- /dev/null +++ b/packages/backend-runtime/src/ai/infrastructure/deepseek.provider.ts @@ -0,0 +1,88 @@ +import { Injectable, Inject } from '@nestjs/common'; +import OpenAI from 'openai'; +import { AppError } from '@ba-helper/shared'; +import { z } from 'zod'; +import { LlmProvider, LlmRequest, LlmResult } from '../domain/llm-provider.interface'; +import { AiConfig, AI_CONFIG_TOKEN } from '../domain/ai-config'; +import { parseStructuredLlmOutput } from './structured-output'; + +@Injectable() +export class DeepseekLlmProvider extends LlmProvider { + readonly providerName = 'deepseek'; + private readonly client: OpenAI; + + constructor(@Inject(AI_CONFIG_TOKEN) private config: AiConfig) { + super(); + this.client = new OpenAI({ + baseURL: this.config.baseUrl, + apiKey: this.config.apiKey + }); + } + + async generateStructured( + request: LlmRequest, + schema: z.ZodSchema, + ): Promise> { + const model = request.options?.model ?? this.config.defaultModel; + const start = Date.now(); + + let response; + try { + response = await this.client.chat.completions.create({ + model, + temperature: request.options?.temperature ?? this.config.temperature, + max_tokens: request.options?.maxTokens ?? this.config.maxTokens, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: request.systemPrompt }, + { role: 'user', content: request.userPrompt }, + ], + }); + } catch (error: any) { + const msg = error?.message?.toLowerCase() || ''; + if (msg.includes('429') || msg.includes('rate limit') || msg.includes('quota') || error?.status === 429) { + throw new AppError('AI_PROVIDER_RATE_LIMITED', 'You have exceeded your AI provider rate limits. Please try again later or check your API quota.'); + } + if (msg.includes('timeout') || msg.includes('abort') || msg.includes('network error') || msg.includes('fetch failed')) { + throw new AppError('AI_PROVIDER_TIMEOUT', 'The AI provider timed out. Try analyzing again.'); + } + if ( + msg.includes('503') || + msg.includes('500') || + msg.includes('502') || + msg.includes('overload') || + msg.includes('unavailable') || + error?.status >= 500 + ) { + throw new AppError( + 'AI_PROVIDER_UNAVAILABLE', + 'DeepSeek is temporarily unavailable or overloaded. Retry the analysis later or switch provider/model.' + ); + } + throw error; + } + + // DeepSeek might return extra text around JSON if used with reasoning model or if it misbehaves + const rawText = response.choices[0].message.content; + const { data, parseMode, rawLength, jsonLength } = parseStructuredLlmOutput({ + rawText, + schema, + allowJsonExtraction: true, // safe fallback + }); + + return { + data, + metadata: { + provider: 'deepseek', + model, + promptVersion: '', // caller sets this + durationMs: Date.now() - start, + inputTokens: response.usage?.prompt_tokens, + outputTokens: response.usage?.completion_tokens, + parseMode, + rawLength, + jsonLength, + }, + }; + } +} diff --git a/packages/backend-runtime/src/ai/infrastructure/fake-ai.provider.ts b/packages/backend-runtime/src/ai/infrastructure/fake-ai.provider.ts new file mode 100644 index 00000000..abf35240 --- /dev/null +++ b/packages/backend-runtime/src/ai/infrastructure/fake-ai.provider.ts @@ -0,0 +1,189 @@ +import { Injectable } from '@nestjs/common'; +import { z } from 'zod'; +import { LlmProvider, LlmRequest, LlmResult } from '../domain/llm-provider.interface'; +import { parseStructuredLlmOutput } from './structured-output'; + +@Injectable() +export class FakeLlmProvider extends LlmProvider { + readonly providerName = 'fake'; + + async generateStructured( + request: LlmRequest, + schema: z.ZodSchema, + ): Promise> { + const start = Date.now(); + + const isOrderInventory = request.userPrompt.includes('InventoryService.releaseReservation') || + request.userPrompt.includes('OrderService.cancelOrder'); + + let mockData: any; + + if (isOrderInventory) { + mockData = { + insights: [ + { + insightKey: 'claim:cancel-order-route', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'Order cancellation endpoint exists.', + description: 'OrderController.cancelOrder provides the entrypoint for cancellation.', + evidenceKeys: ['api:order.controller.cancelOrder'], + }, + { + insightKey: 'claim:cancel-order-service', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'OrderService handles cancellation logic.', + description: 'OrderService.cancelOrder contains the business logic for cancelling an order.', + evidenceKeys: ['service-method:order.service.cancelOrder'], + }, + { + insightKey: 'claim:release-inventory', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'Inventory reservation is released.', + description: 'InventoryService.releaseReservation is called to release reserved stock.', + evidenceKeys: ['service-method:inventory.service.releaseReservation'], + } + ].filter(insight => + insight.evidenceKeys.length === 0 || + insight.evidenceKeys.some(key => request.userPrompt.includes(key)) + ), + unknowns: [ + { + insightKey: 'unknown:refund-payment', + description: 'Refund or payment behavior is missing.', + reasoning: 'No explicit refund or payment artifact was found in the context.', + }, + { + insightKey: 'unknown:shipment-boundary', + description: 'Shipment boundary behavior is missing.', + reasoning: 'It is not explicitly confirmed what happens if the order is already shipped.', + } + ], + qaScenarios: [ + { + scenarioKey: 'qa:cancel-before-shipment', + description: 'Verify order can be cancelled before shipment.', + priority: 'HIGH', + }, + { + scenarioKey: 'qa:cancel-after-shipment', + description: 'Reject cancellation after shipment started.', + priority: 'HIGH', + }, + { + scenarioKey: 'qa:duplicate-cancel', + description: 'Verify idempotency on duplicate cancel.', + priority: 'MEDIUM', + }, + { + scenarioKey: 'qa:inventory-release-fail', + description: 'Test inventory release failure handling.', + priority: 'MEDIUM', + }, + { + scenarioKey: 'qa:happy-path-release', + description: 'Reserved inventory is successfully released when cancellation succeeds.', + priority: 'HIGH', + } + ] + }; + } else { + mockData = { + insights: [ + { + insightKey: 'claim:cancel-route', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'The system exposes an API route for cancelling a booking.', + description: 'The system exposes an API route for cancelling a booking.', + evidenceKeys: ['api:booking.controller.cancel'], + }, + { + insightKey: 'claim:cancel-refund', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'Cancellation triggers a refund operation.', + description: 'Cancellation triggers a refund operation.', + evidenceKeys: ['service-method:payment.service.refund'], + }, + { + insightKey: 'claim:release-slot', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'Cancellation releases the booked slot.', + description: 'Cancellation releases the booked slot.', + evidenceKeys: ['service-method:slot.service.releaseSlot'], + }, + { + insightKey: 'claim:notify-owner', + insightType: 'CLAIM', + certainty: 'EVIDENCED', + confidence: 1, + title: 'Cancellation notifies the booking owner.', + description: 'Cancellation notifies the booking owner.', + evidenceKeys: ['service-method:notification.service.notifyOwner'], + }, + ].filter(insight => + insight.evidenceKeys.length === 0 || + insight.evidenceKeys.some(key => request.userPrompt.includes(key)) + ), + unknowns: [ + { + insightKey: 'unknown:refund-percentage', + description: 'Refund percentage is not confirmed from code evidence.', + reasoning: 'No explicit refund percentage or refund policy artifact was found.', + }, + { + insightKey: 'unknown:refund-deadline', + description: 'Refund deadline is not confirmed from code evidence.', + reasoning: 'No explicit refund deadline was found in the evidence scope.', + }, + { + insightKey: 'unknown:who-may-cancel', + description: 'Who may cancel a booking is not confirmed from code evidence.', + reasoning: 'No authorization or role checks were found in the cancellation flow.', + }, + { + insightKey: 'unknown:owner-approval', + description: 'Owner approval requirements are not confirmed from code evidence.', + reasoning: 'No approval or confirmation step was found in the cancellation flow.', + }, + { + insightKey: 'unknown:slot-reopen', + description: 'Slot re-open policy is not confirmed from code evidence.', + reasoning: 'Slot release is called, but no policy for rebooking timing was found.', + }, + ], + }; + } + + const rawText = JSON.stringify(mockData); + + const { data, parseMode, rawLength, jsonLength } = parseStructuredLlmOutput({ + rawText, + schema, + allowJsonExtraction: false, + }); + + return { + data, + metadata: { + provider: 'fake', + model: 'fake-deterministic', + promptVersion: 'test', + durationMs: Date.now() - start, + parseMode, + rawLength, + jsonLength, + }, + }; + } +} diff --git a/packages/backend-runtime/src/ai/infrastructure/google.provider.ts b/packages/backend-runtime/src/ai/infrastructure/google.provider.ts new file mode 100644 index 00000000..28878d72 --- /dev/null +++ b/packages/backend-runtime/src/ai/infrastructure/google.provider.ts @@ -0,0 +1,121 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { GoogleGenerativeAI } from '@google/generative-ai'; +import { z } from 'zod'; +import { LlmProvider, LlmRequest, LlmResult } from '../domain/llm-provider.interface'; +import { AiConfig, AI_CONFIG_TOKEN } from '../domain/ai-config'; +import { AiPolicy } from '@ba-helper/shared'; +import { parseStructuredLlmOutput } from './structured-output'; +import { AppError } from '@ba-helper/shared'; +import { AiOutputError } from '../domain/ai.errors'; + +@Injectable() +export class GoogleLlmProvider extends LlmProvider { + readonly providerName = 'google'; + private readonly client: GoogleGenerativeAI; + + constructor(@Inject(AI_CONFIG_TOKEN) private config: AiConfig) { + super(); + const apiKey = this.config.apiKey; + + if (!apiKey) { + throw new AppError( + 'AI_PROVIDER_AUTH_FAILED', + 'Google LLM provider requires GOOGLE_API_KEY, GEMINI_API_KEY, or GOOGLE_AI_API_KEY to be set.', + ); + } + + this.client = new GoogleGenerativeAI(apiKey); + } + + async generateStructured( + request: LlmRequest, + schema: z.ZodSchema, + ): Promise> { + const model = request.options?.model ?? this.config.defaultModel; + const promptVersion = request.options?.promptVersion ?? ''; + const start = Date.now(); + + // Invariant #11: Redact secrets from evidence before sending to real provider + const safeUserPrompt = this.config.redactSecrets + ? AiPolicy.redactPayload(request.userPrompt).redactedPayload + : request.userPrompt; + + const genModel = this.client.getGenerativeModel({ model }); + + let result; + try { + result = await genModel.generateContent({ + systemInstruction: request.systemPrompt, + contents: [{ role: 'user', parts: [{ text: safeUserPrompt }] }], + generationConfig: { + responseMimeType: 'application/json', + temperature: request.options?.temperature ?? this.config.temperature, + maxOutputTokens: request.options?.maxTokens ?? this.config.maxTokens, + }, + }); + } catch (error: any) { + const msg = error?.message?.toLowerCase() || ''; + if (msg.includes('429') || msg.includes('rate limit') || msg.includes('quota')) { + throw new AppError('AI_PROVIDER_RATE_LIMITED', 'You have exceeded your AI provider rate limits. Please try again later or check your API quota.'); + } + if (msg.includes('timeout') || msg.includes('abort') || msg.includes('network error') || msg.includes('fetch failed')) { + throw new AppError('AI_PROVIDER_TIMEOUT', 'The AI provider timed out. Try analyzing again.'); + } + if ( + msg.includes('503') || + msg.includes('500') || + msg.includes('502') || + msg.includes('overload') || + msg.includes('unavailable') + ) { + throw new AppError( + 'AI_PROVIDER_UNAVAILABLE', + 'Gemini is temporarily unavailable or overloaded. Retry the analysis later or switch provider/model.' + ); + } + throw error; + } + + const rawText = result.response.text(); + const finishReason = result.response.candidates?.[0]?.finishReason; + if (finishReason && finishReason !== 'STOP') { + new Logger('GoogleLlmProvider').warn(`Unexpected finishReason: ${finishReason}`); + } + + if (finishReason === 'MAX_TOKENS') { + throw new AiOutputError( + 'AI_OUTPUT_TRUNCATED', + 'Google LLM output was truncated before a complete structured response was produced.', + { + provider: 'google', + model, + finishReason, + parseMode: 'raw', + maxTokens: request.options?.maxTokens ?? this.config.maxTokens, + temperature: request.options?.temperature ?? this.config.temperature, + }, + ); + } + + const { data, parseMode, rawLength, jsonLength } = parseStructuredLlmOutput({ + rawText, + schema, + allowJsonExtraction: true, + }); + + return { + data, + metadata: { + provider: 'google', + model, + promptVersion, + durationMs: Date.now() - start, + parseMode, + rawLength, + jsonLength, + inputTokens: result.response.usageMetadata?.promptTokenCount, + outputTokens: result.response.usageMetadata?.candidatesTokenCount, + }, + }; + } +} diff --git a/packages/backend-runtime/src/ai/infrastructure/openai.provider.ts b/packages/backend-runtime/src/ai/infrastructure/openai.provider.ts new file mode 100644 index 00000000..1042b5c8 --- /dev/null +++ b/packages/backend-runtime/src/ai/infrastructure/openai.provider.ts @@ -0,0 +1,89 @@ +import { Injectable, Inject } from '@nestjs/common'; +import OpenAI from 'openai'; +import { AppError } from '@ba-helper/shared'; +import { z } from 'zod'; +import { LlmProvider, LlmRequest, LlmResult } from '../domain/llm-provider.interface'; +import { AiConfig, AI_CONFIG_TOKEN } from '../domain/ai-config'; +import { parseStructuredLlmOutput } from './structured-output'; +import { AiPolicy } from '@ba-helper/shared'; + +@Injectable() +export class OpenAiLlmProvider extends LlmProvider { + readonly providerName = 'openai'; + private readonly client: OpenAI; + + constructor(@Inject(AI_CONFIG_TOKEN) private config: AiConfig) { + super(); + this.client = new OpenAI({ apiKey: this.config.apiKey }); + } + + async generateStructured( + request: LlmRequest, + schema: z.ZodSchema, + ): Promise> { + const model = request.options?.model ?? this.config.defaultModel; + const start = Date.now(); + + const safeUserPrompt = this.config.redactSecrets + ? AiPolicy.redactPayload(request.userPrompt).redactedPayload + : request.userPrompt; + + let response; + try { + response = await this.client.chat.completions.create({ + model, + temperature: request.options?.temperature ?? this.config.temperature, + max_tokens: request.options?.maxTokens ?? this.config.maxTokens, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: request.systemPrompt }, + { role: 'user', content: safeUserPrompt }, + ], + }); + } catch (error: any) { + const msg = error?.message?.toLowerCase() || ''; + if (msg.includes('429') || msg.includes('rate limit') || msg.includes('quota') || error?.status === 429) { + throw new AppError('AI_PROVIDER_RATE_LIMITED', 'You have exceeded your AI provider rate limits. Please try again later or check your API quota.'); + } + if (msg.includes('timeout') || msg.includes('abort') || msg.includes('network error') || msg.includes('fetch failed')) { + throw new AppError('AI_PROVIDER_TIMEOUT', 'The AI provider timed out. Try analyzing again.'); + } + if ( + msg.includes('503') || + msg.includes('500') || + msg.includes('502') || + msg.includes('overload') || + msg.includes('unavailable') || + error?.status >= 500 + ) { + throw new AppError( + 'AI_PROVIDER_UNAVAILABLE', + 'OpenAI is temporarily unavailable or overloaded. Retry the analysis later or switch provider/model.' + ); + } + throw error; + } + + const rawText = response.choices[0].message.content; + const { data, parseMode, rawLength, jsonLength } = parseStructuredLlmOutput({ + rawText, + schema, + allowJsonExtraction: true, // fallback extraction for safety + }); + + return { + data, + metadata: { + provider: 'openai', + model, + promptVersion: '', // caller sets this + durationMs: Date.now() - start, + inputTokens: response.usage?.prompt_tokens, + outputTokens: response.usage?.completion_tokens, + parseMode, + rawLength, + jsonLength, + }, + }; + } +} diff --git a/packages/backend-runtime/src/ai/infrastructure/structured-output.spec.ts b/packages/backend-runtime/src/ai/infrastructure/structured-output.spec.ts new file mode 100644 index 00000000..151b2dc7 --- /dev/null +++ b/packages/backend-runtime/src/ai/infrastructure/structured-output.spec.ts @@ -0,0 +1,90 @@ +import { z } from 'zod'; +import { parseStructuredLlmOutput } from './structured-output'; +import { AiOutputError } from '../domain/ai.errors'; + +describe('Structured Output Parser', () => { + const schema = z.object({ + success: z.boolean(), + message: z.string(), + }); + + it('parses valid raw JSON directly', () => { + const rawText = JSON.stringify({ success: true, message: 'OK' }); + const result = parseStructuredLlmOutput({ rawText, schema, allowJsonExtraction: true }); + + expect(result.data).toEqual({ success: true, message: 'OK' }); + expect(result.parseMode).toBe('raw'); + expect(result.rawLength).toBe(rawText.length); + expect(result.jsonLength).toBe(rawText.length); + }); + + it('extracts valid JSON if wrapped in text', () => { + const jsonText = JSON.stringify({ success: true, message: 'Extracted' }); + const rawText = `some reasoning\nHere is the output:\n${jsonText}\nDone.`; + + const result = parseStructuredLlmOutput({ rawText, schema, allowJsonExtraction: true }); + + expect(result.data).toEqual({ success: true, message: 'Extracted' }); + expect(result.parseMode).toBe('extracted'); + expect(result.rawLength).toBe(rawText.length); + expect(result.jsonLength).toBe(jsonText.length); + }); + + it('extracts fenced JSON payloads', () => { + const rawText = '```json\n{"success":true,"message":"Fenced"}\n```'; + const result = parseStructuredLlmOutput({ rawText, schema, allowJsonExtraction: true }); + + expect(result.data).toEqual({ success: true, message: 'Fenced' }); + expect(result.parseMode).toBe('raw'); + }); + + it('throws AI_EMPTY_RESPONSE for empty string', () => { + expect(() => parseStructuredLlmOutput({ rawText: ' ', schema })) + .toThrow(AiOutputError); + + try { + parseStructuredLlmOutput({ rawText: '', schema }); + } catch (e: any) { + expect(e.code).toBe('AI_EMPTY_RESPONSE'); + } + }); + + it('throws AI_JSON_PARSE_FAILED for invalid JSON', () => { + const rawText = `{"success": true, "message": "unclosed string}`; + try { + parseStructuredLlmOutput({ rawText, schema, allowJsonExtraction: true }); + fail('Expected to throw'); + } catch (e: any) { + expect(e.code).toBe('AI_JSON_PARSE_FAILED'); + } + }); + + it('throws AI_JSON_PARSE_FAILED if wrapped JSON is invalid and extraction disabled', () => { + const jsonText = JSON.stringify({ success: true, message: 'Extracted' }); + const rawText = `Some text ${jsonText}`; + + try { + parseStructuredLlmOutput({ rawText, schema, allowJsonExtraction: false }); + fail('Expected to throw'); + } catch (e: any) { + expect(e.code).toBe('AI_JSON_PARSE_FAILED'); + } + }); + + it('throws AI_OUTPUT_SCHEMA_VALIDATION_FAILED if JSON is valid but does not match schema', () => { + const rawText = JSON.stringify({ unknownField: 'test' }); + try { + parseStructuredLlmOutput({ rawText, schema, allowJsonExtraction: true }); + fail('Expected to throw'); + } catch (e: any) { + expect(e.code).toBe('AI_OUTPUT_SCHEMA_VALIDATION_FAILED'); + expect(e.details?.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'success', + }), + ]), + ); + } + }); +}); diff --git a/packages/backend-runtime/src/ai/infrastructure/structured-output.ts b/packages/backend-runtime/src/ai/infrastructure/structured-output.ts new file mode 100644 index 00000000..9a9ad3a1 --- /dev/null +++ b/packages/backend-runtime/src/ai/infrastructure/structured-output.ts @@ -0,0 +1,165 @@ +import type { z } from 'zod'; +import { AiOutputError } from '../domain/ai.errors'; + +export interface ParseStructuredLlmOutputParams { + rawText: string | null | undefined; + schema: z.ZodSchema; + allowJsonExtraction?: boolean; +} + +export interface ParseStructuredLlmOutputResult { + data: T; + parseMode: 'raw' | 'extracted'; + rawLength: number; + jsonLength: number; +} + +const MARKDOWN_JSON_FENCE = /^```(?:json)?\s*([\s\S]*?)\s*```$/i; + +function stripMarkdownJsonFence(rawText: string): string { + const fenced = rawText.trim().match(MARKDOWN_JSON_FENCE); + return fenced ? fenced[1].trim() : rawText; +} + +function findMatchingClosingIndex( + input: string, + startIndex: number, + opening: '{' | '[', + closing: '}' | ']', +): number { + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = startIndex; index < input.length; index += 1) { + const char = input[index]; + + if (escaped) { + escaped = false; + continue; + } + + if (char === '\\') { + escaped = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (char === opening) { + depth += 1; + continue; + } + + if (char === closing) { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + + return -1; +} + +function extractTopLevelJson(rawText: string): string | null { + const normalized = stripMarkdownJsonFence(rawText).trim(); + const objectIndex = normalized.indexOf('{'); + const arrayIndex = normalized.indexOf('['); + + if (objectIndex === -1 && arrayIndex === -1) { + return null; + } + + const startIndex = + objectIndex === -1 + ? arrayIndex + : arrayIndex === -1 + ? objectIndex + : Math.min(objectIndex, arrayIndex); + const opening = normalized[startIndex] as '{' | '['; + const closing = opening === '{' ? '}' : ']'; + const endIndex = findMatchingClosingIndex(normalized, startIndex, opening, closing); + + if (endIndex === -1) { + return null; + } + + return normalized.slice(startIndex, endIndex + 1).trim(); +} + +export function parseStructuredLlmOutput({ + rawText, + schema, + allowJsonExtraction = true, +}: ParseStructuredLlmOutputParams): ParseStructuredLlmOutputResult { + if (!rawText || rawText.trim().length === 0) { + throw new AiOutputError('AI_EMPTY_RESPONSE', 'LLM returned an empty response.'); + } + + const rawLength = rawText.length; + const normalizedRaw = stripMarkdownJsonFence(rawText).trim(); + let jsonString = normalizedRaw; + let parseMode: 'raw' | 'extracted' = 'raw'; + let rawJsonObj: unknown; + + try { + rawJsonObj = JSON.parse(jsonString); + } catch { + if (!allowJsonExtraction) { + throw new AiOutputError('AI_JSON_PARSE_FAILED', 'Failed to parse JSON and extraction is disabled.', { + rawText, + }); + } + + const extractedJson = extractTopLevelJson(rawText); + if (!extractedJson) { + throw new AiOutputError('AI_JSON_PARSE_FAILED', 'Failed to parse JSON and could not extract a complete top-level JSON payload.', { + rawText, + }); + } + + jsonString = extractedJson; + try { + rawJsonObj = JSON.parse(jsonString); + parseMode = 'extracted'; + } catch { + throw new AiOutputError('AI_JSON_PARSE_FAILED', 'Failed to parse extracted JSON payload.', { + rawText, + extractedText: jsonString, + }); + } + } + + const jsonLength = jsonString.length; + const parsed = schema.safeParse(rawJsonObj); + + if (!parsed.success) { + throw new AiOutputError( + 'AI_OUTPUT_SCHEMA_VALIDATION_FAILED', + 'AI output does not match expected schema.', + { + errors: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + code: issue.code, + message: issue.message, + })), + rawJsonObj, + }, + ); + } + + return { + data: parsed.data, + parseMode, + rawLength, + jsonLength, + }; +} diff --git a/packages/backend-runtime/src/artifact/domain/universal-artifact-kind.ts b/packages/backend-runtime/src/artifact/domain/universal-artifact-kind.ts new file mode 100644 index 00000000..38a28960 --- /dev/null +++ b/packages/backend-runtime/src/artifact/domain/universal-artifact-kind.ts @@ -0,0 +1,26 @@ +import type { UniversalArtifactKind } from '@ba-helper/contracts'; + +export const normalizeArtifactKind = ( + artifactType: string, +): UniversalArtifactKind => { + switch (artifactType) { + case 'API_ROUTE': + case 'HTTP_ENDPOINT': + return 'API_ENDPOINT'; + case 'SERVICE_METHOD': + return 'DOMAIN_SERVICE'; + case 'ENTITY': + return 'DATA_MODEL'; + case 'TEST': + case 'SPRING_TEST': + return 'TEST_CASE'; + case 'SPRING_CONTROLLER_METHOD': + return 'API_ENDPOINT'; + case 'SPRING_SERVICE_METHOD': + return 'DOMAIN_SERVICE'; + case 'SPRING_ENTITY': + return 'DATA_MODEL'; + default: + return 'UNKNOWN'; + } +}; diff --git a/packages/backend-runtime/src/artifact/infrastructure/artifact.repository.ts b/packages/backend-runtime/src/artifact/infrastructure/artifact.repository.ts new file mode 100644 index 00000000..257bdc86 --- /dev/null +++ b/packages/backend-runtime/src/artifact/infrastructure/artifact.repository.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; + +type ArtifactPrismaClient = PrismaService | Prisma.TransactionClient; + +@Injectable() +export class ArtifactRepository { + constructor(private readonly prisma: PrismaService) {} + + async listBySnapshot(snapshotId: string, client: ArtifactPrismaClient = this.prisma) { + return client.codeArtifact.findMany({ + where: { snapshotId }, + }); + } + + async findById(id: string) { + return this.prisma.codeArtifact.findUnique({ + where: { id }, + }); + } + + async createMany(data: Array<{ + snapshotId: string; + artifactKey: string; + artifactType: string; + universalKind: string; + name: string; + filePath: string; + startLine?: number; + endLine?: number; + contentHash?: string | null; + }>, client: ArtifactPrismaClient = this.prisma) { + return client.codeArtifact.createMany({ + data, + skipDuplicates: true, + }); + } +} diff --git a/packages/backend-runtime/src/clarification/infrastructure/clarification.repository.ts b/packages/backend-runtime/src/clarification/infrastructure/clarification.repository.ts new file mode 100644 index 00000000..adde3581 --- /dev/null +++ b/packages/backend-runtime/src/clarification/infrastructure/clarification.repository.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { ClarificationItem, Prisma } from '@prisma/client';; + +@Injectable() +export class ClarificationRepository { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + return this.prisma.clarificationItem.findUnique({ + where: { id }, + }); + } + + async findBySourceInsightId(sourceInsightId: string): Promise { + return this.prisma.clarificationItem.findUnique({ + where: { sourceInsightId }, + }); + } + + async listByAnalysisId(impactAnalysisId: string): Promise { + return this.prisma.clarificationItem.findMany({ + where: { impactAnalysisId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async create(data: Prisma.ClarificationItemUncheckedCreateInput): Promise { + return this.prisma.clarificationItem.create({ data }); + } + + async updateStatusAndAnswer( + id: string, + status: 'ANSWERED' | 'DISMISSED', + answer: string | null = null, + reason: string | null = null, + ): Promise { + return this.prisma.clarificationItem.update({ + where: { id }, + data: { status, answer, reason }, + }); + } + + async markAsConverted(id: string, revisionId: string): Promise { + return this.prisma.clarificationItem.update({ + where: { id }, + data: { + status: 'CONVERTED_TO_REVISION', + convertedRequirementRevisionId: revisionId, + }, + }); + } +} diff --git a/packages/backend-runtime/src/document/application/evaluation-context.adapter.ts b/packages/backend-runtime/src/document/application/evaluation-context.adapter.ts new file mode 100644 index 00000000..ba5d8a74 --- /dev/null +++ b/packages/backend-runtime/src/document/application/evaluation-context.adapter.ts @@ -0,0 +1,81 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { existsSync, readFileSync } from 'fs'; +import * as path from 'path'; + +export const RESEARCH_FINDINGS_PATH = 'evaluation/results/v0/analysis/e13-research-findings.v0.json'; +export const SAME_SUBSET_COMPARISON_PATH = 'evaluation/results/v0/analysis/same-subset-comparison.v0.json'; + +export type EvaluationContext = { + datasetVersion: string; + subsetId: string; + subsetSize: string; + interpretation: 'ILLUSTRATIVE_ONLY'; + knownLimits: string[]; + evidenceQualityNotes: string[]; + datasetExpansionRecommendations: string[]; + researchFindingsArtifact: string; + sameSubsetComparisonArtifact: string; +}; + +@Injectable() +export class EvaluationContextAdapter { + private readonly logger = new Logger(EvaluationContextAdapter.name); + + getEvaluationContext(): EvaluationContext | null { + try { + const e13Path = path.resolve(process.cwd(), RESEARCH_FINDINGS_PATH); + const compPath = path.resolve(process.cwd(), SAME_SUBSET_COMPARISON_PATH); + + if (!existsSync(e13Path) || !existsSync(compPath)) { + return null; // Graceful degradation + } + + const e13Raw = readFileSync(e13Path, 'utf8'); + const e13 = JSON.parse(e13Raw); + + const compRaw = readFileSync(compPath, 'utf8'); + const comp = JSON.parse(compRaw); + + // Deep scan for unauthorized claims + if (this.hasUnauthorizedClaim(e13) || this.hasUnauthorizedClaim(comp)) { + this.logger.warn('Evaluation artifacts contain unauthorized claims. Failing evaluation context load.'); + return null; + } + + if (comp.comparisonPolicy?.winnerAllowed === true) { + this.logger.warn('comparisonPolicy.winnerAllowed is true. Failing evaluation context load.'); + return null; + } + + return { + datasetVersion: e13.datasetVersion || 'v0', + subsetId: e13.subsetId || 'clean-vector-ready-v0', + subsetSize: `${comp.caseCount}/6`, + interpretation: 'ILLUSTRATIVE_ONLY', + knownLimits: e13.knownLimits || [], + evidenceQualityNotes: e13.evidenceQuality?.methodObservations?.map((m: any) => + `Method \`${m.method}\`: rank1HasGroundTruth=${m.rank1HasGroundTruth}, signals=[${(m.traceableSignals || []).join(', ')}]` + ) || [], + datasetExpansionRecommendations: e13.datasetExpansionRecommendation || [], + researchFindingsArtifact: RESEARCH_FINDINGS_PATH, + sameSubsetComparisonArtifact: SAME_SUBSET_COMPARISON_PATH + }; + } catch (e) { + this.logger.error(`Failed to load evaluation context: ${e}`); + return null; + } + } + + private hasUnauthorizedClaim(obj: any): boolean { + if (!obj || typeof obj !== 'object') return false; + + const forbiddenKeys = ['winner', 'bestMethod', 'leaderboard', 'superiorityClaim', 'ranking']; + for (const key of Object.keys(obj)) { + if (forbiddenKeys.includes(key)) return true; + if (typeof obj[key] === 'object') { + if (this.hasUnauthorizedClaim(obj[key])) return true; + } + } + return false; + } +} diff --git a/packages/backend-runtime/src/document/application/evidence-quality.annotator.ts b/packages/backend-runtime/src/document/application/evidence-quality.annotator.ts new file mode 100644 index 00000000..e7148e3e --- /dev/null +++ b/packages/backend-runtime/src/document/application/evidence-quality.annotator.ts @@ -0,0 +1,135 @@ +import type { + EvidenceQualitySummary, + InsightForAnnotation, + QualityAnnotation, + TraceabilityLinkForAnnotation, +} from './evidence-quality.types'; +import { + buildEvidenceReasons, + emptyEvidenceQualitySummary, + hasStructuralMetadata, + hasUsableArtifact, + inspectEvidence, + isDomainHintMetadata, + isReviewRequiredInsight, + readRecord, +} from './evidence-quality.rules'; + +export type { + EvidenceQualityItem, + EvidenceQualitySummary, + InsightForAnnotation, + QualityAnnotation, + QualityLabel, + TraceabilityLinkForAnnotation, +} from './evidence-quality.types'; + +export class EvidenceQualityAnnotator { + static annotate(link: TraceabilityLinkForAnnotation): QualityAnnotation { + return this.annotateTraceabilityLink(link); + } + + static annotateTraceabilityLink(link: TraceabilityLinkForAnnotation): QualityAnnotation { + const evidence = (link.evidenceLinks ?? []).map((item) => item.evidence); + const facts = inspectEvidence(evidence, link.artifact); + const reasons = buildEvidenceReasons(facts); + const hasArtifactStructure = hasUsableArtifact(link.artifact); + + if (link.reviewStatus === 'NEEDS_REVIEW') { + reasons.push('reviewRequired'); + return { label: 'REVIEW_REQUIRED', reasons }; + } + + if (link.linkBasis === 'INFERRED') { + reasons.push('inferredLinkBasis'); + return { + label: hasArtifactStructure || facts.hasSourceEvidence + ? 'INFERRED_FROM_STRUCTURE' + : 'MISSING_EVIDENCE', + reasons, + }; + } + + if (facts.hasDomainHintOnly) { + reasons.push('domainHintOnly'); + return { label: 'DOMAIN_HINT_ONLY', reasons }; + } + + if (!facts.hasEvidence || !facts.hasSourceEvidence) { + reasons.push('missingPersistedSourceEvidence'); + return { label: 'MISSING_EVIDENCE', reasons }; + } + + return { + label: facts.hasStrongSourceEvidence ? 'STRONG_SOURCE_EVIDENCE' : 'WEAK_SOURCE_EVIDENCE', + reasons, + }; + } + + static annotateInsight(insight: InsightForAnnotation): QualityAnnotation { + const evidence = (insight.evidenceLinks ?? []).map((item) => item.evidence); + const facts = inspectEvidence(evidence); + const reasons = buildEvidenceReasons(facts); + const metadata = readRecord(insight.metadata); + + if (insight.certainty === 'CONFLICTING') { + reasons.push('conflictingCertainty'); + return { label: 'CONFLICTING_EVIDENCE', reasons }; + } + + if (isReviewRequiredInsight(insight)) { + reasons.push('reviewRequired'); + return { label: 'REVIEW_REQUIRED', reasons }; + } + + if (facts.hasDomainHintOnly || isDomainHintMetadata(metadata, insight)) { + reasons.push('domainHintOnly'); + return { label: 'DOMAIN_HINT_ONLY', reasons }; + } + + if (insight.certainty === 'INFERRED') { + reasons.push('inferredCertainty'); + return { + label: facts.hasSourceEvidence || hasStructuralMetadata(metadata) + ? 'INFERRED_FROM_STRUCTURE' + : 'MISSING_EVIDENCE', + reasons, + }; + } + + if (insight.certainty === 'UNKNOWN') { + reasons.push('unknownCertainty'); + return { label: 'MISSING_EVIDENCE', reasons }; + } + + if (!facts.hasEvidence || !facts.hasSourceEvidence) { + reasons.push('missingPersistedSourceEvidence'); + return { label: 'MISSING_EVIDENCE', reasons }; + } + + return { + label: facts.hasStrongSourceEvidence ? 'STRONG_SOURCE_EVIDENCE' : 'WEAK_SOURCE_EVIDENCE', + reasons, + }; + } + + static summarize(annotations: QualityAnnotation[]): EvidenceQualitySummary { + const counts = emptyEvidenceQualitySummary(); + for (const annotation of annotations) { + counts[annotation.label]++; + } + + counts.strongSourceEvidence = counts.STRONG_SOURCE_EVIDENCE; + counts.weakSourceEvidence = counts.WEAK_SOURCE_EVIDENCE; + counts.inferredFromStructure = counts.INFERRED_FROM_STRUCTURE; + counts.domainHintOnly = counts.DOMAIN_HINT_ONLY; + counts.missingEvidence = counts.MISSING_EVIDENCE; + counts.conflictingEvidence = counts.CONFLICTING_EVIDENCE; + counts.reviewRequired = counts.REVIEW_REQUIRED; + counts.evidenced = counts.strongSourceEvidence; + counts.inferred = counts.inferredFromStructure; + counts.weakEvidence = counts.weakSourceEvidence; + + return counts; + } +} diff --git a/packages/backend-runtime/src/document/application/evidence-quality.rules.ts b/packages/backend-runtime/src/document/application/evidence-quality.rules.ts new file mode 100644 index 00000000..b033e405 --- /dev/null +++ b/packages/backend-runtime/src/document/application/evidence-quality.rules.ts @@ -0,0 +1,165 @@ +import type { + EvidenceQualitySummary, + InsightForAnnotation, +} from './evidence-quality.types'; + +type EvidenceForQuality = { + sourceType: string; + artifactId?: string | null; + snapshotId?: string | null; + sourcePath?: string | null; + startLine?: number | null; + endLine?: number | null; + excerpt?: string | null; + provenanceKey?: string | null; + artifact?: { + id?: string; + filePath?: string | null; + name?: string | null; + } | null; + retrievalMetadata?: unknown; +}; + +export function inspectEvidence(evidence: EvidenceForQuality[], fallbackArtifact?: { + id?: string; + filePath?: string | null; + name?: string | null; +} | null): { + hasEvidence: boolean; + hasSourceEvidence: boolean; + hasStrongSourceEvidence: boolean; + hasDomainHintOnly: boolean; + hasArtifactLink: boolean; + hasSourcePath: boolean; + hasLineRange: boolean; + hasSpecificExcerpt: boolean; +} { + const hasEvidence = evidence.length > 0; + const hasDomainHintOnly = hasEvidence && evidence.every(isDomainHintEvidence); + const sourceEvidence = evidence.filter(isSourceEvidence); + const hasArtifactLink = + sourceEvidence.some((item) => !!item.artifactId || !!item.artifact?.id) || + !!fallbackArtifact?.id; + const hasSourcePath = sourceEvidence.some((item) => !!item.sourcePath || !!item.artifact?.filePath); + const hasLineRange = sourceEvidence.some((item) => item.startLine !== null && item.endLine !== null); + const hasSpecificExcerpt = sourceEvidence.some((item) => isSpecificExcerpt(item.excerpt)); + + return { + hasEvidence, + hasSourceEvidence: sourceEvidence.length > 0, + hasStrongSourceEvidence: + hasArtifactLink && + hasSourcePath && + hasLineRange && + hasSpecificExcerpt, + hasDomainHintOnly, + hasArtifactLink, + hasSourcePath, + hasLineRange, + hasSpecificExcerpt, + }; +} + +export function buildEvidenceReasons(facts: ReturnType): string[] { + const reasons: string[] = []; + if (facts.hasEvidence) reasons.push('hasPersistedEvidence'); + if (facts.hasSourceEvidence) reasons.push('hasSourceEvidence'); + if (facts.hasArtifactLink) reasons.push('hasArtifactLink'); + if (facts.hasSourcePath) reasons.push('hasSourcePath'); + if (facts.hasLineRange) reasons.push('hasLineRange'); + if (facts.hasSpecificExcerpt) reasons.push('hasSpecificExcerpt'); + if (!facts.hasSpecificExcerpt && facts.hasSourceEvidence) reasons.push('weakOrGenericExcerpt'); + return reasons; +} + +export function hasUsableArtifact(artifact: { filePath?: string | null; name?: string | null } | null): boolean { + if (!artifact) return false; + const name = artifact.name ?? ''; + return !!artifact.filePath || (!!name && !name.includes('UNKNOWN')); +} + +export function readRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +export function isDomainHintMetadata( + metadata: Record, + insight: Pick, +): boolean { + const haystack = [ + metadata.origin, + metadata.source, + metadata.kind, + metadata.evidenceIntegrity, + insight.title, + insight.description, + insight.reasoning, + ].filter((value): value is string => typeof value === 'string'); + + return haystack.some((value) => /domain[-_ ]pack|domain hint|partial .* hint/i.test(value)); +} + +export function hasStructuralMetadata(metadata: Record): boolean { + return ['artifactKey', 'artifactKeys', 'impactedArtifacts', 'retrievalScope', 'sourcePath'] + .some((key) => metadata[key] !== undefined); +} + +export function isReviewRequiredInsight( + insight: Pick, +): boolean { + if (insight.reviewStatus !== 'NEEDS_REVIEW') { + return false; + } + return ( + insight.certainty === 'EVIDENCED' || + insight.certainty === 'CONFLICTING' || + insight.insightType === 'CLAIM' || + insight.insightType === 'UNKNOWN' + ); +} + +export function emptyEvidenceQualitySummary(): EvidenceQualitySummary { + return { + STRONG_SOURCE_EVIDENCE: 0, + WEAK_SOURCE_EVIDENCE: 0, + INFERRED_FROM_STRUCTURE: 0, + DOMAIN_HINT_ONLY: 0, + MISSING_EVIDENCE: 0, + CONFLICTING_EVIDENCE: 0, + REVIEW_REQUIRED: 0, + strongSourceEvidence: 0, + weakSourceEvidence: 0, + inferredFromStructure: 0, + domainHintOnly: 0, + missingEvidence: 0, + conflictingEvidence: 0, + reviewRequired: 0, + evidenced: 0, + inferred: 0, + weakEvidence: 0, + }; +} + +function isSourceEvidence(evidence: EvidenceForQuality): boolean { + return ( + evidence.sourceType === 'CODE' || + evidence.sourceType === 'TEST' || + evidence.sourceType === 'STATIC_ANALYSIS' || + (!!evidence.sourcePath && !!evidence.excerpt && !isDomainHintEvidence(evidence)) + ); +} + +function isDomainHintEvidence(evidence: EvidenceForQuality): boolean { + return [evidence.provenanceKey, evidence.sourcePath, evidence.excerpt] + .some((value) => typeof value === 'string' && /domain[-_ ]pack|domain hint/i.test(value)); +} + +function isSpecificExcerpt(value: string | null | undefined): boolean { + const normalized = value?.trim() ?? ''; + if (normalized.length < 24) { + return false; + } + return !/^(todo|n\/a|unknown|placeholder|domain hint|domain pack hint)$/i.test(normalized); +} diff --git a/packages/backend-runtime/src/document/application/evidence-quality.types.ts b/packages/backend-runtime/src/document/application/evidence-quality.types.ts new file mode 100644 index 00000000..ab092b0e --- /dev/null +++ b/packages/backend-runtime/src/document/application/evidence-quality.types.ts @@ -0,0 +1,66 @@ +import type { Prisma } from '@prisma/client'; + +export type TraceabilityLinkForAnnotation = Prisma.TraceabilityLinkGetPayload<{ + include: { + artifact: true; + evidenceLinks: { + include: { + evidence: true; + }; + }; + reviewDecision: true; + }; +}>; + +export type InsightForAnnotation = Prisma.BaInsightGetPayload<{ + include: { + evidenceLinks: { + include: { + evidence: { + include: { + artifact: true; + }; + }; + }; + }; + }; +}>; + +export type QualityLabel = + | 'STRONG_SOURCE_EVIDENCE' + | 'WEAK_SOURCE_EVIDENCE' + | 'INFERRED_FROM_STRUCTURE' + | 'DOMAIN_HINT_ONLY' + | 'MISSING_EVIDENCE' + | 'CONFLICTING_EVIDENCE' + | 'REVIEW_REQUIRED'; + +export interface QualityAnnotation { + label: QualityLabel; + reasons: string[]; +} + +export type EvidenceQualityItem = { + itemType: 'TRACEABILITY_LINK' | 'INSIGHT'; + itemId: string; + linkId?: string; + insightId?: string; + artifact: string; + quality: QualityLabel; + reasons: string[]; + reviewStatus?: string | null; + reviewDecision?: unknown; +}; + +export type EvidenceQualitySummary = Record & { + strongSourceEvidence: number; + weakSourceEvidence: number; + inferredFromStructure: number; + domainHintOnly: number; + missingEvidence: number; + conflictingEvidence: number; + reviewRequired: number; + evidenced: number; + inferred: number; + weakEvidence: number; +}; diff --git a/packages/backend-runtime/src/document/application/markdown-impact-report.types.ts b/packages/backend-runtime/src/document/application/markdown-impact-report.types.ts new file mode 100644 index 00000000..2678bb6e --- /dev/null +++ b/packages/backend-runtime/src/document/application/markdown-impact-report.types.ts @@ -0,0 +1,52 @@ +import type { Prisma, ReviewNote } from '@prisma/client'; +import type { ClarificationItemDto } from '@ba-helper/contracts'; +import type { ApprovedReportMetadata } from '../domain/approved-report-metadata'; +import type { ReportReviewCoverageSummary } from './report-review-coverage.summary'; +import type { ReportDependencyEdge } from './mermaid-impact-diagram.builder'; +import type { ReportLocale } from './render/report-localization'; + +export type AnalysisSnapshot = Prisma.ImpactAnalysisGetPayload<{ + include: { + snapshot: { include: { repository: true; profile: true } }; + sourceTarget: true; + requirementRevision: true; + }; +}>; + +export type InsightWithEvidence = Prisma.BaInsightGetPayload<{ + include: { + evidenceLinks: { + include: { + evidence: true; + }; + }; + }; +}>; + +export type TraceabilityLinkWithArtifact = Prisma.TraceabilityLinkGetPayload<{ + include: { + artifact: true; + evidenceLinks: { + include: { + evidence: true; + }; + }; + }; +}>; + +export type MarkdownReportRenderContext = { + analysis: AnalysisSnapshot; + locale: ReportLocale; + insights: InsightWithEvidence[]; + traceabilityLinks: TraceabilityLinkWithArtifact[]; + reviewNotes: ReviewNote[]; + hasUnreviewedItems: boolean; + dependencyEdges: ReportDependencyEdge[]; + clarifications: ClarificationItemDto[]; + reviewDecisions: any[]; + reviewDecisionsSnapshot?: any[]; + evidenceQualitySummarySnapshot?: any; + reviewCoverageSummarySnapshot?: ReportReviewCoverageSummary; + diff?: any; + metadata?: ApprovedReportMetadata; +}; diff --git a/packages/backend-runtime/src/document/application/mermaid-impact-diagram.builder.ts b/packages/backend-runtime/src/document/application/mermaid-impact-diagram.builder.ts new file mode 100644 index 00000000..c1444ddb --- /dev/null +++ b/packages/backend-runtime/src/document/application/mermaid-impact-diagram.builder.ts @@ -0,0 +1,202 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from "@prisma/client"; + +export type ReportDependencyEdge = { + id: string; + snapshotId: string; + fromArtifactId: string; + toArtifactId: string; + type: string; +}; + +type TraceabilityLinkWithArtifact = Prisma.TraceabilityLinkGetPayload<{ + include: { + artifact: true; + }; +}>; + +type InsightWithEvidence = Prisma.BaInsightGetPayload<{ + include: { + evidenceLinks: { + include: { + evidence: true; + }; + }; + }; +}>; + +type RequirementRevision = Prisma.RequirementRevisionGetPayload<{}>; + +@Injectable() +export class MermaidImpactDiagramBuilder { + private getArtifactTypeLabel(artifact: { artifactType: string; universalKind?: string | null }): string { + const type = artifact.artifactType; + + if (artifact.universalKind === 'API_ENDPOINT') return 'API'; + if (artifact.universalKind === 'DOMAIN_SERVICE') return 'Service'; + if (artifact.universalKind === 'DATA_MODEL') return 'Entity'; + if (artifact.universalKind === 'TEST_CASE') return 'Test'; + + if (type.includes('CONTROLLER') || type.includes('ROUTE')) return 'API'; + if (type.includes('SERVICE')) return 'Service'; + if (type.includes('ENTITY') || type.includes('MODEL')) return 'Entity'; + if (type.includes('TEST')) return 'Test'; + return 'Component'; + } + + build(params: { + requirement: RequirementRevision; + traceabilityLinks: TraceabilityLinkWithArtifact[]; + dependencyEdges: ReportDependencyEdge[]; + insights: InsightWithEvidence[]; + }): { mermaid: string; isTruncated: boolean } { + const { requirement, traceabilityLinks, dependencyEdges, insights } = params; + + // 1. Filter out rejected links and insights + const approvedLinks = traceabilityLinks.filter((l) => l.reviewStatus !== 'REJECTED'); + const approvedInsights = insights.filter((i) => i.reviewStatus !== 'REJECTED'); + + // 2. Identify all valid artifact IDs that are confirmed/needs_review (impacted) + const impactedArtifactIds = new Set( + approvedLinks.filter((l) => !!l.artifact).map((l) => l.artifact!.id), + ); + + // 3. Define Mermaid Nodes + const nodes: { id: string; label: string; type: string; isArtifact: boolean; priority: number }[] = []; + + // Requirement Node + nodes.push({ + id: 'n_req', + label: `[Requirement] ${this.escapeLabel(requirement.title)}`, + type: 'REQUIREMENT', + isArtifact: false, + priority: 1, + }); + + // Artifact Nodes + for (const link of approvedLinks) { + if (!link.artifact) continue; + + const type = link.artifact.artifactType; + const typeLabel = this.getArtifactTypeLabel(link.artifact); + + // Priority: API > Service Method > Service Class > Entity > Test + let priority = 5; + if (typeLabel === 'API') priority = 2; + else if (type.includes('METHOD')) priority = 3; + else if (typeLabel === 'Service') priority = 3.5; + else if (typeLabel === 'Entity') priority = 4; + + nodes.push({ + id: this.generateNodeId(link.artifact.id), + label: `[${typeLabel}] ${this.escapeLabel(link.artifact.name)}`, + type: link.artifact.artifactType, + isArtifact: true, + priority, + }); + } + + // QA / Unknown Nodes (Optional, only if high priority) + const qaAndUnknown = approvedInsights.filter((i) => i.insightType === 'QA_SCENARIO' || i.insightType === 'UNKNOWN'); + for (const insight of qaAndUnknown) { + nodes.push({ + id: this.generateNodeId(insight.id), + label: `[${insight.insightType === 'QA_SCENARIO' ? 'QA' : 'Unknown'}] ${this.escapeLabel(insight.title)}`, + type: insight.insightType, + isArtifact: false, + priority: 6, + }); + } + + // 4. Sort nodes by priority and cap at 20 + nodes.sort((a, b) => a.priority - b.priority); + let isTruncated = false; + const cappedNodes = nodes.slice(0, 20); + if (nodes.length > 20) { + isTruncated = true; + } + + const cappedNodeIds = new Set(cappedNodes.map((n) => n.id)); + + // 5. Define Mermaid Edges + const edges: { from: string; to: string; label: string }[] = []; + + // Requirement -> High level entrypoints (API, or Services if no API) + const entrypoints = cappedNodes.filter((n) => n.priority === 2); + if (entrypoints.length > 0) { + entrypoints.forEach((n) => { + edges.push({ from: 'n_req', to: n.id, label: 'AFFECTS' }); + }); + } else { + const topServices = cappedNodes.filter((n) => n.priority >= 3 && n.priority < 4); + topServices.forEach((n) => { + edges.push({ from: 'n_req', to: n.id, label: 'AFFECTS' }); + }); + } + + // Dependency Edges between impacted artifacts + for (const edge of dependencyEdges) { + if (impactedArtifactIds.has(edge.fromArtifactId) && impactedArtifactIds.has(edge.toArtifactId)) { + const fromNodeId = this.generateNodeId(edge.fromArtifactId); + const toNodeId = this.generateNodeId(edge.toArtifactId); + + // Only add edge if BOTH nodes are in the capped node list + if (cappedNodeIds.has(fromNodeId) && cappedNodeIds.has(toNodeId)) { + edges.push({ + from: fromNodeId, + to: toNodeId, + label: edge.type, + }); + } + } + } + + // Cap edges at 30 + if (edges.length > 30) { + isTruncated = true; + edges.splice(30); + } + + // 6. Build Mermaid String + const lines = ['```mermaid', 'flowchart TD']; + + // Print nodes + for (const node of cappedNodes) { + lines.push(` ${node.id}["${node.label}"]`); + } + + lines.push(''); + + // Print edges + for (const edge of edges) { + // Mermaid dashed or solid depending on type + if (edge.label === 'TESTS') { + lines.push(` ${edge.from} -.-|${edge.label}| ${edge.to}`); + } else { + lines.push(` ${edge.from} -->|${edge.label}| ${edge.to}`); + } + } + + lines.push('```'); + + return { + mermaid: lines.join('\n'), + isTruncated, + }; + } + + private generateNodeId(uuid: string): string { + // Mermaid safe node ID: n_ + return `n_${uuid.replace(/[^a-zA-Z0-9]/g, '').substring(0, 8)}`; + } + + private escapeLabel(label: string): string { + if (!label) return 'Unknown'; + // Remove quotes, backticks, brackets, newlines, limit length + let safe = label.replace(/["'`\n\r[\]]/g, ' ').replace(/\s+/g, ' ').trim(); + if (safe.length > 60) { + safe = safe.substring(0, 57) + '...'; + } + return safe || 'Unknown'; + } +} diff --git a/packages/backend-runtime/src/document/application/render/__snapshots__/markdown-impact-report.builder.spec.ts.snap b/packages/backend-runtime/src/document/application/render/__snapshots__/markdown-impact-report.builder.spec.ts.snap new file mode 100644 index 00000000..5e848849 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/__snapshots__/markdown-impact-report.builder.spec.ts.snap @@ -0,0 +1,178 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`MarkdownImpactReportBuilder Golden Characterization Snapshot matches the golden characterization snapshot for a full comprehensive report 1`] = ` +"# Impact Analysis Report: Paid booking cancellation refund + +**Status:** Approved +**Requirement:** Paid booking cancellation refund +**Snapshot Commit:** \`f26cd56837cd10a1c00bb89d74d97519abc6f732\` +**Repository:** \`https://github.com/ndmen/booking\` +**Target Ref:** \`main\` +**Generated At:** 2026-01-01 + +## Requirement + +> Allow users to cancel paid bookings and receive refund. + +## Provenance + +- Analysis ID: \`analysis-demo-001\` +- Generated Document ID: \`doc-demo-001\` +- Project ID: \`project-demo-001\` +- Repository ID: \`repo-demo-001\` +- Snapshot ID: \`snapshot-demo-001\` +- Target Ref: \`main\` +- Commit SHA: \`f26cd56837cd10a1c00bb89d74d97519abc6f732\` +- Analyzer Version: \`1.0.0\` +- Finalized At: 2026-01-01T00:00:00.000Z + +## Scanner Capability Profile + +- **Language:** typescript +- **Framework:** nestjs +- **Maturity Status:** STABLE +- **Confidence Level:** HIGH + +## Scanner Diagnostics & Risks + +- **TS_DYNAMIC_IMPORT_UNSUPPORTED**: Dynamic imports are not supported + +## Impact Flow Diagram + +\`\`\`mermaid +flowchart TD +\`\`\` + +## Executive Summary + +This analysis identified **1** evidence-backed impacts, **1** QA scenarios, and **1** open questions. +The primary impacted areas are **service** layers. + +> This report was finalized with unreviewed items acknowledged. + +## Impacted Areas + +### Service + +- \`GoldenService\` in \`src/golden.ts\` — **Confirmed** + +### Reviewer Notes on Impacted Areas + +- \`GoldenService\`: Reviewed the golden link + +## Evidence-backed Impacts + +### 1. This is a golden claim + +> **Certainty:** Evidenced +> **Reviewer Note:** Reviewed the golden claim +> **Reasoning:** Because it is golden + +**Evidence:** +- \`src/golden.ts\` + +## Acceptance Criteria + +- Must be golden +
_Not directly evidenced; derived from requirement and should be confirmed._ + +## QA Scenarios + +### Golden QA + +- **Given:** golden +- **When:** testing +- **Then:** pass + +## Open Questions / Unknowns + +### Golden Unknown + +> **Question:** Why is it golden? +> +> **Why this matters:** Need to investigate +> +> _Derived from scanner diagnostic_ + +## Clarifications + +### Answered + +**Question:** Is it golden? +**Why this matters:** User requested +**Answer:** Yes + +## Evidence Appendix + +> Secrets were redacted before storage, embedding, or LLM processing. + +### \`golden.ts\` + +**File:** \`src/golden.ts\` +**Lines:** 10–12 + +\`\`\`ts +const golden = true; +\`\`\` + +## Review Decision History + +| Time | Reviewer | Decision | Note | +|---|---|---|---| +| 2026-01-01 13:00:00 | John Doe | APPROVED | Looks golden | + +## Evidence Quality & Dataset Readiness + +- Strong source evidence: 0 +- Weak source evidence: 1 +- Inferred from structure: 0 +- Domain hint only: 0 +- Missing evidence: 0 +- Conflicting evidence: 0 +- Review required: 0 + +| Artifact | Quality | Reason | +|---|---|---| +| \`src/golden.ts\` | WEAK_SOURCE_EVIDENCE | hasPersistedEvidence, hasSourceEvidence, hasArtifactLink, hasSourcePath, hasLineRange, weakOrGenericExcerpt | + +## Evaluation Context + +- **Dataset Version**: \`v0-golden\` +- **Subset ID**: \`clean-vector-ready-v0\` +- **Subset Size**: \`1/6\` (Illustrative Only) +- **Interpretation**: \`ILLUSTRATIVE_ONLY\` +- **Research Artifact**: \`e13-golden.json\` +- **Comparison Artifact**: \`comp-golden.json\` + +### Known Limits +- Golden Limit A + +### Evidence Quality Notes +- Golden Note B + +### Dataset Expansion Recommendations +- Golden Rec C + +## Impact Diff Snapshot + +This analysis was derived from baseline analysis: \`base-golden-1\` + +### Summary +- Added code impacts: 1 +- Removed code impacts: 0 +- Resolved unknowns: 1 +- New unknowns: 0 +- Added QA scenarios: 1 + +### Added Code Impacts + +- \`GoldenAdded\` (Class) in \`src/added.ts\` + +### Resolved Unknowns + +- Resolved golden + +### Added QA Scenarios + +- **QA-NEW**: New golden QA" +`; diff --git a/packages/backend-runtime/src/document/application/render/markdown-impact-report.builder.spec.ts b/packages/backend-runtime/src/document/application/render/markdown-impact-report.builder.spec.ts new file mode 100644 index 00000000..8c485678 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/markdown-impact-report.builder.spec.ts @@ -0,0 +1,861 @@ +import { MarkdownImpactReportBuilder } from './markdown-impact-report.builder'; +import type { MermaidImpactDiagramBuilder } from '../mermaid-impact-diagram.builder'; +import type { EvaluationContextAdapter } from '../evaluation-context.adapter'; + +describe('MarkdownImpactReportBuilder', () => { + let builder: MarkdownImpactReportBuilder; + let mermaidBuilder: jest.Mocked; + let evalContextAdapter: jest.Mocked; + + beforeEach(() => { + mermaidBuilder = { + build: jest.fn().mockReturnValue({ mermaid: '```mermaid\nflowchart TD\n```', isTruncated: false }), + } as unknown as jest.Mocked; + + evalContextAdapter = { + getEvaluationContext: jest.fn().mockReturnValue(null), + } as unknown as jest.Mocked; + + builder = new MarkdownImpactReportBuilder(mermaidBuilder, evalContextAdapter); + }); + + const mockAnalysis = { + id: 'test-id', + requirementRevision: { + title: 'Paid booking cancellation refund', + rawText: 'Allow users to cancel paid bookings and receive refund.', + }, + snapshot: { + commitSha: 'f26cd56837cd10a1c00bb89d74d97519abc6f732', + repository: { + canonicalUrl: 'https://github.com/ndmen/booking', + }, + }, + sourceTarget: { + requestedRef: 'main', + }, + } as unknown as any; + + it('generates header and requirement text correctly', () => { + const report = builder.build({ + analysis: mockAnalysis, + insights: [], + traceabilityLinks: [], + hasUnreviewedItems: false, + }); + + expect(report).toContain('# Impact Analysis Report: Paid booking cancellation refund'); + expect(report).toContain('**Requirement:** Paid booking cancellation refund'); + expect(report).toContain('**Snapshot Commit:** `f26cd56837cd10a1c00bb89d74d97519abc6f732`'); + expect(report).toContain('**Repository:** `https://github.com/ndmen/booking`'); + expect(report).toContain('**Target Ref:** `main`'); + expect(report).toContain('## Requirement'); + expect(report).toContain('> Allow users to cancel paid bookings and receive refund.'); + }); + + it('excludes REJECTED insights and adds a note', () => { + const insights = [ + { + insightType: 'CLAIM', + reviewStatus: 'REJECTED', + certainty: 'EVIDENCED', + title: 'This should be excluded', + description: 'This should be excluded', + evidenceLinks: [], + }, + { + insightType: 'CLAIM', + reviewStatus: 'CONFIRMED', + certainty: 'EVIDENCED', + title: 'This should be included', + description: 'This should be included', + evidenceLinks: [], + }, + ] as unknown as any[]; + + const report = builder.build({ + analysis: mockAnalysis, + insights, + traceabilityLinks: [], + hasUnreviewedItems: false, + }); + + expect(report).not.toContain('This should be excluded'); + expect(report).toContain('This should be included'); + expect(report).toContain('> Rejected insights are excluded from this approved report.'); + }); + + it('groups insights correctly into sections', () => { + const insights = [ + { insightType: 'CLAIM', title: 'Claim 1', description: 'Claim 1 desc', certainty: 'EVIDENCED', reviewStatus: 'CONFIRMED', evidenceLinks: [] }, + { insightType: 'QA_SCENARIO', title: 'QA 1', description: 'Given X When Y Then Z', certainty: 'INFERRED', reviewStatus: 'CONFIRMED', evidenceLinks: [] }, + { insightType: 'QUESTION', title: 'Question 1', description: 'Question 1 desc', certainty: 'UNKNOWN', reviewStatus: 'CONFIRMED', evidenceLinks: [] }, + { insightType: 'UNKNOWN', title: 'Unknown 1', description: 'Unknown 1 desc', certainty: 'UNKNOWN', reviewStatus: 'CONFIRMED', evidenceLinks: [] }, + { insightType: 'ACCEPTANCE_CRITERIA', title: 'AC 1', description: 'AC 1 desc', certainty: 'INFERRED', reviewStatus: 'CONFIRMED', evidenceLinks: [] }, + ] as unknown as any[]; + + const report = builder.build({ + analysis: mockAnalysis, + insights, + traceabilityLinks: [], + hasUnreviewedItems: false, + }); + + // Sections should exist + expect(report).toContain('## Evidence-backed Impacts'); + expect(report).toContain('## QA Scenarios'); + expect(report).toContain('## Open Questions / Unknowns'); + expect(report).toContain('## Acceptance Criteria'); + + // Items should be in right places + expect(report).toContain('Claim 1 desc'); + expect(report).toContain('- **Given:** X'); + expect(report).toContain('- **When:** Y'); + expect(report).toContain('- **Then:** Z'); + expect(report).toContain('Question 1 desc'); + expect(report).toContain('Unknown 1 desc'); + expect(report).toContain('AC 1 desc'); + + // QA Scenario formatting + expect(report).not.toContain('| Scenario | Precondition | Action | Expected Result |'); + expect(report).toContain('- **Given:** X'); + }); + + it('handles missing evidence gracefully', () => { + const insights = [ + { + insightType: 'CLAIM', + reviewStatus: 'CONFIRMED', + certainty: 'EVIDENCED', + title: 'Claim with no evidence', + description: 'Claim with no evidence desc', + evidenceLinks: [], + }, + ] as unknown as any[]; + + const report = builder.build({ + analysis: mockAnalysis, + insights, + traceabilityLinks: [], + hasUnreviewedItems: false, + }); + + expect(report).toContain('_No evidence attached._'); + }); + + it('renders evidence appendix when evidence exists', () => { + const insights = [ + { + insightType: 'CLAIM', + reviewStatus: 'CONFIRMED', + certainty: 'EVIDENCED', + title: 'Claim with evidence', + description: 'Claim with evidence desc', + evidenceLinks: [ + { + evidence: { + id: 'ev-1', + sourcePath: 'src/app.ts', + startLine: 1, + endLine: 5, + excerpt: 'console.log("hello");', + }, + }, + ], + }, + ] as unknown as any[]; + + const report = builder.build({ + analysis: mockAnalysis, + insights, + traceabilityLinks: [], + hasUnreviewedItems: false, + }); + + expect(report).toContain('## Evidence Appendix'); + expect(report).toContain('### `app.ts`'); + expect(report).toContain('**File:** `src/app.ts`'); + expect(report).toContain('**Lines:** 1–5'); + expect(report).toContain('console.log("hello");'); + expect(report).toContain('> Secrets were redacted'); + }); + + it('renders Vietnamese report chrome while preserving raw evidence and source text', () => { + const viAnalysis = { + ...mockAnalysis, + metadata: { + domainPack: { + id: 'booking', + version: '0.1.0', + status: 'STABLE', + selectedBy: 'REPOSITORY_PROFILE', + }, + }, + snapshot: { + ...mockAnalysis.snapshot, + profile: { domain: 'BOOKING' }, + }, + }; + const rawEvidence = 'booking.status = BookingStatus.CANCELLED;'; + + const report = builder.build({ + locale: 'vi', + analysis: viAnalysis, + insights: [ + { + insightType: 'CLAIM', + reviewStatus: 'CONFIRMED', + certainty: 'EVIDENCED', + title: 'Booking reaches CANCELLED status', + description: 'Booking reaches CANCELLED status', + evidenceLinks: [ + { + evidence: { + id: 'ev-vi-1', + sourcePath: 'src/booking/booking.service.ts', + startLine: 12, + endLine: 14, + excerpt: rawEvidence, + }, + }, + ], + }, + ] as unknown as any[], + traceabilityLinks: [], + hasUnreviewedItems: false, + }); + + expect(report).toContain('# Báo cáo phân tích tác động: Paid booking cancellation refund'); + expect(report).toContain('## Yêu cầu'); + expect(report).toContain('## Thuật ngữ domain'); + expect(report).toContain('- refund: hoàn tiền'); + expect(report).toContain('## Tác động có bằng chứng'); + expect(report).toContain('## Phụ lục bằng chứng'); + expect(report).toContain('**File:** `src/booking/booking.service.ts`'); + expect(report).toContain(rawEvidence); + expect(report).toContain('> Allow users to cancel paid bookings and receive refund.'); + }); + + it('renders terminology from the selected domain pack glossary', () => { + const viAnalysis = { + ...mockAnalysis, + metadata: { + domainPack: { + id: 'rental', + version: '0.1.0', + status: 'PARTIAL', + selectedBy: 'EXPLICIT', + }, + }, + snapshot: { + ...mockAnalysis.snapshot, + profile: { domain: 'UNKNOWN' }, + }, + }; + + const report = builder.build({ + locale: 'vi', + analysis: viAnalysis, + insights: [], + traceabilityLinks: [], + hasUnreviewedItems: false, + }); + + expect(report).toContain('## Thuật ngữ domain'); + expect(report).toContain('- rentalContract: hợp đồng thuê phòng'); + expect(report).toContain('- deposit: tiền cọc'); + expect(report).not.toContain('- refund: hoàn tiền'); + }); + + it('adds unreviewed acknowledged note if hasUnreviewedItems is true', () => { + const report = builder.build({ + analysis: mockAnalysis, + insights: [], + traceabilityLinks: [], + hasUnreviewedItems: true, + }); + + expect(report).toContain('> This report was finalized with unreviewed items acknowledged.'); + }); + + it('uses universalKind fallback in impacted area labels', () => { + const report = builder.build({ + analysis: mockAnalysis, + insights: [], + traceabilityLinks: [ + { + id: 'link-1', + reviewStatus: 'CONFIRMED', + artifact: { + id: 'artifact-1', + name: 'BookingAggregate', + artifactType: 'CLASS', + universalKind: 'DATA_MODEL', + filePath: 'src/booking.aggregate.ts', + }, + }, + ] as unknown as any[], + hasUnreviewedItems: false, + }); + + expect(report).toContain('The primary impacted areas are **data model** layers.'); + expect(report).toContain('- `BookingAggregate` in `src/booking.aggregate.ts` — **Confirmed**'); + }); + + it('includes a provenance block when metadata is provided', () => { + const report = builder.build({ + analysis: mockAnalysis, + insights: [], + traceabilityLinks: [], + hasUnreviewedItems: false, + metadata: { + analysisId: 'analysis-1', + title: 'Paid booking cancellation refund', + projectId: 'project-1', + repositoryId: 'repo-1', + targetRef: 'main', + commitSha: 'f26cd56837cd10a1c00bb89d74d97519abc6f732', + snapshotId: 'snapshot-1', + analyzerVersion: '1.0.0', + generatedDocumentId: 'document-1', + generatedAt: '2026-06-06T00:00:00.000Z', + finalizedAt: '2026-06-06T00:00:00.000Z', + staleStatusAtReadTime: false, + }, + }); + + expect(report).toContain('## Provenance'); + expect(report).toContain('Generated Document ID: `document-1`'); + expect(report).toContain('Project ID: `project-1`'); + expect(report).toContain('Snapshot ID: `snapshot-1`'); + expect(report).toContain('Analyzer Version: `1.0.0`'); + }); + + describe('Scanner Capability Profile & Diagnostics', () => { + it('renders capability profile and diagnostics when present', () => { + const goAnalysis = { + ...mockAnalysis, + snapshot: { + ...mockAnalysis.snapshot, + diagnostics: [ + { + code: 'SCANNER_CAPABILITY_SUMMARY', + message: 'Scanner capability profile injected', + severity: 'INFO', + category: 'SCANNER', + payload: { + language: 'go', + status: 'EXPERIMENTAL', + confidence: 'LOW', + }, + }, + { + code: 'GO_DYNAMIC_ROUTE_UNSUPPORTED', + message: 'Dynamic route variables are not supported', + severity: 'WARN', + category: 'SCANNER', + }, + ] as unknown as any, + }, + } as unknown as any; + + const report = builder.build({ + analysis: goAnalysis, + insights: [], + traceabilityLinks: [ + { + id: 'link-1', + reviewStatus: 'CONFIRMED', + artifact: { + id: 'artifact-1', + name: 'UNKNOWN /api/v1/payment -> updatePaymentHandler', + artifactType: 'HTTP_ENDPOINT', + universalKind: 'HTTP_ENDPOINT', + filePath: 'src/main.go', + stableId: 'go_http_endpoint__net_http__UNKNOWN__route_hash__handler', + }, + }, + ] as unknown as any[], + hasUnreviewedItems: false, + }); + + // Assert section headers + expect(report).toContain('## Scanner Capability Profile'); + expect(report).toContain('## Scanner Diagnostics & Risks'); + + // Assert Capability details + expect(report).toContain('- **Language:** go'); + expect(report).toContain('- **Maturity Status:** EXPERIMENTAL'); + expect(report).toContain('- **Confidence Level:** LOW'); + + // Assert Diagnostics details + expect(report).toContain('- **GO_DYNAMIC_ROUTE_UNSUPPORTED**: Dynamic route variables are not supported'); + + // Assert Artifact labels + // 1. EXPERIMENTAL flag (derived from capability summary payload) + // 2. [Method: UNKNOWN] flag + expect(report).toContain('`UNKNOWN /api/v1/payment -> updatePaymentHandler` (EXPERIMENTAL) **[Method: UNKNOWN]**'); + }); + + it('renders diagnostic-derived UNKNOWN risks in Open Questions and not in Impacted Artifacts', () => { + const insights = [ + { + insightType: 'UNKNOWN', + title: 'Unsupported Scanner Pattern in main.go', + description: 'Unsupported Router Group', + certainty: 'UNKNOWN', + reviewStatus: 'CONFIRMED', + evidenceLinks: [], + metadata: { + origin: 'SCANNER_DIAGNOSTIC', + evidenceMode: 'DIAGNOSTIC_ONLY', + diagnosticPayload: { + relativePath: 'main.go', + candidateTerms: ['refunds'], + }, + }, + }, + ] as unknown as any[]; + + const report = builder.build({ + analysis: mockAnalysis, + insights, + traceabilityLinks: [], + hasUnreviewedItems: false, + }); + + // It should be in Open Questions / Unknowns + expect(report).toContain('## Open Questions / Unknowns'); + expect(report).toContain('Unsupported Scanner Pattern in main.go'); + expect(report).toContain('Unsupported Router Group'); + expect(report).toContain('_Derived from scanner diagnostic_'); + + // It should NOT be in Impacted Artifacts + expect(report).not.toContain('## Impacted Artifacts'); + }); + + it('does not crash when insight metadata is null', () => { + const insights = [ + { + insightType: 'UNKNOWN', + title: 'Old unknown insight', + description: 'No metadata available', + certainty: 'UNKNOWN', + reviewStatus: 'CONFIRMED', + evidenceLinks: [], + metadata: null, + }, + ] as unknown as any[]; + + expect(() => { + builder.build({ + analysis: mockAnalysis, + insights, + traceabilityLinks: [], + hasUnreviewedItems: false, + }); + }).not.toThrow(); + }); + }); + + describe('Evidence Quality & Dataset Readiness', () => { + it('renders Review Coverage summary when snapshot coverage is present', () => { + const report = builder.build({ + analysis: mockAnalysis, + insights: [], + traceabilityLinks: [], + hasUnreviewedItems: false, + reviewCoverageSummarySnapshot: { + insights: { + total: 24, + reviewed: 18, + unreviewed: 6, + confirmed: 16, + rejected: 2, + needsReview: 6, + }, + traceabilityLinks: { + total: 14, + reviewed: 10, + unreviewed: 4, + accepted: 9, + rejected: 1, + needsReview: 0, + needsMoreEvidence: 0, + }, + decisions: { + accepted: 25, + rejected: 3, + needsReview: 6, + needsMoreEvidence: 0, + needsClarification: 10, + unreviewed: 10, + }, + evidence: { + strong: 9, + weak: 2, + missing: 3, + conflicting: 1, + reviewRequired: 4, + }, + }, + }); + + expect(report).toContain('## Review Coverage'); + expect(report).toContain('- Insights reviewed: 18 / 24'); + expect(report).toContain('- Traceability links reviewed: 10 / 14'); + expect(report).toContain('- Strong source evidence: 9'); + expect(report).toContain('- Weak/missing evidence: 5'); + expect(report).toContain('- Conflicting evidence: 1'); + expect(report).toContain('- Review required: 4'); + }); + + it('renders Evidence Quality section with table and summary when traceability links exist', () => { + const links = [ + { + id: 'link-1', + linkType: 'AFFECTED', + linkBasis: 'EVIDENCED', + reviewStatus: 'CONFIRMED', + retrievalMetadata: { semanticScore: 0.9 }, + artifact: { + id: 'art-1', + filePath: 'src/app.ts', + name: 'AppModule', + }, + evidenceLinks: [ + { + evidence: { + sourceType: 'CODE', + artifactId: 'art-1', + sourcePath: 'src/app.ts', + excerpt: 'export class AppModule configures booking cancellation providers', + startLine: 1, + endLine: 2, + } + } + ] + }, + { + id: 'link-2', + linkType: 'AFFECTED', + linkBasis: 'INFERRED', + reviewStatus: 'NEEDS_REVIEW', + retrievalMetadata: {}, + artifact: { + id: 'art-2', + filePath: 'src/main.ts', + name: 'main', + }, + evidenceLinks: [] + } + ] as unknown as any[]; + + const report = builder.build({ + analysis: mockAnalysis, + insights: [], + traceabilityLinks: links, + hasUnreviewedItems: true, + }); + + expect(report).toContain('## Evidence Quality & Dataset Readiness'); + expect(report).toContain('- Strong source evidence: 1'); + expect(report).toContain('- Weak source evidence: 0'); + expect(report).toContain('- Inferred from structure: 0'); // Because link-2 is REVIEW_REQUIRED (precedence override) + expect(report).toContain('- Review required: 1'); + + expect(report).toContain('| Artifact | Quality | Reason |'); + expect(report).toMatch(/\| `src\/app\.ts` \| STRONG_SOURCE_EVIDENCE \| .*hasPersistedEvidence.*hasSourceEvidence.*hasArtifactLink.*hasLineRange.*hasSpecificExcerpt.* \|/); + expect(report).toMatch(/\| `src\/main\.ts` \| REVIEW_REQUIRED \| .*reviewRequired.* \|/); + }); + + it('omits Evidence Quality section when no traceability links exist', () => { + const report = builder.build({ + analysis: mockAnalysis, + insights: [], + traceabilityLinks: [], + hasUnreviewedItems: false, + }); + + expect(report).not.toContain('## Evidence Quality & Dataset Readiness'); + }); + }); + + describe('Evaluation Context', () => { + it('omits Evaluation Context when adapter returns null', () => { + evalContextAdapter.getEvaluationContext.mockReturnValue(null); + const report = builder.build({ + analysis: mockAnalysis, + insights: [], + traceabilityLinks: [], + hasUnreviewedItems: false, + }); + + expect(report).not.toContain('## Evaluation Context'); + }); + + it('appends Evaluation Context when adapter returns context', () => { + evalContextAdapter.getEvaluationContext.mockReturnValue({ + datasetVersion: 'v0', + subsetId: 'clean-vector-ready-v0', + subsetSize: '1/6', + interpretation: 'ILLUSTRATIVE_ONLY', + knownLimits: ['Limit A'], + evidenceQualityNotes: ['Note B'], + datasetExpansionRecommendations: ['Rec C'], + researchFindingsArtifact: 'e13.json', + sameSubsetComparisonArtifact: 'comp.json' + }); + + const report = builder.build({ + analysis: mockAnalysis, + insights: [], + traceabilityLinks: [], + hasUnreviewedItems: false, + }); + + expect(report).toContain('## Evaluation Context'); + expect(report).toContain('**Dataset Version**: `v0`'); + expect(report).toContain('**Subset Size**: `1/6` (Illustrative Only)'); + expect(report).toContain('### Known Limits'); + expect(report).toContain('- Limit A'); + expect(report).toContain('### Evidence Quality Notes'); + expect(report).toContain('- Note B'); + expect(report).toContain('### Dataset Expansion Recommendations'); + expect(report).toContain('- Rec C'); + }); + }); + + describe('Golden Characterization Snapshot', () => { + it('matches the golden characterization snapshot for a full comprehensive report', () => { + evalContextAdapter.getEvaluationContext.mockReturnValue({ + datasetVersion: 'v0-golden', + subsetId: 'clean-vector-ready-v0', + subsetSize: '1/6', + interpretation: 'ILLUSTRATIVE_ONLY', + knownLimits: ['Golden Limit A'], + evidenceQualityNotes: ['Golden Note B'], + datasetExpansionRecommendations: ['Golden Rec C'], + researchFindingsArtifact: 'e13-golden.json', + sameSubsetComparisonArtifact: 'comp-golden.json' + }); + + const goldenAnalysis = { + ...mockAnalysis, + snapshot: { + ...mockAnalysis.snapshot, + diagnostics: [ + { + code: 'SCANNER_CAPABILITY_SUMMARY', + message: 'Scanner capability profile injected', + severity: 'INFO', + category: 'SCANNER', + payload: { + language: 'typescript', + framework: 'nestjs', + status: 'STABLE', + confidence: 'HIGH', + }, + }, + { + code: 'TS_DYNAMIC_IMPORT_UNSUPPORTED', + message: 'Dynamic imports are not supported', + severity: 'WARN', + category: 'SCANNER', + }, + ] as unknown as any, + }, + } as unknown as any; + + const report = builder.build({ + analysis: goldenAnalysis, + insights: [ + { + id: 'insight-1', + insightType: 'CLAIM', + title: 'Golden Claim', + description: 'This is a golden claim', + certainty: 'EVIDENCED', + reviewStatus: 'CONFIRMED', + reasoning: 'Because it is golden', + evidenceLinks: [ + { + evidence: { + id: 'ev-golden-1', + sourcePath: 'src/golden.ts', + startLine: 10, + endLine: 12, + excerpt: 'const golden = true;', + }, + }, + ], + }, + { + id: 'insight-2', + insightType: 'QA_SCENARIO', + title: 'Golden QA', + description: 'Given golden When testing Then pass', + certainty: 'INFERRED', + reviewStatus: 'CONFIRMED', + evidenceLinks: [], + }, + { + id: 'insight-3', + insightType: 'UNKNOWN', + title: 'Golden Unknown', + description: 'Why is it golden?', + certainty: 'UNKNOWN', + reviewStatus: 'CONFIRMED', + reasoning: 'Need to investigate', + metadata: { + origin: 'SCANNER_DIAGNOSTIC', + }, + evidenceLinks: [], + }, + { + id: 'insight-4', + insightType: 'ACCEPTANCE_CRITERIA', + title: 'Golden AC', + description: 'Must be golden', + certainty: 'INFERRED', + reviewStatus: 'CONFIRMED', + evidenceLinks: [], + }, + ] as unknown as any[], + traceabilityLinks: [ + { + id: 'link-golden-1', + linkType: 'AFFECTED', + linkBasis: 'EVIDENCED', + reviewStatus: 'CONFIRMED', + retrievalMetadata: { semanticScore: 0.99 }, + artifact: { + id: 'art-golden-1', + filePath: 'src/golden.ts', + name: 'GoldenService', + artifactType: 'CLASS', + universalKind: 'SERVICE', + }, + evidenceLinks: [ + { + evidence: { + id: 'ev-golden-1', + sourcePath: 'src/golden.ts', + startLine: 10, + endLine: 12, + excerpt: 'const golden = true;', + }, + }, + ], + }, + ] as unknown as any[], + reviewNotes: [ + { + id: 'note-1', + traceabilityLinkId: 'link-golden-1', + body: 'Reviewed the golden link', + createdAt: new Date('2026-01-01T12:00:00.000Z'), + }, + { + id: 'note-2', + insightId: 'insight-1', + body: 'Reviewed the golden claim', + createdAt: new Date('2026-01-01T12:05:00.000Z'), + }, + ] as unknown as any[], + hasUnreviewedItems: true, + dependencyEdges: [ + { + fromId: 'art-golden-1', + toId: 'art-golden-2', + type: 'CALLS', + }, + ] as unknown as any[], + clarifications: [ + { + id: 'clar-1', + question: 'Is it golden?', + status: 'ANSWERED', + answer: 'Yes', + reason: 'User requested', + }, + ] as unknown as any[], + reviewDecisions: [ + { + id: 'decision-1', + reviewedBy: 'John Doe', + decision: 'APPROVED', + note: 'Looks golden', + createdAt: new Date('2026-01-01T13:00:00.000Z'), + }, + ] as unknown as any[], + diff: { + baseAnalysisId: 'base-golden-1', + summary: { + addedImpacts: 1, + removedImpacts: 0, + resolvedUnknowns: 1, + newUnknowns: 0, + addedQaScenarios: 1, + }, + addedArtifacts: [ + { + name: 'GoldenAdded', + artifactType: 'CLASS', + filePath: 'src/added.ts', + }, + ], + removedArtifacts: [], + resolvedUnknowns: [ + { + statement: 'Resolved golden', + }, + ], + newUnknowns: [], + addedQaScenarios: [ + { + insightKey: 'QA-NEW', + statement: 'New golden QA', + }, + ], + }, + metadata: { + title: 'Paid booking cancellation refund', + analysisId: 'analysis-demo-001', + generatedDocumentId: 'doc-demo-001', + projectId: 'project-demo-001', + repositoryId: 'repo-demo-001', + snapshotId: 'snapshot-demo-001', + targetRef: 'main', + commitSha: 'f26cd56837cd10a1c00bb89d74d97519abc6f732', + analyzerVersion: '1.0.0', + generatedAt: '2026-01-01T00:00:00.000Z', + finalizedAt: '2026-01-01T00:00:00.000Z', + staleStatusAtReadTime: false, + }, + }); + + // Semantic assertions to protect against blind approvals + expect(report).toContain('# Impact Analysis Report: Paid booking cancellation refund'); + expect(report).toContain('## Provenance'); + expect(report).toContain('## Scanner Capability Profile'); + expect(report).toContain('## Scanner Diagnostics & Risks'); + expect(report).toContain('## Impact Flow Diagram'); + expect(report).toContain('## Executive Summary'); + expect(report).toContain('## Impacted Areas'); + expect(report).toContain('## Evidence-backed Impacts'); + expect(report).toContain('## Acceptance Criteria'); + expect(report).toContain('## QA Scenarios'); + expect(report).toContain('## Open Questions / Unknowns'); + expect(report).toContain('## Clarifications'); + expect(report).toContain('## Evidence Appendix'); + expect(report).toContain('## Review Decision History'); + expect(report).toContain('## Evidence Quality & Dataset Readiness'); + expect(report).toContain('## Evaluation Context'); + expect(report).toContain('## Impact Diff Snapshot'); + + // Golden snapshot match + expect(report).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/backend-runtime/src/document/application/render/markdown-impact-report.builder.ts b/packages/backend-runtime/src/document/application/render/markdown-impact-report.builder.ts new file mode 100644 index 00000000..eb7ad200 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/markdown-impact-report.builder.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { MermaidImpactDiagramBuilder } from '../mermaid-impact-diagram.builder'; +import { EvaluationContextAdapter } from '../evaluation-context.adapter'; +import { MarkdownReportRenderContext } from '../markdown-impact-report.types'; +import { renderReportHeader } from './markdown-renderers/report-header.renderer'; +import { renderExecutiveSummary } from './markdown-renderers/executive-summary.renderer'; +import { + renderEvidenceQuality, + renderImpactedAreas, + renderReviewCoverage, +} from './markdown-renderers/traceability-section.renderer'; +import { renderImpactsAndAc, renderQuestionsAndClarifications } from './markdown-renderers/insight-section.renderer'; +import { renderQaSection } from './markdown-renderers/qa-section.renderer'; +import { renderEvidenceAppendix } from './markdown-renderers/evidence-appendix.renderer'; +import { renderReviewHistory } from './markdown-renderers/review-history.renderer'; +import { renderEvaluationContext } from './markdown-renderers/evaluation-context.renderer'; +import { renderImpactDiff } from './markdown-renderers/impact-diff.renderer'; +import { DEFAULT_REPORT_LOCALE } from './report-localization'; + +@Injectable() +export class MarkdownImpactReportBuilder { + constructor( + private readonly mermaidBuilder: MermaidImpactDiagramBuilder, + private readonly evalContextAdapter: EvaluationContextAdapter + ) {} + + build(params: Omit & Partial>): string { + const context: MarkdownReportRenderContext = { + ...params, + locale: params.locale || DEFAULT_REPORT_LOCALE, + reviewNotes: params.reviewNotes || [], + dependencyEdges: params.dependencyEdges || [], + clarifications: params.clarifications || [], + reviewDecisions: params.reviewDecisions || [], + }; + + const diagramResult = this.mermaidBuilder.build({ + requirement: context.analysis.requirementRevision, + traceabilityLinks: context.traceabilityLinks, + dependencyEdges: context.dependencyEdges, + insights: context.insights, + }); + + const evalContext = this.evalContextAdapter.getEvaluationContext(); + + const lines: string[] = [ + ...renderReportHeader(context), + ...renderExecutiveSummary(context, diagramResult), + ...renderImpactedAreas(context), + ...renderImpactsAndAc(context), + ...renderQaSection(context), + ...renderQuestionsAndClarifications(context), + ...renderEvidenceAppendix(context), + ...renderReviewHistory(context), + ...renderReviewCoverage(context), + ...renderEvidenceQuality(context), + ...renderEvaluationContext(evalContext, context.locale), + ...renderImpactDiff(context), + ]; + + // Preserve the original trailing whitespace logic + return lines.join('\n').trim(); + } +} diff --git a/packages/backend-runtime/src/document/application/render/markdown-renderers/evaluation-context.renderer.ts b/packages/backend-runtime/src/document/application/render/markdown-renderers/evaluation-context.renderer.ts new file mode 100644 index 00000000..82553d52 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/markdown-renderers/evaluation-context.renderer.ts @@ -0,0 +1,43 @@ +import type { EvaluationContextAdapter } from '../../evaluation-context.adapter'; +import type { ReportLocale} from '../report-localization'; +import { getReportLabels } from '../report-localization'; + +export function renderEvaluationContext( + evalContext: ReturnType, + locale: ReportLocale, +): string[] { + const labels = getReportLabels(locale); + const lines: string[] = []; + + if (evalContext) { + lines.push(`## ${labels.evaluationContext}`); + lines.push(''); + lines.push(`- **${labels.datasetVersion}**: \`${evalContext.datasetVersion}\``); + lines.push(`- **${labels.subsetId}**: \`${evalContext.subsetId}\``); + lines.push(`- **${labels.subsetSize}**: \`${evalContext.subsetSize}\` (${labels.illustrativeOnly})`); + lines.push(`- **${labels.interpretation}**: \`${evalContext.interpretation}\``); + lines.push(`- **${labels.researchArtifact}**: \`${evalContext.researchFindingsArtifact}\``); + lines.push(`- **${labels.comparisonArtifact}**: \`${evalContext.sameSubsetComparisonArtifact}\``); + lines.push(''); + + if (evalContext.knownLimits.length > 0) { + lines.push(`### ${labels.knownLimits}`); + evalContext.knownLimits.forEach(l => lines.push(`- ${l}`)); + lines.push(''); + } + + if (evalContext.evidenceQualityNotes.length > 0) { + lines.push(`### ${labels.evidenceQualityNotes}`); + evalContext.evidenceQualityNotes.forEach(l => lines.push(`- ${l}`)); + lines.push(''); + } + + if (evalContext.datasetExpansionRecommendations.length > 0) { + lines.push(`### ${labels.datasetExpansionRecommendations}`); + evalContext.datasetExpansionRecommendations.forEach(l => lines.push(`- ${l}`)); + lines.push(''); + } + } + + return lines; +} diff --git a/packages/backend-runtime/src/document/application/render/markdown-renderers/evidence-appendix.renderer.ts b/packages/backend-runtime/src/document/application/render/markdown-renderers/evidence-appendix.renderer.ts new file mode 100644 index 00000000..eca73036 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/markdown-renderers/evidence-appendix.renderer.ts @@ -0,0 +1,44 @@ +import type { MarkdownReportRenderContext } from '../../markdown-impact-report.types'; +import { getReportLabels } from '../report-localization'; + +export function renderEvidenceAppendix(context: MarkdownReportRenderContext): string[] { + const { insights } = context; + const labels = getReportLabels(context.locale); + const lines: string[] = []; + + const approvedInsights = insights.filter((i) => i.reviewStatus !== 'REJECTED'); + const allEvidence = approvedInsights.flatMap(i => i.evidenceLinks.map(el => ({ insightTitle: i.title, evidence: el.evidence }))); + + if (allEvidence.length > 0) { + lines.push(`## ${labels.evidenceAppendix}`); + lines.push(''); + lines.push(`> ${labels.secretsRedacted}`); + lines.push(''); + + // Deduplicate evidence by ID + const uniqueEvidenceMap = new Map(); + for (const item of allEvidence) { + if (!uniqueEvidenceMap.has(item.evidence.id)) { + uniqueEvidenceMap.set(item.evidence.id, item); + } + } + + const uniqueEvidence = Array.from(uniqueEvidenceMap.values()); + + for (const item of uniqueEvidence) { + const e = item.evidence; + const name = e.sourcePath?.split('/').pop() || labels.unknown; + lines.push(`### \`${name}\``); + lines.push(''); + if (e.sourcePath) lines.push(`**${labels.file}:** \`${e.sourcePath}\` `); + if (e.startLine && e.endLine) lines.push(`**${labels.lines}:** ${e.startLine}–${e.endLine}`); + lines.push(''); + lines.push('```ts'); + lines.push(e.excerpt); + lines.push('```'); + lines.push(''); + } + } + + return lines; +} diff --git a/packages/backend-runtime/src/document/application/render/markdown-renderers/executive-summary.renderer.ts b/packages/backend-runtime/src/document/application/render/markdown-renderers/executive-summary.renderer.ts new file mode 100644 index 00000000..f88564ec --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/markdown-renderers/executive-summary.renderer.ts @@ -0,0 +1,49 @@ +import type { MarkdownReportRenderContext } from '../../markdown-impact-report.types'; +import { resolveArtifactDisplayType } from './markdown-render-utils'; +import { getReportLabels } from '../report-localization'; + +export function renderExecutiveSummary(context: MarkdownReportRenderContext, diagramResult: { mermaid: string; isTruncated: boolean }): string[] { + const { insights, traceabilityLinks, hasUnreviewedItems } = context; + const labels = getReportLabels(context.locale); + const lines: string[] = []; + + const approvedInsights = insights.filter((i) => i.reviewStatus !== 'REJECTED'); + const rejectedCount = insights.length - approvedInsights.length; + + lines.push(`## ${labels.impactFlowDiagram}`); + lines.push(''); + lines.push(diagramResult.mermaid); + lines.push(''); + if (diagramResult.isTruncated) { + lines.push(`> ${labels.diagramTruncated}`); + lines.push(''); + } + + const claims = approvedInsights.filter(i => i.insightType === 'CLAIM'); + const qaScenarios = approvedInsights.filter(i => i.insightType === 'QA_SCENARIO'); + const openQuestions = approvedInsights.filter(i => i.insightType === 'QUESTION' || i.insightType === 'UNKNOWN'); + + lines.push(`## ${labels.executiveSummary}`); + lines.push(''); + lines.push(labels.executiveSummaryLine(claims.length, qaScenarios.length, openQuestions.length)); + + if (traceabilityLinks.length > 0) { + const topAreas = Array.from( + new Set(traceabilityLinks.map((l) => resolveArtifactDisplayType(l.artifact))), + ).join(' and '); + lines.push(labels.primaryImpactedAreas(topAreas)); + } + lines.push(''); + + if (rejectedCount > 0) { + lines.push(`> ${labels.rejectedExcluded}`); + lines.push(''); + } + + if (hasUnreviewedItems) { + lines.push(`> ${labels.unreviewedAcknowledged}`); + lines.push(''); + } + + return lines; +} diff --git a/packages/backend-runtime/src/document/application/render/markdown-renderers/impact-diff.renderer.ts b/packages/backend-runtime/src/document/application/render/markdown-renderers/impact-diff.renderer.ts new file mode 100644 index 00000000..0812700d --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/markdown-renderers/impact-diff.renderer.ts @@ -0,0 +1,81 @@ +import type { MarkdownReportRenderContext } from '../../markdown-impact-report.types'; +import { formatArtifactType } from './markdown-render-utils'; +import { getReportLabels } from '../report-localization'; + +export function renderImpactDiff(context: MarkdownReportRenderContext): string[] { + const { diff } = context; + const labels = getReportLabels(context.locale); + const lines: string[] = []; + + if (diff) { + lines.push(`## ${labels.impactDiffSnapshot}`); + lines.push(''); + lines.push(`${labels.derivedFromBaseline}: \`${diff.baseAnalysisId}\``); + lines.push(''); + lines.push(`### ${labels.summary}`); + lines.push(`- ${labels.addedCodeImpacts}: ${diff.summary.addedImpacts}`); + lines.push(`- ${labels.removedCodeImpacts}: ${diff.summary.removedImpacts}`); + lines.push(`- ${labels.resolvedUnknowns}: ${diff.summary.resolvedUnknowns}`); + lines.push(`- ${labels.newUnknowns}: ${diff.summary.newUnknowns}`); + lines.push(`- ${labels.addedQaScenarios}: ${diff.summary.addedQaScenarios}`); + lines.push(''); + + if (diff.addedArtifacts && diff.addedArtifacts.length > 0) { + lines.push(`### ${formatDiffHeading(labels.addedCodeImpacts, context.locale)}`); + lines.push(''); + for (const art of diff.addedArtifacts) { + lines.push(`- \`${art.name}\` (${formatArtifactType(art.artifactType)}) in \`${art.filePath}\``); + } + lines.push(''); + } + + if (diff.removedArtifacts && diff.removedArtifacts.length > 0) { + lines.push(`### ${formatDiffHeading(labels.removedCodeImpacts, context.locale)}`); + lines.push(''); + for (const art of diff.removedArtifacts) { + lines.push(`- \`${art.name}\` (${formatArtifactType(art.artifactType)}) in \`${art.filePath}\``); + } + lines.push(''); + } + + if (diff.resolvedUnknowns && diff.resolvedUnknowns.length > 0) { + lines.push(`### ${formatDiffHeading(labels.resolvedUnknowns, context.locale)}`); + lines.push(''); + for (const unk of diff.resolvedUnknowns) { + lines.push(`- ${unk.statement}`); + } + lines.push(''); + } + + if (diff.newUnknowns && diff.newUnknowns.length > 0) { + lines.push(`### ${formatDiffHeading(labels.newUnknowns, context.locale)}`); + lines.push(''); + for (const unk of diff.newUnknowns) { + lines.push(`- ${unk.statement}`); + } + lines.push(''); + } + + if (diff.addedQaScenarios && diff.addedQaScenarios.length > 0) { + lines.push(`### ${formatDiffHeading(labels.addedQaScenarios, context.locale)}`); + lines.push(''); + for (const qa of diff.addedQaScenarios) { + lines.push(`- **${qa.insightKey || qa.statement}**: ${qa.statement}`); + } + lines.push(''); + } + } + + return lines; +} + +function formatDiffHeading(value: string, locale: string): string { + if (locale !== 'en') { + return value; + } + + return value + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} diff --git a/packages/backend-runtime/src/document/application/render/markdown-renderers/insight-section.renderer.ts b/packages/backend-runtime/src/document/application/render/markdown-renderers/insight-section.renderer.ts new file mode 100644 index 00000000..fa1ca267 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/markdown-renderers/insight-section.renderer.ts @@ -0,0 +1,138 @@ +import type { MarkdownReportRenderContext } from '../../markdown-impact-report.types'; +import { formatCertainty } from './markdown-render-utils'; +import { getReportLabels } from '../report-localization'; + +export function renderImpactsAndAc(context: MarkdownReportRenderContext): string[] { + const { insights, reviewNotes } = context; + const labels = getReportLabels(context.locale); + const lines: string[] = []; + + const approvedInsights = insights.filter((i) => i.reviewStatus !== 'REJECTED'); + const claims = approvedInsights.filter(i => i.insightType === 'CLAIM'); + const acceptanceCriteria = approvedInsights.filter(i => i.insightType === 'ACCEPTANCE_CRITERIA'); + + if (claims.length > 0) { + lines.push(`## ${labels.evidenceBackedImpacts}`); + lines.push(''); + claims.forEach((claim, index) => { + lines.push(`### ${index + 1}. ${claim.description || claim.title}`); + lines.push(''); + lines.push(`> **${labels.certainty}:** ${formatCertainty(claim.certainty, context.locale)} `); + const claimNote = reviewNotes.find(n => n.insightId === claim.id); + if (claimNote) { + lines.push(`> **${labels.reviewerNote}:** ${claimNote.body} `); + } + if (claim.reasoning) { + lines.push(`> **${labels.reasoning}:** ${claim.reasoning} `); + } + lines.push(''); + + if (claim.evidenceLinks.length > 0) { + lines.push(`**${labels.evidence}:**`); + const filePaths = new Set(claim.evidenceLinks.map(e => e.evidence.sourcePath).filter(Boolean)); + filePaths.forEach(path => lines.push(`- \`${path}\``)); + } else { + lines.push(labels.noEvidenceAttached); + } + lines.push(''); + }); + } + + if (acceptanceCriteria.length > 0) { + lines.push(`## ${labels.acceptanceCriteria}`); + lines.push(''); + for (const ac of acceptanceCriteria) { + lines.push(`- ${ac.description || ac.title}`); + const acNote = reviewNotes.find(n => n.insightId === ac.id); + if (acNote) { + lines.push(`
**${labels.reviewerNote}:** ${acNote.body}`); + } + if (ac.evidenceLinks.length === 0) { + lines.push(`
${labels.notDirectlyEvidenced}`); + } + } + lines.push(''); + } + + return lines; +} + +export function renderQuestionsAndClarifications(context: MarkdownReportRenderContext): string[] { + const { insights, reviewNotes, clarifications } = context; + const labels = getReportLabels(context.locale); + const lines: string[] = []; + + const approvedInsights = insights.filter((i) => i.reviewStatus !== 'REJECTED'); + const openQuestions = approvedInsights.filter(i => i.insightType === 'QUESTION' || i.insightType === 'UNKNOWN'); + + if (openQuestions.length > 0) { + lines.push(`## ${labels.openQuestions}`); + lines.push(''); + for (const q of openQuestions) { + lines.push(`### ${q.title}`); + lines.push(''); + lines.push(`> **${labels.question}:** ${q.description || q.title}`); + + const qNote = reviewNotes.find(n => n.insightId === q.id); + if (qNote) { + lines.push(`> `); + lines.push(`> **${labels.reviewerNote}:** ${qNote.body}`); + } + if (q.reasoning) { + lines.push(`> `); + lines.push(`> **${labels.whyThisMatters}:** ${q.reasoning}`); + } + + if (q.metadata && typeof q.metadata === 'object' && (q.metadata as any).origin === 'SCANNER_DIAGNOSTIC') { + lines.push(`> `); + lines.push(`> ${labels.derivedFromScannerDiagnostic}`); + } + lines.push(''); + } + } + + if (clarifications.length > 0) { + lines.push(`## ${labels.clarifications}`); + lines.push(''); + + const answered = clarifications.filter(c => c.status === 'ANSWERED' || c.status === 'CONVERTED_TO_REVISION'); + const open = clarifications.filter(c => c.status === 'OPEN'); + const dismissed = clarifications.filter(c => c.status === 'DISMISSED'); + + if (answered.length > 0) { + lines.push(`### ${labels.answered}`); + lines.push(''); + answered.forEach(c => { + lines.push(`**${labels.question}:** ${c.question} `); + if (c.reason) lines.push(`**${labels.whyThisMatters}:** ${c.reason} `); + lines.push(`**${labels.answer}:** ${c.answer} `); + if (c.status === 'CONVERTED_TO_REVISION' && c.convertedRequirementRevisionId) { + lines.push(`**${labels.disposition}:** ${labels.convertedToRequirementRevision} \`${c.convertedRequirementRevisionId}\``); + } + lines.push(''); + }); + } + + if (open.length > 0) { + lines.push(`### ${labels.stillOpen}`); + lines.push(''); + open.forEach(c => { + lines.push(`**${labels.question}:** ${c.question} `); + if (c.reason) lines.push(`**${labels.whyThisMatters}:** ${c.reason} `); + lines.push(''); + }); + } + + if (dismissed.length > 0) { + lines.push(`### ${labels.dismissed}`); + lines.push(''); + dismissed.forEach(c => { + lines.push(`**${labels.question}:** ${c.question} `); + lines.push(`**${labels.disposition}:** ${labels.dismissedDuringReview} ${c.reason ? `${labels.reason}: ${c.reason}` : ''}`); + lines.push(''); + }); + } + } + + return lines; +} diff --git a/packages/backend-runtime/src/document/application/render/markdown-renderers/markdown-render-utils.ts b/packages/backend-runtime/src/document/application/render/markdown-renderers/markdown-render-utils.ts new file mode 100644 index 00000000..a570ef72 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/markdown-renderers/markdown-render-utils.ts @@ -0,0 +1,50 @@ +import type { ReportLocale } from '../report-localization'; + +export function formatArtifactType(type: string): string { + return type.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' '); +} + +export function resolveArtifactDisplayType(artifact?: { artifactType?: string | null; universalKind?: string | null } | null): string { + if (!artifact) return 'Unknown'; + if (artifact.universalKind) return formatArtifactType(artifact.universalKind); + if (artifact.artifactType) return formatArtifactType(artifact.artifactType); + return 'Unknown'; +} + +export function formatCertainty(certainty: string, locale: ReportLocale = 'en'): string { + if (locale === 'vi') { + switch (certainty) { + case 'EVIDENCED': return 'Có bằng chứng'; + case 'INFERRED': return 'Suy luận'; + case 'UNKNOWN': return 'Không rõ'; + case 'CONFLICTING': return 'Mâu thuẫn'; + default: return certainty; + } + } + + switch (certainty) { + case 'EVIDENCED': return 'Evidenced'; + case 'INFERRED': return 'Inferred'; + case 'UNKNOWN': return 'Unknown'; + case 'CONFLICTING': return 'Conflicting'; + default: return certainty; + } +} + +export function parseQaScenarioParts(description: string): { precondition: string, action: string, expected: string } { + let precondition = '-'; + let action = '-'; + let expected = description; + + const givenMatch = description.match(/Given (.*?) When /i); + const whenMatch = description.match(/When (.*?) Then /i); + const thenMatch = description.match(/Then (.*)/i); + + if (givenMatch && whenMatch && thenMatch) { + precondition = givenMatch[1]; + action = whenMatch[1]; + expected = thenMatch[1]; + } + + return { precondition, action, expected }; +} diff --git a/packages/backend-runtime/src/document/application/render/markdown-renderers/qa-section.renderer.ts b/packages/backend-runtime/src/document/application/render/markdown-renderers/qa-section.renderer.ts new file mode 100644 index 00000000..93b6c778 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/markdown-renderers/qa-section.renderer.ts @@ -0,0 +1,39 @@ +import type { MarkdownReportRenderContext } from '../../markdown-impact-report.types'; +import { parseQaScenarioParts } from './markdown-render-utils'; +import { getReportLabels } from '../report-localization'; + +export function renderQaSection(context: MarkdownReportRenderContext): string[] { + const { insights, reviewNotes } = context; + const labels = getReportLabels(context.locale); + const lines: string[] = []; + + const approvedInsights = insights.filter((i) => i.reviewStatus !== 'REJECTED'); + const qaScenarios = approvedInsights.filter(i => i.insightType === 'QA_SCENARIO'); + + if (qaScenarios.length > 0) { + lines.push(`## ${labels.qaScenarios}`); + lines.push(''); + for (const qa of qaScenarios) { + lines.push(`### ${qa.title}`); + lines.push(''); + const parts = parseQaScenarioParts(qa.description || qa.title); + + if (parts.precondition !== '-' && parts.action !== '-' && parts.expected !== '-') { + lines.push(`- **Given:** ${parts.precondition}`); + lines.push(`- **When:** ${parts.action}`); + lines.push(`- **Then:** ${parts.expected}`); + } else { + lines.push(`- ${qa.description || qa.title}`); + } + + lines.push(''); + const qaNote = reviewNotes.find(n => n.insightId === qa.id); + if (qaNote) { + lines.push(`> **${labels.reviewerNote}:** ${qaNote.body}`); + lines.push(''); + } + } + } + + return lines; +} diff --git a/packages/backend-runtime/src/document/application/render/markdown-renderers/report-header.renderer.ts b/packages/backend-runtime/src/document/application/render/markdown-renderers/report-header.renderer.ts new file mode 100644 index 00000000..3b894c94 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/markdown-renderers/report-header.renderer.ts @@ -0,0 +1,171 @@ +import type { MarkdownReportRenderContext } from '../../markdown-impact-report.types'; +import { getDomainTerminology, getReportLabels } from '../report-localization'; + +export function renderReportHeader(context: MarkdownReportRenderContext): string[] { + const { analysis, metadata } = context; + const labels = getReportLabels(context.locale); + const lines: string[] = []; + + lines.push(`# ${labels.titlePrefix}: ${analysis.requirementRevision.title}`); + lines.push(''); + lines.push(`**${labels.status}:** ${labels.approved} `); + lines.push(`**${labels.requirement}:** ${analysis.requirementRevision.title} `); + lines.push(`**${labels.snapshotCommit}:** \`${analysis.snapshot.commitSha}\` `); + lines.push(`**${labels.repository}:** \`${analysis.snapshot.repository.canonicalUrl}\` `); + lines.push(`**${labels.targetRef}:** \`${analysis.sourceTarget.requestedRef}\` `); + lines.push(`**${labels.generatedAt}:** ${(metadata?.generatedAt ?? new Date().toISOString()).split('T')[0]} `); + lines.push(''); + + lines.push(`## ${labels.requirement}`); + lines.push(''); + lines.push(`> ${analysis.requirementRevision.rawText.split('\n').join('\n> ')}`); + lines.push(''); + + if (metadata) { + lines.push(`## ${labels.provenance}`); + lines.push(''); + lines.push(`- ${labels.analysisId}: \`${metadata.analysisId}\``); + lines.push(`- ${labels.generatedDocumentId}: \`${metadata.generatedDocumentId}\``); + lines.push(`- ${labels.projectId}: \`${metadata.projectId}\``); + lines.push(`- ${labels.repositoryId}: \`${metadata.repositoryId}\``); + lines.push(`- ${labels.snapshotId}: \`${metadata.snapshotId}\``); + lines.push(`- ${labels.targetRef}: \`${metadata.targetRef}\``); + lines.push(`- ${labels.commitSha}: \`${metadata.commitSha}\``); + lines.push(`- ${labels.analyzerVersion}: \`${metadata.analyzerVersion}\``); + lines.push(`- ${labels.finalizedAt}: ${metadata.finalizedAt ?? metadata.generatedAt}`); + const domainPack = readDomainPack(analysis, metadata); + const domainPackId = domainPack?.id ?? null; + const domainPackVersion = domainPack?.version ?? null; + const domainPackStatus = domainPack?.status ?? null; + const domainPackSelectedBy = domainPack?.selectedBy ?? null; + if (domainPackId && domainPackVersion && domainPackStatus && domainPackSelectedBy) { + lines.push(`- ${labels.domainPack}: \`${domainPackId}@${domainPackVersion}\` (${domainPackStatus}, ${domainPackSelectedBy})`); + } + lines.push(''); + } + + if (readDomainPack(analysis, metadata)?.status === 'PARTIAL') { + lines.push(`> **${labels.domainPack}: PARTIAL.** ${labels.partialDomainPackWarning} ${labels.administrativeWorkflowOnly} ${labels.noMedicalClinicalCompliance}`); + lines.push(''); + } + + const terminology = getDomainTerminology(resolveDomainPackId(analysis), context.locale); + if (context.locale === 'vi' && terminology.length > 0) { + lines.push(`## ${labels.terminology}`); + lines.push(''); + for (const term of terminology) { + lines.push(`- ${term.key}: ${term.value}`); + } + lines.push(''); + } + + const diagnostics = (analysis.snapshot.diagnostics as any as any[]) || []; + const capabilitySummary = diagnostics.find(d => d.code === 'SCANNER_CAPABILITY_SUMMARY'); + const unsupportedDiagnostics = diagnostics.filter(d => + d.code !== 'SCANNER_CAPABILITY_SUMMARY' && + (d.code.includes('UNSUPPORTED') || d.severity === 'WARN' || d.severity === 'ERROR') + ); + + if (capabilitySummary?.payload) { + lines.push(`## ${labels.scannerCapabilityProfile}`); + lines.push(''); + const p = capabilitySummary.payload; + lines.push(`- **${labels.language}:** ${p.language}`); + if (p.framework) lines.push(`- **${labels.framework}:** ${p.framework}`); + lines.push(`- **${labels.maturityStatus}:** ${p.status}`); + lines.push(`- **${labels.confidenceLevel}:** ${p.confidence}`); + lines.push(''); + } + + if (unsupportedDiagnostics.length > 0) { + lines.push(`## ${labels.scannerDiagnosticsAndRisks}`); + lines.push(''); + for (const diag of unsupportedDiagnostics) { + lines.push(`- **${diag.code}**: ${diag.message}`); + } + lines.push(''); + } + + return lines; +} + +function resolveDomainPackId(analysis: MarkdownReportRenderContext['analysis']) { + return readDomainPack(analysis)?.id ?? analysis.snapshot.profile?.domain ?? null; +} + +function readDomainPack( + analysis: MarkdownReportRenderContext['analysis'], + metadata?: MarkdownReportRenderContext['metadata'], +): { + id: string; + version: string; + status: string; + selectedBy: string; +} | null { + if (metadata?.domainPack) { + return { + id: metadata.domainPack.domainPackId, + version: metadata.domainPack.domainPackVersion, + status: metadata.domainPack.domainPackStatus, + selectedBy: metadata.domainPack.selectedBy, + }; + } + + const firstClass = readFirstClassDomainPack(analysis); + if (firstClass) { + return firstClass; + } + + const domainPack = readObjectField(analysis.metadata, 'domainPack'); + const id = readStringField(domainPack, 'id'); + const version = readStringField(domainPack, 'version'); + const status = readStringField(domainPack, 'status'); + const selectedBy = readStringField(domainPack, 'selectedBy'); + if (!id || !version || !status || !selectedBy) { + return null; + } + + return { id, version, status, selectedBy }; +} + +function readFirstClassDomainPack( + analysis: MarkdownReportRenderContext['analysis'], +) { + const record = analysis as MarkdownReportRenderContext['analysis'] & { + resolvedDomainPackId?: string | null; + resolvedDomainPackVersion?: string | null; + resolvedDomainPackStatus?: string | null; + domainPackSelectedBy?: string | null; + }; + if ( + !record.resolvedDomainPackId || + !record.resolvedDomainPackVersion || + !record.resolvedDomainPackStatus || + !record.domainPackSelectedBy + ) { + return null; + } + + return { + id: record.resolvedDomainPackId, + version: record.resolvedDomainPackVersion, + status: record.resolvedDomainPackStatus, + selectedBy: record.domainPackSelectedBy, + }; +} + +function readObjectField(source: unknown, key: string): Record | null { + if (!source || typeof source !== 'object' || Array.isArray(source)) { + return null; + } + const value = (source as Record)[key]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function readStringField(source: Record | null, key: string) { + const value = source?.[key]; + return typeof value === 'string' ? value : null; +} diff --git a/packages/backend-runtime/src/document/application/render/markdown-renderers/review-history.renderer.ts b/packages/backend-runtime/src/document/application/render/markdown-renderers/review-history.renderer.ts new file mode 100644 index 00000000..3287adc4 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/markdown-renderers/review-history.renderer.ts @@ -0,0 +1,25 @@ +import type { MarkdownReportRenderContext } from '../../markdown-impact-report.types'; +import { getReportLabels } from '../report-localization'; + +export function renderReviewHistory(context: MarkdownReportRenderContext): string[] { + const { reviewDecisions } = context; + const labels = getReportLabels(context.locale); + const lines: string[] = []; + + if (reviewDecisions && reviewDecisions.length > 0) { + lines.push(`## ${labels.reviewDecisionHistory}`); + lines.push(''); + lines.push(`| ${labels.time} | ${labels.reviewer} | ${labels.decision} | ${labels.note} |`); + lines.push('|---|---|---|---|'); + for (const d of reviewDecisions) { + const time = new Date(d.createdAt).toISOString().replace('T', ' ').substring(0, 19); + const reviewer = d.reviewedBy; + const decision = d.decision; + const note = d.note || '-'; + lines.push(`| ${time} | ${reviewer} | ${decision} | ${note} |`); + } + lines.push(''); + } + + return lines; +} diff --git a/packages/backend-runtime/src/document/application/render/markdown-renderers/traceability-section.renderer.ts b/packages/backend-runtime/src/document/application/render/markdown-renderers/traceability-section.renderer.ts new file mode 100644 index 00000000..b997a8a7 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/markdown-renderers/traceability-section.renderer.ts @@ -0,0 +1,171 @@ +import type { MarkdownReportRenderContext } from '../../markdown-impact-report.types'; +import { resolveArtifactDisplayType } from './markdown-render-utils'; +import { EvidenceQualityAnnotator } from '../../evidence-quality.annotator'; +import { getReportLabels } from '../report-localization'; + +export function renderReviewCoverage(context: MarkdownReportRenderContext): string[] { + const summary = context.reviewCoverageSummarySnapshot; + if (!summary) { + return []; + } + + const labels = getReportLabels(context.locale); + const weakOrMissing = summary.evidence.weak + summary.evidence.missing; + + return [ + `## ${labels.reviewCoverage}`, + '', + `- ${labels.insightsReviewed}: ${summary.insights.reviewed} / ${summary.insights.total}`, + `- ${labels.traceabilityLinksReviewed}: ${summary.traceabilityLinks.reviewed} / ${summary.traceabilityLinks.total}`, + `- ${labels.acceptedItems}: ${summary.decisions.accepted}`, + `- ${labels.rejectedItems}: ${summary.decisions.rejected}`, + `- ${labels.needsClarificationItems}: ${summary.decisions.needsClarification}`, + `- ${labels.strongSourceEvidence}: ${summary.evidence.strong}`, + `- ${labels.weakOrMissingEvidence}: ${weakOrMissing}`, + `- ${labels.conflictingEvidence}: ${summary.evidence.conflicting}`, + `- ${labels.reviewRequired}: ${summary.evidence.reviewRequired}`, + '', + ]; +} + +export function renderImpactedAreas(context: MarkdownReportRenderContext): string[] { + const { analysis, traceabilityLinks, reviewNotes } = context; + const labels = getReportLabels(context.locale); + const lines: string[] = []; + + if (traceabilityLinks.length === 0) { + return lines; + } + + const diagnostics = (analysis.snapshot.diagnostics as any as any[]) || []; + const capabilitySummary = diagnostics.find(d => d.code === 'SCANNER_CAPABILITY_SUMMARY'); + + lines.push(`## ${labels.impactedAreas}`); + lines.push(''); + // Group links by type + const groupedLinks = new Map(); + const sortedLinks = [...traceabilityLinks].sort((a, b) => a.reviewStatus.localeCompare(b.reviewStatus)); + + for (const link of sortedLinks) { + const type = resolveArtifactDisplayType(link.artifact); + if (!groupedLinks.has(type)) { + groupedLinks.set(type, []); + } + groupedLinks.get(type)!.push(link); + } + + for (const [type, links] of groupedLinks.entries()) { + lines.push(`### ${type}`); + lines.push(''); + for (const link of links) { + const nameRaw = link.artifact?.name ? `\`${link.artifact.name}\`` : labels.unknown; + let maturityLabel = ''; + if (capabilitySummary?.payload) { + const p = capabilitySummary.payload; + if (p.status && p.status !== 'STABLE') { + maturityLabel = ` (${p.status})`; + } + } else if (link.artifact?.artifactKey?.startsWith('go_') || link.artifact?.artifactKey?.startsWith('java_')) { + maturityLabel = link.artifact.artifactKey.startsWith('go_') ? ' (EXPERIMENTAL)' : ' (PARTIAL)'; + } + + let methodLabel = ''; + if (link.artifact?.name?.includes('UNKNOWN')) { + methodLabel = ` **[${labels.methodUnknown}]**`; + } + + const name = nameRaw + maturityLabel + methodLabel; + const file = link.artifact?.filePath ? `\`${link.artifact.filePath}\`` : labels.unknown; + const status = link.reviewStatus === 'CONFIRMED' ? labels.confirmed : link.reviewStatus === 'NEEDS_REVIEW' ? labels.needsReview : link.reviewStatus; + + lines.push(`- ${name} in ${file} — **${status}**`); + } + lines.push(''); + } + + const linkNotes = reviewNotes.filter(n => n.traceabilityLinkId && traceabilityLinks.some(l => l.id === n.traceabilityLinkId)); + if (linkNotes.length > 0) { + lines.push(`### ${labels.reviewerNotesOnImpactedAreas}`); + lines.push(''); + for (const note of linkNotes) { + const link = traceabilityLinks.find(l => l.id === note.traceabilityLinkId); + if (link?.artifact?.name) { + lines.push(`- \`${link.artifact.name}\`: ${note.body}`); + } + } + lines.push(''); + } + + return lines; +} + +export function renderEvidenceQuality(context: MarkdownReportRenderContext): string[] { + const { traceabilityLinks, reviewDecisionsSnapshot, evidenceQualitySummarySnapshot } = context; + const labels = getReportLabels(context.locale); + const lines: string[] = []; + + if (traceabilityLinks.length === 0) { + return lines; + } + + lines.push(`## ${labels.evidenceQuality}`); + lines.push(''); + + if (evidenceQualitySummarySnapshot) { + const summary = evidenceQualitySummarySnapshot; + lines.push(`- ${labels.strongSourceEvidence}: ${readSummaryCount(summary, 'strongSourceEvidence', 'evidenced', 'STRONG_SOURCE_EVIDENCE')}`); + lines.push(`- ${labels.weakSourceEvidence}: ${readSummaryCount(summary, 'weakSourceEvidence', 'weakEvidence', 'WEAK_SOURCE_EVIDENCE')}`); + lines.push(`- ${labels.inferredFromStructure}: ${readSummaryCount(summary, 'inferredFromStructure', 'inferred', 'INFERRED_FROM_STRUCTURE')}`); + lines.push(`- ${labels.domainHintOnly}: ${readSummaryCount(summary, 'domainHintOnly', 'DOMAIN_HINT_ONLY')}`); + lines.push(`- ${labels.missingEvidence}: ${readSummaryCount(summary, 'missingEvidence', 'MISSING_EVIDENCE')}`); + lines.push(`- ${labels.conflictingEvidence}: ${readSummaryCount(summary, 'conflictingEvidence', 'CONFLICTING_EVIDENCE')}`); + lines.push(`- ${labels.reviewRequired}: ${readSummaryCount(summary, 'reviewRequired', 'REVIEW_REQUIRED')}`); + } else { + const linkAnnotations = traceabilityLinks.map(link => ({ + link, + annotation: EvidenceQualityAnnotator.annotate(link as any) + })); + + const summary = EvidenceQualityAnnotator.summarize(linkAnnotations.map((item) => item.annotation)); + lines.push(`- ${labels.strongSourceEvidence}: ${summary.strongSourceEvidence}`); + lines.push(`- ${labels.weakSourceEvidence}: ${summary.weakSourceEvidence}`); + lines.push(`- ${labels.inferredFromStructure}: ${summary.inferredFromStructure}`); + lines.push(`- ${labels.domainHintOnly}: ${summary.domainHintOnly}`); + lines.push(`- ${labels.missingEvidence}: ${summary.missingEvidence}`); + lines.push(`- ${labels.conflictingEvidence}: ${summary.conflictingEvidence}`); + lines.push(`- ${labels.reviewRequired}: ${summary.reviewRequired}`); + } + + lines.push(''); + lines.push(`| ${labels.artifact} | ${labels.quality} | ${labels.reason} |`); + lines.push('|---|---|---|'); + + if (reviewDecisionsSnapshot) { + for (const item of reviewDecisionsSnapshot) { + lines.push(`| \`${item.artifact}\` | ${item.quality} | ${item.reasons.join(', ')} |`); + } + } else { + const linkAnnotations = traceabilityLinks.map(link => ({ + link, + annotation: EvidenceQualityAnnotator.annotate(link as any) + })); + + for (const item of linkAnnotations) { + const artifactName = item.link.artifact?.filePath ? `\`${item.link.artifact.filePath}\`` : (item.link.artifact?.name || labels.unknown); + lines.push(`| ${artifactName} | ${item.annotation.label} | ${item.annotation.reasons.join(', ')} |`); + } + } + lines.push(''); + + return lines; +} + +function readSummaryCount(summary: Record, ...keys: string[]): number { + for (const key of keys) { + const value = summary[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + return 0; +} diff --git a/packages/backend-runtime/src/document/application/render/report-localization.ts b/packages/backend-runtime/src/document/application/render/report-localization.ts new file mode 100644 index 00000000..47201595 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/report-localization.ts @@ -0,0 +1,301 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { DEFAULT_REPORT_LOCALE, ReportLabels, ReportLocale } from './report-localization.types'; + +export { DEFAULT_REPORT_LOCALE, ReportLabels, ReportLocale }; + +export function normalizeReportLocale(value?: string | null): ReportLocale { + return value === 'vi' ? 'vi' : DEFAULT_REPORT_LOCALE; +} + +const REPORT_LABELS: Record = { + en: { + titlePrefix: 'Impact Analysis Report', + status: 'Status', + approved: 'Approved', + requirement: 'Requirement', + snapshotCommit: 'Snapshot Commit', + repository: 'Repository', + targetRef: 'Target Ref', + generatedAt: 'Generated At', + provenance: 'Provenance', + analysisId: 'Analysis ID', + generatedDocumentId: 'Generated Document ID', + projectId: 'Project ID', + repositoryId: 'Repository ID', + snapshotId: 'Snapshot ID', + commitSha: 'Commit SHA', + analyzerVersion: 'Analyzer Version', + finalizedAt: 'Finalized At', + domainPack: 'Domain Pack', + partialDomainPackWarning: 'Domain hints are limited and require source evidence.', + administrativeWorkflowOnly: 'This pack supports administrative workflow impact analysis only.', + noMedicalClinicalCompliance: 'It does not provide medical advice, clinical decision support, or compliance validation.', + scannerCapabilityProfile: 'Scanner Capability Profile', + scannerDiagnosticsAndRisks: 'Scanner Diagnostics & Risks', + language: 'Language', + framework: 'Framework', + maturityStatus: 'Maturity Status', + confidenceLevel: 'Confidence Level', + terminology: 'Domain Terminology', + impactFlowDiagram: 'Impact Flow Diagram', + executiveSummary: 'Executive Summary', + impactedAreas: 'Impacted Areas', + reviewerNotesOnImpactedAreas: 'Reviewer Notes on Impacted Areas', + evidenceBackedImpacts: 'Evidence-backed Impacts', + certainty: 'Certainty', + reviewerNote: 'Reviewer Note', + reasoning: 'Reasoning', + evidence: 'Evidence', + noEvidenceAttached: '_No evidence attached._', + acceptanceCriteria: 'Acceptance Criteria', + notDirectlyEvidenced: '_Not directly evidenced; derived from requirement and should be confirmed._', + qaScenarios: 'QA Scenarios', + scenario: 'Scenario', + precondition: 'Precondition', + action: 'Action', + expectedResult: 'Expected Result', + openQuestions: 'Open Questions / Unknowns', + question: 'Question', + whyThisMatters: 'Why this matters', + derivedFromScannerDiagnostic: '_Derived from scanner diagnostic_', + clarifications: 'Clarifications', + answered: 'Answered', + answer: 'Answer', + disposition: 'Disposition', + convertedToRequirementRevision: 'Converted to Requirement Revision', + stillOpen: 'Still Open', + dismissed: 'Dismissed', + dismissedDuringReview: 'Dismissed during review.', + evidenceAppendix: 'Evidence Appendix', + secretsRedacted: 'Secrets were redacted before storage, embedding, or LLM processing.', + file: 'File', + lines: 'Lines', + reviewDecisionHistory: 'Review Decision History', + time: 'Time', + reviewer: 'Reviewer', + decision: 'Decision', + note: 'Note', + reviewCoverage: 'Review Coverage', + insightsReviewed: 'Insights reviewed', + traceabilityLinksReviewed: 'Traceability links reviewed', + acceptedItems: 'Accepted items', + rejectedItems: 'Rejected items', + needsClarificationItems: 'Needs clarification items', + weakOrMissingEvidence: 'Weak/missing evidence', + evidenceQuality: 'Evidence Quality & Dataset Readiness', + evidenceBackedLinks: 'Evidence-backed links', + strongSourceEvidence: 'Strong source evidence', + weakSourceEvidence: 'Weak source evidence', + inferredFromStructure: 'Inferred from structure', + domainHintOnly: 'Domain hint only', + missingEvidence: 'Missing evidence', + conflictingEvidence: 'Conflicting evidence', + inferredLinks: 'Inferred links', + reviewRequired: 'Review required', + artifact: 'Artifact', + quality: 'Quality', + reason: 'Reason', + evaluationContext: 'Evaluation Context', + datasetVersion: 'Dataset Version', + subsetId: 'Subset ID', + subsetSize: 'Subset Size', + illustrativeOnly: 'Illustrative Only', + interpretation: 'Interpretation', + researchArtifact: 'Research Artifact', + comparisonArtifact: 'Comparison Artifact', + knownLimits: 'Known Limits', + evidenceQualityNotes: 'Evidence Quality Notes', + datasetExpansionRecommendations: 'Dataset Expansion Recommendations', + impactDiffSnapshot: 'Impact Diff Snapshot', + derivedFromBaseline: 'This analysis was derived from baseline analysis', + summary: 'Summary', + addedCodeImpacts: 'Added code impacts', + removedCodeImpacts: 'Removed code impacts', + resolvedUnknowns: 'Resolved unknowns', + newUnknowns: 'New unknowns', + addedQaScenarios: 'Added QA scenarios', + area: 'Area', + reviewStatus: 'Review Status', + confirmed: 'Confirmed', + needsReview: 'Needs Review', + unknown: 'Unknown', + methodUnknown: 'Method: UNKNOWN', + rejectedExcluded: 'Rejected insights are excluded from this approved report.', + unreviewedAcknowledged: 'This report was finalized with unreviewed items acknowledged.', + diagramTruncated: 'Diagram truncated to the most relevant impacted artifacts. See the Impacted Areas and Evidence Appendix for full details.', + executiveSummaryLine: (claims, qaScenarios, openQuestions) => + `This analysis identified **${claims}** evidence-backed impacts, **${qaScenarios}** QA scenarios, and **${openQuestions}** open questions.`, + primaryImpactedAreas: (areas) => `The primary impacted areas are **${areas.toLowerCase()}** layers.`, + }, + vi: { + titlePrefix: 'Báo cáo phân tích tác động', + status: 'Trạng thái', + approved: 'Đã phê duyệt', + requirement: 'Yêu cầu', + snapshotCommit: 'Commit snapshot', + repository: 'Repository', + targetRef: 'Nhánh đích', + generatedAt: 'Tạo lúc', + provenance: 'Truy vết', + analysisId: 'Analysis ID', + generatedDocumentId: 'Generated Document ID', + projectId: 'Project ID', + repositoryId: 'Repository ID', + snapshotId: 'Snapshot ID', + commitSha: 'Commit SHA', + analyzerVersion: 'Analyzer Version', + finalizedAt: 'Finalize lúc', + domainPack: 'Domain Pack', + partialDomainPackWarning: 'Domain hint có giới hạn và phải dựa trên bằng chứng từ source.', + administrativeWorkflowOnly: 'Pack này chỉ hỗ trợ phân tích tác động cho workflow hành chính.', + noMedicalClinicalCompliance: 'Pack này không cung cấp tư vấn y tế, hỗ trợ quyết định lâm sàng, hoặc xác thực compliance.', + scannerCapabilityProfile: 'Hồ sơ năng lực scanner', + scannerDiagnosticsAndRisks: 'Chẩn đoán scanner và rủi ro', + language: 'Ngôn ngữ', + framework: 'Framework', + maturityStatus: 'Mức độ hỗ trợ', + confidenceLevel: 'Độ tin cậy', + terminology: 'Thuật ngữ domain', + impactFlowDiagram: 'Sơ đồ luồng tác động', + executiveSummary: 'Tóm tắt điều hành', + impactedAreas: 'Khu vực bị tác động', + reviewerNotesOnImpactedAreas: 'Ghi chú review về khu vực tác động', + evidenceBackedImpacts: 'Tác động có bằng chứng', + certainty: 'Độ chắc chắn', + reviewerNote: 'Ghi chú reviewer', + reasoning: 'Lập luận', + evidence: 'Bằng chứng', + noEvidenceAttached: '_Chưa gắn bằng chứng._', + acceptanceCriteria: 'Tiêu chí chấp nhận', + notDirectlyEvidenced: '_Không có bằng chứng trực tiếp; được suy ra từ yêu cầu và cần xác nhận._', + qaScenarios: 'Kịch bản QA', + scenario: 'Kịch bản', + precondition: 'Điều kiện trước', + action: 'Hành động', + expectedResult: 'Kết quả mong đợi', + openQuestions: 'Câu hỏi mở / điều chưa rõ', + question: 'Câu hỏi', + whyThisMatters: 'Vì sao quan trọng', + derivedFromScannerDiagnostic: '_Được suy ra từ chẩn đoán scanner_', + clarifications: 'Làm rõ', + answered: 'Đã trả lời', + answer: 'Trả lời', + disposition: 'Xử lý', + convertedToRequirementRevision: 'Đã chuyển thành Requirement Revision', + stillOpen: 'Còn mở', + dismissed: 'Đã bỏ qua', + dismissedDuringReview: 'Đã bỏ qua trong quá trình review.', + evidenceAppendix: 'Phụ lục bằng chứng', + secretsRedacted: 'Secret đã được redact trước khi lưu trữ, embedding, hoặc xử lý LLM.', + file: 'File', + lines: 'Dòng', + reviewDecisionHistory: 'Lịch sử quyết định review', + time: 'Thời gian', + reviewer: 'Reviewer', + decision: 'Quyết định', + note: 'Ghi chú', + reviewCoverage: 'Độ phủ review', + insightsReviewed: 'Insight đã review', + traceabilityLinksReviewed: 'Traceability link đã review', + acceptedItems: 'Item đã accept', + rejectedItems: 'Item đã reject', + needsClarificationItems: 'Item cần làm rõ', + weakOrMissingEvidence: 'Bằng chứng yếu/thiếu', + evidenceQuality: 'Chất lượng bằng chứng và mức sẵn sàng dataset', + evidenceBackedLinks: 'Link có bằng chứng', + strongSourceEvidence: 'Bằng chứng source mạnh', + weakSourceEvidence: 'Bằng chứng source yếu', + inferredFromStructure: 'Suy ra từ cấu trúc', + domainHintOnly: 'Chỉ là domain hint', + missingEvidence: 'Thiếu bằng chứng', + conflictingEvidence: 'Bằng chứng mâu thuẫn', + inferredLinks: 'Link suy luận', + reviewRequired: 'Cần review', + artifact: 'Artifact', + quality: 'Chất lượng', + reason: 'Lý do', + evaluationContext: 'Ngữ cảnh đánh giá', + datasetVersion: 'Phiên bản dataset', + subsetId: 'Subset ID', + subsetSize: 'Kích thước subset', + illustrativeOnly: 'Chỉ để minh họa', + interpretation: 'Cách diễn giải', + researchArtifact: 'Artifact nghiên cứu', + comparisonArtifact: 'Artifact so sánh', + knownLimits: 'Giới hạn đã biết', + evidenceQualityNotes: 'Ghi chú chất lượng bằng chứng', + datasetExpansionRecommendations: 'Khuyến nghị mở rộng dataset', + impactDiffSnapshot: 'Snapshot diff tác động', + derivedFromBaseline: 'Analysis này được tạo từ baseline analysis', + summary: 'Tóm tắt', + addedCodeImpacts: 'Tác động code mới', + removedCodeImpacts: 'Tác động code đã gỡ', + resolvedUnknowns: 'Điều chưa rõ đã được giải quyết', + newUnknowns: 'Điều chưa rõ mới', + addedQaScenarios: 'Kịch bản QA mới', + area: 'Khu vực', + reviewStatus: 'Trạng thái review', + confirmed: 'Đã xác nhận', + needsReview: 'Cần review', + unknown: 'Không rõ', + methodUnknown: 'Method: UNKNOWN', + rejectedExcluded: 'Insight bị reject đã được loại khỏi report đã phê duyệt.', + unreviewedAcknowledged: 'Report này được finalize với các item chưa review đã được acknowledge.', + diagramTruncated: 'Sơ đồ đã được rút gọn vào các artifact tác động quan trọng nhất. Xem Khu vực bị tác động và Phụ lục bằng chứng để biết đầy đủ.', + executiveSummaryLine: (claims, qaScenarios, openQuestions) => + `Analysis này xác định **${claims}** tác động có bằng chứng, **${qaScenarios}** kịch bản QA, và **${openQuestions}** câu hỏi mở.`, + primaryImpactedAreas: (areas) => `Khu vực tác động chính là các layer **${areas.toLowerCase()}**.`, + }, +}; + +export function getReportLabels(locale: ReportLocale = DEFAULT_REPORT_LOCALE): ReportLabels { + return REPORT_LABELS[locale] ?? REPORT_LABELS[DEFAULT_REPORT_LOCALE]; +} + +export function getDomainTerminology( + domain: string | null | undefined, + locale: ReportLocale, +): Array<{ key: string; value: string }> { + const normalizedDomain = domain?.toLowerCase().trim(); + if (!normalizedDomain || !/^[a-z0-9-]+$/.test(normalizedDomain)) { + return []; + } + + const glossary = readGlossary(normalizedDomain, locale) ?? + readGlossary(normalizedDomain, DEFAULT_REPORT_LOCALE); + + if (!glossary) { + return []; + } + + return Object.entries(glossary.terms).map(([key, value]) => ({ key, value })); +} + +function readGlossary( + domain: string, + locale: ReportLocale, +): { terms: Record } | null { + const file = resolve( + process.cwd(), + 'packages/domain-packs', + domain, + `${locale}.glossary.json`, + ); + + if (!existsSync(file)) { + return null; + } + + const parsed = JSON.parse(readFileSync(file, 'utf-8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + + const terms = (parsed as { terms?: unknown }).terms; + if (!terms || typeof terms !== 'object' || Array.isArray(terms)) { + return null; + } + + return { terms: terms as Record }; +} diff --git a/packages/backend-runtime/src/document/application/render/report-localization.types.ts b/packages/backend-runtime/src/document/application/render/report-localization.types.ts new file mode 100644 index 00000000..09b33a20 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/report-localization.types.ts @@ -0,0 +1,122 @@ +export type ReportLocale = 'en' | 'vi'; + +export const DEFAULT_REPORT_LOCALE: ReportLocale = 'en'; + +export type ReportLabels = { + titlePrefix: string; + status: string; + approved: string; + requirement: string; + snapshotCommit: string; + repository: string; + targetRef: string; + generatedAt: string; + provenance: string; + analysisId: string; + generatedDocumentId: string; + projectId: string; + repositoryId: string; + snapshotId: string; + commitSha: string; + analyzerVersion: string; + finalizedAt: string; + domainPack: string; + partialDomainPackWarning: string; + administrativeWorkflowOnly: string; + noMedicalClinicalCompliance: string; + scannerCapabilityProfile: string; + scannerDiagnosticsAndRisks: string; + language: string; + framework: string; + maturityStatus: string; + confidenceLevel: string; + terminology: string; + impactFlowDiagram: string; + executiveSummary: string; + impactedAreas: string; + reviewerNotesOnImpactedAreas: string; + evidenceBackedImpacts: string; + certainty: string; + reviewerNote: string; + reasoning: string; + evidence: string; + noEvidenceAttached: string; + acceptanceCriteria: string; + notDirectlyEvidenced: string; + qaScenarios: string; + scenario: string; + precondition: string; + action: string; + expectedResult: string; + openQuestions: string; + question: string; + whyThisMatters: string; + derivedFromScannerDiagnostic: string; + clarifications: string; + answered: string; + answer: string; + disposition: string; + convertedToRequirementRevision: string; + stillOpen: string; + dismissed: string; + dismissedDuringReview: string; + evidenceAppendix: string; + secretsRedacted: string; + file: string; + lines: string; + reviewDecisionHistory: string; + time: string; + reviewer: string; + decision: string; + note: string; + reviewCoverage: string; + insightsReviewed: string; + traceabilityLinksReviewed: string; + acceptedItems: string; + rejectedItems: string; + needsClarificationItems: string; + weakOrMissingEvidence: string; + evidenceQuality: string; + evidenceBackedLinks: string; + strongSourceEvidence: string; + weakSourceEvidence: string; + inferredFromStructure: string; + domainHintOnly: string; + missingEvidence: string; + conflictingEvidence: string; + inferredLinks: string; + reviewRequired: string; + artifact: string; + quality: string; + reason: string; + evaluationContext: string; + datasetVersion: string; + subsetId: string; + subsetSize: string; + illustrativeOnly: string; + interpretation: string; + researchArtifact: string; + comparisonArtifact: string; + knownLimits: string; + evidenceQualityNotes: string; + datasetExpansionRecommendations: string; + impactDiffSnapshot: string; + derivedFromBaseline: string; + summary: string; + addedCodeImpacts: string; + removedCodeImpacts: string; + resolvedUnknowns: string; + newUnknowns: string; + addedQaScenarios: string; + area: string; + reviewStatus: string; + confirmed: string; + needsReview: string; + unknown: string; + methodUnknown: string; + rejectedExcluded: string; + unreviewedAcknowledged: string; + diagramTruncated: string; + executiveSummaryLine: (claims: number, qaScenarios: number, openQuestions: number) => string; + primaryImpactedAreas: (areas: string) => string; +}; diff --git a/packages/backend-runtime/src/document/application/render/reviewed-snapshot-report-context.adapter.ts b/packages/backend-runtime/src/document/application/render/reviewed-snapshot-report-context.adapter.ts new file mode 100644 index 00000000..fb961a39 --- /dev/null +++ b/packages/backend-runtime/src/document/application/render/reviewed-snapshot-report-context.adapter.ts @@ -0,0 +1,213 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { MarkdownReportRenderContext } from '../markdown-impact-report.types'; +import { InsightRepository } from '../../../insight/infrastructure/insight.repository'; +import { TraceabilityRepository } from '../../../traceability/infrastructure/traceability.repository'; +import { ReviewNoteRepository } from '../../../impact-analysis/infrastructure/review-note.repository'; +import { GraphRepository } from '../../../graph/infrastructure/graph.repository'; +import { ReviewClarificationRepository } from '../../../impact-analysis/infrastructure/review-clarification.repository'; +import { ReviewDecisionRepository } from '../../../impact-analysis/infrastructure/review-decision.repository'; +import { GetImpactDiffUseCase } from '../../../impact-analysis/application/queries/get-impact-diff.usecase'; +import { DEFAULT_REPORT_LOCALE, ReportLocale } from './report-localization'; +import type { ApprovedReportMetadata } from '../../domain/approved-report-metadata'; +import { buildReportReviewCoverageSummaryFromSnapshot } from '../report-review-coverage.summary'; + +@Injectable() +export class ReviewedSnapshotReportContextAdapter { + constructor( + private readonly prisma: PrismaService, + private readonly insightRepo: InsightRepository, + private readonly traceabilityRepo: TraceabilityRepository, + private readonly reviewNoteRepo: ReviewNoteRepository, + private readonly graphRepo: GraphRepository, + private readonly clarificationRepo: ReviewClarificationRepository, + private readonly decisionRepo: ReviewDecisionRepository, + private readonly getDiffUseCase: GetImpactDiffUseCase, + ) {} + + async buildContext( + snapshot: any, + analysis: any, + locale: ReportLocale = DEFAULT_REPORT_LOCALE, + ): Promise { + const analysisId = analysis.id; + + // 1. Fetch live elements + const insights = await this.insightRepo.listByAnalysis(analysisId); + let traceabilityLinks = await this.traceabilityRepo.listByAnalysis(analysisId); + let reviewNotes = await this.reviewNoteRepo.findByAnalysisId(analysisId); + const dependencyEdges = await this.graphRepo.listBySnapshot(analysis.snapshot.id); + const clarifications = await this.clarificationRepo.listByAnalysisId(analysisId); + let reviewDecisions = await this.decisionRepo.listByAnalysisId(analysisId); + + // 2. Snapshot overrides and point-in-time filtering + const snapshotDate = new Date(snapshot.createdAt); + + // Filter global review decisions and notes to only those that existed AT snapshot time + reviewDecisions = reviewDecisions.filter(d => new Date(d.createdAt) <= snapshotDate); + reviewNotes = reviewNotes.filter(n => new Date(n.createdAt) <= snapshotDate); + + // Retrieve snapshot payload + const reviewDecisionsSnapshot = snapshot.reviewDecisionsSnapshot as any[]; + const evidenceQualitySummarySnapshot = snapshot.evidenceQualitySummarySnapshot as any; + const reviewCoverageSummarySnapshot = buildReportReviewCoverageSummaryFromSnapshot({ + reviewDecisionsSnapshot, + evidenceQualitySummarySnapshot, + }); + + // Overwrite traceability links with snapshot state + if (reviewDecisionsSnapshot && Array.isArray(reviewDecisionsSnapshot)) { + for (const link of traceabilityLinks) { + const snapItem = reviewDecisionsSnapshot.find(x => x.linkId === link.id); + if (snapItem) { + // Reconstruct the link's reviewDecision to match snapshot exactly + link.reviewDecision = snapItem.reviewDecision; + // Status mapping based on decision + if (snapItem.reviewDecision) { + link.reviewStatus = snapItem.reviewDecision.decision === 'ACCEPTED' ? 'CONFIRMED' : + snapItem.reviewDecision.decision === 'REJECTED' ? 'REJECTED' : 'NEEDS_REVIEW'; + } else { + link.reviewStatus = 'NEEDS_REVIEW'; + } + } + } + } + + // Determine hasUnreviewedItems from the insights (live metadata) + const hasUnreviewed = insights.some( + (insight: { reviewStatus: string }) => insight.reviewStatus === 'NEEDS_REVIEW', + ); + + let diff: any = undefined; + if (analysis.derivedFromAnalysisId) { + const diffResult = await this.getDiffUseCase.computeForAnalysis(analysisId); + if (diffResult.computable) { + diff = diffResult.diff; + } + } + + return { + analysis, + locale, + insights, + traceabilityLinks: traceabilityLinks as any[], + reviewNotes, + hasUnreviewedItems: !!hasUnreviewed, + dependencyEdges: dependencyEdges as any[], + clarifications: clarifications as any[], + reviewDecisions, + reviewDecisionsSnapshot, + evidenceQualitySummarySnapshot, + reviewCoverageSummarySnapshot, + diff, + metadata: { + analysisId: analysis.id, + title: analysis.requirementRevision.title, + projectId: analysis.snapshot.repository.projectId, + repositoryId: analysis.snapshot.repositoryId, + targetRef: analysis.sourceTarget.requestedRef, + commitSha: analysis.snapshot.commitSha, + snapshotId: analysis.snapshot.id, + analyzerVersion: analysis.snapshot.analyzerVersion, + generatedDocumentId: 'pending', + generatedAt: new Date().toISOString(), + finalizedAt: analysis.updatedAt.toISOString(), + staleStatusAtReadTime: false, // Snapshot is never stale at read time + domainPack: readDomainPackProvenance(analysis), + }, + }; + } +} + +function readDomainPackProvenance(analysis: { + requestedDomainPackId?: string | null; + resolvedDomainPackId?: string | null; + resolvedDomainPackVersion?: string | null; + resolvedDomainPackStatus?: string | null; + domainPackSelectedBy?: string | null; + domainPackResolvedAt?: Date | string | null; + domainPackManifestDigest?: string | null; + domainPackRegistryVersion?: string | null; + metadata?: unknown; +}): ApprovedReportMetadata['domainPack'] { + if ( + typeof analysis.resolvedDomainPackId === 'string' && + typeof analysis.resolvedDomainPackVersion === 'string' && + isDomainPackStatus(analysis.resolvedDomainPackStatus) && + isDomainPackSelectedBy(analysis.domainPackSelectedBy) + ) { + return { + requestedDomainPackId: analysis.requestedDomainPackId ?? null, + domainPackId: analysis.resolvedDomainPackId, + domainPackVersion: analysis.resolvedDomainPackVersion, + domainPackStatus: analysis.resolvedDomainPackStatus, + selectedBy: analysis.domainPackSelectedBy, + resolvedAt: normalizeDateTime(analysis.domainPackResolvedAt), + manifestDigest: analysis.domainPackManifestDigest ?? null, + registryVersion: analysis.domainPackRegistryVersion ?? null, + }; + } + + const metadata = analysis.metadata; + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { + return undefined; + } + + const provenance = (metadata as Record).reportProvenance; + if (!provenance || typeof provenance !== 'object' || Array.isArray(provenance)) { + return undefined; + } + + const data = provenance as Record; + if ( + typeof data.domainPackId !== 'string' || + typeof data.domainPackVersion !== 'string' || + !isDomainPackStatus(data.domainPackStatus) || + !isDomainPackSelectedBy(data.selectedBy) + ) { + return undefined; + } + + return { + requestedDomainPackId: readOptionalString(data.requestedDomainPackId), + domainPackId: data.domainPackId, + domainPackVersion: data.domainPackVersion, + domainPackStatus: data.domainPackStatus, + selectedBy: data.selectedBy, + resolvedAt: readOptionalString(data.resolvedAt), + manifestDigest: readOptionalString(data.manifestDigest), + registryVersion: readOptionalString(data.registryVersion), + }; +} + +function normalizeDateTime(value: unknown): string | null { + if (value instanceof Date) { + return value.toISOString(); + } + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function readOptionalString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function isDomainPackStatus( + value: unknown, +): value is NonNullable['domainPackStatus'] { + return ( + value === 'STABLE' || + value === 'PARTIAL' || + value === 'EXPERIMENTAL' || + value === 'FALLBACK' + ); +} + +function isDomainPackSelectedBy( + value: unknown, +): value is NonNullable['selectedBy'] { + return ( + value === 'EXPLICIT' || + value === 'REPOSITORY_PROFILE' || + value === 'FALLBACK' + ); +} diff --git a/packages/backend-runtime/src/document/application/report-review-coverage.summary.ts b/packages/backend-runtime/src/document/application/report-review-coverage.summary.ts new file mode 100644 index 00000000..aab17244 --- /dev/null +++ b/packages/backend-runtime/src/document/application/report-review-coverage.summary.ts @@ -0,0 +1,151 @@ +import type { + EvidenceQualityItem, + EvidenceQualitySummary, +} from './evidence-quality.types'; + +export type ReportReviewCoverageSummary = { + insights: { + total: number; + reviewed: number; + unreviewed: number; + confirmed: number; + rejected: number; + needsReview: number; + }; + traceabilityLinks: { + total: number; + reviewed: number; + unreviewed: number; + accepted: number; + rejected: number; + needsReview: number; + needsMoreEvidence: number; + }; + decisions: { + accepted: number; + rejected: number; + needsReview: number; + needsMoreEvidence: number; + needsClarification: number; + unreviewed: number; + }; + evidence: { + strong: number; + weak: number; + missing: number; + conflicting: number; + reviewRequired: number; + }; +}; + +export function buildReportReviewCoverageSummary(params: { + items: EvidenceQualityItem[]; + evidenceQualitySummary?: Partial | null; +}): ReportReviewCoverageSummary { + const insightItems = params.items.filter((item) => item.itemType === 'INSIGHT'); + const linkItems = params.items.filter((item) => item.itemType !== 'INSIGHT'); + + const insightConfirmed = insightItems.filter((item) => item.reviewStatus === 'CONFIRMED').length; + const insightRejected = insightItems.filter((item) => item.reviewStatus === 'REJECTED').length; + const insightNeedsReview = insightItems.filter((item) => item.reviewStatus === 'NEEDS_REVIEW').length; + const insightReviewed = insightConfirmed + insightRejected; + const insightUnreviewed = Math.max(0, insightItems.length - insightReviewed); + + const linkAccepted = linkItems.filter((item) => readDecision(item) === 'ACCEPTED').length; + const linkRejected = linkItems.filter((item) => readDecision(item) === 'REJECTED').length; + const linkNeedsReview = linkItems.filter((item) => readDecision(item) === 'NEEDS_REVIEW').length; + const linkNeedsMoreEvidence = linkItems + .filter((item) => readDecision(item) === 'NEEDS_MORE_EVIDENCE').length; + const linkReviewed = linkAccepted + linkRejected + linkNeedsReview + linkNeedsMoreEvidence; + const linkUnreviewed = Math.max(0, linkItems.length - linkReviewed); + + const accepted = insightConfirmed + linkAccepted; + const rejected = insightRejected + linkRejected; + const needsReview = insightNeedsReview + linkNeedsReview; + const needsMoreEvidence = linkNeedsMoreEvidence; + const unreviewed = insightUnreviewed + linkUnreviewed; + + return { + insights: { + total: insightItems.length, + reviewed: insightReviewed, + unreviewed: insightUnreviewed, + confirmed: insightConfirmed, + rejected: insightRejected, + needsReview: insightNeedsReview, + }, + traceabilityLinks: { + total: linkItems.length, + reviewed: linkReviewed, + unreviewed: linkUnreviewed, + accepted: linkAccepted, + rejected: linkRejected, + needsReview: linkNeedsReview, + needsMoreEvidence: linkNeedsMoreEvidence, + }, + decisions: { + accepted, + rejected, + needsReview, + needsMoreEvidence, + needsClarification: needsReview + needsMoreEvidence + unreviewed, + unreviewed, + }, + evidence: { + strong: readQualityCount(params.evidenceQualitySummary, 'strongSourceEvidence', 'STRONG_SOURCE_EVIDENCE', 'evidenced'), + weak: readQualityCount(params.evidenceQualitySummary, 'weakSourceEvidence', 'WEAK_SOURCE_EVIDENCE', 'weakEvidence'), + missing: readQualityCount(params.evidenceQualitySummary, 'missingEvidence', 'MISSING_EVIDENCE'), + conflicting: readQualityCount(params.evidenceQualitySummary, 'conflictingEvidence', 'CONFLICTING_EVIDENCE'), + reviewRequired: readQualityCount(params.evidenceQualitySummary, 'reviewRequired', 'REVIEW_REQUIRED'), + }, + }; +} + +export function buildReportReviewCoverageSummaryFromSnapshot(params: { + reviewDecisionsSnapshot: unknown; + evidenceQualitySummarySnapshot: unknown; +}): ReportReviewCoverageSummary { + return buildReportReviewCoverageSummary({ + items: Array.isArray(params.reviewDecisionsSnapshot) + ? params.reviewDecisionsSnapshot.filter(isEvidenceQualityItem) + : [], + evidenceQualitySummary: isRecord(params.evidenceQualitySummarySnapshot) + ? params.evidenceQualitySummarySnapshot as Partial + : null, + }); +} + +function readDecision(item: EvidenceQualityItem): string | null { + const decision = item.reviewDecision; + if (!isRecord(decision)) { + return null; + } + return typeof decision.decision === 'string' ? decision.decision : null; +} + +function readQualityCount( + summary: Partial | null | undefined, + ...keys: Array +): number { + if (!summary) return 0; + for (const key of keys) { + const value = summary[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + return 0; +} + +function isEvidenceQualityItem(value: unknown): value is EvidenceQualityItem { + if (!isRecord(value)) return false; + return ( + typeof value.artifact === 'string' && + typeof value.quality === 'string' && + Array.isArray(value.reasons) + ); +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} diff --git a/packages/backend-runtime/src/document/application/run-document-job.usecase.ts b/packages/backend-runtime/src/document/application/run-document-job.usecase.ts new file mode 100644 index 00000000..a41f63c7 --- /dev/null +++ b/packages/backend-runtime/src/document/application/run-document-job.usecase.ts @@ -0,0 +1,124 @@ +import { Injectable } from '@nestjs/common'; +import { DocumentJobStatus } from '@prisma/client';; +import { PrismaService } from '../../prisma/prisma.service'; +import { MarkdownImpactReportBuilder } from './render/markdown-impact-report.builder'; +import { InsightRepository } from '../../insight/infrastructure/insight.repository'; +import { TraceabilityRepository } from '../../traceability/infrastructure/traceability.repository'; +import { ReviewNoteRepository } from '../../impact-analysis/infrastructure/review-note.repository'; +import { GraphRepository } from '../../graph/infrastructure/graph.repository'; +import { ReviewClarificationRepository } from '../../impact-analysis/infrastructure/review-clarification.repository'; +import { ReviewDecisionRepository } from '../../impact-analysis/infrastructure/review-decision.repository'; +import { GetImpactDiffUseCase } from '../../impact-analysis/application/queries/get-impact-diff.usecase'; +import { DocumentRepository } from '../infrastructure/document.repository'; +import { ReviewedSnapshotReportContextAdapter } from './render/reviewed-snapshot-report-context.adapter'; +import { AppError } from '@ba-helper/shared'; + +@Injectable() +export class RunDocumentJobUseCase { + constructor( + private readonly prisma: PrismaService, + private readonly reportBuilder: MarkdownImpactReportBuilder, + private readonly documentRepo: DocumentRepository, + private readonly contextAdapter: ReviewedSnapshotReportContextAdapter, + ) {} + + async execute(params: { documentJobId: string }) { + const docJob = await this.markRunning(params.documentJobId); + + try { + const snapshot = await this.prisma.reviewedReportSnapshot.findUnique({ + where: { id: docJob.snapshotId }, + }); + if (!snapshot) { + throw new AppError('SNAPSHOT_NOT_FOUND', 'Reviewed report snapshot not found.'); + } + + const analysis = await this.prisma.impactAnalysis.findUnique({ + where: { id: snapshot.analysisId }, + include: { + snapshot: { include: { repository: true, profile: true } }, + sourceTarget: true, + requirementRevision: { include: { requirement: true } }, + insights: true, + }, + }); + if (!analysis) { + throw new AppError('IMPACT_ANALYSIS_NOT_FOUND', 'Impact analysis not found.'); + } + + const context = await this.contextAdapter.buildContext(snapshot, analysis); + const markdown = this.reportBuilder.build(context); + const persistedReport = await this.documentRepo.upsertApproved({ + impactAnalysisId: analysis.id, + content: markdown, + }); + + await this.prisma.$transaction(async (tx) => { + await tx.documentJob.update({ + where: { id: docJob.id }, + data: { + status: DocumentJobStatus.COMPLETED, + progress: 100, + completedAt: new Date(), + generatedDocumentId: persistedReport.id, + }, + }); + + await tx.reviewedReportSnapshot.update({ + where: { id: snapshot.id }, + data: { approvedDocumentId: persistedReport.id }, + }); + }); + + return { success: true, generatedDocumentId: persistedReport.id }; + } catch (error) { + await this.prisma.documentJob.update({ + where: { id: docJob.id }, + data: { + status: DocumentJobStatus.FAILED, + error: this.toErrorJson(error), + failedAt: new Date(), + }, + }); + throw error; + } + } + + private async markRunning(documentJobId: string) { + const job = await this.prisma.documentJob.findUnique({ + where: { id: documentJobId }, + }); + + if (!job) { + throw new AppError('DOCUMENT_JOB_NOT_FOUND', 'Document job not found.'); + } + + if (job.status !== DocumentJobStatus.QUEUED && job.status !== DocumentJobStatus.RUNNING) { + throw new AppError('DOCUMENT_JOB_NOT_READY', 'Document job is not queued or running.', { + status: job.status, + }); + } + + return this.prisma.documentJob.update({ + where: { id: documentJobId }, + data: { + status: DocumentJobStatus.RUNNING, + lastStartedAt: new Date(), + }, + }); + } + + + + private toErrorJson(error: unknown) { + if (error instanceof Error) { + return { + message: error.message, + name: error.name, + }; + } + return { + message: String(error), + }; + } +} diff --git a/packages/backend-runtime/src/document/document-runtime.module.ts b/packages/backend-runtime/src/document/document-runtime.module.ts new file mode 100644 index 00000000..98d18927 --- /dev/null +++ b/packages/backend-runtime/src/document/document-runtime.module.ts @@ -0,0 +1,42 @@ +import { Module } from '@nestjs/common'; +import { RunDocumentJobUseCase } from './application/run-document-job.usecase'; +import { MarkdownImpactReportBuilder } from './application/render/markdown-impact-report.builder'; +import { ReviewedSnapshotReportContextAdapter } from './application/render/reviewed-snapshot-report-context.adapter'; +import { MermaidImpactDiagramBuilder } from './application/mermaid-impact-diagram.builder'; +import { EvaluationContextAdapter } from './application/evaluation-context.adapter'; +import { GetImpactDiffUseCase } from '../impact-analysis/application/queries/get-impact-diff.usecase'; +import { EventLogModule } from '../event-log/event-log.module'; +import { PrismaModule } from '../prisma/prisma.module'; +// Wait, repositories should also be provided, but they might be provided globally or we can just provide them here. +import { DocumentRepository } from './infrastructure/document.repository'; +import { TraceabilityRepository } from '../traceability/infrastructure/traceability.repository'; +import { GraphRepository } from '../graph/infrastructure/graph.repository'; +import { InsightRepository } from '../insight/infrastructure/insight.repository'; +import { ReviewNoteRepository } from '../impact-analysis/infrastructure/review-note.repository'; +import { ReviewClarificationRepository } from '../impact-analysis/infrastructure/review-clarification.repository'; +import { ReviewDecisionRepository } from '../impact-analysis/infrastructure/review-decision.repository'; +import { ImpactAnalysisRepository } from '../impact-analysis/infrastructure/impact-analysis.repository'; + +@Module({ + imports: [PrismaModule, EventLogModule], + providers: [ + RunDocumentJobUseCase, + MarkdownImpactReportBuilder, + ReviewedSnapshotReportContextAdapter, + MermaidImpactDiagramBuilder, + EvaluationContextAdapter, + GetImpactDiffUseCase, + DocumentRepository, + TraceabilityRepository, + GraphRepository, + InsightRepository, + ReviewNoteRepository, + ReviewClarificationRepository, + ReviewDecisionRepository, + ImpactAnalysisRepository, + ], + exports: [ + RunDocumentJobUseCase, + ], +}) +export class DocumentRuntimeModule {} diff --git a/packages/backend-runtime/src/document/domain/approved-report-metadata.ts b/packages/backend-runtime/src/document/domain/approved-report-metadata.ts new file mode 100644 index 00000000..44015b2c --- /dev/null +++ b/packages/backend-runtime/src/document/domain/approved-report-metadata.ts @@ -0,0 +1,31 @@ +export type ApprovedReportMetadata = { + reportScope?: 'SINGLE_ANALYSIS' | 'MULTI_REPO_RUN'; + analysisId: string; + title: string; + projectId: string; + repositoryId?: string; + targetRef?: string; + commitSha?: string; + snapshotId?: string; + analyzerVersion?: string; + generatedDocumentId: string; + generatedAt: string; + finalizedAt?: string; + approvedDocumentCreatedAt?: string; + approvedDocumentUpdatedAt?: string; + staleStatusAtReadTime: boolean; + staleReason?: string; + domainPack?: { + requestedDomainPackId?: string | null; + domainPackId: string; + domainPackVersion: string; + domainPackStatus: 'STABLE' | 'PARTIAL' | 'EXPERIMENTAL' | 'FALLBACK'; + selectedBy: 'EXPLICIT' | 'REPOSITORY_PROFILE' | 'FALLBACK'; + resolvedAt?: string | null; + manifestDigest?: string | null; + registryVersion?: string | null; + }; + requirementRevisionId?: string; + runId?: string; + childAnalysisCount?: number; +}; diff --git a/packages/backend-runtime/src/document/infrastructure/document.repository.ts b/packages/backend-runtime/src/document/infrastructure/document.repository.ts new file mode 100644 index 00000000..e41db246 --- /dev/null +++ b/packages/backend-runtime/src/document/infrastructure/document.repository.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class DocumentRepository { + constructor(private readonly prisma: PrismaService) {} + + async listByAnalysis(impactAnalysisId: string) { + return this.prisma.generatedDocument.findMany({ + where: { impactAnalysisId }, + include: { + impactAnalysis: { + include: { + snapshot: true, + sourceTarget: true, + }, + }, + }, + }); + } + + async upsertApproved(params: { + impactAnalysisId: string; + content: string; + id?: string; + }) { + return this.prisma.generatedDocument.upsert({ + where: { + impactAnalysisId_type_status: { + impactAnalysisId: params.impactAnalysisId, + type: 'IMPACT_REPORT', + status: 'APPROVED', + }, + }, + update: { + content: params.content, + }, + create: { + id: params.id, + impactAnalysisId: params.impactAnalysisId, + type: 'IMPACT_REPORT', + status: 'APPROVED', + content: params.content, + }, + }); + } + + async findApprovedReportByAnalysisId(impactAnalysisId: string) { + return this.prisma.generatedDocument.findUnique({ + where: { + impactAnalysisId_type_status: { + impactAnalysisId, + type: 'IMPACT_REPORT', + status: 'APPROVED', + }, + }, + include: { + impactAnalysis: { + include: { + snapshot: { + include: { + repository: true, + }, + }, + sourceTarget: true, + requirementRevision: { + include: { + requirement: true, + }, + }, + }, + }, + }, + }); + } +} diff --git a/apps/api/src/modules/domain-pack/api/domain-pack.controller.ts b/packages/backend-runtime/src/domain-pack/api/domain-pack.controller.ts similarity index 100% rename from apps/api/src/modules/domain-pack/api/domain-pack.controller.ts rename to packages/backend-runtime/src/domain-pack/api/domain-pack.controller.ts diff --git a/apps/api/src/modules/domain-pack/application/domain-pack-terminology.ts b/packages/backend-runtime/src/domain-pack/application/domain-pack-terminology.ts similarity index 100% rename from apps/api/src/modules/domain-pack/application/domain-pack-terminology.ts rename to packages/backend-runtime/src/domain-pack/application/domain-pack-terminology.ts diff --git a/apps/api/src/modules/domain-pack/application/domain-pack.catalog.ts b/packages/backend-runtime/src/domain-pack/application/domain-pack.catalog.ts similarity index 100% rename from apps/api/src/modules/domain-pack/application/domain-pack.catalog.ts rename to packages/backend-runtime/src/domain-pack/application/domain-pack.catalog.ts diff --git a/apps/api/src/modules/domain-pack/application/domain-pack.governance.spec.ts b/packages/backend-runtime/src/domain-pack/application/domain-pack.governance.spec.ts similarity index 100% rename from apps/api/src/modules/domain-pack/application/domain-pack.governance.spec.ts rename to packages/backend-runtime/src/domain-pack/application/domain-pack.governance.spec.ts diff --git a/apps/api/src/modules/domain-pack/application/domain-pack.governance.ts b/packages/backend-runtime/src/domain-pack/application/domain-pack.governance.ts similarity index 100% rename from apps/api/src/modules/domain-pack/application/domain-pack.governance.ts rename to packages/backend-runtime/src/domain-pack/application/domain-pack.governance.ts diff --git a/apps/api/src/modules/domain-pack/application/domain-pack.registry.spec.ts b/packages/backend-runtime/src/domain-pack/application/domain-pack.registry.spec.ts similarity index 100% rename from apps/api/src/modules/domain-pack/application/domain-pack.registry.spec.ts rename to packages/backend-runtime/src/domain-pack/application/domain-pack.registry.spec.ts diff --git a/apps/api/src/modules/domain-pack/application/domain-pack.registry.ts b/packages/backend-runtime/src/domain-pack/application/domain-pack.registry.ts similarity index 100% rename from apps/api/src/modules/domain-pack/application/domain-pack.registry.ts rename to packages/backend-runtime/src/domain-pack/application/domain-pack.registry.ts diff --git a/apps/api/src/modules/domain-pack/domain-pack.module.ts b/packages/backend-runtime/src/domain-pack/domain-pack.module.ts similarity index 100% rename from apps/api/src/modules/domain-pack/domain-pack.module.ts rename to packages/backend-runtime/src/domain-pack/domain-pack.module.ts diff --git a/apps/api/src/modules/domain-pack/domain/domain-pack.types.ts b/packages/backend-runtime/src/domain-pack/domain/domain-pack.types.ts similarity index 100% rename from apps/api/src/modules/domain-pack/domain/domain-pack.types.ts rename to packages/backend-runtime/src/domain-pack/domain/domain-pack.types.ts diff --git a/apps/api/src/modules/domain-pack/packs/booking.v0.1.0.ts b/packages/backend-runtime/src/domain-pack/packs/booking.v0.1.0.ts similarity index 100% rename from apps/api/src/modules/domain-pack/packs/booking.v0.1.0.ts rename to packages/backend-runtime/src/domain-pack/packs/booking.v0.1.0.ts diff --git a/apps/api/src/modules/domain-pack/packs/ecommerce.v0.1.0.ts b/packages/backend-runtime/src/domain-pack/packs/ecommerce.v0.1.0.ts similarity index 100% rename from apps/api/src/modules/domain-pack/packs/ecommerce.v0.1.0.ts rename to packages/backend-runtime/src/domain-pack/packs/ecommerce.v0.1.0.ts diff --git a/apps/api/src/modules/domain-pack/packs/general.v0.0.0.ts b/packages/backend-runtime/src/domain-pack/packs/general.v0.0.0.ts similarity index 100% rename from apps/api/src/modules/domain-pack/packs/general.v0.0.0.ts rename to packages/backend-runtime/src/domain-pack/packs/general.v0.0.0.ts diff --git a/apps/api/src/modules/domain-pack/packs/healthcare.v0.1.0.ts b/packages/backend-runtime/src/domain-pack/packs/healthcare.v0.1.0.ts similarity index 100% rename from apps/api/src/modules/domain-pack/packs/healthcare.v0.1.0.ts rename to packages/backend-runtime/src/domain-pack/packs/healthcare.v0.1.0.ts diff --git a/apps/api/src/modules/domain-pack/packs/rental.v0.1.0.ts b/packages/backend-runtime/src/domain-pack/packs/rental.v0.1.0.ts similarity index 100% rename from apps/api/src/modules/domain-pack/packs/rental.v0.1.0.ts rename to packages/backend-runtime/src/domain-pack/packs/rental.v0.1.0.ts diff --git a/apps/api/src/modules/embedding/embedding.module.spec.ts b/packages/backend-runtime/src/embedding/embedding.module.spec.ts similarity index 100% rename from apps/api/src/modules/embedding/embedding.module.spec.ts rename to packages/backend-runtime/src/embedding/embedding.module.spec.ts diff --git a/apps/api/src/modules/embedding/embedding.module.ts b/packages/backend-runtime/src/embedding/embedding.module.ts similarity index 100% rename from apps/api/src/modules/embedding/embedding.module.ts rename to packages/backend-runtime/src/embedding/embedding.module.ts diff --git a/apps/api/src/modules/embedding/infrastructure/embedding-chunk.repository.spec.ts b/packages/backend-runtime/src/embedding/infrastructure/embedding-chunk.repository.spec.ts similarity index 100% rename from apps/api/src/modules/embedding/infrastructure/embedding-chunk.repository.spec.ts rename to packages/backend-runtime/src/embedding/infrastructure/embedding-chunk.repository.spec.ts diff --git a/apps/api/src/modules/embedding/infrastructure/embedding-chunk.repository.ts b/packages/backend-runtime/src/embedding/infrastructure/embedding-chunk.repository.ts similarity index 100% rename from apps/api/src/modules/embedding/infrastructure/embedding-chunk.repository.ts rename to packages/backend-runtime/src/embedding/infrastructure/embedding-chunk.repository.ts diff --git a/apps/api/src/modules/embedding/infrastructure/fake-embedding.provider.ts b/packages/backend-runtime/src/embedding/infrastructure/fake-embedding.provider.ts similarity index 100% rename from apps/api/src/modules/embedding/infrastructure/fake-embedding.provider.ts rename to packages/backend-runtime/src/embedding/infrastructure/fake-embedding.provider.ts diff --git a/apps/api/src/modules/embedding/infrastructure/google-embedding.provider.ts b/packages/backend-runtime/src/embedding/infrastructure/google-embedding.provider.ts similarity index 100% rename from apps/api/src/modules/embedding/infrastructure/google-embedding.provider.ts rename to packages/backend-runtime/src/embedding/infrastructure/google-embedding.provider.ts diff --git a/apps/api/src/modules/embedding/infrastructure/openai-embedding.provider.ts b/packages/backend-runtime/src/embedding/infrastructure/openai-embedding.provider.ts similarity index 100% rename from apps/api/src/modules/embedding/infrastructure/openai-embedding.provider.ts rename to packages/backend-runtime/src/embedding/infrastructure/openai-embedding.provider.ts diff --git a/apps/api/src/modules/embedding/infrastructure/prisma-embedding-snapshot.repository.ts b/packages/backend-runtime/src/embedding/infrastructure/prisma-embedding-snapshot.repository.ts similarity index 100% rename from apps/api/src/modules/embedding/infrastructure/prisma-embedding-snapshot.repository.ts rename to packages/backend-runtime/src/embedding/infrastructure/prisma-embedding-snapshot.repository.ts diff --git a/packages/backend-runtime/src/event-log/application/event-log.service.spec.ts b/packages/backend-runtime/src/event-log/application/event-log.service.spec.ts new file mode 100644 index 00000000..16d042f3 --- /dev/null +++ b/packages/backend-runtime/src/event-log/application/event-log.service.spec.ts @@ -0,0 +1,134 @@ +import { EventLogService } from './event-log.service'; +import type { EventLogRepository } from '../infrastructure/event-log.repository'; +import { EventLogDto } from '@ba-helper/contracts'; + +describe('EventLogService', () => { + let service: EventLogService; + let repository: jest.Mocked; + + beforeEach(() => { + repository = { + createEvent: jest.fn(), + findEventsByPrefixes: jest.fn(), + } as unknown as jest.Mocked; + service = new EventLogService(repository); + }); + + describe('getScanJobEvents', () => { + it('should query repository with exact trailing-colon prefixes for scan jobs', async () => { + repository.findEventsByPrefixes.mockResolvedValue([]); + + await service.getScanJobEvents('job-123'); + + expect(repository.findEventsByPrefixes).toHaveBeenCalledWith([ + 'scan-job:job-123:', + 'scan:job-123:' + ]); + }); + }); + + describe('getAnalysisEvents', () => { + it('should query repository with exact trailing-colon prefixes for analysis', async () => { + repository.findEventsByPrefixes.mockResolvedValue([]); + + await service.getAnalysisEvents('ana-456'); + + expect(repository.findEventsByPrefixes).toHaveBeenCalledWith([ + 'analysis:ana-456:', + 'impact:ana-456:' + ]); + }); + }); + + describe('mapToDto', () => { + it('should extract actorType and actorName correctly', async () => { + repository.findEventsByPrefixes.mockResolvedValue([ + { + id: 'ev-1', + eventType: 'SCAN_COMPLETED', + idempotencyKey: 'scan-job:1:', + createdAt: new Date('2023-01-01T00:00:00.000Z'), + payload: { + actorType: 'USER', + actorUserId: 'user-xyz', + actorName: 'John Doe', + artifactCount: 10, + } + } + ]); + + const result = await service.getScanJobEvents('1'); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + actorType: 'USER', + actorName: 'John Doe', + triggeredByUserId: null, + }); + expect(result[0].metadata).toEqual({ + artifactCount: 10 + }); + }); + + it('should extract system actor and triggeredByUserId correctly', async () => { + repository.findEventsByPrefixes.mockResolvedValue([ + { + id: 'ev-2', + eventType: 'ANALYSIS_STARTED', + idempotencyKey: 'analysis:2:', + createdAt: new Date('2023-01-01T00:00:00.000Z'), + payload: { + actorType: 'SYSTEM', + actorUserId: 'system', + actorName: 'Worker', + triggeredByUserId: 'user-123', + insightCount: 5, + unknownCount: 0, + secretField: 'should-not-be-exposed' + } + } + ]); + + const result = await service.getAnalysisEvents('2'); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + actorType: 'SYSTEM', + actorName: 'Worker', + triggeredByUserId: 'user-123', + }); + // Ensure blocklist/allowlist works + expect(result[0].metadata).toEqual({ + insightCount: 5, + unknownCount: 0, + }); + expect(result[0].metadata).not.toHaveProperty('secretField'); + }); + + it('should flatten nested llm token usage', async () => { + repository.findEventsByPrefixes.mockResolvedValue([ + { + id: 'ev-3', + eventType: 'ANALYSIS_AI_REASONING_COMPLETED', + idempotencyKey: 'analysis:3:', + createdAt: new Date('2023-01-01T00:00:00.000Z'), + payload: { + actorType: 'SYSTEM', + llm: { + provider: 'openai', + inputTokens: 100, + outputTokens: 50, + totalTokens: 150 + } + } + } + ]); + + const result = await service.getAnalysisEvents('3'); + expect(result[0].metadata).toEqual({ + provider: 'openai', + inputTokens: 100, + outputTokens: 50, + totalTokens: 150 + }); + }); + }); +}); diff --git a/packages/backend-runtime/src/event-log/application/event-log.service.ts b/packages/backend-runtime/src/event-log/application/event-log.service.ts new file mode 100644 index 00000000..13ecd1ac --- /dev/null +++ b/packages/backend-runtime/src/event-log/application/event-log.service.ts @@ -0,0 +1,101 @@ +import type { EventLogRepository } from '../infrastructure/event-log.repository'; +import { EventLogPolicy } from '../domain/event-log.policy'; +import type { EventLogDto } from '@ba-helper/contracts'; + +export class EventLogService { + constructor(private readonly repository: EventLogRepository) {} + + async recordEvent(params: { + eventType: string; + idempotencyKey: string; + payload: Record; + actorUserId?: string; + }): Promise { + const actorUserId = params.actorUserId || 'SYSTEM'; + + EventLogPolicy.validateEventPayload({ + eventType: params.eventType, + actorUserId, + idempotencyKey: params.idempotencyKey, + isRetryable: false, // Or derive from context + metadata: params.payload, + }); + + await this.repository.createEvent({ + ...params, + payload: { ...params.payload, actorUserId } + }); + } + + async getScanJobEvents(jobId: string): Promise { + const events = await this.repository.findEventsByPrefixes([ + `scan-job:${jobId}:`, + `scan:${jobId}:` + ]); + return events.map(this.mapToDto); + } + + async getAnalysisEvents(analysisId: string): Promise { + const events = await this.repository.findEventsByPrefixes([ + `analysis:${analysisId}:`, + `impact:${analysisId}:` + ]); + return events.map(this.mapToDto); + } + + private mapToDto(event: any): EventLogDto { + const payload = event.payload || {}; + + // Extract actor fields + let actorType: 'SYSTEM' | 'USER' = 'SYSTEM'; + if (payload.actorType === 'USER' || payload.actorUserId !== 'system') { + if (payload.actorUserId && payload.actorUserId !== 'system') { + actorType = 'USER'; + } + } + + const actorName = typeof payload.actorName === 'string' ? payload.actorName : null; + const triggeredByUserId = typeof payload.triggeredByUserId === 'string' ? payload.triggeredByUserId : null; + + // Build allowlisted metadata + const metadata: Record = {}; + const allowlist = [ + 'artifactCount', 'evidenceCount', 'dependencyEdgeCount', 'skippedDependencyEdgeCount', + 'insightCount', 'unknownCount', 'traceabilityLinkCount', 'provider', + 'inputTokens', 'outputTokens', 'totalTokens', 'indexStatus', 'previousStatus', + 'nextStatus', 'phase', 'errorCode', 'errorMessage' + ]; + + for (const key of allowlist) { + if (payload[key] !== undefined) { + if (typeof payload[key] === 'string' || typeof payload[key] === 'number' || typeof payload[key] === 'boolean' || payload[key] === null) { + metadata[key] = payload[key]; + } else if (payload[key] && typeof payload[key] === 'object') { + // If it's an object, we can't expose it directly based on contract. Skip or stringify? + // The blocklist prevents full diagnostics, full response, etc. + // Wait, 'provider', 'inputTokens' might be inside payload.llm. + // Let's flatten specific known nested fields if needed, but the current payload puts them flat or nested? + } + } + } + + // Special handling for token usage if nested + if (payload.llm && typeof payload.llm === 'object') { + if (payload.llm.provider) metadata.provider = String(payload.llm.provider); + if (typeof payload.llm.inputTokens === 'number') metadata.inputTokens = payload.llm.inputTokens; + if (typeof payload.llm.outputTokens === 'number') metadata.outputTokens = payload.llm.outputTokens; + if (typeof payload.llm.totalTokens === 'number') metadata.totalTokens = payload.llm.totalTokens; + } + + return { + id: event.id, + eventType: event.eventType, + idempotencyKey: event.idempotencyKey, + actorType, + actorName, + triggeredByUserId, + metadata, + createdAt: event.createdAt.toISOString(), + }; + } +} diff --git a/packages/backend-runtime/src/event-log/domain/event-log.policy.spec.ts b/packages/backend-runtime/src/event-log/domain/event-log.policy.spec.ts new file mode 100644 index 00000000..6ba7b25c --- /dev/null +++ b/packages/backend-runtime/src/event-log/domain/event-log.policy.spec.ts @@ -0,0 +1,80 @@ +import { AppError } from '@ba-helper/shared'; +import { EventLogPolicy } from './event-log.policy'; + +describe('EventLogPolicy', () => { + describe('validateEventPayload', () => { + it('throws if event type is unknown', () => { + expect(() => { + EventLogPolicy.validateEventPayload({ + eventType: 'UNKNOWN_EVENT', + actorUserId: 'admin', + isRetryable: false, + }); + }).toThrow(new AppError('UNKNOWN_EVENT_TYPE', 'Event type UNKNOWN_EVENT is not recognized.')); + }); + + it('throws if actor is empty', () => { + expect(() => { + EventLogPolicy.validateEventPayload({ + eventType: 'SCAN_STARTED', + actorUserId: ' ', + isRetryable: false, + }); + }).toThrow(new AppError('INVALID_ACTOR', 'Actor cannot be empty.')); + }); + + it('throws if actor string is too long', () => { + expect(() => { + EventLogPolicy.validateEventPayload({ + eventType: 'SCAN_STARTED', + actorUserId: 'a'.repeat(101), + isRetryable: false, + }); + }).toThrow(new AppError('INVALID_ACTOR', 'Actor string exceeds 100 characters limit.')); + }); + + it('throws if metadata is too large', () => { + expect(() => { + EventLogPolicy.validateEventPayload({ + eventType: 'SCAN_STARTED', + actorUserId: 'admin', + isRetryable: false, + metadata: { largeData: 'a'.repeat(10001) }, + }); + }).toThrow(new AppError('EVENT_METADATA_TOO_LARGE', 'Event metadata exceeds 10KB limit.')); + }); + + it('throws if idempotency key is missing for retryable event', () => { + expect(() => { + EventLogPolicy.validateEventPayload({ + eventType: 'ANALYSIS_STARTED', + actorUserId: 'admin', + isRetryable: true, + }); + }).toThrow(new AppError('MISSING_IDEMPOTENCY_KEY', 'Retryable event ANALYSIS_STARTED must have an idempotency key.')); + }); + + it('throws if idempotency key has invalid format', () => { + expect(() => { + EventLogPolicy.validateEventPayload({ + eventType: 'ANALYSIS_STARTED', + actorUserId: 'admin', + idempotencyKey: 'invalid-key', + isRetryable: false, + }); + }).toThrow(new AppError('INVALID_IDEMPOTENCY_KEY', 'Idempotency key must follow a stable format like ::[:].')); + }); + + it('passes for a valid event', () => { + expect(() => { + EventLogPolicy.validateEventPayload({ + eventType: 'SCAN_COMPLETED', + actorUserId: 'system', + idempotencyKey: 'scan:123:completed', + isRetryable: false, + metadata: { result: 'success' }, + }); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/backend-runtime/src/event-log/domain/event-log.policy.ts b/packages/backend-runtime/src/event-log/domain/event-log.policy.ts new file mode 100644 index 00000000..57f55d05 --- /dev/null +++ b/packages/backend-runtime/src/event-log/domain/event-log.policy.ts @@ -0,0 +1,91 @@ +import { AppError } from '@ba-helper/shared'; + +export const EventLogPolicy = { + validateEventPayload: (params: { + eventType: string; + actorUserId: string; + idempotencyKey?: string | null; + isRetryable: boolean; + metadata?: Record; + }) => { + // 1. Event type must be known + const knownEventTypes = [ + 'PROJECT_CREATED', + 'REPOSITORY_CREATED', + 'SCAN_STARTED', + 'SCAN_ARTIFACTS_EXTRACTED', + 'SCAN_DEPENDENCY_EDGES_PERSISTED', + 'SCAN_COMPLETED', + 'SCAN_FAILED', + 'REPOSITORY_TARGET_OBSERVED', + 'REQUIREMENT_CREATED', + 'ANALYSIS_STARTED', + 'ANALYSIS_EVIDENCE_RETRIEVED', + 'ANALYSIS_AI_REASONING_COMPLETED', + 'ANALYSIS_WAITING_FOR_REVIEW', + 'ANALYSIS_FAILED', + 'INSIGHT_CONFIRMED', + 'INSIGHT_REJECTED', + 'TRACEABILITY_LINK_CONFIRMED', + 'TRACEABILITY_LINK_REJECTED', + 'ANALYSIS_FINALIZED', + 'DOCUMENT_EXPORTED', + 'CLARIFICATION_REQUESTED', + 'CLARIFICATION_ANSWERED', + 'REVIEW_DECISION', + // Added missing types from codebase + 'REQUIREMENT_REVISION_CREATED', + 'REQUIREMENT_REVISION_QUALIFIED', + 'INSIGHT_REVIEWED', + 'IMPACT_ANALYSIS_QUEUED', + 'IMPACT_ANALYSIS_FINALIZED', + 'WORKSPACE_DEFAULT_PROJECT_CREATED', + 'SCAN_JOB_COMPLETED', + 'SCAN_JOB_FAILED', + 'SCAN_JOB_QUEUED', + 'TRACEABILITY_REVIEWED', + 'PROJECT_SELECTED', + 'PROJECT_MEMBER_UPSERTED', + 'PROJECT_MEMBER_UPDATED', + 'PROJECT_MEMBER_REMOVED', + 'TRACEABILITY_REVIEW_DECISION_UPDATED', + 'TRACEABILITY_REVIEW_DECISION_DELETED', + 'REVIEWED_REPORT_SNAPSHOT_CREATED', + ]; + + if (!knownEventTypes.includes(params.eventType)) { + throw new AppError('UNKNOWN_EVENT_TYPE', `Event type ${params.eventType} is not recognized.`); + } + + // 2. Actor must be bounded string + if (!params.actorUserId || params.actorUserId.trim() === '') { + throw new AppError('INVALID_ACTOR', 'Actor cannot be empty.'); + } + if (params.actorUserId.length > 100) { + throw new AppError('INVALID_ACTOR', 'Actor string exceeds 100 characters limit.'); + } + + // 3. Metadata size must be bounded + if (params.metadata) { + const size = Buffer.from(JSON.stringify(params.metadata)).length; + if (size > 10000) { // 10KB limit + throw new AppError('EVENT_METADATA_TOO_LARGE', 'Event metadata exceeds 10KB limit.'); + } + } + + // 4. Idempotency Key validations + if (params.isRetryable) { + if (!params.idempotencyKey) { + throw new AppError('MISSING_IDEMPOTENCY_KEY', `Retryable event ${params.eventType} must have an idempotency key.`); + } + } + + if (params.idempotencyKey) { + // Format should generally be ::: + const parts = params.idempotencyKey.split(':'); + if (parts.length < 3) { + throw new AppError('INVALID_IDEMPOTENCY_KEY', 'Idempotency key must follow a stable format like ::[:].'); + } + } + }, +}; diff --git a/packages/backend-runtime/src/event-log/event-log.module.ts b/packages/backend-runtime/src/event-log/event-log.module.ts new file mode 100644 index 00000000..8290273d --- /dev/null +++ b/packages/backend-runtime/src/event-log/event-log.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { EventLogService } from './application/event-log.service'; +import { EventLogRepository } from './infrastructure/event-log.repository'; +import { PrismaService } from '../prisma/prisma.service'; + +@Module({ + imports: [PrismaModule], + providers: [ + { + provide: EventLogService, + useFactory: (repo: EventLogRepository) => new EventLogService(repo), + inject: [EventLogRepository], + }, + { + provide: EventLogRepository, + useFactory: (prisma: PrismaService) => new EventLogRepository(prisma), + inject: [PrismaService], + }, + ], + exports: [EventLogService], +}) +export class EventLogModule {} diff --git a/packages/backend-runtime/src/event-log/infrastructure/event-log-port.adapter.ts b/packages/backend-runtime/src/event-log/infrastructure/event-log-port.adapter.ts new file mode 100644 index 00000000..10ac063f --- /dev/null +++ b/packages/backend-runtime/src/event-log/infrastructure/event-log-port.adapter.ts @@ -0,0 +1,15 @@ +import type { EventLogPort } from '@ba-helper/application'; +import type { EventLogService } from '../application/event-log.service'; + +export class EventLogPortAdapter implements EventLogPort { + constructor(private readonly service: EventLogService) {} + + async recordEvent(params: { + eventType: string; + idempotencyKey: string; + payload: Record; + actorUserId?: string; + }): Promise { + return this.service.recordEvent(params); + } +} diff --git a/packages/backend-runtime/src/event-log/infrastructure/event-log.repository.ts b/packages/backend-runtime/src/event-log/infrastructure/event-log.repository.ts new file mode 100644 index 00000000..ebeaac7d --- /dev/null +++ b/packages/backend-runtime/src/event-log/infrastructure/event-log.repository.ts @@ -0,0 +1,40 @@ +import type { Prisma } from '@prisma/client'; +import type { PrismaService } from '../../prisma/prisma.service'; + +export class EventLogRepository { + constructor(private readonly prisma: PrismaService) {} + + async createEvent(params: { + eventType: string; + idempotencyKey: string; + payload: Record; + }): Promise { + await this.prisma.domainEvent.upsert({ + where: { + idempotencyKey: params.idempotencyKey, + }, + create: { + eventType: params.eventType, + idempotencyKey: params.idempotencyKey, + payload: params.payload as Prisma.InputJsonValue, + }, + update: {}, + }); + } + + async findEventsByPrefixes(prefixes: string[]): Promise { + if (prefixes.length === 0) return []; + + return this.prisma.domainEvent.findMany({ + where: { + OR: prefixes.map(prefix => ({ + idempotencyKey: { startsWith: prefix } + })) + }, + orderBy: [ + { createdAt: 'asc' }, + { id: 'asc' } + ] + }); + } +} diff --git a/packages/backend-runtime/src/evidence/infrastructure/evidence.repository.ts b/packages/backend-runtime/src/evidence/infrastructure/evidence.repository.ts new file mode 100644 index 00000000..8f27a82a --- /dev/null +++ b/packages/backend-runtime/src/evidence/infrastructure/evidence.repository.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import type { EvidenceSourceType, Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; + +type EvidencePrismaClient = PrismaService | Prisma.TransactionClient; + +@Injectable() +export class EvidenceRepository { + constructor(private readonly prisma: PrismaService) {} + + async listByAnalysis(params: { snapshotId: string; revisionId: string }) { + return this.prisma.evidence.findMany({ + where: { + OR: [ + { snapshotId: params.snapshotId }, + { requirementRevisionId: params.revisionId }, + ], + }, + }); + } + + async upsertMany( + items: Array<{ + provenanceKey: string; + sourceType: string; + snapshotId: string | null; + artifactId: string | null; + requirementRevisionId?: string | null; + sourcePath: string | null; + startLine: number | null; + endLine: number | null; + excerpt: string; + contentHash: string; + isRedacted: boolean; + redactionMetadata: Record | null; + }>, + client: EvidencePrismaClient = this.prisma, + ) { + if (items.length === 0) { + return []; + } + + await client.evidence.createMany({ + data: items.map((item) => ({ + provenanceKey: item.provenanceKey, + sourceType: item.sourceType as EvidenceSourceType, + snapshotId: item.snapshotId ?? null, + artifactId: item.artifactId ?? null, + requirementRevisionId: item.requirementRevisionId ?? null, + sourcePath: item.sourcePath ?? null, + startLine: item.startLine ?? null, + endLine: item.endLine ?? null, + excerpt: item.excerpt, + contentHash: item.contentHash, + isRedacted: item.isRedacted, + redactionMetadata: (item.redactionMetadata ?? null) as Prisma.InputJsonValue, + })), + skipDuplicates: true, + }); + + return client.evidence.findMany({ + where: { + provenanceKey: { in: items.map((item) => item.provenanceKey) }, + }, + }); + } +} diff --git a/packages/backend-runtime/src/graph/infrastructure/graph.repository.ts b/packages/backend-runtime/src/graph/infrastructure/graph.repository.ts new file mode 100644 index 00000000..3f6d1a2e --- /dev/null +++ b/packages/backend-runtime/src/graph/infrastructure/graph.repository.ts @@ -0,0 +1,55 @@ +import type { DependencyEdgeType } from '@prisma/client'; +import type { Prisma } from '@prisma/client'; +import type { PrismaService } from '../../prisma/prisma.service'; + +type GraphPrismaClient = PrismaService | Prisma.TransactionClient; + +export class GraphRepository { + constructor(private readonly prisma: PrismaService) {} + + async listBySnapshot(snapshotId: string) { + return this.prisma.dependencyEdge.findMany({ + where: { snapshotId }, + }); + } + + async expandFromSeeds(snapshotId: string, seedArtifactIds: string[]): Promise { + if (seedArtifactIds.length === 0) return []; + + const edges = await this.prisma.dependencyEdge.findMany({ + where: { + snapshotId, + OR: [ + { fromArtifactId: { in: seedArtifactIds } }, + { toArtifactId: { in: seedArtifactIds } }, + ], + }, + }); + + const expandedIds = new Set(); + for (const edge of edges) { + if (edge.fromArtifactId) expandedIds.add(edge.fromArtifactId); + if (edge.toArtifactId) expandedIds.add(edge.toArtifactId); + } + + // Remove seeds from result if desired, or keep them + // Returning just the expansion or the whole set + return Array.from(expandedIds); + } + + async createDependencyEdges(edges: { + snapshotId: string; + fromArtifactId: string; + toArtifactId: string; + type: DependencyEdgeType; + }[], client: GraphPrismaClient = this.prisma): Promise { + if (!edges || edges.length === 0) { + return; + } + + await client.dependencyEdge.createMany({ + data: edges, + skipDuplicates: true, + }); + } +} diff --git a/packages/backend-runtime/src/impact-analysis/application/queries/get-impact-diff.usecase.ts b/packages/backend-runtime/src/impact-analysis/application/queries/get-impact-diff.usecase.ts new file mode 100644 index 00000000..7925be75 --- /dev/null +++ b/packages/backend-runtime/src/impact-analysis/application/queries/get-impact-diff.usecase.ts @@ -0,0 +1,279 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { AppError, AppErrorCode } from '@ba-helper/shared'; +import { ImpactAnalysisDiffResponse, DiffArtifact, DiffInsight, DiagnosticItem } from '@ba-helper/contracts'; +import { InsightType } from '@prisma/client';; + +@Injectable() +export class GetImpactDiffUseCase { + constructor(private prisma: PrismaService) {} + + async execute(analysisId: string): Promise { + const result = await this.computeForAnalysis(analysisId); + if (!result.computable) { + if (result.reason === 'CURRENT_ANALYSIS_MISSING') { + throw new AppError('ANALYSIS_NOT_FOUND', 'Analysis not found.'); + } + if (result.reason === 'BASELINE_ANALYSIS_MISSING') { + throw new AppError('NO_BASELINE_ANALYSIS', 'This analysis does not have a baseline to diff against.'); + } + if (result.reason === 'CURRENT_NOT_COMPLETED') { + throw new AppError('DIFF_NOT_READY', 'Analysis diff is not ready yet.'); + } + throw new AppError(result.reason!, 'Analysis diff is not ready or computable.'); + } + return result.diff!; + } + + async computeForAnalysis(analysisId: string): Promise<{ computable: boolean; reason?: AppErrorCode; diff?: ImpactAnalysisDiffResponse }> { + const currentAnalysis = await this.prisma.impactAnalysis.findUnique({ + where: { id: analysisId }, + include: { + snapshot: true, + }, + }); + + if (!currentAnalysis) { + return { computable: false, reason: 'CURRENT_ANALYSIS_MISSING' }; + } + + if (!currentAnalysis.derivedFromAnalysisId) { + return { computable: false, reason: 'BASELINE_ANALYSIS_MISSING' }; + } + + if (currentAnalysis.status !== 'WAITING_FOR_REVIEW' && currentAnalysis.status !== 'COMPLETED') { + return { computable: false, reason: 'CURRENT_NOT_COMPLETED' }; + } + + const baseAnalysis = await this.prisma.impactAnalysis.findUnique({ + where: { id: currentAnalysis.derivedFromAnalysisId }, + include: { + snapshot: true, + }, + }); + + if (!baseAnalysis) { + return { computable: false, reason: 'BASELINE_ANALYSIS_MISSING' }; + } + + if (baseAnalysis.status !== 'COMPLETED') { + return { computable: false, reason: 'BASELINE_NOT_COMPLETED' }; + } + + if (!currentAnalysis.snapshot || !baseAnalysis.snapshot) { + return { computable: false, reason: 'SNAPSHOT_MISSING' }; + } + + // 1. Comparison Context + const requirementChanged = currentAnalysis.requirementRevisionId !== baseAnalysis.requirementRevisionId; + const snapshotChanged = currentAnalysis.snapshotId !== baseAnalysis.snapshotId; + + const comparisonContext = { + requirementChanged, + snapshotChanged, + baseRequirementRevisionId: baseAnalysis.requirementRevisionId, + currentRequirementRevisionId: currentAnalysis.requirementRevisionId, + baseSnapshotId: baseAnalysis.snapshotId, + currentSnapshotId: currentAnalysis.snapshotId, + baseCommitSha: baseAnalysis.snapshot.commitSha, + currentCommitSha: currentAnalysis.snapshot.commitSha, + sourceClarificationId: currentAnalysis.sourceClarificationId ?? undefined, + reviewClarificationRequestId: currentAnalysis.reviewClarificationRequestId ?? undefined, + }; + + // 2. Diff TraceabilityLinks (Impacted Artifacts) + const baseLinks = await this.prisma.traceabilityLink.findMany({ + where: { + impactAnalysisId: baseAnalysis.id, + linkType: 'AFFECTED', + reviewStatus: { not: 'REJECTED' }, + }, + include: { artifact: true }, + }); + + const currentLinks = await this.prisma.traceabilityLink.findMany({ + where: { + impactAnalysisId: currentAnalysis.id, + linkType: 'AFFECTED', + reviewStatus: { not: 'REJECTED' }, + }, + include: { artifact: true }, + }); + + const baseArtifactsMap = new Map(); + for (const link of baseLinks) { + baseArtifactsMap.set(link.artifact.artifactKey, link); + } + + const currentArtifactsMap = new Map(); + for (const link of currentLinks) { + currentArtifactsMap.set(link.artifact.artifactKey, link); + } + + const addedArtifacts: DiffArtifact[] = []; + const removedArtifacts: DiffArtifact[] = []; + const unchangedArtifacts: DiffArtifact[] = []; + + for (const [key, currentLink] of currentArtifactsMap) { + const mapped: DiffArtifact = { + artifactKey: key, + name: currentLink.artifact.name, + artifactType: currentLink.artifact.artifactType, + universalKind: currentLink.artifact.universalKind as DiffArtifact['universalKind'], + filePath: currentLink.artifact.filePath, + reviewStatus: currentLink.reviewStatus, + }; + + if (baseArtifactsMap.has(key)) { + unchangedArtifacts.push(mapped); + } else { + addedArtifacts.push(mapped); + } + } + + for (const [key, baseLink] of baseArtifactsMap) { + if (!currentArtifactsMap.has(key)) { + removedArtifacts.push({ + artifactKey: key, + name: baseLink.artifact.name, + artifactType: baseLink.artifact.artifactType, + universalKind: baseLink.artifact.universalKind as DiffArtifact['universalKind'], + filePath: baseLink.artifact.filePath, + reviewStatus: baseLink.reviewStatus, // using the old status + }); + } + } + + // 3. Diff Insights (UNKNOWN, QA_SCENARIO) + const baseInsights = await this.prisma.baInsight.findMany({ + where: { + impactAnalysisId: baseAnalysis.id, + insightType: { in: ['UNKNOWN', 'QA_SCENARIO'] }, + }, + }); + + const currentInsights = await this.prisma.baInsight.findMany({ + where: { + impactAnalysisId: currentAnalysis.id, + insightType: { in: ['UNKNOWN', 'QA_SCENARIO'] }, + }, + }); + + const buildInsightDiffKey = (type: InsightType, insightKey: string, title: string, statement: string) => { + // Rule 1: insightKey if present (in our model, insightKey is generated, it should be stable if deterministically computed) + // Actually, if we use UUID for insightKey sometimes, we might need to fallback. + // We will create a normalized statement hash. + const normalizedTitle = title.trim().toLowerCase(); + const normalizedStatement = statement.trim().toLowerCase(); + + if (type === 'UNKNOWN') { + return `UNKNOWN::${normalizedStatement}`; + } else if (type === 'QA_SCENARIO') { + return `QA_SCENARIO::${insightKey || normalizedTitle}`; + } else { + return `${type}::${insightKey || normalizedTitle + '::' + normalizedStatement}`; + } + }; + + const baseInsightsMap = new Map(); + for (const insight of baseInsights) { + const key = buildInsightDiffKey(insight.insightType, insight.insightKey, insight.title, insight.description); + baseInsightsMap.set(key, insight); + } + + const currentInsightsMap = new Map(); + for (const insight of currentInsights) { + const key = buildInsightDiffKey(insight.insightType, insight.insightKey, insight.title, insight.description); + currentInsightsMap.set(key, insight); + } + + const resolvedUnknowns: DiffInsight[] = []; + const removedUnknowns: DiffInsight[] = []; + const newUnknowns: DiffInsight[] = []; + const addedQaScenarios: DiffInsight[] = []; + + // Check for resolved / removed unknowns + for (const [key, baseInsight] of baseInsightsMap) { + if (!currentInsightsMap.has(key)) { + const mapped: DiffInsight = { + insightKey: baseInsight.insightKey, + category: baseInsight.insightType, + statement: baseInsight.description, + reviewStatus: baseInsight.reviewStatus, + }; + + if (baseInsight.insightType === 'UNKNOWN') { + // Check if this unknown has a matching clarification lineage + let isResolved = false; + if (currentAnalysis.sourceClarificationId) { + const clarification = await this.prisma.clarificationItem.findUnique({ + where: { id: currentAnalysis.sourceClarificationId }, + }); + if (clarification && clarification.sourceInsightId === baseInsight.id) { + isResolved = true; + } + } + + if (isResolved) { + resolvedUnknowns.push(mapped); + } else { + removedUnknowns.push(mapped); + } + } + } + } + + // Check for new unknowns and added QA scenarios + for (const [key, currentInsight] of currentInsightsMap) { + if (!baseInsightsMap.has(key)) { + const mapped: DiffInsight = { + insightKey: currentInsight.insightKey, + category: currentInsight.insightType, + statement: currentInsight.description, + reviewStatus: currentInsight.reviewStatus, + }; + + if (currentInsight.insightType === 'UNKNOWN') { + newUnknowns.push(mapped); + } else if (currentInsight.insightType === 'QA_SCENARIO') { + addedQaScenarios.push(mapped); + } + } + } + + const diagnostics: DiagnosticItem[] = []; + if (snapshotChanged) { + diagnostics.push({ + code: 'SNAPSHOT_CHANGED', + severity: 'WARN', + message: 'The base analysis and current analysis use different repository snapshots. Some differences may come from code changes.', + }); + } + + return { + computable: true, + diff: { + baseAnalysisId: baseAnalysis.id, + currentAnalysisId: currentAnalysis.id, + comparisonContext, + summary: { + addedImpacts: addedArtifacts.length, + removedImpacts: removedArtifacts.length, + unchangedImpacts: unchangedArtifacts.length, + resolvedUnknowns: resolvedUnknowns.length, + removedUnknowns: removedUnknowns.length, + newUnknowns: newUnknowns.length, + addedQaScenarios: addedQaScenarios.length, + }, + addedArtifacts, + removedArtifacts, + unchangedArtifacts, + resolvedUnknowns, + removedUnknowns, + newUnknowns, + addedQaScenarios, + diagnostics, + }, + }; + } +} diff --git a/packages/backend-runtime/src/impact-analysis/domain/impact-analysis.types.ts b/packages/backend-runtime/src/impact-analysis/domain/impact-analysis.types.ts new file mode 100644 index 00000000..33432b37 --- /dev/null +++ b/packages/backend-runtime/src/impact-analysis/domain/impact-analysis.types.ts @@ -0,0 +1,43 @@ +import type { ResolvedDomainPackSelection } from '@ba-helper/contracts'; + +export type ImpactAnalysisMetadata = { + llm?: { + provider: string; + model: string; + promptVersion: string; + parseMode?: 'raw' | 'extracted'; + inputTokens?: number | null; + outputTokens?: number | null; + estimatedCostUsd?: number | null; + evidenceItems?: number; + evidenceChars?: number; + evidenceTruncated?: boolean; + /** Domain profile key used for context injection. 'BOOKING' if none specified. */ + domainContextUsed?: string; + }; + retrieval?: { + strategy: string; + maxArtifacts: number; + artifactCount: number; + vectorSignalCount?: number; + }; + domainPack?: { + id: string; + version: string; + status: 'STABLE' | 'PARTIAL' | 'EXPERIMENTAL' | 'FALLBACK'; + selectedBy: string; + }; + selectedDomainPack?: ResolvedDomainPackSelection; + reportProvenance?: { + domainPackId: string; + domainPackVersion: string; + domainPackStatus: 'STABLE' | 'PARTIAL' | 'EXPERIMENTAL' | 'FALLBACK'; + selectedBy: string; + }; + diagnostics?: Array<{ + code: string; + severity: string; + message: string; + payload?: any; + }>; +}; diff --git a/packages/backend-runtime/src/impact-analysis/infrastructure/analyzer-version.spec.ts b/packages/backend-runtime/src/impact-analysis/infrastructure/analyzer-version.spec.ts new file mode 100644 index 00000000..c9e5c49a --- /dev/null +++ b/packages/backend-runtime/src/impact-analysis/infrastructure/analyzer-version.spec.ts @@ -0,0 +1,19 @@ +import { ANALYZER_VERSION } from '@ba-helper/analyzer'; +import { + getCurrentAnalyzerVersion, + isAnalyzerVersionOutdated, +} from './analyzer-version'; + +describe('analyzer-version helper', () => { + it('returns the current analyzer version constant', () => { + expect(getCurrentAnalyzerVersion()).toBe(ANALYZER_VERSION); + }); + + it('marks different analyzer versions as outdated', () => { + expect(isAnalyzerVersionOutdated('analyzer@0.1.0')).toBe(true); + }); + + it('does not mark the current analyzer version as outdated', () => { + expect(isAnalyzerVersionOutdated(ANALYZER_VERSION)).toBe(false); + }); +}); diff --git a/packages/backend-runtime/src/impact-analysis/infrastructure/analyzer-version.ts b/packages/backend-runtime/src/impact-analysis/infrastructure/analyzer-version.ts new file mode 100644 index 00000000..4895ca12 --- /dev/null +++ b/packages/backend-runtime/src/impact-analysis/infrastructure/analyzer-version.ts @@ -0,0 +1,9 @@ +import { ANALYZER_VERSION } from '@ba-helper/analyzer'; + +export function isAnalyzerVersionOutdated(snapshotAnalyzerVersion: string): boolean { + return snapshotAnalyzerVersion !== ANALYZER_VERSION; +} + +export function getCurrentAnalyzerVersion(): string { + return ANALYZER_VERSION; +} diff --git a/packages/backend-runtime/src/impact-analysis/infrastructure/impact-analysis.repository.ts b/packages/backend-runtime/src/impact-analysis/infrastructure/impact-analysis.repository.ts new file mode 100644 index 00000000..55485c35 --- /dev/null +++ b/packages/backend-runtime/src/impact-analysis/infrastructure/impact-analysis.repository.ts @@ -0,0 +1,226 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { ImpactAnalysisMetadata } from '../domain/impact-analysis.types'; +import type { ResolvedDomainPackSelection } from '@ba-helper/contracts'; + +const IMPACT_ANALYSIS_INCLUDE = { + snapshot: { + include: { + repository: true, + profile: true, + }, + }, + sourceTarget: true, + requirementRevision: { + include: { + requirement: true, + }, + }, + insights: true, + multiRepoRun: true, +} as const; + +@Injectable() +export class ImpactAnalysisRepository { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string) { + return this.prisma.impactAnalysis.findUnique({ + where: { id }, + include: IMPACT_ANALYSIS_INCLUDE, + }); + } + + async findByReviewClarificationRequestId(reviewClarificationRequestId: string) { + return this.prisma.impactAnalysis.findFirst({ + where: { reviewClarificationRequestId }, + include: IMPACT_ANALYSIS_INCLUDE, + }); + } + + async updateTraceabilityLineage( + id: string, + data: { derivedFromAnalysisId: string; reviewClarificationRequestId: string } + ) { + return this.prisma.impactAnalysis.update({ + where: { id }, + data, + include: IMPACT_ANALYSIS_INCLUDE, + }); + } + + async findByProject(projectId: string, limit?: number, offset?: number) { + return this.prisma.impactAnalysis.findMany({ + where: { + AND: [ + { + requirementRevision: { + requirement: { + projectId, + }, + }, + }, + { + snapshot: { + repository: { + projectId, + }, + }, + }, + ], + }, + take: limit, + skip: offset, + include: { + snapshot: { + include: { + repository: true, + }, + }, + sourceTarget: true, + requirementRevision: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + async findByComposite(params: { + requirementRevisionId: string; + snapshotId: string; + sourceTargetId: string; + requestKey: string; + }) { + return this.prisma.impactAnalysis.findUnique({ + where: { + requirementRevisionId_snapshotId_sourceTargetId_requestKey: { + requirementRevisionId: params.requirementRevisionId, + snapshotId: params.snapshotId, + sourceTargetId: params.sourceTargetId, + requestKey: params.requestKey, + }, + }, + include: { + snapshot: true, + sourceTarget: true, + requirementRevision: true, + }, + }); + } + + async findByRequestKey(params: { + requestKey: string; + }) { + return this.prisma.impactAnalysis.findFirst({ + where: { + requestKey: params.requestKey, + }, + }); + } + + async createQueued(params: { + requirementRevisionId: string; + snapshotId: string; + sourceTargetId: string; + multiRepoRunId?: string | null; + requestKey: string; + acceptedPartialCoverage: boolean; + coverageWarning?: string | null; + derivedFromAnalysisId?: string | null; + sourceClarificationId?: string | null; + reviewClarificationRequestId?: string | null; + selectedDomainPack: ResolvedDomainPackSelection; + metadata?: ImpactAnalysisMetadata | null; + }) { + return this.prisma.impactAnalysis.create({ + data: { + requirementRevisionId: params.requirementRevisionId, + snapshotId: params.snapshotId, + sourceTargetId: params.sourceTargetId, + multiRepoRunId: params.multiRepoRunId ?? null, + requestKey: params.requestKey, + status: 'QUEUED', + stage: 'WAITING', + progress: 0, + acceptedPartialCoverage: params.acceptedPartialCoverage, + coverageWarning: params.coverageWarning ?? null, + derivedFromAnalysisId: params.derivedFromAnalysisId, + sourceClarificationId: params.sourceClarificationId, + reviewClarificationRequestId: params.reviewClarificationRequestId, + requestedDomainPackId: params.selectedDomainPack.requestedDomainPackId, + resolvedDomainPackId: params.selectedDomainPack.resolvedDomainPackId, + resolvedDomainPackVersion: params.selectedDomainPack.resolvedDomainPackVersion, + resolvedDomainPackStatus: params.selectedDomainPack.resolvedDomainPackStatus, + domainPackSelectedBy: params.selectedDomainPack.selectedBy, + domainPackResolvedAt: new Date(params.selectedDomainPack.resolvedAt), + ...(params.metadata ? { metadata: params.metadata as any } : {}), + }, + include: IMPACT_ANALYSIS_INCLUDE, + }); + } + + async attachToMultiRepoRun(analysisId: string, multiRepoRunId: string) { + return this.prisma.impactAnalysis.update({ + where: { id: analysisId }, + data: { + multiRepoRunId, + }, + include: IMPACT_ANALYSIS_INCLUDE, + }); + } + + async updateStatus(params: { + id: string; + status: 'COMPLETED' | 'WAITING_FOR_REVIEW' | 'FAILED' | 'CANCELLED' | 'RUNNING' | 'QUEUED'; + stage: 'WAITING' | 'RETRIEVING_EVIDENCE' | 'EXPANDING_GRAPH' | 'RUNNING_AI_REASONING' | 'GENERATING_INSIGHTS' | 'GENERATING_DOCUMENTS' | 'DONE'; + progress: number; + metadata?: ImpactAnalysisMetadata; + error?: any; + }) { + return this.prisma.impactAnalysis.update({ + where: { id: params.id }, + data: { + status: params.status, + stage: params.stage, + progress: params.progress, + ...(params.metadata ? { metadata: params.metadata as any } : {}), + ...(params.error ? { error: params.error as any } : {}), + }, + include: { + snapshot: true, + sourceTarget: true, + requirementRevision: true, + insights: true, + }, + }); + } + + async finalizeIfCurrent(params: { + analysisId: string; + status: 'COMPLETED'; + stage: 'DONE'; + progress: number; + expectedCommitSha: string; + expectedTargetCommitSha: string; + expectedResolvedRefType: 'BRANCH' | 'TAG' | 'COMMIT'; + }) { + return this.prisma.impactAnalysis.updateMany({ + where: { + id: params.analysisId, + snapshot: { + commitSha: params.expectedCommitSha, + }, + sourceTarget: { + resolvedRefType: params.expectedResolvedRefType, + latestObservedCommitSha: params.expectedTargetCommitSha, + }, + }, + data: { + status: params.status, + stage: params.stage, + progress: params.progress, + }, + }); + } +} diff --git a/packages/backend-runtime/src/impact-analysis/infrastructure/merged-multi-repo-report-review-decision.repository.ts b/packages/backend-runtime/src/impact-analysis/infrastructure/merged-multi-repo-report-review-decision.repository.ts new file mode 100644 index 00000000..f145c542 --- /dev/null +++ b/packages/backend-runtime/src/impact-analysis/infrastructure/merged-multi-repo-report-review-decision.repository.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { AnalysisReviewDecisionValue } from '@prisma/client';; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class MergedMultiRepoReportReviewDecisionRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: { + mergedReportId: string; + decision: AnalysisReviewDecisionValue; + note?: string; + reviewedByUserId: string; + }) { + return this.prisma.mergedMultiRepoReportReviewDecision.create({ + data: { + mergedReportId: data.mergedReportId, + decision: data.decision, + note: data.note || null, + reviewedByUserId: data.reviewedByUserId, + }, + include: { + reviewedByUser: true, + mergedReport: true, + }, + }); + } + + async listByMergedReportId(mergedReportId: string) { + return this.prisma.mergedMultiRepoReportReviewDecision.findMany({ + where: { mergedReportId }, + orderBy: { createdAt: 'desc' }, + include: { + reviewedByUser: true, + mergedReport: true, + }, + }); + } + + async findLatestByMergedReportId(mergedReportId: string) { + return this.prisma.mergedMultiRepoReportReviewDecision.findFirst({ + where: { mergedReportId }, + orderBy: { createdAt: 'desc' }, + include: { + reviewedByUser: true, + mergedReport: true, + }, + }); + } +} diff --git a/packages/backend-runtime/src/impact-analysis/infrastructure/multi-repo-analysis-run.repository.ts b/packages/backend-runtime/src/impact-analysis/infrastructure/multi-repo-analysis-run.repository.ts new file mode 100644 index 00000000..9c7664b5 --- /dev/null +++ b/packages/backend-runtime/src/impact-analysis/infrastructure/multi-repo-analysis-run.repository.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { ResolvedDomainPackSelection } from '@ba-helper/contracts'; + +const MULTI_REPO_RUN_INCLUDE = { + project: true, + requirementRevision: true, + createdByUser: true, + approvedMergedReport: true, + analyses: { + include: { + snapshot: { + include: { + repository: true, + }, + }, + sourceTarget: true, + requirementRevision: true, + reviewDecisions: { + include: { + reviewedByUser: true, + }, + orderBy: { + createdAt: 'desc', + }, + take: 1, + }, + }, + orderBy: { + createdAt: 'asc', + }, + }, +} as const; + +const MULTI_REPO_RUN_LIST_INCLUDE = { + requirementRevision: true, + createdByUser: true, + analyses: { + select: { + status: true, + }, + }, +} as const; + +@Injectable() +export class MultiRepoAnalysisRunRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByProjectRequestKey(projectId: string, requestKey: string) { + return this.prisma.multiRepoAnalysisRun.findUnique({ + where: { + projectId_requestKey: { + projectId, + requestKey, + }, + }, + include: MULTI_REPO_RUN_INCLUDE, + }); + } + + async findById(id: string) { + return this.prisma.multiRepoAnalysisRun.findUnique({ + where: { id }, + include: MULTI_REPO_RUN_INCLUDE, + }); + } + + async listByProject(projectId: string) { + return this.prisma.multiRepoAnalysisRun.findMany({ + where: { projectId }, + include: MULTI_REPO_RUN_LIST_INCLUDE, + orderBy: { + createdAt: 'desc', + }, + }); + } + + async create(params: { + projectId: string; + requirementRevisionId: string; + createdByUserId: string; + requestKey: string; + selectedDomainPack?: ResolvedDomainPackSelection | null; + }) { + return this.prisma.multiRepoAnalysisRun.create({ + data: { + projectId: params.projectId, + requirementRevisionId: params.requirementRevisionId, + createdByUserId: params.createdByUserId, + requestKey: params.requestKey, + ...(params.selectedDomainPack + ? { + requestedDomainPackId: params.selectedDomainPack.requestedDomainPackId, + resolvedDomainPackId: params.selectedDomainPack.resolvedDomainPackId, + resolvedDomainPackVersion: + params.selectedDomainPack.resolvedDomainPackVersion, + resolvedDomainPackStatus: + params.selectedDomainPack.resolvedDomainPackStatus, + domainPackSelectedBy: params.selectedDomainPack.selectedBy, + domainPackResolvedAt: new Date(params.selectedDomainPack.resolvedAt), + } + : {}), + }, + include: MULTI_REPO_RUN_INCLUDE, + }); + } +} diff --git a/packages/backend-runtime/src/impact-analysis/infrastructure/multi-repo-merged-report.repository.ts b/packages/backend-runtime/src/impact-analysis/infrastructure/multi-repo-merged-report.repository.ts new file mode 100644 index 00000000..a931dc8a --- /dev/null +++ b/packages/backend-runtime/src/impact-analysis/infrastructure/multi-repo-merged-report.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class MultiRepoMergedReportRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByRunId(runId: string) { + return this.prisma.mergedMultiRepoReport.findUnique({ + where: { runId }, + include: { + run: { + include: { + requirementRevision: true, + }, + }, + }, + }); + } + + async upsertApproved(params: { + runId: string; + content: string; + provenance: unknown; + }) { + return this.prisma.mergedMultiRepoReport.upsert({ + where: { runId: params.runId }, + update: { + content: params.content, + provenance: params.provenance as any, + }, + create: { + runId: params.runId, + content: params.content, + provenance: params.provenance as any, + }, + include: { + run: { + include: { + requirementRevision: true, + }, + }, + }, + }); + } +} diff --git a/packages/backend-runtime/src/impact-analysis/infrastructure/review-clarification.repository.ts b/packages/backend-runtime/src/impact-analysis/infrastructure/review-clarification.repository.ts new file mode 100644 index 00000000..ac4f6889 --- /dev/null +++ b/packages/backend-runtime/src/impact-analysis/infrastructure/review-clarification.repository.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { Prisma, ReviewClarificationStatus } from '@prisma/client';; + +const reviewClarificationInclude = { + createdByUser: true, + answeredByUser: true, + derivedAnalyses: { + select: { id: true }, + }, +} satisfies Prisma.ReviewClarificationRequestInclude; + +@Injectable() +export class ReviewClarificationRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: { + analysisId: string; + reviewDecisionId: string; + question: string; + createdByUserId: string; + }) { + return this.prisma.reviewClarificationRequest.create({ + data: { + analysisId: data.analysisId, + reviewDecisionId: data.reviewDecisionId, + question: data.question, + createdByUserId: data.createdByUserId, + status: 'OPEN', + }, + include: reviewClarificationInclude, + }); + } + + async findById(id: string) { + return this.prisma.reviewClarificationRequest.findUnique({ + where: { id }, + include: { + ...reviewClarificationInclude, + reviewDecision: true, + }, + }); + } + + async findOpenByReviewDecisionId(reviewDecisionId: string) { + return this.prisma.reviewClarificationRequest.findFirst({ + where: { + reviewDecisionId, + status: 'OPEN', + }, + }); + } + + async listByAnalysisId(analysisId: string) { + return this.prisma.reviewClarificationRequest.findMany({ + where: { analysisId }, + orderBy: { createdAt: 'desc' }, + include: reviewClarificationInclude, + }); + } + + async answer(id: string, answer: string, answeredByUserId: string) { + return this.prisma.reviewClarificationRequest.update({ + where: { id }, + data: { + answer, + answeredByUserId, + answeredAt: new Date(), + status: 'ANSWERED', + }, + include: reviewClarificationInclude, + }); + } +} diff --git a/packages/backend-runtime/src/impact-analysis/infrastructure/review-decision.repository.ts b/packages/backend-runtime/src/impact-analysis/infrastructure/review-decision.repository.ts new file mode 100644 index 00000000..db4e2880 --- /dev/null +++ b/packages/backend-runtime/src/impact-analysis/infrastructure/review-decision.repository.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { AnalysisReviewDecisionValue } from '@prisma/client';; + +@Injectable() +export class ReviewDecisionRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: { + analysisId: string; + decision: AnalysisReviewDecisionValue; + note?: string; + reviewedByUserId: string; + }) { + return this.prisma.analysisReviewDecision.create({ + data: { + analysisId: data.analysisId, + decision: data.decision, + note: data.note || null, + reviewedByUserId: data.reviewedByUserId, + }, + include: { reviewedByUser: true }, + }); + } + + async findById(id: string) { + return this.prisma.analysisReviewDecision.findUnique({ + where: { id }, + include: { reviewedByUser: true }, + }); + } + + async listByAnalysisId(analysisId: string) { + return this.prisma.analysisReviewDecision.findMany({ + where: { analysisId }, + orderBy: { createdAt: 'desc' }, + include: { reviewedByUser: true }, + }); + } + + async findLatestByAnalysisId(analysisId: string) { + return this.prisma.analysisReviewDecision.findFirst({ + where: { analysisId }, + orderBy: { createdAt: 'desc' }, + include: { reviewedByUser: true }, + }); + } +} diff --git a/packages/backend-runtime/src/impact-analysis/infrastructure/review-note.repository.ts b/packages/backend-runtime/src/impact-analysis/infrastructure/review-note.repository.ts new file mode 100644 index 00000000..d59d9a1c --- /dev/null +++ b/packages/backend-runtime/src/impact-analysis/infrastructure/review-note.repository.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { Prisma } from '@prisma/client';; + +@Injectable() +export class ReviewNoteRepository { + constructor(private readonly prisma: PrismaService) {} + + async upsert(data: { + impactAnalysisId: string; + insightId?: string; + traceabilityLinkId?: string; + body: string; + }) { + // Determine the unique constraint to use for upsert + let where: Prisma.ReviewNoteWhereUniqueInput; + if (data.insightId) { + where = { + impactAnalysisId_insightId: { + impactAnalysisId: data.impactAnalysisId, + insightId: data.insightId, + }, + }; + } else if (data.traceabilityLinkId) { + where = { + impactAnalysisId_traceabilityLinkId: { + impactAnalysisId: data.impactAnalysisId, + traceabilityLinkId: data.traceabilityLinkId, + }, + }; + } else { + throw new Error('Must provide either insightId or traceabilityLinkId'); + } + + return this.prisma.reviewNote.upsert({ + where, + create: { + impactAnalysisId: data.impactAnalysisId, + insightId: data.insightId, + traceabilityLinkId: data.traceabilityLinkId, + body: data.body, + }, + update: { + body: data.body, + }, + }); + } + + async findByAnalysisId(analysisId: string) { + return this.prisma.reviewNote.findMany({ + where: { impactAnalysisId: analysisId }, + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/packages/backend-runtime/src/index.ts b/packages/backend-runtime/src/index.ts new file mode 100644 index 00000000..88c8cafe --- /dev/null +++ b/packages/backend-runtime/src/index.ts @@ -0,0 +1,56 @@ +// Backend runtime exports +export * from './prisma/prisma.module'; +export * from './prisma/prisma.service'; + +// To avoid deep module nesting, we re-export key infrastructure parts. +// We only export the modules and services needed by the runtime boundary. +export * from './event-log/event-log.module'; +export * from './event-log/application/event-log.service'; +export * from './event-log/infrastructure/event-log.repository'; +export * from './event-log/infrastructure/event-log-port.adapter'; +export * from './event-log/domain/event-log.policy'; + +export * from './ai/ai.module'; +export * from './ai/infrastructure/fake-ai.provider'; +export * from './ai/infrastructure/openai.provider'; +export * from './ai/infrastructure/anthropic.provider'; +export * from './ai/infrastructure/google.provider'; +export * from './ai/infrastructure/deepseek.provider'; + +export * from './scanner/infrastructure/scan-job.repository'; +export * from './scanner/application/run-scan-job.usecase'; +export * from './scanner/application/run-scan-job-persistence.step'; +export * from './scanner/domain/scan-job.policy'; + +export * from './repository/infrastructure/repository.repository'; +export * from './impact-analysis/infrastructure/impact-analysis.repository'; +export * from './impact-analysis/infrastructure/review-note.repository'; +export * from './impact-analysis/infrastructure/review-clarification.repository'; +export * from './impact-analysis/infrastructure/review-decision.repository'; +export * from './artifact/infrastructure/artifact.repository'; +export * from './evidence/infrastructure/evidence.repository'; +export * from './graph/infrastructure/graph.repository'; +export * from './traceability/infrastructure/traceability.repository'; +export * from './insight/infrastructure/insight.repository'; +export * from './document/infrastructure/document.repository'; +export * from './document/document-runtime.module'; + +export * from './domain-pack/domain-pack.module'; +export * from './domain-pack/application/domain-pack.registry'; +export * from './domain-pack/packs/booking.v0.1.0'; +export * from './domain-pack/packs/ecommerce.v0.1.0'; +export * from './domain-pack/packs/general.v0.0.0'; +export * from './domain-pack/packs/healthcare.v0.1.0'; +export * from './domain-pack/packs/rental.v0.1.0'; +export * from './retrieval/retrieval.module'; +export * from './retrieval/application/hybrid-retrieval.service'; +export * from './embedding/infrastructure/embedding-chunk.repository'; +export * from './embedding/infrastructure/fake-embedding.provider'; + +export * from './queue/queue.module'; +export * from './queue/queue.service'; +export * from './document/application/run-document-job.usecase'; +export * from './localization/localization.module'; +export * from './localization/application/report-localization.service'; +export { computeCanonicalReportHash } from './localization/domain/report-hash'; +export * from './document/application/markdown-impact-report.types'; diff --git a/packages/backend-runtime/src/insight/infrastructure/insight.repository.ts b/packages/backend-runtime/src/insight/infrastructure/insight.repository.ts new file mode 100644 index 00000000..a1441871 --- /dev/null +++ b/packages/backend-runtime/src/insight/infrastructure/insight.repository.ts @@ -0,0 +1,157 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class InsightRepository { + constructor(private readonly prisma: PrismaService) {} + + async listByAnalysis(impactAnalysisId: string) { + const insights = await this.prisma.baInsight.findMany({ + where: { impactAnalysisId }, + include: { + evidenceLinks: { + include: { + evidence: { + include: { + artifact: true, + }, + }, + }, + }, + }, + }); + + const traceabilityLinks = await this.prisma.traceabilityLink.findMany({ + where: { impactAnalysisId }, + select: { artifactId: true, retrievalMetadata: true }, + }); + + const retrievalMap = new Map( + traceabilityLinks.map((link) => [link.artifactId, link.retrievalMetadata]) + ); + + return insights.map((insight) => ({ + ...insight, + evidenceLinks: insight.evidenceLinks.map((link) => { + const metadata = link.evidence.artifactId + ? retrievalMap.get(link.evidence.artifactId) + : undefined; + return { + ...link, + evidence: { + ...link.evidence, + retrievalMetadata: metadata ?? undefined, + }, + }; + }), + })); + } + + async findById(insightId: string) { + return this.prisma.baInsight.findUnique({ + where: { id: insightId }, + include: { + impactAnalysis: { + include: { + snapshot: true, + sourceTarget: true, + }, + }, + }, + }); + } + + async updateReviewStatus(params: { + insightId: string; + reviewStatus: 'CONFIRMED' | 'REJECTED'; + }) { + return this.prisma.baInsight.update({ + where: { id: params.insightId }, + data: { reviewStatus: params.reviewStatus }, + }); + } + + async updateReviewStatusIfCurrent(params: { + insightId: string; + reviewStatus: 'CONFIRMED' | 'REJECTED'; + expectedCommitSha: string; + expectedTargetCommitSha: string; + expectedResolvedRefType: 'BRANCH' | 'TAG' | 'COMMIT'; + }) { + return this.prisma.baInsight.updateMany({ + where: { + id: params.insightId, + impactAnalysis: { + snapshot: { + commitSha: params.expectedCommitSha, + }, + sourceTarget: { + resolvedRefType: params.expectedResolvedRefType, + latestObservedCommitSha: params.expectedTargetCommitSha, + }, + }, + }, + data: { reviewStatus: params.reviewStatus }, + }); + } + + async upsertMany( + items: Array<{ + impactAnalysisId: string; + insightKey: string; + insightType: 'CLAIM' | 'UNKNOWN' | 'QUESTION' | 'ACCEPTANCE_CRITERIA' | 'QA_SCENARIO'; + certainty: 'EVIDENCED' | 'INFERRED' | 'UNKNOWN' | 'CONFLICTING'; + reviewStatus: 'NEEDS_REVIEW' | 'CONFIRMED' | 'REJECTED'; + confidence: number | null; + title: string; + description: string; + reasoning?: string | null; + metadata?: Record | null; + }>, + ) { + if (items.length === 0) { + return []; + } + + await this.prisma.baInsight.createMany({ + data: items.map((item) => ({ + impactAnalysisId: item.impactAnalysisId, + insightKey: item.insightKey, + insightType: item.insightType, + certainty: item.certainty, + reviewStatus: item.reviewStatus, + confidence: item.confidence, + title: item.title, + description: item.description, + reasoning: item.reasoning ?? null, + metadata: item.metadata ? (item.metadata as any) : undefined, + })), + skipDuplicates: true, + }); + + return this.prisma.baInsight.findMany({ + where: { + impactAnalysisId: items[0].impactAnalysisId, + insightKey: { in: items.map((item) => item.insightKey) }, + }, + }); + } + + async linkEvidence(params: { insightId: string; evidenceIds: string[] }) { + if (params.evidenceIds.length === 0) { + return []; + } + + await this.prisma.insightEvidence.createMany({ + data: params.evidenceIds.map((evidenceId) => ({ + insightId: params.insightId, + evidenceId, + })), + skipDuplicates: true, + }); + + return this.prisma.insightEvidence.findMany({ + where: { insightId: params.insightId }, + }); + } +} diff --git a/packages/backend-runtime/src/localization/application/report-localization.service.ts b/packages/backend-runtime/src/localization/application/report-localization.service.ts new file mode 100644 index 00000000..8901d489 --- /dev/null +++ b/packages/backend-runtime/src/localization/application/report-localization.service.ts @@ -0,0 +1,147 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { TranslationProviderPort } from './translation-provider.port'; +import { StructuralValidator } from '../domain/structural-validator'; +import { extractTranslatableFields, mergeTranslatedFields } from '../domain/field-policy'; +import { computeCanonicalReportHash } from '../domain/report-hash'; +import { MarkdownReportRenderContext } from '../../document/application/markdown-impact-report.types'; +import { MarkdownImpactReportBuilder } from '../../document/application/render/markdown-impact-report.builder'; +import { SupportedReportLocale, LocalizedReportArtifact } from '@ba-helper/contracts'; +import { LocalizedArtifactRepository } from '../infrastructure/localized-artifact.repository'; +import { DomainPackRegistry } from '../../domain-pack/application/domain-pack.registry'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class ReportLocalizationService { + private readonly logger = new Logger(ReportLocalizationService.name); + + constructor( + private readonly translationProvider: TranslationProviderPort, + private readonly validator: StructuralValidator, + private readonly reportBuilder: MarkdownImpactReportBuilder, + private readonly artifactRepo: LocalizedArtifactRepository, + private readonly domainPackRegistry: DomainPackRegistry, + private readonly prisma: PrismaService, + ) {} + + async localizeReport( + sourceDocumentId: string, + canonicalContext: MarkdownReportRenderContext, + targetLocale: SupportedReportLocale + ): Promise { + const sourceContentHash = computeCanonicalReportHash(canonicalContext); + + // Check if we already have a valid completed translation for this hash + const existing = await this.artifactRepo.findByDocumentAndLocale(sourceDocumentId, targetLocale); + if (existing && existing.localizationStatus === 'COMPLETED' && existing.sourceContentHash === sourceContentHash) { + return existing; // Serve cache + } + + // Fail closed if domain pack / glossary is not stable for the domain (MVP specific) + const profileDomain = canonicalContext.analysis.snapshot.profile?.domain; + const domainPackSelection = this.domainPackRegistry.selectPack({ + manualPackId: canonicalContext.analysis.requestedDomainPackId, + repositoryProfileDomain: profileDomain, + }); + + // In our ADR: "Domain-specific localization must fail closed if glossary is unavailable." + // If we only have FALLBACK pack for this domain, we probably shouldn't translate + if (domainPackSelection.pack.status === 'FALLBACK') { + return this.failLocalization( + sourceDocumentId, + targetLocale, + sourceContentHash, + 'GLOSSARY_NOT_AVAILABLE', + existing?.id + ); + } + + try { + // 1. Extract Translatable Fields + const payloadToTranslate = extractTranslatableFields(canonicalContext); + + // 2. Call Translation Provider + const translationResult = await this.translationProvider.translate({ + payload: payloadToTranslate, + targetLocale, + glossary: domainPackSelection.pack, + }); + + // 3. Validate Structural Integrity + const isValid = this.validator.validate(payloadToTranslate, translationResult.translatedPayload); + if (!isValid) { + return this.failLocalization( + sourceDocumentId, + targetLocale, + sourceContentHash, + 'STRUCTURAL_VALIDATION_FAILED', + existing?.id, + translationResult + ); + } + + // 4. Re-merge Translated Text with Canonical Structural Data + const localizedContext = mergeTranslatedFields(canonicalContext, translationResult.translatedPayload); + // Ensure locale is updated for rendering (mapping vi-VN to vi if needed) + localizedContext.locale = targetLocale.startsWith('vi') ? 'vi' : 'en'; + + // 5. Render Final Markdown + const contentMarkdown = this.reportBuilder.build(localizedContext); + + // 6. Persist Localized Artifact + const artifact = await this.artifactRepo.upsert({ + id: existing?.id, + sourceDocumentId, + locale: targetLocale, + sourceLocale: 'en', + localizationStatus: 'COMPLETED', + contentMarkdown, + sourceContentHash, + glossaryVersion: domainPackSelection.pack.version, + provider: translationResult.provider, + model: translationResult.model, + translationPromptVersion: translationResult.promptVersion, + structuralValidatorVersion: this.validator.version, + fieldPolicyVersion: 'v1.0.0', + errorCode: null, + }); + + return artifact; + + } catch (e) { + this.logger.error(`Localization failed for document ${sourceDocumentId}`, e); + return this.failLocalization( + sourceDocumentId, + targetLocale, + sourceContentHash, + 'TRANSLATION_PROVIDER_FAILED', + existing?.id + ); + } + } + + private async failLocalization( + sourceDocumentId: string, + locale: SupportedReportLocale, + sourceContentHash: string, + errorCode: string, + existingId?: string, + translationResult?: any, + ): Promise { + return this.artifactRepo.upsert({ + id: existingId, + sourceDocumentId, + locale, + sourceLocale: 'en', + localizationStatus: 'FAILED', + contentMarkdown: null, + sourceContentHash, + glossaryVersion: null, + provider: translationResult?.provider ?? null, + model: translationResult?.model ?? null, + translationPromptVersion: translationResult?.promptVersion ?? null, + structuralValidatorVersion: this.validator.version, + fieldPolicyVersion: 'v1.0.0', + errorCode, + }); + } +} diff --git a/packages/backend-runtime/src/localization/application/translation-provider.port.ts b/packages/backend-runtime/src/localization/application/translation-provider.port.ts new file mode 100644 index 00000000..dfee3b87 --- /dev/null +++ b/packages/backend-runtime/src/localization/application/translation-provider.port.ts @@ -0,0 +1,20 @@ +import { TranslatablePayload } from '../domain/field-policy'; +import { DomainPack } from '@ba-helper/contracts'; +import { SupportedReportLocale } from '@ba-helper/contracts'; + +export interface TranslationRequest { + payload: TranslatablePayload; + targetLocale: SupportedReportLocale; + glossary?: DomainPack; +} + +export interface TranslationResult { + translatedPayload: TranslatablePayload; + provider: string; + model: string; + promptVersion: string; +} + +export abstract class TranslationProviderPort { + abstract translate(request: TranslationRequest): Promise; +} diff --git a/packages/backend-runtime/src/localization/domain/field-policy.ts b/packages/backend-runtime/src/localization/domain/field-policy.ts new file mode 100644 index 00000000..6df809ab --- /dev/null +++ b/packages/backend-runtime/src/localization/domain/field-policy.ts @@ -0,0 +1,90 @@ +import { MarkdownReportRenderContext, InsightWithEvidence } from '../../document/application/markdown-impact-report.types'; +import { z } from 'zod'; + +/** + * Representation of the extracted translatable fields. + * This is what will be sent to the LLM for translation. + */ +export const translatablePayloadSchema = z.object({ + insights: z.array(z.object({ + id: z.string().uuid(), // Required to map back, but NOT translated + title: z.string(), + description: z.string(), + })), + clarifications: z.array(z.object({ + id: z.string().uuid(), // Required to map back + question: z.string(), + answer: z.string().nullable(), + })), + reviewNotes: z.array(z.object({ + id: z.string().uuid(), // Required to map back + body: z.string(), + })), +}); + +export type TranslatablePayload = z.infer; + +/** + * Extracts only the safe, translatable human-facing text from the canonical context. + */ +export function extractTranslatableFields(context: MarkdownReportRenderContext): TranslatablePayload { + return { + insights: context.insights.map(i => ({ + id: i.id, + title: i.title, + description: i.description, + })), + clarifications: context.clarifications.map(c => ({ + id: c.id, + question: c.question, + answer: c.answer, + })), + reviewNotes: context.reviewNotes.map(n => ({ + id: n.id, + body: n.body, + })), + }; +} + +/** + * Merges the translated text back into a clone of the canonical context. + * It strictly ignores any structural changes from the translated payload (like IDs), + * by looking up the translated values by ID and updating only the allowed text fields. + */ +export function mergeTranslatedFields( + canonicalContext: MarkdownReportRenderContext, + translatedPayload: TranslatablePayload +): MarkdownReportRenderContext { + // Deep clone to avoid mutating the canonical context + const localizedContext: MarkdownReportRenderContext = JSON.parse(JSON.stringify(canonicalContext)); + + const insightMap = new Map(translatedPayload.insights.map(i => [i.id, i])); + localizedContext.insights.forEach(insight => { + const translation = insightMap.get(insight.id); + if (translation) { + insight.title = translation.title; + insight.description = translation.description; + } + }); + + const clarificationMap = new Map(translatedPayload.clarifications.map(c => [c.id, c])); + localizedContext.clarifications.forEach(clarif => { + const translation = clarificationMap.get(clarif.id); + if (translation) { + clarif.question = translation.question; + if (translation.answer !== null) { + clarif.answer = translation.answer; + } + } + }); + + const noteMap = new Map(translatedPayload.reviewNotes.map(n => [n.id, n])); + localizedContext.reviewNotes.forEach(note => { + const translation = noteMap.get(note.id); + if (translation) { + note.body = translation.body; + } + }); + + return localizedContext; +} diff --git a/packages/backend-runtime/src/localization/domain/report-hash.ts b/packages/backend-runtime/src/localization/domain/report-hash.ts new file mode 100644 index 00000000..22344d8a --- /dev/null +++ b/packages/backend-runtime/src/localization/domain/report-hash.ts @@ -0,0 +1,53 @@ +import * as crypto from 'crypto'; +import { MarkdownReportRenderContext } from '../../document/application/markdown-impact-report.types'; + +/** + * Computes a deterministic hash of the canonical report context to detect staleness. + * This ensures that if the source English report data changes, any derived localized + * artifacts are correctly invalidated. + */ +export function computeCanonicalReportHash(context: MarkdownReportRenderContext): string { + // We only include the fields that actually affect the text content of the report. + // We explicitly ignore `locale` since the source is always canonical (usually 'en'). + const hashPayload = { + analysisId: context.analysis.id, + requirementRevisionId: context.analysis.requirementRevision.id, + snapshotId: context.analysis.snapshot.id, + + // Insights (Risks, Unknowns, QA Scenarios) + insights: context.insights.map(i => ({ + id: i.id, + title: i.title, + description: i.description, + insightType: i.insightType, + certainty: i.certainty, + reviewStatus: i.reviewStatus, + evidenceIds: i.evidenceLinks.map(el => el.evidence.id).sort(), + })).sort((a: any, b: any) => a.id.localeCompare(b.id)), + + // Traceability Links (Affected Artifacts) + traceabilityLinks: context.traceabilityLinks.map(tl => ({ + id: tl.id, + artifactId: tl.artifact.id, + linkType: tl.linkType, + reviewStatus: tl.reviewStatus, + evidenceIds: tl.evidenceLinks.map(el => el.evidence.id).sort(), + })).sort((a: any, b: any) => a.id.localeCompare(b.id)), + + // Review Notes + reviewNotes: context.reviewNotes.map(n => ({ + id: n.id, + body: n.body, + })).sort((a: any, b: any) => a.id.localeCompare(b.id)), + + // Clarifications + clarifications: context.clarifications.map(c => ({ + id: c.id, + question: c.question, + answer: c.answer, + })).sort((a: any, b: any) => a.id.localeCompare(b.id)), + }; + + const jsonString = JSON.stringify(hashPayload); + return crypto.createHash('sha256').update(jsonString).digest('hex'); +} diff --git a/packages/backend-runtime/src/localization/domain/structural-validator.ts b/packages/backend-runtime/src/localization/domain/structural-validator.ts new file mode 100644 index 00000000..75b5f7c2 --- /dev/null +++ b/packages/backend-runtime/src/localization/domain/structural-validator.ts @@ -0,0 +1,43 @@ +import { TranslatablePayload } from './field-policy'; +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class StructuralValidator { + private readonly logger = new Logger(StructuralValidator.name); + readonly version = 'v1.0.0'; + + /** + * Validates that the translated payload maintains structural integrity + * with the original payload (same IDs, same array lengths). + */ + validate(original: TranslatablePayload, translated: TranslatablePayload): boolean { + try { + if (original.insights.length !== translated.insights.length) return false; + if (original.clarifications.length !== translated.clarifications.length) return false; + if (original.reviewNotes.length !== translated.reviewNotes.length) return false; + + // Check Insight IDs + const origInsightIds = new Set(original.insights.map(i => i.id)); + for (const i of translated.insights) { + if (!origInsightIds.has(i.id)) return false; + } + + // Check Clarification IDs + const origClarifIds = new Set(original.clarifications.map(c => c.id)); + for (const c of translated.clarifications) { + if (!origClarifIds.has(c.id)) return false; + } + + // Check ReviewNote IDs + const origNoteIds = new Set(original.reviewNotes.map(n => n.id)); + for (const n of translated.reviewNotes) { + if (!origNoteIds.has(n.id)) return false; + } + + return true; + } catch (e) { + this.logger.error('Structural validation threw an error', e); + return false; + } + } +} diff --git a/packages/backend-runtime/src/localization/infrastructure/fake-translation.provider.ts b/packages/backend-runtime/src/localization/infrastructure/fake-translation.provider.ts new file mode 100644 index 00000000..2e924c0a --- /dev/null +++ b/packages/backend-runtime/src/localization/infrastructure/fake-translation.provider.ts @@ -0,0 +1,37 @@ +import { TranslationProviderPort, TranslationRequest, TranslationResult } from '../application/translation-provider.port'; +import { TranslatablePayload } from '../domain/field-policy'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class FakeTranslationProvider extends TranslationProviderPort { + async translate(request: TranslationRequest): Promise { + const { payload, targetLocale } = request; + + // Simulate API delay + await new Promise(r => setTimeout(r, 100)); + + const translatedPayload: TranslatablePayload = { + insights: payload.insights.map(i => ({ + id: i.id, + title: `[${targetLocale}] ${i.title}`, + description: `[${targetLocale}] ${i.description}`, + })), + clarifications: payload.clarifications.map(c => ({ + id: c.id, + question: `[${targetLocale}] ${c.question}`, + answer: c.answer ? `[${targetLocale}] ${c.answer}` : null, + })), + reviewNotes: payload.reviewNotes.map(n => ({ + id: n.id, + body: `[${targetLocale}] ${n.body}`, + })), + }; + + return { + translatedPayload, + provider: 'FakeTranslationProvider', + model: 'fake-deterministic-model', + promptVersion: 'v1.0.0-fake', + }; + } +} diff --git a/packages/backend-runtime/src/localization/infrastructure/localized-artifact.repository.ts b/packages/backend-runtime/src/localization/infrastructure/localized-artifact.repository.ts new file mode 100644 index 00000000..c66925c9 --- /dev/null +++ b/packages/backend-runtime/src/localization/infrastructure/localized-artifact.repository.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { LocalizedReportArtifact } from '@ba-helper/contracts'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class LocalizedArtifactRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByDocumentAndLocale(sourceDocumentId: string, locale: string): Promise { + const record = await this.prisma.localizedReportArtifact.findUnique({ + where: { + sourceDocumentId_locale: { + sourceDocumentId, + locale, + }, + }, + }); + + if (!record) return null; + + // Prisma enum mapping to Contract enum + return record as unknown as LocalizedReportArtifact; + } + + async upsert(data: { + id?: string; + sourceDocumentId: string; + locale: string; + sourceLocale: string; + localizationStatus: 'QUEUED' | 'COMPLETED' | 'FAILED'; + contentMarkdown: string | null; + sourceContentHash: string; + glossaryVersion: string | null; + provider: string | null; + model: string | null; + translationPromptVersion: string | null; + structuralValidatorVersion: string | null; + fieldPolicyVersion: string | null; + errorCode: string | null; + }): Promise { + const record = await this.prisma.localizedReportArtifact.upsert({ + where: { + sourceDocumentId_locale: { + sourceDocumentId: data.sourceDocumentId, + locale: data.locale, + }, + }, + update: data, + create: data, + }); + + return record as unknown as LocalizedReportArtifact; + } +} diff --git a/packages/backend-runtime/src/localization/localization.module.ts b/packages/backend-runtime/src/localization/localization.module.ts new file mode 100644 index 00000000..cbd766b5 --- /dev/null +++ b/packages/backend-runtime/src/localization/localization.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { DomainPackModule } from '../domain-pack/domain-pack.module'; +import { DocumentRuntimeModule } from '../document/document-runtime.module'; +import { ReportLocalizationService } from './application/report-localization.service'; +import { TranslationProviderPort } from './application/translation-provider.port'; +import { FakeTranslationProvider } from './infrastructure/fake-translation.provider'; +import { StructuralValidator } from './domain/structural-validator'; +import { LocalizedArtifactRepository } from './infrastructure/localized-artifact.repository'; + +@Module({ + imports: [PrismaModule, DomainPackModule, DocumentRuntimeModule], + providers: [ + { + provide: TranslationProviderPort, + useClass: FakeTranslationProvider, + }, + StructuralValidator, + LocalizedArtifactRepository, + ReportLocalizationService, + ], + exports: [ReportLocalizationService, LocalizedArtifactRepository], +}) +export class LocalizationModule {} diff --git a/packages/backend-runtime/src/prisma/prisma.module.ts b/packages/backend-runtime/src/prisma/prisma.module.ts new file mode 100644 index 00000000..ec0ce329 --- /dev/null +++ b/packages/backend-runtime/src/prisma/prisma.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/packages/backend-runtime/src/prisma/prisma.service.ts b/packages/backend-runtime/src/prisma/prisma.service.ts new file mode 100644 index 00000000..ba1a6ce1 --- /dev/null +++ b/packages/backend-runtime/src/prisma/prisma.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { Pool } from 'pg'; +import { requireEnv } from '@ba-helper/shared'; + +@Injectable() +export class PrismaService extends PrismaClient { + private readonly pool: Pool; + + constructor() { + const pool = new Pool({ + connectionString: requireEnv('DATABASE_URL', 'postgresql://localhost/ba_helper'), + }); + const adapter = new PrismaPg(pool); + super({ adapter } as ConstructorParameters[0]); + this.pool = pool; + } + + async onModuleInit(): Promise { + await this.$connect(); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + await this.pool.end(); + } +} diff --git a/packages/backend-runtime/src/queue/domain/queue.policy.spec.ts b/packages/backend-runtime/src/queue/domain/queue.policy.spec.ts new file mode 100644 index 00000000..8d0550d5 --- /dev/null +++ b/packages/backend-runtime/src/queue/domain/queue.policy.spec.ts @@ -0,0 +1,77 @@ +import { QueuePolicy } from './queue.policy'; + +describe('QueuePolicy', () => { + describe('assertRetryableJob', () => { + it('throws if idempotency key is missing', () => { + expect(() => { + QueuePolicy.assertRetryableJob({ + jobType: 'TEST_JOB', + idempotencyKey: null, + attempt: 1, + maxAttempts: 3, + payload: { id: 1 }, + }); + }).toThrow('Retryable job TEST_JOB must have an idempotency key.'); + }); + + it('throws if max attempts exceeded', () => { + expect(() => { + QueuePolicy.assertRetryableJob({ + jobType: 'TEST_JOB', + idempotencyKey: 'key-123', + attempt: 4, + maxAttempts: 3, + payload: { id: 1 }, + }); + }).toThrow('Job TEST_JOB exceeded max attempts (3).'); + }); + + it('throws if payload is empty', () => { + expect(() => { + QueuePolicy.assertRetryableJob({ + jobType: 'TEST_JOB', + idempotencyKey: 'key-123', + attempt: 1, + maxAttempts: 3, + payload: {}, + }); + }).toThrow('Job TEST_JOB payload cannot be empty.'); + }); + + it('throws if SCAN_REPOSITORY is missing repositoryId', () => { + expect(() => { + QueuePolicy.assertRetryableJob({ + jobType: 'SCAN_REPOSITORY', + idempotencyKey: 'key-123', + attempt: 1, + maxAttempts: 3, + payload: { someOtherField: true }, + }); + }).toThrow('Job SCAN_REPOSITORY payload must contain repositoryId.'); + }); + + it('throws if RUN_IMPACT_ANALYSIS is missing analysisId', () => { + expect(() => { + QueuePolicy.assertRetryableJob({ + jobType: 'RUN_IMPACT_ANALYSIS', + idempotencyKey: 'key-123', + attempt: 1, + maxAttempts: 3, + payload: { repositoryId: 'abc' }, + }); + }).toThrow('Job RUN_IMPACT_ANALYSIS payload must contain analysisId.'); + }); + + it('passes for valid job', () => { + expect(() => { + QueuePolicy.assertRetryableJob({ + jobType: 'RUN_IMPACT_ANALYSIS', + idempotencyKey: 'key-123', + attempt: 1, + maxAttempts: 3, + payload: { analysisId: 'uuid-123' }, + }); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/backend-runtime/src/queue/domain/queue.policy.ts b/packages/backend-runtime/src/queue/domain/queue.policy.ts new file mode 100644 index 00000000..8840df6c --- /dev/null +++ b/packages/backend-runtime/src/queue/domain/queue.policy.ts @@ -0,0 +1,37 @@ +import { AppError } from '@ba-helper/shared'; + +export const QueuePolicy = { + assertRetryableJob: (params: { + jobType: string; + idempotencyKey?: string | null; + attempt: number; + maxAttempts: number; + payload: Record; + }) => { + if (!params.idempotencyKey) { + throw new AppError('QUEUE_MISSING_IDEMPOTENCY_KEY', `Retryable job ${params.jobType} must have an idempotency key.`); + } + + if (params.attempt > params.maxAttempts) { + throw new AppError('QUEUE_MAX_RETRIES_EXCEEDED', `Job ${params.jobType} exceeded max attempts (${params.maxAttempts}).`); + } + + // Check for stable target identifiers based on job type if needed, + // or broadly enforce that payload must have an ID of some sort if we can guess it. + // At minimum, payload cannot be empty if it's a processing job. + if (!params.payload || Object.keys(params.payload).length === 0) { + throw new AppError('QUEUE_EMPTY_PAYLOAD', `Job ${params.jobType} payload cannot be empty.`); + } + + // specific checks for known jobs + if (params.jobType === 'SCAN_REPOSITORY') { + if (!params.payload.repositoryId) { + throw new AppError('QUEUE_MISSING_TARGET', `Job SCAN_REPOSITORY payload must contain repositoryId.`); + } + } else if (params.jobType === 'RUN_IMPACT_ANALYSIS') { + if (!params.payload.analysisId) { + throw new AppError('QUEUE_MISSING_TARGET', `Job RUN_IMPACT_ANALYSIS payload must contain analysisId.`); + } + } + }, +}; diff --git a/packages/backend-runtime/src/queue/queue.module.ts b/packages/backend-runtime/src/queue/queue.module.ts new file mode 100644 index 00000000..61058c84 --- /dev/null +++ b/packages/backend-runtime/src/queue/queue.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { QueueService } from './queue.service'; +import { requireEnv } from '@ba-helper/shared'; + +@Module({ + imports: [ + BullModule.forRoot({ + connection: { + url: requireEnv('REDIS_URL', 'redis://localhost:6379'), + }, + }), + BullModule.registerQueue({ name: 'impact-analysis' }), + BullModule.registerQueue({ name: 'embedding' }), + BullModule.registerQueue({ name: 'scan-job' }), + BullModule.registerQueue({ name: 'document-job' }), + ], + providers: [QueueService], + exports: [QueueService], +}) +export class QueueModule {} diff --git a/packages/backend-runtime/src/queue/queue.service.spec.ts b/packages/backend-runtime/src/queue/queue.service.spec.ts new file mode 100644 index 00000000..1d0a6aab --- /dev/null +++ b/packages/backend-runtime/src/queue/queue.service.spec.ts @@ -0,0 +1,99 @@ +import { QueueService } from '../index'; + +const makeQueue = () => ({ + add: jest.fn().mockResolvedValue(undefined), + client: Promise.resolve({ ping: jest.fn().mockResolvedValue('PONG') }), + getJobCounts: jest.fn().mockResolvedValue({}), +}); + +describe('QueueService', () => { + it('uses a deterministic embedding job id per snapshot', async () => { + const impactQueue = makeQueue(); + const embeddingQueue = makeQueue(); + const scanJobQueue = makeQueue(); + const documentJobQueue = makeQueue(); + const service = new QueueService( + impactQueue as never, + embeddingQueue as never, + scanJobQueue as never, + documentJobQueue as never, + ); + + await service.enqueueSnapshotEmbedding('snapshot-1'); + await service.enqueueSnapshotEmbedding('snapshot-1'); + + expect(embeddingQueue.add).toHaveBeenCalledTimes(2); + expect(embeddingQueue.add).toHaveBeenNthCalledWith( + 1, + 'embed_snapshot', + { snapshotId: 'snapshot-1' }, + { jobId: 'embed-snapshot-1' }, + ); + expect(embeddingQueue.add).toHaveBeenNthCalledWith( + 2, + 'embed_snapshot', + { snapshotId: 'snapshot-1' }, + { jobId: 'embed-snapshot-1' }, + ); + }); + + it('returns non-sensitive aggregate operations counts', async () => { + const impactQueue = makeQueue(); + const embeddingQueue = makeQueue(); + const scanJobQueue = makeQueue(); + const documentJobQueue = makeQueue(); + scanJobQueue.getJobCounts.mockResolvedValue({ + waiting: 2, + delayed: 1, + prioritized: 3, + active: 4, + failed: 5, + }); + impactQueue.getJobCounts.mockResolvedValue({ + waiting: 7, + active: 8, + failed: 9, + }); + documentJobQueue.getJobCounts.mockResolvedValue({ + waiting: 11, + active: 12, + failed: 13, + }); + const service = new QueueService( + impactQueue as never, + embeddingQueue as never, + scanJobQueue as never, + documentJobQueue as never, + ); + + await expect(service.getOperationsHealthSummary()).resolves.toEqual({ + scanJobs: { status: 'up', pending: 6, running: 4, failed: 5 }, + analysisJobs: { status: 'up', pending: 7, running: 8, failed: 9 }, + documentJobs: { status: 'up', pending: 11, running: 12, failed: 13 }, + }); + }); + + it('marks a queue summary down without exposing job payloads when counts fail', async () => { + const impactQueue = makeQueue(); + const embeddingQueue = makeQueue(); + const scanJobQueue = makeQueue(); + const documentJobQueue = makeQueue(); + scanJobQueue.getJobCounts.mockRejectedValue(new Error('redis unavailable')); + const service = new QueueService( + impactQueue as never, + embeddingQueue as never, + scanJobQueue as never, + documentJobQueue as never, + ); + + const summary = await service.getOperationsHealthSummary(); + + expect(summary.scanJobs).toEqual({ + status: 'down', + pending: 0, + running: 0, + failed: 0, + }); + expect(JSON.stringify(summary)).not.toMatch(/payload|source|prompt|secret/i); + }); +}); diff --git a/packages/backend-runtime/src/queue/queue.service.ts b/packages/backend-runtime/src/queue/queue.service.ts new file mode 100644 index 00000000..d4d2f50e --- /dev/null +++ b/packages/backend-runtime/src/queue/queue.service.ts @@ -0,0 +1,145 @@ +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; + +export type QueueJobCountSummary = { + status: 'up' | 'down'; + pending: number; + running: number; + failed: number; +}; + +export type OperationsQueueSummary = { + scanJobs: QueueJobCountSummary; + analysisJobs: QueueJobCountSummary; + documentJobs: QueueJobCountSummary; +}; + +export class QueueService { + constructor( + @InjectQueue('impact-analysis') + private readonly impactQueue: Queue, + @InjectQueue('embedding') + private readonly embeddingQueue: Queue, + @InjectQueue('scan-job') + private readonly scanJobQueue: Queue, + @InjectQueue('document-job') + private readonly documentJobQueue: Queue, + ) {} + + async enqueueImpactAnalysis(analysisId: string) { + await this.impactQueue.add( + 'run', + { analysisId }, + { + jobId: `impact-${analysisId}`, + attempts: 3, + backoff: { type: 'exponential', delay: 2000 } + }, + ); + } + + async enqueueSnapshotEmbedding(snapshotId: string) { + await this.embeddingQueue.add( + 'embed_snapshot', + { snapshotId }, + { jobId: `embed-${snapshotId}` }, + ); + } + + async enqueueScanJob(jobId: string) { + await this.scanJobQueue.add( + 'scan', + { jobId }, + { jobId: `scan-${jobId}` }, + ); + } + + async enqueueDocumentJob(documentJobId: string) { + // We use a deterministic BullMQ jobId so BullMQ can deduplicate if needed, + // though Prisma DocumentJob is the true idempotency source of truth. + const uniqueJobId = `doc-${documentJobId}`; + await this.documentJobQueue.add( + 'generate', + { documentJobId }, + { + jobId: uniqueJobId, + attempts: 3, + backoff: { type: 'exponential', delay: 2000 } + }, + ); + } + + async checkQueueHealth(): Promise<{ redis: boolean; queue: boolean }> { + try { + const client = (await this.scanJobQueue.client) as { + ping?: () => Promise; + }; + const redisStatus = + typeof client.ping === 'function' + ? (await client.ping()) === 'PONG' + : false; + await this.scanJobQueue.getJobCounts( + 'active', + 'completed', + 'delayed', + 'failed', + 'paused', + 'prioritized', + 'waiting', + ); + + return { + queue: true, + redis: redisStatus, + }; + } catch { + return { + queue: false, + redis: false, + }; + } + } + + async getOperationsHealthSummary(): Promise { + const [scanJobs, analysisJobs, documentJobs] = await Promise.all([ + this.getQueueJobCountSummary(this.scanJobQueue), + this.getQueueJobCountSummary(this.impactQueue), + this.getQueueJobCountSummary(this.documentJobQueue), + ]); + + return { + scanJobs, + analysisJobs, + documentJobs, + }; + } + + private async getQueueJobCountSummary(queue: Queue): Promise { + try { + const counts = await queue.getJobCounts( + 'active', + 'delayed', + 'failed', + 'prioritized', + 'waiting', + ); + + return { + status: 'up', + pending: + (counts.waiting ?? 0) + + (counts.delayed ?? 0) + + (counts.prioritized ?? 0), + running: counts.active ?? 0, + failed: counts.failed ?? 0, + }; + } catch { + return { + status: 'down', + pending: 0, + running: 0, + failed: 0, + }; + } + } +} diff --git a/packages/backend-runtime/src/repository/infrastructure/repository.repository.ts b/packages/backend-runtime/src/repository/infrastructure/repository.repository.ts new file mode 100644 index 00000000..d9a42057 --- /dev/null +++ b/packages/backend-runtime/src/repository/infrastructure/repository.repository.ts @@ -0,0 +1,75 @@ +import type { PrismaService } from '../../prisma/prisma.service'; + +export class RepositoryRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByProjectAndUrl(params: { projectId: string; canonicalUrl: string }) { + return this.prisma.repository.findUnique({ + where: { + projectId_canonicalUrl: { + projectId: params.projectId, + canonicalUrl: params.canonicalUrl, + }, + }, + }); + } + + async createRepository(params: { projectId: string; canonicalUrl: string }) { + return this.prisma.repository.create({ + data: { + projectId: params.projectId, + canonicalUrl: params.canonicalUrl, + }, + }); + } + + async findById(id: string) { + return this.prisma.repository.findUnique({ + where: { id }, + include: { + targets: { + orderBy: { lastObservedAt: 'desc' }, + take: 1, + }, + snapshots: { + orderBy: { createdAt: 'desc' }, + take: 1, + include: { + artifacts: true, + profile: true, + } + }, + scanJobs: { + orderBy: { createdAt: 'desc' }, + take: 1, + } + }, + }); + } + + async findByProject(projectId: string, limit?: number, offset?: number) { + return this.prisma.repository.findMany({ + where: { projectId }, + take: limit, + skip: offset, + include: { + targets: { + orderBy: { lastObservedAt: 'desc' }, + take: 1, + }, + snapshots: { + orderBy: { createdAt: 'desc' }, + take: 1, + include: { + profile: true, + }, + }, + scanJobs: { + orderBy: { createdAt: 'desc' }, + take: 1, + } + }, + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/apps/api/src/modules/retrieval/application/hybrid-retrieval.service.spec.ts b/packages/backend-runtime/src/retrieval/application/hybrid-retrieval.service.spec.ts similarity index 100% rename from apps/api/src/modules/retrieval/application/hybrid-retrieval.service.spec.ts rename to packages/backend-runtime/src/retrieval/application/hybrid-retrieval.service.spec.ts diff --git a/apps/api/src/modules/retrieval/application/hybrid-retrieval.service.ts b/packages/backend-runtime/src/retrieval/application/hybrid-retrieval.service.ts similarity index 99% rename from apps/api/src/modules/retrieval/application/hybrid-retrieval.service.ts rename to packages/backend-runtime/src/retrieval/application/hybrid-retrieval.service.ts index a699187a..2fbd78cc 100644 --- a/apps/api/src/modules/retrieval/application/hybrid-retrieval.service.ts +++ b/packages/backend-runtime/src/retrieval/application/hybrid-retrieval.service.ts @@ -2,7 +2,7 @@ import { Injectable, Inject, Logger } from '@nestjs/common'; import { RetrievalRequest, RetrievedArtifact } from '../domain/retrieval.types'; import { buildRetrievalSuggestion } from '../domain/retrieval-suggestion'; import { EmbeddingChunkRepository } from '../../embedding/infrastructure/embedding-chunk.repository'; -import { EmbeddingProviderPort } from '@ba-helper/application'; +import { EmbeddingProviderPort } from '../../../../application/src/embedding/ports/embedding-provider.port'; import { ArtifactRepository } from '../../artifact/infrastructure/artifact.repository'; import { GraphRepository } from '../../graph/infrastructure/graph.repository'; import { PrismaService } from '../../prisma/prisma.service'; diff --git a/apps/api/src/modules/retrieval/domain/retrieval-suggestion.spec.ts b/packages/backend-runtime/src/retrieval/domain/retrieval-suggestion.spec.ts similarity index 100% rename from apps/api/src/modules/retrieval/domain/retrieval-suggestion.spec.ts rename to packages/backend-runtime/src/retrieval/domain/retrieval-suggestion.spec.ts diff --git a/apps/api/src/modules/retrieval/domain/retrieval-suggestion.ts b/packages/backend-runtime/src/retrieval/domain/retrieval-suggestion.ts similarity index 100% rename from apps/api/src/modules/retrieval/domain/retrieval-suggestion.ts rename to packages/backend-runtime/src/retrieval/domain/retrieval-suggestion.ts diff --git a/apps/api/src/modules/retrieval/domain/retrieval.types.ts b/packages/backend-runtime/src/retrieval/domain/retrieval.types.ts similarity index 100% rename from apps/api/src/modules/retrieval/domain/retrieval.types.ts rename to packages/backend-runtime/src/retrieval/domain/retrieval.types.ts diff --git a/packages/backend-runtime/src/retrieval/retrieval.module.ts b/packages/backend-runtime/src/retrieval/retrieval.module.ts new file mode 100644 index 00000000..e1581b77 --- /dev/null +++ b/packages/backend-runtime/src/retrieval/retrieval.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { HybridRetrievalService } from './application/hybrid-retrieval.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { DomainPackModule } from '../domain-pack/domain-pack.module'; +import { EmbeddingModule } from '../embedding/embedding.module'; +import { ArtifactRepository } from '../artifact/infrastructure/artifact.repository'; +import { GraphRepository } from '../graph/infrastructure/graph.repository'; +import { EmbeddingChunkRepository } from '../embedding/infrastructure/embedding-chunk.repository'; +import { PrismaService } from '../prisma/prisma.service'; + +@Module({ + imports: [PrismaModule, DomainPackModule, EmbeddingModule], + providers: [ + { + provide: ArtifactRepository, + useFactory: (prisma: PrismaService) => new ArtifactRepository(prisma), + inject: [PrismaService], + }, + { + provide: GraphRepository, + useFactory: (prisma: PrismaService) => new GraphRepository(prisma), + inject: [PrismaService], + }, + { + provide: EmbeddingChunkRepository, + useFactory: (prisma: PrismaService) => new EmbeddingChunkRepository(prisma), + inject: [PrismaService], + }, + HybridRetrievalService + ], + exports: [HybridRetrievalService], +}) +export class RetrievalModule {} + diff --git a/packages/backend-runtime/src/scanner/application/incremental-scan-classifier.spec.ts b/packages/backend-runtime/src/scanner/application/incremental-scan-classifier.spec.ts new file mode 100644 index 00000000..b8196965 --- /dev/null +++ b/packages/backend-runtime/src/scanner/application/incremental-scan-classifier.spec.ts @@ -0,0 +1,253 @@ +import { IncrementalScanClassifier } from './incremental-scan-classifier'; +import type { ScanArtifact } from '@ba-helper/analyzer'; +import type { CodeArtifact } from '@prisma/client'; + +describe('IncrementalScanClassifier', () => { + const currentAnalyzerVersion = '1.0.0'; + + const makeScanArtifact = (id: string, hash: string | null = 'hash123'): ScanArtifact => ({ + stableId: id, + type: 'FUNCTION', + filePath: 'src/main.ts', + symbolName: id, + startLine: 1, + endLine: 10, + excerpt: 'function foo() {}', + contentHash: hash, + }); + + const makeCodeArtifact = (id: string, hash: string | null = 'hash123'): CodeArtifact => ({ + id: `db-${id}`, + snapshotId: 'prev-snap', + artifactKey: id, + artifactType: 'FUNCTION', + universalKind: 'FUNCTION', + name: id, + filePath: 'src/main.ts', + startLine: 1, + endLine: 10, + contentHash: hash, + } as CodeArtifact); + + it('classifies all as ADDED when no previous snapshot exists', () => { + const currentArtifacts = [makeScanArtifact('a1')]; + + const result = IncrementalScanClassifier.generateDiagnostics({ + targetSnapshotId: 'target-snap', + currentArtifacts, + currentAnalyzerVersion, + previousSnapshot: null, + previousArtifacts: [], + }); + + const summary = result.scanSummary; + const plan = result.reusePlan; + + expect(summary.baseSnapshotId).toBeNull(); + expect(summary.reuseSafety).toBe('NO_BASELINE'); + expect(summary.addedArtifactCount).toBe(1); + expect(summary.unchangedArtifactCount).toBe(0); + expect(summary.reuseEligibleRatio).toBe(0); + expect(summary.samples.added.length).toBe(1); + expect(summary.samples.added[0].artifactKey).toBe('a1'); + + expect(plan.reuseMode).toBe('PLAN_ONLY'); + expect(plan.reuseSafety).toBe('NO_BASELINE'); + expect(plan.eligibleArtifactCount).toBe(0); + expect(plan.ineligibleArtifactCount).toBe(1); + expect(plan.eligibleRatio).toBe(0); + expect(plan.ineligibleReasons.addedArtifactCount).toBe(1); + expect(plan.samples.eligible.length).toBe(0); + expect(plan.samples.ineligible.length).toBe(1); + expect(plan.samples.ineligible[0].artifactKey).toBe('a1'); + }); + + it('classifies ADDED, CHANGED, UNCHANGED, REMOVED correctly', () => { + const currentArtifacts = [ + makeScanArtifact('added1'), + makeScanArtifact('changed1', 'new-hash'), + makeScanArtifact('unchanged1', 'same-hash'), + ]; + + const previousArtifacts = [ + makeCodeArtifact('changed1', 'old-hash'), + makeCodeArtifact('unchanged1', 'same-hash'), + makeCodeArtifact('removed1'), + ]; + + const result = IncrementalScanClassifier.generateDiagnostics({ + targetSnapshotId: 'target-snap', + currentArtifacts, + currentAnalyzerVersion, + previousSnapshot: { id: 'prev-snap', analyzerVersion: '1.0.0' }, + previousArtifacts, + }); + + const summary = result.scanSummary; + const plan = result.reusePlan; + + expect(summary.baseSnapshotId).toBe('prev-snap'); + expect(summary.reuseSafety).toBe('SAFE_FOR_FUTURE_REUSE'); + expect(summary.addedArtifactCount).toBe(1); + expect(summary.samples.added[0].artifactKey).toBe('added1'); + + expect(summary.changedArtifactCount).toBe(1); + expect(summary.samples.changed[0].artifactKey).toBe('changed1'); + + expect(summary.unchangedArtifactCount).toBe(1); + + expect(summary.removedArtifactCount).toBe(1); + expect(summary.samples.removed[0].artifactKey).toBe('removed1'); + + expect(summary.hashUnavailableArtifactCount).toBe(0); + expect(summary.reuseEligibleArtifactCount).toBe(1); // unchanged is 1 + expect(summary.reuseEligibleRatio).toBeCloseTo(1 / 3); + + expect(plan.reuseSafety).toBe('SAFE_FOR_FUTURE_REUSE'); + expect(plan.eligibleArtifactCount).toBe(1); + expect(plan.ineligibleArtifactCount).toBe(2); // added1, changed1 + expect(plan.ineligibleReasons.addedArtifactCount).toBe(1); + expect(plan.ineligibleReasons.changedArtifactCount).toBe(1); + expect(plan.ineligibleReasons.removedArtifactCount).toBe(1); // Context only + expect(plan.samples.eligible.length).toBe(1); + expect(plan.samples.eligible[0].artifactKey).toBe('unchanged1'); + expect(plan.samples.ineligible.length).toBe(2); + }); + + it('classifies matched artifact with missing old hash as HASH_UNAVAILABLE', () => { + const currentArtifacts = [makeScanArtifact('a1', 'new-hash')]; + const previousArtifacts = [makeCodeArtifact('a1', null)]; + + const result = IncrementalScanClassifier.generateDiagnostics({ + targetSnapshotId: 'target-snap', + currentArtifacts, + currentAnalyzerVersion, + previousSnapshot: { id: 'prev-snap', analyzerVersion: '1.0.0' }, + previousArtifacts, + }); + + const summary = result.scanSummary; + const plan = result.reusePlan; + + expect(summary.hashUnavailableArtifactCount).toBe(1); + expect(summary.changedArtifactCount).toBe(0); + expect(summary.unchangedArtifactCount).toBe(0); + expect(summary.samples.hashUnavailable[0].artifactKey).toBe('a1'); + + expect(plan.eligibleArtifactCount).toBe(0); + expect(plan.ineligibleArtifactCount).toBe(1); + expect(plan.ineligibleReasons.hashUnavailableArtifactCount).toBe(1); + }); + + it('classifies matched artifact with missing new hash as HASH_UNAVAILABLE', () => { + const currentArtifacts = [makeScanArtifact('a1', null)]; + const previousArtifacts = [makeCodeArtifact('a1', 'old-hash')]; + + const result = IncrementalScanClassifier.generateDiagnostics({ + targetSnapshotId: 'target-snap', + currentArtifacts, + currentAnalyzerVersion, + previousSnapshot: { id: 'prev-snap', analyzerVersion: '1.0.0' }, + previousArtifacts, + }); + + const summary = result.scanSummary; + expect(summary.hashUnavailableArtifactCount).toBe(1); + expect(summary.changedArtifactCount).toBe(0); + expect(summary.unchangedArtifactCount).toBe(0); + }); + + it('added artifact with missing hash remains ADDED, not HASH_UNAVAILABLE', () => { + const currentArtifacts = [makeScanArtifact('added1', null)]; + + const result = IncrementalScanClassifier.generateDiagnostics({ + targetSnapshotId: 'target-snap', + currentArtifacts, + currentAnalyzerVersion, + previousSnapshot: { id: 'prev-snap', analyzerVersion: '1.0.0' }, + previousArtifacts: [], + }); + + const summary = result.scanSummary; + expect(summary.addedArtifactCount).toBe(1); + expect(summary.hashUnavailableArtifactCount).toBe(0); + }); + + it('removed artifact with missing old hash remains REMOVED, not HASH_UNAVAILABLE', () => { + const currentArtifacts: ScanArtifact[] = []; + const previousArtifacts = [makeCodeArtifact('removed1', null)]; + + const result = IncrementalScanClassifier.generateDiagnostics({ + targetSnapshotId: 'target-snap', + currentArtifacts, + currentAnalyzerVersion, + previousSnapshot: { id: 'prev-snap', analyzerVersion: '1.0.0' }, + previousArtifacts, + }); + + const summary = result.scanSummary; + const plan = result.reusePlan; + + expect(summary.removedArtifactCount).toBe(1); + expect(summary.hashUnavailableArtifactCount).toBe(0); + + expect(plan.ineligibleReasons.removedArtifactCount).toBe(1); + expect(plan.ineligibleArtifactCount).toBe(0); // Removed are not in current snapshot + }); + + it('bounds samples to max 20 per category and sorts deterministically', () => { + const currentArtifacts: ScanArtifact[] = []; + const previousArtifacts: CodeArtifact[] = []; + + // Add 25 removed artifacts, intentionally out of order + for (let i = 25; i >= 1; i--) { + const id = i.toString().padStart(2, '0'); + previousArtifacts.push(makeCodeArtifact(`rm-${id}`)); + } + + const result = IncrementalScanClassifier.generateDiagnostics({ + targetSnapshotId: 'target-snap', + currentArtifacts, + currentAnalyzerVersion, + previousSnapshot: { id: 'prev-snap', analyzerVersion: '1.0.0' }, + previousArtifacts, + }); + + const summary = result.scanSummary; + expect(summary.removedArtifactCount).toBe(25); + expect(summary.samples.removed.length).toBe(20); + + // They should be sorted by universalKind, filePath, name, artifactKey + // Since everything is the same except artifactKey (`rm-XX`), they sort alphabetically + expect(summary.samples.removed[0].artifactKey).toBe('rm-01'); + expect(summary.samples.removed[19].artifactKey).toBe('rm-20'); + }); + + it('adds warning and sets reuseSafety VERSION_CHANGED_REVIEW_REQUIRED if versions differ', () => { + const currentArtifacts = [makeScanArtifact('unchanged1', 'same-hash')]; + const previousArtifacts = [makeCodeArtifact('unchanged1', 'same-hash')]; + + const result = IncrementalScanClassifier.generateDiagnostics({ + targetSnapshotId: 'target-snap', + currentArtifacts, + currentAnalyzerVersion: '1.1.0', + previousSnapshot: { id: 'prev-snap', analyzerVersion: '1.0.0' }, + previousArtifacts, + }); + + const summary = result.scanSummary; + const plan = result.reusePlan; + + expect(summary.reuseSafety).toBe('VERSION_CHANGED_REVIEW_REQUIRED'); + expect(summary.warnings).toContain('SCANNER_OR_ANALYZER_VERSION_CHANGED'); + expect(summary.unchangedArtifactCount).toBe(1); + expect(summary.reuseEligibleArtifactCount).toBe(0); + + expect(plan.reuseSafety).toBe('VERSION_CHANGED_REVIEW_REQUIRED'); + expect(plan.eligibleArtifactCount).toBe(0); + expect(plan.ineligibleArtifactCount).toBe(1); // the unchanged artifact is now blocked + expect(plan.ineligibleReasons.versionChangedBlockedCount).toBe(1); + expect(plan.samples.eligible.length).toBe(0); + expect(plan.samples.ineligible[0].artifactKey).toBe('unchanged1'); + }); +}); diff --git a/packages/backend-runtime/src/scanner/application/incremental-scan-classifier.ts b/packages/backend-runtime/src/scanner/application/incremental-scan-classifier.ts new file mode 100644 index 00000000..7f7f40ab --- /dev/null +++ b/packages/backend-runtime/src/scanner/application/incremental-scan-classifier.ts @@ -0,0 +1,219 @@ +import type { ScanArtifact } from '@ba-helper/analyzer'; +import { normalizeArtifactKind } from '../../artifact/domain/universal-artifact-kind'; +import type { IncrementalScanSummaryPayload, ArtifactReuseSample, EmbeddingReusePlanPayload } from '@ba-helper/contracts'; +import type { CodeArtifact } from '@prisma/client'; + +export class IncrementalScanClassifier { + static generateDiagnostics(params: { + targetSnapshotId: string; + currentArtifacts: ScanArtifact[]; + currentAnalyzerVersion: string; + previousSnapshot: { id: string; analyzerVersion: string } | null; + previousArtifacts: CodeArtifact[]; + }): { scanSummary: IncrementalScanSummaryPayload; reusePlan: EmbeddingReusePlanPayload } { + const { targetSnapshotId, currentArtifacts, currentAnalyzerVersion, previousSnapshot, previousArtifacts } = params; + + let addedArtifactCount = 0; + let changedArtifactCount = 0; + let unchangedArtifactCount = 0; + let removedArtifactCount = 0; + let hashUnavailableArtifactCount = 0; + + const added: ArtifactReuseSample[] = []; + const changed: ArtifactReuseSample[] = []; + const unchanged: ArtifactReuseSample[] = []; + const removed: ArtifactReuseSample[] = []; + const hashUnavailable: ArtifactReuseSample[] = []; + + const addSample = (list: ArtifactReuseSample[], artifact: ArtifactReuseSample) => { + list.push(artifact); + }; + + const mapToSample = (a: ScanArtifact | CodeArtifact, isCurrent: boolean): ArtifactReuseSample => { + if (isCurrent) { + const cur = a as ScanArtifact; + return { + artifactKey: cur.stableId, + universalKind: normalizeArtifactKind(cur.type), + artifactType: cur.type, + filePath: cur.filePath, + symbolName: cur.symbolName ?? null, + name: cur.symbolName ?? null, + displayName: cur.symbolName ?? null, + }; + } else { + const prev = a as CodeArtifact; + return { + artifactKey: prev.artifactKey, + universalKind: prev.universalKind, + artifactType: prev.artifactType, + filePath: prev.filePath, + symbolName: prev.name ?? null, + name: prev.name ?? null, + displayName: prev.name ?? null, + }; + } + }; + + const prevMap = new Map(); + for (const p of previousArtifacts) { + prevMap.set(p.artifactKey, p); + } + + if (!previousSnapshot) { + addedArtifactCount = currentArtifacts.length; + for (const cur of currentArtifacts) { + addSample(added, mapToSample(cur, true)); + } + const addedSorted = this.sortSamples(added); + return { + scanSummary: { + baseSnapshotId: null, + addedArtifactCount, + changedArtifactCount: 0, + unchangedArtifactCount: 0, + removedArtifactCount: 0, + hashUnavailableArtifactCount: 0, + reuseEligibleArtifactCount: 0, + reuseEligibleRatio: 0, + reuseSafety: 'NO_BASELINE', + warnings: [], + sampleLimit: 20, + samples: { + added: addedSorted.slice(0, 20), + changed: [], + removed: [], + hashUnavailable: [], + }, + }, + reusePlan: { + baseSnapshotId: null, + targetSnapshotId, + reuseMode: 'PLAN_ONLY', + reuseSafety: 'NO_BASELINE', + eligibleArtifactCount: 0, + ineligibleArtifactCount: addedArtifactCount, + eligibleRatio: 0, + ineligibleReasons: { + addedArtifactCount, + changedArtifactCount: 0, + removedArtifactCount: 0, + hashUnavailableArtifactCount: 0, + versionChangedBlockedCount: 0, + }, + sampleLimit: 20, + samples: { + eligible: [], + ineligible: addedSorted.slice(0, 20), + }, + } + }; + } + + for (const cur of currentArtifacts) { + const prev = prevMap.get(cur.stableId); + + if (!prev) { + addedArtifactCount++; + addSample(added, mapToSample(cur, true)); + } else { + // Exists in both + if (prev.contentHash == null || cur.contentHash == null) { + hashUnavailableArtifactCount++; + addSample(hashUnavailable, mapToSample(cur, true)); + } else if (prev.contentHash === cur.contentHash) { + unchangedArtifactCount++; + addSample(unchanged, mapToSample(cur, true)); + } else { + changedArtifactCount++; + addSample(changed, mapToSample(cur, true)); + } + prevMap.delete(cur.stableId); // mark as seen + } + } + + // Any remaining in prevMap are removed + for (const prev of prevMap.values()) { + removedArtifactCount++; + addSample(removed, mapToSample(prev, false)); + } + + let reuseEligibleArtifactCount = unchangedArtifactCount; + let reuseSafety: IncrementalScanSummaryPayload['reuseSafety'] = 'SAFE_FOR_FUTURE_REUSE'; + const warnings: string[] = []; + let versionChangedBlockedCount = 0; + + let eligibleSamples = unchanged; + let ineligibleSamples = [...added, ...changed, ...hashUnavailable]; // do not include removed + + if (currentAnalyzerVersion !== previousSnapshot.analyzerVersion) { + reuseSafety = 'VERSION_CHANGED_REVIEW_REQUIRED'; + warnings.push('SCANNER_OR_ANALYZER_VERSION_CHANGED'); + + // All unchanged artifacts become blocked + versionChangedBlockedCount = unchangedArtifactCount; + reuseEligibleArtifactCount = 0; + + // Move eligible to ineligible + ineligibleSamples.push(...unchanged); + eligibleSamples = []; + } + + const reuseEligibleRatio = currentArtifacts.length > 0 ? reuseEligibleArtifactCount / currentArtifacts.length : 0; + const ineligibleArtifactCount = currentArtifacts.length - reuseEligibleArtifactCount; + + return { + scanSummary: { + baseSnapshotId: previousSnapshot.id, + addedArtifactCount, + changedArtifactCount, + unchangedArtifactCount, + removedArtifactCount, + hashUnavailableArtifactCount, + reuseEligibleArtifactCount, + reuseEligibleRatio, + reuseSafety, + warnings, + sampleLimit: 20, + samples: { + added: this.sortSamples(added).slice(0, 20), + changed: this.sortSamples(changed).slice(0, 20), + removed: this.sortSamples(removed).slice(0, 20), + hashUnavailable: this.sortSamples(hashUnavailable).slice(0, 20), + }, + }, + reusePlan: { + baseSnapshotId: previousSnapshot.id, + targetSnapshotId, + reuseMode: 'PLAN_ONLY', + reuseSafety, + eligibleArtifactCount: reuseEligibleArtifactCount, + ineligibleArtifactCount, + eligibleRatio: reuseEligibleRatio, + ineligibleReasons: { + addedArtifactCount, + changedArtifactCount, + removedArtifactCount, // Context only, not in ineligibleArtifactCount + hashUnavailableArtifactCount, + versionChangedBlockedCount, + }, + sampleLimit: 20, + samples: { + eligible: this.sortSamples(eligibleSamples).slice(0, 20), + ineligible: this.sortSamples(ineligibleSamples).slice(0, 20), + }, + } + }; + } + + private static sortSamples(samples: ArtifactReuseSample[]): ArtifactReuseSample[] { + return samples.sort((a, b) => { + if (a.universalKind !== b.universalKind) return a.universalKind.localeCompare(b.universalKind); + if (a.filePath !== b.filePath) return a.filePath.localeCompare(b.filePath); + const aName = a.symbolName || a.name || ''; + const bName = b.symbolName || b.name || ''; + if (aName !== bName) return aName.localeCompare(bName); + return a.artifactKey.localeCompare(b.artifactKey); + }); + } +} diff --git a/packages/backend-runtime/src/scanner/application/run-scan-job-persistence.step.ts b/packages/backend-runtime/src/scanner/application/run-scan-job-persistence.step.ts new file mode 100644 index 00000000..60658c22 --- /dev/null +++ b/packages/backend-runtime/src/scanner/application/run-scan-job-persistence.step.ts @@ -0,0 +1,302 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Prisma, ScanJobStage, ScanJobStatus } from '@prisma/client';; +import type { + DetectedRepositoryProfile, + ScanArtifact, + ScanResult, +} from '@ba-helper/analyzer'; +import type { DiagnosticItem } from '@ba-helper/contracts'; +import { ArtifactRepository } from '../../artifact/infrastructure/artifact.repository'; +import { normalizeArtifactKind } from '../../artifact/domain/universal-artifact-kind'; +import { EvidenceRepository } from '../../evidence/infrastructure/evidence.repository'; +import { GraphRepository } from '../../graph/infrastructure/graph.repository'; +import { PrismaService } from '../../prisma/prisma.service'; +import { ScanJobRepository } from '../infrastructure/scan-job.repository'; +import { + addIncrementalDiagnostics, + addScanHealthDiagnostic, + assertRequiredDiagnostics, + buildDependencyEdges, + buildEvidenceInputs, + type DiagnosticCollectorLike, + type PersistedArtifactRef, +} from './scan-persistence-mappers'; + +type ScanJobForPersistence = { + id: string; + repositoryId: string; + requestedRef: string | null; +}; + +type PersistScanOutputParams = { + job: ScanJobForPersistence; + commitSha: string; + scanResult: ScanResult; + repositoryProfile: DetectedRepositoryProfile | null; + collector: DiagnosticCollectorLike; +}; + +type ObserveTargetParams = { + job: ScanJobForPersistence; + commitSha: string; +}; + +export type PersistedScanOutput = { + snapshotId: string; + sourceTargetId: string; + coverageStatus: 'READY' | 'PARTIAL'; + artifactCount: number; + evidenceCount: number; + dependencyEdgeCount: number; + skippedDependencyEdgeCount: number; + diagnostics: DiagnosticItem[]; + shouldEnqueueEmbedding: boolean; +}; + +@Injectable() +export class RunScanJobPersistenceStep { + private readonly logger = new Logger(RunScanJobPersistenceStep.name); + + constructor( + private readonly prisma: PrismaService, + private readonly artifactRepository: ArtifactRepository, + private readonly graphRepository: GraphRepository, + private readonly evidenceRepo: EvidenceRepository, + private readonly scanJobRepository: ScanJobRepository, + ) {} + + async observeTarget(params: ObserveTargetParams): Promise<{ id: string }> { + return this.upsertObservedTarget(params, this.prisma); + } + + async markEmbeddingEnqueueFailed(snapshotId: string): Promise { + await this.prisma.repositorySnapshot.update({ + where: { id: snapshotId }, + data: { indexStatus: 'VECTOR_FAILED' }, + }); + } + + async persist(params: PersistScanOutputParams): Promise { + return this.prisma.$transaction(async (tx) => this.persistInTransaction(params, tx)); + } + + private async persistInTransaction( + params: PersistScanOutputParams, + tx: Prisma.TransactionClient, + ): Promise { + const coverageStatus = params.scanResult.coverage.status === 'FULL' ? 'READY' : 'PARTIAL'; + + addScanHealthDiagnostic({ + scanResult: params.scanResult, + collector: params.collector, + }); + + const previousSnapshot = await tx.repositorySnapshot.findFirst({ + where: { + repositoryId: params.job.repositoryId, + coverageStatus: { in: ['READY', 'PARTIAL'] }, + }, + orderBy: [ + { createdAt: 'desc' }, + { id: 'desc' }, + ], + }); + + const snapshot = await tx.repositorySnapshot.upsert({ + where: { + repositoryId_commitSha_analyzerVersion: { + repositoryId: params.job.repositoryId, + commitSha: params.commitSha, + analyzerVersion: params.scanResult.analyzerVersion, + }, + }, + create: { + repositoryId: params.job.repositoryId, + commitSha: params.commitSha, + analyzerVersion: params.scanResult.analyzerVersion, + coverageStatus, + diagnostics: [] as unknown as Prisma.InputJsonValue, + }, + update: { + coverageStatus, + diagnostics: [] as unknown as Prisma.InputJsonValue, + }, + }); + + const previousArtifacts = previousSnapshot + ? await this.artifactRepository.listBySnapshot(previousSnapshot.id, tx) + : []; + addIncrementalDiagnostics({ + snapshotId: snapshot.id, + previousSnapshot, + previousArtifacts, + scanResult: params.scanResult, + collector: params.collector, + }); + + const target = await this.upsertObservedTarget({ + job: params.job, + commitSha: params.commitSha, + }, tx); + + await this.persistProfile(snapshot.id, params.repositoryProfile, tx); + + const persistedArtifacts = await this.persistArtifacts( + snapshot.id, + params.scanResult.artifacts, + tx, + ); + const artifactIdByStableId = new Map( + persistedArtifacts.map((artifact) => [artifact.artifactKey, artifact.id]), + ); + + const evidenceInputs = buildEvidenceInputs({ + snapshotId: snapshot.id, + artifacts: params.scanResult.artifacts, + persistedArtifacts, + collector: params.collector, + }); + const { edgesToPersist, droppedEdgeCount } = buildDependencyEdges( + snapshot.id, + params.scanResult.dependencyEdges ?? [], + artifactIdByStableId, + ); + + if (droppedEdgeCount > 0) { + this.logger.debug(`Dropped ${droppedEdgeCount} unresolved or unsupported dependency edges.`); + } + + await this.graphRepository.createDependencyEdges(edgesToPersist, tx); + await this.evidenceRepo.upsertMany(evidenceInputs, tx); + + const finalDiagnostics = params.collector.getItems() as DiagnosticItem[]; + assertRequiredDiagnostics(finalDiagnostics); + + await tx.repositorySnapshot.update({ + where: { id: snapshot.id }, + data: { + diagnostics: finalDiagnostics as unknown as Prisma.InputJsonValue, + ...(params.scanResult.artifacts.length > 0 ? { indexStatus: 'LEXICAL_READY' } : {}), + }, + }); + + await tx.scanJob.update({ + where: { id: params.job.id }, + data: { + snapshotId: snapshot.id, + sourceTargetId: target.id, + }, + }); + + await this.scanJobRepository.updateState({ + jobId: params.job.id, + status: ScanJobStatus.COMPLETED, + stage: ScanJobStage.DONE, + progress: 100, + }, tx); + + return { + snapshotId: snapshot.id, + sourceTargetId: target.id, + coverageStatus, + artifactCount: params.scanResult.artifacts.length, + evidenceCount: evidenceInputs.length, + dependencyEdgeCount: edgesToPersist.length, + skippedDependencyEdgeCount: droppedEdgeCount, + diagnostics: finalDiagnostics, + shouldEnqueueEmbedding: params.scanResult.artifacts.length > 0, + }; + } + + private async upsertObservedTarget( + params: ObserveTargetParams, + client: PrismaService | Prisma.TransactionClient, + ): Promise<{ id: string }> { + return client.repositoryTarget.upsert({ + where: { + repositoryId_targetKey: { + repositoryId: params.job.repositoryId, + targetKey: params.job.requestedRef ?? 'main', + }, + }, + create: { + repositoryId: params.job.repositoryId, + targetKey: params.job.requestedRef ?? 'main', + requestedRef: params.job.requestedRef ?? 'main', + resolvedRefType: 'BRANCH', + latestObservedCommitSha: params.commitSha, + lastObservedAt: new Date(), + }, + update: { + latestObservedCommitSha: params.commitSha, + lastObservedAt: new Date(), + }, + }); + } + + private async persistProfile( + snapshotId: string, + repositoryProfile: DetectedRepositoryProfile | null, + tx: Prisma.TransactionClient, + ): Promise { + if (!repositoryProfile) { + return; + } + + await tx.repositoryProfile.upsert({ + where: { snapshotId }, + create: { + snapshotId, + domain: repositoryProfile.domain, + language: repositoryProfile.language, + framework: repositoryProfile.framework, + architectureStyle: repositoryProfile.architectureStyle, + sourceRoots: repositoryProfile.sourceRoots as unknown as Prisma.InputJsonValue, + testRoots: repositoryProfile.testRoots as unknown as Prisma.InputJsonValue, + diagnostics: repositoryProfile.diagnostics + ? (repositoryProfile.diagnostics as unknown as Prisma.InputJsonValue) + : undefined, + profileVersion: repositoryProfile.profileVersion, + }, + update: { + domain: repositoryProfile.domain, + language: repositoryProfile.language, + framework: repositoryProfile.framework, + architectureStyle: repositoryProfile.architectureStyle, + sourceRoots: repositoryProfile.sourceRoots as unknown as Prisma.InputJsonValue, + testRoots: repositoryProfile.testRoots as unknown as Prisma.InputJsonValue, + diagnostics: repositoryProfile.diagnostics + ? (repositoryProfile.diagnostics as unknown as Prisma.InputJsonValue) + : undefined, + profileVersion: repositoryProfile.profileVersion, + }, + }); + } + + private async persistArtifacts( + snapshotId: string, + artifacts: ScanArtifact[], + tx: Prisma.TransactionClient, + ): Promise { + if (artifacts.length === 0) { + return []; + } + + await this.artifactRepository.createMany( + artifacts.map((artifact) => ({ + snapshotId, + artifactKey: artifact.stableId, + artifactType: artifact.type, + universalKind: normalizeArtifactKind(artifact.type), + name: artifact.symbolName, + filePath: artifact.filePath, + startLine: artifact.startLine, + endLine: artifact.endLine, + contentHash: artifact.contentHash, + })), + tx, + ); + + return this.artifactRepository.listBySnapshot(snapshotId, tx); + } +} diff --git a/packages/backend-runtime/src/scanner/application/run-scan-job.usecase.spec.ts b/packages/backend-runtime/src/scanner/application/run-scan-job.usecase.spec.ts new file mode 100644 index 00000000..a0072b25 --- /dev/null +++ b/packages/backend-runtime/src/scanner/application/run-scan-job.usecase.spec.ts @@ -0,0 +1,996 @@ +import type { AppError } from '@ba-helper/shared'; +import { RunScanJobPersistenceStep } from './run-scan-job-persistence.step'; +import { RunScanJobUseCase } from './run-scan-job.usecase'; +import * as fs from 'node:fs/promises'; +import { ScanJobStage, ScanJobStatus } from '@prisma/client';; + +jest.mock('node:fs/promises', () => ({ + mkdtemp: jest.fn(), + rm: jest.fn(), +})); + +jest.mock('@ba-helper/analyzer', () => { + class ScannerAdapterRegistry { + getAdapter(lang: string, fw: string) { + if (lang === 'UNKNOWN' || lang === 'python') { + throw new Error('No scanner adapter found'); + } + return { + adapterVersion: '0.2.0', + scan: async (input: any) => { + const analyzerMock = require('@ba-helper/analyzer'); + let capability: any = { adapterVersion: '0.2.0' }; + let capabilityDiagnostic: any = { code: 'SCANNER_CAPABILITY_SUMMARY', severity: 'INFO' }; + let result: any; + + if (lang === 'java') { + result = analyzerMock.scanJavaSpringProject(input); + capability = { + language: 'java', + framework: 'spring', + status: 'PARTIAL', + confidence: 'MEDIUM', + adapterVersion: '0.2.0', + }; + capabilityDiagnostic = { + code: 'SCANNER_CAPABILITY_SUMMARY', + severity: 'INFO', + payload: { ...capability } + }; + } else if (lang === 'go') { + result = analyzerMock.scanGoHttpProject(input); + capability = { + language: 'go', + framework: fw, + status: 'EXPERIMENTAL', + confidence: 'MEDIUM', + adapterVersion: '0.2.0', + }; + capabilityDiagnostic = { + code: 'SCANNER_CAPABILITY_SUMMARY', + severity: 'INFO', + payload: { ...capability } + }; + } else { + result = analyzerMock.scanProject(input); + } + return { + artifacts: result?.artifacts || [], + dependencyEdges: result?.dependencyEdges || [], + diagnostics: result?.diagnostics ? [...result.diagnostics, capabilityDiagnostic] : [capabilityDiagnostic], + capability + }; + } + }; + } + } + + return { + GitHubUrlValidator: { + validate: jest.fn(), + }, + GitRepositoryFetcher: { + fetch: jest.fn(), + }, + FrameworkDetector: { + detect: jest.fn(), + }, + RepositoryProfileDetector: { + detect: jest.fn(), + }, + SafeFileEnumerator: jest.fn(), + SecretRedactor: { + redact: jest.fn((content: string) => ({ + redactedContent: content, + foundSecrets: false, + })), + }, + DiagnosticCollector: class { + private readonly items: any[] = []; + + add(item: any) { + this.items.push(item); + } + + addFromFileDiagnostic(item: any) { + this.items.push(item); + } + + addSecretRedacted(relativePath: string) { + this.items.push({ code: 'SECRET_REDACTED', samplePaths: [relativePath] }); + } + + getItems() { + return this.items; + } + }, + scanProject: jest.fn(), + scanJavaSpringProject: jest.fn(), + scanGoHttpProject: jest.fn(), + scanFixture: jest.fn(), + ScannerAdapterRegistry, + }; +}); + +const analyzer = jest.requireMock('@ba-helper/analyzer') as { + GitHubUrlValidator: { validate: jest.Mock }; + GitRepositoryFetcher: { fetch: jest.Mock }; + FrameworkDetector: { detect: jest.Mock }; + RepositoryProfileDetector: { detect: jest.Mock }; + SafeFileEnumerator: jest.Mock; + scanProject: jest.Mock; + scanJavaSpringProject: jest.Mock; + scanGoHttpProject: jest.Mock; +}; + +describe('RunScanJobUseCase', () => { + const originalPreserveScanWorkspaceEnv = process.env.BA_HELPER_PRESERVE_SCAN_WORKSPACE; + let useCase: RunScanJobUseCase; + let scanJobRepository: any; + let artifactRepository: any; + let graphRepository: any; + let eventLogService: any; + let evidenceRepo: any; + let prisma: any; + let queueService: any; + + beforeEach(() => { + jest.resetAllMocks(); + delete process.env.BA_HELPER_PRESERVE_SCAN_WORKSPACE; + const secretRedactor = ( + jest.requireMock('@ba-helper/analyzer') as { + SecretRedactor: { redact: jest.Mock }; + } + ).SecretRedactor; + secretRedactor.redact.mockImplementation((content: string) => ({ + redactedContent: content, + foundSecrets: false, + })); + + scanJobRepository = { + findById: jest.fn().mockResolvedValue({ + id: 'job-1', + repositoryId: 'repo-1', + requestedRef: 'main', + status: 'QUEUED', + repository: { canonicalUrl: 'https://github.com/owner/repo' }, + }), + updateState: jest.fn().mockResolvedValue(undefined), + updateDiagnostics: jest.fn().mockResolvedValue(undefined), + }; + + artifactRepository = { + createMany: jest.fn().mockResolvedValue(undefined), + listBySnapshot: jest.fn().mockResolvedValue([ + { + id: 'artifact-1', + artifactKey: 'api:booking.controller.cancel', + }, + ]), + }; + + eventLogService = { + recordEvent: jest.fn().mockResolvedValue(undefined), + }; + + evidenceRepo = { + upsertMany: jest.fn().mockResolvedValue(undefined), + }; + + prisma = { + repositoryTarget: { upsert: jest.fn().mockResolvedValue({ id: 'target-1' }) }, + repositorySnapshot: { + findFirst: jest.fn().mockResolvedValue(null), + upsert: jest.fn().mockResolvedValue({ id: 'snapshot-1' }), + update: jest.fn().mockResolvedValue(undefined), + }, + repositoryProfile: { + upsert: jest.fn().mockResolvedValue({ id: 'profile-1' }), + }, + scanJob: { update: jest.fn().mockResolvedValue(undefined) }, + }; + prisma.$transaction = jest.fn(async (callback: (tx: any) => unknown) => + callback(prisma), + ); + + queueService = { + enqueueSnapshotEmbedding: jest.fn().mockResolvedValue(undefined), + }; + + graphRepository = { + createDependencyEdges: jest.fn().mockResolvedValue(undefined), + } as any; + const persistenceStep = new RunScanJobPersistenceStep( + prisma, + artifactRepository, + graphRepository, + evidenceRepo, + scanJobRepository, + ); + + useCase = new RunScanJobUseCase( + scanJobRepository, + eventLogService, + queueService, + persistenceStep, + ); + }); + + afterAll(() => { + if (originalPreserveScanWorkspaceEnv === undefined) { + delete process.env.BA_HELPER_PRESERVE_SCAN_WORKSPACE; + } else { + process.env.BA_HELPER_PRESERVE_SCAN_WORKSPACE = originalPreserveScanWorkspaceEnv; + } + }); + + const mockSuccessfulTypeScriptScan = (params: { + commitSha?: string; + tempDir?: string; + artifacts?: any[]; + dependencyEdges?: any[]; + } = {}) => { + const tempDir = params.tempDir ?? '/tmp/ba-scan-success'; + (fs.mkdtemp as jest.Mock).mockResolvedValue(tempDir); + (fs.rm as jest.Mock).mockResolvedValue(undefined); + analyzer.GitHubUrlValidator.validate.mockReturnValue({ isValid: true }); + analyzer.GitRepositoryFetcher.fetch.mockResolvedValue({ + commitSha: params.commitSha ?? '0123456789abcdef0123456789abcdef01234567', + }); + analyzer.FrameworkDetector.detect.mockResolvedValue({ + isSupported: true, + language: 'typescript', + framework: 'nestjs', + }); + analyzer.RepositoryProfileDetector.detect.mockResolvedValue({ + domain: 'BOOKING', + language: 'TYPESCRIPT', + framework: 'NESTJS', + architectureStyle: 'MODULAR_MONOLITH', + sourceRoots: ['src'], + testRoots: ['test'], + diagnostics: { detectedMarkers: ['NESTJS'], confidence: 0.9 }, + profileVersion: 'repo-profile@0.1.0', + }); + analyzer.SafeFileEnumerator.mockImplementation(() => ({ + enumerate: jest.fn().mockResolvedValue({ + tsFiles: [], + allFiles: [], + diagnostics: [], + isPartial: false, + }), + })); + analyzer.scanProject.mockReturnValue({ + analyzerVersion: '0.2.0', + artifacts: params.artifacts ?? [ + { + stableId: 'api:booking.controller.cancel', + type: 'API_ROUTE', + filePath: 'src/booking/booking.controller.ts', + symbolName: 'BookingController.cancel', + startLine: 10, + endLine: 20, + excerpt: 'cancel() {}', + contentHash: 'hash-123', + }, + ], + dependencyEdges: params.dependencyEdges ?? [], + coverage: { status: 'READY', skippedSummary: {} }, + }); + }; + + it('removes temp workspace after a successful secure clone scan', async () => { + (fs.mkdtemp as jest.Mock).mockResolvedValue('/tmp/ba-scan-success'); + (fs.rm as jest.Mock).mockResolvedValue(undefined); + analyzer.GitHubUrlValidator.validate.mockReturnValue({ isValid: true }); + analyzer.GitRepositoryFetcher.fetch.mockResolvedValue({ + commitSha: '0123456789abcdef0123456789abcdef01234567', + }); + analyzer.FrameworkDetector.detect.mockResolvedValue({ isSupported: true, language: 'typescript', framework: 'nestjs' }); + analyzer.RepositoryProfileDetector.detect.mockResolvedValue({ + domain: 'BOOKING', + language: 'TYPESCRIPT', + framework: 'NESTJS', + architectureStyle: 'MODULAR_MONOLITH', + sourceRoots: ['src'], + testRoots: ['test'], + diagnostics: { detectedMarkers: ['NESTJS'], confidence: 0.9 }, + profileVersion: 'repo-profile@0.1.0', + }); + analyzer.SafeFileEnumerator.mockImplementation(() => ({ + enumerate: jest.fn().mockResolvedValue({ + tsFiles: [], + allFiles: [], + diagnostics: [], + isPartial: false, + }), + })); + analyzer.scanProject.mockReturnValue({ + analyzerVersion: '0.1.0', + artifacts: [ + { + stableId: 'api:booking.controller.cancel', + type: 'API_ROUTE', + filePath: 'src/booking/booking.controller.ts', + symbolName: 'BookingController.cancel', + startLine: 10, + endLine: 20, + excerpt: 'cancel() {}', + }, + ], + coverage: { status: 'READY', skippedFiles: [] }, + sourceRoot: '/tmp/ba-scan-success', + }); + + await useCase.execute({ jobId: 'job-1' }); + + expect(prisma.repositoryProfile.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { snapshotId: 'snapshot-1' }, + create: expect.objectContaining({ + domain: 'BOOKING', + framework: 'NESTJS', + profileVersion: 'repo-profile@0.1.0', + }), + }), + ); + expect(artifactRepository.createMany).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + universalKind: expect.any(String), + }), + ]), + prisma, + ); + + expect(fs.rm).toHaveBeenCalledWith('/tmp/ba-scan-success', { + recursive: true, + force: true, + }); + expect(scanJobRepository.updateState).toHaveBeenLastCalledWith({ + jobId: 'job-1', + status: ScanJobStatus.COMPLETED, + stage: ScanJobStage.DONE, + progress: 100, + }, prisma); + expect(eventLogService.recordEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + eventType: 'SCAN_COMPLETED', + payload: expect.objectContaining({ + scanJobId: 'job-1', + repositoryId: 'repo-1', + snapshotId: 'snapshot-1', + previousStatus: 'RUNNING', + nextStatus: 'COMPLETED', + indexStatus: 'LEXICAL_READY', + artifactCount: 1, + }), + }), + ); + }); + + it('persists scanner artifacts, evidence, lexical-ready state, and enqueues embedding after commit', async () => { + (fs.mkdtemp as jest.Mock).mockResolvedValue('/tmp/ba-scan-deps'); + (fs.rm as jest.Mock).mockResolvedValue(undefined); + analyzer.GitHubUrlValidator.validate.mockReturnValue({ isValid: true }); + analyzer.GitRepositoryFetcher.fetch.mockResolvedValue({ commitSha: 'new-commit' }); + analyzer.FrameworkDetector.detect.mockResolvedValue({ isSupported: true, language: 'typescript', framework: 'nestjs' }); + analyzer.RepositoryProfileDetector.detect.mockResolvedValue({ domain: 'BOOKING', language: 'TYPESCRIPT', framework: 'NESTJS' }); + analyzer.SafeFileEnumerator.mockImplementation(() => ({ + enumerate: jest.fn().mockResolvedValue({ tsFiles: [], allFiles: [], diagnostics: [], isPartial: false }), + })); + + analyzer.scanProject.mockReturnValue({ + analyzerVersion: '0.2.0', + artifacts: [ + { + stableId: 'api:booking.controller.cancel', + type: 'API_ROUTE', + filePath: 'src/booking/booking.controller.ts', + symbolName: 'BookingController.cancel', + startLine: 10, + endLine: 20, + excerpt: 'cancel() {}', + contentHash: 'hash-123', + }, + ], + dependencyEdges: [], + coverage: { status: 'READY', skippedSummary: {} }, + }); + + await useCase.execute({ jobId: 'job-1' }); + + // 1. Assert exact domain-critical fields in Artifact persistence + expect(artifactRepository.createMany).toHaveBeenCalledWith([ + expect.objectContaining({ + artifactKey: 'api:booking.controller.cancel', + artifactType: 'API_ROUTE', + universalKind: expect.any(String), + filePath: 'src/booking/booking.controller.ts', + contentHash: 'hash-123', + }) + ], prisma); + + // 2. Assert exact domain-critical fields in Evidence persistence + expect(evidenceRepo.upsertMany).toHaveBeenCalledWith([ + expect.objectContaining({ + provenanceKey: 'snapshot:snapshot-1:artifact:api:booking.controller.cancel', + sourceType: 'CODE', + sourcePath: 'src/booking/booking.controller.ts', + isRedacted: false, + }) + ], prisma); + + // 3. Assert snapshot is marked LEXICAL_READY + expect(prisma.repositorySnapshot.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'snapshot-1' }, + data: expect.objectContaining({ indexStatus: 'LEXICAL_READY' }), + }), + ); + + // 4. Assert enqueueSnapshotEmbedding is called + expect(queueService.enqueueSnapshotEmbedding).toHaveBeenCalledWith('snapshot-1'); + }); + + it('runs clone and scanner work outside the persistence transaction', async () => { + const milestones: string[] = []; + mockSuccessfulTypeScriptScan(); + analyzer.GitRepositoryFetcher.fetch.mockImplementation(async () => { + milestones.push('clone'); + return { commitSha: 'new-commit' }; + }); + analyzer.scanProject.mockImplementation((input: any) => { + milestones.push('scan'); + return { + analyzerVersion: '0.2.0', + artifacts: [ + { + stableId: 'api:booking.controller.cancel', + type: 'API_ROUTE', + filePath: 'src/booking/booking.controller.ts', + symbolName: 'BookingController.cancel', + startLine: 10, + endLine: 20, + excerpt: 'cancel() {}', + contentHash: 'hash-123', + }, + ], + dependencyEdges: [], + coverage: input.coverage, + }; + }); + prisma.$transaction.mockImplementation(async (callback: (tx: any) => unknown) => { + milestones.push('tx:start'); + const result = await callback(prisma); + milestones.push('tx:commit'); + return result; + }); + queueService.enqueueSnapshotEmbedding.mockImplementation(async () => { + milestones.push('enqueue'); + }); + + await useCase.execute({ jobId: 'job-1' }); + + expect(milestones).toEqual(['clone', 'scan', 'tx:start', 'tx:commit', 'enqueue']); + }); + + it('marks scan completed inside the persistence transaction before embedding enqueue', async () => { + mockSuccessfulTypeScriptScan(); + + await useCase.execute({ jobId: 'job-1' }); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(scanJobRepository.updateState).toHaveBeenCalledWith({ + jobId: 'job-1', + status: ScanJobStatus.COMPLETED, + stage: ScanJobStage.DONE, + progress: 100, + }, prisma); + expect(queueService.enqueueSnapshotEmbedding).toHaveBeenCalledWith('snapshot-1'); + }); + + it('does not enqueue embedding when scan persistence transaction fails', async () => { + mockSuccessfulTypeScriptScan(); + prisma.$transaction.mockRejectedValueOnce(new Error('commit failed')); + + await expect(useCase.execute({ jobId: 'job-1' })).rejects.toThrow('commit failed'); + + expect(queueService.enqueueSnapshotEmbedding).not.toHaveBeenCalled(); + expect(eventLogService.recordEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ eventType: 'SCAN_COMPLETED' }), + ); + const finalState = scanJobRepository.updateState.mock.calls.at(-1)?.[0]; + expect(finalState?.status).toBe(ScanJobStatus.FAILED); + expect(scanJobRepository.updateDiagnostics).toHaveBeenCalledWith({ + jobId: 'job-1', + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + code: 'SCAN_FAILED', + message: 'commit failed', + }), + ]), + }); + }); + + it.each([ + { + label: 'after snapshot upsert before artifact persistence', + setupFailure: () => { + artifactRepository.createMany.mockRejectedValueOnce(new Error('artifact create failed')); + }, + expectedMessage: 'artifact create failed', + }, + { + label: 'after artifact persistence before evidence persistence', + setupFailure: () => { + graphRepository.createDependencyEdges.mockRejectedValueOnce(new Error('edge create failed')); + }, + expectedMessage: 'edge create failed', + }, + { + label: 'after dependency edge persistence before index publication', + setupFailure: () => { + evidenceRepo.upsertMany.mockRejectedValueOnce(new Error('evidence upsert failed')); + }, + expectedMessage: 'evidence upsert failed', + }, + ])('keeps scan unpublished when transaction fails $label', async ({ setupFailure, expectedMessage }) => { + mockSuccessfulTypeScriptScan(); + setupFailure(); + + await expect(useCase.execute({ jobId: 'job-1' })).rejects.toThrow(expectedMessage); + + expect(prisma.repositorySnapshot.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ indexStatus: 'LEXICAL_READY' }), + }), + ); + expect(queueService.enqueueSnapshotEmbedding).not.toHaveBeenCalled(); + expect(eventLogService.recordEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ eventType: 'SCAN_COMPLETED' }), + ); + const finalState = scanJobRepository.updateState.mock.calls.at(-1)?.[0]; + expect(finalState?.status).toBe(ScanJobStatus.FAILED); + }); + + it('keeps scan completed and marks vector failed when embedding enqueue fails after commit', async () => { + mockSuccessfulTypeScriptScan(); + queueService.enqueueSnapshotEmbedding.mockRejectedValueOnce(new Error('redis down')); + + await useCase.execute({ jobId: 'job-1' }); + + expect(scanJobRepository.updateState).toHaveBeenCalledWith({ + jobId: 'job-1', + status: ScanJobStatus.COMPLETED, + stage: ScanJobStage.DONE, + progress: 100, + }, prisma); + expect(prisma.repositorySnapshot.update).toHaveBeenCalledWith({ + where: { id: 'snapshot-1' }, + data: { indexStatus: 'VECTOR_FAILED' }, + }); + expect(eventLogService.recordEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'SCAN_COMPLETED', + payload: expect.objectContaining({ + snapshotId: 'snapshot-1', + nextStatus: 'COMPLETED', + indexStatus: 'VECTOR_FAILED', + }), + }), + ); + expect(eventLogService.recordEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ eventType: 'SCAN_FAILED' }), + ); + }); + + it('reruns the same commit through stable snapshot, artifact, edge, evidence, and embedding keys', async () => { + mockSuccessfulTypeScriptScan({ + commitSha: 'same-commit', + artifacts: [ + { + stableId: 'api:booking.controller.cancel', + type: 'API_ROUTE', + filePath: 'src/booking/booking.controller.ts', + symbolName: 'BookingController.cancel', + startLine: 10, + endLine: 20, + excerpt: 'cancel() {}', + contentHash: 'hash-route', + }, + { + stableId: 'service:booking.service.cancelBooking', + type: 'SERVICE_METHOD', + filePath: 'src/booking/booking.service.ts', + symbolName: 'BookingService.cancelBooking', + startLine: 30, + endLine: 50, + excerpt: 'cancelBooking() {}', + contentHash: 'hash-service', + }, + ], + dependencyEdges: [ + { + fromArtifactId: 'api:booking.controller.cancel', + toArtifactId: 'service:booking.service.cancelBooking', + type: 'CALLS', + }, + ], + }); + artifactRepository.listBySnapshot.mockResolvedValue([ + { + id: 'artifact-route', + artifactKey: 'api:booking.controller.cancel', + }, + { + id: 'artifact-service', + artifactKey: 'service:booking.service.cancelBooking', + }, + ]); + + await useCase.execute({ jobId: 'job-1' }); + await useCase.execute({ jobId: 'job-1' }); + + expect(prisma.repositorySnapshot.upsert).toHaveBeenCalledTimes(2); + for (const call of prisma.repositorySnapshot.upsert.mock.calls) { + expect(call[0].where.repositoryId_commitSha_analyzerVersion).toEqual({ + repositoryId: 'repo-1', + commitSha: 'same-commit', + analyzerVersion: '0.2.0', + }); + } + expect(artifactRepository.createMany).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + artifactKey: 'api:booking.controller.cancel', + contentHash: 'hash-route', + }), + expect.objectContaining({ + artifactKey: 'service:booking.service.cancelBooking', + contentHash: 'hash-service', + }), + ]), + prisma, + ); + expect(graphRepository.createDependencyEdges).toHaveBeenCalledWith([ + expect.objectContaining({ + snapshotId: 'snapshot-1', + fromArtifactId: 'artifact-route', + toArtifactId: 'artifact-service', + type: 'CALLS', + }), + ], prisma); + expect(evidenceRepo.upsertMany).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + provenanceKey: 'snapshot:snapshot-1:artifact:api:booking.controller.cancel', + artifactId: 'artifact-route', + }), + expect.objectContaining({ + provenanceKey: 'snapshot:snapshot-1:artifact:service:booking.service.cancelBooking', + artifactId: 'artifact-service', + }), + ]), + prisma, + ); + expect(queueService.enqueueSnapshotEmbedding).toHaveBeenNthCalledWith(1, 'snapshot-1'); + expect(queueService.enqueueSnapshotEmbedding).toHaveBeenNthCalledWith(2, 'snapshot-1'); + }); + + it('removes temp workspace on clone failure without masking the original error', async () => { + (fs.mkdtemp as jest.Mock).mockResolvedValue('/tmp/ba-scan-fail'); + (fs.rm as jest.Mock).mockRejectedValue(new Error('cleanup failed')); + analyzer.GitHubUrlValidator.validate.mockReturnValue({ isValid: true }); + analyzer.GitRepositoryFetcher.fetch.mockRejectedValue(new Error('network down')); + analyzer.RepositoryProfileDetector.detect.mockResolvedValue({ + domain: 'UNKNOWN', + language: 'UNKNOWN', + framework: 'UNKNOWN', + architectureStyle: 'UNKNOWN', + sourceRoots: [], + testRoots: [], + diagnostics: { confidence: 0.2 }, + profileVersion: 'repo-profile@0.1.0', + }); + + await expect(useCase.execute({ jobId: 'job-1' })).rejects.toMatchObject({ + code: 'CLONE_FAILED', + message: 'network down', + } satisfies Partial); + + expect(fs.rm).toHaveBeenCalledWith('/tmp/ba-scan-fail', { + recursive: true, + force: true, + }); + expect(scanJobRepository.updateState).toHaveBeenLastCalledWith({ + jobId: 'job-1', + status: ScanJobStatus.FAILED, + stage: ScanJobStage.DONE, + progress: 0, + errorCode: 'CLONE_FAILED', + errorMessage: 'network down', + }); + expect(eventLogService.recordEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + eventType: 'SCAN_FAILED', + payload: expect.objectContaining({ + scanJobId: 'job-1', + repositoryId: 'repo-1', + errorCode: 'CLONE_FAILED', + errorMessage: 'network down', + }), + }), + ); + expect(prisma.repositoryProfile.upsert).not.toHaveBeenCalled(); + }); + + it('preserves temp workspace on scan failure when debug preserve mode is enabled', async () => { + process.env.BA_HELPER_PRESERVE_SCAN_WORKSPACE = '1'; + (fs.mkdtemp as jest.Mock).mockResolvedValue('/tmp/ba-scan-debug-preserve'); + analyzer.GitHubUrlValidator.validate.mockReturnValue({ isValid: true }); + analyzer.GitRepositoryFetcher.fetch.mockRejectedValue(new Error('network down')); + + await expect(useCase.execute({ jobId: 'job-1' })).rejects.toMatchObject({ + code: 'CLONE_FAILED', + message: 'network down', + } satisfies Partial); + + expect(fs.rm).not.toHaveBeenCalled(); + expect(scanJobRepository.updateState).toHaveBeenLastCalledWith({ + jobId: 'job-1', + status: ScanJobStatus.FAILED, + stage: ScanJobStage.DONE, + progress: 0, + errorCode: 'CLONE_FAILED', + errorMessage: 'network down', + }); + }); + + it('persists INCREMENTAL_SCAN_SUMMARY diagnostic without raw source or hashes', async () => { + (fs.mkdtemp as jest.Mock).mockResolvedValue('/tmp/ba-scan-incremental'); + (fs.rm as jest.Mock).mockResolvedValue(undefined); + analyzer.GitHubUrlValidator.validate.mockReturnValue({ isValid: true }); + analyzer.GitRepositoryFetcher.fetch.mockResolvedValue({ commitSha: 'new-commit' }); + analyzer.FrameworkDetector.detect.mockResolvedValue({ isSupported: true, language: 'typescript', framework: 'nestjs' }); + analyzer.RepositoryProfileDetector.detect.mockResolvedValue({ domain: 'BOOKING', language: 'TYPESCRIPT', framework: 'NESTJS' }); + analyzer.SafeFileEnumerator.mockImplementation(() => ({ + enumerate: jest.fn().mockResolvedValue({ tsFiles: [], allFiles: [], diagnostics: [], isPartial: false }), + })); + analyzer.scanProject.mockReturnValue({ + analyzerVersion: '0.2.0', + artifacts: [{ + stableId: 'api:booking.controller.cancel', + type: 'API_ROUTE', + filePath: 'src/booking.ts', + contentHash: 'hash-abc', + excerpt: 'cancel() {}' + }], + coverage: { status: 'READY', skippedSummary: {} }, + }); + + prisma.repositorySnapshot.findFirst.mockResolvedValue({ + id: 'prev-snapshot', + analyzerVersion: '0.2.0', + }); + + artifactRepository.listBySnapshot.mockResolvedValue([ + { artifactKey: 'api:booking.controller.cancel', contentHash: 'hash-abc', universalKind: 'API_ROUTE', filePath: 'src/booking.ts' }, + { artifactKey: 'api:booking.controller.other', contentHash: 'hash-def', universalKind: 'API_ROUTE', filePath: 'src/other.ts' }, + ]); + + await useCase.execute({ jobId: 'job-1' }); + + expect(prisma.repositorySnapshot.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + code: 'INCREMENTAL_SCAN_SUMMARY', + payload: expect.objectContaining({ + baseSnapshotId: 'prev-snapshot', + addedArtifactCount: 0, + unchangedArtifactCount: 1, + removedArtifactCount: 1, + }) + }), + expect.objectContaining({ + code: 'EMBEDDING_REUSE_PLAN', + payload: expect.objectContaining({ + reuseMode: 'PLAN_ONLY', + }) + }) + ]) + }) + }) + ); + + // Verify sample payload does not have raw source or hashes + const callArgs = prisma.repositorySnapshot.update.mock.calls[0][0]; + const diag = callArgs.data.diagnostics.find((d: any) => d.code === 'INCREMENTAL_SCAN_SUMMARY'); + const removedSample = diag.payload.samples.removed[0]; + + expect(removedSample).not.toHaveProperty('contentHash'); + expect(removedSample).not.toHaveProperty('excerpt'); + }); + + it('persists all four required diagnostics: SCAN_HEALTH, SCANNER_CAPABILITY_SUMMARY, INCREMENTAL_SCAN_SUMMARY, EMBEDDING_REUSE_PLAN', async () => { + (fs.mkdtemp as jest.Mock).mockResolvedValue('/tmp/ba-scan-required-diag'); + (fs.rm as jest.Mock).mockResolvedValue(undefined); + analyzer.GitHubUrlValidator.validate.mockReturnValue({ isValid: true }); + analyzer.GitRepositoryFetcher.fetch.mockResolvedValue({ commitSha: 'req-commit' }); + analyzer.FrameworkDetector.detect.mockResolvedValue({ isSupported: true, language: 'typescript', framework: 'nestjs' }); + analyzer.RepositoryProfileDetector.detect.mockResolvedValue({ domain: 'BOOKING', language: 'TYPESCRIPT', framework: 'NESTJS' }); + analyzer.SafeFileEnumerator.mockImplementation(() => ({ + enumerate: jest.fn().mockResolvedValue({ tsFiles: [], allFiles: [], diagnostics: [], isPartial: false }), + })); + analyzer.scanProject.mockReturnValue({ + analyzerVersion: '0.2.0', + artifacts: [], + coverage: { status: 'READY', skippedSummary: {} }, + }); + + await useCase.execute({ jobId: 'job-1' }); + + const updateCall = prisma.repositorySnapshot.update.mock.calls[0][0]; + const diagnosticCodes: string[] = updateCall.data.diagnostics.map((d: any) => d.code); + + expect(diagnosticCodes).toContain('SCAN_HEALTH'); + expect(diagnosticCodes).toContain('SCANNER_CAPABILITY_SUMMARY'); + expect(diagnosticCodes).toContain('INCREMENTAL_SCAN_SUMMARY'); + expect(diagnosticCodes).toContain('EMBEDDING_REUSE_PLAN'); + }); + + it('fails with controlled error if unknown language/framework is provided, does not fallback to TypeScript', async () => { + (fs.mkdtemp as jest.Mock).mockResolvedValue('/tmp/ba-scan-unknown-lang'); + (fs.rm as jest.Mock).mockResolvedValue(undefined); + analyzer.GitHubUrlValidator.validate.mockReturnValue({ isValid: true }); + analyzer.GitRepositoryFetcher.fetch.mockResolvedValue({ commitSha: 'unknown-commit' }); + analyzer.FrameworkDetector.detect.mockResolvedValue({ isSupported: true, language: 'python', framework: 'django' }); + + // Simulate an unsupported language detected + analyzer.RepositoryProfileDetector.detect.mockResolvedValue({ + domain: 'UNKNOWN', + language: 'python', + framework: 'django', + architectureStyle: 'UNKNOWN', + sourceRoots: [], + testRoots: [], + profileVersion: 'repo-profile@0.1.0' + }); + + analyzer.SafeFileEnumerator.mockImplementation(() => ({ + enumerate: jest.fn().mockResolvedValue({ tsFiles: [], allFiles: [], diagnostics: [], isPartial: false }), + })); + + await expect(useCase.execute({ jobId: 'job-1' })).rejects.toThrow('No scanner adapter found'); + + // Should not call scanProject (TypeScript default) + expect(analyzer.scanProject).not.toHaveBeenCalled(); + expect(analyzer.scanJavaSpringProject).not.toHaveBeenCalled(); + + // Job must be marked FAILED + const finalState = scanJobRepository.updateState.mock.calls.at(-1)?.[0]; + expect(finalState?.status).toBe(ScanJobStatus.FAILED); + expect(finalState?.errorCode).toBe('UNSUPPORTED_FRAMEWORK'); + + expect(prisma.repositoryTarget.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + repositoryId_targetKey: { + repositoryId: 'repo-1', + targetKey: 'main', + }, + }, + update: expect.objectContaining({ + latestObservedCommitSha: 'unknown-commit', + }), + }), + ); + expect(prisma.repositorySnapshot.upsert).not.toHaveBeenCalled(); + expect(prisma.scanJob.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + snapshotId: expect.any(String), + sourceTargetId: expect.any(String), + }), + }), + ); + expect(queueService.enqueueSnapshotEmbedding).not.toHaveBeenCalled(); + + // No SCAN_COMPLETED event emitted + const completedCall = eventLogService.recordEvent.mock.calls.find( + (c: any[]) => c[0]?.eventType === 'SCAN_COMPLETED', + ); + expect(completedCall).toBeUndefined(); + + // Snapshot is not successfully updated to have LEXICAL_READY or diagnostics + expect(prisma.repositorySnapshot.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ indexStatus: 'LEXICAL_READY' }) + }) + ); + }); + + it('persists Java/Spring SCANNER_CAPABILITY_SUMMARY showing PARTIAL/MEDIUM, without source or vectors', async () => { + (fs.mkdtemp as jest.Mock).mockResolvedValue('/tmp/ba-scan-java-spring'); + (fs.rm as jest.Mock).mockResolvedValue(undefined); + analyzer.GitHubUrlValidator.validate.mockReturnValue({ isValid: true }); + analyzer.GitRepositoryFetcher.fetch.mockResolvedValue({ commitSha: 'java-commit' }); + analyzer.FrameworkDetector.detect.mockResolvedValue({ isSupported: true, language: 'java', framework: 'spring_boot' }); + analyzer.RepositoryProfileDetector.detect.mockResolvedValue({ + domain: 'BOOKING', + language: 'JAVA', + framework: 'SPRING_BOOT', + architectureStyle: 'MODULAR_MONOLITH', + sourceRoots: ['src/main/java'], + testRoots: ['src/test/java'], + profileVersion: 'repo-profile@0.1.0' + }); + analyzer.SafeFileEnumerator.mockImplementation(() => ({ + enumerate: jest.fn().mockResolvedValue({ tsFiles: [], javaFiles: ['src/main/java/Controller.java'], allFiles: [], diagnostics: [], isPartial: false }), + })); + analyzer.scanJavaSpringProject.mockReturnValue({ + analyzerVersion: '0.2.0', + artifacts: [], + coverage: { status: 'PARTIAL', skippedSummary: {} }, + }); + + await useCase.execute({ jobId: 'job-1' }); + + expect(analyzer.scanJavaSpringProject).toHaveBeenCalled(); + expect(analyzer.scanProject).not.toHaveBeenCalled(); + + const updateCall = prisma.repositorySnapshot.update.mock.calls[0][0]; + const capabilityDiag = updateCall.data.diagnostics.find((d: any) => d.code === 'SCANNER_CAPABILITY_SUMMARY'); + + expect(capabilityDiag).toBeDefined(); + expect(capabilityDiag.payload).toMatchObject({ + language: 'java', + framework: 'spring', + status: 'PARTIAL', + confidence: 'MEDIUM' + }); + + // Ensure it doesn't contain source code, vectors, embeddings, prompt text, or absolute local paths + const payloadStr = JSON.stringify(capabilityDiag.payload); + expect(payloadStr).not.toMatch(/sourceCode/i); + expect(payloadStr).not.toMatch(/vector/i); + expect(payloadStr).not.toMatch(/embedding/i); + expect(payloadStr).not.toMatch(/prompt/i); + expect(payloadStr).not.toMatch(/\/tmp\/ba-scan-java-spring/); + }); + + it('fails the job explicitly when diagnostics update throws — no silent success', async () => { + (fs.mkdtemp as jest.Mock).mockResolvedValue('/tmp/ba-scan-diag-fail'); + (fs.rm as jest.Mock).mockResolvedValue(undefined); + analyzer.GitHubUrlValidator.validate.mockReturnValue({ isValid: true }); + analyzer.GitRepositoryFetcher.fetch.mockResolvedValue({ commitSha: 'diag-fail-commit' }); + analyzer.FrameworkDetector.detect.mockResolvedValue({ isSupported: true, language: 'typescript', framework: 'nestjs' }); + analyzer.RepositoryProfileDetector.detect.mockResolvedValue({ domain: 'BOOKING', language: 'TYPESCRIPT', framework: 'NESTJS' }); + analyzer.SafeFileEnumerator.mockImplementation(() => ({ + enumerate: jest.fn().mockResolvedValue({ tsFiles: [], allFiles: [], diagnostics: [], isPartial: false }), + })); + analyzer.scanProject.mockReturnValue({ + analyzerVersion: '0.2.0', + artifacts: [], + coverage: { status: 'READY', skippedSummary: {} }, + }); + + // Simulate DB failure when persisting final diagnostics + prisma.repositorySnapshot.update.mockRejectedValueOnce(new Error('db connection lost')); + + await expect(useCase.execute({ jobId: 'job-1' })).rejects.toThrow('db connection lost'); + + // Job must be marked FAILED — not COMPLETED + const finalState = scanJobRepository.updateState.mock.calls.at(-1)?.[0]; + expect(finalState?.status).toBe(ScanJobStatus.FAILED); + expect(queueService.enqueueSnapshotEmbedding).not.toHaveBeenCalled(); + + // No SCAN_COMPLETED event should have been emitted + const completedCall = eventLogService.recordEvent.mock.calls.find( + (c: any[]) => c[0]?.eventType === 'SCAN_COMPLETED', + ); + expect(completedCall).toBeUndefined(); + }); +}); diff --git a/packages/backend-runtime/src/scanner/application/run-scan-job.usecase.ts b/packages/backend-runtime/src/scanner/application/run-scan-job.usecase.ts new file mode 100644 index 00000000..2e2b3476 --- /dev/null +++ b/packages/backend-runtime/src/scanner/application/run-scan-job.usecase.ts @@ -0,0 +1,493 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ScanJobRepository } from '../infrastructure/scan-job.repository'; +import { EventLogService } from '../../event-log/application/event-log.service'; +import { AppError } from '@ba-helper/shared'; +import { ScanJobStatus, ScanJobStage } from '@prisma/client';; +import { + scanFixture, + FrameworkDetector, + RepositoryProfileDetector, + SafeFileEnumerator, + DiagnosticCollector, + GitHubUrlValidator, + GitRepositoryFetcher, + ScannerAdapterRegistry, +} from '@ba-helper/analyzer'; +import { QueueService } from '../../queue/queue.service'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { + DetectedRepositoryProfile, + ScanCoverage, + ScanResult, +} from '@ba-helper/analyzer'; +import type { DiagnosticItem } from '@ba-helper/contracts'; +import { summarizeDiagnostics } from './scan-diagnostic-summary'; +import { RunScanJobPersistenceStep } from './run-scan-job-persistence.step'; +import { ScanWorkspaceCleanupPolicy } from './scan-workspace-cleanup.policy'; + +const toProfileFrameworkHint = (framework?: string): DetectedRepositoryProfile['framework'] | undefined => { + if (framework === 'nestjs') return 'NESTJS'; + if (framework === 'spring_boot') return 'SPRING_BOOT'; + if (framework === 'generic_typescript') return 'GENERIC_TYPESCRIPT'; + if (framework === 'net/http') return 'NET_HTTP'; + if (framework === 'gin') return 'GIN'; + if (framework === 'fastapi') return 'FASTAPI'; + if (framework === 'aspnetcore') return 'ASPNETCORE'; + if (framework === 'laravel') return 'LARAVEL'; + if (framework === 'rails') return 'RAILS'; + return undefined; +}; + +const toProfileLanguageHint = (language?: string): DetectedRepositoryProfile['language'] | undefined => { + if (language === 'typescript') return 'TYPESCRIPT'; + if (language === 'java') return 'JAVA'; + if (language === 'go') return 'GO'; + if (language === 'python') return 'PYTHON'; + if (language === 'csharp') return 'CSHARP'; + if (language === 'php') return 'PHP'; + if (language === 'ruby') return 'RUBY'; + return undefined; +}; + +@Injectable() +export class RunScanJobUseCase { + private readonly logger = new Logger(RunScanJobUseCase.name); + + private readonly scannerAdapterRegistry = new ScannerAdapterRegistry(); + private readonly cleanupPolicy = new ScanWorkspaceCleanupPolicy(); + + constructor( + private readonly scanJobRepository: ScanJobRepository, + private readonly eventLogService: EventLogService, + private readonly queueService: QueueService, + private readonly persistenceStep: RunScanJobPersistenceStep, + ) {} + + async execute(params: { jobId: string }): Promise { + const job = await this.scanJobRepository.findById(params.jobId); + if (!job) { + throw new AppError('SCAN_JOB_NOT_FOUND', 'Scan job not found.'); + } + + if (job.status !== 'QUEUED') { + throw new AppError('INVALID_SCAN_JOB_STATE', 'Job is not queued.'); + } + + await this.scanJobRepository.updateState({ + jobId: job.id, + status: ScanJobStatus.RUNNING, + stage: ScanJobStage.EXTRACTING_ARTIFACTS, + progress: 10, + }); + + await this.eventLogService.recordEvent({ + eventType: 'SCAN_STARTED', + idempotencyKey: `scan-job:${job.id}:started`, + actorUserId: 'system', + payload: { + actorType: 'SYSTEM', + actorId: 'system', + actorName: 'BA Helper Worker', + scanJobId: job.id, + repositoryId: job.repositoryId, + previousStatus: 'QUEUED', + nextStatus: 'RUNNING', + }, + }); + + let cleanupDir: string | undefined; + const collector = new DiagnosticCollector(); + let commitSha: string | null = null; + let currentStage: ScanJobStage = ScanJobStage.EXTRACTING_ARTIFACTS; + + try { + let scanResult: ScanResult; + let repositoryProfile: DetectedRepositoryProfile | null = null; + + const sourceRoot = job.repository.canonicalUrl; + const isLocalFixtureSource = + process.env.NODE_ENV === 'test' && (sourceRoot.startsWith('/') || sourceRoot.startsWith('file://')); + const urlValidation = isLocalFixtureSource + ? { isValid: true } + : GitHubUrlValidator.validate(sourceRoot); + + if (urlValidation.isValid) { + currentStage = ScanJobStage.CLONING_REPO; + await this.scanJobRepository.updateState({ + jobId: job.id, + status: ScanJobStatus.RUNNING, + stage: currentStage, + progress: 15, + }); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ba-scan-')); + cleanupDir = tempDir; + + try { + const fetchResult = await GitRepositoryFetcher.fetch({ + url: sourceRoot, + targetDir: tempDir, + ref: job.requestedRef ?? undefined, + }); + commitSha = fetchResult.commitSha; + } catch (err) { + throw new AppError('CLONE_FAILED', (err as Error).message); + } + await this.persistenceStep.observeTarget({ + job: { + id: job.id, + repositoryId: job.repositoryId, + requestedRef: job.requestedRef, + }, + commitSha, + }); + + currentStage = ScanJobStage.DETECTING_PROJECT; + await this.scanJobRepository.updateState({ + jobId: job.id, + status: ScanJobStatus.RUNNING, + stage: currentStage, + progress: 25, + }); + const frameworkResult = await FrameworkDetector.detect(tempDir); + repositoryProfile = await RepositoryProfileDetector.detect({ + rootDir: tempDir, + languageHint: toProfileLanguageHint(frameworkResult.language), + frameworkHint: toProfileFrameworkHint(frameworkResult.framework), + unsupportedReason: frameworkResult.isSupported ? undefined : frameworkResult.reason, + }); + if (!frameworkResult.isSupported) { + collector.add({ + code: 'UNSUPPORTED_FRAMEWORK', + severity: 'BLOCKER', + message: frameworkResult.reason || 'Not a NestJS repository', + category: 'FRAMEWORK', + }); + throw new AppError('UNSUPPORTED_FRAMEWORK', frameworkResult.reason || 'Not a NestJS repository'); + } + + currentStage = ScanJobStage.FILTERING_FILES; + await this.scanJobRepository.updateState({ + jobId: job.id, + status: ScanJobStatus.RUNNING, + stage: currentStage, + progress: 35, + }); + const enumerator = new SafeFileEnumerator(tempDir); + const enumResult = await enumerator.enumerate(); + + for (const d of enumResult.diagnostics) { + collector.addFromFileDiagnostic(d, d.filePath ? path.relative(tempDir, d.filePath) : undefined); + } + + const scanCoverage: ScanCoverage = { + status: enumResult.isPartial ? 'PARTIAL' : 'FULL', + skippedFiles: enumResult.skippedFiles, + skippedSummary: enumResult.skippedSummary, + limits: enumResult.limits, + limitHits: enumResult.limitHits, + }; + + currentStage = ScanJobStage.EXTRACTING_ARTIFACTS; + + let adapter; + try { + adapter = this.scannerAdapterRegistry.getAdapter( + frameworkResult?.language || 'UNKNOWN', + frameworkResult?.framework || 'UNKNOWN' + ); + } catch (e) { + collector.add({ + code: 'UNSUPPORTED_SCANNER_ADAPTER', + severity: 'BLOCKER', + message: e instanceof Error ? e.message : 'No scanner adapter found', + category: 'SCANNER', + }); + throw new AppError('UNSUPPORTED_FRAMEWORK', e instanceof Error ? e.message : 'No scanner adapter found'); + } + + const adapterResult = await adapter.scan({ + rootDir: tempDir, + repositoryId: job.repositoryId, + projectId: job.repository.projectId, + fixturePath: tempDir, + tsFiles: enumResult.tsFiles, + javaFiles: enumResult.javaFiles, + goFiles: enumResult.goFiles, + pyFiles: enumResult.pyFiles, + csFiles: enumResult.csFiles, + phpFiles: enumResult.phpFiles, + rbFiles: enumResult.rbFiles, + coverage: scanCoverage, + }); + + scanResult = { + analyzerVersion: adapter.adapterVersion, + artifacts: adapterResult.artifacts, + dependencyEdges: adapterResult.dependencyEdges, + coverage: scanCoverage, + sourceRoot: tempDir, + }; + + for (const diagnostic of adapterResult.diagnostics) { + collector.add(diagnostic); + } + } else { + commitSha = 'mock-commit-sha'; + scanResult = scanFixture({ + fixturePath: sourceRoot, + analyzerVersion: '0.2.0', + }); + collector.add({ + code: 'SCANNER_CAPABILITY_SUMMARY', + severity: 'INFO', + message: 'Fixture scanner capability summary', + category: 'SCANNER', + payload: { + features: { + routes: 'FULL', + services: 'FULL', + dataModels: 'FULL', + events: 'NONE', + }, + }, + }); + } + + if (!commitSha) { + throw new Error('Commit SHA was not resolved for scan job.'); + } + + await this.scanJobRepository.updateState({ + jobId: job.id, + status: ScanJobStatus.RUNNING, + stage: ScanJobStage.EXTRACTING_ARTIFACTS, + progress: 50, + }); + + const persisted = await this.persistenceStep.persist({ + job: { + id: job.id, + repositoryId: job.repositoryId, + requestedRef: job.requestedRef, + }, + commitSha, + scanResult, + repositoryProfile, + collector, + }); + + if (persisted.artifactCount > 0) { + await this.eventLogService.recordEvent({ + eventType: 'SCAN_ARTIFACTS_EXTRACTED', + idempotencyKey: `scan-job:${job.id}:artifacts-extracted`, + actorUserId: 'system', + payload: { + actorType: 'SYSTEM', + actorId: 'system', + actorName: 'BA Helper Worker', + scanJobId: job.id, + repositoryId: job.repositoryId, + snapshotId: persisted.snapshotId, + artifactCount: persisted.artifactCount, + }, + }); + + await this.eventLogService.recordEvent({ + eventType: 'SCAN_DEPENDENCY_EDGES_PERSISTED', + idempotencyKey: `scan-job:${job.id}:edges-persisted`, + actorUserId: 'system', + payload: { + actorType: 'SYSTEM', + actorId: 'system', + actorName: 'BA Helper Worker', + scanJobId: job.id, + repositoryId: job.repositoryId, + snapshotId: persisted.snapshotId, + dependencyEdgeCount: persisted.dependencyEdgeCount, + skippedEdgeCount: persisted.skippedDependencyEdgeCount, + }, + }); + } + + let embeddingEnqueueFailed = false; + if (persisted.shouldEnqueueEmbedding) { + try { + await this.queueService.enqueueSnapshotEmbedding(persisted.snapshotId); + } catch (error) { + embeddingEnqueueFailed = true; + await this.persistenceStep.markEmbeddingEnqueueFailed(persisted.snapshotId); + this.logger.warn( + JSON.stringify({ + event: 'SCAN_EMBEDDING_ENQUEUE_FAILED', + jobId: job.id, + repositoryId: job.repositoryId, + snapshotId: persisted.snapshotId, + errorMessage: error instanceof Error ? error.message : 'Unknown error', + }), + ); + } + } + + const completionIndexStatus = + embeddingEnqueueFailed ? 'VECTOR_FAILED' : 'LEXICAL_READY'; + + await this.eventLogService.recordEvent({ + eventType: 'SCAN_COMPLETED', + idempotencyKey: `scan-job:${job.id}:completed`, + actorUserId: 'system', + payload: { + actorType: 'SYSTEM', + actorId: 'system', + actorName: 'BA Helper Worker', + scanJobId: job.id, + repositoryId: job.repositoryId, + snapshotId: persisted.snapshotId, + previousStatus: 'RUNNING', + nextStatus: 'COMPLETED', + indexStatus: completionIndexStatus, + artifactCount: persisted.artifactCount, + }, + }); + this.logger.log( + JSON.stringify({ + event: 'SCAN_JOB_COMPLETED', + jobId: job.id, + repositoryId: job.repositoryId, + requestedRef: job.requestedRef ?? 'main', + sourceTargetId: persisted.sourceTargetId, + snapshotId: persisted.snapshotId, + commitSha, + coverageStatus: persisted.coverageStatus, + diagnostics: summarizeDiagnostics(persisted.diagnostics), + }), + ); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + const errorCode = error instanceof AppError ? error.code : null; + if (error instanceof AppError && error.code === 'UNSUPPORTED_FRAMEWORK') { + // Blocker diagnostic already collected above + } else { + collector.add({ + code: 'SCAN_FAILED', + severity: 'BLOCKER', + message: errorMsg, + category: 'SCANNER' + }); + } + + try { + await this.scanJobRepository.updateState({ + jobId: job.id, + status: ScanJobStatus.FAILED, + stage: ScanJobStage.DONE, + progress: 0, + errorCode, + errorMessage: errorMsg, + }); + } catch (persistError) { + this.logger.warn( + JSON.stringify({ + event: 'SCAN_JOB_FAILURE_STATE_PERSIST_FAILED', + jobId: job.id, + repositoryId: job.repositoryId, + originalErrorCode: errorCode, + originalErrorMessage: errorMsg, + persistenceError: + persistError instanceof Error ? persistError.message : 'Unknown persistence error', + }), + ); + } + + try { + await this.scanJobRepository.updateDiagnostics({ + jobId: job.id, + diagnostics: collector.getItems(), + }); + } catch (persistError) { + this.logger.warn( + JSON.stringify({ + event: 'SCAN_JOB_FAILURE_DIAGNOSTICS_PERSIST_FAILED', + jobId: job.id, + repositoryId: job.repositoryId, + originalErrorCode: errorCode, + originalErrorMessage: errorMsg, + persistenceError: + persistError instanceof Error ? persistError.message : 'Unknown persistence error', + }), + ); + } + + try { + await this.eventLogService.recordEvent({ + eventType: 'SCAN_FAILED', + idempotencyKey: `scan-job:${job.id}:failed`, + actorUserId: 'system', + payload: { + actorType: 'SYSTEM', + actorId: 'system', + actorName: 'BA Helper Worker', + scanJobId: job.id, + repositoryId: job.repositoryId, + previousStatus: 'RUNNING', + nextStatus: 'FAILED', + errorCode, + errorMessage: errorMsg, + }, + }); + } catch (persistError) { + this.logger.warn( + JSON.stringify({ + event: 'SCAN_JOB_FAILURE_EVENT_PERSIST_FAILED', + jobId: job.id, + repositoryId: job.repositoryId, + originalErrorCode: errorCode, + originalErrorMessage: errorMsg, + persistenceError: + persistError instanceof Error ? persistError.message : 'Unknown persistence error', + }), + ); + } + this.logger.error( + JSON.stringify({ + event: 'SCAN_JOB_FAILED', + jobId: job.id, + repositoryId: job.repositoryId, + requestedRef: job.requestedRef ?? 'main', + stage: currentStage, + commitSha, + errorCode, + errorMessage: errorMsg, + diagnostics: summarizeDiagnostics(collector.getItems() as DiagnosticItem[]), + }), + ); + throw error; + } finally { + const cleanup = await this.cleanupPolicy.cleanup(cleanupDir); + if (cleanup.reason !== 'NO_WORKSPACE') { + const logPayload = { + event: 'SCAN_WORKSPACE_CLEANUP', + jobId: job.id, + repositoryId: job.repositoryId, + attempted: cleanup.attempted, + preserved: cleanup.preserved, + succeeded: cleanup.succeeded, + reason: cleanup.reason, + workspaceId: cleanup.workspaceId, + }; + + if (cleanup.succeeded === false) { + this.logger.warn( + JSON.stringify({ + ...logPayload, + errorMessage: cleanup.errorMessage, + }), + ); + } else { + this.logger.log(JSON.stringify(logPayload)); + } + } + } + } +} diff --git a/packages/backend-runtime/src/scanner/application/scan-diagnostic-summary.ts b/packages/backend-runtime/src/scanner/application/scan-diagnostic-summary.ts new file mode 100644 index 00000000..7a27c633 --- /dev/null +++ b/packages/backend-runtime/src/scanner/application/scan-diagnostic-summary.ts @@ -0,0 +1,42 @@ +import type { DiagnosticItem } from '@ba-helper/contracts'; + +type SeverityBucket = Record<'BLOCKER' | 'ERROR' | 'WARN' | 'INFO', number>; +type CategoryBucket = Partial< + Record<'SECURITY' | 'LIMIT' | 'FRAMEWORK' | 'FILE_SYSTEM' | 'GIT' | 'SCANNER', number> +>; + +export type ScanDiagnosticSummary = { + total: number; + bySeverity: SeverityBucket; + byCategory: CategoryBucket; + codes: string[]; +}; + +export function summarizeDiagnostics( + diagnostics: DiagnosticItem[], +): ScanDiagnosticSummary { + const bySeverity: SeverityBucket = { + BLOCKER: 0, + ERROR: 0, + WARN: 0, + INFO: 0, + }; + const byCategory: CategoryBucket = {}; + + for (const diagnostic of diagnostics) { + const weight = diagnostic.count ?? 1; + bySeverity[diagnostic.severity] += weight; + + if (diagnostic.category) { + byCategory[diagnostic.category] = + (byCategory[diagnostic.category] ?? 0) + weight; + } + } + + return { + total: diagnostics.length, + bySeverity, + byCategory, + codes: diagnostics.map((diagnostic) => diagnostic.code), + }; +} diff --git a/packages/backend-runtime/src/scanner/application/scan-persistence-mappers.ts b/packages/backend-runtime/src/scanner/application/scan-persistence-mappers.ts new file mode 100644 index 00000000..828d1835 --- /dev/null +++ b/packages/backend-runtime/src/scanner/application/scan-persistence-mappers.ts @@ -0,0 +1,207 @@ +import { createHash } from 'node:crypto'; +import type { CodeArtifact, DependencyEdgeType } from '@prisma/client'; +import type { + ScanArtifact, + ScanHealthDiagnostics, + ScanResult, +} from '@ba-helper/analyzer'; +import { SecretRedactor } from '@ba-helper/analyzer'; +import { AppError } from '@ba-helper/shared'; +import type { DiagnosticItem } from '@ba-helper/contracts'; +import type { EvidenceRepository } from '../../evidence/infrastructure/evidence.repository'; +import { IncrementalScanClassifier } from './incremental-scan-classifier'; + +export type DiagnosticCollectorLike = { + add(item: DiagnosticItem): void; + addSecretRedacted(relativePath: string): void; + getItems(): DiagnosticItem[]; +}; + +export type PersistedArtifactRef = { + id: string; + artifactKey: string; +}; + +const REQUIRED_SCAN_DIAGNOSTIC_CODES = [ + 'SCAN_HEALTH', + 'SCANNER_CAPABILITY_SUMMARY', + 'INCREMENTAL_SCAN_SUMMARY', + 'EMBEDDING_REUSE_PLAN', +] as const; + +export function addScanHealthDiagnostic(params: { + scanResult: ScanResult; + collector: DiagnosticCollectorLike; +}): void { + const scanHealth: ScanHealthDiagnostics = { + coverageStatus: params.scanResult.coverage.status, + scannerVersion: 'scanner@0.2.0', + analyzerVersion: params.scanResult.analyzerVersion, + scannedFileCount: params.scanResult.artifacts.length, + skippedFileCount: Object.values(params.scanResult.coverage?.skippedSummary || {}) + .reduce((a, b) => a + b, 0), + artifactCount: params.scanResult.artifacts.length, + skippedSummary: params.scanResult.coverage?.skippedSummary || {}, + skippedFilesSample: params.scanResult.coverage?.skippedFiles || [], + limits: params.scanResult.coverage?.limits || { maxFiles: 0, maxFileSize: 0 }, + limitHits: params.scanResult.coverage?.limitHits || [], + }; + + params.collector.add({ + code: 'SCAN_HEALTH', + severity: 'INFO', + message: 'Scan health summary generated', + category: 'SCANNER', + payload: scanHealth as unknown as Record, + }); +} + +export function addIncrementalDiagnostics(params: { + snapshotId: string; + previousSnapshot: { id: string; analyzerVersion: string } | null; + previousArtifacts: CodeArtifact[]; + scanResult: ScanResult; + collector: DiagnosticCollectorLike; +}): void { + const { scanSummary, reusePlan } = IncrementalScanClassifier.generateDiagnostics({ + targetSnapshotId: params.snapshotId, + currentArtifacts: params.scanResult.artifacts, + currentAnalyzerVersion: params.scanResult.analyzerVersion, + previousSnapshot: params.previousSnapshot + ? { + id: params.previousSnapshot.id, + analyzerVersion: params.previousSnapshot.analyzerVersion, + } + : null, + previousArtifacts: params.previousArtifacts, + }); + + params.collector.add({ + code: 'INCREMENTAL_SCAN_SUMMARY', + severity: 'INFO', + message: 'Incremental scan classification summary generated', + category: 'SCANNER', + payload: scanSummary as unknown as Record, + }); + + params.collector.add({ + code: 'EMBEDDING_REUSE_PLAN', + severity: 'INFO', + message: 'Embedding chunk reuse plan generated', + category: 'SCANNER', + payload: reusePlan as unknown as Record, + }); +} + +export function buildEvidenceInputs(params: { + snapshotId: string; + artifacts: ScanArtifact[]; + persistedArtifacts: PersistedArtifactRef[]; + collector: DiagnosticCollectorLike; +}): Parameters[0] { + const persistedArtifactByKey = new Map( + params.persistedArtifacts.map((artifact) => [artifact.artifactKey, artifact.id]), + ); + + return params.artifacts + .map((artifact) => { + const persistedId = persistedArtifactByKey.get(artifact.stableId); + if (!persistedId) return null; + + const redaction = SecretRedactor.redact(artifact.excerpt || ''); + const excerpt = redaction.redactedContent; + const contentHash = createHash('sha256').update(excerpt).digest('hex'); + + if (redaction.foundSecrets) { + params.collector.addSecretRedacted('source files'); + } + + return { + provenanceKey: `snapshot:${params.snapshotId}:artifact:${artifact.stableId}`, + sourceType: artifact.type === 'TEST' ? 'TEST' : 'CODE', + snapshotId: params.snapshotId, + artifactId: persistedId, + sourcePath: artifact.filePath, + startLine: artifact.startLine, + endLine: artifact.endLine, + excerpt, + contentHash, + isRedacted: redaction.foundSecrets, + redactionMetadata: null, + }; + }) + .filter((item): item is NonNullable => item !== null); +} + +export function buildDependencyEdges( + snapshotId: string, + dependencyEdges: NonNullable, + artifactIdByStableId: Map, +): { + edgesToPersist: { + snapshotId: string; + fromArtifactId: string; + toArtifactId: string; + type: DependencyEdgeType; + }[]; + droppedEdgeCount: number; +} { + const edgesToPersist: { + snapshotId: string; + fromArtifactId: string; + toArtifactId: string; + type: DependencyEdgeType; + }[] = []; + let droppedEdgeCount = 0; + + for (const edge of dependencyEdges) { + const mappedType = mapScannerEdgeType(edge.type); + if (!mappedType) { + droppedEdgeCount++; + continue; + } + + const fromId = artifactIdByStableId.get(edge.fromArtifactId); + const toId = artifactIdByStableId.get(edge.toArtifactId); + if (!fromId || !toId) { + droppedEdgeCount++; + continue; + } + + edgesToPersist.push({ + snapshotId, + fromArtifactId: fromId, + toArtifactId: toId, + type: mappedType, + }); + } + + return { edgesToPersist, droppedEdgeCount }; +} + +export function assertRequiredDiagnostics(items: DiagnosticItem[]): void { + const presentCodes = new Set(items.map((d) => d.code)); + const missing = REQUIRED_SCAN_DIAGNOSTIC_CODES.filter((code) => !presentCodes.has(code)); + if (missing.length > 0) { + throw new AppError( + 'SNAPSHOT_DIAGNOSTICS_INCOMPLETE', + `Required scan diagnostics missing before finalization: ${missing.join(', ')}`, + ); + } +} + +function mapScannerEdgeType(type: string): DependencyEdgeType | null { + switch (type) { + case 'CALLS': + return 'CALLS'; + case 'IMPORTS': + return 'IMPORTS'; + case 'TESTS': + return 'TESTS'; + case 'USES': + case 'REFERENCES': + return 'REFERENCES'; + default: + return null; + } +} diff --git a/packages/backend-runtime/src/scanner/application/scan-workspace-cleanup.policy.spec.ts b/packages/backend-runtime/src/scanner/application/scan-workspace-cleanup.policy.spec.ts new file mode 100644 index 00000000..d72b2b22 --- /dev/null +++ b/packages/backend-runtime/src/scanner/application/scan-workspace-cleanup.policy.spec.ts @@ -0,0 +1,84 @@ +import * as fs from 'node:fs/promises'; +import { ScanWorkspaceCleanupPolicy } from './scan-workspace-cleanup.policy'; + +jest.mock('node:fs/promises', () => ({ + rm: jest.fn(), +})); + +describe('ScanWorkspaceCleanupPolicy', () => { + const originalEnv = process.env.BA_HELPER_PRESERVE_SCAN_WORKSPACE; + + beforeEach(() => { + jest.resetAllMocks(); + delete process.env.BA_HELPER_PRESERVE_SCAN_WORKSPACE; + }); + + afterAll(() => { + if (originalEnv === undefined) { + delete process.env.BA_HELPER_PRESERVE_SCAN_WORKSPACE; + } else { + process.env.BA_HELPER_PRESERVE_SCAN_WORKSPACE = originalEnv; + } + }); + + it('removes a scan workspace and returns a safe workspace id', async () => { + (fs.rm as jest.Mock).mockResolvedValue(undefined); + + const result = await new ScanWorkspaceCleanupPolicy().cleanup('/tmp/private/repo-checkout'); + + expect(fs.rm).toHaveBeenCalledWith('/tmp/private/repo-checkout', { + recursive: true, + force: true, + }); + expect(result).toMatchObject({ + attempted: true, + preserved: false, + succeeded: true, + reason: 'CLEANED', + }); + expect(result.workspaceId).toMatch(/^sha256:[a-f0-9]{16}$/); + expect(JSON.stringify(result)).not.toContain('/tmp/private/repo-checkout'); + }); + + it('preserves the workspace when debug preserve mode is enabled', async () => { + process.env.BA_HELPER_PRESERVE_SCAN_WORKSPACE = 'true'; + + const result = await new ScanWorkspaceCleanupPolicy().cleanup('/tmp/private/repo-checkout'); + + expect(fs.rm).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + attempted: false, + preserved: true, + succeeded: null, + reason: 'DEBUG_PRESERVE', + }); + expect(JSON.stringify(result)).not.toContain('/tmp/private/repo-checkout'); + }); + + it('reports cleanup failure without exposing the raw workspace path', async () => { + (fs.rm as jest.Mock).mockRejectedValue(new Error('permission denied')); + + const result = await new ScanWorkspaceCleanupPolicy().cleanup('/tmp/private/repo-checkout'); + + expect(result).toMatchObject({ + attempted: true, + preserved: false, + succeeded: false, + reason: 'CLEANUP_FAILED', + errorMessage: 'permission denied', + }); + expect(JSON.stringify(result)).not.toContain('/tmp/private/repo-checkout'); + }); + + it('does not attempt cleanup when no workspace was created', async () => { + const result = await new ScanWorkspaceCleanupPolicy().cleanup(undefined); + + expect(fs.rm).not.toHaveBeenCalled(); + expect(result).toEqual({ + attempted: false, + preserved: false, + succeeded: null, + reason: 'NO_WORKSPACE', + }); + }); +}); diff --git a/packages/backend-runtime/src/scanner/application/scan-workspace-cleanup.policy.ts b/packages/backend-runtime/src/scanner/application/scan-workspace-cleanup.policy.ts new file mode 100644 index 00000000..f0ca223d --- /dev/null +++ b/packages/backend-runtime/src/scanner/application/scan-workspace-cleanup.policy.ts @@ -0,0 +1,64 @@ +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs/promises'; + +export type ScanWorkspaceCleanupOutcome = { + attempted: boolean; + preserved: boolean; + succeeded: boolean | null; + workspaceId?: string; + reason: 'NO_WORKSPACE' | 'DEBUG_PRESERVE' | 'CLEANED' | 'CLEANUP_FAILED'; + errorMessage?: string; +}; + +const PRESERVE_SCAN_WORKSPACE_ENV = 'BA_HELPER_PRESERVE_SCAN_WORKSPACE'; + +const isEnabled = (value: string | undefined): boolean => + value === '1' || value?.toLowerCase() === 'true'; + +export class ScanWorkspaceCleanupPolicy { + async cleanup(workspacePath?: string): Promise { + if (!workspacePath) { + return { + attempted: false, + preserved: false, + succeeded: null, + reason: 'NO_WORKSPACE', + }; + } + + const workspaceId = this.hashWorkspacePath(workspacePath); + if (isEnabled(process.env[PRESERVE_SCAN_WORKSPACE_ENV])) { + return { + attempted: false, + preserved: true, + succeeded: null, + workspaceId, + reason: 'DEBUG_PRESERVE', + }; + } + + try { + await fs.rm(workspacePath, { recursive: true, force: true }); + return { + attempted: true, + preserved: false, + succeeded: true, + workspaceId, + reason: 'CLEANED', + }; + } catch (error) { + return { + attempted: true, + preserved: false, + succeeded: false, + workspaceId, + reason: 'CLEANUP_FAILED', + errorMessage: error instanceof Error ? error.message : 'Unknown cleanup error', + }; + } + } + + private hashWorkspacePath(workspacePath: string): string { + return `sha256:${createHash('sha256').update(workspacePath).digest('hex').slice(0, 16)}`; + } +} diff --git a/packages/backend-runtime/src/scanner/domain/scan-job.policy.ts b/packages/backend-runtime/src/scanner/domain/scan-job.policy.ts new file mode 100644 index 00000000..df45e275 --- /dev/null +++ b/packages/backend-runtime/src/scanner/domain/scan-job.policy.ts @@ -0,0 +1,17 @@ +import { AppError } from '@ba-helper/shared'; + +export const ScanJobPolicy = { + validateRef: (ref?: string) => { + if (!ref) return; + if (ref.includes(' ') || ref.includes('..') || ref.includes('~')) { + throw new AppError('INVALID_REPOSITORY_REF', 'Repository ref is invalid.'); + } + }, + validateIdempotentRetry: (existingJobStatus: string) => { + if (existingJobStatus === 'COMPLETED' || existingJobStatus === 'FAILED') { + return { canReuse: true }; + } + // If it is QUEUED or PROCESSING, we also return the existing one + return { canReuse: true }; + }, +}; diff --git a/packages/backend-runtime/src/scanner/infrastructure/scan-job.repository.ts b/packages/backend-runtime/src/scanner/infrastructure/scan-job.repository.ts new file mode 100644 index 00000000..7e7df18b --- /dev/null +++ b/packages/backend-runtime/src/scanner/infrastructure/scan-job.repository.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { ScanJobStatus, ScanJobStage } from '@prisma/client';; + +type ScanJobPrismaClient = PrismaService | Prisma.TransactionClient; + +@Injectable() +export class ScanJobRepository { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string) { + return this.prisma.scanJob.findUnique({ + where: { id }, + include: { + repository: true, + snapshot: { + include: { repository: true }, + }, + }, + }); + } + + async findByRepositoryAndRequestKey(params: { + repositoryId: string; + requestKey: string; + }) { + return this.prisma.scanJob.findUnique({ + where: { + repositoryId_requestKey: { + repositoryId: params.repositoryId, + requestKey: params.requestKey, + }, + }, + }); + } + + async createQueued(params: { + repositoryId: string; + requestKey: string; + requestedRef?: string; + }) { + return this.prisma.scanJob.create({ + data: { + repositoryId: params.repositoryId, + requestKey: params.requestKey, + requestedRef: params.requestedRef, + status: ScanJobStatus.QUEUED, + stage: ScanJobStage.WAITING, + progress: 0, + }, + }); + } + + async updateState(params: { + jobId: string; + status: ScanJobStatus; + stage: ScanJobStage; + progress: number; + errorCode?: string | null; + errorMessage?: string; + }, client: ScanJobPrismaClient = this.prisma) { + return client.scanJob.update({ + where: { id: params.jobId }, + data: { + status: params.status, + stage: params.stage, + progress: params.progress, + errorCode: params.errorCode ?? null, + errorMessage: params.errorMessage ?? null, + }, + }); + } + + async updateDiagnostics( + params: { + jobId: string; + diagnostics: unknown; + }, + client: ScanJobPrismaClient = this.prisma, + ) { + return client.scanJob.update({ + where: { id: params.jobId }, + data: { + diagnostics: params.diagnostics as Prisma.InputJsonValue, + }, + }); + } +} diff --git a/packages/backend-runtime/src/traceability/infrastructure/traceability.repository.ts b/packages/backend-runtime/src/traceability/infrastructure/traceability.repository.ts new file mode 100644 index 00000000..e2908b56 --- /dev/null +++ b/packages/backend-runtime/src/traceability/infrastructure/traceability.repository.ts @@ -0,0 +1,199 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { Prisma } from '@prisma/client';; + +export type TraceabilityLinkWithArtifactAndReviewDecision = Prisma.TraceabilityLinkGetPayload<{ + include: { + artifact: true; + evidenceLinks: { + include: { + evidence: { + include: { + artifact: true; + }; + }; + }; + }; + reviewDecision: true; + }; +}>; + +@Injectable() +export class TraceabilityRepository { + constructor(private readonly prisma: PrismaService) {} + + async listByAnalysis(impactAnalysisId: string): Promise { + return this.prisma.traceabilityLink.findMany({ + where: { impactAnalysisId }, + include: { + evidenceLinks: { + include: { + evidence: { + include: { + artifact: true, + }, + }, + }, + }, + artifact: true, + reviewDecision: true, + }, + }); + } + + async findById(linkId: string) { + return this.prisma.traceabilityLink.findUnique({ + where: { id: linkId }, + include: { + impactAnalysis: { + include: { + snapshot: true, + sourceTarget: true, + }, + }, + }, + }); + } + + async updateReviewStatus(params: { + linkId: string; + reviewStatus: 'CONFIRMED' | 'REJECTED'; + }) { + return this.prisma.traceabilityLink.update({ + where: { id: params.linkId }, + data: { reviewStatus: params.reviewStatus }, + }); + } + + async updateReviewStatusIfCurrent(params: { + linkId: string; + reviewStatus: 'CONFIRMED' | 'REJECTED'; + expectedCommitSha: string; + expectedTargetCommitSha: string; + expectedResolvedRefType: 'BRANCH' | 'TAG' | 'COMMIT'; + }) { + return this.prisma.traceabilityLink.updateMany({ + where: { + id: params.linkId, + impactAnalysis: { + snapshot: { + commitSha: params.expectedCommitSha, + }, + sourceTarget: { + resolvedRefType: params.expectedResolvedRefType, + latestObservedCommitSha: params.expectedTargetCommitSha, + }, + }, + }, + data: { reviewStatus: params.reviewStatus }, + }); + } + + async upsertMany( + items: Array<{ + impactAnalysisId: string; + artifactId: string; + linkType: 'AFFECTED' | 'RELATED'; + linkBasis: 'EVIDENCED' | 'INFERRED'; + reviewStatus: 'NEEDS_REVIEW' | 'CONFIRMED' | 'REJECTED'; + confidence: number | null; + retrievalMetadata?: any; + }>, + ) { + if (items.length === 0) { + return []; + } + + await this.prisma.traceabilityLink.createMany({ + data: items.map((item) => ({ + impactAnalysisId: item.impactAnalysisId, + artifactId: item.artifactId, + linkType: item.linkType, + linkBasis: item.linkBasis, + reviewStatus: item.reviewStatus, + confidence: item.confidence, + retrievalMetadata: item.retrievalMetadata ?? undefined, + })), + skipDuplicates: true, + }); + + return this.prisma.traceabilityLink.findMany({ + where: { + impactAnalysisId: items[0].impactAnalysisId, + artifactId: { in: items.map((item) => item.artifactId) }, + linkType: { in: items.map((item) => item.linkType) }, + }, + }); + } + + async linkEvidence(params: { linkId: string; evidenceIds: string[] }) { + if (params.evidenceIds.length === 0) { + return []; + } + + await this.prisma.traceabilityEvidence.createMany({ + data: params.evidenceIds.map((evidenceId) => ({ + traceabilityLinkId: params.linkId, + evidenceId, + })), + skipDuplicates: true, + }); + + return this.prisma.traceabilityEvidence.findMany({ + where: { traceabilityLinkId: params.linkId }, + }); + } + async deleteReviewDecision(linkId: string) { + return this.prisma.$transaction(async (tx) => { + const deleted = await tx.traceabilityReviewDecision.delete({ + where: { traceabilityLinkId: linkId }, + }); + await tx.traceabilityLink.update({ + where: { id: linkId }, + data: { reviewStatus: 'NEEDS_REVIEW' }, + }); + return deleted; + }); + } + + async upsertReviewDecision(params: { + linkId: string; + analysisId: string; + decision: 'ACCEPTED' | 'REJECTED' | 'NEEDS_REVIEW' | 'NEEDS_MORE_EVIDENCE'; + note?: string | null; + reviewedByUserId?: string | null; + }) { + const reviewStatus = toTraceabilityReviewStatus(params.decision); + return this.prisma.$transaction(async (tx) => { + await tx.traceabilityLink.update({ + where: { id: params.linkId }, + data: { reviewStatus }, + }); + + return tx.traceabilityReviewDecision.upsert({ + where: { traceabilityLinkId: params.linkId }, + create: { + traceabilityLinkId: params.linkId, + analysisId: params.analysisId, + decision: params.decision, + note: params.note, + reviewedByUserId: params.reviewedByUserId, + }, + update: { + decision: params.decision, + note: params.note, + reviewedByUserId: params.reviewedByUserId, + reviewedAt: new Date(), + }, + }); + }); + } +} + +function toTraceabilityReviewStatus( + decision: 'ACCEPTED' | 'REJECTED' | 'NEEDS_REVIEW' | 'NEEDS_MORE_EVIDENCE', +) { + if (decision === 'ACCEPTED') return 'CONFIRMED'; + if (decision === 'REJECTED') return 'REJECTED'; + return 'NEEDS_REVIEW'; +} diff --git a/packages/backend-runtime/tsconfig.json b/packages/backend-runtime/tsconfig.json new file mode 100644 index 00000000..2f1f721d --- /dev/null +++ b/packages/backend-runtime/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.ts"] +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 2fe2a0e0..bb07c12f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -28,3 +28,4 @@ export * from './repository-drift.contract'; export * from './domain-pack.contract'; export * from './review.contract'; export * from './event-log.contract'; +export * from './localization.contract'; diff --git a/packages/contracts/src/localization.contract.ts b/packages/contracts/src/localization.contract.ts new file mode 100644 index 00000000..0df5fc3e --- /dev/null +++ b/packages/contracts/src/localization.contract.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +export const supportedReportLocales = ['en', 'vi-VN', 'ja-JP'] as const; +export type SupportedReportLocale = typeof supportedReportLocales[number]; + +export const localizationStatusSchema = z.enum(['QUEUED', 'COMPLETED', 'FAILED']); +export type LocalizationStatus = z.infer; + +export const localizedReportArtifactSchema = z.object({ + id: z.string().uuid(), + sourceDocumentId: z.string().uuid(), + locale: z.enum(supportedReportLocales), + sourceLocale: z.string(), + localizationStatus: localizationStatusSchema, + contentMarkdown: z.string().nullable(), + sourceContentHash: z.string(), + + glossaryVersion: z.string().nullable(), + provider: z.string().nullable(), + model: z.string().nullable(), + translationPromptVersion: z.string().nullable(), + structuralValidatorVersion: z.string().nullable(), + fieldPolicyVersion: z.string().nullable(), + errorCode: z.string().nullable(), + + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export type LocalizedReportArtifact = z.infer; + +export const generateLocalizedReportRequestSchema = z.object({ + locale: z.enum(supportedReportLocales), +}); + +export type GenerateLocalizedReportRequest = z.infer; + +export const localizationStatusResponseSchema = z.object({ + status: z.enum(['READY', 'NOT_TRANSLATED', 'QUEUED', 'FAILED', 'OUT_OF_SYNC', 'SOURCE_NOT_READY']), +}); + +export type LocalizationStatusResponse = z.infer; diff --git a/packages/shared/src/errors/app-error.ts b/packages/shared/src/errors/app-error.ts index 5bf774c0..a3344f05 100644 --- a/packages/shared/src/errors/app-error.ts +++ b/packages/shared/src/errors/app-error.ts @@ -108,7 +108,11 @@ export type AppErrorCode = | 'INVALID_IDEMPOTENCY_KEY' | 'MISSING_IDEMPOTENCY_KEY' | 'REVIEW_COMPLETION_REQUIRED' - | 'SNAPSHOT_DIAGNOSTICS_INCOMPLETE'; + | 'SNAPSHOT_DIAGNOSTICS_INCOMPLETE' + | 'LOCALIZED_REPORT_NOT_READY' + | 'LOCALIZED_REPORT_FAILED' + | 'LOCALIZED_REPORT_OUT_OF_SYNC' + | 'UNSUPPORTED_LOCALE'; export class AppError extends Error { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36a3c85f..fc00f57e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@ba-helper/backend-runtime': + specifier: workspace:* + version: link:packages/backend-runtime '@ba-helper/contracts': specifier: workspace:* version: link:packages/contracts @@ -123,6 +126,9 @@ importers: '@ba-helper/application': specifier: workspace:* version: link:../../packages/application + '@ba-helper/backend-runtime': + specifier: workspace:* + version: link:../../packages/backend-runtime '@ba-helper/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -424,6 +430,55 @@ importers: specifier: ^5 version: 5.4.5 + packages/backend-runtime: + dependencies: + '@anthropic-ai/sdk': + specifier: ^0.99.0 + version: 0.99.0(zod@3.23.8) + '@ba-helper/analyzer': + specifier: workspace:* + version: link:../analyzer + '@ba-helper/application': + specifier: workspace:* + version: link:../application + '@ba-helper/contracts': + specifier: workspace:* + version: link:../contracts + '@ba-helper/shared': + specifier: workspace:* + version: link:../shared + '@google/generative-ai': + specifier: ^0.24.1 + version: 0.24.1 + '@nestjs/bullmq': + specifier: ^10.1.1 + version: 10.2.3(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24))(reflect-metadata@0.2.2)(rxjs@7.8.2))(bullmq@5.77.6) + '@prisma/adapter-pg': + specifier: 7.8.0 + version: 7.8.0 + bullmq: + specifier: ^5.7.8 + version: 5.77.6 + openai: + specifier: ^6.39.0 + version: 6.39.0(ws@8.21.0)(zod@3.23.8) + devDependencies: + '@nestjs/common': + specifier: 10.3.9 + version: 10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: 10.3.9 + version: 10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@prisma/client': + specifier: 7.8.0 + version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.4.5))(typescript@5.4.5) + typescript: + specifier: ^5.4.5 + version: 5.4.5 + zod: + specifier: ^3.23.8 + version: 3.23.8 + packages/contracts: dependencies: zod: @@ -1453,12 +1508,25 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nestjs/bull-shared@10.2.3': + resolution: {integrity: sha512-XcgAjNOgq6b5DVCytxhR5BKiwWo7hsusVeyE7sfFnlXRHeEtIuC2hYWBr/ZAtvL/RH0/O0tqtq0rVl972nbhJw==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/bull-shared@11.0.4': resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==} peerDependencies: '@nestjs/common': ^10.0.0 || ^11.0.0 '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/bullmq@10.2.3': + resolution: {integrity: sha512-Lo4W5kWD61/246Y6H70RNgV73ybfRbZyKKS4CBRDaMELpxgt89O+EgYZUB4pdoNrWH16rKcaT0AoVsB/iDztKg==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0 + '@nestjs/bullmq@11.0.4': resolution: {integrity: sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==} peerDependencies: @@ -1466,6 +1534,19 @@ packages: '@nestjs/core': ^10.0.0 || ^11.0.0 bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0 + '@nestjs/common@10.3.9': + resolution: {integrity: sha512-JAQONPagMa+sy/fcIqh/Hn3rkYQ9pQM51vXCFNOM5ujefxUVqn3gwFRMN8Y1+MxdUHipV+8daEj2jEm0IqJzOA==} + peerDependencies: + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/common@11.1.24': resolution: {integrity: sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==} peerDependencies: @@ -1479,6 +1560,23 @@ packages: class-validator: optional: true + '@nestjs/core@10.3.9': + resolution: {integrity: sha512-NzZUfWAmaf8sqhhwoRA+CuqxQe+P4Rz8PZp5U7CdCbjyeB9ZVGcBkihcJC9wMdtiOWHRndB2J8zRfs5w06jK3w==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/microservices': ^10.0.0 + '@nestjs/platform-express': ^10.0.0 + '@nestjs/websockets': ^10.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + '@nestjs/core@11.1.24': resolution: {integrity: sha512-K4bzT+lEdd0Hhcsw3jtk56QAW6s6skK3ViN7hIROSN0kUf4ROwWEAKopJID6yhPQxB45kDtP2wEcjzE8171J3g==} engines: {node: '>= 20'} @@ -1648,6 +1746,11 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true + '@nuxtjs/opencollective@0.3.2': + resolution: {integrity: sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -3266,6 +3369,9 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + consola@2.15.3: + resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -5764,6 +5870,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@3.2.0: + resolution: {integrity: sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -6669,6 +6778,9 @@ packages: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} + tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -8160,12 +8272,26 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@nestjs/bull-shared@10.2.3(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24))(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24))(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)': dependencies: '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 + '@nestjs/bullmq@10.2.3(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24))(reflect-metadata@0.2.2)(rxjs@7.8.2))(bullmq@5.77.6)': + dependencies: + '@nestjs/bull-shared': 10.2.3(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24))(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/common': 10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24))(reflect-metadata@0.2.2)(rxjs@7.8.2) + bullmq: 5.77.6 + tslib: 2.8.1 + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(bullmq@5.77.6)': dependencies: '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) @@ -8174,6 +8300,14 @@ snapshots: bullmq: 5.77.6 tslib: 2.8.1 + '@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + iterare: 1.2.1 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.6.2 + uid: 2.0.2 + '@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.4 @@ -8186,6 +8320,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/core@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxtjs/opencollective': 0.3.2 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 3.2.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.6.2 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + transitivePeerDependencies: + - encoding + '@nestjs/core@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -8304,6 +8454,14 @@ snapshots: dependencies: consola: 3.4.2 + '@nuxtjs/opencollective@0.3.2': + dependencies: + chalk: 4.1.2 + consola: 2.15.3 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/deferred-promise@3.0.0': {} @@ -9977,6 +10135,8 @@ snapshots: confbox@0.2.4: {} + consola@2.15.3: {} + consola@3.4.2: {} content-disposition@1.1.0: {} @@ -13146,6 +13306,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + path-to-regexp@3.2.0: {} + path-to-regexp@6.3.0: {} path-to-regexp@8.4.2: {} @@ -14193,6 +14355,8 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@2.6.2: {} + tslib@2.8.1: {} tsx@4.22.3: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 41ce5e7a..089d4c54 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - "tests" allowBuilds: '@nestjs/core': false + '@prisma/client': set this to true or false '@prisma/engines': false '@scarf/scarf': false '@swc/core': false diff --git a/scripts/verify-domain-packs.ts b/scripts/verify-domain-packs.ts index b1ddb328..eab6c832 100644 --- a/scripts/verify-domain-packs.ts +++ b/scripts/verify-domain-packs.ts @@ -1,7 +1,7 @@ import 'tsconfig-paths/register'; import { resolve } from 'node:path'; -import { BUILT_IN_DOMAIN_PACK_CATALOG } from '../apps/api/src/modules/domain-pack/application/domain-pack.catalog'; -import { validateDomainPackCatalog } from '../apps/api/src/modules/domain-pack/application/domain-pack.governance'; +import { BUILT_IN_DOMAIN_PACK_CATALOG } from '../packages/backend-runtime/src/domain-pack/application/domain-pack.catalog'; +import { validateDomainPackCatalog } from '../packages/backend-runtime/src/domain-pack/application/domain-pack.governance'; const result = validateDomainPackCatalog(BUILT_IN_DOMAIN_PACK_CATALOG, { glossaryRoot: resolve(process.cwd(), 'packages/domain-packs'), diff --git a/tests/api/impact-analysis.partial.spec.ts b/tests/api/impact-analysis.partial.spec.ts index 46ad79e3..9b17a94d 100644 --- a/tests/api/impact-analysis.partial.spec.ts +++ b/tests/api/impact-analysis.partial.spec.ts @@ -1,5 +1,5 @@ import { CreateImpactAnalysisUseCase } from '../../apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase'; -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; class StubImpactRepo { created: Array<{ coverageWarning?: string | null; acceptedPartialCoverage: boolean }> = []; diff --git a/tests/benchmark/vector-gap-benchmark.spec.ts b/tests/benchmark/vector-gap-benchmark.spec.ts index 4568e6e0..4ac25584 100644 --- a/tests/benchmark/vector-gap-benchmark.spec.ts +++ b/tests/benchmark/vector-gap-benchmark.spec.ts @@ -5,8 +5,8 @@ import { scanFixture, buildGraph } from '../../packages/analyzer/src'; import { AppModule } from '../../apps/api/src/app.module'; import { PrismaService } from '../../apps/api/src/modules/prisma/prisma.service'; import { DependencyEdgeType } from '@prisma/client'; -import { HybridRetrievalService } from '../../apps/api/src/modules/retrieval/application/hybrid-retrieval.service'; -import { EmbeddingChunkRepository } from '../../apps/api/src/modules/embedding/infrastructure/embedding-chunk.repository'; +import { HybridRetrievalService } from '@ba-helper/backend-runtime'; +import { EmbeddingChunkRepository } from '@ba-helper/backend-runtime'; import { resetDatabase } from '../../apps/api/test/e2e/helpers/reset-db'; import { prepareIsolatedTestEnv } from '../../apps/api/test/e2e/helpers/prepare-test-env'; import * as crypto from 'crypto'; diff --git a/tests/domain-pack/concept-matching.spec.ts b/tests/domain-pack/concept-matching.spec.ts index 4524646c..b1968492 100644 --- a/tests/domain-pack/concept-matching.spec.ts +++ b/tests/domain-pack/concept-matching.spec.ts @@ -1,7 +1,7 @@ -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; -import { BookingDomainPack } from '../../apps/api/src/modules/domain-pack/packs/booking.v0.1.0'; -import { HealthcareDomainPack } from '../../apps/api/src/modules/domain-pack/packs/healthcare.v0.1.0'; -import { RentalDomainPack } from '../../apps/api/src/modules/domain-pack/packs/rental.v0.1.0'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; +import { BookingDomainPack } from '@ba-helper/backend-runtime'; +import { HealthcareDomainPack } from '@ba-helper/backend-runtime'; +import { RentalDomainPack } from '@ba-helper/backend-runtime'; describe('Domain Pack Concept Matching', () => { let registry: DomainPackRegistry; diff --git a/tests/embedding/embed-snapshot-artifacts.spec.ts b/tests/embedding/embed-snapshot-artifacts.spec.ts index 64a9d4e4..4f6f9db1 100644 --- a/tests/embedding/embed-snapshot-artifacts.spec.ts +++ b/tests/embedding/embed-snapshot-artifacts.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, jest } from '@jest/globals'; import { EmbedSnapshotArtifactsUseCase } from '@ba-helper/application'; -import { FakeEmbeddingProvider } from '../../apps/api/src/modules/embedding/infrastructure/fake-embedding.provider'; +import { FakeEmbeddingProvider } from '@ba-helper/backend-runtime'; import { ArtifactChunkBuilder, CHUNK_BUILDER_VERSION } from '@ba-helper/application'; import { createHash } from 'node:crypto'; diff --git a/tests/embedding/rag-isolation.spec.ts b/tests/embedding/rag-isolation.spec.ts index f33922ba..ec256eab 100644 --- a/tests/embedding/rag-isolation.spec.ts +++ b/tests/embedding/rag-isolation.spec.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach, jest } from '@jest/globals'; -import { EmbeddingChunkRepository } from '../../apps/api/src/modules/embedding/infrastructure/embedding-chunk.repository'; +import { EmbeddingChunkRepository } from '@ba-helper/backend-runtime'; import { EmbedSnapshotArtifactsUseCase } from '@ba-helper/application'; import { EmbeddingChunkRepositoryPort } from '@ba-helper/application'; import { ArtifactChunkBuilder } from '@ba-helper/application'; -import { FakeEmbeddingProvider } from '../../apps/api/src/modules/embedding/infrastructure/fake-embedding.provider'; +import { FakeEmbeddingProvider } from '@ba-helper/backend-runtime'; import { createHash } from 'node:crypto'; // ─── Shared constants ──────────────────────────────────────────────────────── diff --git a/tests/evaluation/adapters/hybrid-retrieval.adapter.ts b/tests/evaluation/adapters/hybrid-retrieval.adapter.ts index ac5d7083..89cf8758 100644 --- a/tests/evaluation/adapters/hybrid-retrieval.adapter.ts +++ b/tests/evaluation/adapters/hybrid-retrieval.adapter.ts @@ -5,8 +5,8 @@ import { EvaluationCase, NormalizedEvaluationResult } from '../evaluation-types' import { SafeFileEnumerator } from '../../../packages/analyzer/src/scanner/core/safe-file-enumerator'; import { scanProject } from '../../../packages/analyzer/src/scanner/scanner'; import { PrismaService } from '../../../apps/api/src/modules/prisma/prisma.service'; -import { HybridRetrievalService } from '../../../apps/api/src/modules/retrieval/application/hybrid-retrieval.service'; -import { DomainPackRegistry } from '../../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; +import { HybridRetrievalService } from '@ba-helper/backend-runtime'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; export class HybridRetrievalEvaluationAdapter implements EvaluationAdapter { private readonly fixtureRoot = path.join(process.cwd(), 'tests/fixtures'); diff --git a/tests/evaluation/booking-domain-stable.spec.ts b/tests/evaluation/booking-domain-stable.spec.ts index d05688dd..0d33c745 100644 --- a/tests/evaluation/booking-domain-stable.spec.ts +++ b/tests/evaluation/booking-domain-stable.spec.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; -import { BookingDomainPack } from '../../apps/api/src/modules/domain-pack/packs/booking.v0.1.0'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; +import { BookingDomainPack } from '@ba-helper/backend-runtime'; import { evaluationCaseSchema } from './evaluation-types'; import { bookingStableEvaluationCases } from './cases'; diff --git a/tests/evaluation/domain-pack-cross-domain-summary.spec.ts b/tests/evaluation/domain-pack-cross-domain-summary.spec.ts index 3a18d808..37a3ab89 100644 --- a/tests/evaluation/domain-pack-cross-domain-summary.spec.ts +++ b/tests/evaluation/domain-pack-cross-domain-summary.spec.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; import { bookingStableEvaluationCases, ecommercePartialEvaluationCases, diff --git a/tests/evaluation/ecommerce-domain-partial.spec.ts b/tests/evaluation/ecommerce-domain-partial.spec.ts index 0af52cc3..ee53618b 100644 --- a/tests/evaluation/ecommerce-domain-partial.spec.ts +++ b/tests/evaluation/ecommerce-domain-partial.spec.ts @@ -1,6 +1,6 @@ import { domainPackAppliedDiagnosticPayloadSchema } from '@ba-helper/contracts'; -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; -import { EcommerceDomainPack } from '../../apps/api/src/modules/domain-pack/packs/ecommerce.v0.1.0'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; +import { EcommerceDomainPack } from '@ba-helper/backend-runtime'; import { SafeFileEnumerator } from '../../packages/analyzer/src/scanner/core/safe-file-enumerator'; import { scanProject } from '../../packages/analyzer/src/scanner/scanner'; import { EvaluationAdapter, EvaluationRunner } from './evaluation-runner'; diff --git a/tests/evaluation/evaluation-runner.ts b/tests/evaluation/evaluation-runner.ts index 34625299..1c3eeca5 100644 --- a/tests/evaluation/evaluation-runner.ts +++ b/tests/evaluation/evaluation-runner.ts @@ -9,7 +9,7 @@ import { computeConceptPrecision } from './evaluation-scoring'; -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; export interface EvaluationAdapter { evaluateCase(evalCase: EvaluationCase): Promise; diff --git a/tests/evaluation/general-domain-fallback.spec.ts b/tests/evaluation/general-domain-fallback.spec.ts index beb126ce..65957ba5 100644 --- a/tests/evaluation/general-domain-fallback.spec.ts +++ b/tests/evaluation/general-domain-fallback.spec.ts @@ -1,8 +1,8 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { domainPackAppliedDiagnosticPayloadSchema } from '@ba-helper/contracts'; -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; -import { BookingDomainPack } from '../../apps/api/src/modules/domain-pack/packs/booking.v0.1.0'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; +import { BookingDomainPack } from '@ba-helper/backend-runtime'; import { EvaluationAdapter, EvaluationRunner } from './evaluation-runner'; import { EvaluationCase, NormalizedEvaluationResult, evaluationCaseSchema } from './evaluation-types'; import { generalFallbackEvaluationCases } from './cases'; diff --git a/tests/evaluation/healthcare-domain-partial.spec.ts b/tests/evaluation/healthcare-domain-partial.spec.ts index ea39033c..64b31488 100644 --- a/tests/evaluation/healthcare-domain-partial.spec.ts +++ b/tests/evaluation/healthcare-domain-partial.spec.ts @@ -1,6 +1,6 @@ import { domainPackAppliedDiagnosticPayloadSchema } from '@ba-helper/contracts'; -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; -import { HealthcareDomainPack } from '../../apps/api/src/modules/domain-pack/packs/healthcare.v0.1.0'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; +import { HealthcareDomainPack } from '@ba-helper/backend-runtime'; import { EvaluationAdapter, EvaluationRunner } from './evaluation-runner'; import { EvaluationCase, NormalizedEvaluationResult, evaluationCaseSchema } from './evaluation-types'; import { healthcarePartialEvaluationCases } from './cases'; diff --git a/tests/evaluation/hybrid-retrieval.smoke.spec.ts b/tests/evaluation/hybrid-retrieval.smoke.spec.ts index a168de84..95a7969c 100644 --- a/tests/evaluation/hybrid-retrieval.smoke.spec.ts +++ b/tests/evaluation/hybrid-retrieval.smoke.spec.ts @@ -1,14 +1,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AppModule } from '../../apps/api/src/app.module'; import { PrismaService } from '../../apps/api/src/modules/prisma/prisma.service'; -import { HybridRetrievalService } from '../../apps/api/src/modules/retrieval/application/hybrid-retrieval.service'; -import { EmbeddingChunkRepository } from '../../apps/api/src/modules/embedding/infrastructure/embedding-chunk.repository'; +import { HybridRetrievalService } from '@ba-helper/backend-runtime'; +import { EmbeddingChunkRepository } from '@ba-helper/backend-runtime'; import { resetDatabase } from '../../apps/api/test/e2e/helpers/reset-db'; import { prepareIsolatedTestEnv } from '../../apps/api/test/e2e/helpers/prepare-test-env'; import { ALL_EVALUATION_CASES } from './cases'; import { EvaluationRunner } from './evaluation-runner'; import { HybridRetrievalEvaluationAdapter } from './adapters/hybrid-retrieval.adapter'; -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; // @ts-ignore import * as dotenv from 'dotenv'; import { resolve } from 'node:path'; diff --git a/tests/evaluation/rental-domain-partial.spec.ts b/tests/evaluation/rental-domain-partial.spec.ts index da5b3224..38afae06 100644 --- a/tests/evaluation/rental-domain-partial.spec.ts +++ b/tests/evaluation/rental-domain-partial.spec.ts @@ -1,6 +1,6 @@ import { domainPackAppliedDiagnosticPayloadSchema } from '@ba-helper/contracts'; -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; -import { RentalDomainPack } from '../../apps/api/src/modules/domain-pack/packs/rental.v0.1.0'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; +import { RentalDomainPack } from '@ba-helper/backend-runtime'; import { SafeFileEnumerator } from '../../packages/analyzer/src/scanner/core/safe-file-enumerator'; import { scanProject } from '../../packages/analyzer/src/scanner/scanner'; import { EvaluationAdapter, EvaluationRunner } from './evaluation-runner'; diff --git a/tests/impact-analysis/create-impact-analysis-queue.spec.ts b/tests/impact-analysis/create-impact-analysis-queue.spec.ts index dbd69955..6302bcc0 100644 --- a/tests/impact-analysis/create-impact-analysis-queue.spec.ts +++ b/tests/impact-analysis/create-impact-analysis-queue.spec.ts @@ -1,5 +1,5 @@ import { CreateImpactAnalysisUseCase } from '../../apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase'; -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; class StubImpactRepo { findByRequestKey = async () => null; diff --git a/tests/impact-analysis/domain-quality-evaluation.spec.ts b/tests/impact-analysis/domain-quality-evaluation.spec.ts index 482b8572..d67ca81c 100644 --- a/tests/impact-analysis/domain-quality-evaluation.spec.ts +++ b/tests/impact-analysis/domain-quality-evaluation.spec.ts @@ -1,15 +1,15 @@ -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; import { HybridRetrievalEvaluationAdapter } from '../evaluation/adapters/hybrid-retrieval.adapter'; import { EvaluationRunner } from '../evaluation/evaluation-runner'; import { PrismaService } from '../../apps/api/src/modules/prisma/prisma.service'; -import { HybridRetrievalService } from '../../apps/api/src/modules/retrieval/application/hybrid-retrieval.service'; +import { HybridRetrievalService } from '@ba-helper/backend-runtime'; import { Test, TestingModule } from '@nestjs/testing'; import { AppModule } from '../../apps/api/src/app.module'; import { prepareIsolatedTestEnv } from '../../apps/api/test/e2e/helpers/prepare-test-env'; import { resetDatabase } from '../../apps/api/test/e2e/helpers/reset-db'; import { ALL_EVALUATION_CASES } from '../evaluation/cases'; import { AppError } from '@ba-helper/shared'; -import { EmbeddingChunkRepository } from '../../apps/api/src/modules/embedding/infrastructure/embedding-chunk.repository'; +import { EmbeddingChunkRepository } from '@ba-helper/backend-runtime'; // @ts-ignore import * as dotenv from 'dotenv'; import { resolve } from 'node:path'; diff --git a/tests/impact-analysis/impact-analysis-fixture-output.spec.ts b/tests/impact-analysis/impact-analysis-fixture-output.spec.ts index 50615b0d..313bc202 100644 --- a/tests/impact-analysis/impact-analysis-fixture-output.spec.ts +++ b/tests/impact-analysis/impact-analysis-fixture-output.spec.ts @@ -7,7 +7,7 @@ import { ImpactAiReasoningStep, } from '@ba-helper/application'; import { FakeLlmProvider } from '../../apps/api/src/modules/ai/infrastructure/fake-ai.provider'; -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; class StubImpactRepo { findById = async () => ({ diff --git a/tests/impact-analysis/run-impact-analysis.spec.ts b/tests/impact-analysis/run-impact-analysis.spec.ts index dad9cbf7..f3e934c1 100644 --- a/tests/impact-analysis/run-impact-analysis.spec.ts +++ b/tests/impact-analysis/run-impact-analysis.spec.ts @@ -6,7 +6,7 @@ import { } from '@ba-helper/application'; import { AppError } from '@ba-helper/shared'; import { FakeLlmProvider } from '../../apps/api/src/modules/ai/infrastructure/fake-ai.provider'; -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; type StubArtifact = { id: string; diff --git a/tests/retrieval/hybrid-retrieval.spec.ts b/tests/retrieval/hybrid-retrieval.spec.ts index 91b81a1c..f9836551 100644 --- a/tests/retrieval/hybrid-retrieval.spec.ts +++ b/tests/retrieval/hybrid-retrieval.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, jest } from '@jest/globals'; -import { HybridRetrievalService } from '../../apps/api/src/modules/retrieval/application/hybrid-retrieval.service'; -import { FakeEmbeddingProvider } from '../../apps/api/src/modules/embedding/infrastructure/fake-embedding.provider'; -import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; +import { HybridRetrievalService } from '@ba-helper/backend-runtime'; +import { FakeEmbeddingProvider } from '@ba-helper/backend-runtime'; +import { DomainPackRegistry } from '@ba-helper/backend-runtime'; describe('HybridRetrievalService', () => { let service: HybridRetrievalService; diff --git a/tsconfig.base.json b/tsconfig.base.json index 11282093..379c216e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,7 +8,10 @@ "@ba-helper/contracts": ["packages/contracts/src/index.ts"], "@ba-helper/shared": ["packages/shared/src/index.ts"], "@ba-helper/analyzer": ["packages/analyzer/src/index.ts"], - "@ba-helper/application": ["packages/application/src/index.ts"] + "@ba-helper/application": ["packages/application/src/index.ts"], + "@ba-helper/backend-runtime": ["packages/backend-runtime/src/index.ts"], + "@ba-helper/backend-runtime/*": ["packages/backend-runtime/src/*"], + "@prisma/client": ["apps/api/node_modules/.prisma/client/index.d.ts"] }, "strict": true, "experimentalDecorators": true,