diff --git a/.env.example b/.env.example index f3cf11e7..2e41aade 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,103 @@ -DATABASE_URL=postgresql://USER:PASSWORD@localhost:5432/ba_helper +# ========================================== +# Database & Queue +# ========================================== +DATABASE_URL=postgresql://ba_helper:ba_helper@localhost:5432/ba_helper REDIS_URL=redis://localhost:6379 -# Root env is intentionally minimal. App-specific runtime examples live in: -# - apps/api/.env.example -# - apps/web/.env.example +# ========================================== +# Backend App (API & Worker) +# ========================================== +# SECURITY HYGIENE: +# - In Development (NODE_ENV=development), missing or default environment variables may fallback to safe local values (e.g., localhost DB, 'dev-secret'). +# - In Production (NODE_ENV=production), the application is designed to FAIL FAST if critical variables (e.g., JWT_SECRET, DATABASE_URL) are missing or set to weak defaults. + +PORT=3001 +NODE_ENV=development +WORKSPACE_MODE=dev-single-user +ENABLE_DEV_LOGIN=true +JWT_SECRET=replace-with-a-long-random-secret-32-chars-min +CORS_ALLOWED_ORIGINS=http://localhost:3000 +PUBLIC_PREVIEW_MODE=false + +# ========================================== +# Web App (Next.js) +# ========================================== +NEXT_PUBLIC_API_URL=http://localhost:3001 +INTERNAL_API_URL=http://localhost:3001 +NEXTAUTH_SECRET=replace-with-a-long-random-secret-32-chars-min + +# Set to true to use mock data for UI development without backend +# Note: This is strictly forbidden in production. +NEXT_PUBLIC_USE_MOCK_API=false + +# ========================================== +# Public Preview Guard (Basic Auth) +# ========================================== +# Enables Basic Auth middleware to protect public preview deployments. +PREVIEW_AUTH_ENABLED=false +PREVIEW_USERNAME=demo +PREVIEW_PASSWORD=change-me + +# ========================================== +# AI Provider (LLM) +# ========================================== +# Options: fake | google | openai | anthropic | deepseek +# Demo runtime default: google/Gemini for real LLM output. +# Automated tests override this to fake so CI never calls external APIs. +AI_PROVIDER=google + +# Temperature and token limits (apply to all providers unless overridden) +AI_TEMPERATURE=0.2 +AI_MAX_TOKENS=8192 + +# ========================================== +# Google Gemini (AI_PROVIDER=google) +# ========================================== +# Key lookup priority: GOOGLE_API_KEY > GEMINI_API_KEY > GOOGLE_AI_API_KEY +GOOGLE_API_KEY= +GEMINI_API_KEY= +GOOGLE_MODEL=gemini-2.5-flash + +# ========================================== +# OpenAI (AI_PROVIDER=openai) +# ========================================== +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o + +# ========================================== +# Anthropic (AI_PROVIDER=anthropic) +# ========================================== +ANTHROPIC_API_KEY= +ANTHROPIC_MODEL=claude-3-5-sonnet-20241022 + +# ========================================== +# DeepSeek (AI_PROVIDER=deepseek) +# ========================================== +DEEPSEEK_API_KEY= +DEEPSEEK_MODEL=deepseek-chat +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_TEMPERATURE=0 +DEEPSEEK_MAX_TOKENS=4096 + +# ========================================== +# Embedding Provider +# ========================================== +# Options: fake | openai | google +# Demo keeps embeddings local/fake unless you intentionally validate real vector retrieval. +EMBEDDING_PROVIDER=fake +GOOGLE_EMBEDDING_MODEL=gemini-embedding-001 +OPENAI_EMBEDDING_MODEL=text-embedding-3-small + +# ========================================== +# Smoke / E2E +# ========================================== +# Set REAL_PATH_SMOKE=true to run with real vector retrieval + LLM. +# Set GEMINI_API_KEY or GOOGLE_API_KEY before running real LLM smoke. +REAL_PATH_SMOKE=false +SMOKE_ALLOW_DEV_LOGIN_FALLBACK=false +SMOKE_DEV_LOGIN_EMAIL=smoke-admin@ba-helper.local +SMOKE_DEV_LOGIN_ROLE=ADMIN +RUN_REAL_LLM_BENCHMARK=false +SMOKE_API_URL=http://localhost:3001 +SMOKE_POLL_INTERVAL_MS=2000 +SMOKE_REQUEST_TIMEOUT_MS=15000 diff --git a/apps/api/.env.example b/apps/api/.env.example index a4cc49f9..e0b8a921 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,86 +1,4 @@ -# BA Helper API — Environment Variables Reference +# ENVIRONMENT VARIABLES CONSOLIDATED # -# SECURITY HYGIENE: -# - In Development (NODE_ENV=development), missing or default environment variables may fallback to safe local values (e.g., localhost DB, 'dev-secret'). -# - In Production (NODE_ENV=production), the application is designed to FAIL FAST if critical variables (e.g., JWT_SECRET, DATABASE_URL) are missing or set to weak defaults. - -# ========================================== -# Database & Queue -# ========================================== -DATABASE_URL=postgresql://ba_helper:ba_helper@localhost:5432/ba_helper -REDIS_URL=redis://localhost:6379 -WORKSPACE_MODE=dev-single-user -ENABLE_DEV_LOGIN=true -JWT_SECRET=replace-with-a-long-random-secret-32-chars-min -CORS_ALLOWED_ORIGINS=http://localhost:3000 -PUBLIC_PREVIEW_MODE=false - -# ========================================== -# AI Provider (LLM) -# ========================================== -# Options: fake | google | openai | anthropic | deepseek -# Demo runtime default: google/Gemini for real LLM output. -# Automated tests override this to fake so CI never calls external APIs. -AI_PROVIDER=google - -# Temperature and token limits (apply to all providers unless overridden) -AI_TEMPERATURE=0.2 -AI_MAX_TOKENS=8192 - -# ========================================== -# Google Gemini (AI_PROVIDER=google) -# ========================================== -# Key lookup priority: GOOGLE_API_KEY > GEMINI_API_KEY > GOOGLE_AI_API_KEY -GOOGLE_API_KEY= -GEMINI_API_KEY= -GOOGLE_MODEL=gemini-2.5-flash - -# ========================================== -# OpenAI (AI_PROVIDER=openai) -# ========================================== -OPENAI_API_KEY= -OPENAI_MODEL=gpt-4o - -# ========================================== -# Anthropic (AI_PROVIDER=anthropic) -# ========================================== -ANTHROPIC_API_KEY= -ANTHROPIC_MODEL=claude-3-5-sonnet-20241022 - -# ========================================== -# DeepSeek (AI_PROVIDER=deepseek) -# ========================================== -DEEPSEEK_API_KEY= -DEEPSEEK_MODEL=deepseek-chat -DEEPSEEK_BASE_URL=https://api.deepseek.com -DEEPSEEK_TEMPERATURE=0 -DEEPSEEK_MAX_TOKENS=4096 - -# ========================================== -# Embedding Provider -# ========================================== -# Options: fake | openai | google -# Demo keeps embeddings local/fake unless you intentionally validate real vector retrieval. -EMBEDDING_PROVIDER=fake -GOOGLE_EMBEDDING_MODEL=gemini-embedding-001 -OPENAI_EMBEDDING_MODEL=text-embedding-3-small - -# ========================================== -# Smoke / E2E -# ========================================== -# Set REAL_PATH_SMOKE=true to run with real vector retrieval + LLM. -# Set GEMINI_API_KEY or GOOGLE_API_KEY before running real LLM smoke. -REAL_PATH_SMOKE=false -SMOKE_ALLOW_DEV_LOGIN_FALLBACK=false -SMOKE_DEV_LOGIN_EMAIL=smoke-admin@ba-helper.local -SMOKE_DEV_LOGIN_ROLE=ADMIN -RUN_REAL_LLM_BENCHMARK=false -SMOKE_API_URL=http://localhost:3001 -SMOKE_POLL_INTERVAL_MS=2000 -SMOKE_REQUEST_TIMEOUT_MS=15000 - -# ========================================== -# App -# ========================================== -PORT=3001 -NODE_ENV=development +# All environment variables have been consolidated to the project root. +# Please use the `/.env.example` file at the root of the repository to configure this application. diff --git a/apps/api/package.json b/apps/api/package.json index dd2efe6b..5fd002ea 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,16 +5,16 @@ "main": "dist/main.js", "scripts": { "build": "tsc -p tsconfig.json", - "dev": "pnpm exec ts-node -r tsconfig-paths/register --project tsconfig.json src/main.ts", + "dev": "dotenv -e ../../.env -- pnpm exec ts-node -r tsconfig-paths/register --project tsconfig.json src/main.ts", "lint": "echo \"lint api\"", - "smoke:public-github": "tsx src/smoke-e2e.ts", - "smoke:public-github:real-llm": "REAL_LLM_SMOKE=true tsx src/smoke-e2e.ts", - "smoke:public-github:real-path": "REAL_PATH_SMOKE=true tsx src/smoke-e2e.ts", + "smoke:public-github": "dotenv -e ../../.env -- tsx src/smoke-e2e.ts", + "smoke:public-github:real-llm": "REAL_LLM_SMOKE=true dotenv -e ../../.env -- tsx src/smoke-e2e.ts", + "smoke:public-github:real-path": "REAL_PATH_SMOKE=true dotenv -e ../../.env -- tsx src/smoke-e2e.ts", "test": "jest", "typecheck": "tsc --noEmit", - "prisma:generate": "prisma generate", - "prisma:migrate": "prisma migrate dev", - "prisma:studio": "prisma studio" + "prisma:generate": "dotenv -e ../../.env -- prisma generate", + "prisma:migrate": "dotenv -e ../../.env -- prisma migrate dev", + "prisma:studio": "dotenv -e ../../.env -- prisma studio" }, "dependencies": { "@anthropic-ai/sdk": "^0.99.0", diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 1a9f84fa..f49b963e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,5 +1,4 @@ import 'reflect-metadata'; -import 'dotenv/config'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { apiReference } from '@scalar/nestjs-api-reference'; diff --git a/apps/api/src/modules/document/api/document.controller.ts b/apps/api/src/modules/document/api/document.controller.ts index 019fd4dd..0200454a 100644 --- a/apps/api/src/modules/document/api/document.controller.ts +++ b/apps/api/src/modules/document/api/document.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Post, Body, Res } from '@nestjs/common'; +import { Controller, Get, Param, Post, Body, Query, Res } from '@nestjs/common'; import { documentListResponseSchema } from '@ba-helper/contracts'; import { ListDocumentsUseCase } from '../application/queries/list-documents.usecase'; import { GetApprovedReportUseCase } from '../application/get-approved-report.usecase'; @@ -12,6 +12,7 @@ import { reviewedReportSnapshotSchema, finalReviewedReportResponseSchema, documentJobSchema, + localeAwareReportQuerySchema, RequestUser } from '@ba-helper/contracts'; import { DocumentMapper } from './document.mapper'; @@ -153,11 +154,15 @@ export class DocumentController { @Get('/impact-analyses/:analysisId/final-reviewed-report') async getFinalReviewedReportGate( @Param('analysisId') analysisId: string, + @Query() query: unknown, @CurrentUser() actor: RequestUser, ) { await this.permissions.assertCanReadAnalysis(actor, analysisId); + const parsedQuery = localeAwareReportQuerySchema.parse(query ?? {}); - const result = await this.getFinalReviewedReport.execute(analysisId); + const result = await this.getFinalReviewedReport.execute(analysisId, { + locale: parsedQuery.locale, + }); return finalReviewedReportResponseSchema.parse(result); } diff --git a/apps/api/src/modules/document/application/jobs/run-document-job.usecase.ts b/apps/api/src/modules/document/application/jobs/run-document-job.usecase.ts index de268fbc..b21b1ece 100644 --- a/apps/api/src/modules/document/application/jobs/run-document-job.usecase.ts +++ b/apps/api/src/modules/document/application/jobs/run-document-job.usecase.ts @@ -36,7 +36,7 @@ export class RunDocumentJobUseCase { const analysis = await this.prisma.impactAnalysis.findUnique({ where: { id: snapshot.analysisId }, include: { - snapshot: { include: { repository: true } }, + snapshot: { include: { repository: true, profile: true } }, sourceTarget: true, requirementRevision: { include: { requirement: true } }, insights: true, diff --git a/apps/api/src/modules/document/application/markdown-impact-report.types.ts b/apps/api/src/modules/document/application/markdown-impact-report.types.ts index 2968163c..cd6f6194 100644 --- a/apps/api/src/modules/document/application/markdown-impact-report.types.ts +++ b/apps/api/src/modules/document/application/markdown-impact-report.types.ts @@ -2,10 +2,11 @@ import { Prisma, ReviewNote } from '@prisma/client'; import { ClarificationItemDto } from '@ba-helper/contracts'; import { ApprovedReportMetadata } from '../domain/approved-report-metadata'; import { ReportDependencyEdge } from './mermaid-impact-diagram.builder'; +import { ReportLocale } from './render/report-localization'; export type AnalysisSnapshot = Prisma.ImpactAnalysisGetPayload<{ include: { - snapshot: { include: { repository: true } }; + snapshot: { include: { repository: true; profile: true } }; sourceTarget: true; requirementRevision: true; }; @@ -34,6 +35,7 @@ export type TraceabilityLinkWithArtifact = Prisma.TraceabilityLinkGetPayload<{ export type MarkdownReportRenderContext = { analysis: AnalysisSnapshot; + locale: ReportLocale; insights: InsightWithEvidence[]; traceabilityLinks: TraceabilityLinkWithArtifact[]; reviewNotes: ReviewNote[]; 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 9824fc33..ffdbdd8b 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,6 +6,8 @@ describe('GetFinalReviewedReportUseCase', () => { let getReviewCompletionMock: any; let getLatestSnapshotMock: any; let prismaMock: any; + let contextAdapterMock: any; + let reportBuilderMock: any; beforeEach(() => { getReviewCompletionMock = { @@ -21,12 +23,23 @@ describe('GetFinalReviewedReportUseCase', () => { documentJob: { findFirst: jest.fn(), }, + impactAnalysis: { + 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, ); }); @@ -88,6 +101,7 @@ describe('GetFinalReviewedReportUseCase', () => { expect(result).toEqual({ analysisId: 'analysis-123', snapshotId: 'snap-1', + locale: 'en', markdown: '# Generated Document Markdown', createdAt: '2026-01-01T00:00:00.000Z', reviewCompletion: mockCompletion, @@ -122,6 +136,45 @@ describe('GetFinalReviewedReportUseCase', () => { const result = await useCase.execute('analysis-123'); expect(result.markdown).toBe('# Job Document'); + 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 = { + 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); + prismaMock.generatedDocument.findUnique.mockResolvedValue({ + id: 'doc-1', + content: '# English persisted document', + }); + 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' }); + + 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); }); it('throws a document readiness error when the async document job is still queued', async () => { 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 704e4dcb..ed69c6da 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,6 +5,9 @@ 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'; @Injectable() export class GetFinalReviewedReportUseCase { @@ -12,9 +15,15 @@ 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(analysisId: string): Promise { + async execute( + analysisId: string, + params: { locale?: ReportLocale } = {}, + ): Promise { + const locale = params.locale ?? DEFAULT_REPORT_LOCALE; const completion = await this.getReviewCompletion.execute(analysisId); if (!completion.isComplete) { @@ -33,11 +42,12 @@ export class GetFinalReviewedReportUseCase { ); } - const markdown = await this.resolveSnapshotMarkdown(snapshot); + const markdown = await this.resolveSnapshotMarkdown(snapshot, locale); return { analysisId, snapshotId: snapshot.id, + locale, markdown, createdAt: snapshot.createdAt.toISOString(), reviewCompletion: completion, @@ -49,6 +59,20 @@ export class GetFinalReviewedReportUseCase { } private async resolveSnapshotMarkdown(snapshot: { + id: string; + analysisId: string; + approvedDocumentId: string | null; + markdown: string | null; + }, locale: ReportLocale) { + const defaultMarkdown = await this.resolveDefaultSnapshotMarkdown(snapshot); + if (locale === DEFAULT_REPORT_LOCALE) { + return defaultMarkdown; + } + + return this.renderLocalizedSnapshotMarkdown(snapshot, locale); + } + + private async resolveDefaultSnapshotMarkdown(snapshot: { id: string; approvedDocumentId: string | null; markdown: string | null; @@ -98,4 +122,26 @@ export class GetFinalReviewedReportUseCase { { status: 'MISSING' }, ); } + + 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/application/render/__snapshots__/markdown-impact-report.builder.spec.ts.snap b/apps/api/src/modules/document/application/render/__snapshots__/markdown-impact-report.builder.spec.ts.snap index 39f825de..61d05e22 100644 --- a/apps/api/src/modules/document/application/render/__snapshots__/markdown-impact-report.builder.spec.ts.snap +++ b/apps/api/src/modules/document/application/render/__snapshots__/markdown-impact-report.builder.spec.ts.snap @@ -45,16 +45,16 @@ 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 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 -| Area | Artifact | File | Review Status | -|---|---|---|---| -| Service | \`GoldenService\` | \`src/golden.ts\` | Confirmed | +### Service + +- \`GoldenService\` in \`src/golden.ts\` — **Confirmed** ### Reviewer Notes on Impacted Areas @@ -64,9 +64,9 @@ The primary impacted areas are service layers. ### 1. This is a golden claim -**Certainty:** Evidenced -**Reviewer Note:** Reviewed the golden claim -**Reasoning:** Because it is golden +> **Certainty:** Evidenced +> **Reviewer Note:** Reviewed the golden claim +> **Reasoning:** Because it is golden **Evidence:** - \`src/golden.ts\` @@ -78,19 +78,21 @@ The primary impacted areas are service layers. ## QA Scenarios -| Scenario | Precondition | Action | Expected Result | -|---|---|---|---| -| Golden QA | golden | testing | pass | +### 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_ +> **Question:** Why is it golden? +> +> **Why this matters:** Need to investigate +> +> _Derived from scanner diagnostic_ ## Clarifications diff --git a/apps/api/src/modules/document/application/render/markdown-impact-report.builder.spec.ts b/apps/api/src/modules/document/application/render/markdown-impact-report.builder.spec.ts index c931e126..01b16dfd 100644 --- a/apps/api/src/modules/document/application/render/markdown-impact-report.builder.spec.ts +++ b/apps/api/src/modules/document/application/render/markdown-impact-report.builder.spec.ts @@ -109,15 +109,16 @@ describe('MarkdownImpactReportBuilder', () => { // Items should be in right places expect(report).toContain('Claim 1 desc'); - expect(report).toContain('| X | Y | Z |'); + 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 table formatting - expect(report).toContain('| Scenario | Precondition | Action | Expected Result |'); - expect(report).toContain('|---|---|---|---|'); - expect(report).toContain('| QA 1 | X | Y | Z |'); + // QA Scenario formatting + expect(report).not.toContain('| Scenario | Precondition | Action | Expected Result |'); + expect(report).toContain('- **Given:** X'); }); it('handles missing evidence gracefully', () => { @@ -179,6 +180,54 @@ describe('MarkdownImpactReportBuilder', () => { expect(report).toContain('> Secrets were redacted'); }); + it('renders Vietnamese report chrome while preserving raw evidence and source text', () => { + const viAnalysis = { + ...mockAnalysis, + 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('adds unreviewed acknowledged note if hasUnreviewedItems is true', () => { const report = builder.build({ analysis: mockAnalysis, @@ -210,8 +259,8 @@ describe('MarkdownImpactReportBuilder', () => { hasUnreviewedItems: false, }); - expect(report).toContain('The primary impacted areas are data model layers.'); - expect(report).toContain('| Data Model | `BookingAggregate` | `src/booking.aggregate.ts` | Confirmed |'); + 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', () => { diff --git a/apps/api/src/modules/document/application/render/markdown-impact-report.builder.ts b/apps/api/src/modules/document/application/render/markdown-impact-report.builder.ts index cdd00f26..97f5c338 100644 --- a/apps/api/src/modules/document/application/render/markdown-impact-report.builder.ts +++ b/apps/api/src/modules/document/application/render/markdown-impact-report.builder.ts @@ -11,6 +11,7 @@ import { renderEvidenceAppendix } from './markdown-renderers/evidence-appendix.r 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 { @@ -19,9 +20,10 @@ export class MarkdownImpactReportBuilder { private readonly evalContextAdapter: EvaluationContextAdapter ) {} - build(params: Omit & Partial>): string { + build(params: Omit & Partial>): string { const context: MarkdownReportRenderContext = { ...params, + locale: params.locale || DEFAULT_REPORT_LOCALE, reviewNotes: params.reviewNotes || [], dependencyEdges: params.dependencyEdges || [], clarifications: params.clarifications || [], @@ -47,7 +49,7 @@ export class MarkdownImpactReportBuilder { ...renderEvidenceAppendix(context), ...renderReviewHistory(context), ...renderEvidenceQuality(context), - ...renderEvaluationContext(evalContext), + ...renderEvaluationContext(evalContext, context.locale), ...renderImpactDiff(context), ]; diff --git a/apps/api/src/modules/document/application/render/markdown-renderers/evaluation-context.renderer.ts b/apps/api/src/modules/document/application/render/markdown-renderers/evaluation-context.renderer.ts index 085c0687..0ec24533 100644 --- a/apps/api/src/modules/document/application/render/markdown-renderers/evaluation-context.renderer.ts +++ b/apps/api/src/modules/document/application/render/markdown-renderers/evaluation-context.renderer.ts @@ -1,33 +1,38 @@ import { EvaluationContextAdapter } from '../../evaluation-context.adapter'; +import { ReportLocale, getReportLabels } from '../report-localization'; -export function renderEvaluationContext(evalContext: ReturnType): string[] { +export function renderEvaluationContext( + evalContext: ReturnType, + locale: ReportLocale, +): string[] { + const labels = getReportLabels(locale); const lines: string[] = []; if (evalContext) { - lines.push('## Evaluation Context'); + lines.push(`## ${labels.evaluationContext}`); lines.push(''); - lines.push(`- **Dataset Version**: \`${evalContext.datasetVersion}\``); - lines.push(`- **Subset ID**: \`${evalContext.subsetId}\``); - lines.push(`- **Subset Size**: \`${evalContext.subsetSize}\` (Illustrative Only)`); - lines.push(`- **Interpretation**: \`${evalContext.interpretation}\``); - lines.push(`- **Research Artifact**: \`${evalContext.researchFindingsArtifact}\``); - lines.push(`- **Comparison Artifact**: \`${evalContext.sameSubsetComparisonArtifact}\``); + 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('### Known Limits'); + lines.push(`### ${labels.knownLimits}`); evalContext.knownLimits.forEach(l => lines.push(`- ${l}`)); lines.push(''); } if (evalContext.evidenceQualityNotes.length > 0) { - lines.push('### Evidence Quality Notes'); + lines.push(`### ${labels.evidenceQualityNotes}`); evalContext.evidenceQualityNotes.forEach(l => lines.push(`- ${l}`)); lines.push(''); } if (evalContext.datasetExpansionRecommendations.length > 0) { - lines.push('### Dataset Expansion Recommendations'); + lines.push(`### ${labels.datasetExpansionRecommendations}`); evalContext.datasetExpansionRecommendations.forEach(l => lines.push(`- ${l}`)); lines.push(''); } diff --git a/apps/api/src/modules/document/application/render/markdown-renderers/evidence-appendix.renderer.ts b/apps/api/src/modules/document/application/render/markdown-renderers/evidence-appendix.renderer.ts index 5f22e999..0a3d1fb0 100644 --- a/apps/api/src/modules/document/application/render/markdown-renderers/evidence-appendix.renderer.ts +++ b/apps/api/src/modules/document/application/render/markdown-renderers/evidence-appendix.renderer.ts @@ -1,16 +1,18 @@ import { 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('## Evidence Appendix'); + lines.push(`## ${labels.evidenceAppendix}`); lines.push(''); - lines.push('> Secrets were redacted before storage, embedding, or LLM processing.'); + lines.push(`> ${labels.secretsRedacted}`); lines.push(''); // Deduplicate evidence by ID @@ -25,11 +27,11 @@ export function renderEvidenceAppendix(context: MarkdownReportRenderContext): st for (const item of uniqueEvidence) { const e = item.evidence; - const name = e.sourcePath?.split('/').pop() || 'Unknown'; + const name = e.sourcePath?.split('/').pop() || labels.unknown; lines.push(`### \`${name}\``); lines.push(''); - if (e.sourcePath) lines.push(`**File:** \`${e.sourcePath}\` `); - if (e.startLine && e.endLine) lines.push(`**Lines:** ${e.startLine}–${e.endLine}`); + 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); diff --git a/apps/api/src/modules/document/application/render/markdown-renderers/executive-summary.renderer.ts b/apps/api/src/modules/document/application/render/markdown-renderers/executive-summary.renderer.ts index c1e9f921..9c01afcf 100644 --- a/apps/api/src/modules/document/application/render/markdown-renderers/executive-summary.renderer.ts +++ b/apps/api/src/modules/document/application/render/markdown-renderers/executive-summary.renderer.ts @@ -1,19 +1,21 @@ import { 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('## Impact Flow Diagram'); + lines.push(`## ${labels.impactFlowDiagram}`); lines.push(''); lines.push(diagramResult.mermaid); lines.push(''); if (diagramResult.isTruncated) { - lines.push('> Diagram truncated to the most relevant impacted artifacts. See the Impacted Areas and Evidence Appendix for full details.'); + lines.push(`> ${labels.diagramTruncated}`); lines.push(''); } @@ -21,25 +23,25 @@ export function renderExecutiveSummary(context: MarkdownReportRenderContext, dia const qaScenarios = approvedInsights.filter(i => i.insightType === 'QA_SCENARIO'); const openQuestions = approvedInsights.filter(i => i.insightType === 'QUESTION' || i.insightType === 'UNKNOWN'); - lines.push('## Executive Summary'); + lines.push(`## ${labels.executiveSummary}`); lines.push(''); - lines.push(`This analysis identified ${claims.length} evidence-backed impacts, ${qaScenarios.length} QA scenarios, and ${openQuestions.length} open questions.`); + 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(`The primary impacted areas are ${topAreas.toLowerCase()} layers.`); + lines.push(labels.primaryImpactedAreas(topAreas)); } lines.push(''); if (rejectedCount > 0) { - lines.push(`> Rejected insights are excluded from this approved report.`); + lines.push(`> ${labels.rejectedExcluded}`); lines.push(''); } if (hasUnreviewedItems) { - lines.push(`> This report was finalized with unreviewed items acknowledged.`); + lines.push(`> ${labels.unreviewedAcknowledged}`); lines.push(''); } diff --git a/apps/api/src/modules/document/application/render/markdown-renderers/impact-diff.renderer.ts b/apps/api/src/modules/document/application/render/markdown-renderers/impact-diff.renderer.ts index 5b90b98b..3b38509b 100644 --- a/apps/api/src/modules/document/application/render/markdown-renderers/impact-diff.renderer.ts +++ b/apps/api/src/modules/document/application/render/markdown-renderers/impact-diff.renderer.ts @@ -1,25 +1,27 @@ import { 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('## Impact Diff Snapshot'); + lines.push(`## ${labels.impactDiffSnapshot}`); lines.push(''); - lines.push(`This analysis was derived from baseline analysis: \`${diff.baseAnalysisId}\``); + lines.push(`${labels.derivedFromBaseline}: \`${diff.baseAnalysisId}\``); lines.push(''); - lines.push('### Summary'); - lines.push(`- Added code impacts: ${diff.summary.addedImpacts}`); - lines.push(`- Removed code impacts: ${diff.summary.removedImpacts}`); - lines.push(`- Resolved unknowns: ${diff.summary.resolvedUnknowns}`); - lines.push(`- New unknowns: ${diff.summary.newUnknowns}`); - lines.push(`- Added QA scenarios: ${diff.summary.addedQaScenarios}`); + 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('### Added Code Impacts'); + 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}\``); @@ -28,7 +30,7 @@ export function renderImpactDiff(context: MarkdownReportRenderContext): string[] } if (diff.removedArtifacts && diff.removedArtifacts.length > 0) { - lines.push('### Removed Code Impacts'); + 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}\``); @@ -37,7 +39,7 @@ export function renderImpactDiff(context: MarkdownReportRenderContext): string[] } if (diff.resolvedUnknowns && diff.resolvedUnknowns.length > 0) { - lines.push('### Resolved Unknowns'); + lines.push(`### ${formatDiffHeading(labels.resolvedUnknowns, context.locale)}`); lines.push(''); for (const unk of diff.resolvedUnknowns) { lines.push(`- ${unk.statement}`); @@ -46,7 +48,7 @@ export function renderImpactDiff(context: MarkdownReportRenderContext): string[] } if (diff.newUnknowns && diff.newUnknowns.length > 0) { - lines.push('### New Unknowns'); + lines.push(`### ${formatDiffHeading(labels.newUnknowns, context.locale)}`); lines.push(''); for (const unk of diff.newUnknowns) { lines.push(`- ${unk.statement}`); @@ -55,7 +57,7 @@ export function renderImpactDiff(context: MarkdownReportRenderContext): string[] } if (diff.addedQaScenarios && diff.addedQaScenarios.length > 0) { - lines.push('### Added QA Scenarios'); + lines.push(`### ${formatDiffHeading(labels.addedQaScenarios, context.locale)}`); lines.push(''); for (const qa of diff.addedQaScenarios) { lines.push(`- **${qa.insightKey || qa.statement}**: ${qa.statement}`); @@ -66,3 +68,14 @@ export function renderImpactDiff(context: MarkdownReportRenderContext): string[] 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/apps/api/src/modules/document/application/render/markdown-renderers/insight-section.renderer.ts b/apps/api/src/modules/document/application/render/markdown-renderers/insight-section.renderer.ts index fa5135fb..17103fee 100644 --- a/apps/api/src/modules/document/application/render/markdown-renderers/insight-section.renderer.ts +++ b/apps/api/src/modules/document/application/render/markdown-renderers/insight-section.renderer.ts @@ -1,8 +1,10 @@ import { 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'); @@ -10,43 +12,43 @@ export function renderImpactsAndAc(context: MarkdownReportRenderContext): string const acceptanceCriteria = approvedInsights.filter(i => i.insightType === 'ACCEPTANCE_CRITERIA'); if (claims.length > 0) { - lines.push('## Evidence-backed Impacts'); + lines.push(`## ${labels.evidenceBackedImpacts}`); lines.push(''); claims.forEach((claim, index) => { lines.push(`### ${index + 1}. ${claim.description || claim.title}`); lines.push(''); - lines.push(`**Certainty:** ${formatCertainty(claim.certainty)} `); + lines.push(`> **${labels.certainty}:** ${formatCertainty(claim.certainty, context.locale)} `); const claimNote = reviewNotes.find(n => n.insightId === claim.id); if (claimNote) { - lines.push(`**Reviewer Note:** ${claimNote.body} `); + lines.push(`> **${labels.reviewerNote}:** ${claimNote.body} `); } if (claim.reasoning) { - lines.push(`**Reasoning:** ${claim.reasoning} `); + lines.push(`> **${labels.reasoning}:** ${claim.reasoning} `); } lines.push(''); if (claim.evidenceLinks.length > 0) { - lines.push('**Evidence:**'); + 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('_No evidence attached._'); + lines.push(labels.noEvidenceAttached); } lines.push(''); }); } if (acceptanceCriteria.length > 0) { - lines.push('## Acceptance Criteria'); + 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(`
**Reviewer Note:** ${acNote.body}`); + lines.push(`
**${labels.reviewerNote}:** ${acNote.body}`); } if (ac.evidenceLinks.length === 0) { - lines.push(`
_Not directly evidenced; derived from requirement and should be confirmed._`); + lines.push(`
${labels.notDirectlyEvidenced}`); } } lines.push(''); @@ -57,38 +59,40 @@ export function renderImpactsAndAc(context: MarkdownReportRenderContext): string 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('## Open Questions / Unknowns'); + lines.push(`## ${labels.openQuestions}`); lines.push(''); for (const q of openQuestions) { lines.push(`### ${q.title}`); lines.push(''); - lines.push(`**Question:** ${q.description || 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(`**Reviewer Note:** ${qNote.body}`); - lines.push(''); + lines.push(`> `); + lines.push(`> **${labels.reviewerNote}:** ${qNote.body}`); } if (q.reasoning) { - lines.push(`**Why this matters:** ${q.reasoning}`); - lines.push(''); + lines.push(`> `); + lines.push(`> **${labels.whyThisMatters}:** ${q.reasoning}`); } if (q.metadata && typeof q.metadata === 'object' && (q.metadata as any).origin === 'SCANNER_DIAGNOSTIC') { - lines.push(`_Derived from scanner diagnostic_`); - lines.push(''); + lines.push(`> `); + lines.push(`> ${labels.derivedFromScannerDiagnostic}`); } + lines.push(''); } } if (clarifications.length > 0) { - lines.push('## Clarifications'); + lines.push(`## ${labels.clarifications}`); lines.push(''); const answered = clarifications.filter(c => c.status === 'ANSWERED' || c.status === 'CONVERTED_TO_REVISION'); @@ -96,35 +100,35 @@ export function renderQuestionsAndClarifications(context: MarkdownReportRenderCo const dismissed = clarifications.filter(c => c.status === 'DISMISSED'); if (answered.length > 0) { - lines.push('### Answered'); + lines.push(`### ${labels.answered}`); lines.push(''); answered.forEach(c => { - lines.push(`**Question:** ${c.question} `); - if (c.reason) lines.push(`**Why this matters:** ${c.reason} `); - lines.push(`**Answer:** ${c.answer} `); + 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(`**Disposition:** Converted to Requirement Revision \`${c.convertedRequirementRevisionId}\``); + lines.push(`**${labels.disposition}:** ${labels.convertedToRequirementRevision} \`${c.convertedRequirementRevisionId}\``); } lines.push(''); }); } if (open.length > 0) { - lines.push('### Still Open'); + lines.push(`### ${labels.stillOpen}`); lines.push(''); open.forEach(c => { - lines.push(`**Question:** ${c.question} `); - if (c.reason) lines.push(`**Why this matters:** ${c.reason} `); + lines.push(`**${labels.question}:** ${c.question} `); + if (c.reason) lines.push(`**${labels.whyThisMatters}:** ${c.reason} `); lines.push(''); }); } if (dismissed.length > 0) { - lines.push('### Dismissed'); + lines.push(`### ${labels.dismissed}`); lines.push(''); dismissed.forEach(c => { - lines.push(`**Question:** ${c.question} `); - lines.push(`**Disposition:** Dismissed during review. ${c.reason ? `Reason: ${c.reason}` : ''}`); + lines.push(`**${labels.question}:** ${c.question} `); + lines.push(`**${labels.disposition}:** ${labels.dismissedDuringReview} ${c.reason ? `${labels.reason}: ${c.reason}` : ''}`); lines.push(''); }); } diff --git a/apps/api/src/modules/document/application/render/markdown-renderers/markdown-render-utils.ts b/apps/api/src/modules/document/application/render/markdown-renderers/markdown-render-utils.ts index 1aa5486f..8b4d9ad7 100644 --- a/apps/api/src/modules/document/application/render/markdown-renderers/markdown-render-utils.ts +++ b/apps/api/src/modules/document/application/render/markdown-renderers/markdown-render-utils.ts @@ -1,3 +1,5 @@ +import { ReportLocale } from '../report-localization'; + export function formatArtifactType(type: string): string { return type.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' '); } @@ -9,7 +11,17 @@ export function resolveArtifactDisplayType(artifact?: { artifactType?: string | return 'Unknown'; } -export function formatCertainty(certainty: string): string { +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'; diff --git a/apps/api/src/modules/document/application/render/markdown-renderers/qa-section.renderer.ts b/apps/api/src/modules/document/application/render/markdown-renderers/qa-section.renderer.ts index 42387107..28c885d9 100644 --- a/apps/api/src/modules/document/application/render/markdown-renderers/qa-section.renderer.ts +++ b/apps/api/src/modules/document/application/render/markdown-renderers/qa-section.renderer.ts @@ -1,28 +1,38 @@ import { 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('## QA Scenarios'); + lines.push(`## ${labels.qaScenarios}`); lines.push(''); - lines.push('| Scenario | Precondition | Action | Expected Result |'); - lines.push('|---|---|---|---|'); - for (const qa of qaScenarios) { + lines.push(`### ${qa.title}`); + lines.push(''); const parts = parseQaScenarioParts(qa.description || qa.title); - lines.push(`| ${qa.title} | ${parts.precondition} | ${parts.action} | ${parts.expected} |`); + + 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(`| _Reviewer Note_ | ${qaNote.body} | - | - |`); + lines.push(`> **${labels.reviewerNote}:** ${qaNote.body}`); + lines.push(''); } } - lines.push(''); } return lines; diff --git a/apps/api/src/modules/document/application/render/markdown-renderers/report-header.renderer.ts b/apps/api/src/modules/document/application/render/markdown-renderers/report-header.renderer.ts index 9c853903..d771bee9 100644 --- a/apps/api/src/modules/document/application/render/markdown-renderers/report-header.renderer.ts +++ b/apps/api/src/modules/document/application/render/markdown-renderers/report-header.renderer.ts @@ -1,36 +1,47 @@ import { MarkdownReportRenderContext } from '../../markdown-impact-report.types'; +import { getBookingTerminology, getReportLabels } from '../report-localization'; export function renderReportHeader(context: MarkdownReportRenderContext): string[] { const { analysis, metadata } = context; + const labels = getReportLabels(context.locale); const lines: string[] = []; - lines.push(`# Impact Analysis Report: ${analysis.requirementRevision.title}`); + lines.push(`# ${labels.titlePrefix}: ${analysis.requirementRevision.title}`); lines.push(''); - lines.push(`**Status:** Approved `); - lines.push(`**Requirement:** ${analysis.requirementRevision.title} `); - lines.push(`**Snapshot Commit:** \`${analysis.snapshot.commitSha}\` `); - lines.push(`**Repository:** \`${analysis.snapshot.repository.canonicalUrl}\` `); - lines.push(`**Target Ref:** \`${analysis.sourceTarget.requestedRef}\` `); - lines.push(`**Generated At:** ${(metadata?.generatedAt ?? new Date().toISOString()).split('T')[0]} `); + 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('## Requirement'); + lines.push(`## ${labels.requirement}`); lines.push(''); lines.push(`> ${analysis.requirementRevision.rawText.split('\n').join('\n> ')}`); lines.push(''); if (metadata) { - lines.push('## Provenance'); + lines.push(`## ${labels.provenance}`); lines.push(''); - lines.push(`- Analysis ID: \`${metadata.analysisId}\``); - lines.push(`- Generated Document ID: \`${metadata.generatedDocumentId}\``); - lines.push(`- Project ID: \`${metadata.projectId}\``); - lines.push(`- Repository ID: \`${metadata.repositoryId}\``); - lines.push(`- Snapshot ID: \`${metadata.snapshotId}\``); - lines.push(`- Target Ref: \`${metadata.targetRef}\``); - lines.push(`- Commit SHA: \`${metadata.commitSha}\``); - lines.push(`- Analyzer Version: \`${metadata.analyzerVersion}\``); - lines.push(`- Finalized At: ${metadata.finalizedAt ?? metadata.generatedAt}`); + 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}`); + lines.push(''); + } + + if (context.locale === 'vi' && analysis.snapshot.profile?.domain === 'BOOKING') { + lines.push(`## ${labels.terminology}`); + lines.push(''); + for (const term of getBookingTerminology(context.locale)) { + lines.push(`- ${term.key}: ${term.value}`); + } lines.push(''); } @@ -42,18 +53,18 @@ export function renderReportHeader(context: MarkdownReportRenderContext): string ); if (capabilitySummary?.payload) { - lines.push('## Scanner Capability Profile'); + lines.push(`## ${labels.scannerCapabilityProfile}`); lines.push(''); const p = capabilitySummary.payload; - lines.push(`- **Language:** ${p.language}`); - if (p.framework) lines.push(`- **Framework:** ${p.framework}`); - lines.push(`- **Maturity Status:** ${p.status}`); - lines.push(`- **Confidence Level:** ${p.confidence}`); + 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('## Scanner Diagnostics & Risks'); + lines.push(`## ${labels.scannerDiagnosticsAndRisks}`); lines.push(''); for (const diag of unsupportedDiagnostics) { lines.push(`- **${diag.code}**: ${diag.message}`); diff --git a/apps/api/src/modules/document/application/render/markdown-renderers/review-history.renderer.ts b/apps/api/src/modules/document/application/render/markdown-renderers/review-history.renderer.ts index ea8cc595..29382457 100644 --- a/apps/api/src/modules/document/application/render/markdown-renderers/review-history.renderer.ts +++ b/apps/api/src/modules/document/application/render/markdown-renderers/review-history.renderer.ts @@ -1,13 +1,15 @@ import { 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('## Review Decision History'); + lines.push(`## ${labels.reviewDecisionHistory}`); lines.push(''); - lines.push('| Time | Reviewer | Decision | Note |'); + 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); diff --git a/apps/api/src/modules/document/application/render/markdown-renderers/traceability-section.renderer.ts b/apps/api/src/modules/document/application/render/markdown-renderers/traceability-section.renderer.ts index 19df5f07..9ce8e900 100644 --- a/apps/api/src/modules/document/application/render/markdown-renderers/traceability-section.renderer.ts +++ b/apps/api/src/modules/document/application/render/markdown-renderers/traceability-section.renderer.ts @@ -1,9 +1,11 @@ import { 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 renderImpactedAreas(context: MarkdownReportRenderContext): string[] { const { analysis, traceabilityLinks, reviewNotes } = context; + const labels = getReportLabels(context.locale); const lines: string[] = []; if (traceabilityLinks.length === 0) { @@ -13,40 +15,52 @@ export function renderImpactedAreas(context: MarkdownReportRenderContext): strin const diagnostics = (analysis.snapshot.diagnostics as any as any[]) || []; const capabilitySummary = diagnostics.find(d => d.code === 'SCANNER_CAPABILITY_SUMMARY'); - lines.push('## Impacted Areas'); + lines.push(`## ${labels.impactedAreas}`); lines.push(''); - lines.push('| Area | Artifact | File | Review Status |'); - 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); - const nameRaw = link.artifact?.name ? `\`${link.artifact.name}\`` : '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 = ' **[Method: UNKNOWN]**'; + 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}\`` : 'Unknown'; - const status = link.reviewStatus === 'CONFIRMED' ? 'Confirmed' : link.reviewStatus === 'NEEDS_REVIEW' ? 'Needs Review' : link.reviewStatus; - lines.push(`| ${type} | ${name} | ${file} | ${status} |`); + 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(''); } - lines.push(''); const linkNotes = reviewNotes.filter(n => n.traceabilityLinkId && traceabilityLinks.some(l => l.id === n.traceabilityLinkId)); if (linkNotes.length > 0) { - lines.push('### Reviewer Notes on Impacted Areas'); + lines.push(`### ${labels.reviewerNotesOnImpactedAreas}`); lines.push(''); for (const note of linkNotes) { const link = traceabilityLinks.find(l => l.id === note.traceabilityLinkId); @@ -62,20 +76,21 @@ export function renderImpactedAreas(context: MarkdownReportRenderContext): strin 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('## Evidence Quality & Dataset Readiness'); + lines.push(`## ${labels.evidenceQuality}`); lines.push(''); if (evidenceQualitySummarySnapshot) { const summary = evidenceQualitySummarySnapshot; - lines.push(`- Evidence-backed links: ${summary.evidenced + summary.weakEvidence}`); - lines.push(`- Inferred links: ${summary.inferred}`); - lines.push(`- Review required: ${summary.reviewRequired}`); + lines.push(`- ${labels.evidenceBackedLinks}: ${summary.evidenced + summary.weakEvidence}`); + lines.push(`- ${labels.inferredLinks}: ${summary.inferred}`); + lines.push(`- ${labels.reviewRequired}: ${summary.reviewRequired}`); } else { const linkAnnotations = traceabilityLinks.map(link => ({ link, @@ -86,13 +101,13 @@ export function renderEvidenceQuality(context: MarkdownReportRenderContext): str const inferredCount = linkAnnotations.filter(l => l.annotation.label === 'INFERRED').length; const reviewRequiredCount = linkAnnotations.filter(l => l.annotation.label === 'REVIEW_REQUIRED').length; - lines.push(`- Evidence-backed links: ${evidencedCount}`); - lines.push(`- Inferred links: ${inferredCount}`); - lines.push(`- Review required: ${reviewRequiredCount}`); + lines.push(`- ${labels.evidenceBackedLinks}: ${evidencedCount}`); + lines.push(`- ${labels.inferredLinks}: ${inferredCount}`); + lines.push(`- ${labels.reviewRequired}: ${reviewRequiredCount}`); } lines.push(''); - lines.push('| Artifact | Quality | Reason |'); + lines.push(`| ${labels.artifact} | ${labels.quality} | ${labels.reason} |`); lines.push('|---|---|---|'); if (reviewDecisionsSnapshot) { @@ -106,7 +121,7 @@ export function renderEvidenceQuality(context: MarkdownReportRenderContext): str })); for (const item of linkAnnotations) { - const artifactName = item.link.artifact?.filePath ? `\`${item.link.artifact.filePath}\`` : (item.link.artifact?.name || 'Unknown'); + 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(', ')} |`); } } diff --git a/apps/api/src/modules/document/application/render/report-localization.ts b/apps/api/src/modules/document/application/render/report-localization.ts new file mode 100644 index 00000000..e6cb81e7 --- /dev/null +++ b/apps/api/src/modules/document/application/render/report-localization.ts @@ -0,0 +1,241 @@ +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', + 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', + evidenceQuality: 'Evidence Quality & Dataset Readiness', + evidenceBackedLinks: 'Evidence-backed links', + 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', + 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ú', + evidenceQuality: 'Chất lượng bằng chứng và mức sẵn sàng dataset', + evidenceBackedLinks: 'Link có bằng chứng', + 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()}**.`, + }, +}; + +const BOOKING_TERMS: Record> = { + en: [ + { key: 'booking', value: 'booking' }, + { key: 'cancellation', value: 'cancellation' }, + { key: 'refund', value: 'refund' }, + { key: 'doubleRefund', value: 'double refund' }, + { key: 'inventoryRelease', value: 'inventory release' }, + { key: 'paymentState', value: 'payment state' }, + ], + vi: [ + { key: 'booking', value: 'đơn đặt phòng' }, + { key: 'cancellation', value: 'hủy đặt phòng' }, + { key: 'refund', value: 'hoàn tiền' }, + { key: 'doubleRefund', value: 'hoàn tiền trùng' }, + { key: 'inventoryRelease', value: 'giải phóng tồn phòng' }, + { key: 'paymentState', value: 'trạng thái thanh toán' }, + ], +}; + +export function getReportLabels(locale: ReportLocale = DEFAULT_REPORT_LOCALE): ReportLabels { + return REPORT_LABELS[locale] ?? REPORT_LABELS[DEFAULT_REPORT_LOCALE]; +} + +export function getBookingTerminology(locale: ReportLocale): Array<{ key: string; value: string }> { + return BOOKING_TERMS[locale] ?? BOOKING_TERMS[DEFAULT_REPORT_LOCALE]; +} diff --git a/apps/api/src/modules/document/application/render/report-localization.types.ts b/apps/api/src/modules/document/application/render/report-localization.types.ts new file mode 100644 index 00000000..571d9556 --- /dev/null +++ b/apps/api/src/modules/document/application/render/report-localization.types.ts @@ -0,0 +1,105 @@ +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; + 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; + evidenceQuality: string; + evidenceBackedLinks: 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/apps/api/src/modules/document/application/render/reviewed-snapshot-report-context.adapter.ts b/apps/api/src/modules/document/application/render/reviewed-snapshot-report-context.adapter.ts index 9ee34d01..3ad4399b 100644 --- a/apps/api/src/modules/document/application/render/reviewed-snapshot-report-context.adapter.ts +++ b/apps/api/src/modules/document/application/render/reviewed-snapshot-report-context.adapter.ts @@ -8,6 +8,7 @@ import { GraphRepository } from '../../../graph/infrastructure/graph.repository' import { ClarificationRepository } from '../../../clarification/infrastructure/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'; @Injectable() export class ReviewedSnapshotReportContextAdapter { @@ -22,7 +23,11 @@ export class ReviewedSnapshotReportContextAdapter { private readonly getDiffUseCase: GetImpactDiffUseCase, ) {} - async buildContext(snapshot: any, analysis: any): Promise { + async buildContext( + snapshot: any, + analysis: any, + locale: ReportLocale = DEFAULT_REPORT_LOCALE, + ): Promise { const analysisId = analysis.id; // 1. Fetch live elements @@ -77,6 +82,7 @@ export class ReviewedSnapshotReportContextAdapter { return { analysis, + locale, insights, traceabilityLinks: traceabilityLinks as any[], reviewNotes, diff --git a/apps/api/src/modules/domain-pack/application/domain-pack.registry.spec.ts b/apps/api/src/modules/domain-pack/application/domain-pack.registry.spec.ts index ebe7d05c..cc34ad92 100644 --- a/apps/api/src/modules/domain-pack/application/domain-pack.registry.spec.ts +++ b/apps/api/src/modules/domain-pack/application/domain-pack.registry.spec.ts @@ -27,6 +27,7 @@ describe('DomainPackRegistry', () => { const result = registry.selectPack({ manualPackId: 'booking' }); expect(result.pack.id).toBe('booking'); expect(result.pack.version).toBe('0.1.0'); + expect(result.pack.status).toBe('STABLE'); expect(result.normalizedPackId).toBe('booking'); expect(result.selectedBy).toBe('manual_config'); }); @@ -38,6 +39,15 @@ describe('DomainPackRegistry', () => { expect(result.selectedBy).toBe('repository_profile'); }); + it('selects repository RENTAL as rental@0.1.0 PARTIAL', () => { + const result = registry.selectPack({ repositoryProfileDomain: 'RENTAL' }); + expect(result.pack.id).toBe('rental'); + expect(result.pack.version).toBe('0.1.0'); + expect(result.pack.status).toBe('PARTIAL'); + expect(result.normalizedPackId).toBe('rental'); + expect(result.selectedBy).toBe('repository_profile'); + }); + it('manual config overrides repository profile', () => { const result = registry.selectPack({ manualPackId: 'booking', @@ -50,6 +60,7 @@ describe('DomainPackRegistry', () => { it('undefined or null selects general@0.0.0 with safe_default', () => { const result1 = registry.selectPack({}); expect(result1.pack.id).toBe('general'); + expect(result1.pack.status).toBe('FALLBACK'); expect(result1.selectedBy).toBe('safe_default'); const result2 = registry.selectPack({ manualPackId: null, repositoryProfileDomain: null }); @@ -83,4 +94,47 @@ describe('DomainPackRegistry', () => { }).toThrow(AppError); }); }); + + describe('listProfiles', () => { + it('exposes bounded profile registry entries with capability status', () => { + const profiles = registry.listProfiles(); + + expect(profiles).toEqual([ + expect.objectContaining({ + id: 'booking', + version: '0.1.0', + status: 'STABLE', + glossaryMetadata: [ + { locale: 'en', status: 'foundation', version: '1.0.0', termCount: 6 }, + { locale: 'vi', status: 'foundation', version: '1.0.0', termCount: 6 }, + ], + }), + expect.objectContaining({ + id: 'general', + version: '0.0.0', + status: 'FALLBACK', + glossaryMetadata: [], + }), + expect.objectContaining({ + id: 'rental', + version: '0.1.0', + status: 'PARTIAL', + glossaryMetadata: [ + { locale: 'en', status: 'foundation', version: '1.0.0', termCount: 9 }, + { locale: 'vi', status: 'foundation', version: '1.0.0', termCount: 9 }, + ], + }), + ]); + }); + + it('does not expose executable hints or templates in registry summaries', () => { + const booking = registry.getProfileById('booking') as Record; + + expect(booking.concepts).toBeUndefined(); + expect(booking.retrievalHints).toBeUndefined(); + expect(booking.riskTemplates).toBeUndefined(); + expect(booking.qaTemplates).toBeUndefined(); + expect(booking.unknownTemplates).toBeUndefined(); + }); + }); }); diff --git a/apps/api/src/modules/domain-pack/application/domain-pack.registry.ts b/apps/api/src/modules/domain-pack/application/domain-pack.registry.ts index 23054e8a..1c5f564e 100644 --- a/apps/api/src/modules/domain-pack/application/domain-pack.registry.ts +++ b/apps/api/src/modules/domain-pack/application/domain-pack.registry.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { DomainPack } from '@ba-helper/contracts'; +import { DomainPack, DomainProfileRegistryEntry } from '@ba-helper/contracts'; import { GeneralDomainPack } from '../packs/general.v0.0.0'; import { BookingDomainPack } from '../packs/booking.v0.1.0'; +import { RentalDomainPack } from '../packs/rental.v0.1.0'; import { AppError } from '../../../shared/app-error'; export type DomainPackSelectionInput = { @@ -22,6 +23,7 @@ export class DomainPackRegistry { constructor() { this.register(GeneralDomainPack); this.register(BookingDomainPack); + this.register(RentalDomainPack); } /** @@ -31,6 +33,16 @@ export class DomainPackRegistry { this.builtInPacks.set(pack.id, pack); } + listProfiles(): DomainProfileRegistryEntry[] { + return Array.from(this.builtInPacks.values()) + .map((pack) => this.toProfileEntry(pack)) + .sort((a, b) => a.id.localeCompare(b.id)); + } + + getProfileById(id?: string | null): DomainProfileRegistryEntry { + return this.toProfileEntry(this.getPackById(id)); + } + /** * Returns a domain pack by its ID. * If the pack is not found, returns the safe General fallback. @@ -39,13 +51,23 @@ export class DomainPackRegistry { if (!id) { return GeneralDomainPack; } - - // Convert to lowercase to ensure matching works even if repository.domain is capitalized + const normalizedId = id.toLowerCase(); - + return this.builtInPacks.get(normalizedId) ?? GeneralDomainPack; } + private toProfileEntry(pack: DomainPack): DomainProfileRegistryEntry { + return { + id: pack.id, + name: pack.name, + version: pack.version, + status: pack.status, + description: pack.description, + glossaryMetadata: pack.glossaryMetadata, + }; + } + /** * Normalizes a pack ID by stripping version numbers and standardizing casing. * e.g., "BOOKING" -> "booking", "booking@0.1.0" -> "booking" @@ -63,21 +85,14 @@ export class DomainPackRegistry { * 3. safe_default (general) */ selectPack(input: DomainPackSelectionInput): DomainPackSelectionResult { - // 1. Manual Config if (input.manualPackId) { const normalized = this.normalizePackId(input.manualPackId); - - // If version was explicitly provided in manual config, we must ensure it matches - // the registered version. For now, since we only have one version per pack, - // we just check if it exists in builtInPacks. If a user provided booking@0.2.0, - // but we only have booking@0.1.0, wait, the requirement says "unsupported version behavior is explicit and tested". - // Let's see what the registry has. const foundPack = this.builtInPacks.get(normalized); + if (!foundPack) { throw new AppError('UNSUPPORTED_DOMAIN_PACK', `Unsupported manual domain pack: ${input.manualPackId}`); } - - // Check exact version match if version is provided + if (input.manualPackId.includes('@')) { const providedVersion = input.manualPackId.split('@')[1]; if (providedVersion !== foundPack.version) { @@ -92,11 +107,9 @@ export class DomainPackRegistry { }; } - // 2. Repository Profile Domain if (input.repositoryProfileDomain) { const normalized = this.normalizePackId(input.repositoryProfileDomain); - - // We map UNKNOWN to general + if (normalized === 'unknown') { return { pack: GeneralDomainPack, @@ -115,7 +128,6 @@ export class DomainPackRegistry { } } - // 3. Safe Default return { pack: GeneralDomainPack, normalizedPackId: 'general', @@ -148,9 +160,6 @@ export class DomainPackRegistry { } } - // Convert Set to array. - // It's deterministic because Set iterates in insertion order, - // and we iterate over pack.concepts in their defined order. return Array.from(matchedKeys); } } diff --git a/apps/api/src/modules/domain-pack/packs/booking.v0.1.0.ts b/apps/api/src/modules/domain-pack/packs/booking.v0.1.0.ts index 4b4c5f36..486cba18 100644 --- a/apps/api/src/modules/domain-pack/packs/booking.v0.1.0.ts +++ b/apps/api/src/modules/domain-pack/packs/booking.v0.1.0.ts @@ -4,7 +4,22 @@ export const BookingDomainPack: DomainPack = { id: 'booking', name: 'Booking', version: '0.1.0', + status: 'STABLE', description: 'Core domain pack for booking, payment, and refund lifecycle systems.', + glossaryMetadata: [ + { + locale: 'en', + status: 'foundation', + version: '1.0.0', + termCount: 6, + }, + { + locale: 'vi', + status: 'foundation', + version: '1.0.0', + termCount: 6, + }, + ], concepts: [ { diff --git a/apps/api/src/modules/domain-pack/packs/general.v0.0.0.ts b/apps/api/src/modules/domain-pack/packs/general.v0.0.0.ts index 1e53c634..3764acb8 100644 --- a/apps/api/src/modules/domain-pack/packs/general.v0.0.0.ts +++ b/apps/api/src/modules/domain-pack/packs/general.v0.0.0.ts @@ -4,7 +4,9 @@ export const GeneralDomainPack: DomainPack = { id: 'general', name: 'General', version: '0.0.0', + status: 'FALLBACK', description: 'A safe empty default domain pack used when no specific domain is selected.', + glossaryMetadata: [], concepts: [], retrievalHints: [], riskTemplates: [], diff --git a/apps/api/src/modules/domain-pack/packs/rental.v0.1.0.ts b/apps/api/src/modules/domain-pack/packs/rental.v0.1.0.ts new file mode 100644 index 00000000..a214c5b1 --- /dev/null +++ b/apps/api/src/modules/domain-pack/packs/rental.v0.1.0.ts @@ -0,0 +1,117 @@ +import { DomainPack } from '@ba-helper/contracts'; + +export const RentalDomainPack: DomainPack = { + id: 'rental', + name: 'Rental', + version: '0.1.0', + status: 'PARTIAL', + description: 'Partial domain pack for rental contracts, deposits, room availability, and tenant/landlord workflows.', + glossaryMetadata: [ + { + locale: 'en', + status: 'foundation', + version: '1.0.0', + termCount: 9, + }, + { + locale: 'vi', + status: 'foundation', + version: '1.0.0', + termCount: 9, + }, + ], + + concepts: [ + { + key: 'rental_contract', + label: 'Rental Contract', + aliases: ['rental contract', 'lease contract', 'contract', 'rental agreement', 'lease'], + relatedArtifactKeywords: ['contract', 'lease', 'agreement', 'rental'], + relatedKinds: ['SERVICE', 'DATABASE_MODEL', 'API_ENDPOINT'], + }, + { + key: 'deposit', + label: 'Deposit', + aliases: ['deposit', 'security deposit', 'deposit payment'], + relatedArtifactKeywords: ['deposit', 'security', 'payment'], + relatedKinds: ['SERVICE', 'DATABASE_MODEL'], + }, + { + key: 'room_availability', + label: 'Room Availability', + aliases: ['room availability', 'availability', 'available room', 'vacancy', 'room status'], + relatedArtifactKeywords: ['room', 'availability', 'vacancy', 'status'], + relatedKinds: ['SERVICE', 'DATABASE_MODEL'], + }, + { + key: 'booking_request', + label: 'Booking Request', + aliases: ['booking request', 'rental request', 'room request', 'application request'], + relatedArtifactKeywords: ['request', 'booking', 'application'], + relatedKinds: ['SERVICE', 'API_ENDPOINT'], + }, + { + key: 'tenant', + label: 'Tenant', + aliases: ['tenant', 'renter', 'occupant'], + relatedArtifactKeywords: ['tenant', 'renter', 'occupant'], + relatedKinds: ['SERVICE', 'DATABASE_MODEL'], + }, + { + key: 'landlord', + label: 'Landlord', + aliases: ['landlord', 'owner', 'property owner'], + relatedArtifactKeywords: ['landlord', 'owner', 'property'], + relatedKinds: ['SERVICE', 'DATABASE_MODEL'], + }, + { + key: 'payment_record', + label: 'Payment Record', + aliases: ['payment record', 'payment', 'rent payment', 'payment history', 'receipt'], + relatedArtifactKeywords: ['payment', 'record', 'receipt', 'ledger'], + relatedKinds: ['SERVICE', 'DATABASE_MODEL'], + }, + { + key: 'contract_transition', + label: 'Contract Transition', + aliases: ['contract transition', 'contract status', 'activate contract', 'cancel contract'], + relatedArtifactKeywords: ['transition', 'status', 'activate', 'cancel'], + relatedKinds: ['SERVICE', 'DATABASE_MODEL'], + }, + { + key: 'maintenance_request', + label: 'Maintenance Request', + aliases: ['maintenance request', 'repair request', 'maintenance', 'repair ticket'], + relatedArtifactKeywords: ['maintenance', 'repair', 'ticket'], + relatedKinds: ['SERVICE', 'API_ENDPOINT'], + }, + ], + + retrievalHints: [ + 'rental contract status transition', + 'deposit payment record consistency', + 'room availability update', + 'booking request lifecycle', + 'tenant landlord notification', + ], + + riskTemplates: [ + 'PARTIAL rental hint: deposit payment and contract state may become inconsistent without source-backed transition evidence.', + 'PARTIAL rental hint: room availability may be updated before booking request state is settled.', + 'PARTIAL rental hint: tenant and landlord notification rules may differ by contract state.', + 'PARTIAL rental hint: maintenance request workflows are not covered beyond terminology matching.', + ], + + qaTemplates: [ + 'PARTIAL rental hint: verify deposit update changes only source-backed contract and payment-record behavior.', + 'PARTIAL rental hint: verify room availability updates through the booking request flow.', + 'PARTIAL rental hint: verify contract cancellation effects on payment records and tenant/landlord notification.', + ], + + unknownTemplates: [ + 'Which rental contract states allow deposit payment updates?', + 'When does room availability become visible after a booking request changes?', + 'Who must be notified when a rental contract is cancelled?', + 'Are maintenance requests in scope for this rental profile revision?', + ], +}; diff --git a/apps/api/src/modules/impact-analysis/api/impact-analysis-read-model.controller.spec.ts b/apps/api/src/modules/impact-analysis/api/impact-analysis-read-model.controller.spec.ts index b9d4db60..822c937b 100644 --- a/apps/api/src/modules/impact-analysis/api/impact-analysis-read-model.controller.spec.ts +++ b/apps/api/src/modules/impact-analysis/api/impact-analysis-read-model.controller.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ImpactAnalysisReadModelController } from './impact-analysis-read-model.controller'; import { ProjectPermissionService } from '../../project/application/project-permission.service'; import { GetAnalysisDriftFreshnessUseCase } from '../application/queries/get-analysis-drift-freshness.usecase'; +import { GetAnalysisWorkspaceUseCase } from '../application/queries/get-analysis-workspace.usecase'; import { UnauthorizedException, NotFoundException } from '@nestjs/common'; import { RequestUser } from '@ba-helper/contracts'; @@ -9,6 +10,7 @@ describe('ImpactAnalysisReadModelController - driftFreshness', () => { let controller: ImpactAnalysisReadModelController; let permissions: jest.Mocked; let getAnalysisDriftFreshness: jest.Mocked; + let getAnalysisWorkspace: jest.Mocked; const mockActor: RequestUser = { id: 'user-1', email: 'test@example.com', name: 'Test', role: 'VIEWER' }; @@ -20,6 +22,9 @@ describe('ImpactAnalysisReadModelController - driftFreshness', () => { getAnalysisDriftFreshness = { execute: jest.fn(), } as any; + getAnalysisWorkspace = { + execute: jest.fn(), + } as any; controller = new ImpactAnalysisReadModelController( null as any, // getMatrixRowDetail @@ -29,6 +34,7 @@ describe('ImpactAnalysisReadModelController - driftFreshness', () => { null as any, // getImpactDiff null as any, // getLineage getAnalysisDriftFreshness, + getAnalysisWorkspace, permissions, ); }); @@ -56,4 +62,71 @@ describe('ImpactAnalysisReadModelController - driftFreshness', () => { const result = await controller.driftFreshness('proj-1', 'analysis-1', mockActor); expect(result.status).toBe('CURRENT'); }); + + it('returns the analysis workspace read model', async () => { + permissions.assertCanReadAnalysis.mockResolvedValueOnce(undefined); + getAnalysisWorkspace.execute.mockResolvedValueOnce({ + overview: { + analysisId: '00000000-0000-4000-8000-000000000001', + requirement: { + revisionId: '00000000-0000-4000-8000-000000000002', + title: 'Refund API', + summary: 'Cancel paid bookings.', + language: 'en', + domainProfileId: 'booking@0.1.0', + }, + snapshot: { + snapshotId: '00000000-0000-4000-8000-000000000003', + repositoryId: '00000000-0000-4000-8000-000000000004', + commitSha: 'abc123', + analyzerVersion: 'nestjs-ts/0.1.0', + }, + status: { + analysisStatus: 'WAITING_FOR_REVIEW', + reviewStatus: 'not_started', + snapshotStatus: 'locked', + reportStatus: 'missing', + driftStatus: 'fresh', + }, + counts: { + impactedArtifacts: 0, + evidenceItems: 0, + risks: 0, + unknowns: 0, + qaScenarios: 0, + pendingReviewItems: 0, + }, + }, + impactGroups: [], + evidenceCards: [], + risks: [], + unknowns: [], + qaScenarios: [], + reviewQueue: [], + reportStatus: { + status: 'missing', + generatedDocumentId: null, + documentJobId: null, + reviewedReportSnapshotId: null, + canExport: false, + lastGeneratedAt: null, + failureMessage: null, + }, + driftStatus: { + status: 'fresh', + isStale: false, + basis: 'latest_observed_source_target', + sourceTargetId: '00000000-0000-4000-8000-000000000005', + latestObservedCommitSha: 'abc123', + snapshotCommitSha: 'abc123', + reason: null, + }, + }); + + const result = await controller.workspace('analysis-1', mockActor); + + expect(permissions.assertCanReadAnalysis).toHaveBeenCalledWith(mockActor, 'analysis-1'); + expect(getAnalysisWorkspace.execute).toHaveBeenCalledWith('analysis-1'); + expect(result.overview.status.reportStatus).toBe('missing'); + }); }); diff --git a/apps/api/src/modules/impact-analysis/api/impact-analysis-read-model.controller.ts b/apps/api/src/modules/impact-analysis/api/impact-analysis-read-model.controller.ts index cf626db6..6855dd62 100644 --- a/apps/api/src/modules/impact-analysis/api/impact-analysis-read-model.controller.ts +++ b/apps/api/src/modules/impact-analysis/api/impact-analysis-read-model.controller.ts @@ -25,6 +25,7 @@ import { reviewDecisionResponseSchema, lineageTimelineResponseSchema, driftFreshnessRecommendationSchema, + analysisWorkspaceResponseSchema, RequestUser, } from '@ba-helper/contracts'; import { CurrentUser } from '../../auth/api/current-user.decorator'; @@ -54,6 +55,7 @@ import { GetLatestReviewDecisionUseCase } from '../application/review/get-latest import { GetImpactAnalysisLineageUseCase } from '../application/queries/get-impact-analysis-lineage.usecase'; import { GetReviewCoverageUseCase } from '../application/review/get-review-coverage.usecase'; import { GetAnalysisDriftFreshnessUseCase } from '../application/queries/get-analysis-drift-freshness.usecase'; +import { GetAnalysisWorkspaceUseCase } from '../application/queries/get-analysis-workspace.usecase'; import { mapImpactAnalysisListItem, mapImpactAnalysisResponse, @@ -77,6 +79,7 @@ export class ImpactAnalysisReadModelController { private readonly getImpactDiff: GetImpactDiffUseCase, private readonly getLineage: GetImpactAnalysisLineageUseCase, private readonly getAnalysisDriftFreshness: GetAnalysisDriftFreshnessUseCase, + private readonly getAnalysisWorkspace: GetAnalysisWorkspaceUseCase, private readonly permissions: ProjectPermissionService, ) {} @@ -113,6 +116,16 @@ export class ImpactAnalysisReadModelController { return impactGraphResponseSchema.parse(result); } + @Get('/impact-analyses/:analysisId/workspace') + async workspace( + @Param('analysisId') analysisId: string, + @CurrentUser() actor: RequestUser, + ) { + await this.permissions.assertCanReadAnalysis(actor, analysisId); + const result = await this.getAnalysisWorkspace.execute(analysisId); + return analysisWorkspaceResponseSchema.parse(result); + } + @Get('/impact-analyses/:analysisId/qa-coverage') async qaCoverage( @Param('analysisId') analysisId: string, 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 55650b9e..a3c9c047 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 @@ -64,6 +64,7 @@ describe('RunImpactAnalysisUseCase', () => { pack: { id: 'test-pack', version: '1.0', + status: 'EXPERIMENTAL', concepts: [], retrievalHints: [], riskTemplates: [], diff --git a/apps/api/src/modules/impact-analysis/application/lifecycle/run-impact-analysis.usecase.ts b/apps/api/src/modules/impact-analysis/application/lifecycle/run-impact-analysis.usecase.ts index e3128311..51b87131 100644 --- a/apps/api/src/modules/impact-analysis/application/lifecycle/run-impact-analysis.usecase.ts +++ b/apps/api/src/modules/impact-analysis/application/lifecycle/run-impact-analysis.usecase.ts @@ -206,6 +206,7 @@ export class RunImpactAnalysisUseCase { domainPack: { id: domainPack.id, version: domainPack.version, + status: domainPack.status, selectedBy: domainPackSelection.selectedBy, }, diagnostics: [ @@ -216,6 +217,7 @@ export class RunImpactAnalysisUseCase { payload: { domainPackId: domainPack.id, domainPackVersion: domainPack.version, + domainPackStatus: domainPack.status, selectedBy: domainPackSelection.selectedBy, conceptCount: domainPack.concepts.length, retrievalHintCount: domainPack.retrievalHints.length, diff --git a/apps/api/src/modules/impact-analysis/application/mappers/analysis-workspace.mapper.helpers.ts b/apps/api/src/modules/impact-analysis/application/mappers/analysis-workspace.mapper.helpers.ts new file mode 100644 index 00000000..9ee92bb1 --- /dev/null +++ b/apps/api/src/modules/impact-analysis/application/mappers/analysis-workspace.mapper.helpers.ts @@ -0,0 +1,234 @@ +import { AnalysisWorkspaceResponse } from '@ba-helper/contracts'; +import { + WorkspaceAnalysis, + WorkspaceDocumentJob, + WorkspaceInsight, + WorkspaceReviewedReportSnapshot, +} from './analysis-workspace.mapper.types'; + +export function buildReportStatus( + analysis: WorkspaceAnalysis, +): AnalysisWorkspaceResponse['reportStatus'] { + const latestSnapshot = analysis.reviewedReportSnapshots[0] ?? null; + const latestJob = analysis.documentJobs[0] ?? null; + const generatedDocument = + latestJob?.generatedDocument ?? latestSnapshot?.approvedDocument ?? null; + + if (latestJob?.status === 'QUEUED' || latestJob?.status === 'RUNNING') { + return reportCard(latestJob.status.toLowerCase() as 'queued' | 'running', latestJob, latestSnapshot); + } + + if (generatedDocument?.status === 'APPROVED' || latestJob?.status === 'COMPLETED') { + return reportCard('completed', latestJob, latestSnapshot); + } + + if (latestJob?.status === 'FAILED') { + return reportCard('failed', latestJob, latestSnapshot); + } + + return reportCard('missing', latestJob, latestSnapshot); +} + +export function buildDriftStatus( + analysis: WorkspaceAnalysis, +): AnalysisWorkspaceResponse['driftStatus'] { + const target = analysis.sourceTarget; + if (!target) { + return { + status: 'unknown', + isStale: false, + basis: 'unknown', + sourceTargetId: null, + latestObservedCommitSha: null, + snapshotCommitSha: analysis.snapshot.commitSha, + reason: 'No source target is available for freshness projection.', + }; + } + + const pinned = target.resolvedRefType === 'COMMIT'; + const stale = !pinned && target.latestObservedCommitSha !== analysis.snapshot.commitSha; + + return { + status: stale ? 'stale' : 'fresh', + isStale: stale, + basis: pinned ? 'pinned_commit' : 'latest_observed_source_target', + sourceTargetId: target.id, + latestObservedCommitSha: target.latestObservedCommitSha, + snapshotCommitSha: analysis.snapshot.commitSha, + reason: stale ? 'Selected repository target has a newer observed commit.' : null, + }; +} + +export function reportCard( + status: AnalysisWorkspaceResponse['reportStatus']['status'], + job: WorkspaceDocumentJob | null, + snapshot: WorkspaceReviewedReportSnapshot | null, +): AnalysisWorkspaceResponse['reportStatus'] { + const document = job?.generatedDocument ?? snapshot?.approvedDocument ?? null; + return { + status, + generatedDocumentId: document?.id ?? job?.generatedDocumentId ?? null, + documentJobId: job?.id ?? null, + reviewedReportSnapshotId: snapshot?.id ?? null, + canExport: status === 'completed', + lastGeneratedAt: + job?.completedAt?.toISOString() ?? + document?.updatedAt?.toISOString() ?? + snapshot?.createdAt?.toISOString() ?? + null, + failureMessage: status === 'failed' ? stringifyJobError(job?.error) : null, + }; +} + +export function deriveReviewStatus( + analysis: WorkspaceAnalysis, +): AnalysisWorkspaceResponse['overview']['status']['reviewStatus'] { + const statuses = [ + ...analysis.insights.map((item) => item.reviewStatus), + ...analysis.traceabilityLinks.map((item) => item.reviewStatus), + ]; + if (statuses.length === 0 || statuses.every((status) => status === 'NEEDS_REVIEW')) { + return 'not_started'; + } + return statuses.some((status) => status === 'NEEDS_REVIEW') ? 'in_progress' : 'complete'; +} + +export function isRiskInsight(insight: WorkspaceInsight): boolean { + return insight.certainty === 'CONFLICTING' || readMetadata(insight.metadata, 'kind') === 'risk'; +} + +export function deriveRiskSeverity( + insight: WorkspaceInsight, +): AnalysisWorkspaceResponse['risks'][number]['severity'] { + const severity = readMetadata(insight.metadata, 'severity'); + return severity === 'low' || severity === 'medium' || severity === 'high' + ? severity + : insight.certainty === 'CONFLICTING' + ? 'high' + : 'medium'; +} + +export function toReviewDecision( + status: string, +): AnalysisWorkspaceResponse['reviewQueue'][number]['currentDecision'] { + if (status === 'CONFIRMED' || status === 'ACCEPTED') return 'accepted'; + if (status === 'REJECTED') return 'rejected'; + if (status === 'NEEDS_MORE_EVIDENCE') return 'needs_more_evidence'; + return 'needs_review'; +} + +export function toEvidenceBasis( + basis: string, +): AnalysisWorkspaceResponse['impactGroups'][number]['artifacts'][number]['impactBasis'] { + if (basis === 'EVIDENCED') return 'evidenced'; + if (basis === 'INFERRED') return 'inferred'; + return 'unknown'; +} + +export function normalizeUniversalKind( + kind: string, +): AnalysisWorkspaceResponse['impactGroups'][number]['artifacts'][number]['universalKind'] { + if ( + kind === 'API_ENDPOINT' || + kind === 'DOMAIN_SERVICE' || + kind === 'DATA_MODEL' || + kind === 'TEST_CASE' + ) { + return kind; + } + return 'UNKNOWN'; +} + +export function detectRequirementLanguage(rawText: string, normalizedText: string) { + const text = `${rawText} ${normalizedText}`; + return /[ăâđêôơưáàạảãấầậẩẫắằặẳẵéèẹẻẽếềệểễíìịỉĩóòọỏõốồộổỗớờợởỡúùụủũứừựửữýỳỵỷỹ]/i.test(text) + ? 'vi' + : text.trim() + ? 'en' + : 'unknown'; +} + +export function buildDomainProfileId(profile: WorkspaceAnalysis['snapshot']['profile']) { + if (!profile) return 'unknown'; + return `${profile.domain.toLowerCase()}@${profile.profileVersion}`; +} + +export function evidenceArtifactKeys(insight: WorkspaceInsight): string[] { + return Array.from( + new Set( + insight.evidenceLinks + .map((link) => link.evidence.artifact?.artifactKey) + .filter((key): key is string => Boolean(key)), + ), + ); +} + +export function parseQaSteps(description: string) { + return { + given: readStep(description, 'given') ?? 'The impacted workflow is available.', + when: readStep(description, 'when') ?? description, + then: readStep(description, 'then') ?? 'The expected behavior is verified.', + }; +} + +export function readMetadata(metadata: unknown, key: string): unknown { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { + return undefined; + } + return (metadata as Record)[key]; +} + +export function reviewItemTypeForInsight( + insight: WorkspaceInsight, +): AnalysisWorkspaceResponse['reviewQueue'][number]['itemType'] { + if (insight.insightType === 'UNKNOWN') return 'unknown'; + if (insight.insightType === 'QA_SCENARIO') return 'qa_scenario'; + if (isRiskInsight(insight)) return 'risk'; + return 'evidence'; +} + +export function impactGroupTitle( + group: AnalysisWorkspaceResponse['impactGroups'][number]['group'], +) { + return { + primary: 'Primary impact', + secondary: 'Secondary impact', + test: 'Tests', + config: 'Data and configuration', + unknown: 'Unknown classification', + }[group]; +} + +export function impactGroupDescription( + group: AnalysisWorkspaceResponse['impactGroups'][number]['group'], +) { + return { + primary: 'Entry points and primary workflow artifacts.', + secondary: 'Supporting service and domain behavior artifacts.', + test: 'Test artifacts related to the change.', + config: 'Data model or configuration artifacts.', + unknown: 'Artifacts without a normalized presentation group.', + }[group]; +} + +export function pushMap(map: Map, key: string, value: string) { + map.set(key, [...(map.get(key) ?? []), value]); +} + +export function uniqueCount(items: string[]) { + return new Set(items).size; +} + +function readStep(text: string, label: 'given' | 'when' | 'then') { + const match = text.match(new RegExp(`${label}:\\s*([^\\n]+)`, 'i')); + return match?.[1]?.trim(); +} + +function stringifyJobError(error: unknown) { + if (!error) return null; + if (typeof error === 'string') return error; + if (typeof error === 'object' && 'message' in error) { + return String((error as { message?: unknown }).message); + } + return 'Document generation failed.'; +} diff --git a/apps/api/src/modules/impact-analysis/application/mappers/analysis-workspace.mapper.ts b/apps/api/src/modules/impact-analysis/application/mappers/analysis-workspace.mapper.ts new file mode 100644 index 00000000..6d4f1bca --- /dev/null +++ b/apps/api/src/modules/impact-analysis/application/mappers/analysis-workspace.mapper.ts @@ -0,0 +1,255 @@ +import { + AnalysisWorkspaceResponse, + analysisWorkspaceResponseSchema, +} from '@ba-helper/contracts'; +import { + KIND_GROUPS, + WorkspaceAnalysis, + WorkspaceEvidence, + WorkspaceInsight, + WorkspaceTraceabilityLink, +} from './analysis-workspace.mapper.types'; +import { + buildDomainProfileId, + buildDriftStatus, + buildReportStatus, + deriveReviewStatus, + deriveRiskSeverity, + detectRequirementLanguage, + evidenceArtifactKeys, + impactGroupDescription, + impactGroupTitle, + isRiskInsight, + normalizeUniversalKind, + parseQaSteps, + pushMap, + readMetadata, + reviewItemTypeForInsight, + toEvidenceBasis, + toReviewDecision, + uniqueCount, +} from './analysis-workspace.mapper.helpers'; + +export function mapAnalysisWorkspace( + analysis: WorkspaceAnalysis, +): AnalysisWorkspaceResponse { + const evidenceCards = buildEvidenceCards(analysis); + const risks = analysis.insights.filter(isRiskInsight).map(mapRisk); + const unknowns = analysis.insights + .filter((insight) => insight.insightType === 'UNKNOWN') + .map(mapUnknown); + const qaScenarios = analysis.insights + .filter((insight) => insight.insightType === 'QA_SCENARIO') + .map(mapQaScenario); + const reviewQueue = buildReviewQueue(analysis); + const reportStatus = buildReportStatus(analysis); + const driftStatus = buildDriftStatus(analysis); + + const response: AnalysisWorkspaceResponse = { + overview: { + analysisId: analysis.id, + requirement: { + revisionId: analysis.requirementRevision.id, + title: analysis.requirementRevision.title, + summary: analysis.requirementRevision.normalizedText, + language: detectRequirementLanguage( + analysis.requirementRevision.rawText, + analysis.requirementRevision.normalizedText, + ), + domainProfileId: buildDomainProfileId(analysis.snapshot.profile), + }, + snapshot: { + snapshotId: analysis.snapshot.id, + repositoryId: analysis.snapshot.repositoryId, + commitSha: analysis.snapshot.commitSha, + analyzerVersion: analysis.snapshot.analyzerVersion, + profileVersion: analysis.snapshot.profile?.profileVersion, + }, + status: { + analysisStatus: analysis.status as AnalysisWorkspaceResponse['overview']['status']['analysisStatus'], + reviewStatus: deriveReviewStatus(analysis), + snapshotStatus: 'locked', + reportStatus: reportStatus.status, + driftStatus: driftStatus.status, + }, + counts: { + impactedArtifacts: uniqueCount( + analysis.traceabilityLinks.map((link) => link.artifact.artifactKey), + ), + evidenceItems: evidenceCards.length, + risks: risks.length, + unknowns: unknowns.length, + qaScenarios: qaScenarios.length, + pendingReviewItems: reviewQueue.length, + }, + }, + impactGroups: buildImpactGroups(analysis.traceabilityLinks), + evidenceCards, + risks, + unknowns, + qaScenarios, + reviewQueue, + reportStatus, + driftStatus, + }; + + return analysisWorkspaceResponseSchema.parse(response); +} + +function buildImpactGroups( + links: WorkspaceTraceabilityLink[], +): AnalysisWorkspaceResponse['impactGroups'] { + const grouped = new Map< + AnalysisWorkspaceResponse['impactGroups'][number]['group'], + AnalysisWorkspaceResponse['impactGroups'][number]['artifacts'] + >(); + + for (const link of links) { + const group = KIND_GROUPS[link.artifact.universalKind] ?? 'unknown'; + const artifacts = grouped.get(group) ?? []; + artifacts.push({ + artifactId: link.artifact.id, + artifactKey: link.artifact.artifactKey, + name: link.artifact.name, + filePath: link.artifact.filePath, + universalKind: normalizeUniversalKind(link.artifact.universalKind), + impactBasis: toEvidenceBasis(link.linkBasis), + impactReason: `Traceability link ${link.id} is ${link.linkBasis.toLowerCase()}.`, + traceabilityLinkIds: [link.id], + evidenceIds: link.evidenceLinks.map((item) => item.evidenceId), + reviewDecision: toReviewDecision( + link.reviewDecision?.decision ?? link.reviewStatus, + ), + }); + grouped.set(group, artifacts); + } + + return Array.from(grouped.entries()).map(([group, artifacts]) => ({ + group, + title: impactGroupTitle(group), + description: impactGroupDescription(group), + artifacts, + })); +} + +function buildEvidenceCards( + analysis: WorkspaceAnalysis, +): AnalysisWorkspaceResponse['evidenceCards'] { + const insightLinks = new Map(); + const traceabilityLinks = new Map(); + const evidence = new Map(); + + for (const insight of analysis.insights) { + for (const link of insight.evidenceLinks) { + evidence.set(link.evidenceId, link.evidence); + pushMap(insightLinks, link.evidenceId, insight.id); + } + } + + for (const traceability of analysis.traceabilityLinks) { + for (const link of traceability.evidenceLinks) { + evidence.set(link.evidenceId, link.evidence); + pushMap(traceabilityLinks, link.evidenceId, traceability.id); + } + } + + return Array.from(evidence.values()).map((item) => ({ + evidenceId: item.id, + sourceType: item.sourceType.toLowerCase() as AnalysisWorkspaceResponse['evidenceCards'][number]['sourceType'], + filePath: item.sourcePath, + lineRange: { + startLine: item.startLine, + endLine: item.endLine, + }, + excerpt: item.excerpt, + relevanceReason: 'Linked to analysis insight or traceability evidence.', + artifactId: item.artifactId, + artifactKey: item.artifact?.artifactKey ?? null, + linkedInsightIds: insightLinks.get(item.id) ?? [], + linkedTraceabilityLinkIds: traceabilityLinks.get(item.id) ?? [], + })); +} + +function mapRisk( + insight: WorkspaceInsight, +): AnalysisWorkspaceResponse['risks'][number] { + return { + riskId: insight.insightKey, + sourceInsightId: insight.id, + title: insight.title, + severity: deriveRiskSeverity(insight), + category: String(readMetadata(insight.metadata, 'category') ?? insight.insightType), + whyItMatters: insight.reasoning ?? insight.description, + relatedArtifactKeys: evidenceArtifactKeys(insight), + relatedEvidenceIds: insight.evidenceLinks.map((link) => link.evidenceId), + relatedUnknownIds: [], + reviewDecision: toReviewDecision(insight.reviewStatus), + }; +} + +function mapUnknown( + insight: WorkspaceInsight, +): AnalysisWorkspaceResponse['unknowns'][number] { + return { + unknownId: insight.insightKey, + sourceInsightId: insight.id, + title: insight.title, + question: insight.description, + whyItMatters: insight.reasoning ?? insight.description, + relatedArtifactKeys: evidenceArtifactKeys(insight), + relatedEvidenceIds: insight.evidenceLinks.map((link) => link.evidenceId), + reviewDecision: toReviewDecision(insight.reviewStatus), + }; +} + +function mapQaScenario( + insight: WorkspaceInsight, +): AnalysisWorkspaceResponse['qaScenarios'][number] { + const steps = parseQaSteps(insight.description); + return { + scenarioId: insight.insightKey, + sourceInsightId: insight.id, + title: insight.title, + given: steps.given, + when: steps.when, + then: steps.then, + regressionTarget: insight.reasoning ?? insight.title, + relatedRiskIds: [], + relatedUnknownIds: [], + relatedArtifactKeys: evidenceArtifactKeys(insight), + relatedEvidenceIds: insight.evidenceLinks.map((link) => link.evidenceId), + reviewDecision: toReviewDecision(insight.reviewStatus), + }; +} + +function buildReviewQueue( + analysis: WorkspaceAnalysis, +): AnalysisWorkspaceResponse['reviewQueue'] { + const insightItems = analysis.insights + .filter((insight) => insight.reviewStatus === 'NEEDS_REVIEW') + .map((insight) => ({ + itemId: insight.id, + itemType: reviewItemTypeForInsight(insight), + title: insight.title, + currentDecision: toReviewDecision(insight.reviewStatus), + evidenceCount: insight.evidenceLinks.length, + linkedArtifactKeys: evidenceArtifactKeys(insight), + linkedEvidenceIds: insight.evidenceLinks.map((link) => link.evidenceId), + blockingFinalize: true, + })); + + const linkItems = analysis.traceabilityLinks + .filter((link) => link.reviewStatus === 'NEEDS_REVIEW') + .map((link) => ({ + itemId: link.id, + itemType: 'impact' as const, + title: `Review impact link: ${link.artifact.name}`, + currentDecision: toReviewDecision(link.reviewStatus), + evidenceCount: link.evidenceLinks.length, + linkedArtifactKeys: [link.artifact.artifactKey], + linkedEvidenceIds: link.evidenceLinks.map((item) => item.evidenceId), + blockingFinalize: true, + })); + + return [...insightItems, ...linkItems]; +} diff --git a/apps/api/src/modules/impact-analysis/application/mappers/analysis-workspace.mapper.types.ts b/apps/api/src/modules/impact-analysis/application/mappers/analysis-workspace.mapper.types.ts new file mode 100644 index 00000000..b7d4c414 --- /dev/null +++ b/apps/api/src/modules/impact-analysis/application/mappers/analysis-workspace.mapper.types.ts @@ -0,0 +1,117 @@ +import { AnalysisWorkspaceResponse } from '@ba-helper/contracts'; + +export type WorkspaceAnalysis = { + id: string; + status: string; + progress: number; + requirementRevision: { + id: string; + title: string; + rawText: string; + normalizedText: string; + }; + snapshot: { + id: string; + repositoryId: string; + commitSha: string; + analyzerVersion: string; + profile?: { + domain: string; + profileVersion: string; + } | null; + }; + sourceTarget: { + id: string; + resolvedRefType: string; + latestObservedCommitSha: string; + } | null; + insights: WorkspaceInsight[]; + traceabilityLinks: WorkspaceTraceabilityLink[]; + documentJobs: WorkspaceDocumentJob[]; + reviewedReportSnapshots: WorkspaceReviewedReportSnapshot[]; +}; + +export type WorkspaceInsight = { + id: string; + insightKey: string; + insightType: string; + certainty: string; + reviewStatus: string; + title: string; + description: string; + reasoning: string | null; + metadata: unknown; + evidenceLinks: Array<{ + evidenceId: string; + evidence: WorkspaceEvidence; + }>; +}; + +export type WorkspaceTraceabilityLink = { + id: string; + linkBasis: string; + reviewStatus: string; + artifact: { + id: string; + artifactKey: string; + name: string; + filePath: string; + universalKind: string; + }; + evidenceLinks: Array<{ + evidenceId: string; + evidence: WorkspaceEvidence; + }>; + reviewDecision?: { + decision: string; + } | null; +}; + +export type WorkspaceEvidence = { + id: string; + sourceType: string; + sourcePath: string | null; + startLine: number | null; + endLine: number | null; + excerpt: string; + artifactId: string | null; + artifact?: { + artifactKey: string; + } | null; +}; + +export type WorkspaceDocumentJob = { + id: string; + status: string; + error: unknown; + generatedDocumentId: string | null; + completedAt: Date | null; + updatedAt: Date; + generatedDocument?: { + id: string; + status: string; + updatedAt: Date; + } | null; +}; + +export type WorkspaceReviewedReportSnapshot = { + id: string; + approvedDocumentId: string | null; + createdAt: Date; + approvedDocument?: { + id: string; + status: string; + updatedAt: Date; + } | null; +}; + +export const KIND_GROUPS: Record< + string, + AnalysisWorkspaceResponse['impactGroups'][number]['group'] +> = { + API_ENDPOINT: 'primary', + DOMAIN_SERVICE: 'secondary', + DATA_MODEL: 'config', + TEST_CASE: 'test', + UNKNOWN: 'unknown', +}; diff --git a/apps/api/src/modules/impact-analysis/application/queries/get-analysis-workspace.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/queries/get-analysis-workspace.usecase.spec.ts new file mode 100644 index 00000000..6be9c74f --- /dev/null +++ b/apps/api/src/modules/impact-analysis/application/queries/get-analysis-workspace.usecase.spec.ts @@ -0,0 +1,256 @@ +import { analysisWorkspaceResponseSchema } from '@ba-helper/contracts'; +import { GetAnalysisWorkspaceUseCase } from './get-analysis-workspace.usecase'; + +const ids = { + analysis: '00000000-0000-4000-8000-000000000001', + revision: '00000000-0000-4000-8000-000000000002', + snapshot: '00000000-0000-4000-8000-000000000003', + repository: '00000000-0000-4000-8000-000000000004', + artifact: '00000000-0000-4000-8000-000000000005', + link: '00000000-0000-4000-8000-000000000006', + evidence: '00000000-0000-4000-8000-000000000007', + riskInsight: '00000000-0000-4000-8000-000000000008', + qaInsight: '00000000-0000-4000-8000-000000000009', + target: '00000000-0000-4000-8000-000000000010', + job: '00000000-0000-4000-8000-000000000011', + document: '00000000-0000-4000-8000-000000000012', + reportSnapshot: '00000000-0000-4000-8000-000000000013', +}; + +describe('GetAnalysisWorkspaceUseCase', () => { + it('returns AnalysisWorkspaceResponse shape with taxonomy projections', async () => { + const result = await executeWith(createAnalysis()); + + expect(() => analysisWorkspaceResponseSchema.parse(result)).not.toThrow(); + expect(result.overview.analysisId).toBe(ids.analysis); + expect(result.impactGroups[0].artifacts[0].artifactKey).toBe( + 'api:booking.controller.cancel', + ); + expect(result.risks).toHaveLength(1); + expect(result.unknowns).toHaveLength(1); + expect(result.qaScenarios).toHaveLength(1); + }); + + it('does not infer report completion from analysis progress', async () => { + const result = await executeWith( + createAnalysis({ + progress: 100, + documentJobs: [], + reviewedReportSnapshots: [], + }), + ); + + expect(result.overview.status.analysisStatus).toBe('WAITING_FOR_REVIEW'); + expect(result.overview.status.reportStatus).toBe('missing'); + expect(result.reportStatus.status).toBe('missing'); + }); + + it('keeps completed historical output visible when the analysis is stale', async () => { + const result = await executeWith( + createAnalysis({ + status: 'COMPLETED', + sourceTarget: { + id: ids.target, + resolvedRefType: 'BRANCH', + latestObservedCommitSha: 'newer-commit', + }, + documentJobs: [completedDocumentJob()], + reviewedReportSnapshots: [reviewedReportSnapshot()], + }), + ); + + expect(result.overview.status.reportStatus).toBe('completed'); + expect(result.reportStatus.generatedDocumentId).toBe(ids.document); + expect(result.overview.status.driftStatus).toBe('stale'); + expect(result.driftStatus.isStale).toBe(true); + }); + + it('preserves evidence source path, lines, and provenance links', async () => { + const result = await executeWith(createAnalysis()); + const evidence = result.evidenceCards[0]; + + expect(evidence.filePath).toBe('src/booking/booking.controller.ts'); + expect(evidence.lineRange).toEqual({ startLine: 10, endLine: 22 }); + expect(evidence.artifactId).toBe(ids.artifact); + expect(evidence.artifactKey).toBe('api:booking.controller.cancel'); + expect(evidence.linkedInsightIds).toContain(ids.riskInsight); + expect(evidence.linkedTraceabilityLinkIds).toContain(ids.link); + }); + + it('counts pending review items from insight and traceability state', async () => { + const result = await executeWith(createAnalysis()); + + expect(result.reviewQueue).toHaveLength(3); + expect(result.overview.counts.pendingReviewItems).toBe(3); + }); + + it('derives drift independently from lifecycle status', async () => { + const result = await executeWith( + createAnalysis({ + status: 'COMPLETED', + progress: 100, + sourceTarget: { + id: ids.target, + resolvedRefType: 'BRANCH', + latestObservedCommitSha: 'newer-commit', + }, + }), + ); + + expect(result.overview.status.analysisStatus).toBe('COMPLETED'); + expect(result.overview.status.driftStatus).toBe('stale'); + expect(result.driftStatus.snapshotCommitSha).toBe('abc123'); + expect(result.driftStatus.latestObservedCommitSha).toBe('newer-commit'); + }); +}); + +async function executeWith(analysis: any) { + const prisma = { + impactAnalysis: { + findUnique: jest.fn().mockResolvedValue(analysis), + }, + }; + const useCase = new GetAnalysisWorkspaceUseCase(prisma as any); + return useCase.execute(ids.analysis); +} + +function createAnalysis(overrides: Record = {}) { + return { + id: ids.analysis, + status: 'WAITING_FOR_REVIEW', + stage: 'DONE', + progress: 100, + requirementRevision: { + id: ids.revision, + title: 'Paid booking cancellation refund', + rawText: 'Allow users to cancel paid bookings and receive refund.', + normalizedText: 'Cancel paid bookings and create a refund.', + }, + snapshot: { + id: ids.snapshot, + repositoryId: ids.repository, + commitSha: 'abc123', + analyzerVersion: 'nestjs-ts/0.1.0', + profile: { + domain: 'BOOKING', + profileVersion: 'repo-profile@0.1.0', + }, + }, + sourceTarget: { + id: ids.target, + resolvedRefType: 'BRANCH', + latestObservedCommitSha: 'abc123', + }, + insights: [riskInsight(), unknownInsight(), qaInsight()], + traceabilityLinks: [traceabilityLink()], + documentJobs: [], + reviewedReportSnapshots: [], + ...overrides, + }; +} + +function baseEvidence() { + return { + id: ids.evidence, + sourceType: 'CODE', + sourcePath: 'src/booking/booking.controller.ts', + startLine: 10, + endLine: 22, + excerpt: 'cancelPaidBooking(command)', + artifactId: ids.artifact, + artifact: { + artifactKey: 'api:booking.controller.cancel', + }, + }; +} + +function riskInsight() { + return { + id: ids.riskInsight, + insightKey: 'risk:duplicate-refund', + insightType: 'CLAIM', + certainty: 'CONFLICTING', + reviewStatus: 'NEEDS_REVIEW', + title: 'Duplicate refund risk', + description: 'Refund retry behavior may duplicate refund requests.', + reasoning: 'No idempotency evidence is linked.', + metadata: { kind: 'risk', severity: 'high', category: 'payment' }, + evidenceLinks: [{ evidenceId: ids.evidence, evidence: baseEvidence() }], + }; +} + +function unknownInsight() { + return { + id: '00000000-0000-4000-8000-000000000014', + insightKey: 'unknown:refund-policy', + insightType: 'UNKNOWN', + certainty: 'UNKNOWN', + reviewStatus: 'NEEDS_REVIEW', + title: 'Refund policy is unclear', + description: 'Should partial payments receive partial refunds?', + reasoning: 'Policy is absent from code evidence.', + metadata: {}, + evidenceLinks: [{ evidenceId: ids.evidence, evidence: baseEvidence() }], + }; +} + +function qaInsight() { + return { + id: ids.qaInsight, + insightKey: 'qa:cancel-paid-booking', + insightType: 'QA_SCENARIO', + certainty: 'INFERRED', + reviewStatus: 'CONFIRMED', + title: 'Cancel paid booking once', + description: 'Given: A paid booking exists\nWhen: It is cancelled\nThen: One refund request is created', + reasoning: 'Regression target: duplicate refund prevention.', + metadata: {}, + evidenceLinks: [{ evidenceId: ids.evidence, evidence: baseEvidence() }], + }; +} + +function traceabilityLink() { + return { + id: ids.link, + linkBasis: 'EVIDENCED', + reviewStatus: 'NEEDS_REVIEW', + artifact: { + id: ids.artifact, + artifactKey: 'api:booking.controller.cancel', + name: 'BookingController.cancel', + filePath: 'src/booking/booking.controller.ts', + universalKind: 'API_ENDPOINT', + }, + evidenceLinks: [{ evidenceId: ids.evidence, evidence: baseEvidence() }], + reviewDecision: null, + }; +} + +function completedDocumentJob() { + return { + id: ids.job, + status: 'COMPLETED', + error: null, + generatedDocumentId: ids.document, + completedAt: new Date('2026-06-24T00:00:00.000Z'), + updatedAt: new Date('2026-06-24T00:00:00.000Z'), + generatedDocument: { + id: ids.document, + status: 'APPROVED', + updatedAt: new Date('2026-06-24T00:00:00.000Z'), + }, + }; +} + +function reviewedReportSnapshot() { + return { + id: ids.reportSnapshot, + approvedDocumentId: ids.document, + createdAt: new Date('2026-06-24T00:00:00.000Z'), + approvedDocument: { + id: ids.document, + status: 'APPROVED', + updatedAt: new Date('2026-06-24T00:00:00.000Z'), + }, + }; +} diff --git a/apps/api/src/modules/impact-analysis/application/queries/get-analysis-workspace.usecase.ts b/apps/api/src/modules/impact-analysis/application/queries/get-analysis-workspace.usecase.ts new file mode 100644 index 00000000..955d9ae5 --- /dev/null +++ b/apps/api/src/modules/impact-analysis/application/queries/get-analysis-workspace.usecase.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { AppError } from '../../../../shared/app-error'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { mapAnalysisWorkspace } from '../mappers/analysis-workspace.mapper'; + +@Injectable() +export class GetAnalysisWorkspaceUseCase { + constructor(private readonly prisma: PrismaService) {} + + async execute(analysisId: string) { + const analysis = await this.prisma.impactAnalysis.findUnique({ + where: { id: analysisId }, + include: { + requirementRevision: true, + snapshot: { + include: { + profile: true, + }, + }, + sourceTarget: true, + insights: { + include: { + evidenceLinks: { + include: { + evidence: { + include: { + artifact: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'asc' }, + }, + traceabilityLinks: { + include: { + artifact: true, + evidenceLinks: { + include: { + evidence: { + include: { + artifact: true, + }, + }, + }, + }, + reviewDecision: true, + }, + orderBy: { createdAt: 'asc' }, + }, + documentJobs: { + include: { + generatedDocument: true, + }, + orderBy: { updatedAt: 'desc' }, + }, + reviewedReportSnapshots: { + include: { + approvedDocument: true, + }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!analysis) { + throw new AppError( + 'IMPACT_ANALYSIS_NOT_FOUND', + 'Impact analysis not found.', + ); + } + + return mapAnalysisWorkspace(analysis); + } +} diff --git a/apps/api/src/modules/impact-analysis/application/queries/get-impact-diff.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/queries/get-impact-diff.usecase.spec.ts index a42c62d1..353a170d 100644 --- a/apps/api/src/modules/impact-analysis/application/queries/get-impact-diff.usecase.spec.ts +++ b/apps/api/src/modules/impact-analysis/application/queries/get-impact-diff.usecase.spec.ts @@ -31,6 +31,7 @@ describe('GetImpactDiffUseCase', () => { status: 'COMPLETED', derivedFromAnalysisId: 'base-analysis', sourceClarificationId: 'clar-1', + reviewClarificationRequestId: '00000000-0000-4000-8000-000000000111', snapshot: { commitSha: 'def5678' }, }; @@ -113,6 +114,8 @@ describe('GetImpactDiffUseCase', () => { expect(result.comparisonContext.snapshotChanged).toBe(true); expect(result.comparisonContext.baseCommitSha).toBe('abc1234'); expect(result.comparisonContext.currentCommitSha).toBe('def5678'); + expect(result.comparisonContext.sourceClarificationId).toBe('clar-1'); + expect(result.comparisonContext.reviewClarificationRequestId).toBe('00000000-0000-4000-8000-000000000111'); expect(result.summary.addedImpacts).toBe(1); expect(result.summary.removedImpacts).toBe(1); diff --git a/apps/api/src/modules/impact-analysis/application/queries/get-impact-diff.usecase.ts b/apps/api/src/modules/impact-analysis/application/queries/get-impact-diff.usecase.ts index 26324298..4ceef5af 100644 --- a/apps/api/src/modules/impact-analysis/application/queries/get-impact-diff.usecase.ts +++ b/apps/api/src/modules/impact-analysis/application/queries/get-impact-diff.usecase.ts @@ -78,6 +78,7 @@ export class GetImpactDiffUseCase { baseCommitSha: baseAnalysis.snapshot.commitSha, currentCommitSha: currentAnalysis.snapshot.commitSha, sourceClarificationId: currentAnalysis.sourceClarificationId ?? undefined, + reviewClarificationRequestId: currentAnalysis.reviewClarificationRequestId ?? undefined, }; // 2. Diff TraceabilityLinks (Impacted Artifacts) diff --git a/apps/api/src/modules/impact-analysis/application/risks/diagnostic-risk-propagation.spec.ts b/apps/api/src/modules/impact-analysis/application/risks/diagnostic-risk-propagation.spec.ts index 6bc2587d..00feba81 100644 --- a/apps/api/src/modules/impact-analysis/application/risks/diagnostic-risk-propagation.spec.ts +++ b/apps/api/src/modules/impact-analysis/application/risks/diagnostic-risk-propagation.spec.ts @@ -98,13 +98,14 @@ describe('Diagnostic Risk Propagation', () => { pack: { id: 'test-pack', version: '1.0', + status: 'EXPERIMENTAL', concepts: [], retrievalHints: [], riskTemplates: [], qaTemplates: [], unknownTemplates: [], }, - selectedBy: 'default', + selectedBy: 'safe_default', normalizedPackId: 'test-pack', }), }; diff --git a/apps/api/src/modules/impact-analysis/domain/impact-analysis.types.ts b/apps/api/src/modules/impact-analysis/domain/impact-analysis.types.ts index 54d0ebf0..3b00b30b 100644 --- a/apps/api/src/modules/impact-analysis/domain/impact-analysis.types.ts +++ b/apps/api/src/modules/impact-analysis/domain/impact-analysis.types.ts @@ -22,6 +22,7 @@ export type ImpactAnalysisMetadata = { domainPack?: { id: string; version: string; + status: 'STABLE' | 'PARTIAL' | 'EXPERIMENTAL' | 'FALLBACK'; selectedBy: string; }; diagnostics?: Array<{ 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 d2731864..6cb46ef4 100644 --- a/apps/api/src/modules/impact-analysis/impact-analysis.module.ts +++ b/apps/api/src/modules/impact-analysis/impact-analysis.module.ts @@ -74,6 +74,7 @@ import { CreateRequirementRevisionUseCase } from '../requirement/application/cre 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'; @Module({ @@ -138,6 +139,7 @@ import { DomainPackModule } from '../domain-pack/domain-pack.module'; GetImpactAnalysisLineageUseCase, GetReviewCoverageUseCase, GetAnalysisDriftFreshnessUseCase, + GetAnalysisWorkspaceUseCase, { provide: ProjectRepository, useFactory: (prisma: PrismaService) => new ProjectRepository(prisma), diff --git a/apps/api/src/smoke-e2e.ts b/apps/api/src/smoke-e2e.ts index 116b98c0..7ae55267 100644 --- a/apps/api/src/smoke-e2e.ts +++ b/apps/api/src/smoke-e2e.ts @@ -1,5 +1,4 @@ import 'reflect-metadata'; -import 'dotenv/config'; import { approvedImpactReportResponseSchema, currentWorkspaceResponseSchema, diff --git a/apps/api/test/e2e/analysis-list.e2e-spec.ts b/apps/api/test/e2e/analysis-list.e2e-spec.ts index 468c2eae..466052ea 100644 --- a/apps/api/test/e2e/analysis-list.e2e-spec.ts +++ b/apps/api/test/e2e/analysis-list.e2e-spec.ts @@ -12,6 +12,7 @@ import { import { impactAnalysisListResponseSchema, impactAnalysisResponseSchema, + analysisWorkspaceResponseSchema, projectCreateResponseSchema, repositoryCreateResponseSchema, requirementCreateResponseSchema, @@ -114,6 +115,17 @@ describe('Analysis List (E2E)', () => { await seedImpactAnalysisCompletion(prisma, analysis.id); + const workspaceRes = await request(app.getHttpServer()) + .get(`/api/v1/impact-analyses/${analysis.id}/workspace`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + const workspace = analysisWorkspaceResponseSchema.parse(workspaceRes.body); + expect(workspace.overview.analysisId).toBe(analysis.id); + expect(workspace.overview.status.reportStatus).toBe('missing'); + expect(workspace.overview.status.driftStatus).toBe('fresh'); + expect(workspace.reviewQueue).toHaveLength(1); + const listRes = await request(app.getHttpServer()) .get(`/api/v1/projects/${project.projectId}/analyses`) .set('Authorization', `Bearer ${adminToken}`) diff --git a/apps/api/test/e2e/impact-analysis-workspace.e2e-spec.ts b/apps/api/test/e2e/impact-analysis-workspace.e2e-spec.ts new file mode 100644 index 00000000..5cc6a389 --- /dev/null +++ b/apps/api/test/e2e/impact-analysis-workspace.e2e-spec.ts @@ -0,0 +1,140 @@ +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { + analysisWorkspaceResponseSchema, + impactAnalysisResponseSchema, + projectCreateResponseSchema, + repositoryCreateResponseSchema, + requirementCreateResponseSchema, + requirementRevisionCreateResponseSchema, + scanJobResponseSchema, +} from '@ba-helper/contracts'; +import * as crypto from 'crypto'; +import request from 'supertest'; +import { PrismaService } from '../../src/modules/prisma/prisma.service'; +import { createTestApp } from './helpers/test-app'; +import { resetDatabase } from './helpers/reset-db'; +import { + seedImpactAnalysisCompletion, + seedScanJobCompletion, +} from './helpers/seed-fixture'; + +describe('Impact Analysis Workspace (E2E)', () => { + let app: INestApplication; + let prisma: PrismaService; + let jwtService: JwtService; + let adminToken: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + jwtService = app.get(JwtService); + }); + + afterAll(async () => { + await prisma.$disconnect(); + await app.close(); + }); + + beforeEach(async () => { + await resetDatabase(prisma); + const user = await prisma.user.create({ + data: { + id: crypto.randomUUID(), + email: 'admin@ba-helper.local', + name: 'John Doe', + role: 'ADMIN', + }, + }); + adminToken = jwtService.sign({ + sub: user.id, + email: user.email, + role: user.role, + }); + }); + + it('returns the analysis workspace presentation read model', async () => { + const analysisId = await seedAnalysis(); + + const workspaceRes = await request(app.getHttpServer()) + .get(`/api/v1/impact-analyses/${analysisId}/workspace`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + const workspace = analysisWorkspaceResponseSchema.parse(workspaceRes.body); + expect(workspace.overview.analysisId).toBe(analysisId); + expect(workspace.overview.status.reportStatus).toBe('missing'); + expect(workspace.overview.status.driftStatus).toBe('fresh'); + expect(workspace.reviewQueue).toHaveLength(1); + expect(workspace.evidenceCards[0]).toMatchObject({ + filePath: 'src/mock.ts', + lineRange: { startLine: null, endLine: null }, + }); + }); + + async function seedAnalysis() { + const createProjectRes = await request(app.getHttpServer()) + .post('/api/v1/projects') + .set('Authorization', `Bearer ${adminToken}`) + .send({ name: 'Workspace Project' }) + .expect(201); + const project = projectCreateResponseSchema.parse(createProjectRes.body); + + const createRepoRes = await request(app.getHttpServer()) + .post(`/api/v1/projects/${project.projectId}/repositories`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ url: 'https://github.com/mock/repo' }) + .expect(201); + const repository = repositoryCreateResponseSchema.parse(createRepoRes.body); + + const createScanJobRes = await request(app.getHttpServer()) + .post(`/api/v1/repositories/${repository.repositoryId}/scan-jobs`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + requestKey: crypto.randomUUID(), + requestedRef: 'main', + }) + .expect(201); + const scanJob = scanJobResponseSchema.parse(createScanJobRes.body); + const { snapshot, target } = await seedScanJobCompletion(prisma, scanJob.id); + + const createRequirementRes = await request(app.getHttpServer()) + .post(`/api/v1/projects/${project.projectId}/requirements`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'Refund API', + rawText: 'Allow users to cancel and refund bookings.', + }) + .expect(201); + const requirement = requirementCreateResponseSchema.parse( + createRequirementRes.body, + ); + + const createRevisionRes = await request(app.getHttpServer()) + .post(`/api/v1/requirements/${requirement.requirementId}/revisions`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'Refund API (Final)', + rawText: 'Allow users to cancel and refund bookings.', + readinessStatus: 'READY_FOR_ANALYSIS', + }) + .expect(201); + const revision = requirementRevisionCreateResponseSchema.parse( + createRevisionRes.body, + ); + + const createAnalysisRes = await request(app.getHttpServer()) + .post(`/api/v1/requirement-revisions/${revision.revisionId}/impact-analyses`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + snapshotId: snapshot.id, + sourceTargetId: target.id, + allowPartialSnapshot: false, + requestKey: crypto.randomUUID(), + }) + .expect(201); + const analysis = impactAnalysisResponseSchema.parse(createAnalysisRes.body); + await seedImpactAnalysisCompletion(prisma, analysis.id); + return analysis.id; + } +}); diff --git a/apps/web/.env.example b/apps/web/.env.example index 4ab0360c..e0b8a921 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,17 +1,4 @@ -# BA Helper Web — Environment Variables Reference - -NEXT_PUBLIC_API_URL=http://localhost:3001 -INTERNAL_API_URL=http://localhost:3001 -NEXTAUTH_SECRET=replace-with-a-long-random-secret-32-chars-min - -# Set to true to use mock data for UI development without backend -# Note: This is strictly forbidden in production. -NEXT_PUBLIC_USE_MOCK_API=false - -# ========================================== -# Public Preview Guard (Basic Auth) -# ========================================== -# Enables Basic Auth middleware to protect public preview deployments. -PREVIEW_AUTH_ENABLED=false -PREVIEW_USERNAME=demo -PREVIEW_PASSWORD=change-me +# ENVIRONMENT VARIABLES CONSOLIDATED +# +# All environment variables have been consolidated to the project root. +# Please use the `/.env.example` file at the root of the repository to configure this application. diff --git a/apps/web/package.json b/apps/web/package.json index fe2c5a0c..dc4cc932 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", + "dev": "dotenv -e ../../.env -- next dev", + "build": "dotenv -e ../../.env -- next build", + "start": "dotenv -e ../../.env -- next start", "lint": "eslint" }, "dependencies": { diff --git a/apps/web/src/app/(app)/analyses/[analysisId]/_components/analysis-tab-bar.tsx b/apps/web/src/app/(app)/analyses/[analysisId]/_components/analysis-tab-bar.tsx index c1dd676c..efed6782 100644 --- a/apps/web/src/app/(app)/analyses/[analysisId]/_components/analysis-tab-bar.tsx +++ b/apps/web/src/app/(app)/analyses/[analysisId]/_components/analysis-tab-bar.tsx @@ -6,6 +6,7 @@ import { FinalizeAnalysisDialog } from "@/components/workspace/analysis/finalize import { AnalysisHeader } from "@/components/workspace/analysis/analysis-header" import { E2ETimeline } from "@/components/workspace/analysis/e2e-timeline" import type { ImpactAnalysisResponse } from "@ba-helper/contracts" +import { getAnalysisWorkspaceLabels } from "@/lib/i18n/analysis-labels" type TabValue = "insights" | "graph" | "traceability-matrix" | "qa-coverage" | "review-queue" | "diff" | "lineage" @@ -35,6 +36,8 @@ export function AnalysisTabBar({ onTabChange, blockingRemaining, }: AnalysisTabBarProps) { + const labels = getAnalysisWorkspaceLabels().reviewReport.finalizeDialog + const tabClass = (tab: TabValue) => `min-h-10 shrink-0 px-3 py-2 text-sm font-medium border-b-2 transition-colors -mb-px flex items-center gap-1.5 whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-t ${ @@ -144,6 +147,7 @@ export function AnalysisTabBar({ commitSha={analysis.snapshot.commitSha} stats={stats} isStale={analysis.freshness.isStale} + labels={labels} > - ) : ( -

- An Analyst or Owner can rerun this analysis. -

- )} ) } - const isFullHeightTab = ws.currentTab === "graph" || ws.currentTab === "review-queue" || ws.currentTab === "diff" || ws.currentTab === "lineage" || ws.currentTab === "traceability-matrix" - - return ( - ws.setSelection(null)} - onInsightReviewChange={ws.handleInsightReviewChange} - onLinkReviewChange={ws.handleLinkReviewChange} - > - {/* app-page-scroll no longer has default padding. Add explicit padding for normal tabs. */} -
-
- - - -
- -
- {/* Graph tab */} - {ws.currentTab === "graph" && ( -
- {ws.graphLoading ? ( -
Loading graph…
- ) : ws.graphData ? ( - n.isTruncated)} - onNodeSelect={ws.handleGraphNodeSelect} - /> - ) : ( -
- -

No graph data available

-
- )} -
- )} - - {ws.currentTab === "qa-coverage" && ( -
- { - const node = ws.graphData?.nodes.find(n => n.id === `artifact-${artifactId}`) - if (node) ws.handleGraphNodeSelect(node) - }} - /> -
- )} - - {/* Traceability Matrix tab */} - {ws.currentTab === "traceability-matrix" && ( -
- -
- )} - - {/* Review Queue tab — true full bleed layout */} - {ws.currentTab === "review-queue" && ( -
- {ws.reviewQueueLoading ? ( -
- - -
- ) : ws.reviewQueueResponse ? ( - { - if (type === "INSIGHT") ws.handleSelectInsight(ws.insights.find(i => i.id === id)!) - else if (type === "TRACEABILITY_LINK" && artifactId) ws.setSelection({ type: "TRACEABILITY_LINK", linkId: id, artifactId }) - else if (type === "GRAPH_NODE" && artifactId) { - const node = ws.graphData?.nodes.find(n => n.id === `artifact-${artifactId}`) - if (node) ws.setSelection({ type: "GRAPH_NODE", nodeId: node.id, node }) - } - }} - selectedQueueItemId={ws.selectedInsight?.id ?? ws.selectedLink?.id} - /> - ) : ( -
- 📋 -

No review queue data available

-
- )} -
- )} - - {/* Diff tab */} - {ws.currentTab === "diff" && ( - - )} - - {/* Lineage tab */} - {ws.currentTab === "lineage" && ( - - )} - - {/* Insights tab */} - {ws.currentTab === "insights" && ( - ws.setTab("review-queue")} - /> - )} - -
- - {!isFullHeightTab && ( -
- -
- )} -
-
- ) + return } diff --git a/apps/web/src/components/landing/landing-hero.tsx b/apps/web/src/components/landing/landing-hero.tsx index 6a59cd99..720b17f6 100644 --- a/apps/web/src/components/landing/landing-hero.tsx +++ b/apps/web/src/components/landing/landing-hero.tsx @@ -6,18 +6,18 @@ import Link from "next/link" export function LandingHero() { return (
-
-
+
+
Now available for NestJS

Requirement-to-Code
Impact Analyzer

-

+

Map business requirements to backend code artifacts with persisted evidence, explicit unknowns, and human review before approval.

-
+
-