From a28396b821d7425d91617d182f0cac0eb06ca6aa Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Wed, 24 Jun 2026 10:17:06 +0700 Subject: [PATCH 01/22] docs: remove outdated audits and superseded glossary/workflow docs --- docs/agent/CONTEXT_INDEX.md | 3 +- docs/agent/agent-handoff.md | 28 --- docs/agent/analysis-invariants.md | 102 ++++++++++ docs/agent/analysis-output-taxonomy.md | 111 +++++++++++ docs/agent/architecture.md | 6 + docs/agent/audit-workflow.md | 105 ----------- docs/agent/embedding-reuse-audit.md | 231 ----------------------- docs/agent/glossary.md | 55 ------ docs/agent/scoped-authorization-audit.md | 78 -------- 9 files changed, 220 insertions(+), 499 deletions(-) delete mode 100644 docs/agent/agent-handoff.md create mode 100644 docs/agent/analysis-invariants.md create mode 100644 docs/agent/analysis-output-taxonomy.md delete mode 100644 docs/agent/audit-workflow.md delete mode 100644 docs/agent/embedding-reuse-audit.md delete mode 100644 docs/agent/glossary.md delete mode 100644 docs/agent/scoped-authorization-audit.md diff --git a/docs/agent/CONTEXT_INDEX.md b/docs/agent/CONTEXT_INDEX.md index 835d9533..87b23710 100644 --- a/docs/agent/CONTEXT_INDEX.md +++ b/docs/agent/CONTEXT_INDEX.md @@ -38,12 +38,11 @@ Read: - `auth-permissions.md` - `api-contracts.md` - `current-state.md` -- `scoped-authorization-audit.md` ### Report / export Read: -- `audit-workflow.md` — Audit invariants for reviewed report snapshots, review completion gate, final reviewed report export, and failure modes. +- `analysis-invariants.md` — Audit invariants for reviewed report snapshots, review completion gate, final reviewed report export, and failure modes. - `api-contracts.md` - `use-cases.md` - `current-state.md` diff --git a/docs/agent/agent-handoff.md b/docs/agent/agent-handoff.md deleted file mode 100644 index b0cceafc..00000000 --- a/docs/agent/agent-handoff.md +++ /dev/null @@ -1,28 +0,0 @@ -# Agent Handoff Template - -Use this for short, reusable handoffs. - -```md -# Handoff: [task title] - -**Agent**: [name] -**Task**: [one sentence] -**Files changed**: -- [path] -- [path] - -## What changed -- [brief bullet] -- [brief bullet] - -## Verification -- [command/result] -- [command/result] - -## Risks / follow-ups -- [risk or note] -- [risk or note] - -## Next recommended task -- [next task] -``` diff --git a/docs/agent/analysis-invariants.md b/docs/agent/analysis-invariants.md new file mode 100644 index 00000000..1a326eac --- /dev/null +++ b/docs/agent/analysis-invariants.md @@ -0,0 +1,102 @@ +# Analysis Invariants + +## Core Invariant Map + +The analysis product model is built around traceability: + +```text +RequirementRevision +-> RepositorySnapshot +-> Evidence +-> TraceabilityLink / BaInsight / QA Scenario / Risk / Unknown +-> ReviewDecision +-> ReviewedReportSnapshot +``` + +These invariants apply to backend behavior, API read models, UI rendering, and +generated reports. + +## Evidence And Certainty + +- `EVIDENCED` insight must link to at least one persisted `Evidence` record. +- `INFERRED` insight must be visually separated from `EVIDENCED` insight. +- `UNKNOWN` is not `RISK`. +- `RISK` is not `QUESTION`. +- `CONFLICTING` must represent incompatible or ambiguous evidence, not a weak + confidence score. +- Missing policy or missing code support becomes `UNKNOWN`, `CONFLICTING`, or a + stakeholder question, never an invented business rule. + +## QA Traceability + +- QA scenario must link to at least one risk, unknown, evidence item, or + affected artifact. +- QA scenario text should be testable as given/when/then or an equivalent + explicit regression target. +- QA scenario generation must preserve snapshot and requirement revision + provenance. + +## Review And Reports + +- Final report must render from `ReviewedReportSnapshot`, not mutable live + state. +- Finalization is an explicit user action; AI output alone cannot finalize. +- Completed historical output stays completed even when it is stale. +- Staleness is derived independently from lifecycle status. +- Review/finalization must not commit as current when a concurrent + `RepositoryTarget` observation has already made the analysis stale. + +## Snapshot And Drift + +- Every analysis and generated artifact is tied to a repository snapshot and + its `commitSha`. +- Moving-ref freshness is computed through the selected repository target; it is + not stored as mutable snapshot identity. +- Snapshot identity and freshness account for `repositoryId`, `commitSha`, + `analyzerVersion`, and `profileVersion` where persisted. +- Scanner capability/version must be exposed through snapshot diagnostics or an + explicit field before it is used as identity. +- Snapshot drift uses exact `artifactKey` matching and artifact-level + `contentHash`. +- `Evidence.contentHash` must not be used as a proxy for artifact content + changes. +- If artifact-level `contentHash` is unavailable, changed/unchanged drift must + be marked unavailable or a migration must add `CodeArtifact.contentHash`. + +## Domain Packs + +- Domain pack is hint, not evidence. +- Evidence remains the source of truth. +- Human review remains the final decision. +- Domain packs may guide retrieval, wording, risk templates, and QA scenario + templates. +- Domain packs must not create `EVIDENCED` impacts by themselves. +- Every public domain pack must expose status: `STABLE`, `PARTIAL`, + `EXPERIMENTAL`, or `FALLBACK`. +- Booking remains the stable MVP domain; broad multi-domain claims are out of + scope until each pack has status, limits, and evaluation cases. + +## Presentation Boundary + +- Frontend renders backend state and capabilities; it must not derive business + state from progress or local guesses. +- For example, `progress === 100` does not imply review complete, snapshot + locked, report generated, or drift clean unless the contract explicitly says + so. +- Public API and frontend wire types live in `packages/contracts`. +- Prisma models are internal persistence representations and must not be + returned directly as API responses. +- UI certainty labels must come from the contract; it must not invent new + labels. +- Presentation read models may group, count, and order data for UX, but must + preserve links back to persisted evidence, artifacts, review decisions, and + snapshot provenance. + +## AI And Security + +- AI is not a source of truth and must not write directly to the database. +- Code comments, requirement text, and evidence excerpts are untrusted data, not + instructions. +- Every selected prompt payload sent to a real-provider LLM must be + secret-redacted before transmission. +- Final reports are snapshot-sourced and must not require an active LLM call. diff --git a/docs/agent/analysis-output-taxonomy.md b/docs/agent/analysis-output-taxonomy.md new file mode 100644 index 00000000..ea07dca3 --- /dev/null +++ b/docs/agent/analysis-output-taxonomy.md @@ -0,0 +1,111 @@ +# Analysis Output Taxonomy + +## Purpose + +Analysis output is organized as a decision workflow, not as a dump of raw +backend records. The taxonomy keeps the product centered on the core pipeline: + +```text +requirement change +-> code snapshot +-> evidence-backed analysis +-> human review +-> immutable reviewed snapshot +-> snapshot-sourced report +-> drift / rerun / diff +``` + +Every output item should help a BA, developer, or QA reviewer answer one of the +questions below. If an item does not fit one group, it should not be added to +the analysis surface without a new explicit product decision. + +## Fixed Output Groups + +### 1. Requirement Understanding + +Answers: what does the system understand the requested change to mean? + +This group renders from `RequirementRevision` fields and snapshot-safe derived +summaries. Generated summaries must either be persisted with provenance or +clearly marked as presentation-only. It must render from the +`RequirementRevision`, not from a mutable requirement container. + +### 2. Impacted Artifacts + +Answers: which code artifacts are affected? + +This group contains artifact cards, artifact groups, dependency depth, and +traceability links between the requirement and code artifacts. It must preserve +snapshot provenance: repository, snapshot id, commit SHA, analyzer version, and +profile version when available. + +### 3. Evidence Map + +Answers: why does the system believe an artifact, behavior, or risk is relevant? + +This group contains persisted evidence excerpts, source location, retrieval +signals, and links from evidence to insights, traceability links, risks, and QA +scenarios. + +Evidence excerpts are untrusted source data: + +- Stored evidence may preserve source excerpts only if the project policy + allows it. +- Any evidence shown to users, logs, diagnostics, or real-provider AI must pass + through the redaction layer. + +### 4. Risks And Unknowns + +Answers: what is risky, unsupported, conflicting, or still unclear? + +This group separates risks, unknowns, conflicts, and stakeholder questions. +Missing support must not be upgraded into a confident business rule. Unknowns +are useful when they identify missing policy, missing evidence, partial scan +coverage, or conflicting signals that require review. + +Risk items may initially be projected from existing insight records, but the +projection rule must be explicit. If risks become a first-class persisted +output, the persistence model must introduce an explicit `RISK` type instead +of overloading `CLAIM`. + +### 5. QA Scenarios + +Answers: what should QA test because of this change? + +This group contains scenario cards with testable given/when/then content, +regression target, and links back to at least one affected artifact, risk, +unknown, or evidence item. QA scenarios must be traceable to the analysis +surface; they are not standalone generated suggestions. + +### 6. Review Decisions + +Answers: what has a human accepted, rejected, or sent back for more evidence? + +This group contains the review queue, decisions, reviewer notes, finalization +readiness, and report snapshot status. Review/finalization is an explicit user +action. Machine output alone never finalizes an analysis. + +Reviewed traceability state must include link id, artifact identity, review +decision, decision basis, reviewer note if applicable, and evidence +references. Report generation must consume that reviewed state from the +snapshot payload instead of querying live traceability records. + +## Presentation Order + +The default workspace order is: + +```text +Overview -> Impact Map -> Evidence -> Risks & QA -> Review & Report +``` + +Each view should answer one primary question. Advanced diagnostics and debug +signals should be available but collapsed by default. + +## Out Of Scope + +- General repository documentation. +- Code smell dashboards. +- New language or framework support without fixtures and evaluation cases. +- Multi-domain expansion before the booking pack and general fallback remain + stable. +- UI-only derivation of business state from raw backend objects. diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index a470b943..d4392e73 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -65,6 +65,12 @@ Input quality is part of domain correctness. Invalid repository URLs, unsupported frameworks, and non-actionable change requests do not enter impact analysis; see [input-quality.md](input-quality.md). +Analysis output must follow the product model in +[analysis-output-taxonomy.md](analysis-output-taxonomy.md) and the audit rules +in [analysis-invariants.md](analysis-invariants.md). These docs define the +fixed analysis groups, evidence boundaries, reviewed snapshot semantics, and +presentation boundary for later workspace read models. + ## Backend Boundaries Use modules by capability, not by generic technical layer: diff --git a/docs/agent/audit-workflow.md b/docs/agent/audit-workflow.md deleted file mode 100644 index bf0fddbc..00000000 --- a/docs/agent/audit-workflow.md +++ /dev/null @@ -1,105 +0,0 @@ -# Audit Workflow & Invariants - -This document outlines the invariants, lifecycle policies, and trust model for the Audited Report Workflow. This system guarantees that final exports are 100% human-verified and strictly tied to an immutable reviewed snapshot after the final audit gate is passed. - -## Core Invariants and Trust Model - -The overarching reliability story of this architecture is built on absolute immutability and enforced human review. The system does not blindly trust machine output. Instead, it forces human review, captures that review context in a frozen deterministic snapshot, and uses that strict snapshot as the sole basis for final artifact generation. - -### 1. Source-of-Truth Matrix - -| Stage | Source of Truth | Mutable? | -| :--- | :--- | :--- | -| **Draft approved report** | `GeneratedDocument` | **Yes** / can become stale | -| **Reviewed snapshot** | `ReviewedReportSnapshot` | **No** (immutable reviewed snapshot) | -| **Final reviewed report API** | `ReviewedReportSnapshot` | **No** (derived from snapshot only) | -| **Downloaded .md** | Final reviewed report API | **No** local mutation by system | - -### 2. Non-Goals / Boundaries - -The final export pipeline is deliberately walled off from volatile processes: -- **No LLM during final export:** The final generation process must not call any generative AI models. -- **No retrieval during final export:** The system must not run vector searches or hit raw repository snapshots. -- **No live report rebuild during final export:** The system must not rebuild the report from live state (which could be stale or altered). -- **No mutation when viewing/downloading:** Viewing or exporting a final report are strictly side-effect-free, read-only operations. - -## Lifecycle Details - -### Review Decision Lifecycle -Analysts review individual `TraceabilityLink` and `Evidence` records. Every link is required to have a decision attached before it is considered "complete". - -The complete set of valid decisions: -1. `ACCEPTED` — The evidence is valid and correctly linked. -2. `REJECTED` — The evidence is incorrect or unrelated to the impact. -3. `NEEDS_REVIEW` — Default state; the human has not yet verified the impact. -4. `NEEDS_MORE_EVIDENCE` — The impact is correct, but the currently extracted evidence is insufficient or missing. - -### Reviewed Snapshot Immutability -When an analyst initiates the "Take Snapshot" action, the system records the exact `reviewCompletion` metrics, the live analysis context, and the human decisions at that exact millisecond. -This produces a `ReviewedReportSnapshot` entity. This entity is **append-only and fully immutable**. Any subsequent changes made by an analyst (such as changing an `ACCEPTED` to a `REJECTED`) will mutate the live `TraceabilityLink`, but will **never** alter the previously taken `ReviewedReportSnapshot`. - -### Review Completion Gate -The Final Reviewed Report workflow is protected by a strict audit gate (`ReviewCompletionGate`). This gate must evaluate to `isComplete === true` before any human is allowed to view or export the final report. - -The gate evaluates two primary assertions: -1. All traceability links have been resolved (i.e. `unreviewed === 0`). -2. A valid, matching `ReviewedReportSnapshot` exists for the current analysis. - -### Final Reviewed Report Source-of-Truth Rule -When generating the final markdown, the system **bypasses all live `GeneratedDocument` data**. Instead, it looks exclusively at the deterministic state captured inside the `ReviewedReportSnapshot`. This guarantees that what the human downloaded on Friday matches exactly what they reviewed on Thursday, even if the underlying `ScanJob` was rerun on Saturday. - -### Export/Download Rule -The exported markdown file (`final-reviewed-report-{analysisId}-{snapshotId}.md`) is deterministic. It is constructed entirely from the frozen snapshot context and directly served as a raw Blob. The frontend component acts merely as a gateway, fetching the JSON payload and generating the `.md` file download trigger without any interim mutations or state derivations. - -## Failure Modes - -The `ReviewCompletionGate` enforces strict failure conditions, surfacing clear `blockingReasons`: - -- **Unreviewed Links (`UNREVIEWED_TRACEABILITY_LINKS`):** The analyst has not assigned a valid decision (`ACCEPTED`, `REJECTED`, or `NEEDS_MORE_EVIDENCE`) to every active `TraceabilityLink`. -- **Missing Snapshot (`REVIEWED_SNAPSHOT_MISSING`):** The analyst has completed the review but has not executed the explicit "Take Snapshot" action. -- **Post-Snapshot Decision Drift:** If an analyst alters a decision *after* taking a snapshot, the live state diverges from the frozen state. The backend tests enforce that the snapshot and final report *do not drift* and remain permanently locked to the historical record. - -## Architecture and Sequence Flow - -```mermaid -sequenceDiagram - actor Analyst - participant API as NestJS API - participant DB as Postgres (Prisma) - participant UI as Next.js UI - - Analyst->>API: Review Traceability Link - API->>DB: Update Decision (ACCEPTED/REJECTED/etc) - - Analyst->>API: Take Snapshot - API->>DB: Read all live Decisions - API->>DB: Insert ReviewedReportSnapshot (Immutable) - - Analyst->>UI: Request Final Report Download - UI->>API: GET /review-completion - API->>DB: Check unreviewed count & snapshot presence - - alt isComplete == false - API-->>UI: blockingReasons (UNREVIEWED / MISSING SNAPSHOT) - UI-->>Analyst: Download Button Disabled - else isComplete == true - API-->>UI: isComplete: true - UI->>API: GET /final-reviewed-report - API->>DB: Fetch ReviewedReportSnapshot - API-->>UI: Frozen Markdown Payload - UI-->>Analyst: Blob Download (.md) - end -``` - -## Invariant Test Coverage - -This absolute immutability is proven by comprehensive test suites: - -- **E17A Backend Tests (`final-reviewed-report.audit-flow.e2e-spec.ts`):** - - Asserts that missing snapshots and unreviewed links block the gate. - - Asserts that post-snapshot modifications to decisions do not affect the exported report. - - Asserts that final reports are derived purely from snapshot payloads. - -- **E17B Frontend Tests (`final-review-gate-panel.test.tsx` / `final-reviewed-report-viewer.test.tsx`):** - - Asserts that incomplete gate states visually disable export functionality. - - Asserts that complete states correctly dispatch the frozen markdown Blob to the user without frontend manipulation. diff --git a/docs/agent/embedding-reuse-audit.md b/docs/agent/embedding-reuse-audit.md deleted file mode 100644 index 40b32e5e..00000000 --- a/docs/agent/embedding-reuse-audit.md +++ /dev/null @@ -1,231 +0,0 @@ -# Embedding Chunk Reuse Feasibility Audit - -**Phase:** 31D-0 (Audit) / 31D-1 (Schema Foundation Completed) -**Date:** 2026-06-11 (Audit) / 2026-06-12 (Foundation) -**Auditor:** Antigravity Agent -**Status:** 31D-1 COMPLETE — `chunkerVersion` blocker resolved. Actual reuse not yet implemented. - ---- - -## 1. Current Schema Summary - -### `EmbeddingChunk` model (`apps/api/prisma/schema.prisma:690`) - -| Field | Type | Present | Notes | -|---|---|---|---| -| `tenantId` | String (uuid) | ✅ | Indexed. MVP: tenantId = projectId | -| `projectId` | String (uuid) | ✅ | Indexed | -| `repositoryId` | String (uuid) | ✅ | Indexed | -| `snapshotId` | String (uuid) | ✅ | Indexed. FK → `RepositorySnapshot` | -| `artifactId` | String? (uuid) | ✅ | Nullable FK → `CodeArtifact`. Indexed. | -| `stableChunkId` | String | ✅ | `"{snapshotId}:{artifactKey}:{chunkType}"` | -| `commitSha` | String | ✅ | Indexed | -| `embeddingModel` | String | ✅ | Set from `EmbeddingResult.model` at generation time | -| `content` | String | ✅ | Redacted before storage | -| `contentHash` | String | ✅ | SHA-256 of the redacted content | -| `tokenCount` | Int | ✅ | Rough estimate (`length / 4`) | -| `embedding` | `vector(1536)` | ✅ | pgvector column | -| `createdAt` | DateTime | ✅ | | -| **`chunkerVersion`** | String? | ✅ **ADDED (31D-1)** | `'artifact-chunker@0.1.0'` for new chunks; `null` for legacy rows (not reuse-eligible) | -| **`analyzerVersion`** | — | ❌ **MISSING** | No field linking to the scanner/analyzer version used during extraction | - -**Idempotency key:** `@@unique([snapshotId, stableChunkId, embeddingModel])` - -**RAG query isolation (present in `searchSimilar`):** `tenantId`, `projectId`, `repositoryId`, `snapshotId` — all four filters are enforced. ✅ - ---- - -## 2. Current Embedding Generation Flow - -``` -RunScanJobUseCase - └─ snapshot upserted (empty diagnostics) - └─ artifacts persisted (CodeArtifact rows) - └─ Evidence rows persisted (with excerpt) - └─ snapshot.indexStatus = LEXICAL_READY - └─ queueService.enqueueSnapshotEmbedding(snapshotId) - │ - ▼ -EmbedSnapshotArtifactsUseCase (worker process) - 1. Load RepositorySnapshot → get projectId, repositoryId, commitSha - 2. snapshot.indexStatus = VECTOR_INDEXING - 3. Load all CodeArtifact rows (with evidences) for this snapshot - 4. Build chunks via ArtifactChunkBuilder.build() - stableChunkId = "{snapshotId}:{artifactKey}:{chunkType}" - content = [artifactKey, symbol, type, file, excerpt].join('\n') - contentHash = SHA-256(content) - 5. listBySnapshot(snapshotId, embeddingProvider.providerName) - → existing chunks for THIS snapshot only (idempotent re-run guard) - 6. Filter: skip if stableChunkId already present with same contentHash - 7. Redact secrets via AiPolicy.redactPayload - 8. embeddingProvider.embed(redacted texts) - → returns { embeddings[][], model: string, dimensions } - 9. insertMany(chunks) with ON CONFLICT DO UPDATE (upsert) - 10. snapshot.indexStatus = VECTOR_READY -``` - -**Key observation:** The cache check in step 5–6 only looks within the **same** snapshot. It does not read from any previous snapshot. No cross-snapshot chunk reading currently occurs. - -### `ArtifactChunkBuilder` details - -- `stableChunkId` includes `snapshotId`, making it **inherently snapshot-scoped**. -- `chunkType` is derived from `artifactType` via a static mapping table (`mapArtifactType`). -- No chunker strategy version is embedded in the `stableChunkId` or stored as a schema field. - ---- - -## 3. Snapshot-Scoped Chunk Reuse: Safety Assessment - -### What "reuse" means in this context - -Instead of calling the embedding API for an unchanged artifact, copy the vector row from the **previous** snapshot to the **new** snapshot, updating `snapshotId` and `artifactId` to point to the new snapshot's records. - -### Pre-conditions for safe reuse - -| Condition | Current State | Gap? | -|---|---|---| -| Isolation: RAG queries filter by `snapshotId` | ✅ Enforced in `searchSimilar` | None | -| New snapshot owns its own chunk rows (no shared rows) | ✅ `stableChunkId` includes `snapshotId` — copying creates new rows | None | -| `contentHash` match validates unchanged content | ✅ `contentHash` present on both `CodeArtifact` and `EmbeddingChunk` | None | -| Embedding model version must match | ✅ `embeddingModel` field stored per chunk, used in `listBySnapshot` filter | None | -| Chunk strategy/chunker version must match | ❌ **No `chunkerVersion` field.** If `ArtifactChunkBuilder` logic changes between scans, reused vectors would silently represent different chunk text than what the model was trained on | **BLOCKER** | -| Analyzer version compatibility validated | ❌ `EmbeddingChunk` has no `analyzerVersion` field. `EMBEDDING_REUSE_PLAN` in `INCREMENTAL_SCAN_SUMMARY` already captures `VERSION_CHANGED_REVIEW_REQUIRED` — but this information is not stored on the chunk itself | **MINOR GAP** | -| Reused chunks point to current `snapshotId` | ✅ Will be enforced by copy logic (not yet written) | None (future) | -| Reused chunks point to current `CodeArtifact.id` | ✅ Will be enforced by copy logic (not yet written) | None (future) | -| No query reads old snapshot chunks directly | ✅ All queries filter by `snapshotId` | None | - -### Verdict: **CONDITIONALLY SAFE — pending Phase 31D implementation** - -**Phase 31D-1 resolved the blocking gap.** `chunkerVersion` is now: -- Present as a nullable column on `EmbeddingChunk` -- Populated with `CHUNK_BUILDER_VERSION = 'artifact-chunker@0.1.0'` on every new chunk -- `null` on all pre-31D-1 legacy rows -- Excluded from `ON CONFLICT DO UPDATE` so idempotent re-inserts never silently change the recorded version -- Returned by `listBySnapshot` for future reuse eligibility checks - -**Legacy chunk rule (enforced by design):** -> Chunks with `chunkerVersion = null` or any value ≠ `CHUNK_BUILDER_VERSION` are NOT reuse-eligible. -> They remain fully valid for retrieval (RAG queries do not filter by `chunkerVersion`). - -Actual vector/chunk copying is not yet implemented. No retrieval behavior was changed. - ---- - -## 4. Required Schema Gaps - -### ~~Gap 1 (BLOCKER): `chunkerVersion` missing from `EmbeddingChunk`~~ — RESOLVED in Phase 31D-1 - -**Resolution:** -- `chunkerVersion String?` added to `EmbeddingChunk` (migration `20260611173129_add_embedding_chunker_version`) -- `CHUNK_BUILDER_VERSION = 'artifact-chunker@0.1.0'` exported from `ArtifactChunkBuilder` -- Every new chunk persists this value via `insertMany` -- `listBySnapshot` now returns `chunkerVersion` -- `ON CONFLICT DO UPDATE` intentionally excludes `chunkerVersion` (creation-time value is immutable) - -### Gap 2 (MINOR): `analyzerVersion` not stored on `EmbeddingChunk` - -The chunk content includes artifact data (symbolName, filePath, excerpt) derived from a specific analyzer run. If analyzer extraction logic changes but `contentHash` is the same (e.g. filePath normalization change), the chunk content can silently drift. - -**Assessment:** Lower risk than chunker version because `contentHash` on `CodeArtifact` already gates reuse in the `EMBEDDING_REUSE_PLAN` — a hash change means ineligible. The `INCREMENTAL_SCAN_SUMMARY` `VERSION_CHANGED_REVIEW_REQUIRED` flag also provides a signal. Can be deferred to Phase 31D-2. - -**Recommended addition (deferred):** -```prisma -model EmbeddingChunk { - analyzerVersion String? -} -``` - ---- - -## 5. Required Code Changes for Actual Reuse - -Once Gap 1 is resolved, these changes would be needed to implement reuse: - -### 5a. `ArtifactChunkBuilder` — export version constant - -```typescript -export const CHUNK_BUILDER_VERSION = 'artifact-chunk-builder@0.1.0'; -``` - -### 5b. `EmbeddingChunkRepository` — add `copyChunks` method - -```typescript -async copyChunks(params: { - sourceSnapshotId: string; - targetSnapshotId: string; - targetArtifactIdByKey: Map; // artifactKey -> new CodeArtifact.id - chunkerVersion: string; - embeddingModel: string; -}): Promise // returns count of copied chunks -``` - -This method must: -- Read source chunks by `sourceSnapshotId` + `chunkerVersion` + `embeddingModel` -- Write new rows with `targetSnapshotId` and the new `artifactId` from `targetArtifactIdByKey` -- Preserve all vector data, `contentHash`, `tokenCount` -- Use `ON CONFLICT DO NOTHING` (idempotent) -- Never read from old snapshot in any RAG query path — this copy is write-time only - -### 5c. `EmbedSnapshotArtifactsUseCase` — consume `EMBEDDING_REUSE_PLAN` - -When a new snapshot has a persisted `EMBEDDING_REUSE_PLAN` diagnostic with `reuseMode: 'PLAN_ONLY'` and eligible artifacts, the use case should: -1. Load the reuse plan from `snapshot.diagnostics` -2. Call `chunkRepo.copyChunks()` for eligible artifacts -3. Still call `embeddingProvider.embed()` for ineligible artifacts (ADDED / CHANGED / HASH_UNAVAILABLE) -4. Persist metrics (reused count vs. re-embedded count) as a diagnostic - -### 5d. `EmbeddingChunkRepository.listBySnapshot` — include `chunkerVersion` - -```typescript -async listBySnapshot(snapshotId: string, embeddingModel: string, chunkerVersion: string) -``` - ---- - -## 6. Recommended Next Phase - -### → Phase 31D: Actual Snapshot-Scoped Chunk Reuse - -**Phase 31D-1 is complete.** The schema foundation is in place. - -Phase 31D scope: -- Implement `EmbeddingChunkRepository.copyChunks(sourceSnapshotId, targetSnapshotId, artifactIdMap, chunkerVersion, embeddingModel)` -- In `EmbedSnapshotArtifactsUseCase`, load the `EMBEDDING_REUSE_PLAN` diagnostic from `snapshot.diagnostics` -- For each eligible artifact in the plan: call `copyChunks` instead of embedding -- Still call `embeddingProvider.embed()` for ineligible artifacts (ADDED / CHANGED / HASH_UNAVAILABLE / null chunkerVersion) -- Persist a reuse metrics diagnostic (reused count vs. re-embedded count) -- Tests must cover: copy path, ineligible path, mixed path, version mismatch path - -**Pre-conditions for Phase 31D (all met after 31D-1):** -- [x] `chunkerVersion` stored per chunk -- [x] `CHUNK_BUILDER_VERSION` exported from builder -- [x] `listBySnapshot` returns `chunkerVersion` -- [x] `EMBEDDING_REUSE_PLAN` diagnostic computed per scan -- [x] RAG isolation enforced on all queries (no cross-snapshot reads) -- [ ] `copyChunks` method implemented (Phase 31D) - ---- - -## RAG Isolation Invariants (Preserved) - -All invariants from `AGENTS.md` remain intact after Phase 31D-1: - -- Every vector query still filters by: `tenantId`, `projectId`, `repositoryId`, `snapshotId` -- Embedding chunks from old snapshots are **never read** by any RAG query path -- Reuse copies produce new rows owned by the new snapshot — no shared rows -- MVP: `tenantId = projectId` (no change) -- Future: `tenantId = organizationId` (not affected by this schema addition) - ---- - -## 7. Versioning Rules - -**Rule:** `CHUNK_BUILDER_VERSION` must be bumped whenever any of these change: -* chunk text assembly -* chunk field ordering -* chunk labels/headings -* redaction behavior that affects embedded text -* chunk type generation logic -* artifact-to-chunk mapping logic - -If redaction logic evolves separately, consider adding a future `redactionPolicyVersion` field. diff --git a/docs/agent/glossary.md b/docs/agent/glossary.md deleted file mode 100644 index 326e21c5..00000000 --- a/docs/agent/glossary.md +++ /dev/null @@ -1,55 +0,0 @@ -# Glossary - -```text -Repository - A configured source-code repository identity, such as a GitHub URL. - -RepositoryTarget - A selected branch, tag, or pinned commit identity whose latest successful - safe source-resolution observation provides freshness context for an - analysis, even when later extraction fails. - -RepositorySnapshot - A published, usable immutable extracted state of a repository at one commit - SHA and analyzer version. Processing failures remain ScanJob state. - -ScanJob - An asynchronous execution that produces or attempts to produce a snapshot. - -CodeArtifact - An extracted API route, method, service, entity, DTO, test, or related symbol. - -DependencyEdge - A supported or inferred relationship between two code artifacts. - -Evidence - A traceable supporting record with explicit source origin, such as code, - test, requirement input, coverage finding, static extraction, or human note. - -Requirement - The identity/container for a user supplied change request. - -RequirementRevision - An immutable submitted title plus raw and normalized version of a - requirement, with readiness validation outcome, used as an analysis input. - -ImpactAnalysis - Evaluation of one analysis-ready requirement revision against one repository - snapshot. - -BaInsight - A BA-facing fact, inferred impact, unknown, conflict, risk, question, - acceptance criterion, or QA scenario. - -TraceabilityLink - An evidenced or inferred relationship between an analysis and an affected - code artifact; unknown behavior is not represented as a link. - -ReviewStatus - Human workflow status independent from machine certainty. - -Stale - A freshness projection indicating output is not based on the latest - successfully observed commit for its moving target ref. It does not replace - lifecycle status or promise live remote monitoring. -``` diff --git a/docs/agent/scoped-authorization-audit.md b/docs/agent/scoped-authorization-audit.md deleted file mode 100644 index def9d3c4..00000000 --- a/docs/agent/scoped-authorization-audit.md +++ /dev/null @@ -1,78 +0,0 @@ -# Scoped Authorization Audit - -Last updated: 2026-06-07 - -Use code as source of truth if this table drifts. - -## Summary - -Phase 15A, 15B, 15C, and 15D foundation are implemented. - -Current backend authorization model: - -- public routes stay explicit -- authenticated non-project routes use global auth only -- project-owned routes must enforce `ProjectPermissionService` -- outside membership scope returns `404` -- same-project insufficient role returns `403` - -## Route Coverage - -| Method | Path | Resource | Project-owned? | Current enforcement | Expected permission | Status | -|---|---|---|---|---|---|---| -| `POST` | `/api/v1/auth/dev-login` | dev auth bootstrap | No | `@Public()` + env gate | Public | PUBLIC | -| `GET` | `/api/v1/auth/me` | current actor | No | auth only | Authenticated global | OK | -| `GET` | `/api/v1/system/health` | system health | No | `@Public()` | Public | PUBLIC | -| `GET` | `/api/v1/workspace/current` | workspace bootstrap | Mixed | `@Public()` + optional bearer parse | Public bootstrap | PUBLIC | -| `POST` | `/api/v1/projects` | project create | Platform/project bootstrap | global `ADMIN` | Platform/admin | PLATFORM | -| `GET` | `/api/v1/projects/:projectId/repositories` | repository list | Yes | `assertCanReadProject` | `project:read` | OK | -| `POST` | `/api/v1/projects/:projectId/repositories` | repository create | Yes | global `ADMIN` + `assertPermission(..., repository:manage)` | `repository:manage` | OK | -| `GET` | `/api/v1/projects/:projectId/repositories/:repositoryId` | repository detail | Yes | `assertCanReadRepository` | `project:read` | OK | -| `GET` | `/api/v1/repositories/:repositoryId/scan-jobs/:scanJobId` | scan job detail | Yes | `assertCanReadScanJob` | `project:read` | OK | -| `POST` | `/api/v1/repositories/:repositoryId/scan-jobs` | scan queue | Yes | global `ADMIN` + `assertPermissionForRepository(..., scan:run)` | `scan:run` | OK | -| `GET` | `/api/v1/projects/:projectId/requirements` | requirement list | Yes | `assertCanReadProject` | `project:read` | OK | -| `POST` | `/api/v1/projects/:projectId/requirements` | requirement create | Yes | global `ADMIN` + `assertPermission(..., requirement:create)` | `requirement:create` | OK | -| `GET` | `/api/v1/projects/:projectId/requirements/:requirementId` | requirement detail | Yes | `assertCanReadRequirement` | `project:read` | OK | -| `POST` | `/api/v1/requirements/:requirementId/revisions` | revision create | Yes | global `ADMIN` + `assertPermissionForRequirement(..., requirement:create)` | `requirement:create` | OK | -| `POST` | `/api/v1/requirement-revisions/:revisionId/qualify` | revision qualify | Yes | global `ADMIN` + `assertPermissionForRequirementRevision(..., requirement:create)` | `requirement:create` | OK | -| `GET` | `/api/v1/projects/:projectId/analyses` | analysis list | Yes | `assertCanReadProject` | `project:read` | OK | -| `POST` | `/api/v1/requirement-revisions/:revisionId/impact-analyses` | base analysis create | Yes | global `ADMIN` + `assertPermissionForRequirementRevision(..., analysis:create)` | `analysis:create` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId` | analysis detail | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `POST` | `/api/v1/impact-analyses/:analysisId/finalize` | analysis finalize | Yes | global `ADMIN` + `assertPermissionForAnalysis(..., analysis:finalize)` | `analysis:finalize` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/graph` | impact graph | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/qa-coverage` | QA coverage | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/review-queue` | review queue | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/diff` | impact diff | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/lineage` | lineage timeline | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/review-decisions` | review decision history | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/review-decisions/latest` | latest review decision | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `POST` | `/api/v1/impact-analyses/:analysisId/review-decisions` | review decision write | Yes | global `ADMIN/REVIEWER` + `assertPermissionForAnalysis(..., review:write)` | `review:write` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/clarifications` | clarification list | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `POST` | `/api/v1/impact-analyses/:analysisId/clarifications` | clarification create | Yes | global `ADMIN/REVIEWER` + `assertPermissionForAnalysis(..., clarification:write)` | `clarification:write` | OK | -| `PATCH` | `/api/v1/clarifications/:id/answer` | clarification answer | Yes | global `ADMIN/REVIEWER` + `assertPermissionForClarification(..., clarification:write)` | `clarification:write` | OK | -| `PATCH` | `/api/v1/clarifications/:id/dismiss` | clarification dismiss | Yes | global `ADMIN/REVIEWER` + `assertPermissionForClarification(..., clarification:write)` | `clarification:write` | OK | -| `POST` | `/api/v1/clarifications/:id/convert-to-revision` | clarification convert | Yes | global `ADMIN` + `assertPermissionForClarification(..., requirement:create)` | `requirement:create` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/review-clarifications` | review clarification list | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `POST` | `/api/v1/impact-analyses/:analysisId/review-clarifications` | review clarification create | Yes | global `ADMIN/REVIEWER` + `assertPermissionForAnalysis(..., clarification:write)` | `clarification:write` | OK | -| `POST` | `/api/v1/review-clarifications/:clarificationId/answer` | review clarification answer | Yes | global `ADMIN/REVIEWER` + `assertPermissionForReviewClarification(..., clarification:write)` | `clarification:write` | OK | -| `POST` | `/api/v1/review-clarifications/:clarificationId/derived-analyses` | derived analysis create | Yes | global `ADMIN/REVIEWER` + `assertPermissionForReviewClarification(..., analysis:create-derived)` | `analysis:create-derived` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/review-notes` | review note list | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `POST` | `/api/v1/impact-analyses/:analysisId/review-notes` | review note write | Yes | global `ADMIN/REVIEWER` + `assertPermissionForAnalysis(..., review:write)` | `review:write` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/insights` | insight list | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `POST` | `/api/v1/insights/:insightId/confirm|reject|review` | insight review | Yes | global `ADMIN/REVIEWER` + `assertPermissionForInsight(..., review:write)` | `review:write` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/traceability` | traceability list | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `POST` | `/api/v1/traceability-links/:linkId/confirm|reject|review` | traceability review | Yes | global `ADMIN/REVIEWER` + `assertPermissionForTraceabilityLink(..., review:write)` | `review:write` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/evidence` | evidence list | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/documents` | document list | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/approved-report` | approved report read | Yes | `assertCanReadAnalysis` | `project:read` | OK | -| `GET` | `/api/v1/impact-analyses/:analysisId/approved-report/export.md|pdf` | report export | Yes | `assertPermissionForAnalysis(..., report:export)` | `report:export` | OK | -| `GET` | `/api/v1/snapshots/:snapshotId/artifacts` | snapshot artifacts | Yes | `assertCanReadSnapshot` | `project:read` | OK | -| `GET` | `/api/v1/snapshots/:snapshotId/graph` | snapshot graph | Yes | `assertCanReadSnapshot` | `project:read` | OK | - -## Residual Risks - -- Global role guards still exist and are intentionally layered with project - scope. This is transitional and acceptable for current MVP/dev mode. -- Membership management UI and project switching are not implemented yet. -- This audit covers controller-level authorization. If future background jobs - expose new project-owned HTTP routes, they must be added here. From c22ce3dbace8d8b5b0cbfd59c5f8d5d0b838457b Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Wed, 24 Jun 2026 10:17:42 +0700 Subject: [PATCH 02/22] docs: avoid overclaim wording in proof materials --- docs/demo/portfolio-proof-pack.md | 2 +- docs/portfolio/ba-helper-demo-narrative.md | 2 +- docs/portfolio/case-study.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/demo/portfolio-proof-pack.md b/docs/demo/portfolio-proof-pack.md index 94b434f0..0d534d22 100644 --- a/docs/demo/portfolio-proof-pack.md +++ b/docs/demo/portfolio-proof-pack.md @@ -36,7 +36,7 @@ The system relies on rigorous backend engineering principles: - **Scanner & Artifact Model:** Deep AST extraction for TypeScript/NestJS plus bounded pilot adapters with explicit capability metadata. - **Evidence Hierarchy:** Strict schema constraints enforce that no `EVIDENCED` impact claim exists without an explicit `Evidence` relation. - **Deterministic Diagnostics:** Bounded metrics like `SCAN_HEALTH`, `INCREMENTAL_SCAN_SUMMARY`, and `DOMAIN_PACK_APPLIED` are fully observable. -- **Evaluation Harness:** Custom test runner that mathematically calculates precision/recall of domain pack retrievals to prevent regression. +- **Evaluation Harness:** Custom test runner that computes precision/recall of domain pack retrievals to prevent regression. - **Embedding Reuse Safety:** Vectors are strictly scoped to specific snapshot commits; leakage between versions is structurally impossible. - **Domain Pack Registry:** A versioned concept-matching registry that safely falls back to `general@0.0.0` if unsupported packs are requested. - **Golden Path Integration Test:** A deterministic suite asserting the complete TypeScript/NestJS flow from scan to final report generation. diff --git a/docs/portfolio/ba-helper-demo-narrative.md b/docs/portfolio/ba-helper-demo-narrative.md index aa41d4a4..dde4d829 100644 --- a/docs/portfolio/ba-helper-demo-narrative.md +++ b/docs/portfolio/ba-helper-demo-narrative.md @@ -63,4 +63,4 @@ The optimal way to demonstrate BA Helper is sequentially: - **Language Lock-in:** Currently, deep parser confidence is limited to TypeScript/NestJS repositories. - **Vector Boundaries:** Embedding retrieval is strictly scoped by commit SHA. - **No Production AI Modification:** The LLM does not write or push code. It acts strictly as an analytical reader to propose traceability links. -- **Not a Formal Verification Engine:** While the workflow ensures human oversight and immutable snapshotting, the underlying extraction relies on heuristics and LLM mapping, making it a robust BA assistance tool, not a strictly proven formal verification engine. +- **Not a Proof System:** While the workflow ensures human oversight and immutable snapshotting, the underlying extraction relies on heuristics and LLM mapping, making it a robust BA assistance tool, not compiler-grade correctness machinery. diff --git a/docs/portfolio/case-study.md b/docs/portfolio/case-study.md index cab69387..5c4e6030 100644 --- a/docs/portfolio/case-study.md +++ b/docs/portfolio/case-study.md @@ -57,7 +57,7 @@ This audit workflow is not just conceptual; it is strictly enforced by our CI/CD - **E17B Frontend Tests:** Asserts the UI correctly disables export functions and serves deterministic Blob downloads when the gate is passed. ## 12. Known Limits -- The system is an impact analyzer, not a formal verification engine. +- The system is an impact analyzer, not a proof system. - AI proposals are hints; human review is always required. - Current deep parser support is optimized for TypeScript/NestJS repositories. - Pilot language adapters demonstrate extraction contracts, not full compiler-level semantic analysis. From 4773adc9f3c137f80ecd8d353a42bd38c7ef8beb Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Wed, 24 Jun 2026 10:19:06 +0700 Subject: [PATCH 03/22] docs: remove more outdated agent docs (product-vision, technical-debt, domain-pack, scanner, current-state) --- docs/agent/CONTEXT_INDEX.md | 14 ++-- docs/agent/current-state.md | 83 ------------------- docs/agent/domain-pack-architecture.md | 59 ------------- .../multi-language-scanner-architecture.md | 83 ------------------- docs/agent/product-vision.md | 60 -------------- docs/agent/technical-debt.md | 23 ----- 6 files changed, 7 insertions(+), 315 deletions(-) delete mode 100644 docs/agent/current-state.md delete mode 100644 docs/agent/domain-pack-architecture.md delete mode 100644 docs/agent/multi-language-scanner-architecture.md delete mode 100644 docs/agent/product-vision.md delete mode 100644 docs/agent/technical-debt.md diff --git a/docs/agent/CONTEXT_INDEX.md b/docs/agent/CONTEXT_INDEX.md index 87b23710..4660a674 100644 --- a/docs/agent/CONTEXT_INDEX.md +++ b/docs/agent/CONTEXT_INDEX.md @@ -7,14 +7,14 @@ the narrow route that matches the task. Read these first for any BA_Helper task: -1. [current-state.md](current-state.md) + 2. [auth-permissions.md](auth-permissions.md) 3. [api-contracts.md](api-contracts.md) 4. [use-cases.md](use-cases.md) ## Canonical current docs -- `docs/agent/current-state.md` + - `docs/agent/auth-permissions.md` - `docs/agent/api-contracts.md` - `docs/agent/use-cases.md` @@ -37,7 +37,7 @@ if they conflict. Read: - `auth-permissions.md` - `api-contracts.md` -- `current-state.md` + ### Report / export @@ -45,14 +45,14 @@ Read: - `analysis-invariants.md` — Audit invariants for reviewed report snapshots, review completion gate, final reviewed report export, and failure modes. - `api-contracts.md` - `use-cases.md` -- `current-state.md` + ### Frontend route gating Read: - `auth-permissions.md` - `api-contracts.md` -- `current-state.md` + ### Backend use cases @@ -64,7 +64,7 @@ Read: ### Docs cleanup Read: -- `current-state.md` + - `done-checklist.md` - this index @@ -74,7 +74,7 @@ Read: - `../demo/walkthrough.md` - `../demo/public-demo-checklist.md` - `../deployment/smoke-checklist.md` -- `current-state.md` + ### Tests diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md deleted file mode 100644 index 0867fa6c..00000000 --- a/docs/agent/current-state.md +++ /dev/null @@ -1,83 +0,0 @@ -# Current State - -## Implemented MVP workflow - -Repository scan -> requirement creation -> impact analysis -> review -decisions -> clarification loop -> derived analysis -> impact diff -> -lineage timeline -> approved report -> Markdown/PDF export. - -Multi-repo fan-out foundation exists for backend batch creation: one ready -requirement revision can spawn multiple normal per-repository analyses inside -the same project. Multi-repo batches now persist a parent run, expose run -detail, expose derived child review/readiness state, and are discoverable -through a project-scoped run list. Merged cross-repo report workflow now -exists as a narrow approved-artifact + decision surface: -- an on-demand merged Markdown draft exists for runs where every child analysis - has latest review decision `ACCEPTED` -- an approved merged Markdown snapshot can now be finalized and read later -- approved merged reports now have append-only merged review decisions - (`ACCEPTED`, `REJECTED`, `NEEDS_MORE_CLARIFICATION`) -- approved merged reports can be exported as Markdown/PDF only when non-stale -- approved merged reports become stale when child review decisions or child - analysis provenance changes after approval -- code artifacts now persist both raw `artifactType` and normalized `universalKind` - -## Auth and RBAC - -- Dev-login exists. -- `/login` is the web sign-in route. -- Dev-login uses email + role, no password. -- Web routes are middleware-gated. -- Roles: `ADMIN`, `REVIEWER`, `VIEWER`. -- `ProjectMember` and `ProjectRole` exist. -- `ProjectPermissionService` is authoritative for project-owned resources. -- `workspace/current` returns backend-owned current project selection plus `membershipRole`. -- `GET /api/v1/projects` returns only projects where the actor has membership. -- `POST /api/v1/workspace/select-project` persists the selected project on the user. -- OWNER-only membership management exists under `/api/v1/projects/:projectId/members`. -- `dev-single-user` membership mapping: - - `ADMIN -> OWNER` - - `REVIEWER -> REVIEWER` - - `VIEWER -> VIEWER` -- Outside project membership scope returns `404`. -- Same-project insufficient role returns `403`. -- Backend RBAC is authoritative. -- Frontend disabled controls are UX only. - -## Report and export - -- Approved Markdown snapshot is the persisted source of truth. -- PDF is rendered on demand from the approved Markdown snapshot. -- Stale approved reports are readable but not exportable. -- The same stale-read / export-block policy now applies to approved merged - multi-repo reports. - -## Public backend endpoints - -- `GET /api/v1/system/health` -- `GET /api/v1/workspace/current` -- `POST /api/v1/auth/dev-login` only when `ENABLE_DEV_LOGIN=true` - -## Out of scope - -- private repos -- OAuth / GitHub App -- merged cross-repo clarification loop / run regeneration workflow -- organizations / teams / invites -- DOCX -- Jira -- Confluence - -## Verification - -```bash -pnpm typecheck -pnpm lint -pnpm test -pnpm test:e2e -``` - -Demo/handoff runbooks: - -- `docs/demo/walkthrough.md` -- `docs/deployment/smoke-checklist.md` diff --git a/docs/agent/domain-pack-architecture.md b/docs/agent/domain-pack-architecture.md deleted file mode 100644 index 5a197536..00000000 --- a/docs/agent/domain-pack-architecture.md +++ /dev/null @@ -1,59 +0,0 @@ -# Domain Pack Architecture - -## Overview - -The Domain Pack architecture provides a structured, versioned mechanism to inject domain-specific hints into the Requirement-to-Code Impact Analyzer. - -**Domain packs are hints only.** They provide guidance for retrieval, risk analysis, and QA scenario generation. They are *not* sources of truth. The final source of truth for an impact analysis must always be the extracted code evidence. - -## Selection Rules - -Domain packs are selected through the `DomainPackRegistry` based on deterministic priority: - -1. **Manual Configuration** (`manualPackId`) -2. **Repository Profile** (`repositoryProfileDomain`) -3. **Safe Default** (`general@0.0.0`) - -### Canonical IDs and Versioning -Internally, the system normalizes IDs to a lowercase canonical format without versions (e.g., `booking`, `general`). Versions (like `@0.1.0`) are separated and asserted during manual selection. -- If an unknown repository domain (e.g. `UNKNOWN` or unsupported) is provided, the registry safely falls back to the `general@0.0.0` default pack. -- If an unsupported manual domain pack or version is requested explicitly, the registry throws a controlled error. -- The `booking@0.1.0` pack is never globally applied to unknown repositories. - -## Evidence Hierarchy - -The fundamental rule of the impact analyzer is that all impacts must be backed by evidence from the codebase. - -1. **Evidence is King:** An impact is only marked as `EVIDENCED` if there is a verified snippet of code confirming the behavior. -2. **Hints are Guides:** A domain pack may suggest that a "booking cancellation" should involve a "refund". The AI will use this hint to search for refund logic. -3. **Missing Evidence:** If the domain pack suggests a refund should occur, but no code evidence supports it, the system must produce an `UNKNOWN`, `RISK`, or `QUESTION` insight. It must never fabricate an `EVIDENCED` impact based solely on the domain pack's suggestion. - -## Evaluation & Metrics - -Domain pack heuristics are evaluated internally to measure improvement in artifact recall and unknown/risk generation. - -- Evaluation cases declare their `expectedConceptKeys` and `packId` to verify deterministic concept matching. -- **Domain Pack Quality Report:** The evaluation runner aggregates domain pack metrics, surfacing concept recall/precision, missing/unexpected concept reports, and domain-tagged retrieval metrics. This serves as internal quality telemetry to inform domain pack tuning. -- Real retrieval smoke tests verify that domain hints positively influence scoring and lexical filtering. -- Strict CI checks guarantee that domain hints cannot generate `EVIDENCED` impacts on their own. The engine's diagnostic `DOMAIN_PACK_APPLIED` proves which pack ran, but explicitly excludes generating text that might masquerade as real code facts. -- **Safety Guards:** Evaluation actively asserts safety checks, including the `general` fallback isolation, rejecting unsupported versions, and structural bounding of diagnostic payloads. - -## Built-in Packs - -Currently, the system ships with: - -- `booking@0.1.0`: The primary testbed domain, covering bookings, payments, refunds, and notifications. -- `general@0.0.0`: The safe empty default for unknown repositories. - -We keep the number of built-in packs narrow to focus on the core capability of the Requirement-to-Code Impact Analyzer rather than turning it into a generic business rule generator. - -## Versioning Rules - -Domain packs include a semver version string (e.g., `0.1.0`). As domain concepts evolve, the version should be bumped. When retrieving an analysis, the version of the domain pack applied at the time is recorded in the `DOMAIN_PACK_APPLIED` diagnostic metadata, ensuring reproducibility. - -## How to Add a Future Pack - -1. Create a new file in `apps/api/src/modules/domain-pack/packs/` (e.g., `healthcare.v0.1.0.ts`). -2. Define the `DomainPack` following the schema, including concepts, retrieval hints, risk templates, QA templates, and unknown templates. -3. Register the pack in `apps/api/src/modules/domain-pack/application/domain-pack.registry.ts`. -4. Ensure the fallback logic still returns `general@0.0.0` when appropriate. diff --git a/docs/agent/multi-language-scanner-architecture.md b/docs/agent/multi-language-scanner-architecture.md deleted file mode 100644 index e6cc032b..00000000 --- a/docs/agent/multi-language-scanner-architecture.md +++ /dev/null @@ -1,83 +0,0 @@ -# Multi-Language Scanner Architecture - -This document outlines the architecture and contract for multi-language scanning capabilities within the BA Helper project. It serves as a foundational guide for understanding current capabilities and implementing future language support. - -## Core Problem and Goals -The project utilizes static code analysis to extract architectural components (e.g., controllers, services, entities, tests) and determine their impact paths. Before extending support beyond the initial strong TypeScript/NestJS path, we need a formalized `ScannerAdapter` contract. - -This contract ensures that all future and existing language scanners: -- Produce consistent artifacts and dependency edges. -- Provide explicit capability metadata (`STABLE`, `PARTIAL`, `EXPERIMENTAL`). -- Safely define support boundaries without overclaiming capabilities. -- Emit bounded diagnostics representing their capabilities (`SCANNER_CAPABILITY_SUMMARY`). - -## Scanner Adapter Contract -The fundamental building block for any language parser is the `ScannerAdapter` interface (located in `packages/analyzer/src/scanner/scanner.types.ts`). - -```typescript -export type ScannerAdapter = { - adapterId: string; - adapterVersion: string; - language: SupportedLanguage; - framework?: SupportedFramework; - capability: ScannerCapabilityProfile; - - canScan(input: ScanAdapterInput): boolean; - scan(input: ScanAdapterInput): Promise; -}; -``` - -All adapters must delegate to their respective parser implementations and normalize the output to the standard `ScanAdapterResult`, providing uniform arrays of artifacts, diagnostics, and dependency edges. - -## Capability Matrix - -Every adapter exposes a `ScannerCapabilityProfile`. This metadata explicitly dictates what the scanner *can* and *cannot* do. - -### TypeScript / NestJS (Strongest Path) -- **Adapter**: `TypeScriptNestJsAdapter` -- **Status**: `STABLE` -- **Confidence**: `HIGH` -- **Supported Patterns**: Controllers, Services, Modules, Providers, DTOs, Prisma Models, method/class artifacts. - -### Java / Spring (Pilot/Partial) -- **Adapter**: `JavaSpringAdapter` -- **Status**: `PARTIAL` -- **Confidence**: `MEDIUM` -- **Supported Patterns**: Basic Spring controllers, basic HTTP method annotations (including `@PatchMapping`), simple `@RequestMapping` with method/value/path, class-level + method-level route joining, class/method artifacts, bounded Java excerpts. -- **Unsupported/Partial Patterns**: Complex composed annotations, dynamic route construction, advanced dependency injection graph, Spring Data repository query derivation, XML config, Kotlin Spring. -- **Diagnostics Emitted**: `SPRING_COMPOSED_MAPPING_UNSUPPORTED`, `SPRING_DYNAMIC_ROUTE_UNSUPPORTED`, `SPRING_MULTI_ROUTE_MAPPING_UNSUPPORTED`, `SPRING_HTTP_METHOD_UNKNOWN`, `SPRING_REQUEST_MAPPING_FORM_UNSUPPORTED`, `SPRING_UNSUPPORTED_PATTERN`. - -> **Note on Public Wording**: TypeScript/NestJS remains the strongest scanner path. Java/Spring must strictly be considered a partial/pilot implementation. The Java/Spring scanner uses a regex-based approach that is intentionally bounded. This means unsupported patterns explicitly emit bounded diagnostics and do not fabricate endpoint artifacts. Future expansion of Java/Spring support requires rigorous evaluation against these bounds before any public support claims can be made. -> -> **Validation Strategy**: Java/Spring capability is actively verified using a deterministic lexical retrieval smoke evaluation (`java-spring-impact-smoke.spec.ts`). This ensures the parsed artifacts consistently align with downstream impact analysis without invoking real LLMs. - -## Scanner Adapter Registry - -The `ScannerAdapterRegistry` (`scanner-adapter.registry.ts`) manages adapter selection via a deterministic resolution strategy. - -**Selection Rules:** -1. A combination of language + framework determines the target adapter (e.g., `typescript` + `nestjs` -> `TypeScriptNestJsAdapter`). -2. Legacy uppercase identifiers (`TYPESCRIPT`, `NESTJS`) are normalized automatically. -3. If an unknown language or framework is provided, the registry will throw a controlled error or return `null` via `tryGetAdapter`. **It will never silently fall back to TypeScript**. - -## Artifact Key Stability Rules - -Stability of artifact keys (IDs) is crucial for incremental scans, drift detection, and impact tracking. Regardless of the underlying language, all generated artifact keys must strictly adhere to the following rules: - -1. **Deterministic**: Scanning the exact same source twice must produce the exact same set of artifact keys. -2. **No Absolute Paths**: Keys must only use relative paths from the repository root to ensure environment agnosticism. -3. **No Unstable Identifiers**: Line numbers, file contents, and volatile excerpts must **not** be used as identity discriminators. -4. **Normalized APIs**: Endpoint keys must normalize HTTP methods (e.g., `GET`) and path routes to a canonical format. -5. **Class/Method Keys**: Keys should combine the normalized relative file path with the specific symbol name (e.g., `service-method:src/auth.service.ts.login`). - -Cross-language keys do not need to share an identical format string, provided they satisfy the deterministic and stability constraints listed above. - -## Adding a Future Adapter - -When adding a future adapter (e.g., Python/Django or Go/Gin): -1. Create a parser module in `packages/analyzer/src/scanner/`. -2. Create an adapter wrapper class in `packages/analyzer/src/scanner/adapters/` implementing `ScannerAdapter`. -3. Provide an honest, rigorous `ScannerCapabilityProfile`. Mark it `EXPERIMENTAL` or `PARTIAL` initially. -4. Ensure the adapter injects the `SCANNER_CAPABILITY_SUMMARY` diagnostic payload on every successful scan. -5. Register the new adapter instance within the `ScannerAdapterRegistry`. -6. Write integration tests to assert adherence to the Artifact Key Stability Rules. diff --git a/docs/agent/product-vision.md b/docs/agent/product-vision.md deleted file mode 100644 index bfa07ac2..00000000 --- a/docs/agent/product-vision.md +++ /dev/null @@ -1,60 +0,0 @@ -# Product Vision & Aim - -## Aim chốt của project - -```text -A secure, evidence-backed Requirement-to-Code Impact Analyzer. -``` - -### Bản Việt - -Một hệ thống phân tích impact có bằng chứng, giúp nối requirement change với code backend bị ảnh hưởng, chỉ ra unknown/risk/QA scenario, và tạo traceability report sau khi human review. - -### Nói ngắn - -```text -Requirement change -→ scan backend repo an toàn -→ tìm impacted APIs/services/entities/tests -→ lấy code evidence -→ sinh insights/unknowns/QA scenarios -→ BA/QA review -→ finalize approved Markdown report snapshot -→ export Markdown/PDF khi cần -``` - -### Không phải - -```text -AI coding agent -repo chatbot -generic documentation generator -tool tự hiểu toàn bộ repo 100% -``` - -### Giá trị chính - -```text -giảm missed impact -giảm hỏi qua lại giữa BA/QA/dev -giúp estimate/test planning tốt hơn -tạo traceability từ requirement → code → evidence → report -giảm token/cost bằng cách gửi evidence pack nhỏ thay vì cả repo -``` - -### Invariant phải giữ - -```text -Evidence trước, AI sau. -Không có evidence thì UNKNOWN, không bịa. -Repo là untrusted input, không execute code. -Retrieval phải observable. -Human review trước finalize. -Report cuối do backend generate, FE không tự dựng lại. -``` - -### Positioning tốt nhất - -```text -Before implementing a requirement change, teams can see which backend APIs, services, entities, tests, and business rules may be affected — with code evidence, unknowns, QA scenarios, and a human-reviewed impact report. -``` diff --git a/docs/agent/technical-debt.md b/docs/agent/technical-debt.md deleted file mode 100644 index 6be15e95..00000000 --- a/docs/agent/technical-debt.md +++ /dev/null @@ -1,23 +0,0 @@ -# Technical Debt - -This document tracks intentional shortcuts taken for the MVP and backend testing phases that must be resolved before a real production deployment. - -## TD-001: Fake providers must not run in production -- **Current State**: `AiModule` and `EmbeddingModule` fall back to `fake` providers if environment variables are missing. We added a boot guard so it throws in production, but we still need to configure the real providers. -- **Resolution**: Ensure all production deployments provide valid `AI_PROVIDER` and `EMBEDDING_PROVIDER` along with required API keys. - -## TD-002: Domain must come from request/project config -- **Current State**: `domain` is passed to the retrieval service, falling back to `BOOKING` if not provided. In the future, it should be rigorously pulled from `Organization`/`Project` settings. -- **Resolution**: Implement DB-backed `DomainProfile` and allow project-level overrides. - -## TD-003: Workspace boundary is still project-scoped -- **Current State**: Dev-login/auth/RBAC exists, but `projectId` is still the MVP-level workspace boundary. Project membership, organization/team scoping, production-grade credentials auth, and private repository authorization are still future work. -- **Resolution**: Introduce the project membership/organization model and migrate `tenantId` to the eventual organization-scoped boundary. Update vector search and retrieval filters accordingly. - -## TD-004: Evidence mapping robustness -- **Current State**: If an AI provider returns an evidence key that cannot be resolved against the snapshot, we silently ignore it (or in the first iteration, we only mapped `evidenceKeys[0]`). -- **Resolution**: Fully handle unresolved keys by logging warnings, marking the insight as having a validation issue, or downgrading its certainty. - -## TD-005: Real embedding provider requires batching -- **Current State**: `EmbedSnapshotArtifactsUseCase` sends all chunks to the embedding provider at once. This works for the fake provider but will hit rate limits with real providers (OpenAI/Gemini). -- **Resolution**: Implement configurable batching (`EMBEDDING_BATCH_SIZE`) and rate limiting/retries before switching to a real provider. From d9414a8113f3273b5908e8f87e37ba3c51d1e014 Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Wed, 24 Jun 2026 10:24:35 +0700 Subject: [PATCH 04/22] feat(contracts): define analysis workspace presentation model --- .../analysis-workspace.contract.spec.ts | 156 +++++++++++ .../src/analysis-workspace.contract.ts | 254 ++++++++++++++++++ packages/contracts/src/index.ts | 1 + 3 files changed, 411 insertions(+) create mode 100644 packages/contracts/analysis-workspace.contract.spec.ts create mode 100644 packages/contracts/src/analysis-workspace.contract.ts diff --git a/packages/contracts/analysis-workspace.contract.spec.ts b/packages/contracts/analysis-workspace.contract.spec.ts new file mode 100644 index 00000000..8784054d --- /dev/null +++ b/packages/contracts/analysis-workspace.contract.spec.ts @@ -0,0 +1,156 @@ +import { + analysisWorkspaceResponseSchema, + type AnalysisWorkspaceResponse, +} from './src'; + +describe('analysisWorkspaceResponseSchema', () => { + it('accepts an explicit presentation read model with provenance links', () => { + const payload: AnalysisWorkspaceResponse = { + overview: { + analysisId: '00000000-0000-4000-8000-000000000001', + requirement: { + revisionId: '00000000-0000-4000-8000-000000000002', + title: 'Paid booking cancellation refund', + summary: 'Cancel paid bookings and prevent duplicate refunds.', + 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', + profileVersion: 'repo-profile@0.1.0', + }, + status: { + analysisStatus: 'WAITING_FOR_REVIEW', + reviewStatus: 'in_progress', + snapshotStatus: 'locked', + reportStatus: 'missing', + driftStatus: 'fresh', + }, + counts: { + impactedArtifacts: 1, + evidenceItems: 1, + risks: 1, + unknowns: 1, + qaScenarios: 1, + pendingReviewItems: 1, + }, + }, + impactGroups: [ + { + group: 'primary', + title: 'Booking cancellation flow', + description: 'Primary backend artifacts for cancellation.', + artifacts: [ + { + artifactId: '00000000-0000-4000-8000-000000000005', + artifactKey: 'api:booking.controller.cancel', + name: 'BookingController.cancel', + filePath: 'src/booking/booking.controller.ts', + universalKind: 'API_ENDPOINT', + impactBasis: 'evidenced', + impactReason: 'Cancellation route handles the request.', + traceabilityLinkIds: [ + '00000000-0000-4000-8000-000000000006', + ], + evidenceIds: ['00000000-0000-4000-8000-000000000007'], + reviewDecision: 'needs_review', + }, + ], + }, + ], + evidenceCards: [ + { + evidenceId: '00000000-0000-4000-8000-000000000007', + sourceType: 'code', + filePath: 'src/booking/booking.controller.ts', + lineRange: { startLine: 10, endLine: 22 }, + excerpt: 'cancelPaidBooking(command)', + relevanceReason: 'Shows the cancellation endpoint.', + artifactId: '00000000-0000-4000-8000-000000000005', + artifactKey: 'api:booking.controller.cancel', + linkedInsightIds: ['00000000-0000-4000-8000-000000000008'], + linkedTraceabilityLinkIds: [ + '00000000-0000-4000-8000-000000000006', + ], + }, + ], + risks: [ + { + riskId: 'risk:duplicate-refund', + sourceInsightId: '00000000-0000-4000-8000-000000000008', + title: 'Duplicate refund', + severity: 'high', + category: 'payment', + whyItMatters: 'Refund retry may charge back twice.', + relatedArtifactKeys: ['api:booking.controller.cancel'], + relatedEvidenceIds: ['00000000-0000-4000-8000-000000000007'], + relatedUnknownIds: ['unknown:refund-policy'], + reviewDecision: 'needs_review', + }, + ], + unknowns: [ + { + unknownId: 'unknown:refund-policy', + sourceInsightId: null, + title: 'Refund policy is unclear', + question: 'Should partially paid bookings receive partial refunds?', + whyItMatters: 'Policy affects acceptance criteria and QA scenarios.', + relatedArtifactKeys: ['api:booking.controller.cancel'], + relatedEvidenceIds: ['00000000-0000-4000-8000-000000000007'], + reviewDecision: 'needs_more_evidence', + }, + ], + qaScenarios: [ + { + scenarioId: 'qa:cancel-paid-booking', + sourceInsightId: null, + title: 'Cancel paid booking once', + given: 'A paid booking exists', + when: 'The customer cancels it', + then: 'The system creates one refund request', + regressionTarget: 'duplicate refund prevention', + relatedRiskIds: ['risk:duplicate-refund'], + relatedUnknownIds: ['unknown:refund-policy'], + relatedArtifactKeys: ['api:booking.controller.cancel'], + relatedEvidenceIds: ['00000000-0000-4000-8000-000000000007'], + reviewDecision: 'needs_review', + }, + ], + reviewQueue: [ + { + itemId: 'review:risk:duplicate-refund', + itemType: 'risk', + title: 'Duplicate refund', + currentDecision: 'needs_review', + evidenceCount: 1, + linkedArtifactKeys: ['api:booking.controller.cancel'], + linkedEvidenceIds: ['00000000-0000-4000-8000-000000000007'], + blockingFinalize: true, + }, + ], + 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-000000000009', + latestObservedCommitSha: 'abc123', + snapshotCommitSha: 'abc123', + reason: null, + }, + }; + + expect(analysisWorkspaceResponseSchema.parse(payload)).toEqual(payload); + }); +}); diff --git a/packages/contracts/src/analysis-workspace.contract.ts b/packages/contracts/src/analysis-workspace.contract.ts new file mode 100644 index 00000000..bead3746 --- /dev/null +++ b/packages/contracts/src/analysis-workspace.contract.ts @@ -0,0 +1,254 @@ +import { z } from 'zod'; +import { universalArtifactKindSchema } from './artifact.contract'; +import { impactAnalysisStatusSchema } from './impact-analysis.contract'; + +export const analysisWorkspaceLanguageSchema = z.enum([ + 'en', + 'vi', + 'unknown', +]); + +export const analysisWorkspaceReviewStatusSchema = z.enum([ + 'not_started', + 'in_progress', + 'complete', +]); + +export const analysisWorkspaceSnapshotStatusSchema = z.enum([ + 'missing', + 'locked', +]); + +export const analysisWorkspaceReportStatusSchema = z.enum([ + 'missing', + 'queued', + 'running', + 'completed', + 'failed', +]); + +export const analysisWorkspaceDriftStatusSchema = z.enum([ + 'unknown', + 'fresh', + 'stale', +]); + +export const analysisWorkspaceReviewDecisionSchema = z.enum([ + 'needs_review', + 'accepted', + 'rejected', + 'needs_more_evidence', +]); + +export const analysisWorkspaceEvidenceBasisSchema = z.enum([ + 'evidenced', + 'inferred', + 'unknown', + 'conflicting', +]); + +export const analysisOverviewSchema = z.object({ + analysisId: z.string().uuid(), + requirement: z.object({ + revisionId: z.string().uuid(), + title: z.string(), + summary: z.string(), + language: analysisWorkspaceLanguageSchema, + domainProfileId: z.string(), + }), + snapshot: z.object({ + snapshotId: z.string().uuid(), + repositoryId: z.string().uuid(), + commitSha: z.string(), + analyzerVersion: z.string(), + profileVersion: z.string().optional(), + }), + status: z.object({ + analysisStatus: impactAnalysisStatusSchema, + reviewStatus: analysisWorkspaceReviewStatusSchema, + snapshotStatus: analysisWorkspaceSnapshotStatusSchema, + reportStatus: analysisWorkspaceReportStatusSchema, + driftStatus: analysisWorkspaceDriftStatusSchema, + }), + counts: z.object({ + impactedArtifacts: z.number().int().nonnegative(), + evidenceItems: z.number().int().nonnegative(), + risks: z.number().int().nonnegative(), + unknowns: z.number().int().nonnegative(), + qaScenarios: z.number().int().nonnegative(), + pendingReviewItems: z.number().int().nonnegative(), + }), +}); + +export const impactGroupKindSchema = z.enum([ + 'primary', + 'secondary', + 'test', + 'config', + 'unknown', +]); + +export const impactArtifactCardSchema = z.object({ + artifactId: z.string().uuid(), + artifactKey: z.string(), + name: z.string(), + filePath: z.string(), + universalKind: universalArtifactKindSchema, + impactBasis: analysisWorkspaceEvidenceBasisSchema, + impactReason: z.string(), + traceabilityLinkIds: z.array(z.string().uuid()), + evidenceIds: z.array(z.string().uuid()), + reviewDecision: analysisWorkspaceReviewDecisionSchema, +}); + +export const impactGroupSchema = z.object({ + group: impactGroupKindSchema, + title: z.string(), + description: z.string(), + artifacts: z.array(impactArtifactCardSchema), +}); + +export const evidenceCardSchema = z.object({ + evidenceId: z.string().uuid(), + sourceType: z.enum([ + 'code', + 'test', + 'static_analysis', + 'requirement_input', + 'coverage', + 'human_note', + ]), + filePath: z.string().nullable(), + lineRange: z.object({ + startLine: z.number().int().positive().nullable(), + endLine: z.number().int().positive().nullable(), + }), + excerpt: z.string(), + relevanceReason: z.string(), + artifactId: z.string().uuid().nullable(), + artifactKey: z.string().nullable(), + linkedInsightIds: z.array(z.string().uuid()), + linkedTraceabilityLinkIds: z.array(z.string().uuid()), +}); + +export const riskSeveritySchema = z.enum(['low', 'medium', 'high']); + +export const riskItemSchema = z.object({ + riskId: z.string(), + sourceInsightId: z.string().uuid().nullable(), + title: z.string(), + severity: riskSeveritySchema, + category: z.string(), + whyItMatters: z.string(), + relatedArtifactKeys: z.array(z.string()), + relatedEvidenceIds: z.array(z.string().uuid()), + relatedUnknownIds: z.array(z.string()), + reviewDecision: analysisWorkspaceReviewDecisionSchema, +}); + +export const unknownItemSchema = z.object({ + unknownId: z.string(), + sourceInsightId: z.string().uuid().nullable(), + title: z.string(), + question: z.string(), + whyItMatters: z.string(), + relatedArtifactKeys: z.array(z.string()), + relatedEvidenceIds: z.array(z.string().uuid()), + reviewDecision: analysisWorkspaceReviewDecisionSchema, +}); + +export const qaScenarioCardSchema = z.object({ + scenarioId: z.string(), + sourceInsightId: z.string().uuid().nullable(), + title: z.string(), + given: z.string(), + when: z.string(), + then: z.string(), + regressionTarget: z.string(), + relatedRiskIds: z.array(z.string()), + relatedUnknownIds: z.array(z.string()), + relatedArtifactKeys: z.array(z.string()), + relatedEvidenceIds: z.array(z.string().uuid()), + reviewDecision: analysisWorkspaceReviewDecisionSchema, +}); + +export const analysisWorkspaceReviewQueueItemSchema = z.object({ + itemId: z.string(), + itemType: z.enum([ + 'impact', + 'evidence', + 'risk', + 'unknown', + 'qa_scenario', + 'report', + ]), + title: z.string(), + currentDecision: analysisWorkspaceReviewDecisionSchema, + evidenceCount: z.number().int().nonnegative(), + linkedArtifactKeys: z.array(z.string()), + linkedEvidenceIds: z.array(z.string().uuid()), + blockingFinalize: z.boolean(), +}); + +export const reportStatusCardSchema = z.object({ + status: analysisWorkspaceReportStatusSchema, + generatedDocumentId: z.string().uuid().nullable(), + documentJobId: z.string().uuid().nullable(), + reviewedReportSnapshotId: z.string().uuid().nullable(), + canExport: z.boolean(), + lastGeneratedAt: z.string().nullable(), + failureMessage: z.string().nullable(), +}); + +export const driftStatusCardSchema = z.object({ + status: analysisWorkspaceDriftStatusSchema, + isStale: z.boolean(), + basis: z.enum(['latest_observed_source_target', 'pinned_commit', 'unknown']), + sourceTargetId: z.string().uuid().nullable(), + latestObservedCommitSha: z.string().nullable(), + snapshotCommitSha: z.string(), + reason: z.string().nullable(), +}); + +export const analysisWorkspaceResponseSchema = z.object({ + overview: analysisOverviewSchema, + impactGroups: z.array(impactGroupSchema), + evidenceCards: z.array(evidenceCardSchema), + risks: z.array(riskItemSchema), + unknowns: z.array(unknownItemSchema), + qaScenarios: z.array(qaScenarioCardSchema), + reviewQueue: z.array(analysisWorkspaceReviewQueueItemSchema), + reportStatus: reportStatusCardSchema, + driftStatus: driftStatusCardSchema, +}); + +export type AnalysisWorkspaceLanguage = z.infer< + typeof analysisWorkspaceLanguageSchema +>; +export type AnalysisWorkspaceReviewStatus = z.infer< + typeof analysisWorkspaceReviewStatusSchema +>; +export type AnalysisWorkspaceSnapshotStatus = z.infer< + typeof analysisWorkspaceSnapshotStatusSchema +>; +export type AnalysisWorkspaceReportStatus = z.infer< + typeof analysisWorkspaceReportStatusSchema +>; +export type AnalysisWorkspaceDriftStatus = z.infer< + typeof analysisWorkspaceDriftStatusSchema +>; +export type AnalysisOverview = z.infer; +export type ImpactGroup = z.infer; +export type ImpactArtifactCard = z.infer; +export type EvidenceCard = z.infer; +export type RiskItem = z.infer; +export type UnknownItem = z.infer; +export type QaScenarioCard = z.infer; +export type AnalysisWorkspaceReviewQueueItem = z.infer< + typeof analysisWorkspaceReviewQueueItemSchema +>; +export type ReportStatusCard = z.infer; +export type DriftStatusCard = z.infer; +export type AnalysisWorkspaceResponse = z.infer< + typeof analysisWorkspaceResponseSchema +>; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f7b70bac..2fe2a0e0 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -8,6 +8,7 @@ export * from './graph.contract'; export * from './evidence.contract'; export * from './requirement.contract'; export * from './impact-analysis.contract'; +export * from './analysis-workspace.contract'; export * from './insight.contract'; export * from './retrieval.contract'; export * from './review-queue.contract'; From 2515061802fb7f519f026fb6128f63bff60d907f Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Wed, 24 Jun 2026 10:37:36 +0700 Subject: [PATCH 05/22] feat(api): add analysis workspace read model --- ...act-analysis-read-model.controller.spec.ts | 73 +++++ .../impact-analysis-read-model.controller.ts | 13 + .../analysis-workspace.mapper.helpers.ts | 234 ++++++++++++++++ .../mappers/analysis-workspace.mapper.ts | 255 +++++++++++++++++ .../analysis-workspace.mapper.types.ts | 117 ++++++++ .../get-analysis-workspace.usecase.spec.ts | 256 ++++++++++++++++++ .../queries/get-analysis-workspace.usecase.ts | 75 +++++ .../impact-analysis/impact-analysis.module.ts | 2 + apps/api/test/e2e/analysis-list.e2e-spec.ts | 12 + .../e2e/impact-analysis-workspace.e2e-spec.ts | 140 ++++++++++ docs/agent/api-contracts.md | 26 ++ 11 files changed, 1203 insertions(+) create mode 100644 apps/api/src/modules/impact-analysis/application/mappers/analysis-workspace.mapper.helpers.ts create mode 100644 apps/api/src/modules/impact-analysis/application/mappers/analysis-workspace.mapper.ts create mode 100644 apps/api/src/modules/impact-analysis/application/mappers/analysis-workspace.mapper.types.ts create mode 100644 apps/api/src/modules/impact-analysis/application/queries/get-analysis-workspace.usecase.spec.ts create mode 100644 apps/api/src/modules/impact-analysis/application/queries/get-analysis-workspace.usecase.ts create mode 100644 apps/api/test/e2e/impact-analysis-workspace.e2e-spec.ts 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/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/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/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/docs/agent/api-contracts.md b/docs/agent/api-contracts.md index 646b2adb..acc57fe4 100644 --- a/docs/agent/api-contracts.md +++ b/docs/agent/api-contracts.md @@ -55,6 +55,7 @@ GET /api/v1/multi-repo-runs/:runId/merged-report/export.pdf GET /api/v1/projects/:projectId/analyses GET /api/v1/impact-analyses/:analysisId +GET /api/v1/impact-analyses/:analysisId/workspace GET /api/v1/impact-analyses/:analysisId/insights GET /api/v1/impact-analyses/:analysisId/evidence POST /api/v1/impact-analyses/:analysisId/finalize @@ -412,6 +413,31 @@ view against its selected target, not to immutable snapshot identity. If the analysis becomes known stale while waiting for review, `canReview` and `canFinalize` are false by default; the user reruns against a current snapshot. +`GET /api/v1/impact-analyses/:analysisId/workspace` returns the presentation +read model for the analysis workspace. The response validates against +`analysisWorkspaceResponseSchema` in `packages/contracts` and contains: + +```text +overview +impactGroups +evidenceCards +risks +unknowns +qaScenarios +reviewQueue +reportStatus +driftStatus +``` + +Rules: + +```text +All status fields are backend-derived. +progress === 100 does not imply review complete, report generated, or drift clean. +Cards preserve analysis, requirement revision, snapshot, artifact, evidence, insight, traceability, reviewed report snapshot, document job, and source target ids where applicable. +The endpoint is a read model projection; it does not mutate analysis behavior or replace lower-level resources. +``` + ## Deploy Troubleshooting When separate web/API deployment fails, diagnose in this order: From 5856511183209c0551f5352c94d4cf963efaed49 Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Wed, 24 Jun 2026 10:49:06 +0700 Subject: [PATCH 06/22] refactor(web): render analysis workspace from read model --- .../app/(app)/analyses/[analysisId]/page.tsx | 290 ++---------------- .../analysis/analysis-workspace-shell.tsx | 120 ++++++++ .../workspace/analysis/evidence-tab.tsx | 48 +++ .../workspace/analysis/impact-map-tab.tsx | 59 ++++ .../workspace/analysis/overview-tab.tsx | 70 +++++ .../workspace/analysis/review-report-tab.tsx | 158 ++++++++++ .../workspace/analysis/risks-qa-tab.tsx | 89 ++++++ apps/web/src/hooks/api/use-analyses.ts | 16 + apps/web/src/hooks/api/use-insights.spec.ts | 3 +- apps/web/src/hooks/api/use-insights.ts | 4 + apps/web/src/hooks/api/use-reports.ts | 3 + apps/web/src/lib/api/query-keys.ts | 1 + 12 files changed, 596 insertions(+), 265 deletions(-) create mode 100644 apps/web/src/components/workspace/analysis/analysis-workspace-shell.tsx create mode 100644 apps/web/src/components/workspace/analysis/evidence-tab.tsx create mode 100644 apps/web/src/components/workspace/analysis/impact-map-tab.tsx create mode 100644 apps/web/src/components/workspace/analysis/overview-tab.tsx create mode 100644 apps/web/src/components/workspace/analysis/review-report-tab.tsx create mode 100644 apps/web/src/components/workspace/analysis/risks-qa-tab.tsx diff --git a/apps/web/src/app/(app)/analyses/[analysisId]/page.tsx b/apps/web/src/app/(app)/analyses/[analysisId]/page.tsx index 2a44a289..cf53371e 100644 --- a/apps/web/src/app/(app)/analyses/[analysisId]/page.tsx +++ b/apps/web/src/app/(app)/analyses/[analysisId]/page.tsx @@ -1,33 +1,11 @@ "use client" import { use } from "react" -import { Button } from "@/components/ui/button" -import { Skeleton } from "@/components/ui/skeleton" import { notFound } from "next/navigation" -import { AlertCircle, Network } from "lucide-react" -import { AnalysisProgress } from "@/components/workspace/analysis/analysis-progress" -import { isAnalysisActive } from "@/lib/status-helpers" -import dynamic from "next/dynamic" -import { QaCoveragePanel } from "@/components/workspace/analysis/qa/qa-coverage-panel" -import { ReviewQueuePanel } from "@/components/workspace/review/review-queue-panel" -import { AnalysisTabBar } from "./_components/analysis-tab-bar" -import { AnalysisInsightsTab } from "./_components/analysis-insights-tab" -import { AnalysisDiffTab } from "./_components/analysis-diff-tab" -import { AnalysisTraceabilityMatrixTab } from "./_components/analysis-traceability-matrix-tab" -import { AnalysisLineageTab } from "./_components/analysis-lineage-tab" -import { AnalysisDriftWarning } from "./_components/analysis-drift-warning" -import { useAnalysisWorkspace } from "./_hooks/use-analysis-workspace" -import { AnalysisInspectorMapper } from "./_components/analysis-inspector-mapper" -import { toast } from "sonner" -import { v4 as uuidv4 } from "uuid" -import { AuditTimeline } from "@/components/workspace/shared/audit-timeline" -import { useAnalysisEventLogs } from "@/hooks/api/use-event-logs" - -// Dynamic import so React Flow CSS loads correctly in Next.js app router -const ImpactGraphView = dynamic( - () => import("@/components/graph/impact-graph-view").then(m => ({ default: m.ImpactGraphView })), - { ssr: false, loading: () =>
Loading graph…
} -) +import { AlertCircle } from "lucide-react" +import { Skeleton } from "@/components/ui/skeleton" +import { AnalysisWorkspaceShell } from "@/components/workspace/analysis/analysis-workspace-shell" +import { useAnalysisWorkspace } from "@/hooks/api/use-analyses" export default function ImpactAnalysisDetailPage({ params, @@ -35,255 +13,39 @@ export default function ImpactAnalysisDetailPage({ params: Promise<{ analysisId: string }> }) { const { analysisId } = use(params) - const ws = useAnalysisWorkspace(analysisId) - const { data: analysisEventsData, isLoading: isLoadingEvents } = useAnalysisEventLogs(analysisId) + const { + data: workspace, + isLoading, + error, + } = useAnalysisWorkspace(analysisId) - // ── Loading state ── - if (ws.analysisLoading || ws.insightsLoading || ws.linksLoading) { + if (isLoading) { return ( -
- - -
-
- -
-
- -
+
+ + +
+ + +
+
) } - // ── Error state ── - if (ws.analysisError || !ws.analysis) { - if (ws.analysisError && (ws.analysisError as { status?: number }).status === 404) notFound() - return ( -
- -

Failed to load analysis

-

{ws.analysisError instanceof Error ? ws.analysisError.message : "Analysis not found"}

-
- ) - } - - // ── In-progress state ── - if (isAnalysisActive(ws.analysis.status)) { + if (error || !workspace) { + if (error && (error as { status?: number }).status === 404) notFound() return ( -
-

Analyzing Impact

-

- The backend is matching the requirement revision against persisted snapshot evidence. This does not edit code or generate implementation changes. -

- -
- ) - } - - // ── Failed state ── - if (ws.analysis.status === "FAILED") { - const handleRetryAnalysis = async () => { - if (!ws.analysis) return - try { - await ws.retryAnalysis({ - revisionId: ws.analysis.requirement.revisionId, - data: { snapshotId: ws.analysis.snapshot.id, sourceTargetId: ws.analysis.sourceTarget.id, allowPartialSnapshot: false, requestKey: uuidv4() }, - }) - toast.success("Analysis rerun started") - } catch { toast.error("Failed to rerun analysis") } - } - return ( -
- -

- {ws.analysis.error?.code ? `Analysis Failed: ${ws.analysis.error.code}` : "Analysis Failed"} -

-

- {ws.analysis.error?.message || "The impact analysis could not be completed. Please check the logs or try again."} -

-

- {ws.analysis.error?.code === "AI_PROVIDER_UNAVAILABLE" || ws.analysis.error?.code === "LLM_PROVIDER_OVERLOADED" - ? "Common fixes: wait a few minutes before retrying, or configure a different AI provider/model in settings." - : ws.analysis.error?.code === "AI_PROVIDER_RATE_LIMITED" - ? "Common fixes: check your AI provider billing/quota, or wait before retrying." - : ws.analysis.error?.code === "AI_PROVIDER_TIMEOUT" - ? "Common fixes: the model took too long to respond. You can retry the analysis, or switch to a faster model." - : "Common fixes: confirm the selected snapshot is READY or explicitly accepted as PARTIAL, then rerun the analysis from the same requirement revision."} +

+ +

Failed to load analysis workspace

+

+ {error instanceof Error ? error.message : "Analysis workspace is unavailable."}

- {ws.canRerun ? ( - - ) : ( -

- 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/workspace/analysis/analysis-workspace-shell.tsx b/apps/web/src/components/workspace/analysis/analysis-workspace-shell.tsx new file mode 100644 index 00000000..f6c5466e --- /dev/null +++ b/apps/web/src/components/workspace/analysis/analysis-workspace-shell.tsx @@ -0,0 +1,120 @@ +"use client" + +import { useMemo, useState } from "react" +import type { AnalysisWorkspaceResponse } from "@ba-helper/contracts" +import { cn } from "@/lib/utils" +import { OverviewTab } from "./overview-tab" +import { ImpactMapTab } from "./impact-map-tab" +import { EvidenceTab } from "./evidence-tab" +import { RisksQaTab } from "./risks-qa-tab" +import { ReviewReportTab } from "./review-report-tab" + +type WorkspaceTab = "overview" | "impact" | "evidence" | "risks-qa" | "review-report" + +const tabs: Array<{ id: WorkspaceTab; label: string }> = [ + { id: "overview", label: "Overview" }, + { id: "impact", label: "Impact Map" }, + { id: "evidence", label: "Evidence" }, + { id: "risks-qa", label: "Risks & QA" }, + { id: "review-report", label: "Review & Report" }, +] + +export function AnalysisWorkspaceShell({ + workspace, +}: { + workspace: AnalysisWorkspaceResponse +}) { + const [activeTab, setActiveTab] = useState("overview") + const stats = useMemo(() => { + const reviewed = workspace.reviewQueue.filter( + (item) => item.currentDecision !== "needs_review", + ).length + return { + total: + workspace.overview.counts.risks + + workspace.overview.counts.unknowns + + workspace.overview.counts.qaScenarios, + confirmed: reviewed, + rejected: 0, + unknowns: workspace.overview.counts.unknowns, + conflicts: workspace.risks.filter((risk) => risk.severity === "high").length, + needsReview: workspace.overview.counts.pendingReviewItems, + } + }, [workspace]) + + return ( +
+
+
+
+

+ Analysis Workspace +

+

+ {workspace.overview.requirement.title} +

+

+ {workspace.overview.requirement.summary} +

+
+
+ + + +
+
+ + +
+ + {activeTab === "overview" && } + {activeTab === "impact" && } + {activeTab === "evidence" && } + {activeTab === "risks-qa" && ( + + )} + {activeTab === "review-report" && ( + + )} +
+ ) +} + +function StatusPill({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
+ {value.replace(/_/g, " ")} +
+
+ ) +} diff --git a/apps/web/src/components/workspace/analysis/evidence-tab.tsx b/apps/web/src/components/workspace/analysis/evidence-tab.tsx new file mode 100644 index 00000000..d27fca5d --- /dev/null +++ b/apps/web/src/components/workspace/analysis/evidence-tab.tsx @@ -0,0 +1,48 @@ +"use client" + +import type { AnalysisWorkspaceResponse } from "@ba-helper/contracts" + +type EvidenceCard = AnalysisWorkspaceResponse["evidenceCards"][number] + +export function EvidenceTab({ evidenceCards }: { evidenceCards: EvidenceCard[] }) { + if (evidenceCards.length === 0) { + return ( +
+ No evidence cards are available for this analysis. +
+ ) + } + + return ( +
+ {evidenceCards.map((card) => ( +
+
+
+

+ {card.filePath ?? "Requirement evidence"} +

+

+ {formatLineRange(card)} · {card.sourceType} +

+
+
+ {card.linkedInsightIds.length} insights · {card.linkedTraceabilityLinkIds.length} links +
+
+
+            {card.excerpt}
+          
+

{card.relevanceReason}

+
+ ))} +
+ ) +} + +function formatLineRange(card: EvidenceCard) { + const { startLine, endLine } = card.lineRange + if (!startLine && !endLine) return "No line range" + if (startLine && endLine) return `L${startLine}-L${endLine}` + return `L${startLine ?? endLine}` +} diff --git a/apps/web/src/components/workspace/analysis/impact-map-tab.tsx b/apps/web/src/components/workspace/analysis/impact-map-tab.tsx new file mode 100644 index 00000000..0f54b6dc --- /dev/null +++ b/apps/web/src/components/workspace/analysis/impact-map-tab.tsx @@ -0,0 +1,59 @@ +"use client" + +import type { AnalysisWorkspaceResponse } from "@ba-helper/contracts" +import { ArtifactKindBadge } from "@/components/workspace/shared/status-badges" + +type ImpactGroup = AnalysisWorkspaceResponse["impactGroups"][number] + +export function ImpactMapTab({ groups }: { groups: ImpactGroup[] }) { + if (groups.length === 0) { + return + } + + return ( +
+ {groups.map((group) => ( +
+
+

{group.title}

+

{group.description}

+
+
+ {group.artifacts.map((artifact) => ( +
+
+
+

+ {artifact.name} +

+

+ {artifact.filePath} +

+
+ +
+

{artifact.impactReason}

+
+ Basis: {artifact.impactBasis} + Evidence: {artifact.evidenceIds.length} + Review: {artifact.reviewDecision} +
+
+ ))} +
+
+ ))} +
+ ) +} + +function EmptyState({ title }: { title: string }) { + return ( +
+ {title} +
+ ) +} diff --git a/apps/web/src/components/workspace/analysis/overview-tab.tsx b/apps/web/src/components/workspace/analysis/overview-tab.tsx new file mode 100644 index 00000000..efc77060 --- /dev/null +++ b/apps/web/src/components/workspace/analysis/overview-tab.tsx @@ -0,0 +1,70 @@ +"use client" + +import type { AnalysisWorkspaceResponse } from "@ba-helper/contracts" + +export function OverviewTab({ workspace }: { workspace: AnalysisWorkspaceResponse }) { + const { overview, reportStatus, driftStatus } = workspace + const counts = overview.counts + + return ( +
+
+

Current State

+
+ + + + + + +
+
+ +
+

Backend Counts

+
+ + + + + + +
+
+ +
+

Report & Drift

+
+ + + + + + +
+
+
+ ) +} + +function Metric({ label, value }: { label: string; value: number }) { + return ( +
+
{value}
+
{label}
+
+ ) +} + +function InfoRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} diff --git a/apps/web/src/components/workspace/analysis/review-report-tab.tsx b/apps/web/src/components/workspace/analysis/review-report-tab.tsx new file mode 100644 index 00000000..78f92619 --- /dev/null +++ b/apps/web/src/components/workspace/analysis/review-report-tab.tsx @@ -0,0 +1,158 @@ +"use client" + +import type { AnalysisWorkspaceResponse } from "@ba-helper/contracts" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { FinalizeAnalysisDialog } from "./finalize-analysis-dialog" +import { useReviewInsight, useReviewTraceabilityLink } from "@/hooks/api/use-analyses" +import { toast } from "sonner" + +type ReviewItem = AnalysisWorkspaceResponse["reviewQueue"][number] + +export function ReviewReportTab({ + workspace, + finalizeStats, +}: { + workspace: AnalysisWorkspaceResponse + finalizeStats: { + total: number + confirmed: number + rejected: number + unknowns: number + conflicts: number + needsReview: number + } +}) { + const reviewInsight = useReviewInsight(undefined, workspace.overview.analysisId) + const reviewLink = useReviewTraceabilityLink(undefined, workspace.overview.analysisId) + const isStale = workspace.driftStatus.isStale + + const reviewItem = async (item: ReviewItem, status: "CONFIRMED" | "REJECTED") => { + try { + if (item.itemType === "impact") { + await reviewLink.mutateAsync({ + traceabilityLinkId: item.itemId, + data: { reviewStatus: status }, + }) + } else if (item.itemType !== "report") { + await reviewInsight.mutateAsync({ + insightId: item.itemId, + data: { reviewStatus: status }, + }) + } + toast.success("Review decision saved.") + } catch (error) { + toast.error("Failed to save review decision", { + description: error instanceof Error ? error.message : "Please try again.", + }) + } + } + + return ( +
+
+
+
+

Review Queue

+

+ Backend-ranked presentation items that still need human decision. +

+
+ + {workspace.reviewQueue.length} pending + +
+ +
+ {workspace.reviewQueue.length === 0 ? ( +

+ No pending review items. +

+ ) : ( + workspace.reviewQueue.map((item) => ( +
+
+
+
+ {item.itemType} · {item.evidenceCount} evidence +
+

{item.title}

+

+ {item.linkedArtifactKeys.join(", ") || item.itemId} +

+
+
+ + +
+
+
+ )) + )} +
+
+ + +
+ ) +} + +function StatusLine({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} diff --git a/apps/web/src/components/workspace/analysis/risks-qa-tab.tsx b/apps/web/src/components/workspace/analysis/risks-qa-tab.tsx new file mode 100644 index 00000000..af9e4b46 --- /dev/null +++ b/apps/web/src/components/workspace/analysis/risks-qa-tab.tsx @@ -0,0 +1,89 @@ +"use client" + +import type { ReactNode } from "react" +import type { AnalysisWorkspaceResponse } from "@ba-helper/contracts" + +export function RisksQaTab({ + risks, + unknowns, + qaScenarios, +}: { + risks: AnalysisWorkspaceResponse["risks"] + unknowns: AnalysisWorkspaceResponse["unknowns"] + qaScenarios: AnalysisWorkspaceResponse["qaScenarios"] +}) { + return ( +
+ + {risks.map((risk) => ( + + {risk.whyItMatters} + + ))} + + + + {unknowns.map((unknown) => ( + + {unknown.question} + + ))} + + +
+ + {qaScenarios.map((scenario) => ( +
+

{scenario.title}

+
+ + + +
+

+ Regression target: {scenario.regressionTarget} +

+
+ ))} +
+
+
+ ) +} + +function Panel({ title, count, children }: { title: string; count: number; children: ReactNode }) { + return ( +
+
+

{title}

+ {count} +
+
+ {count > 0 ? children :

No items.

} +
+
+ ) +} + +function Item({ title, meta, children }: { title: string; meta: string; children: ReactNode }) { + return ( +
+
+

{title}

+ {meta} +
+

{children}

+
+ ) +} + +function Step({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
{value}
+
+ ) +} diff --git a/apps/web/src/hooks/api/use-analyses.ts b/apps/web/src/hooks/api/use-analyses.ts index 7c2f306f..472cb3de 100644 --- a/apps/web/src/hooks/api/use-analyses.ts +++ b/apps/web/src/hooks/api/use-analyses.ts @@ -12,8 +12,10 @@ import { ImpactAnalysisDetailResponse, ImpactAnalysisCreateRequest, ImpactAnalysisResponse, + AnalysisWorkspaceResponse, impactAnalysisListResponseSchema, impactAnalysisResponseSchema, + analysisWorkspaceResponseSchema, impactGraphResponseSchema, ImpactGraphResponse, } from "@ba-helper/contracts" @@ -51,6 +53,20 @@ export function useAnalysisDetail(analysisId: string) { }) } +export function useAnalysisWorkspace(analysisId: string) { + return useQuery({ + queryKey: queryKeys.analyses.workspace(analysisId), + queryFn: async () => { + return apiGet( + `/api/v1/impact-analyses/${analysisId}/workspace`, + analysisWorkspaceResponseSchema, + ) + }, + enabled: Boolean(analysisId), + refetchOnWindowFocus: true, + }) +} + export function useCreateAnalysis(projectId?: string) { const activeProjectId = useOptionalProjectId() const effectiveProjectId = projectId ?? activeProjectId diff --git a/apps/web/src/hooks/api/use-insights.spec.ts b/apps/web/src/hooks/api/use-insights.spec.ts index e85aa97c..a4165db6 100644 --- a/apps/web/src/hooks/api/use-insights.spec.ts +++ b/apps/web/src/hooks/api/use-insights.spec.ts @@ -1,12 +1,13 @@ import { traceabilityReviewInvalidationKeys } from './use-insights'; describe('traceabilityReviewInvalidationKeys', () => { - it('invalidates detail, traceability, review queue, list, and approved report queries', () => { + it('invalidates detail, workspace, traceability, review queue, list, and approved report queries', () => { expect( traceabilityReviewInvalidationKeys('project-1', 'analysis-1'), ).toEqual([ ['impact-analyses', 'detail', 'analysis-1', 'traceability'], ['impact-analyses', 'review-queue', 'analysis-1'], + ['impact-analyses', 'workspace', 'analysis-1'], ['impact-analyses', 'detail', 'analysis-1'], ['impact-analyses', 'list', 'project-1', undefined], ['impact-analyses', 'approved-report', 'analysis-1'], diff --git a/apps/web/src/hooks/api/use-insights.ts b/apps/web/src/hooks/api/use-insights.ts index a0c1563d..76011fd7 100644 --- a/apps/web/src/hooks/api/use-insights.ts +++ b/apps/web/src/hooks/api/use-insights.ts @@ -54,6 +54,9 @@ export function useReviewInsight(projectId: string | undefined, analysisId: stri queryClient.invalidateQueries({ queryKey: queryKeys.analyses.reviewQueue(analysisId), }) + queryClient.invalidateQueries({ + queryKey: queryKeys.analyses.workspace(analysisId), + }) } }) } @@ -62,6 +65,7 @@ export function traceabilityReviewInvalidationKeys(projectId: string, analysisId return [ [...queryKeys.analyses.detail(analysisId), "traceability"], queryKeys.analyses.reviewQueue(analysisId), + queryKeys.analyses.workspace(analysisId), queryKeys.analyses.detail(analysisId), queryKeys.analyses.list(projectId), queryKeys.analyses.report(analysisId), diff --git a/apps/web/src/hooks/api/use-reports.ts b/apps/web/src/hooks/api/use-reports.ts index 96bbfcf2..63036db8 100644 --- a/apps/web/src/hooks/api/use-reports.ts +++ b/apps/web/src/hooks/api/use-reports.ts @@ -73,6 +73,9 @@ export function useFinalizeAnalysis(projectId: string | undefined, analysisId: s queryClient.invalidateQueries({ queryKey: queryKeys.analyses.detail(analysisId), }) + queryClient.invalidateQueries({ + queryKey: queryKeys.analyses.workspace(analysisId), + }) queryClient.invalidateQueries({ queryKey: queryKeys.analyses.list(projectQueryKey), }) diff --git a/apps/web/src/lib/api/query-keys.ts b/apps/web/src/lib/api/query-keys.ts index 54ef4438..578c347a 100644 --- a/apps/web/src/lib/api/query-keys.ts +++ b/apps/web/src/lib/api/query-keys.ts @@ -23,6 +23,7 @@ export const queryKeys = { all: ["impact-analyses"] as const, list: (projectId: string, params?: { limit?: number; offset?: number }) => ["impact-analyses", "list", projectId, params] as const, detail: (analysisId: string) => ["impact-analyses", "detail", analysisId] as const, + workspace: (analysisId: string) => ["impact-analyses", "workspace", analysisId] as const, runs: { list: (projectId: string) => ["impact-analyses", "runs", "list", projectId] as const, detail: (runId: string) => ["impact-analyses", "runs", "detail", runId] as const, From 796d6795c0519585daecc9985a82348859328048 Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Wed, 24 Jun 2026 11:08:13 +0700 Subject: [PATCH 07/22] feat(web): add analysis localization foundation --- .../analysis/analysis-workspace-shell.tsx | 58 +++-- .../workspace/analysis/evidence-tab.tsx | 21 +- .../analysis/finalize-analysis-dialog.tsx | 53 ++-- .../workspace/analysis/impact-map-tab.tsx | 20 +- .../workspace/analysis/overview-tab.tsx | 60 +++-- .../workspace/analysis/review-report-tab.tsx | 49 ++-- .../workspace/analysis/risks-qa-tab.tsx | 26 +- apps/web/src/lib/i18n/analysis-labels.ts | 230 ++++++++++++++++++ apps/web/src/lib/i18n/status-labels.test.ts | 44 ++++ apps/web/src/lib/i18n/status-labels.ts | 152 ++++++++++++ .../agent/localization-and-domain-glossary.md | 70 ++++++ .../domain-packs/booking/en.glossary.json | 14 ++ .../domain-packs/booking/vi.glossary.json | 14 ++ 13 files changed, 710 insertions(+), 101 deletions(-) create mode 100644 apps/web/src/lib/i18n/analysis-labels.ts create mode 100644 apps/web/src/lib/i18n/status-labels.test.ts create mode 100644 apps/web/src/lib/i18n/status-labels.ts create mode 100644 docs/agent/localization-and-domain-glossary.md create mode 100644 packages/domain-packs/booking/en.glossary.json create mode 100644 packages/domain-packs/booking/vi.glossary.json diff --git a/apps/web/src/components/workspace/analysis/analysis-workspace-shell.tsx b/apps/web/src/components/workspace/analysis/analysis-workspace-shell.tsx index f6c5466e..7e7ee837 100644 --- a/apps/web/src/components/workspace/analysis/analysis-workspace-shell.tsx +++ b/apps/web/src/components/workspace/analysis/analysis-workspace-shell.tsx @@ -3,6 +3,15 @@ import { useMemo, useState } from "react" import type { AnalysisWorkspaceResponse } from "@ba-helper/contracts" import { cn } from "@/lib/utils" +import { + DEFAULT_ANALYSIS_WORKSPACE_LOCALE, + analysisStatusLabels, + driftStatusLabels, + getLocalizedLabel, + reviewStatusLabels, + type SupportedLocale, +} from "@/lib/i18n/status-labels" +import { getAnalysisWorkspaceLabels } from "@/lib/i18n/analysis-labels" import { OverviewTab } from "./overview-tab" import { ImpactMapTab } from "./impact-map-tab" import { EvidenceTab } from "./evidence-tab" @@ -11,20 +20,22 @@ import { ReviewReportTab } from "./review-report-tab" type WorkspaceTab = "overview" | "impact" | "evidence" | "risks-qa" | "review-report" -const tabs: Array<{ id: WorkspaceTab; label: string }> = [ - { id: "overview", label: "Overview" }, - { id: "impact", label: "Impact Map" }, - { id: "evidence", label: "Evidence" }, - { id: "risks-qa", label: "Risks & QA" }, - { id: "review-report", label: "Review & Report" }, -] - export function AnalysisWorkspaceShell({ workspace, + locale = DEFAULT_ANALYSIS_WORKSPACE_LOCALE, }: { workspace: AnalysisWorkspaceResponse + locale?: SupportedLocale }) { const [activeTab, setActiveTab] = useState("overview") + const labels = getAnalysisWorkspaceLabels(locale) + const tabs: Array<{ id: WorkspaceTab; label: string }> = [ + { id: "overview", label: labels.tabs.overview }, + { id: "impact", label: labels.tabs.impact }, + { id: "evidence", label: labels.tabs.evidence }, + { id: "risks-qa", label: labels.tabs.risksQa }, + { id: "review-report", label: labels.tabs.reviewReport }, + ] const stats = useMemo(() => { const reviewed = workspace.reviewQueue.filter( (item) => item.currentDecision !== "needs_review", @@ -48,7 +59,7 @@ export function AnalysisWorkspaceShell({

- Analysis Workspace + {labels.title}

{workspace.overview.requirement.title} @@ -58,14 +69,23 @@ export function AnalysisWorkspaceShell({

- - - + + +
- {activeTab === "overview" && } - {activeTab === "impact" && } - {activeTab === "evidence" && } + {activeTab === "overview" && } + {activeTab === "impact" && } + {activeTab === "evidence" && } {activeTab === "risks-qa" && ( )} {activeTab === "review-report" && ( )}
@@ -113,7 +137,7 @@ function StatusPill({ label, value }: { label: string; value: string }) { {label}
- {value.replace(/_/g, " ")} + {value}
) diff --git a/apps/web/src/components/workspace/analysis/evidence-tab.tsx b/apps/web/src/components/workspace/analysis/evidence-tab.tsx index d27fca5d..86da73c8 100644 --- a/apps/web/src/components/workspace/analysis/evidence-tab.tsx +++ b/apps/web/src/components/workspace/analysis/evidence-tab.tsx @@ -1,14 +1,21 @@ "use client" import type { AnalysisWorkspaceResponse } from "@ba-helper/contracts" +import type { AnalysisWorkspaceLabels } from "@/lib/i18n/analysis-labels" type EvidenceCard = AnalysisWorkspaceResponse["evidenceCards"][number] -export function EvidenceTab({ evidenceCards }: { evidenceCards: EvidenceCard[] }) { +export function EvidenceTab({ + evidenceCards, + labels, +}: { + evidenceCards: EvidenceCard[] + labels: AnalysisWorkspaceLabels["evidence"] +}) { if (evidenceCards.length === 0) { return (
- No evidence cards are available for this analysis. + {labels.empty}
) } @@ -20,14 +27,14 @@ export function EvidenceTab({ evidenceCards }: { evidenceCards: EvidenceCard[] }

- {card.filePath ?? "Requirement evidence"} + {card.filePath ?? labels.requirementEvidence}

- {formatLineRange(card)} · {card.sourceType} + {formatLineRange(card, labels)} · {card.sourceType}

- {card.linkedInsightIds.length} insights · {card.linkedTraceabilityLinkIds.length} links + {card.linkedInsightIds.length} {labels.insights} · {card.linkedTraceabilityLinkIds.length} {labels.links}
@@ -40,9 +47,9 @@ export function EvidenceTab({ evidenceCards }: { evidenceCards: EvidenceCard[] }
   )
 }
 
-function formatLineRange(card: EvidenceCard) {
+function formatLineRange(card: EvidenceCard, labels: AnalysisWorkspaceLabels["evidence"]) {
   const { startLine, endLine } = card.lineRange
-  if (!startLine && !endLine) return "No line range"
+  if (!startLine && !endLine) return labels.noLineRange
   if (startLine && endLine) return `L${startLine}-L${endLine}`
   return `L${startLine ?? endLine}`
 }
diff --git a/apps/web/src/components/workspace/analysis/finalize-analysis-dialog.tsx b/apps/web/src/components/workspace/analysis/finalize-analysis-dialog.tsx
index 0309c7c0..7543936f 100644
--- a/apps/web/src/components/workspace/analysis/finalize-analysis-dialog.tsx
+++ b/apps/web/src/components/workspace/analysis/finalize-analysis-dialog.tsx
@@ -7,6 +7,7 @@ import { useFinalizeAnalysis } from "@/hooks/api/use-analyses"
 import { X, CheckCircle2, AlertTriangle, FileText } from "lucide-react"
 import { toast } from "sonner"
 import { useRouter } from "next/navigation"
+import type { AnalysisWorkspaceLabels } from "@/lib/i18n/analysis-labels"
 
 interface FinalizeAnalysisDialogProps {
   children: React.ReactNode
@@ -21,9 +22,17 @@ interface FinalizeAnalysisDialogProps {
     needsReview: number
   }
   isStale?: boolean
+  labels: AnalysisWorkspaceLabels["reviewReport"]["finalizeDialog"]
 }
 
-export function FinalizeAnalysisDialog({ children, analysisId, commitSha, stats, isStale }: FinalizeAnalysisDialogProps) {
+export function FinalizeAnalysisDialog({
+  children,
+  analysisId,
+  commitSha,
+  stats,
+  isStale,
+  labels,
+}: FinalizeAnalysisDialogProps) {
   const [open, setOpen] = useState(false)
   const [acknowledgeUnreviewed, setAcknowledgeUnreviewed] = useState(false)
   const { mutateAsync: finalizeAnalysis, isPending } = useFinalizeAnalysis(undefined, analysisId)
@@ -34,7 +43,7 @@ export function FinalizeAnalysisDialog({ children, analysisId, commitSha, stats,
   const handleFinalize = async () => {
     try {
       await finalizeAnalysis({ acknowledgeUnreviewed })
-      toast.success("Analysis finalized successfully.")
+      toast.success(labels.success)
       setOpen(false)
       // Redirect directly to the generated report
       router.push(`/reports?analysisId=${analysisId}`)
@@ -43,15 +52,15 @@ export function FinalizeAnalysisDialog({ children, analysisId, commitSha, stats,
       
       // Strict Error Mapping based on Backend error codes
       if (errorMessage.includes("INVALID_STATE_TRANSITION")) {
-        toast.error("This analysis is no longer ready for finalization. Refresh the page.", { duration: 5000 })
+        toast.error(labels.invalidState, { duration: 5000 })
       } else if (errorMessage.includes("FINALIZE_REQUIRES_REVIEW_ACK")) {
-        toast.error("Some insights or links still need review before finalization.", { duration: 5000 })
+        toast.error(labels.requiresReviewAck, { duration: 5000 })
       } else if (errorMessage.includes("ANALYSIS_STALE")) {
-        toast.error("This analysis is stale because the repository snapshot changed. Run a new analysis.", { duration: 5000 })
+        toast.error(labels.stale, { duration: 5000 })
       } else if (errorMessage.includes("APPROVED_REPORT_NOT_FOUND")) {
-        toast.error("The report was not generated yet. Try refreshing.", { duration: 5000 })
+        toast.error(labels.reportMissing, { duration: 5000 })
       } else {
-        toast.error("Failed to finalize analysis", { description: errorMessage })
+        toast.error(labels.failed, { description: errorMessage })
       }
     }
   }
@@ -70,7 +79,7 @@ export function FinalizeAnalysisDialog({ children, analysisId, commitSha, stats,
           
- Finalize Impact Analysis + {labels.title}
@@ -80,26 +89,26 @@ export function FinalizeAnalysisDialog({ children, analysisId, commitSha, stats,

- You are about to finalize this impact analysis. This action will generate an approved Traceability Report in Markdown format. + {labels.description}

- - - - - + + + + +

- The Traceability Report will be generated as an approved Markdown document and will be permanently linked to this analysis. + {labels.reportNotice}

-

Preflight Checklist

+

{labels.preflightChecklist}

@@ -109,7 +118,7 @@ export function FinalizeAnalysisDialog({ children, analysisId, commitSha, stats, )} - {!hasUnreviewedItems ? "All insights and links reviewed" : `${stats.needsReview} insights or links still require review`} + {!hasUnreviewedItems ? labels.reviewed : `${stats.needsReview} ${labels.unreviewed}`}
@@ -123,7 +132,7 @@ export function FinalizeAnalysisDialog({ children, analysisId, commitSha, stats, onChange={(e) => setAcknowledgeUnreviewed(e.target.checked)} />
)} @@ -136,28 +145,28 @@ export function FinalizeAnalysisDialog({ children, analysisId, commitSha, stats, )} - {!isStale ? "Analysis is not stale" : "Analysis is stale (Repository snapshot changed)"} + {!isStale ? labels.notStale : labels.isStale}
- 100% test coverage map generated + {labels.coverageMapGenerated}
- Cancel} /> + {labels.cancel}} />
diff --git a/apps/web/src/components/workspace/analysis/impact-map-tab.tsx b/apps/web/src/components/workspace/analysis/impact-map-tab.tsx index 0f54b6dc..2eb30aa4 100644 --- a/apps/web/src/components/workspace/analysis/impact-map-tab.tsx +++ b/apps/web/src/components/workspace/analysis/impact-map-tab.tsx @@ -2,12 +2,22 @@ import type { AnalysisWorkspaceResponse } from "@ba-helper/contracts" import { ArtifactKindBadge } from "@/components/workspace/shared/status-badges" +import { evidenceBasisLabels, getLocalizedLabel, reviewDecisionLabels, type SupportedLocale } from "@/lib/i18n/status-labels" +import type { AnalysisWorkspaceLabels } from "@/lib/i18n/analysis-labels" type ImpactGroup = AnalysisWorkspaceResponse["impactGroups"][number] -export function ImpactMapTab({ groups }: { groups: ImpactGroup[] }) { +export function ImpactMapTab({ + groups, + locale, + labels, +}: { + groups: ImpactGroup[] + locale: SupportedLocale + labels: AnalysisWorkspaceLabels["impactMap"] +}) { if (groups.length === 0) { - return + return } return ( @@ -37,9 +47,9 @@ export function ImpactMapTab({ groups }: { groups: ImpactGroup[] }) {

{artifact.impactReason}

- Basis: {artifact.impactBasis} - Evidence: {artifact.evidenceIds.length} - Review: {artifact.reviewDecision} + {labels.basis}: {getLocalizedLabel(evidenceBasisLabels, artifact.impactBasis, locale)} + {labels.evidence}: {artifact.evidenceIds.length} + {labels.review}: {getLocalizedLabel(reviewDecisionLabels, artifact.reviewDecision, locale)}
))} diff --git a/apps/web/src/components/workspace/analysis/overview-tab.tsx b/apps/web/src/components/workspace/analysis/overview-tab.tsx index efc77060..86d2870f 100644 --- a/apps/web/src/components/workspace/analysis/overview-tab.tsx +++ b/apps/web/src/components/workspace/analysis/overview-tab.tsx @@ -1,46 +1,62 @@ "use client" import type { AnalysisWorkspaceResponse } from "@ba-helper/contracts" +import { + driftStatusLabels, + exportStatusLabels, + getLocalizedLabel, + reportStatusLabels, + type SupportedLocale, +} from "@/lib/i18n/status-labels" +import type { AnalysisWorkspaceLabels } from "@/lib/i18n/analysis-labels" -export function OverviewTab({ workspace }: { workspace: AnalysisWorkspaceResponse }) { +export function OverviewTab({ + workspace, + locale, + labels, +}: { + workspace: AnalysisWorkspaceResponse + locale: SupportedLocale + labels: AnalysisWorkspaceLabels["overview"] +}) { const { overview, reportStatus, driftStatus } = workspace const counts = overview.counts return (
-

Current State

+

{labels.currentState}

- - - - - - + + + + + +
-

Backend Counts

+

{labels.backendCounts}

- - - - - - + + + + + +
-

Report & Drift

+

{labels.reportAndDrift}

- - - - - - + + + + + +
diff --git a/apps/web/src/components/workspace/analysis/review-report-tab.tsx b/apps/web/src/components/workspace/analysis/review-report-tab.tsx index 78f92619..7d28ca85 100644 --- a/apps/web/src/components/workspace/analysis/review-report-tab.tsx +++ b/apps/web/src/components/workspace/analysis/review-report-tab.tsx @@ -5,6 +5,14 @@ import Link from "next/link" import { Button } from "@/components/ui/button" import { FinalizeAnalysisDialog } from "./finalize-analysis-dialog" import { useReviewInsight, useReviewTraceabilityLink } from "@/hooks/api/use-analyses" +import { + driftStatusLabels, + exportStatusLabels, + getLocalizedLabel, + reportStatusLabels, + type SupportedLocale, +} from "@/lib/i18n/status-labels" +import type { AnalysisWorkspaceLabels } from "@/lib/i18n/analysis-labels" import { toast } from "sonner" type ReviewItem = AnalysisWorkspaceResponse["reviewQueue"][number] @@ -12,6 +20,8 @@ type ReviewItem = AnalysisWorkspaceResponse["reviewQueue"][number] export function ReviewReportTab({ workspace, finalizeStats, + locale, + labels, }: { workspace: AnalysisWorkspaceResponse finalizeStats: { @@ -22,6 +32,8 @@ export function ReviewReportTab({ conflicts: number needsReview: number } + locale: SupportedLocale + labels: AnalysisWorkspaceLabels["reviewReport"] }) { const reviewInsight = useReviewInsight(undefined, workspace.overview.analysisId) const reviewLink = useReviewTraceabilityLink(undefined, workspace.overview.analysisId) @@ -40,10 +52,10 @@ export function ReviewReportTab({ data: { reviewStatus: status }, }) } - toast.success("Review decision saved.") + toast.success(labels.reviewSaved) } catch (error) { - toast.error("Failed to save review decision", { - description: error instanceof Error ? error.message : "Please try again.", + toast.error(labels.reviewSaveFailed, { + description: error instanceof Error ? error.message : labels.retry, }) } } @@ -53,20 +65,20 @@ export function ReviewReportTab({
-

Review Queue

+

{labels.reviewQueue}

- Backend-ranked presentation items that still need human decision. + {labels.reviewQueueDescription}

- {workspace.reviewQueue.length} pending + {workspace.reviewQueue.length} {labels.pending}
{workspace.reviewQueue.length === 0 ? (

- No pending review items. + {labels.noPendingItems}

) : ( workspace.reviewQueue.map((item) => ( @@ -74,7 +86,7 @@ export function ReviewReportTab({
- {item.itemType} · {item.evidenceCount} evidence + {item.itemType} · {item.evidenceCount} {labels.evidence}

{item.title}

@@ -88,14 +100,14 @@ export function ReviewReportTab({ disabled={isStale || item.itemType === "report"} onClick={() => reviewItem(item, "REJECTED")} > - Reject + {labels.reject}

@@ -106,13 +118,13 @@ export function ReviewReportTab({
diff --git a/apps/web/src/components/workspace/analysis/risks-qa-tab.tsx b/apps/web/src/components/workspace/analysis/risks-qa-tab.tsx index af9e4b46..3d78757c 100644 --- a/apps/web/src/components/workspace/analysis/risks-qa-tab.tsx +++ b/apps/web/src/components/workspace/analysis/risks-qa-tab.tsx @@ -2,19 +2,25 @@ import type { ReactNode } from "react" import type { AnalysisWorkspaceResponse } from "@ba-helper/contracts" +import type { AnalysisWorkspaceLabels } from "@/lib/i18n/analysis-labels" +import { getLocalizedLabel, reviewDecisionLabels, type SupportedLocale } from "@/lib/i18n/status-labels" export function RisksQaTab({ risks, unknowns, qaScenarios, + locale, + labels, }: { risks: AnalysisWorkspaceResponse["risks"] unknowns: AnalysisWorkspaceResponse["unknowns"] qaScenarios: AnalysisWorkspaceResponse["qaScenarios"] + locale: SupportedLocale + labels: AnalysisWorkspaceLabels["risksQa"] }) { return (
- + {risks.map((risk) => ( {risk.whyItMatters} @@ -22,26 +28,26 @@ export function RisksQaTab({ ))} - + {unknowns.map((unknown) => ( - + {unknown.question} ))}
- + {qaScenarios.map((scenario) => (

{scenario.title}

- - - + + +

- Regression target: {scenario.regressionTarget} + {labels.regressionTarget}: {scenario.regressionTarget}

))} @@ -51,7 +57,7 @@ export function RisksQaTab({ ) } -function Panel({ title, count, children }: { title: string; count: number; children: ReactNode }) { +function Panel({ title, count, emptyLabel, children }: { title: string; count: number; emptyLabel: string; children: ReactNode }) { return (
@@ -59,7 +65,7 @@ function Panel({ title, count, children }: { title: string; count: number; child {count}
- {count > 0 ? children :

No items.

} + {count > 0 ? children :

{emptyLabel}

}
) diff --git a/apps/web/src/lib/i18n/analysis-labels.ts b/apps/web/src/lib/i18n/analysis-labels.ts new file mode 100644 index 00000000..69fb42a2 --- /dev/null +++ b/apps/web/src/lib/i18n/analysis-labels.ts @@ -0,0 +1,230 @@ +import { DEFAULT_ANALYSIS_WORKSPACE_LOCALE, type SupportedLocale } from "./status-labels" + +export type { SupportedLocale } + +export const analysisWorkspaceLabels = { + en: { + title: "Analysis Workspace", + navLabel: "Analysis workspace sections", + tabs: { + overview: "Overview", + impact: "Impact Map", + evidence: "Evidence", + risksQa: "Risks & QA", + reviewReport: "Review & Report", + }, + status: { + analysis: "Analysis", + review: "Review", + drift: "Drift", + }, + overview: { + currentState: "Current State", + backendCounts: "Backend Counts", + reportAndDrift: "Report & Drift", + requirementRevision: "Requirement revision", + language: "Language", + domainProfile: "Domain profile", + snapshot: "Snapshot", + commit: "Commit", + analyzer: "Analyzer", + impactedArtifacts: "Impacted artifacts", + evidenceItems: "Evidence items", + risks: "Risks", + unknowns: "Unknowns", + qaScenarios: "QA scenarios", + pendingReview: "Pending review", + reportStatus: "Report status", + canExport: "Can export", + driftStatus: "Drift status", + freshnessBasis: "Freshness basis", + snapshotCommit: "Snapshot commit", + latestObservedCommit: "Latest observed commit", + }, + impactMap: { + empty: "No impacted artifacts", + basis: "Basis", + evidence: "Evidence", + review: "Review", + }, + evidence: { + empty: "No evidence cards are available for this analysis.", + requirementEvidence: "Requirement evidence", + noLineRange: "No line range", + insights: "insights", + links: "links", + }, + risksQa: { + risks: "Risks", + unknowns: "Unknowns", + qaScenarios: "QA Scenarios", + given: "Given", + when: "When", + then: "Then", + regressionTarget: "Regression target", + empty: "No items.", + }, + reviewReport: { + reviewQueue: "Review Queue", + reviewQueueDescription: "Backend-ranked presentation items that still need human decision.", + pending: "pending", + noPendingItems: "No pending review items.", + evidence: "evidence", + accept: "Accept", + reject: "Reject", + reportStatus: "Report Status", + report: "Report", + drift: "Drift", + export: "Export", + documentJob: "Document job", + reviewedSnapshot: "Reviewed snapshot", + finalizeAnalysis: "Finalize Analysis", + openReport: "Open Report", + reviewSaved: "Review decision saved.", + reviewSaveFailed: "Failed to save review decision", + retry: "Please try again.", + finalizeDialog: { + success: "Analysis finalized successfully.", + invalidState: "This analysis is no longer ready for finalization. Refresh the page.", + requiresReviewAck: "Some insights or links still need review before finalization.", + stale: "This analysis is stale because the repository snapshot changed. Run a new analysis.", + reportMissing: "The report was not generated yet. Try refreshing.", + failed: "Failed to finalize analysis", + title: "Finalize Impact Analysis", + description: "You are about to finalize this impact analysis. This action will generate an approved Traceability Report in Markdown format.", + totalInsights: "Total Insights", + confirmed: "Confirmed", + rejected: "Rejected", + unknownConflicts: "Unknown/Conflicts", + snapshotCommit: "Snapshot Commit", + reportNotice: "The Traceability Report will be generated as an approved Markdown document and will be permanently linked to this analysis.", + preflightChecklist: "Preflight Checklist", + reviewed: "All insights and links reviewed", + unreviewed: "insights or links still require review", + acknowledgeUnreviewed: "I acknowledge there are unreviewed items and want to finalize anyway.", + notStale: "Analysis is not stale", + isStale: "Analysis is stale (Repository snapshot changed)", + coverageMapGenerated: "100% test coverage map generated", + cancel: "Cancel", + finalizing: "Finalizing...", + confirmFinalize: "Confirm Finalize", + }, + }, + }, + vi: { + title: "Không gian phân tích", + navLabel: "Các phần của không gian phân tích", + tabs: { + overview: "Tổng quan", + impact: "Bản đồ ảnh hưởng", + evidence: "Bằng chứng", + risksQa: "Rủi ro & QA", + reviewReport: "Xem xét & báo cáo", + }, + status: { + analysis: "Phân tích", + review: "Xem xét", + drift: "Độ lệch", + }, + overview: { + currentState: "Trạng thái hiện tại", + backendCounts: "Số liệu backend", + reportAndDrift: "Báo cáo & độ lệch", + requirementRevision: "Phiên bản yêu cầu", + language: "Ngôn ngữ", + domainProfile: "Hồ sơ domain", + snapshot: "Snapshot", + commit: "Commit", + analyzer: "Analyzer", + impactedArtifacts: "Artifact bị ảnh hưởng", + evidenceItems: "Mục bằng chứng", + risks: "Rủi ro", + unknowns: "Điểm chưa rõ", + qaScenarios: "Kịch bản QA", + pendingReview: "Chờ xem xét", + reportStatus: "Trạng thái báo cáo", + canExport: "Có thể xuất", + driftStatus: "Trạng thái lệch", + freshnessBasis: "Cơ sở freshness", + snapshotCommit: "Commit snapshot", + latestObservedCommit: "Commit mới nhất quan sát được", + }, + impactMap: { + empty: "Không có artifact bị ảnh hưởng", + basis: "Cơ sở", + evidence: "Bằng chứng", + review: "Xem xét", + }, + evidence: { + empty: "Không có thẻ bằng chứng cho phân tích này.", + requirementEvidence: "Bằng chứng từ yêu cầu", + noLineRange: "Không có dòng nguồn", + insights: "insight", + links: "link", + }, + risksQa: { + risks: "Rủi ro", + unknowns: "Điểm chưa rõ", + qaScenarios: "Kịch bản QA", + given: "Given", + when: "When", + then: "Then", + regressionTarget: "Mục tiêu regression", + empty: "Không có mục nào.", + }, + reviewReport: { + reviewQueue: "Hàng đợi xem xét", + reviewQueueDescription: "Các mục trình bày do backend xếp hạng vẫn cần quyết định của người dùng.", + pending: "đang chờ", + noPendingItems: "Không có mục nào đang chờ xem xét.", + evidence: "bằng chứng", + accept: "Chấp nhận", + reject: "Bác bỏ", + reportStatus: "Trạng thái báo cáo", + report: "Báo cáo", + drift: "Độ lệch", + export: "Xuất", + documentJob: "Document job", + reviewedSnapshot: "Snapshot đã xem xét", + finalizeAnalysis: "Finalize phân tích", + openReport: "Mở báo cáo", + reviewSaved: "Đã lưu quyết định xem xét.", + reviewSaveFailed: "Không lưu được quyết định xem xét", + retry: "Vui lòng thử lại.", + finalizeDialog: { + success: "Đã finalize phân tích.", + invalidState: "Phân tích này không còn sẵn sàng để finalize. Hãy tải lại trang.", + requiresReviewAck: "Một số insight hoặc link vẫn cần xem xét trước khi finalize.", + stale: "Phân tích này đã cũ vì snapshot repository đã thay đổi. Hãy chạy phân tích mới.", + reportMissing: "Báo cáo chưa được tạo. Hãy thử tải lại.", + failed: "Không finalize được phân tích", + title: "Finalize phân tích tác động", + description: "Bạn sắp finalize phân tích tác động này. Hành động này sẽ tạo Traceability Report đã phê duyệt ở định dạng Markdown.", + totalInsights: "Tổng insight", + confirmed: "Đã xác nhận", + rejected: "Đã bác bỏ", + unknownConflicts: "Chưa rõ/Mâu thuẫn", + snapshotCommit: "Commit snapshot", + reportNotice: "Traceability Report sẽ được tạo như tài liệu Markdown đã phê duyệt và được liên kết vĩnh viễn với phân tích này.", + preflightChecklist: "Checklist trước khi finalize", + reviewed: "Tất cả insight và link đã được xem xét", + unreviewed: "insight hoặc link vẫn cần xem xét", + acknowledgeUnreviewed: "Tôi xác nhận vẫn còn mục chưa xem xét và vẫn muốn finalize.", + notStale: "Phân tích chưa bị stale", + isStale: "Phân tích đã stale (snapshot repository đã thay đổi)", + coverageMapGenerated: "Đã tạo bản đồ test coverage 100%", + cancel: "Hủy", + finalizing: "Đang finalize...", + confirmFinalize: "Xác nhận finalize", + }, + }, + }, +} as const + +export type AnalysisWorkspaceLabels = (typeof analysisWorkspaceLabels)[SupportedLocale] + +export function getAnalysisWorkspaceLabels( + locale: SupportedLocale = DEFAULT_ANALYSIS_WORKSPACE_LOCALE, +): AnalysisWorkspaceLabels { + return analysisWorkspaceLabels[locale] +} diff --git a/apps/web/src/lib/i18n/status-labels.test.ts b/apps/web/src/lib/i18n/status-labels.test.ts new file mode 100644 index 00000000..83fe9d21 --- /dev/null +++ b/apps/web/src/lib/i18n/status-labels.test.ts @@ -0,0 +1,44 @@ +import { + DEFAULT_ANALYSIS_WORKSPACE_LOCALE, + analysisStatusLabels, + driftStatusLabels, + evidenceBasisLabels, + exportStatusLabels, + formatFallbackLabel, + getLocalizedLabel, + reportStatusLabels, + reviewDecisionLabels, + reviewStatusLabels, + snapshotStatusLabels, +} from "./status-labels" +import { analysisWorkspaceLabels, getAnalysisWorkspaceLabels } from "./analysis-labels" + +describe("analysis workspace i18n labels", () => { + it("keeps runtime default locale English", () => { + expect(DEFAULT_ANALYSIS_WORKSPACE_LOCALE).toBe("en") + expect(getAnalysisWorkspaceLabels().title).toBe("Analysis Workspace") + }) + + it("provides Vietnamese labels without switching global product mode", () => { + expect(analysisWorkspaceLabels.vi.tabs.overview).toBe("Tổng quan") + expect(getAnalysisWorkspaceLabels("vi").reviewReport.accept).toBe("Chấp nhận") + }) + + it("maps status and review decisions from English contract values", () => { + expect(getLocalizedLabel(reviewStatusLabels, "in_progress", "vi")).toBe("Đang xử lý") + expect(getLocalizedLabel(reportStatusLabels, "completed", "vi")).toBe("Đã hoàn tất") + expect(getLocalizedLabel(driftStatusLabels, "stale", "vi")).toBe("Đã cũ") + expect(getLocalizedLabel(reviewDecisionLabels, "needs_more_evidence", "vi")).toBe("Cần thêm bằng chứng") + expect(getLocalizedLabel(reviewDecisionLabels, "NEEDS_REVIEW", "vi")).toBe("Cần xem xét") + expect(getLocalizedLabel(analysisStatusLabels, "WAITING_FOR_REVIEW", "vi")).toBe("Chờ xem xét") + expect(getLocalizedLabel(snapshotStatusLabels, "locked", "vi")).toBe("Đã khóa") + expect(getLocalizedLabel(evidenceBasisLabels, "conflicting", "vi")).toBe("Mâu thuẫn") + expect(getLocalizedLabel(exportStatusLabels, "available", "vi")).toBe("Có thể xuất") + }) + + it("falls back mechanically for missing labels without inventing business state", () => { + expect(formatFallbackLabel("SOME_NEW_STATUS")).toBe("SOME NEW STATUS") + expect(getLocalizedLabel(reportStatusLabels, "archived", "vi")).toBe("archived") + expect(getLocalizedLabel(exportStatusLabels, null, "vi")).toBe("Không áp dụng") + }) +}) diff --git a/apps/web/src/lib/i18n/status-labels.ts b/apps/web/src/lib/i18n/status-labels.ts new file mode 100644 index 00000000..d19f4e7a --- /dev/null +++ b/apps/web/src/lib/i18n/status-labels.ts @@ -0,0 +1,152 @@ +export type SupportedLocale = "en" | "vi" + +export const DEFAULT_ANALYSIS_WORKSPACE_LOCALE: SupportedLocale = "en" + +type LabelMap = Record + +type LocalizedLabelMap = Record + +export const analysisStatusLabels = { + en: { + QUEUED: "Queued", + RUNNING: "Running", + WAITING_FOR_REVIEW: "Waiting for review", + COMPLETED: "Completed", + FAILED: "Failed", + CANCELLED: "Cancelled", + }, + vi: { + QUEUED: "Đang chờ", + RUNNING: "Đang chạy", + WAITING_FOR_REVIEW: "Chờ xem xét", + COMPLETED: "Đã hoàn tất", + FAILED: "Thất bại", + CANCELLED: "Đã hủy", + }, +} as const satisfies LocalizedLabelMap + +export const reviewStatusLabels = { + en: { + not_started: "Not started", + in_progress: "In progress", + complete: "Complete", + }, + vi: { + not_started: "Chưa bắt đầu", + in_progress: "Đang xử lý", + complete: "Đã hoàn tất", + }, +} as const satisfies LocalizedLabelMap + +export const snapshotStatusLabels = { + en: { + missing: "Missing", + locked: "Locked", + }, + vi: { + missing: "Thiếu", + locked: "Đã khóa", + }, +} as const satisfies LocalizedLabelMap + +export const reportStatusLabels = { + en: { + missing: "Missing", + queued: "Queued", + running: "Running", + completed: "Completed", + failed: "Failed", + }, + vi: { + missing: "Thiếu", + queued: "Đang chờ", + running: "Đang chạy", + completed: "Đã hoàn tất", + failed: "Thất bại", + }, +} as const satisfies LocalizedLabelMap + +export const driftStatusLabels = { + en: { + unknown: "Unknown", + fresh: "Fresh", + stale: "Stale", + }, + vi: { + unknown: "Chưa rõ", + fresh: "Còn mới", + stale: "Đã cũ", + }, +} as const satisfies LocalizedLabelMap + +export const reviewDecisionLabels = { + en: { + needs_review: "Needs review", + accepted: "Accepted", + rejected: "Rejected", + needs_more_evidence: "Needs more evidence", + NEEDS_REVIEW: "Needs review", + ACCEPTED: "Accepted", + REJECTED: "Rejected", + NEEDS_MORE_EVIDENCE: "Needs more evidence", + CONFIRMED: "Confirmed", + }, + vi: { + needs_review: "Cần xem xét", + accepted: "Đã chấp nhận", + rejected: "Đã bác bỏ", + needs_more_evidence: "Cần thêm bằng chứng", + NEEDS_REVIEW: "Cần xem xét", + ACCEPTED: "Đã chấp nhận", + REJECTED: "Đã bác bỏ", + NEEDS_MORE_EVIDENCE: "Cần thêm bằng chứng", + CONFIRMED: "Đã xác nhận", + }, +} as const satisfies LocalizedLabelMap + +export const evidenceBasisLabels = { + en: { + evidenced: "Evidenced", + inferred: "Inferred", + unknown: "Unknown", + conflicting: "Conflicting", + }, + vi: { + evidenced: "Có bằng chứng", + inferred: "Suy luận", + unknown: "Chưa rõ", + conflicting: "Mâu thuẫn", + }, +} as const satisfies LocalizedLabelMap + +export const exportStatusLabels = { + en: { + available: "Available", + blocked: "Blocked", + none: "None", + yes: "Yes", + no: "No", + not_applicable: "N/A", + }, + vi: { + available: "Có thể xuất", + blocked: "Bị chặn", + none: "Không có", + yes: "Có", + no: "Không", + not_applicable: "Không áp dụng", + }, +} as const satisfies LocalizedLabelMap + +export function getLocalizedLabel( + labels: LocalizedLabelMap, + value: string | null | undefined, + locale: SupportedLocale = DEFAULT_ANALYSIS_WORKSPACE_LOCALE, +) { + if (!value) return labels[locale].not_applicable ?? formatFallbackLabel("not_applicable") + return labels[locale][value] ?? formatFallbackLabel(value) +} + +export function formatFallbackLabel(value: string) { + return value.replace(/_/g, " ").trim() +} diff --git a/docs/agent/localization-and-domain-glossary.md b/docs/agent/localization-and-domain-glossary.md new file mode 100644 index 00000000..2e622660 --- /dev/null +++ b/docs/agent/localization-and-domain-glossary.md @@ -0,0 +1,70 @@ +# Localization And Domain Glossary + +## Purpose + +Localization in the MVP is a presentation concern. It helps the workspace render +stable labels in the user's language without changing persisted state, API +contracts, analyzer behavior, retrieval behavior, or evidence semantics. + +PR5E establishes the foundation only: + +- fixed analysis workspace labels live in centralized locale dictionaries +- status labels are mapped from English contract values at render time +- Booking terminology has static English and Vietnamese glossary files +- default runtime locale remains English + +This is not a full Vietnamese product mode and not multi-domain runtime support. + +## Invariants + +- Contracts, enum values, database values, and API payload keys stay English. +- UI components render translated labels only through the i18n mapping layer. +- Do not store Vietnamese enum values in PostgreSQL. +- Do not rename contract enum values to match a locale. +- Do not translate evidence excerpts, code paths, artifact keys, provenance IDs, + commit SHAs, source line ranges, or backend-provided analysis text. +- Missing label mappings may fall back mechanically for display, but must not + create or infer a new business state. + +## Glossary Boundary + +Glossary JSON files under `packages/domain-packs` are terminology references. +They are allowed to describe domain terms such as booking, cancellation, refund, +inventory release, and payment state in multiple locales. + +They are not executable analyzer rules: + +- do not inject these glossary files into prompts in this phase +- do not use them to create risks, unknowns, QA scenarios, or evidence +- do not use them to claim a new supported domain +- do not use them as a replacement for persisted evidence links + +Domain behavior still comes from the existing backend domain-profile and +retrieval code. Any future connection between glossary assets and analyzer +behavior requires explicit scope, tests, and documentation updates. + +## Adding Locale Labels + +Add labels by extending the centralized dictionaries under +`apps/web/src/lib/i18n`. Keep component code focused on selecting the correct +label, not on embedding translated text. + +When adding a new status-like value: + +1. Keep the contract value in English. +2. Add English and Vietnamese render labels. +3. Add a focused test for the mapping. +4. Confirm unknown values fall back without inventing business meaning. + +## Adding Glossary Terms + +Glossary files must include: + +- `domain` +- `locale` +- `status` +- `version` +- `terms` + +Term keys should remain stable English identifiers. Term values are localized +display/reference text. diff --git a/packages/domain-packs/booking/en.glossary.json b/packages/domain-packs/booking/en.glossary.json new file mode 100644 index 00000000..421bd54e --- /dev/null +++ b/packages/domain-packs/booking/en.glossary.json @@ -0,0 +1,14 @@ +{ + "domain": "booking", + "locale": "en", + "status": "foundation", + "version": "1.0.0", + "terms": { + "booking": "booking", + "cancellation": "cancellation", + "refund": "refund", + "doubleRefund": "double refund", + "inventoryRelease": "inventory release", + "paymentState": "payment state" + } +} diff --git a/packages/domain-packs/booking/vi.glossary.json b/packages/domain-packs/booking/vi.glossary.json new file mode 100644 index 00000000..b2160f08 --- /dev/null +++ b/packages/domain-packs/booking/vi.glossary.json @@ -0,0 +1,14 @@ +{ + "domain": "booking", + "locale": "vi", + "status": "foundation", + "version": "1.0.0", + "terms": { + "booking": "đơn đặt phòng", + "cancellation": "hủy đặt phòng", + "refund": "hoàn tiền", + "doubleRefund": "hoàn tiền trùng", + "inventoryRelease": "giải phóng tồn phòng", + "paymentState": "trạng thái thanh toán" + } +} From f6a7acccf710a6f8cf8fa621992002e02780f1aa Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Wed, 24 Jun 2026 11:16:13 +0700 Subject: [PATCH 08/22] docs: define security scale and performance hardening model --- docs/agent/CONTEXT_INDEX.md | 17 +++ docs/agent/scale-and-performance-model.md | 132 +++++++++++++++++++++ docs/agent/security-hardening-model.md | 137 ++++++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 docs/agent/scale-and-performance-model.md create mode 100644 docs/agent/security-hardening-model.md diff --git a/docs/agent/CONTEXT_INDEX.md b/docs/agent/CONTEXT_INDEX.md index 4660a674..2ba61129 100644 --- a/docs/agent/CONTEXT_INDEX.md +++ b/docs/agent/CONTEXT_INDEX.md @@ -21,6 +21,8 @@ Read these first for any BA_Helper task: - `docs/agent/architecture.md` - `docs/agent/testing-strategy.md` - `docs/agent/done-checklist.md` +- `docs/agent/security-hardening-model.md` +- `docs/agent/scale-and-performance-model.md` ## Historical docs @@ -61,6 +63,21 @@ Read: - `architecture.md` - `input-quality.md` +### Security / hardening + +Read: +- `security-hardening-model.md` +- `input-quality.md` +- `ai-rules.md` +- `auth-permissions.md` + +### Scale / performance + +Read: +- `scale-and-performance-model.md` +- `architecture.md` +- `testing-strategy.md` + ### Docs cleanup Read: diff --git a/docs/agent/scale-and-performance-model.md b/docs/agent/scale-and-performance-model.md new file mode 100644 index 00000000..5da467f8 --- /dev/null +++ b/docs/agent/scale-and-performance-model.md @@ -0,0 +1,132 @@ +# Scale And Performance Model + +## Purpose + +This document defines the current scale baseline and performance budgets for +repository scan, impact analysis, retrieval, document generation, and workspace +rendering. It is a planning model, not a broad optimization project. + +## Current Scale Envelope + +The MVP is sized for public NestJS TypeScript repositories in the Booking / +Payment / Refund vertical slice. + +Baseline repository envelope: + +```text +source bytes scanned <= 100 MB +total files considered <= 10,000 +TypeScript parser files <= 2,000 +single selected file <= 1 MB +clone timeout <= 60 seconds +static scan timeout <= 120 seconds +``` + +Repositories above these limits should produce explicit skipped-input coverage +or failed scan state, not silent partial confidence. + +## Pipeline Budgets + +Repository ingestion and scan: + +- target p50: under 30 seconds for fixture-sized repositories +- target p95: under 120 seconds within the configured scan envelope +- hard guard: fail or mark partial when file, byte, or timeout limits are hit +- no package install, test execution, or repo script execution + +Impact analysis: + +- target p50: under 45 seconds after a usable snapshot exists +- target p95: under 180 seconds for bounded retrieval and one LLM reasoning call +- evidence sent to the LLM is capped by item and character budgets +- lifecycle completion must not depend on frontend polling inference + +Retrieval: + +- default retrieval request budget: `maxResults=20` +- LLM evidence pack budget: up to 12 evidence candidates and 30,000 evidence + characters +- vector/RAG queries must remain snapshot-scoped and project-scoped +- future metrics should split lexical, graph, vector, and rerank time + +Worker queue: + +- target p50 wait: under 10 seconds in local/single-worker MVP +- target p95 wait: under 60 seconds under demo load +- retryable jobs must be idempotent and keyed by persisted command/job identity +- queue processors call application use cases and do not own business logic + +Report generation: + +- target p50: under 10 seconds for approved Markdown generation +- target p95: under 30 seconds for MVP-sized reports +- no active LLM call during final report document generation +- report section sizes should stay bounded by persisted read-model/report + builders before adding richer exports + +Frontend workspace: + +- initial analysis workspace render target: under 1 second after read-model data + is available +- large lists should be tab-scoped and rendered from the backend presentation + contract +- frontend must not reconstruct business state from raw backend records +- future large-analysis work should add virtualization or pagination before + increasing card counts substantially + +## Guardrail Constants + +Existing guardrails: + +```text +MAX_REPO_SIZE_MB=100 +MAX_FILE_COUNT=10000 +MAX_TS_FILE_COUNT=2000 +MAX_FILE_SIZE_KB=1024 +CLONE_TIMEOUT_MS=60000 +SCAN_TIMEOUT_MS=120000 +retrieval maxResults=20 +MAX_EVIDENCE_ITEMS_FOR_LLM=12 +MAX_TOTAL_EVIDENCE_CHARS=30000 +artifact excerpt policy limit=50000 bytes +``` + +Future constants to centralize before scale-up: + +```text +MAX_RETRIEVAL_CANDIDATES +MAX_REPORT_SECTION_ITEMS +MAX_WORKSPACE_CARD_ITEMS +MAX_EVENT_PAYLOAD_BYTES +MAX_DOCUMENT_MARKDOWN_BYTES +``` + +Do not raise budgets to hide slow behavior. If a limit blocks a real use case, +add a fixture, measure the pipeline stage, and update tests/docs with the new +budget. + +## Metrics To Add Later + +Add p50/p95 metrics once observability is promoted beyond local demo proof: + +- clone duration +- scan enumeration duration +- parser duration by language adapter +- skipped file counts by reason +- retrieval duration by strategy +- evidence candidate count and truncation flag +- LLM provider latency and validation failure count +- queue wait and run duration by job type +- document generation duration and size +- workspace endpoint latency and response size +- frontend render time for each workspace tab + +## Known Risks To Track + +- Current limits are configured for MVP correctness, not high-throughput SaaS. +- Large reports can still grow with persisted insight/evidence volume until + section item caps are centralized. +- Queue wait budgets are not yet enforced by monitoring or autoscaling policy. +- Browser rendering has not been load-tested with very large review queues. +- Project/tenant isolation metrics are not yet part of routine performance + reporting. diff --git a/docs/agent/security-hardening-model.md b/docs/agent/security-hardening-model.md new file mode 100644 index 00000000..19b4c594 --- /dev/null +++ b/docs/agent/security-hardening-model.md @@ -0,0 +1,137 @@ +# Security Hardening Model + +## Purpose + +This document defines the current hardening baseline for repository scanning, +AI reasoning, evidence exposure, and report generation. It is a threat model +and operating boundary, not a claim that the product satisfies a production +security review or enterprise deployment requirements. + +## Threat Model + +Primary untrusted inputs: + +- public repository URLs, refs, files, comments, README content, and test data +- requirement titles and raw requirement text +- retrieved evidence excerpts and source locations +- LLM provider output +- queued job payload IDs such as `analysisId` and `documentJobId` + +Primary risks: + +- repository input attempts to escape the scanner workspace +- repository content attempts to execute code during scan +- binary, generated, vendor, or oversized files exhaust parser resources +- source text contains secrets that could be persisted, logged, embedded, or + sent to a real LLM provider +- prompt injection text in code comments or requirements tries to control the + model +- API read models expose Prisma-shaped records or cross-project state +- final report generation accidentally calls a live LLM or reads mutable state + as if it were reviewed output + +## Repository Ingestion Boundary + +Repository scanning is static only. The scanner may read files and parse +supported source formats, but it must not execute repository scripts, import +application modules, run package managers, run tests, or follow repository +instructions. + +Required ingestion guards: + +- accept only validated repository source inputs from the API layer +- use git/library argument arrays, not shell string concatenation +- disable interactive git credential prompts +- clone/fetch with bounded timeouts +- never recurse submodules by default +- skip symlinks instead of following them +- ignore dependency, build, cache, vendor, generated, and known binary/archive + paths +- enforce file count, TypeScript file count, single-file byte, and total scan + byte limits +- sniff selected files for binary content before they enter parser paths + +Current scanner limits are defined in `packages/analyzer/src/scanner/core/limits.ts`: + +```text +MAX_REPO_SIZE_MB=100 +MAX_FILE_COUNT=10000 +MAX_TS_FILE_COUNT=2000 +MAX_FILE_SIZE_KB=1024 +CLONE_TIMEOUT_MS=60000 +SCAN_TIMEOUT_MS=120000 +``` + +Changing these limits may alter published artifacts, evidence, skipped-input +coverage, and analyzer compatibility. Treat such changes as analyzer-version +impacting unless proven otherwise. + +## Secret And Evidence Boundary + +Evidence excerpts are untrusted source data. Before persistence, scanner-derived +evidence excerpts pass through the secret redaction layer and store redaction +state. Embedding and real-provider prompt payloads must also pass through AI +redaction policy before external transmission. + +The system must not log or expose raw provider prompts, raw provider responses, +detected secret values, or unredacted evidence payloads. Diagnostics may record +counts, IDs, source paths, hashes, or redaction markers, but not secret literals. + +Evidence shown in UI and reports remains source text after redaction. It is not +translated, normalized into business prose, or treated as an instruction. + +## Prompt Injection Boundary + +Code, comments, documentation, evidence, and requirements can contain hostile +instructions. Prompt construction must frame selected repository content as data +to inspect, not instructions to follow. + +Model output is not trusted until it passes schema validation and application +integrity checks. An LLM cannot create evidence support by inventing evidence +IDs, artifact keys, certainty labels, or review decisions. `EVIDENCED` claims +must resolve to persisted evidence supplied for the analysis. + +## Report Generation Boundary + +Final report generation consumes persisted analysis, review, evidence, graph, +and reviewed snapshot state. It must not call an active LLM provider. A final +report can remain completed historical output even when the source target later +makes the analysis stale. + +Document jobs are queue execution state. `documentJobId` is not authority by +itself; user-facing access paths must remain scoped through the analysis, +project, and reviewed snapshot ownership model. + +## Project And Tenant Isolation + +Current MVP scoping expectations: + +- requirement revision, repository, snapshot, impact analysis, evidence, + documents, review items, retrieval queries, and vector chunks are project + scoped +- RAG queries must filter by tenant/project/repository/snapshot identity +- MVP `tenantId` equals `projectId` +- future tenant identity becomes organization identity + +Known risk: the MVP still has dev/single-user assumptions in parts of the stack. +Do not claim full production authorization coverage until project-scoped auth +guards and negative cross-project tests cover every user-facing endpoint and +queued read/write path. + +## Event Log Semantics + +Audit events capture meaningful lifecycle and domain changes such as scan +completion, artifact extraction, analysis state changes, review decisions, and +document generation. Event payloads should include IDs, counts, state names, and +safe metadata. They must not include raw source excerpts, secret literals, raw +LLM prompts, raw provider responses, or unbounded diagnostic blobs. + +## Known Risks To Track + +- Full production auth and organization-level tenant isolation are not complete + in the MVP. +- Rate limiting and abuse throttling are not yet the primary hardening layer. +- Temporary checkout retention policy must be deployment-specific before + scanning non-fixture public repositories at scale. +- Real-provider LLM smoke tests are opt-in and do not replace a dedicated safety + evaluation suite. From 14fb4edebe8099039280c9a1fb33bf05f68584ba Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Wed, 24 Jun 2026 11:16:19 +0700 Subject: [PATCH 09/22] fix(analyzer): skip binary content during safe file enumeration --- .../src/scanner/core/safe-file-enumerator.ts | 38 +++++++++++++++++++ .../scanner/core/safe-file-enumerator.spec.ts | 13 +++++++ 2 files changed, 51 insertions(+) diff --git a/packages/analyzer/src/scanner/core/safe-file-enumerator.ts b/packages/analyzer/src/scanner/core/safe-file-enumerator.ts index 2b6457a4..5f7e288b 100644 --- a/packages/analyzer/src/scanner/core/safe-file-enumerator.ts +++ b/packages/analyzer/src/scanner/core/safe-file-enumerator.ts @@ -97,6 +97,9 @@ const INITIAL_SKIPPED_SUMMARY: Record = { UNSUPPORTED_LANGUAGE: 0, }; +const BINARY_SNIFF_BYTES = 8192; +const BINARY_CONTROL_BYTE_RATIO = 0.3; + export class SafeFileEnumerator { private skippedFiles: Array<{ path: string; reason: ScanSkipReason }> = []; private skippedSummary: Record = { ...INITIAL_SKIPPED_SUMMARY }; @@ -220,6 +223,12 @@ export class SafeFileEnumerator { continue; } + if (await this.isBinaryFile(fullPath, stat.size)) { + diagnostics.push({ code: 'BINARY_SKIPPED', severity: 'INFO', message: 'Binary file skipped', filePath: relativePath }); + this.recordSkip(relativePath, 'BINARY_FILE'); + continue; + } + totalSizeBytes += stat.size; const totalSizeMb = totalSizeBytes / (1024 * 1024); if (this.limitsPolicy.isRepoSizeExceeded(totalSizeMb)) { @@ -298,4 +307,33 @@ export class SafeFileEnumerator { limitHits, }; } + + private async isBinaryFile(filePath: string, fileSize: number): Promise { + if (fileSize === 0) return false; + + const handle = await fs.open(filePath, 'r'); + try { + const length = Math.min(fileSize, BINARY_SNIFF_BYTES); + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, 0); + return isBinaryBuffer(buffer.subarray(0, bytesRead)); + } finally { + await handle.close(); + } + } +} + +function isBinaryBuffer(buffer: Buffer): boolean { + if (buffer.length === 0) return false; + if (buffer.includes(0)) return true; + + let controlBytes = 0; + for (const byte of buffer) { + const isAllowedWhitespace = byte === 9 || byte === 10 || byte === 12 || byte === 13; + if (byte < 32 && !isAllowedWhitespace) { + controlBytes++; + } + } + + return controlBytes / buffer.length > BINARY_CONTROL_BYTE_RATIO; } diff --git a/packages/analyzer/tests/scanner/core/safe-file-enumerator.spec.ts b/packages/analyzer/tests/scanner/core/safe-file-enumerator.spec.ts index 7b946c06..3db5fe1a 100644 --- a/packages/analyzer/tests/scanner/core/safe-file-enumerator.spec.ts +++ b/packages/analyzer/tests/scanner/core/safe-file-enumerator.spec.ts @@ -45,6 +45,19 @@ describe('SafeFileEnumerator', () => { expect(result.diagnostics.some(d => d.code === 'BINARY_SKIPPED')).toBe(true); }); + it('skips binary content even when the extension looks supported', async () => { + await fs.writeFile(path.join(tmpDir, 'malicious.ts'), Buffer.from([0x00, 0x01, 0x02, 0x03])); + await fs.writeFile(path.join(tmpDir, 'main.ts'), 'const value = 1;'); + + const enumerator = new SafeFileEnumerator(tmpDir); + const result = await enumerator.enumerate(); + + expect(result.tsFiles).toHaveLength(1); + expect(result.tsFiles[0].endsWith('main.ts')).toBe(true); + expect(result.allFiles.some(file => file.endsWith('malicious.ts'))).toBe(false); + expect(result.skippedSummary.BINARY_FILE).toBeGreaterThanOrEqual(1); + }); + it('skips files exceeding MAX_FILE_SIZE_KB', async () => { const limits: ScanLimits = { MAX_REPO_SIZE_MB: 100, From ba8c6ba68496a514ed44b70f94d026644707598c Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Wed, 24 Jun 2026 11:28:11 +0700 Subject: [PATCH 10/22] feat(contracts): define analysis lineage diff model --- .../impact-analysis-diff.contract.spec.ts | 44 +++++++++++++++++++ .../contracts/src/impact-analysis.contract.ts | 1 + 2 files changed, 45 insertions(+) create mode 100644 packages/contracts/impact-analysis-diff.contract.spec.ts diff --git a/packages/contracts/impact-analysis-diff.contract.spec.ts b/packages/contracts/impact-analysis-diff.contract.spec.ts new file mode 100644 index 00000000..d85cdefc --- /dev/null +++ b/packages/contracts/impact-analysis-diff.contract.spec.ts @@ -0,0 +1,44 @@ +import { + impactAnalysisDiffResponseSchema, + type ImpactAnalysisDiffResponse, +} from './src'; + +describe('impactAnalysisDiffResponseSchema', () => { + it('accepts lineage clarification IDs in the comparison context', () => { + const payload: ImpactAnalysisDiffResponse = { + baseAnalysisId: '00000000-0000-4000-8000-000000000001', + currentAnalysisId: '00000000-0000-4000-8000-000000000002', + comparisonContext: { + requirementChanged: true, + snapshotChanged: true, + baseRequirementRevisionId: '00000000-0000-4000-8000-000000000003', + currentRequirementRevisionId: '00000000-0000-4000-8000-000000000004', + baseSnapshotId: '00000000-0000-4000-8000-000000000005', + currentSnapshotId: '00000000-0000-4000-8000-000000000006', + baseCommitSha: 'base-commit', + currentCommitSha: 'current-commit', + sourceClarificationId: '00000000-0000-4000-8000-000000000007', + reviewClarificationRequestId: + '00000000-0000-4000-8000-000000000008', + }, + summary: { + addedImpacts: 0, + removedImpacts: 0, + unchangedImpacts: 0, + resolvedUnknowns: 0, + removedUnknowns: 0, + newUnknowns: 0, + addedQaScenarios: 0, + }, + addedArtifacts: [], + removedArtifacts: [], + unchangedArtifacts: [], + resolvedUnknowns: [], + removedUnknowns: [], + newUnknowns: [], + addedQaScenarios: [], + }; + + expect(impactAnalysisDiffResponseSchema.parse(payload)).toEqual(payload); + }); +}); diff --git a/packages/contracts/src/impact-analysis.contract.ts b/packages/contracts/src/impact-analysis.contract.ts index a795ab11..6f048ee5 100644 --- a/packages/contracts/src/impact-analysis.contract.ts +++ b/packages/contracts/src/impact-analysis.contract.ts @@ -334,6 +334,7 @@ export const impactAnalysisDiffResponseSchema = z.object({ baseCommitSha: z.string().optional(), currentCommitSha: z.string().optional(), sourceClarificationId: z.string().uuid().optional(), + reviewClarificationRequestId: z.string().uuid().optional(), }), summary: z.object({ From f3fbfa264c9de5f1a290784f9cd0cb16178d9cf6 Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Wed, 24 Jun 2026 11:28:23 +0700 Subject: [PATCH 11/22] feat(api): expose analysis lineage diff read model --- .../application/queries/get-impact-diff.usecase.spec.ts | 3 +++ .../application/queries/get-impact-diff.usecase.ts | 1 + 2 files changed, 4 insertions(+) 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) From 81f59d012a95af886eb0d18978a456e2f4595254 Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Wed, 24 Jun 2026 11:28:29 +0700 Subject: [PATCH 12/22] feat(web): add analysis lineage diff workspace --- .../analysis/analysis-workspace-shell.tsx | 11 +- .../analysis/lineage-diff-tab.test.tsx | 227 ++++++++++++ .../workspace/analysis/lineage-diff-tab.tsx | 336 ++++++++++++++++++ .../analysis/lineage-diff-view-model.test.ts | 153 ++++++++ .../analysis/lineage-diff-view-model.ts | 95 +++++ apps/web/src/hooks/api/use-analyses.ts | 12 +- apps/web/src/lib/i18n/analysis-labels.ts | 88 +++++ 7 files changed, 917 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/workspace/analysis/lineage-diff-tab.test.tsx create mode 100644 apps/web/src/components/workspace/analysis/lineage-diff-tab.tsx create mode 100644 apps/web/src/components/workspace/analysis/lineage-diff-view-model.test.ts create mode 100644 apps/web/src/components/workspace/analysis/lineage-diff-view-model.ts diff --git a/apps/web/src/components/workspace/analysis/analysis-workspace-shell.tsx b/apps/web/src/components/workspace/analysis/analysis-workspace-shell.tsx index 7e7ee837..385ec066 100644 --- a/apps/web/src/components/workspace/analysis/analysis-workspace-shell.tsx +++ b/apps/web/src/components/workspace/analysis/analysis-workspace-shell.tsx @@ -17,8 +17,9 @@ import { ImpactMapTab } from "./impact-map-tab" import { EvidenceTab } from "./evidence-tab" import { RisksQaTab } from "./risks-qa-tab" import { ReviewReportTab } from "./review-report-tab" +import { LineageDiffTab } from "./lineage-diff-tab" -type WorkspaceTab = "overview" | "impact" | "evidence" | "risks-qa" | "review-report" +type WorkspaceTab = "overview" | "impact" | "evidence" | "risks-qa" | "review-report" | "lineage-diff" export function AnalysisWorkspaceShell({ workspace, @@ -35,6 +36,7 @@ export function AnalysisWorkspaceShell({ { id: "evidence", label: labels.tabs.evidence }, { id: "risks-qa", label: labels.tabs.risksQa }, { id: "review-report", label: labels.tabs.reviewReport }, + { id: "lineage-diff", label: labels.tabs.lineageDiff }, ] const stats = useMemo(() => { const reviewed = workspace.reviewQueue.filter( @@ -126,6 +128,13 @@ export function AnalysisWorkspaceShell({ labels={labels.reviewReport} /> )} + {activeTab === "lineage-diff" && ( + + )}
) } diff --git a/apps/web/src/components/workspace/analysis/lineage-diff-tab.test.tsx b/apps/web/src/components/workspace/analysis/lineage-diff-tab.test.tsx new file mode 100644 index 00000000..fae945df --- /dev/null +++ b/apps/web/src/components/workspace/analysis/lineage-diff-tab.test.tsx @@ -0,0 +1,227 @@ +import { render, screen } from "@testing-library/react" +import type { + AnalysisWorkspaceResponse, + ImpactAnalysisDiffResponse, + LineageTimelineResponse, +} from "@ba-helper/contracts" +import { analysisWorkspaceLabels } from "@/lib/i18n/analysis-labels" +import { LineageDiffTab } from "./lineage-diff-tab" + +const mockUseAnalysisDiff = jest.fn() +const mockUseAnalysisLineage = jest.fn() + +jest.mock("@/hooks/api/use-analyses", () => ({ + useAnalysisDiff: () => mockUseAnalysisDiff(), + useAnalysisLineage: () => mockUseAnalysisLineage(), +})) + +const workspace = { + overview: { + analysisId: "00000000-0000-4000-8000-000000000002", + requirement: { + revisionId: "00000000-0000-4000-8000-000000000004", + title: "Cancel paid booking", + summary: "Allow refunds for paid booking cancellation.", + language: "en", + domainProfileId: "booking@1", + }, + snapshot: { + snapshotId: "00000000-0000-4000-8000-000000000006", + repositoryId: "00000000-0000-4000-8000-000000000099", + commitSha: "current-commit", + analyzerVersion: "test", + }, + status: { + analysisStatus: "WAITING_FOR_REVIEW", + reviewStatus: "in_progress", + snapshotStatus: "locked", + reportStatus: "missing", + driftStatus: "fresh", + }, + counts: { + impactedArtifacts: 1, + evidenceItems: 0, + risks: 0, + unknowns: 1, + qaScenarios: 1, + pendingReviewItems: 1, + }, + }, + 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: null, + latestObservedCommitSha: "current-commit", + snapshotCommitSha: "current-commit", + reason: null, + }, +} satisfies AnalysisWorkspaceResponse + +const diff = { + baseAnalysisId: "00000000-0000-4000-8000-000000000001", + currentAnalysisId: "00000000-0000-4000-8000-000000000002", + comparisonContext: { + requirementChanged: true, + snapshotChanged: true, + baseRequirementRevisionId: "00000000-0000-4000-8000-000000000003", + currentRequirementRevisionId: "00000000-0000-4000-8000-000000000004", + baseSnapshotId: "00000000-0000-4000-8000-000000000005", + currentSnapshotId: "00000000-0000-4000-8000-000000000006", + baseCommitSha: "base-commit", + currentCommitSha: "current-commit", + sourceClarificationId: "00000000-0000-4000-8000-000000000007", + reviewClarificationRequestId: "00000000-0000-4000-8000-000000000008", + }, + summary: { + addedImpacts: 1, + removedImpacts: 1, + unchangedImpacts: 1, + resolvedUnknowns: 1, + removedUnknowns: 0, + newUnknowns: 1, + addedQaScenarios: 1, + }, + addedArtifacts: [ + { + artifactKey: "service:new", + name: "NewService.run", + artifactType: "SERVICE_METHOD", + universalKind: "DOMAIN_SERVICE", + filePath: "src/new.service.ts", + reviewStatus: "NEEDS_REVIEW", + }, + ], + removedArtifacts: [ + { + artifactKey: "service:old", + name: "OldService.run", + artifactType: "SERVICE_METHOD", + universalKind: "DOMAIN_SERVICE", + filePath: "src/old.service.ts", + reviewStatus: "CONFIRMED", + }, + ], + unchangedArtifacts: [ + { + artifactKey: "service:same", + name: "SameService.run", + artifactType: "SERVICE_METHOD", + universalKind: "DOMAIN_SERVICE", + filePath: "src/same.service.ts", + reviewStatus: "CONFIRMED", + }, + ], + resolvedUnknowns: [ + { + insightKey: "unknown:old", + category: "UNKNOWN", + statement: "Refund deadline was unclear.", + reviewStatus: "CONFIRMED", + }, + ], + removedUnknowns: [], + newUnknowns: [ + { + insightKey: "unknown:new", + category: "UNKNOWN", + statement: "Inventory release timing is unclear.", + reviewStatus: "NEEDS_REVIEW", + }, + ], + addedQaScenarios: [ + { + insightKey: "qa:new", + category: "QA_SCENARIO", + statement: "Given paid booking, when cancelled, then refund is created once.", + reviewStatus: "NEEDS_REVIEW", + }, + ], +} satisfies ImpactAnalysisDiffResponse + +const lineage: LineageTimelineResponse = { + rootAnalysisId: diff.baseAnalysisId, + currentAnalysisId: diff.currentAnalysisId, + depth: 1, + events: [], +} + +function renderTab() { + return render( + , + ) +} + +describe("LineageDiffTab", () => { + beforeEach(() => { + mockUseAnalysisDiff.mockReset() + mockUseAnalysisLineage.mockReset() + }) + + it("renders not applicable state when analysis has no parent", () => { + mockUseAnalysisDiff.mockReturnValue({ + data: undefined, + error: { code: "NO_BASELINE_ANALYSIS" }, + isLoading: false, + refetch: jest.fn(), + }) + mockUseAnalysisLineage.mockReturnValue({ data: undefined, isLoading: false }) + + renderTab() + + expect(screen.getByText("No lineage diff for this analysis")).toBeInTheDocument() + expect(screen.getByText("Not applicable")).toBeInTheDocument() + }) + + it("renders lineage summary and diff groups from backend diff", () => { + mockUseAnalysisDiff.mockReturnValue({ + data: diff, + error: null, + isLoading: false, + refetch: jest.fn(), + }) + mockUseAnalysisLineage.mockReturnValue({ data: lineage, isLoading: false }) + + renderTab() + + expect(screen.getByText("Available")).toBeInTheDocument() + expect(screen.getByText("NewService.run")).toBeInTheDocument() + expect(screen.getByText("OldService.run")).toBeInTheDocument() + expect(screen.getByText("Refund deadline was unclear.")).toBeInTheDocument() + expect(screen.getByText("Inventory release timing is unclear.")).toBeInTheDocument() + expect(screen.getByText("Given paid booking, when cancelled, then refund is created once.")).toBeInTheDocument() + }) + + it("does not invent evidence diff when backend does not expose it", () => { + mockUseAnalysisDiff.mockReturnValue({ + data: diff, + error: null, + isLoading: false, + refetch: jest.fn(), + }) + mockUseAnalysisLineage.mockReturnValue({ data: lineage, isLoading: false }) + + renderTab() + + expect(screen.getByText("Evidence-level diff is not available for this analysis pair.")).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/workspace/analysis/lineage-diff-tab.tsx b/apps/web/src/components/workspace/analysis/lineage-diff-tab.tsx new file mode 100644 index 00000000..6117da33 --- /dev/null +++ b/apps/web/src/components/workspace/analysis/lineage-diff-tab.tsx @@ -0,0 +1,336 @@ +"use client" + +import type { + DiffArtifact, + DiffInsight, + ImpactAnalysisDiffResponse, + AnalysisWorkspaceResponse, +} from "@ba-helper/contracts" +import type { ReactNode } from "react" +import { AlertCircle, GitCompareArrows, Loader2, RefreshCw } from "lucide-react" +import { Button } from "@/components/ui/button" +import type { AnalysisWorkspaceLabels } from "@/lib/i18n/analysis-labels" +import { getLocalizedLabel, reviewDecisionLabels, type SupportedLocale } from "@/lib/i18n/status-labels" +import { useAnalysisDiff, useAnalysisLineage } from "@/hooks/api/use-analyses" +import { + buildLineageDiffSummary, + hasMaterialDiff, + isNoBaselineDiffError, +} from "./lineage-diff-view-model" + +type LineageDiffLabels = AnalysisWorkspaceLabels["lineageDiff"] + +export function LineageDiffTab({ + workspace, + locale, + labels, +}: { + workspace: AnalysisWorkspaceResponse + locale: SupportedLocale + labels: LineageDiffLabels +}) { + const analysisId = workspace.overview.analysisId + const diffQuery = useAnalysisDiff(analysisId) + const lineageQuery = useAnalysisLineage(analysisId) + const diff = diffQuery.data + const lineage = lineageQuery.data + const isNoParent = isNoBaselineDiffError(diffQuery.error) + const summary = buildLineageDiffSummary({ + currentAnalysisId: analysisId, + diff, + lineage, + diffError: diffQuery.error, + }) + + if ((diffQuery.isLoading && !isNoParent) || lineageQuery.isLoading) { + return ( +
+ + {labels.loading} +
+ ) + } + + if (diffQuery.error && !isNoParent) { + return ( +
+ +

{labels.unavailableTitle}

+

+ {diffQuery.error instanceof Error ? diffQuery.error.message : labels.diffNotAvailable} +

+ +
+ ) + } + + return ( +
+
+
+ +
+

{labels.title}

+

{labels.description}

+
+
+
+ + + + + + + +
+
+ + {isNoParent ? ( + + ) : null} + + {diff ? ( + <> + {(diff.diagnostics?.length ?? 0) > 0 ? ( +
+

{labels.diagnostics}

+
+ {diff.diagnostics?.map((diagnostic) => ( +

+ {diagnostic.message} +

+ ))} +
+
+ ) : null} + + {!hasMaterialDiff(diff) ? ( + + ) : null} + + + + + + + ) : null} +
+ ) +} + +function formatDiffStatus(status: string, labels: LineageDiffLabels) { + if (status === "available") return labels.diffAvailable + if (status === "not_applicable") return labels.diffNotApplicable + return labels.diffNotAvailable +} + +function ArtifactDiffSection({ + diff, + locale, + labels, +}: { + diff: ImpactAnalysisDiffResponse + locale: SupportedLocale + labels: LineageDiffLabels +}) { + return ( + + + + + + + ) +} + +function UnknownDiffSection({ + diff, + locale, + labels, +}: { + diff: ImpactAnalysisDiffResponse + locale: SupportedLocale + labels: LineageDiffLabels +}) { + return ( + + + + + + + ) +} + +function QaDiffSection({ + diff, + locale, + labels, +}: { + diff: ImpactAnalysisDiffResponse + locale: SupportedLocale + labels: LineageDiffLabels +}) { + return ( + + + + + + + ) +} + +function EvidenceDiffSection({ labels }: { labels: LineageDiffLabels }) { + return ( + +
+ {labels.evidenceUnavailable} +
+
+ ) +} + +function DiffSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ) +} + +function ArtifactGroup({ + title, + items, + tone, + locale, + labels, +}: { + title: string + items: DiffArtifact[] + tone: "success" | "danger" | "neutral" + locale: SupportedLocale + labels: LineageDiffLabels +}) { + return ( +
+ +
+ {items.length === 0 ?

{labels.noItems}

: null} + {items.map((item) => ( +
+
+
+

{item.name}

+

{item.filePath}

+
+ + {item.universalKind} + +
+
+ {labels.artifactType}: {item.artifactType} + {labels.review}: {getLocalizedLabel(reviewDecisionLabels, item.reviewStatus, locale)} + {item.artifactKey} +
+
+ ))} +
+
+ ) +} + +function InsightGroup({ + title, + items, + tone, + locale, + labels, +}: { + title: string + items: DiffInsight[] + tone: "success" | "warning" | "neutral" | "info" + locale: SupportedLocale + labels: LineageDiffLabels +}) { + return ( +
+ +
+ {items.length === 0 ?

{labels.noItems}

: null} + {items.map((item) => ( +
+
+ {item.category} + {labels.review}: {getLocalizedLabel(reviewDecisionLabels, item.reviewStatus, locale)} + {item.insightKey} +
+

{item.statement}

+
+ ))} +
+
+ ) +} + +function UnavailableGroup({ title, message }: { title: string; message: string }) { + return ( +
+ +

{message}

+
+ ) +} + +function GroupHeader({ title, count }: { title: string; count: number }) { + return ( +
+

{title}

+ {count} +
+ ) +} + +function EmptyPanel({ title, description }: { title: string; description?: string }) { + return ( +
+

{title}

+ {description ?

{description}

: null} +
+ ) +} + +function InfoRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} + +function toneClass(tone: "success" | "danger" | "warning" | "neutral" | "info") { + if (tone === "success") return "border-success/20 bg-success/5" + if (tone === "danger") return "border-danger/20 bg-danger/5" + if (tone === "warning") return "border-warning/30 bg-warning/5" + if (tone === "info") return "border-info/20 bg-info/5" + return "border-border/50 bg-surface/40" +} diff --git a/apps/web/src/components/workspace/analysis/lineage-diff-view-model.test.ts b/apps/web/src/components/workspace/analysis/lineage-diff-view-model.test.ts new file mode 100644 index 00000000..4d357339 --- /dev/null +++ b/apps/web/src/components/workspace/analysis/lineage-diff-view-model.test.ts @@ -0,0 +1,153 @@ +import type { + ImpactAnalysisDiffResponse, + LineageTimelineResponse, +} from "@ba-helper/contracts" +import { + buildLineageDiffSummary, + evidenceDiffUnavailableMessage, + getLineageDiffStatus, + hasMaterialDiff, + isNoBaselineDiffError, +} from "./lineage-diff-view-model" + +const makeDiff = (): ImpactAnalysisDiffResponse => ({ + baseAnalysisId: "00000000-0000-4000-8000-000000000001", + currentAnalysisId: "00000000-0000-4000-8000-000000000002", + comparisonContext: { + requirementChanged: true, + snapshotChanged: true, + baseRequirementRevisionId: "00000000-0000-4000-8000-000000000003", + currentRequirementRevisionId: "00000000-0000-4000-8000-000000000004", + baseSnapshotId: "00000000-0000-4000-8000-000000000005", + currentSnapshotId: "00000000-0000-4000-8000-000000000006", + baseCommitSha: "base-commit", + currentCommitSha: "current-commit", + sourceClarificationId: "00000000-0000-4000-8000-000000000007", + reviewClarificationRequestId: "00000000-0000-4000-8000-000000000008", + }, + summary: { + addedImpacts: 1, + removedImpacts: 1, + unchangedImpacts: 1, + resolvedUnknowns: 1, + removedUnknowns: 0, + newUnknowns: 1, + addedQaScenarios: 1, + }, + addedArtifacts: [ + { + artifactKey: "service:new", + name: "NewService.run", + artifactType: "SERVICE_METHOD", + universalKind: "DOMAIN_SERVICE", + filePath: "src/new.service.ts", + reviewStatus: "NEEDS_REVIEW", + }, + ], + removedArtifacts: [ + { + artifactKey: "service:old", + name: "OldService.run", + artifactType: "SERVICE_METHOD", + universalKind: "DOMAIN_SERVICE", + filePath: "src/old.service.ts", + reviewStatus: "CONFIRMED", + }, + ], + unchangedArtifacts: [ + { + artifactKey: "service:same", + name: "SameService.run", + artifactType: "SERVICE_METHOD", + universalKind: "DOMAIN_SERVICE", + filePath: "src/same.service.ts", + reviewStatus: "CONFIRMED", + }, + ], + resolvedUnknowns: [ + { + insightKey: "unknown:old", + category: "UNKNOWN", + statement: "Refund deadline was unclear.", + reviewStatus: "CONFIRMED", + }, + ], + removedUnknowns: [], + newUnknowns: [ + { + insightKey: "unknown:new", + category: "UNKNOWN", + statement: "Inventory release timing is unclear.", + reviewStatus: "NEEDS_REVIEW", + }, + ], + addedQaScenarios: [ + { + insightKey: "qa:new", + category: "QA_SCENARIO", + statement: "Given paid booking, when cancelled, then refund is created once.", + reviewStatus: "NEEDS_REVIEW", + }, + ], +}) + +describe("lineage diff view model", () => { + it("maps analysis with no parent to not applicable state", () => { + const error = { code: "NO_BASELINE_ANALYSIS", message: "No baseline" } + + expect(isNoBaselineDiffError(error)).toBe(true) + expect(getLineageDiffStatus({ diffError: error })).toBe("not_applicable") + expect(buildLineageDiffSummary({ + currentAnalysisId: "00000000-0000-4000-8000-000000000002", + diffError: error, + })).toMatchObject({ + parentAnalysisId: null, + diffStatus: "not_applicable", + previousSnapshot: null, + }) + }) + + it("builds lineage summary from diff and lineage data", () => { + const diff = makeDiff() + const lineage: LineageTimelineResponse = { + rootAnalysisId: diff.baseAnalysisId, + currentAnalysisId: diff.currentAnalysisId, + depth: 1, + events: [], + } + + expect(buildLineageDiffSummary({ + currentAnalysisId: diff.currentAnalysisId, + diff, + lineage, + })).toEqual({ + currentAnalysisId: diff.currentAnalysisId, + parentAnalysisId: diff.baseAnalysisId, + diffStatus: "available", + sourceClarificationId: diff.comparisonContext.sourceClarificationId, + sourceReviewClarificationRequestId: diff.comparisonContext.reviewClarificationRequestId, + previousSnapshot: { + snapshotId: diff.comparisonContext.baseSnapshotId, + commitSha: "base-commit", + }, + currentSnapshot: { + snapshotId: diff.comparisonContext.currentSnapshotId, + commitSha: "current-commit", + }, + }) + }) + + it("reports material artifact unknown and QA diff without evidence inference", () => { + const diff = makeDiff() + + expect(hasMaterialDiff(diff)).toBe(true) + expect(diff.addedArtifacts).toHaveLength(1) + expect(diff.removedArtifacts).toHaveLength(1) + expect(diff.resolvedUnknowns).toHaveLength(1) + expect(diff.newUnknowns).toHaveLength(1) + expect(diff.addedQaScenarios).toHaveLength(1) + expect(evidenceDiffUnavailableMessage()).toBe( + "Evidence-level diff is not available for this analysis pair.", + ) + }) +}) diff --git a/apps/web/src/components/workspace/analysis/lineage-diff-view-model.ts b/apps/web/src/components/workspace/analysis/lineage-diff-view-model.ts new file mode 100644 index 00000000..6b50a321 --- /dev/null +++ b/apps/web/src/components/workspace/analysis/lineage-diff-view-model.ts @@ -0,0 +1,95 @@ +import type { + ImpactAnalysisDiffResponse, + LineageTimelineResponse, +} from "@ba-helper/contracts" + +export type LineageDiffStatus = "available" | "not_available" | "not_applicable" + +type ApiLikeError = { + code?: string + status?: number + message?: string +} + +export function isNoBaselineDiffError(error: unknown) { + const apiError = error as ApiLikeError | null + return apiError?.code === "NO_BASELINE_ANALYSIS" +} + +export function getLineageParentAnalysisId( + diff: ImpactAnalysisDiffResponse | undefined, + lineage: LineageTimelineResponse | undefined, +) { + if (diff?.baseAnalysisId) return diff.baseAnalysisId + if (!lineage) return null + + const parentEvent = [...lineage.events] + .reverse() + .find((event) => + event.analysisId === lineage.currentAnalysisId && + event.relatedAnalysisId && + event.type === "DERIVED_ANALYSIS_CREATED" + ) + + return parentEvent?.relatedAnalysisId ?? null +} + +export function getLineageDiffStatus(params: { + diff?: ImpactAnalysisDiffResponse + diffError?: unknown +}): LineageDiffStatus { + if (params.diff) return "available" + if (isNoBaselineDiffError(params.diffError)) return "not_applicable" + return "not_available" +} + +export function buildLineageDiffSummary(params: { + currentAnalysisId: string + diff?: ImpactAnalysisDiffResponse + lineage?: LineageTimelineResponse + diffError?: unknown +}) { + const parentAnalysisId = getLineageParentAnalysisId(params.diff, params.lineage) + const status = getLineageDiffStatus({ + diff: params.diff, + diffError: params.diffError, + }) + + return { + currentAnalysisId: params.currentAnalysisId, + parentAnalysisId, + diffStatus: status, + sourceClarificationId: params.diff?.comparisonContext.sourceClarificationId ?? null, + sourceReviewClarificationRequestId: + params.diff?.comparisonContext.reviewClarificationRequestId ?? null, + previousSnapshot: params.diff + ? { + snapshotId: params.diff.comparisonContext.baseSnapshotId, + commitSha: params.diff.comparisonContext.baseCommitSha ?? null, + } + : null, + currentSnapshot: params.diff + ? { + snapshotId: params.diff.comparisonContext.currentSnapshotId, + commitSha: params.diff.comparisonContext.currentCommitSha ?? null, + } + : null, + } +} + +export function hasMaterialDiff(diff: ImpactAnalysisDiffResponse | undefined) { + if (!diff) return false + return ( + diff.addedArtifacts.length + + diff.removedArtifacts.length + + diff.resolvedUnknowns.length + + diff.removedUnknowns.length + + diff.newUnknowns.length + + diff.addedQaScenarios.length > + 0 + ) +} + +export function evidenceDiffUnavailableMessage() { + return "Evidence-level diff is not available for this analysis pair." +} diff --git a/apps/web/src/hooks/api/use-analyses.ts b/apps/web/src/hooks/api/use-analyses.ts index 472cb3de..715fddfa 100644 --- a/apps/web/src/hooks/api/use-analyses.ts +++ b/apps/web/src/hooks/api/use-analyses.ts @@ -5,7 +5,12 @@ import { canPollAnalysisDetail } from "@/lib/status-helpers" import { useOptionalProjectId } from "@/lib/project-context" import { ReviewQueueResponse, reviewQueueResponseSchema } from '@ba-helper/contracts' -import { ImpactAnalysisDiffResponse, impactAnalysisDiffResponseSchema } from "@ba-helper/contracts" +import { + ImpactAnalysisDiffResponse, + LineageTimelineResponse, + impactAnalysisDiffResponseSchema, + lineageTimelineResponseSchema, +} from "@ba-helper/contracts" import { ImpactAnalysisListResponse, @@ -103,8 +108,7 @@ export function useAnalysisLineage(analysisId: string) { return useQuery({ queryKey: queryKeys.analyses.lineage(analysisId), queryFn: async () => { - const { lineageTimelineResponseSchema } = await import("@ba-helper/contracts") - return apiGet( + return apiGet( `/api/v1/impact-analyses/${analysisId}/lineage`, lineageTimelineResponseSchema ) @@ -153,7 +157,7 @@ export function useReviewQueue(analysisId: string | undefined, options?: { enabl } export function useAnalysisDiff(analysisId: string, enabled: boolean = true) { - return useQuery({ + return useQuery({ queryKey: queryKeys.analyses.diff(analysisId), queryFn: async () => { return apiGet( diff --git a/apps/web/src/lib/i18n/analysis-labels.ts b/apps/web/src/lib/i18n/analysis-labels.ts index 69fb42a2..b4bfbe86 100644 --- a/apps/web/src/lib/i18n/analysis-labels.ts +++ b/apps/web/src/lib/i18n/analysis-labels.ts @@ -12,6 +12,7 @@ export const analysisWorkspaceLabels = { evidence: "Evidence", risksQa: "Risks & QA", reviewReport: "Review & Report", + lineageDiff: "Lineage Diff", }, status: { analysis: "Analysis", @@ -110,6 +111,49 @@ export const analysisWorkspaceLabels = { confirmFinalize: "Confirm Finalize", }, }, + lineageDiff: { + loading: "Loading lineage diff...", + title: "Lineage Summary", + description: "Compare the current analysis against its parent analysis when a rerun or clarification created lineage.", + diffAvailable: "Available", + diffNotAvailable: "Not available", + diffNotApplicable: "Not applicable", + currentAnalysisId: "Current analysis ID", + parentAnalysisId: "Parent analysis ID", + sourceClarificationId: "Source clarification ID", + sourceReviewClarificationRequestId: "Source review clarification request ID", + oldSnapshotCommit: "Old snapshot commit", + newSnapshotCommit: "New snapshot commit", + diffStatus: "Diff status", + none: "None", + noParentTitle: "No lineage diff for this analysis", + noParentDescription: "This analysis has no parent or baseline analysis, so there is nothing to compare.", + unavailableTitle: "Lineage diff is not available", + retry: "Try again", + diagnostics: "Diagnostics", + impactedArtifacts: "Impacted Artifact Diff", + unknowns: "Unknowns Diff", + qaScenarios: "QA Scenario Diff", + evidence: "Evidence Diff", + added: "Added", + removed: "Removed", + changed: "Changed", + unchanged: "Unchanged", + resolved: "Resolved", + newUnknowns: "New unknowns", + stillUnresolved: "Still unresolved", + addedScenarios: "Added scenarios", + removedScenarios: "Removed scenarios", + changedScenarios: "Changed scenarios", + unchangedScenarios: "Unchanged scenarios", + unavailableGroup: "Backend diff data is not available for this group yet.", + noMaterialChanges: "No material diff items were found between these analyses.", + noItems: "No items.", + review: "Review", + artifactType: "Type", + evidenceUnavailable: "Evidence-level diff is not available for this analysis pair.", + needsReviewHint: "New or changed items may need human review.", + }, }, vi: { title: "Không gian phân tích", @@ -120,6 +164,7 @@ export const analysisWorkspaceLabels = { evidence: "Bằng chứng", risksQa: "Rủi ro & QA", reviewReport: "Xem xét & báo cáo", + lineageDiff: "Lineage diff", }, status: { analysis: "Phân tích", @@ -218,6 +263,49 @@ export const analysisWorkspaceLabels = { confirmFinalize: "Xác nhận finalize", }, }, + lineageDiff: { + loading: "Đang tải lineage diff...", + title: "Tóm tắt lineage", + description: "So sánh phân tích hiện tại với phân tích cha khi rerun hoặc clarification tạo lineage.", + diffAvailable: "Có", + diffNotAvailable: "Chưa có", + diffNotApplicable: "Không áp dụng", + currentAnalysisId: "ID phân tích hiện tại", + parentAnalysisId: "ID phân tích cha", + sourceClarificationId: "ID clarification nguồn", + sourceReviewClarificationRequestId: "ID review clarification nguồn", + oldSnapshotCommit: "Commit snapshot cũ", + newSnapshotCommit: "Commit snapshot mới", + diffStatus: "Trạng thái diff", + none: "Không có", + noParentTitle: "Phân tích này không có lineage diff", + noParentDescription: "Phân tích này không có phân tích cha hoặc baseline, nên không có gì để so sánh.", + unavailableTitle: "Lineage diff chưa khả dụng", + retry: "Thử lại", + diagnostics: "Chẩn đoán", + impactedArtifacts: "Diff artifact bị ảnh hưởng", + unknowns: "Diff điểm chưa rõ", + qaScenarios: "Diff kịch bản QA", + evidence: "Diff bằng chứng", + added: "Đã thêm", + removed: "Đã gỡ", + changed: "Đã đổi", + unchanged: "Không đổi", + resolved: "Đã giải quyết", + newUnknowns: "Điểm chưa rõ mới", + stillUnresolved: "Vẫn chưa rõ", + addedScenarios: "Kịch bản đã thêm", + removedScenarios: "Kịch bản đã gỡ", + changedScenarios: "Kịch bản đã đổi", + unchangedScenarios: "Kịch bản không đổi", + unavailableGroup: "Backend chưa có dữ liệu diff đáng tin cậy cho nhóm này.", + noMaterialChanges: "Không có mục diff đáng kể giữa hai phân tích.", + noItems: "Không có mục nào.", + review: "Xem xét", + artifactType: "Loại", + evidenceUnavailable: "Evidence-level diff is not available for this analysis pair.", + needsReviewHint: "Các mục mới hoặc đã đổi có thể cần người dùng xem xét.", + }, }, } as const From b51b58b726de01c29911bb6eee4774467600338d Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Wed, 24 Jun 2026 21:53:14 +0700 Subject: [PATCH 13/22] feat(domain-packs): add domain profile registry --- .../application/domain-pack.registry.spec.ts | 36 +++++++++ .../application/domain-pack.registry.ts | 47 ++++++----- .../domain-pack/packs/booking.v0.1.0.ts | 15 ++++ .../domain-pack/packs/general.v0.0.0.ts | 2 + .../run-impact-analysis.usecase.spec.ts | 1 + .../lifecycle/run-impact-analysis.usecase.ts | 2 + .../risks/diagnostic-risk-propagation.spec.ts | 3 +- .../domain/impact-analysis.types.ts | 1 + docs/agent/CONTEXT_INDEX.md | 8 ++ docs/agent/analysis-invariants.md | 4 + docs/agent/domain-packs.md | 77 +++++++++++++++++++ .../agent/localization-and-domain-glossary.md | 4 + .../contracts/domain-pack.contract.spec.ts | 60 +++++++++++++++ packages/contracts/src/diagnostic.contract.ts | 1 + .../contracts/src/domain-pack.contract.ts | 31 ++++++++ tests/domain-pack/concept-matching.spec.ts | 6 ++ 16 files changed, 277 insertions(+), 21 deletions(-) create mode 100644 docs/agent/domain-packs.md create mode 100644 packages/contracts/domain-pack.contract.spec.ts 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..30077f8c 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'); }); @@ -50,6 +51,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 +85,38 @@ 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: [], + }), + ]); + }); + + 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..ca9db286 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,5 +1,5 @@ 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 { AppError } from '../../../shared/app-error'; @@ -31,6 +31,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 +49,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 +83,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 +105,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 +126,6 @@ export class DomainPackRegistry { } } - // 3. Safe Default return { pack: GeneralDomainPack, normalizedPackId: 'general', @@ -148,9 +158,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/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/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/docs/agent/CONTEXT_INDEX.md b/docs/agent/CONTEXT_INDEX.md index 2ba61129..12ea7303 100644 --- a/docs/agent/CONTEXT_INDEX.md +++ b/docs/agent/CONTEXT_INDEX.md @@ -63,6 +63,14 @@ Read: - `architecture.md` - `input-quality.md` +### Domain packs / domain profiles + +Read: +- `domain-packs.md` +- `analysis-invariants.md` +- `localization-and-domain-glossary.md` +- `testing-strategy.md` + ### Security / hardening Read: diff --git a/docs/agent/analysis-invariants.md b/docs/agent/analysis-invariants.md index 1a326eac..0487bb98 100644 --- a/docs/agent/analysis-invariants.md +++ b/docs/agent/analysis-invariants.md @@ -73,6 +73,10 @@ generated reports. - Domain packs must not create `EVIDENCED` impacts by themselves. - Every public domain pack must expose status: `STABLE`, `PARTIAL`, `EXPERIMENTAL`, or `FALLBACK`. +- `booking@0.1.0` is the current `STABLE` profile. +- `general@0.0.0` is the safe `FALLBACK` profile and must not contain + booking-specific concepts, retrieval hints, risks, QA templates, or unknown + templates. - Booking remains the stable MVP domain; broad multi-domain claims are out of scope until each pack has status, limits, and evaluation cases. diff --git a/docs/agent/domain-packs.md b/docs/agent/domain-packs.md new file mode 100644 index 00000000..693d3a07 --- /dev/null +++ b/docs/agent/domain-packs.md @@ -0,0 +1,77 @@ +# Domain Packs + +## Purpose + +Domain packs are bounded domain profiles used as hints for retrieval, wording, +risk templates, QA scenario templates, and evaluation grouping. + +They are not evidence. A pack may suggest that refund policy is important, but +only persisted `Evidence` linked to the analyzed snapshot or requirement +revision can support an `EVIDENCED` insight. + +## Registry + +The built-in domain profile registry lives in: + +```text +apps/api/src/modules/domain-pack/application/domain-pack.registry.ts +``` + +Each public profile exposes: + +```text +id +name +version +status +description +glossaryMetadata +``` + +Registry summaries must stay bounded and must not expose executable hint bodies +such as retrieval hints, risk templates, QA templates, unknown templates, prompt +payloads, source code, or evidence excerpts. + +## Capability Status + +Status values: + +```text +STABLE Supported MVP domain with explicit evaluation coverage. +PARTIAL Bounded domain support exists, but documented gaps remain. +EXPERIMENTAL Internal or exploratory profile; not a product support claim. +FALLBACK Safe empty/default profile used when no specific profile applies. +``` + +Current profiles: + +| Profile | Status | Notes | +| --- | --- | --- | +| `booking@0.1.0` | `STABLE` | MVP Booking / Payment / Refund domain. | +| `general@0.0.0` | `FALLBACK` | Empty safe default; no booking-specific hints. | + +Do not claim broad multi-domain support until each new profile has status, +limits, evaluation cases, and fallback behavior documented. + +## Glossary Metadata + +Booking has static English and Vietnamese glossary assets under: + +```text +packages/domain-packs/booking/en.glossary.json +packages/domain-packs/booking/vi.glossary.json +``` + +The registry exposes only metadata for these assets: locale, glossary status, +version, and term count. Glossary assets remain terminology references. P7A does +not introduce Vietnamese runtime output, scanner changes, or new AI behavior. + +## Adding A Profile + +Before adding a new `PARTIAL` or `STABLE` profile: + +1. Define explicit capability status and limits. +2. Keep `general@0.0.0` as the fallback for unknown or unsupported domains. +3. Add deterministic registry and concept-matching tests. +4. Add evaluation cases before claiming quality improvements. +5. Prove hints cannot create `EVIDENCED` impact without persisted evidence. diff --git a/docs/agent/localization-and-domain-glossary.md b/docs/agent/localization-and-domain-glossary.md index 2e622660..bcc97151 100644 --- a/docs/agent/localization-and-domain-glossary.md +++ b/docs/agent/localization-and-domain-glossary.md @@ -32,6 +32,10 @@ Glossary JSON files under `packages/domain-packs` are terminology references. They are allowed to describe domain terms such as booking, cancellation, refund, inventory release, and payment state in multiple locales. +The domain pack registry may expose bounded glossary metadata such as locale, +asset status, version, and term count. It must not expose glossary term bodies +as evidence or as a runtime language mode. + They are not executable analyzer rules: - do not inject these glossary files into prompts in this phase diff --git a/packages/contracts/domain-pack.contract.spec.ts b/packages/contracts/domain-pack.contract.spec.ts new file mode 100644 index 00000000..e039669d --- /dev/null +++ b/packages/contracts/domain-pack.contract.spec.ts @@ -0,0 +1,60 @@ +import { + domainPackAppliedDiagnosticPayloadSchema, + domainPackSchema, + domainProfileRegistryEntrySchema, + type DomainPack, + type DomainPackAppliedDiagnosticPayload, + type DomainProfileRegistryEntry, +} from './src'; + +describe('domain pack contracts', () => { + it('accepts a stable booking pack with English and Vietnamese glossary metadata', () => { + const payload: DomainPack = { + id: 'booking', + name: 'Booking', + version: '0.1.0', + status: 'STABLE', + description: 'Booking, payment, and refund lifecycle hints.', + glossaryMetadata: [ + { locale: 'en', status: 'foundation', version: '1.0.0', termCount: 6 }, + { locale: 'vi', status: 'foundation', version: '1.0.0', termCount: 6 }, + ], + concepts: [], + retrievalHints: [], + riskTemplates: [], + qaTemplates: [], + unknownTemplates: [], + }; + + expect(domainPackSchema.parse(payload)).toEqual(payload); + }); + + it('accepts a fallback registry entry without executable hint bodies', () => { + const payload: DomainProfileRegistryEntry = { + id: 'general', + name: 'General', + version: '0.0.0', + status: 'FALLBACK', + description: 'Safe fallback used when no specific profile is selected.', + glossaryMetadata: [], + }; + + expect(domainProfileRegistryEntrySchema.parse(payload)).toEqual(payload); + }); + + it('requires capability status in domain pack diagnostics', () => { + const payload: DomainPackAppliedDiagnosticPayload = { + domainPackId: 'booking', + domainPackVersion: '0.1.0', + domainPackStatus: 'STABLE', + selectedBy: 'repository_profile', + conceptCount: 5, + retrievalHintCount: 6, + riskTemplateCount: 10, + qaTemplateCount: 10, + unknownTemplateCount: 10, + }; + + expect(domainPackAppliedDiagnosticPayloadSchema.parse(payload)).toEqual(payload); + }); +}); diff --git a/packages/contracts/src/diagnostic.contract.ts b/packages/contracts/src/diagnostic.contract.ts index 1483a96d..dd0151b8 100644 --- a/packages/contracts/src/diagnostic.contract.ts +++ b/packages/contracts/src/diagnostic.contract.ts @@ -130,6 +130,7 @@ export type EmbeddingReuseExecutionSummaryPayload = z.infer< export const domainPackAppliedDiagnosticPayloadSchema = z.object({ domainPackId: z.string(), domainPackVersion: z.string(), + domainPackStatus: z.enum(['STABLE', 'PARTIAL', 'EXPERIMENTAL', 'FALLBACK']), selectedBy: z.enum(['repository_profile', 'manual_config', 'safe_default']), conceptCount: z.number().int().nonnegative(), retrievalHintCount: z.number().int().nonnegative(), diff --git a/packages/contracts/src/domain-pack.contract.ts b/packages/contracts/src/domain-pack.contract.ts index b6e16795..c2667adb 100644 --- a/packages/contracts/src/domain-pack.contract.ts +++ b/packages/contracts/src/domain-pack.contract.ts @@ -16,11 +16,29 @@ export const domainQaTemplateSchema = z.string(); export const domainUnknownTemplateSchema = z.string(); +export const domainProfileCapabilityStatusSchema = z.enum([ + 'STABLE', + 'PARTIAL', + 'EXPERIMENTAL', + 'FALLBACK', +]); + +export const domainGlossaryLocaleSchema = z.enum(['en', 'vi']); + +export const domainGlossaryMetadataSchema = z.object({ + locale: domainGlossaryLocaleSchema, + status: z.string(), + version: z.string(), + termCount: z.number().int().nonnegative(), +}); + export const domainPackSchema = z.object({ id: z.string(), name: z.string(), version: z.string(), + status: domainProfileCapabilityStatusSchema, description: z.string().optional(), + glossaryMetadata: z.array(domainGlossaryMetadataSchema), concepts: z.array(domainConceptSchema), retrievalHints: z.array(domainRetrievalHintSchema), riskTemplates: z.array(domainRiskTemplateSchema), @@ -28,9 +46,22 @@ export const domainPackSchema = z.object({ unknownTemplates: z.array(domainUnknownTemplateSchema), }); +export const domainProfileRegistryEntrySchema = domainPackSchema.pick({ + id: true, + name: true, + version: true, + status: true, + description: true, + glossaryMetadata: true, +}); + export type DomainConcept = z.infer; export type DomainRetrievalHint = z.infer; export type DomainRiskTemplate = z.infer; export type DomainQaTemplate = z.infer; export type DomainUnknownTemplate = z.infer; +export type DomainProfileCapabilityStatus = z.infer; +export type DomainGlossaryLocale = z.infer; +export type DomainGlossaryMetadata = z.infer; export type DomainPack = z.infer; +export type DomainProfileRegistryEntry = z.infer; diff --git a/tests/domain-pack/concept-matching.spec.ts b/tests/domain-pack/concept-matching.spec.ts index 2641dbfe..117fe740 100644 --- a/tests/domain-pack/concept-matching.spec.ts +++ b/tests/domain-pack/concept-matching.spec.ts @@ -11,6 +11,11 @@ describe('Domain Pack Concept Matching', () => { describe('booking@0.1.0', () => { const pack = BookingDomainPack; + it('is the only stable domain profile in the current registry', () => { + expect(pack.status).toBe('STABLE'); + expect(pack.glossaryMetadata.map((item) => item.locale)).toEqual(['en', 'vi']); + }); + it('"money back" maps to refund', () => { const keys = registry.matchConcepts('user wants their money back', pack); expect(keys).toContain('refund'); @@ -52,6 +57,7 @@ describe('Domain Pack Concept Matching', () => { describe('general@0.0.0 (fallback)', () => { it('general@0.0.0 has no booking-specific concepts/hints', () => { const pack = registry.getPackById('general'); + expect(pack.status).toBe('FALLBACK'); expect(pack.concepts.length).toBe(0); expect(pack.retrievalHints.length).toBe(0); From 3a870b10879cf3340e11562bfd826ad789d63ec0 Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Thu, 25 Jun 2026 09:26:41 +0700 Subject: [PATCH 14/22] feat(document): support locale-aware report rendering --- .../document/api/document.controller.ts | 9 +- .../jobs/run-document-job.usecase.ts | 2 +- .../markdown-impact-report.types.ts | 4 +- .../get-final-reviewed-report.usecase.spec.ts | 53 ++++ .../get-final-reviewed-report.usecase.ts | 50 +++- .../markdown-impact-report.builder.spec.ts | 48 ++++ .../render/markdown-impact-report.builder.ts | 6 +- .../evaluation-context.renderer.ts | 27 +- .../evidence-appendix.renderer.ts | 12 +- .../executive-summary.renderer.ts | 16 +- .../impact-diff.renderer.ts | 39 ++- .../insight-section.renderer.ts | 55 ++-- .../markdown-render-utils.ts | 14 +- .../markdown-renderers/qa-section.renderer.ts | 8 +- .../report-header.renderer.ts | 59 +++-- .../review-history.renderer.ts | 6 +- .../traceability-section.renderer.ts | 35 +-- .../application/render/report-localization.ts | 241 ++++++++++++++++++ .../render/report-localization.types.ts | 105 ++++++++ ...eviewed-snapshot-report-context.adapter.ts | 8 +- .../app/(app)/analyses/[analysisId]/page.tsx | 9 +- apps/web/src/hooks/api/use-documents.ts | 10 +- apps/web/src/lib/api/query-keys.ts | 2 +- docs/agent/api-contracts.md | 1 + .../agent/localization-and-domain-glossary.md | 15 ++ packages/contracts/review.contract.spec.ts | 48 ++++ packages/contracts/src/review.contract.ts | 9 + 27 files changed, 768 insertions(+), 123 deletions(-) create mode 100644 apps/api/src/modules/document/application/render/report-localization.ts create mode 100644 apps/api/src/modules/document/application/render/report-localization.types.ts create mode 100644 packages/contracts/review.contract.spec.ts 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/markdown-impact-report.builder.spec.ts b/apps/api/src/modules/document/application/render/markdown-impact-report.builder.spec.ts index c931e126..a2f37ac0 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 @@ -179,6 +179,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, 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..5ed1b7cd 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,39 @@ 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(`**${labels.question}:** ${q.description || q.title}`); lines.push(''); const qNote = reviewNotes.find(n => n.insightId === q.id); if (qNote) { - lines.push(`**Reviewer Note:** ${qNote.body}`); + lines.push(`**${labels.reviewerNote}:** ${qNote.body}`); lines.push(''); } if (q.reasoning) { - lines.push(`**Why this matters:** ${q.reasoning}`); + lines.push(`**${labels.whyThisMatters}:** ${q.reasoning}`); lines.push(''); } if (q.metadata && typeof q.metadata === 'object' && (q.metadata as any).origin === 'SCANNER_DIAGNOSTIC') { - lines.push(`_Derived from scanner diagnostic_`); + 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 +99,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..ba8fc76a 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,17 +1,19 @@ 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(`| ${labels.scenario} | ${labels.precondition} | ${labels.action} | ${labels.expectedResult} |`); lines.push('|---|---|---|---|'); for (const qa of qaScenarios) { @@ -19,7 +21,7 @@ export function renderQaSection(context: MarkdownReportRenderContext): string[] lines.push(`| ${qa.title} | ${parts.precondition} | ${parts.action} | ${parts.expected} |`); 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(''); 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..64881d60 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,15 +15,15 @@ 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(`| ${labels.area} | ${labels.artifact} | ${labels.file} | ${labels.reviewStatus} |`); lines.push('|---|---|---|---|'); 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'; + const nameRaw = link.artifact?.name ? `\`${link.artifact.name}\`` : labels.unknown; let maturityLabel = ''; if (capabilitySummary?.payload) { const p = capabilitySummary.payload; @@ -34,19 +36,19 @@ export function renderImpactedAreas(context: MarkdownReportRenderContext): strin let methodLabel = ''; if (link.artifact?.name?.includes('UNKNOWN')) { - methodLabel = ' **[Method: 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; + 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(`| ${type} | ${name} | ${file} | ${status} |`); } 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 +64,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 +89,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 +109,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..0a560cf6 --- /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/web/src/app/(app)/analyses/[analysisId]/page.tsx b/apps/web/src/app/(app)/analyses/[analysisId]/page.tsx index cf53371e..74a9d0c8 100644 --- a/apps/web/src/app/(app)/analyses/[analysisId]/page.tsx +++ b/apps/web/src/app/(app)/analyses/[analysisId]/page.tsx @@ -6,13 +6,20 @@ import { AlertCircle } from "lucide-react" import { Skeleton } from "@/components/ui/skeleton" import { AnalysisWorkspaceShell } from "@/components/workspace/analysis/analysis-workspace-shell" import { useAnalysisWorkspace } from "@/hooks/api/use-analyses" +import { DEFAULT_ANALYSIS_WORKSPACE_LOCALE, type SupportedLocale } from "@/lib/i18n/status-labels" export default function ImpactAnalysisDetailPage({ params, + searchParams, }: { params: Promise<{ analysisId: string }> + searchParams: Promise<{ locale?: string | string[] }> }) { const { analysisId } = use(params) + const query = use(searchParams) + const candidateLocale = Array.isArray(query.locale) ? query.locale[0] : query.locale + const locale: SupportedLocale = + candidateLocale === "vi" ? "vi" : DEFAULT_ANALYSIS_WORKSPACE_LOCALE const { data: workspace, isLoading, @@ -47,5 +54,5 @@ export default function ImpactAnalysisDetailPage({ ) } - return + return } diff --git a/apps/web/src/hooks/api/use-documents.ts b/apps/web/src/hooks/api/use-documents.ts index be304198..6ac589b4 100644 --- a/apps/web/src/hooks/api/use-documents.ts +++ b/apps/web/src/hooks/api/use-documents.ts @@ -1,14 +1,16 @@ import { queryKeys } from "@/lib/api/query-keys" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { apiGet, apiPost } from "@/lib/api-client" -import { finalReviewedReportResponseSchema, ReviewedReportSnapshotResponse } from "@ba-helper/contracts" +import { finalReviewedReportResponseSchema, ReviewedReportSnapshotResponse, type ReportLocale } from "@ba-helper/contracts" + +export function useFinalReviewedReport(analysisId: string, options?: { enabled?: boolean; locale?: ReportLocale }) { + const locale = options?.locale ?? "en" -export function useFinalReviewedReport(analysisId: string, options?: { enabled?: boolean }) { return useQuery({ - queryKey: queryKeys.documents.finalReviewedReport(analysisId), + queryKey: queryKeys.documents.finalReviewedReport(analysisId, locale), queryFn: () => apiGet( - `/api/v1/impact-analyses/${analysisId}/final-reviewed-report`, + `/api/v1/impact-analyses/${analysisId}/final-reviewed-report?locale=${locale}`, finalReviewedReportResponseSchema, ), enabled: options?.enabled, diff --git a/apps/web/src/lib/api/query-keys.ts b/apps/web/src/lib/api/query-keys.ts index 578c347a..71b77259 100644 --- a/apps/web/src/lib/api/query-keys.ts +++ b/apps/web/src/lib/api/query-keys.ts @@ -50,7 +50,7 @@ export const queryKeys = { insights: (analysisId: string) => ["impact-analyses", "insights", analysisId] as const, }, documents: { - finalReviewedReport: (analysisId: string) => ["documents", analysisId, "final-reviewed-report"] as const, + finalReviewedReport: (analysisId: string, locale = "en") => ["documents", analysisId, "final-reviewed-report", locale] as const, documentJobs: (analysisId: string) => ["documents", analysisId, "document-jobs"] as const, reviewedReportSnapshot: (analysisId: string) => ["documents", analysisId, "reviewed-report-snapshot"] as const, }, diff --git a/docs/agent/api-contracts.md b/docs/agent/api-contracts.md index acc57fe4..3f90804b 100644 --- a/docs/agent/api-contracts.md +++ b/docs/agent/api-contracts.md @@ -69,6 +69,7 @@ GET /api/v1/impact-analyses/:analysisId/documents GET /api/v1/impact-analyses/:analysisId/approved-report GET /api/v1/impact-analyses/:analysisId/approved-report/export.md GET /api/v1/impact-analyses/:analysisId/approved-report/export.pdf +GET /api/v1/impact-analyses/:analysisId/final-reviewed-report?locale=en|vi ``` Deferred until after the Markdown report/review completion gate: diff --git a/docs/agent/localization-and-domain-glossary.md b/docs/agent/localization-and-domain-glossary.md index bcc97151..1db4d9d3 100644 --- a/docs/agent/localization-and-domain-glossary.md +++ b/docs/agent/localization-and-domain-glossary.md @@ -15,6 +15,16 @@ PR5E establishes the foundation only: This is not a full Vietnamese product mode and not multi-domain runtime support. +P7B adds explicit locale-aware rendering for presentation chrome: + +- analysis workspace shell labels may render from `locale=en|vi` +- final reviewed report reads accept `locale=en|vi` +- default locale remains `en` +- `vi` renders report headings, table headers, fixed notices, and terminology + references only +- generated statements, evidence excerpts, code, file paths, artifact keys, + provenance IDs, commit SHAs, and source line ranges remain raw + ## Invariants - Contracts, enum values, database values, and API payload keys stay English. @@ -36,6 +46,11 @@ The domain pack registry may expose bounded glossary metadata such as locale, asset status, version, and term count. It must not expose glossary term bodies as evidence or as a runtime language mode. +Locale-aware report rendering may display a bounded terminology section derived +from the static booking glossary. This remains a terminology aid only; it must +not change retrieval, analysis reasoning, evidence links, or persisted report +truth. + They are not executable analyzer rules: - do not inject these glossary files into prompts in this phase diff --git a/packages/contracts/review.contract.spec.ts b/packages/contracts/review.contract.spec.ts new file mode 100644 index 00000000..a8f5d0c2 --- /dev/null +++ b/packages/contracts/review.contract.spec.ts @@ -0,0 +1,48 @@ +import { + finalReviewedReportResponseSchema, + localeAwareReportQuerySchema, + type FinalReviewedReportResponse, +} from './src'; + +describe('review report locale contracts', () => { + it('defaults report locale to English', () => { + expect(localeAwareReportQuerySchema.parse({})).toEqual({ locale: 'en' }); + }); + + it('accepts explicit Vietnamese locale', () => { + expect(localeAwareReportQuerySchema.parse({ locale: 'vi' })).toEqual({ locale: 'vi' }); + }); + + it('rejects unsupported locales', () => { + expect(() => localeAwareReportQuerySchema.parse({ locale: 'fr' })).toThrow(); + }); + + it('includes the selected locale in final reviewed report responses', () => { + const payload: FinalReviewedReportResponse = { + analysisId: 'analysis-1', + snapshotId: 'snapshot-1', + locale: 'vi', + markdown: '# Báo cáo phân tích tác động', + createdAt: '2026-06-25T00:00:00.000Z', + reviewCompletion: { + analysisId: 'analysis-1', + totalLinks: 1, + accepted: 1, + rejected: 0, + needsReview: 0, + needsMoreEvidence: 0, + unreviewed: 0, + isComplete: true, + hasReviewedSnapshot: true, + latestSnapshotId: 'snapshot-1', + blockingReasons: [], + }, + reviewDecisionsSnapshot: [], + evidenceQualitySummarySnapshot: {}, + evaluationContextSnapshot: null, + createdByUserId: null, + }; + + expect(finalReviewedReportResponseSchema.parse(payload)).toEqual(payload); + }); +}); diff --git a/packages/contracts/src/review.contract.ts b/packages/contracts/src/review.contract.ts index 049edbcc..881fc261 100644 --- a/packages/contracts/src/review.contract.ts +++ b/packages/contracts/src/review.contract.ts @@ -1,5 +1,11 @@ import { z } from 'zod'; +export const reportLocaleSchema = z.enum(['en', 'vi']); + +export const localeAwareReportQuerySchema = z.object({ + locale: reportLocaleSchema.default('en'), +}); + export const reviewCompletionResponseSchema = z.object({ analysisId: z.string().min(1), totalLinks: z.number().int().nonnegative(), @@ -18,10 +24,13 @@ export const reviewCompletionResponseSchema = z.object({ }); export type ReviewCompletionResponse = z.infer; +export type ReportLocale = z.infer; +export type LocaleAwareReportQuery = z.infer; export const finalReviewedReportResponseSchema = z.object({ analysisId: z.string().min(1), snapshotId: z.string().min(1), + locale: reportLocaleSchema.default('en'), markdown: z.string().nullable().optional(), createdAt: z.string(), reviewCompletion: reviewCompletionResponseSchema, From 54e7a2fe42d8c84d40c18ced0814dd80daa21da9 Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Thu, 25 Jun 2026 09:48:26 +0700 Subject: [PATCH 15/22] test(domain-packs): add booking evaluation cases --- docs/agent/domain-packs.md | 6 +- docs/evaluation/impact-evaluation.md | 29 ++++ .../evaluation/booking-domain-stable.spec.ts | 145 ++++++++++++++++++ tests/evaluation/cases/booking-stable.ts | 119 ++++++++++++++ tests/evaluation/cases/index.ts | 13 +- 5 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 tests/evaluation/booking-domain-stable.spec.ts create mode 100644 tests/evaluation/cases/booking-stable.ts diff --git a/docs/agent/domain-packs.md b/docs/agent/domain-packs.md index 693d3a07..2b3ed05e 100644 --- a/docs/agent/domain-packs.md +++ b/docs/agent/domain-packs.md @@ -47,12 +47,16 @@ Current profiles: | Profile | Status | Notes | | --- | --- | --- | -| `booking@0.1.0` | `STABLE` | MVP Booking / Payment / Refund domain. | +| `booking@0.1.0` | `STABLE` | MVP Booking / Payment / Refund domain with P7C fixture-backed coverage for cancellation, refund, double-refund prevention, inventory release, and payment state. | | `general@0.0.0` | `FALLBACK` | Empty safe default; no booking-specific hints. | Do not claim broad multi-domain support until each new profile has status, limits, evaluation cases, and fallback behavior documented. +`booking@0.1.0` `STABLE` means the current fixture-backed evaluation set covers +the MVP booking cancellation/refund slice. It does not claim complete booking +runtime support, broad multi-domain behavior, or Vietnamese product mode. + ## Glossary Metadata Booking has static English and Vietnamese glossary assets under: diff --git a/docs/evaluation/impact-evaluation.md b/docs/evaluation/impact-evaluation.md index cc012d62..919408dc 100644 --- a/docs/evaluation/impact-evaluation.md +++ b/docs/evaluation/impact-evaluation.md @@ -82,6 +82,35 @@ The Evaluation summary also explicitly asserts the following deterministic safet 3. **General Fallback Integrity:** General pack strictly has no `booking` hints leaked. 4. **Diagnostic Boundedness:** Guarantees `DOMAIN_PACK_APPLIED` never blows out memory via payload template leakage. +## Booking STABLE Coverage (P7C) +P7C adds deterministic booking-domain cases that justify the current +`booking@0.1.0` `STABLE` registry status with fixture-backed expectations. + +The stable set covers: + +- cancellation through the paid-booking refund flow +- duplicate cancellation / double-refund prevention +- slot inventory release after cancellation +- payment transaction state changing from `PAID` to `REFUNDED` +- refund policy hints that must remain unknown until source evidence or + stakeholder confirmation exists + +These cases assert expected impacted artifacts, unknowns, risks, and QA +scenarios against the `nestjs-booking-with-payment` fixture. Every expected +impacted artifact must map to a scanner fixture artifact with a source file, +line range, and raw code excerpt. Domain pack retrieval hints, risk templates, +QA templates, unknown templates, and glossary terms are terminology hints only; +they cannot satisfy evidence requirements and cannot become impacted artifact +keys. + +Known limits: + +- The evaluation proves deterministic fixture coverage, not broad booking + product support. +- It does not add new scanner behavior, retrieval behavior, or AI behavior. +- It does not translate code, artifact keys, paths, or evidence excerpts. +- It does not promote any additional domain to `PARTIAL` or `STABLE`. + ## How to Add a New Case 1. Ensure the requirement matches an existing fixture in `tests/fixtures/`. 2. Create a new `.ts` file under `tests/evaluation/cases/`. diff --git a/tests/evaluation/booking-domain-stable.spec.ts b/tests/evaluation/booking-domain-stable.spec.ts new file mode 100644 index 00000000..d05688dd --- /dev/null +++ b/tests/evaluation/booking-domain-stable.spec.ts @@ -0,0 +1,145 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; +import { BookingDomainPack } from '../../apps/api/src/modules/domain-pack/packs/booking.v0.1.0'; +import { evaluationCaseSchema } from './evaluation-types'; +import { bookingStableEvaluationCases } from './cases'; + +type FixtureArtifact = { + stableId: string; + filePath: string; + startLine: number; + endLine: number; + excerpt: string; +}; + +const fixtureArtifacts = (): Map => { + const expectedArtifactsPath = resolve( + __dirname, + '../fixtures/nestjs-booking-with-payment/expected/artifacts.json', + ); + const parsed = JSON.parse(readFileSync(expectedArtifactsPath, 'utf-8')) as { + artifacts: FixtureArtifact[]; + }; + + return new Map(parsed.artifacts.map((artifact) => [artifact.stableId, artifact])); +}; + +describe('Booking domain STABLE evaluation cases', () => { + let registry: DomainPackRegistry; + let artifactsByKey: Map; + + beforeEach(() => { + registry = new DomainPackRegistry(); + artifactsByKey = fixtureArtifacts(); + }); + + it('declares booking@0.1.0 as the explicit STABLE evaluation target', () => { + const selection = registry.selectPack({ manualPackId: 'booking@0.1.0' }); + + expect(selection.normalizedPackId).toBe('booking'); + expect(selection.pack.version).toBe('0.1.0'); + expect(selection.pack.status).toBe('STABLE'); + expect(bookingStableEvaluationCases.length).toBe(5); + }); + + it('keeps all booking stable cases schema-valid with expected outcomes', () => { + for (const evalCase of bookingStableEvaluationCases) { + expect(() => evaluationCaseSchema.parse(evalCase)).not.toThrow(); + expect(evalCase.domain?.packId).toBe('booking'); + expect(evalCase.expected.impactedArtifactKeys.length).toBeGreaterThan(0); + expect(evalCase.expected.unknownsOrQuestions?.length).toBeGreaterThan(0); + expect(evalCase.expected.risks?.length).toBeGreaterThan(0); + expect(evalCase.expected.qaScenarios?.length).toBeGreaterThan(0); + } + }); + + it('covers cancellation, refund, double refund, inventory release, and payment state', () => { + const corpus = bookingStableEvaluationCases + .map((evalCase) => [ + evalCase.id, + evalCase.requirementTitle, + evalCase.requirementText, + ...(evalCase.expected.unknownsOrQuestions ?? []), + ...(evalCase.expected.risks ?? []), + ...(evalCase.expected.qaScenarios ?? []), + ].join(' ')) + .join(' ') + .toLowerCase(); + + for (const term of ['cancellation', 'refund', 'double refund', 'inventory release', 'payment state']) { + expect(corpus).toContain(term); + } + }); + + it('maps expected domain concepts through the registry glossary only', () => { + for (const evalCase of bookingStableEvaluationCases) { + const expectedConceptKeys = evalCase.domain?.expectedConceptKeys ?? []; + const matchedConcepts = registry.matchConcepts( + `${evalCase.requirementTitle} ${evalCase.requirementText}`, + BookingDomainPack, + ); + + expect(matchedConcepts).toEqual(expect.arrayContaining(expectedConceptKeys)); + } + }); + + it('requires source-code evidence for every expected impacted artifact', () => { + for (const evalCase of bookingStableEvaluationCases) { + for (const artifactKey of evalCase.expected.impactedArtifactKeys) { + const artifact = artifactsByKey.get(artifactKey); + + expect(artifact).toBeDefined(); + expect(artifact?.filePath).toMatch(/^src\//); + expect(artifact?.startLine).toBeGreaterThan(0); + expect(artifact?.endLine).toBeGreaterThanOrEqual(artifact?.startLine ?? 0); + expect(artifact?.excerpt.trim().length).toBeGreaterThan(0); + } + } + }); + + it('keeps domain-pack hints out of evidence and impacted artifact keys', () => { + const domainPackTemplates = [ + ...BookingDomainPack.retrievalHints, + ...BookingDomainPack.riskTemplates, + ...BookingDomainPack.qaTemplates, + ...BookingDomainPack.unknownTemplates, + ]; + + for (const evalCase of bookingStableEvaluationCases) { + for (const artifactKey of evalCase.expected.impactedArtifactKeys) { + const artifact = artifactsByKey.get(artifactKey); + + expect(artifactKey).not.toMatch(/^domain-pack:/); + expect(artifact?.filePath).not.toContain('domain-pack'); + expect(domainPackTemplates).not.toContain(artifact?.excerpt); + } + } + }); + + it('keeps policy hints as unknowns instead of fabricated evidence', () => { + const policyCase = bookingStableEvaluationCases.find( + (evalCase) => evalCase.id === 'booking-stable-policy-hints-stay-unknown', + ); + + expect(policyCase).toBeDefined(); + expect(policyCase?.expected.unknownsOrQuestions).toEqual( + expect.arrayContaining(['refund deadline', 'refund amount', 'owner approval']), + ); + expect(policyCase?.expected.impactedArtifactKeys).not.toEqual( + expect.arrayContaining(['domain-pack:refund-deadline', 'domain-pack:owner-approval']), + ); + }); + + it('treats admin refund report as deterministic keyword noise', () => { + for (const evalCase of bookingStableEvaluationCases) { + expect(artifactsByKey.has('service-method:refund-report.service.generateReport')).toBe(true); + expect(evalCase.expected.negativeArtifactKeys).toContain( + 'service-method:refund-report.service.generateReport', + ); + expect(evalCase.expected.impactedArtifactKeys).not.toContain( + 'service-method:refund-report.service.generateReport', + ); + } + }); +}); diff --git a/tests/evaluation/cases/booking-stable.ts b/tests/evaluation/cases/booking-stable.ts new file mode 100644 index 00000000..f364eca6 --- /dev/null +++ b/tests/evaluation/cases/booking-stable.ts @@ -0,0 +1,119 @@ +import { EvaluationCase } from '../evaluation-types'; + +export const bookingStableEvaluationCases: EvaluationCase[] = [ + { + id: 'booking-stable-cancel-refund-flow', + requirementTitle: 'Cancel booking through payment refund flow', + requirementText: + 'When a user cancels a paid booking, the cancellation flow must call payment refund, release the slot, and send a notification to the owner.', + targetFixture: 'nestjs-booking-with-payment', + expected: { + impactedArtifactKeys: [ + 'api:booking.controller.cancel', + 'service-method:booking.service.cancelBooking', + 'service-method:payment.service.refund', + 'service-method:slot.service.releaseSlot', + 'service-method:notification.service.notifyOwner', + ], + negativeArtifactKeys: ['service-method:refund-report.service.generateReport'], + evidenceHints: ['cancelBooking', 'PaymentService.refund', 'releaseSlot', 'notifyOwner'], + unknownsOrQuestions: ['refund percentage', 'who may cancel', 'slot re-open policy'], + risks: ['duplicate refund', 'payment gateway failure'], + qaScenarios: ['cancel paid booking triggers exactly one refund and slot release'], + }, + domain: { + packId: 'booking', + expectedConceptKeys: ['booking', 'payment', 'refund', 'cancellation', 'notification'], + }, + }, + { + id: 'booking-stable-double-refund-policy', + requirementTitle: 'Prevent double refund during booking cancellation', + requirementText: + 'Repeated cancellation for the same booking must not create a double refund or duplicate payment refund request.', + targetFixture: 'nestjs-booking-with-payment', + expected: { + impactedArtifactKeys: [ + 'service-method:booking.service.cancelBooking', + 'service-method:payment.service.refund', + 'entity:paymenttransaction', + ], + negativeArtifactKeys: ['service-method:refund-report.service.generateReport'], + evidenceHints: ['refund', 'PaymentTransaction', 'PaymentStatus.REFUNDED'], + unknownsOrQuestions: ['idempotency key', 'existing refund record', 'concurrent cancellation'], + risks: ['duplicate refund', 'race condition between repeated cancellation requests'], + qaScenarios: ['submit the same cancellation twice and verify only one refund is created'], + }, + domain: { + packId: 'booking', + expectedConceptKeys: ['booking', 'payment', 'refund', 'cancellation'], + }, + }, + { + id: 'booking-stable-inventory-release', + requirementTitle: 'Release slot inventory after booking cancellation', + requirementText: + 'After booking cancellation succeeds, release the slot inventory so the booked slot can be considered for rebooking.', + targetFixture: 'nestjs-booking-with-payment', + expected: { + impactedArtifactKeys: [ + 'service-method:booking.service.cancelBooking', + 'service-method:slot.service.releaseSlot', + ], + negativeArtifactKeys: ['service-method:refund-report.service.generateReport'], + evidenceHints: ['releaseSlot', 'slotId'], + unknownsOrQuestions: ['when the released slot becomes bookable again'], + risks: ['slot reopened before refund state is settled'], + qaScenarios: ['cancel paid booking and verify inventory release is invoked once'], + }, + domain: { + packId: 'booking', + expectedConceptKeys: ['booking', 'cancellation'], + }, + }, + { + id: 'booking-stable-payment-state', + requirementTitle: 'Mark payment state as refunded after cancellation', + requirementText: + 'When a booking refund is processed, the payment transaction state must move from PAID to REFUNDED.', + targetFixture: 'nestjs-booking-with-payment', + expected: { + impactedArtifactKeys: [ + 'service-method:payment.service.refund', + 'entity:paymenttransaction', + ], + negativeArtifactKeys: ['service-method:refund-report.service.generateReport'], + evidenceHints: ['PaymentStatus.PAID', 'PaymentStatus.REFUNDED'], + unknownsOrQuestions: ['refund failure rollback behavior'], + risks: ['payment state mismatch after refund failure'], + qaScenarios: ['refund paid booking and assert payment state is REFUNDED'], + }, + domain: { + packId: 'booking', + expectedConceptKeys: ['booking', 'payment', 'refund'], + }, + }, + { + id: 'booking-stable-policy-hints-stay-unknown', + requirementTitle: 'Clarify refund deadline for cancelled booking', + requirementText: + 'Booking cancellation should explain whether refund deadline, refund amount, and owner approval are required before payment refund.', + targetFixture: 'nestjs-booking-with-payment', + expected: { + impactedArtifactKeys: [ + 'api:booking.controller.cancel', + 'service-method:booking.service.cancelBooking', + 'service-method:payment.service.refund', + ], + negativeArtifactKeys: ['service-method:refund-report.service.generateReport'], + evidenceHints: ['cancelBooking', 'refund'], + unknownsOrQuestions: ['refund deadline', 'refund amount', 'owner approval'], + risks: ['domain pack hint presented as existing refund policy'], + qaScenarios: ['reviewer confirms refund deadline before final acceptance criteria are approved'], + }, + domain: { + packId: 'booking', + expectedConceptKeys: ['booking', 'payment', 'refund', 'cancellation'], + }, + }, +]; diff --git a/tests/evaluation/cases/index.ts b/tests/evaluation/cases/index.ts index 1c8b0df8..ebf652c2 100644 --- a/tests/evaluation/cases/index.ts +++ b/tests/evaluation/cases/index.ts @@ -6,7 +6,18 @@ import { case05 } from './05-block-cancel-completed-booking'; import { case06 } from './06-require-cancel-reason'; import { case07 } from './07-admin-manual-refund'; import { case08 } from './08-payment-callback-retry'; +import { bookingStableEvaluationCases } from './booking-stable'; + +export { bookingStableEvaluationCases } from './booking-stable'; export const ALL_EVALUATION_CASES = [ - case01, case02, case03, case04, case05, case06, case07, case08 + case01, + case02, + case03, + case04, + case05, + case06, + case07, + case08, + ...bookingStableEvaluationCases, ]; From be35b7a967d67f57a5d93f6a8cec051fad1752ac Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Thu, 25 Jun 2026 09:56:44 +0700 Subject: [PATCH 16/22] test(domain-packs): harden general fallback evaluation --- docs/agent/domain-packs.md | 8 +- docs/agent/testing-strategy.md | 13 ++ docs/evaluation/impact-evaluation.md | 32 +++ tests/evaluation/cases/general-fallback.ts | 116 ++++++++++ tests/evaluation/cases/index.ts | 3 + .../general-domain-fallback.spec.ts | 204 ++++++++++++++++++ tests/evaluation/impact-evaluation.spec.ts | 2 +- .../run-impact-analysis.spec.ts | 2 + 8 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 tests/evaluation/cases/general-fallback.ts create mode 100644 tests/evaluation/general-domain-fallback.spec.ts diff --git a/docs/agent/domain-packs.md b/docs/agent/domain-packs.md index 2b3ed05e..4e91c9f8 100644 --- a/docs/agent/domain-packs.md +++ b/docs/agent/domain-packs.md @@ -48,7 +48,7 @@ Current profiles: | Profile | Status | Notes | | --- | --- | --- | | `booking@0.1.0` | `STABLE` | MVP Booking / Payment / Refund domain with P7C fixture-backed coverage for cancellation, refund, double-refund prevention, inventory release, and payment state. | -| `general@0.0.0` | `FALLBACK` | Empty safe default; no booking-specific hints. | +| `general@0.0.0` | `FALLBACK` | Empty safe default with P7D defensive coverage; no booking-specific hints. | Do not claim broad multi-domain support until each new profile has status, limits, evaluation cases, and fallback behavior documented. @@ -57,6 +57,12 @@ limits, evaluation cases, and fallback behavior documented. the MVP booking cancellation/refund slice. It does not claim complete booking runtime support, broad multi-domain behavior, or Vietnamese product mode. +`general@0.0.0` `FALLBACK` means no supported domain profile was selected. It +must stay conservative: no concepts, no retrieval hints, no risk/QA/unknown +templates, and no glossary metadata. Fallback diagnostics may expose bounded +metadata such as id, version, status, selectedBy, and counts, but must not +expose template bodies, prompt payloads, source code, or evidence excerpts. + ## Glossary Metadata Booking has static English and Vietnamese glossary assets under: diff --git a/docs/agent/testing-strategy.md b/docs/agent/testing-strategy.md index 38a63919..84ff08d9 100644 --- a/docs/agent/testing-strategy.md +++ b/docs/agent/testing-strategy.md @@ -109,6 +109,19 @@ the fixture explicitly encodes that behavior. ## Required Test Layers +### Local execution note + +Do not run the full Jest suite and `pnpm demo:golden-path` concurrently when +they share the same test database or schema. Both paths reset and seed test +data, so concurrent execution can create false failures from duplicate fixed +fixture IDs. Run them as separate commands when validating a release or phase +handoff: + +```bash +pnpm test +pnpm demo:golden-path +``` + ### Scanner fixture tests Verify routes, controller/service methods, entities/models, tests, line ranges, diff --git a/docs/evaluation/impact-evaluation.md b/docs/evaluation/impact-evaluation.md index 919408dc..abdeeb23 100644 --- a/docs/evaluation/impact-evaluation.md +++ b/docs/evaluation/impact-evaluation.md @@ -111,6 +111,38 @@ Known limits: - It does not translate code, artifact keys, paths, or evidence excerpts. - It does not promote any additional domain to `PARTIAL` or `STABLE`. +## General FALLBACK Coverage (P7D) +P7D adds defensive evaluation coverage for `general@0.0.0`. The goal is to +prove the fallback profile stays conservative when no supported domain applies. + +The fallback set covers: + +- generic lifecycle state change +- generic external transaction reversal +- downstream availability side effects +- outbound side effects + +The expected outputs deliberately use empty domain concept expectations and +uncertain wording: + +- unknowns start with `unknown:` +- risks start with `inferred risk:` +- QA scenarios start with `uncertain qa:` + +The fallback profile must not contain booking concepts, retrieval hints, risk +templates, QA templates, unknown templates, or glossary metadata. Its bounded +diagnostic metadata must include `domainPackStatus: FALLBACK` with only counts +and identifiers, not template bodies or source excerpts. + +Known limits: + +- `general@0.0.0` is not a broad domain pack and does not claim multi-domain + support. +- It can preserve raw source artifact names and evidence excerpts from the + analyzed repository, but it must not inject booking-specific business + terminology from domain-pack metadata. +- It cannot create `EVIDENCED` claims from generic hints or fallback status. + ## How to Add a New Case 1. Ensure the requirement matches an existing fixture in `tests/fixtures/`. 2. Create a new `.ts` file under `tests/evaluation/cases/`. diff --git a/tests/evaluation/cases/general-fallback.ts b/tests/evaluation/cases/general-fallback.ts new file mode 100644 index 00000000..24dde682 --- /dev/null +++ b/tests/evaluation/cases/general-fallback.ts @@ -0,0 +1,116 @@ +import { EvaluationCase } from '../evaluation-types'; + +export const generalFallbackEvaluationCases: EvaluationCase[] = [ + { + id: 'general-fallback-resource-state-change', + requirementTitle: 'Review resource lifecycle state change', + requirementText: + 'A resource lifecycle operation changes state and may trigger connected side effects. Identify source-backed artifacts only and keep domain policy gaps uncertain.', + targetFixture: 'nestjs-booking-with-payment', + expected: { + impactedArtifactKeys: [ + 'service-method:booking.service.cancelBooking', + 'entity:booking', + ], + negativeArtifactKeys: ['service-method:refund-report.service.generateReport'], + evidenceHints: ['status', 'connected side effects'], + unknownsOrQuestions: [ + 'unknown: exact lifecycle eligibility requires source evidence or stakeholder confirmation', + ], + risks: [ + 'inferred risk: state transition side effects may be inconsistent without stronger evidence', + ], + qaScenarios: [ + 'uncertain qa: verify the concrete source-backed state transition before approval', + ], + }, + domain: { + packId: 'general', + expectedConceptKeys: [], + }, + }, + { + id: 'general-fallback-external-transaction-reversal', + requirementTitle: 'Review external transaction reversal', + requirementText: + 'A lifecycle operation may reverse an external transaction after a state change. Use raw source excerpts for evidence and keep policy assumptions uncertain.', + targetFixture: 'nestjs-booking-with-payment', + expected: { + impactedArtifactKeys: [ + 'service-method:payment.service.refund', + 'entity:paymenttransaction', + ], + negativeArtifactKeys: ['service-method:refund-report.service.generateReport'], + evidenceHints: ['transaction', 'state'], + unknownsOrQuestions: [ + 'unknown: reversal eligibility and amount rules are not established by the fallback profile', + ], + risks: [ + 'inferred risk: external transaction state can diverge from lifecycle state', + ], + qaScenarios: [ + 'uncertain qa: verify transaction state changes only against source-backed behavior', + ], + }, + domain: { + packId: 'general', + expectedConceptKeys: [], + }, + }, + { + id: 'general-fallback-inventory-availability-side-effect', + requirementTitle: 'Review availability side effect', + requirementText: + 'A resource lifecycle operation may change downstream availability. Treat business timing as unknown unless current source evidence proves it.', + targetFixture: 'nestjs-booking-with-payment', + expected: { + impactedArtifactKeys: [ + 'service-method:booking.service.cancelBooking', + 'service-method:slot.service.releaseSlot', + ], + negativeArtifactKeys: ['service-method:refund-report.service.generateReport'], + evidenceHints: ['availability', 'side effect'], + unknownsOrQuestions: [ + 'unknown: downstream availability timing is not proven by fallback metadata', + ], + risks: [ + 'inferred risk: availability may change before related state is settled', + ], + qaScenarios: [ + 'uncertain qa: verify availability behavior with source-backed timing evidence', + ], + }, + domain: { + packId: 'general', + expectedConceptKeys: [], + }, + }, + { + id: 'general-fallback-notification-side-effect', + requirementTitle: 'Review outbound side effect', + requirementText: + 'A resource lifecycle operation may emit an outbound message. Keep recipient and timing policy uncertain without explicit source evidence.', + targetFixture: 'nestjs-booking-with-payment', + expected: { + impactedArtifactKeys: [ + 'service-method:booking.service.cancelBooking', + 'service-method:notification.service.notifyOwner', + ], + negativeArtifactKeys: ['service-method:refund-report.service.generateReport'], + evidenceHints: ['outbound message', 'side effect'], + unknownsOrQuestions: [ + 'unknown: recipient and timing policy require source evidence or stakeholder confirmation', + ], + risks: [ + 'inferred risk: outbound side effect may be emitted at the wrong lifecycle point', + ], + qaScenarios: [ + 'uncertain qa: verify outbound side effect timing after evidence review', + ], + }, + domain: { + packId: 'general', + expectedConceptKeys: [], + }, + }, +]; diff --git a/tests/evaluation/cases/index.ts b/tests/evaluation/cases/index.ts index ebf652c2..c9e18a18 100644 --- a/tests/evaluation/cases/index.ts +++ b/tests/evaluation/cases/index.ts @@ -7,8 +7,10 @@ import { case06 } from './06-require-cancel-reason'; import { case07 } from './07-admin-manual-refund'; import { case08 } from './08-payment-callback-retry'; import { bookingStableEvaluationCases } from './booking-stable'; +import { generalFallbackEvaluationCases } from './general-fallback'; export { bookingStableEvaluationCases } from './booking-stable'; +export { generalFallbackEvaluationCases } from './general-fallback'; export const ALL_EVALUATION_CASES = [ case01, @@ -20,4 +22,5 @@ export const ALL_EVALUATION_CASES = [ case07, case08, ...bookingStableEvaluationCases, + ...generalFallbackEvaluationCases, ]; diff --git a/tests/evaluation/general-domain-fallback.spec.ts b/tests/evaluation/general-domain-fallback.spec.ts new file mode 100644 index 00000000..ba728db4 --- /dev/null +++ b/tests/evaluation/general-domain-fallback.spec.ts @@ -0,0 +1,204 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { domainPackAppliedDiagnosticPayloadSchema } from '@ba-helper/contracts'; +import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry'; +import { BookingDomainPack } from '../../apps/api/src/modules/domain-pack/packs/booking.v0.1.0'; +import { EvaluationAdapter, EvaluationRunner } from './evaluation-runner'; +import { EvaluationCase, NormalizedEvaluationResult, evaluationCaseSchema } from './evaluation-types'; +import { generalFallbackEvaluationCases } from './cases'; + +type FixtureArtifact = { + stableId: string; + filePath: string; + startLine: number; + endLine: number; + excerpt: string; +}; + +const fixtureArtifacts = (): Map => { + const expectedArtifactsPath = resolve( + __dirname, + '../fixtures/nestjs-booking-with-payment/expected/artifacts.json', + ); + const parsed = JSON.parse(readFileSync(expectedArtifactsPath, 'utf-8')) as { + artifacts: FixtureArtifact[]; + }; + + return new Map(parsed.artifacts.map((artifact) => [artifact.stableId, artifact])); +}; + +class ConservativeFallbackAdapter implements EvaluationAdapter { + constructor(private readonly artifactsByKey: Map) {} + + async evaluateCase(evalCase: EvaluationCase): Promise { + const evidenceByArtifactKey: Record = {}; + + for (const artifactKey of evalCase.expected.impactedArtifactKeys) { + const artifact = this.artifactsByKey.get(artifactKey); + if (artifact) { + evidenceByArtifactKey[artifactKey] = [artifact.excerpt]; + } + } + + return { + foundImpactedArtifactKeys: evalCase.expected.impactedArtifactKeys, + evidenceByArtifactKey, + unknownsOrQuestions: evalCase.expected.unknownsOrQuestions ?? [], + risks: evalCase.expected.risks ?? [], + qaScenarios: evalCase.expected.qaScenarios ?? [], + domainPackId: 'general', + domainPackVersion: '0.0.0', + matchedConceptKeys: [], + }; + } +} + +const businessChrome = (evalCase: EvaluationCase): string => [ + evalCase.requirementTitle, + evalCase.requirementText, + ...(evalCase.expected.unknownsOrQuestions ?? []), + ...(evalCase.expected.risks ?? []), + ...(evalCase.expected.qaScenarios ?? []), +].join(' ').toLowerCase(); + +describe('General domain FALLBACK evaluation cases', () => { + let registry: DomainPackRegistry; + let artifactsByKey: Map; + + beforeEach(() => { + registry = new DomainPackRegistry(); + artifactsByKey = fixtureArtifacts(); + }); + + it('declares general@0.0.0 as a safe FALLBACK profile', () => { + const selection = registry.selectPack({ repositoryProfileDomain: 'UNKNOWN' }); + + expect(selection.normalizedPackId).toBe('general'); + expect(selection.selectedBy).toBe('safe_default'); + expect(selection.pack.version).toBe('0.0.0'); + expect(selection.pack.status).toBe('FALLBACK'); + expect(selection.pack.concepts).toEqual([]); + expect(selection.pack.retrievalHints).toEqual([]); + expect(selection.pack.riskTemplates).toEqual([]); + expect(selection.pack.qaTemplates).toEqual([]); + expect(selection.pack.unknownTemplates).toEqual([]); + expect(selection.pack.glossaryMetadata).toEqual([]); + }); + + it('emits bounded fallback status metadata for diagnostics', () => { + const selection = registry.selectPack({ repositoryProfileDomain: 'UNSUPPORTED_DOMAIN' }); + const payload = { + domainPackId: selection.pack.id, + domainPackVersion: selection.pack.version, + domainPackStatus: selection.pack.status, + selectedBy: selection.selectedBy, + conceptCount: selection.pack.concepts.length, + retrievalHintCount: selection.pack.retrievalHints.length, + riskTemplateCount: selection.pack.riskTemplates.length, + qaTemplateCount: selection.pack.qaTemplates.length, + unknownTemplateCount: selection.pack.unknownTemplates.length, + }; + + expect(domainPackAppliedDiagnosticPayloadSchema.parse(payload)).toMatchObject({ + domainPackId: 'general', + domainPackVersion: '0.0.0', + domainPackStatus: 'FALLBACK', + selectedBy: 'safe_default', + }); + expect(JSON.stringify(payload)).not.toContain('refund eligibility'); + expect(JSON.stringify(payload)).not.toContain('sourceCode'); + expect(JSON.stringify(payload)).not.toContain('qaTemplates'); + }); + + it('keeps fallback cases schema-valid and concept-free', () => { + expect(generalFallbackEvaluationCases.length).toBe(4); + + for (const evalCase of generalFallbackEvaluationCases) { + expect(() => evaluationCaseSchema.parse(evalCase)).not.toThrow(); + expect(evalCase.domain?.packId).toBe('general'); + expect(evalCase.domain?.expectedConceptKeys).toEqual([]); + expect( + registry.matchConcepts(`${evalCase.requirementTitle} ${evalCase.requirementText}`, registry.getPackById('general')), + ).toEqual([]); + } + }); + + it('marks fallback unknowns, risks, and QA as uncertain rather than evidenced', () => { + for (const evalCase of generalFallbackEvaluationCases) { + for (const unknown of evalCase.expected.unknownsOrQuestions ?? []) { + expect(unknown).toMatch(/^unknown:/); + } + for (const risk of evalCase.expected.risks ?? []) { + expect(risk).toMatch(/^inferred risk:/); + } + for (const qaScenario of evalCase.expected.qaScenarios ?? []) { + expect(qaScenario).toMatch(/^uncertain qa:/); + } + } + }); + + it('does not leak booking profile terminology into fallback business chrome', () => { + const forbiddenTerms = ['booking', 'payment', 'refund', 'cancellation', 'notification']; + + for (const evalCase of generalFallbackEvaluationCases) { + const text = businessChrome(evalCase); + + for (const term of forbiddenTerms) { + expect(text).not.toContain(term); + } + } + + const generalPack = registry.getPackById('general'); + const bookingText = BookingDomainPack.concepts + .flatMap((concept) => [concept.key, concept.label, ...concept.aliases]) + .join(' '); + + expect(registry.matchConcepts(bookingText, generalPack)).toEqual([]); + }); + + it('requires raw source excerpts for fallback impacted artifacts', () => { + for (const evalCase of generalFallbackEvaluationCases) { + for (const artifactKey of evalCase.expected.impactedArtifactKeys) { + const artifact = artifactsByKey.get(artifactKey); + + expect(artifact).toBeDefined(); + expect(artifact?.filePath).toMatch(/^src\//); + expect(artifact?.startLine).toBeGreaterThan(0); + expect(artifact?.endLine).toBeGreaterThanOrEqual(artifact?.startLine ?? 0); + expect(artifact?.excerpt.trim().length).toBeGreaterThan(0); + expect(artifactKey).not.toMatch(/^domain-pack:/); + } + } + }); + + it('reports conservative fallback evaluation without booking pack overclaim', async () => { + const runner = new EvaluationRunner( + new ConservativeFallbackAdapter(artifactsByKey), + registry, + ); + + const result = await runner.run(generalFallbackEvaluationCases); + + expect(result.report.totalCases).toBe(4); + expect(result.report.failedCases).toEqual([]); + expect(result.report.domainPackSummary).toMatchObject({ + totalCasesWithDomain: 4, + packIdsUsed: ['general'], + conceptMatchRecall: '0%', + missingExpectedConcepts: [], + unexpectedMatchedConcepts: [], + }); + + for (const caseReport of result.report.cases) { + expect(caseReport.domainPackId).toBe('general'); + expect(caseReport.domainPackVersion).toBe('0.0.0'); + expect(caseReport.expectedConceptKeys).toEqual([]); + expect(caseReport.matchedConceptKeys).toEqual([]); + expect(caseReport.evidenceCoverage).toBe('100.0%'); + } + + expect(result.textSummary).toContain('domain pack: general@0.0.0'); + expect(result.textSummary).not.toContain('booking@0.1.0'); + expect(result.textSummary).not.toContain('refund eligibility'); + }); +}); diff --git a/tests/evaluation/impact-evaluation.spec.ts b/tests/evaluation/impact-evaluation.spec.ts index cc44f8d6..0c329c3c 100644 --- a/tests/evaluation/impact-evaluation.spec.ts +++ b/tests/evaluation/impact-evaluation.spec.ts @@ -143,7 +143,7 @@ describe('Impact Evaluation Pack', () => { // Output is bounded string summary expect(typeof result.textSummary).toBe('string'); expect(result.textSummary.length).toBeGreaterThan(0); - expect(result.textSummary.length).toBeLessThan(10000); // bounded check + expect(result.textSummary.length).toBeLessThan(15000); // bounded check // We explicitly did not connect to Prisma or any LLM clients here. // The tests are entirely offline and CPU bound. diff --git a/tests/impact-analysis/run-impact-analysis.spec.ts b/tests/impact-analysis/run-impact-analysis.spec.ts index 7def68a3..d8ae6228 100644 --- a/tests/impact-analysis/run-impact-analysis.spec.ts +++ b/tests/impact-analysis/run-impact-analysis.spec.ts @@ -437,6 +437,8 @@ describe('RunImpactAnalysisUseCase', () => { const diagnostic = finalUpdateCall![0].metadata.diagnostics.find((d: any) => d.code === 'DOMAIN_PACK_APPLIED'); expect(diagnostic.payload.domainPackId).toBe('general'); + expect(diagnostic.payload.domainPackVersion).toBe('0.0.0'); + expect(diagnostic.payload.domainPackStatus).toBe('FALLBACK'); expect(diagnostic.payload.selectedBy).toBe('safe_default'); }); From 64e43f1efb81daa08ed8020b657ef2ac4971966f Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Thu, 25 Jun 2026 10:01:12 +0700 Subject: [PATCH 17/22] style(web): align analysis workspace visual tokens --- .../_components/drift-details-drawer.tsx | 8 +++---- .../report/final-review-gate-panel.tsx | 24 +++++++++---------- .../new-analysis/confirmation-step.tsx | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/web/src/app/(app)/analyses/[analysisId]/_components/drift-details-drawer.tsx b/apps/web/src/app/(app)/analyses/[analysisId]/_components/drift-details-drawer.tsx index cfd0ce72..d5571e5f 100644 --- a/apps/web/src/app/(app)/analyses/[analysisId]/_components/drift-details-drawer.tsx +++ b/apps/web/src/app/(app)/analyses/[analysisId]/_components/drift-details-drawer.tsx @@ -66,7 +66,7 @@ export function DriftDetailsDrawer({
{drift.samples.addedArtifacts.length > 0 && (
-

+

Added Artifacts ({drift.samples.addedArtifacts.length})

    @@ -79,7 +79,7 @@ export function DriftDetailsDrawer({ {drift.samples.removedArtifacts.length > 0 && (
    -

    +

    Removed Artifacts ({drift.samples.removedArtifacts.length})

      @@ -92,7 +92,7 @@ export function DriftDetailsDrawer({ {drift.samples.changedArtifacts.length > 0 && (
      -

      +

      Changed Artifacts ({drift.samples.changedArtifacts.length})

        @@ -105,7 +105,7 @@ export function DriftDetailsDrawer({ {drift.samples.unknownChangedArtifacts.length > 0 && (
        -

        +

        Hash Unavailable / Unknown Changes ({drift.samples.unknownChangedArtifacts.length})

          diff --git a/apps/web/src/components/report/final-review-gate-panel.tsx b/apps/web/src/components/report/final-review-gate-panel.tsx index a8afbdc8..656768bd 100644 --- a/apps/web/src/components/report/final-review-gate-panel.tsx +++ b/apps/web/src/components/report/final-review-gate-panel.tsx @@ -79,12 +79,12 @@ export function FinalReviewGatePanel({ analysisId }: FinalReviewGatePanelProps) return (
          -
          +
          {/* Subtle background decoration */} {isComplete && (
          - +
          )} @@ -92,9 +92,9 @@ export function FinalReviewGatePanel({ analysisId }: FinalReviewGatePanelProps)
          {isComplete ? ( - + ) : ( - + )}

          Final Review Gate

          @@ -110,7 +110,7 @@ export function FinalReviewGatePanel({ analysisId }: FinalReviewGatePanelProps)
          Accepted
          -
          {accepted}
          +
          {accepted}
          Rejected
          @@ -118,26 +118,26 @@ export function FinalReviewGatePanel({ analysisId }: FinalReviewGatePanelProps)
          Needs Rev.
          -
          {needsReview}
          +
          {needsReview}
          More Evid.
          -
          {needsMoreEvidence}
          +
          {needsMoreEvidence}
          -
          0 ? 'border-orange-500/30' : 'border-border/50'}`}> +
          0 ? 'border-warning/30' : 'border-border/50'}`}>
          Unreviewed
          -
          0 ? 'text-orange-500' : 'text-muted-foreground'}`}>{unreviewed}
          +
          0 ? 'text-warning' : 'text-muted-foreground'}`}>{unreviewed}
          {!isComplete && blockingReasons.map(reason => ( -
          +
          {formatBlockingReason(reason)}
          ))} {!isComplete && !hasReviewedSnapshot && !blockingReasons.includes('REVIEWED_SNAPSHOT_MISSING') && ( -
          +
          Blocked: reviewed snapshot is missing
          )} @@ -146,7 +146,7 @@ export function FinalReviewGatePanel({ analysisId }: FinalReviewGatePanelProps)
          {isComplete && ( - + Ready for audited export )} diff --git a/apps/web/src/components/workspace/analysis/new-analysis/confirmation-step.tsx b/apps/web/src/components/workspace/analysis/new-analysis/confirmation-step.tsx index b4c13145..9bc881c8 100644 --- a/apps/web/src/components/workspace/analysis/new-analysis/confirmation-step.tsx +++ b/apps/web/src/components/workspace/analysis/new-analysis/confirmation-step.tsx @@ -146,7 +146,7 @@ export function ConfirmationStep({ )} {oldAnalysisSnapshotCommit && selectedRepo?.latestSnapshot?.commitSha !== oldAnalysisSnapshotCommit && ( -
          +

          This analysis will use a newer repository snapshot ( From 1d0a9b77b099fc84f9515f883b2fba6660ba91c6 Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Thu, 25 Jun 2026 10:07:56 +0700 Subject: [PATCH 18/22] style(web): namespace landing and workspace css classes --- .../src/components/landing/landing-hero.tsx | 8 ++--- .../web/src/components/layout/app-sidebar.tsx | 28 ++++++++-------- .../retrieval/code-evidence-block.tsx | 6 ++-- apps/web/src/styles/landing.css | 26 +++++++-------- apps/web/src/styles/responsive.css | 6 ++-- apps/web/src/styles/workspace.css | 32 +++++++++---------- 6 files changed, 53 insertions(+), 53 deletions(-) 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.

          -
          +
          -
          {/* ── Code block ── */} -
          +      
                   {lines.map((line, i) => (
          -          
          -
          {startLine + i}
          +
          +
          {startLine + i}
          ))} diff --git a/apps/web/src/styles/landing.css b/apps/web/src/styles/landing.css index 44df7021..218aa9b0 100644 --- a/apps/web/src/styles/landing.css +++ b/apps/web/src/styles/landing.css @@ -60,7 +60,7 @@ color: var(--accent); } -.hero-content { +.landing-hero-content { position: relative; z-index: 1; max-width: 900px; @@ -68,7 +68,7 @@ text-align: center; } -.hero-eyebrow { +.landing-hero-eyebrow { display: inline-flex; align-items: center; gap: 8px; @@ -81,7 +81,7 @@ box-shadow: var(--shadow-card); } -.hero-subtitle { +.landing-hero-subtitle { max-width: 680px; margin: 24px auto 0; color: var(--text-secondary); @@ -89,7 +89,7 @@ line-height: 1.5; } -.hero-actions { +.landing-hero-actions { display: flex; justify-content: center; gap: 12px; @@ -97,7 +97,7 @@ flex-wrap: wrap; } -.hero-mockup { +.landing-hero-mockup { position: relative; z-index: 1; width: min(100% - 32px, 1120px); @@ -108,7 +108,7 @@ PRODUCT MOCKUP ========================================================= */ -.product-mockup { +.landing-product-mockup { overflow: hidden; border-radius: 32px; background: var(--surface); @@ -116,7 +116,7 @@ box-shadow: var(--shadow-xl); } -.product-mockup-header { +.landing-product-mockup-header { display: flex; align-items: center; justify-content: space-between; @@ -126,39 +126,39 @@ background: var(--surface); } -.product-mockup-dots { +.landing-product-mockup-dots { display: flex; gap: 7px; } -.product-mockup-dot { +.landing-product-mockup-dot { width: 10px; height: 10px; border-radius: 9999px; background: var(--border); } -.product-mockup-body { +.landing-product-mockup-body { display: grid; grid-template-columns: 240px minmax(0, 1fr) 320px; min-height: 520px; background: var(--surface-muted); } -.mockup-sidebar { +.landing-mockup-sidebar { padding: 18px; border-right: 1px solid var(--border-subtle); background: var(--surface); } -.mockup-main { +.landing-mockup-main { padding: 18px; display: flex; flex-direction: column; gap: 14px; } -.mockup-inspector { +.landing-mockup-inspector { padding: 18px; border-left: 1px solid var(--border-subtle); background: var(--inspector-bg); diff --git a/apps/web/src/styles/responsive.css b/apps/web/src/styles/responsive.css index 30258f9f..129bc219 100644 --- a/apps/web/src/styles/responsive.css +++ b/apps/web/src/styles/responsive.css @@ -24,12 +24,12 @@ } @media (max-width: 1000px) { - .product-mockup-body { + .landing-product-mockup-body { grid-template-columns: 1fr; } - .mockup-sidebar, - .mockup-inspector { + .landing-mockup-sidebar, + .landing-mockup-inspector { display: none; } } diff --git a/apps/web/src/styles/workspace.css b/apps/web/src/styles/workspace.css index 0724dc50..1acb371b 100644 --- a/apps/web/src/styles/workspace.css +++ b/apps/web/src/styles/workspace.css @@ -81,14 +81,14 @@ -.nav-section { +.app-nav-section { display: flex; flex-direction: column; gap: 4px; margin-top: var(--spacing-workspace); } -.nav-label { +.app-nav-label { padding: 0 8px; margin-bottom: 4px; color: var(--text-disabled); @@ -97,7 +97,7 @@ letter-spacing: 0.04em; } -.nav-item { +.app-nav-item { display: flex; align-items: center; gap: 8px; @@ -108,8 +108,8 @@ font-size: 14px; } -.nav-item:hover, -.nav-item[data-active="true"] { +.app-nav-item:hover, +.app-nav-item[data-active="true"] { background: var(--surface-soft); color: var(--text-primary); } @@ -149,14 +149,14 @@ color: var(--text-tertiary); } -.panel { +.analysis-panel { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow-card); } -.panel-header { +.analysis-panel-header { display: flex; align-items: center; justify-content: space-between; @@ -165,23 +165,23 @@ border-bottom: 1px solid var(--border); } -.panel-title { +.analysis-panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); } -.panel-body { +.analysis-panel-body { padding: 14px; } -.artifact-list { +.analysis-artifact-list { display: flex; flex-direction: column; gap: 8px; } -.artifact-item { +.analysis-artifact-item { display: grid; grid-template-columns: 90px minmax(0, 1fr) auto; align-items: center; @@ -192,7 +192,7 @@ background: var(--surface-muted); } -.artifact-name { +.analysis-artifact-name { min-width: 0; color: var(--text-primary); font-weight: 500; @@ -201,7 +201,7 @@ white-space: nowrap; } -.artifact-path { +.analysis-artifact-path { margin-top: 2px; color: var(--text-tertiary); font-family: var(--font-mono); @@ -234,7 +234,7 @@ font-size: 12px; } -.code-block { +.evidence-code-block { margin: 0; padding: 12px; overflow: auto; @@ -245,13 +245,13 @@ line-height: 1.5; } -.code-line { +.evidence-code-line { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 12px; } -.code-line-number { +.evidence-code-line-number { color: var(--text-disabled); user-select: none; text-align: right; From dc119a750a43c059a5590086e6ba6bfa4f961a3e Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Thu, 25 Jun 2026 10:13:29 +0700 Subject: [PATCH 19/22] fix(web): pass localized finalization labels --- .../analyses/[analysisId]/_components/analysis-tab-bar.tsx | 4 ++++ 1 file changed, 4 insertions(+) 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} >
          ) : null} - {!hasMaterialDiff(diff) ? ( - + {!hasMaterialLineageChanges(diff) ? ( + ) : null} diff --git a/apps/web/src/components/workspace/analysis/lineage-diff-view-model.ts b/apps/web/src/components/workspace/analysis/lineage-diff-view-model.ts index 6b50a321..ded7d63e 100644 --- a/apps/web/src/components/workspace/analysis/lineage-diff-view-model.ts +++ b/apps/web/src/components/workspace/analysis/lineage-diff-view-model.ts @@ -77,17 +77,14 @@ export function buildLineageDiffSummary(params: { } } -export function hasMaterialDiff(diff: ImpactAnalysisDiffResponse | undefined) { +export function hasMaterialLineageChanges(diff: ImpactAnalysisDiffResponse | undefined) { if (!diff) return false - return ( - diff.addedArtifacts.length + - diff.removedArtifacts.length + - diff.resolvedUnknowns.length + - diff.removedUnknowns.length + - diff.newUnknowns.length + - diff.addedQaScenarios.length > - 0 - ) + + const hasImpacts = diff.summary.addedImpacts > 0 || diff.summary.removedImpacts > 0 + const hasUnknowns = diff.summary.newUnknowns > 0 || diff.summary.resolvedUnknowns > 0 || diff.summary.removedUnknowns > 0 + const hasQa = diff.summary.addedQaScenarios > 0 + + return hasImpacts || hasUnknowns || hasQa } export function evidenceDiffUnavailableMessage() { diff --git a/apps/web/src/components/workspace/shared/status-badges.tsx b/apps/web/src/components/workspace/shared/status-badges.tsx index d7113e81..a1ea13eb 100644 --- a/apps/web/src/components/workspace/shared/status-badges.tsx +++ b/apps/web/src/components/workspace/shared/status-badges.tsx @@ -1,4 +1,5 @@ import { cn } from "@/lib/utils" +import { getDomainCapabilityBadge, type SupportedLocale } from "@/lib/i18n/status-labels" type BadgeTone = "neutral" | "success" | "warning" | "danger" | "info" | "muted" @@ -207,3 +208,29 @@ export function ScanStatusBadge({ status, className }: { status: string; classNa export function CoverageStatusBadge({ status, className }: { status: string; className?: string }) { return renderBadge(getCoverageStatusMeta(status), className) } + +export function DomainStatusBadge({ + domainProfileId, + domainPackStatus, + locale, + className +}: { + domainProfileId?: string | null + domainPackStatus?: string | null + locale?: SupportedLocale + className?: string +}) { + const badgeData = getDomainCapabilityBadge({ domainProfileId, domainPackStatus, locale }) + + let tone: BadgeTone = "muted" + if (badgeData.status === "STABLE") tone = "success" + else if (badgeData.status === "PARTIAL") tone = "warning" + else if (badgeData.status === "FALLBACK") tone = "muted" + else if (badgeData.status === "EXPERIMENTAL") tone = "info" + + return renderBadge({ + label: badgeData.label, + tone, + description: badgeData.tooltip + }, className) +} diff --git a/apps/web/src/lib/i18n/analysis-labels.ts b/apps/web/src/lib/i18n/analysis-labels.ts index b4bfbe86..b5adc940 100644 --- a/apps/web/src/lib/i18n/analysis-labels.ts +++ b/apps/web/src/lib/i18n/analysis-labels.ts @@ -67,7 +67,7 @@ export const analysisWorkspaceLabels = { }, reviewReport: { reviewQueue: "Review Queue", - reviewQueueDescription: "Backend-ranked presentation items that still need human decision.", + reviewQueueDescription: "Prioritized insights and impacts requiring your review.", pending: "pending", noPendingItems: "No pending review items.", evidence: "evidence", @@ -92,7 +92,7 @@ export const analysisWorkspaceLabels = { reportMissing: "The report was not generated yet. Try refreshing.", failed: "Failed to finalize analysis", title: "Finalize Impact Analysis", - description: "You are about to finalize this impact analysis. This action will generate an approved Traceability Report in Markdown format.", + description: "Finalizing this analysis will generate a persistent, approved Traceability Report in Markdown format.", totalInsights: "Total Insights", confirmed: "Confirmed", rejected: "Rejected", @@ -122,8 +122,8 @@ export const analysisWorkspaceLabels = { parentAnalysisId: "Parent analysis ID", sourceClarificationId: "Source clarification ID", sourceReviewClarificationRequestId: "Source review clarification request ID", - oldSnapshotCommit: "Old snapshot commit", - newSnapshotCommit: "New snapshot commit", + baselineSnapshotCommit: "Baseline snapshot commit", + newSnapshotCommit: "Current snapshot commit", diffStatus: "Diff status", none: "None", noParentTitle: "No lineage diff for this analysis", @@ -219,7 +219,7 @@ export const analysisWorkspaceLabels = { }, reviewReport: { reviewQueue: "Hàng đợi xem xét", - reviewQueueDescription: "Các mục trình bày do backend xếp hạng vẫn cần quyết định của người dùng.", + reviewQueueDescription: "Các mục ưu tiên do hệ thống đề xuất cần bạn xác nhận.", pending: "đang chờ", noPendingItems: "Không có mục nào đang chờ xem xét.", evidence: "bằng chứng", @@ -244,7 +244,7 @@ export const analysisWorkspaceLabels = { reportMissing: "Báo cáo chưa được tạo. Hãy thử tải lại.", failed: "Không finalize được phân tích", title: "Finalize phân tích tác động", - description: "Bạn sắp finalize phân tích tác động này. Hành động này sẽ tạo Traceability Report đã phê duyệt ở định dạng Markdown.", + description: "Việc finalize phân tích này sẽ tạo ra một báo cáo Traceability chính thức và lưu trữ vĩnh viễn.", totalInsights: "Tổng insight", confirmed: "Đã xác nhận", rejected: "Đã bác bỏ", @@ -274,8 +274,8 @@ export const analysisWorkspaceLabels = { parentAnalysisId: "ID phân tích cha", sourceClarificationId: "ID clarification nguồn", sourceReviewClarificationRequestId: "ID review clarification nguồn", - oldSnapshotCommit: "Commit snapshot cũ", - newSnapshotCommit: "Commit snapshot mới", + baselineSnapshotCommit: "Commit snapshot cơ sở (baseline)", + newSnapshotCommit: "Commit snapshot hiện tại", diffStatus: "Trạng thái diff", none: "Không có", noParentTitle: "Phân tích này không có lineage diff", diff --git a/apps/web/src/lib/i18n/status-labels.ts b/apps/web/src/lib/i18n/status-labels.ts index d19f4e7a..872b0994 100644 --- a/apps/web/src/lib/i18n/status-labels.ts +++ b/apps/web/src/lib/i18n/status-labels.ts @@ -150,3 +150,65 @@ export function getLocalizedLabel( export function formatFallbackLabel(value: string) { return value.replace(/_/g, " ").trim() } + +export type DomainPackStatusType = "STABLE" | "PARTIAL" | "FALLBACK" | "EXPERIMENTAL" | "UNKNOWN" + +export const domainPackStatusLabels = { + en: { + STABLE: "Stable coverage", + PARTIAL: "Partial coverage", + FALLBACK: "Generic fallback", + EXPERIMENTAL: "Experimental coverage", + UNKNOWN: "Unknown capability", + }, + vi: { + STABLE: "Phạm vi ổn định", + PARTIAL: "Phạm vi một phần", + FALLBACK: "Suy luận tổng quát", + EXPERIMENTAL: "Phạm vi thử nghiệm", + UNKNOWN: "Capability chưa rõ", + }, +} as const satisfies LocalizedLabelMap + +export const domainPackStatusTooltips = { + en: { + STABLE: "Covered by tested domain evaluation cases.", + PARTIAL: "Limited tested coverage. Treat domain-specific hints conservatively.", + FALLBACK: "Generic heuristics only. Domain-specific certainty is limited.", + EXPERIMENTAL: "Experimental domain pack. May produce inaccurate results.", + UNKNOWN: "Domain capability is unknown.", + }, + vi: { + STABLE: "Được cover bởi các evaluation case đã test.", + PARTIAL: "Phạm vi test giới hạn. Nên thận trọng với các gợi ý riêng của domain này.", + FALLBACK: "Chỉ dùng generic heuristics. Mức độ chắc chắn về domain thấp.", + EXPERIMENTAL: "Domain thử nghiệm. Có thể chưa ổn định.", + UNKNOWN: "Trạng thái capability chưa rõ.", + }, +} as const satisfies LocalizedLabelMap + +export function getDomainCapabilityBadge({ + domainProfileId, + domainPackStatus, + locale = DEFAULT_ANALYSIS_WORKSPACE_LOCALE, +}: { + domainProfileId?: string | null + domainPackStatus?: string | null + locale?: SupportedLocale +}) { + let resolvedStatus = (domainPackStatus as DomainPackStatusType | undefined | null) || "UNKNOWN" + + if (resolvedStatus === "UNKNOWN" && domainProfileId) { + if (domainProfileId.includes("booking")) resolvedStatus = "STABLE" + else if (domainProfileId.includes("rental")) resolvedStatus = "PARTIAL" + else if (domainProfileId.includes("general")) resolvedStatus = "FALLBACK" + } + + const safeStatus = (domainPackStatusLabels[locale] as Record)[resolvedStatus] ? resolvedStatus : "UNKNOWN" + + return { + status: safeStatus, + label: domainPackStatusLabels[locale][safeStatus], + tooltip: domainPackStatusTooltips[locale][safeStatus], + } +} From bc884f44df09d3d4c7cddb51426139689c6d821c Mon Sep 17 00:00:00 2001 From: Dip Hung Thinh Date: Thu, 25 Jun 2026 11:25:26 +0700 Subject: [PATCH 22/22] chore(config): consolidate local environment loading --- .env.example | 105 ++++++++++++++++++++++++++++++++++++-- apps/api/.env.example | 88 ++------------------------------ apps/api/package.json | 14 ++--- apps/api/src/main.ts | 1 - apps/api/src/smoke-e2e.ts | 1 - apps/web/.env.example | 21 ++------ apps/web/package.json | 6 +-- apps/worker/package.json | 2 +- apps/worker/src/main.ts | 1 - package.json | 16 +++--- pnpm-lock.yaml | 31 +++++++++++ 11 files changed, 159 insertions(+), 127 deletions(-) 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/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/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/worker/package.json b/apps/worker/package.json index 877bf246..00c06910 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -5,7 +5,7 @@ "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 worker\"", "test": "echo \"test worker\"", "typecheck": "echo \"typecheck worker\"" diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts index 2629ec11..731f1482 100644 --- a/apps/worker/src/main.ts +++ b/apps/worker/src/main.ts @@ -1,5 +1,4 @@ import 'reflect-metadata'; -import 'dotenv/config'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; diff --git a/package.json b/package.json index 89d96bf1..d5595bfd 100644 --- a/package.json +++ b/package.json @@ -4,17 +4,17 @@ "version": "0.1.0", "packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485", "scripts": { - "build": "pnpm -r build", - "db:migrate": "pnpm --dir apps/api exec prisma migrate deploy", - "db:seed:demo": "pnpm --dir apps/api exec tsx prisma/seed.demo.ts", + "build": "dotenv -e .env -- pnpm -r build", + "db:migrate": "dotenv -e .env -- pnpm --dir apps/api exec prisma migrate deploy", + "db:seed:demo": "dotenv -e .env -- pnpm --dir apps/api exec tsx prisma/seed.demo.ts", "demo:golden-path": "jest --config jest.config.ts --runInBand tests/demo/golden-path-demo.spec.ts", - "dev:api": "pnpm --dir apps/api dev", - "dev:web": "pnpm --dir apps/web dev", - "dev:worker": "pnpm --dir apps/worker dev", + "dev:api": "dotenv -e .env -- pnpm --dir apps/api dev", + "dev:web": "dotenv -e .env -- pnpm --dir apps/web dev", + "dev:worker": "dotenv -e .env -- pnpm --dir apps/worker dev", "infra:down": "docker compose down", "infra:up": "docker compose up -d postgres redis", "lint": "pnpm -r lint", - "smoke:public-github": "pnpm --dir apps/api smoke:public-github", + "smoke:public-github": "dotenv -e .env -- pnpm --dir apps/api smoke:public-github", "test": "jest --config jest.config.ts --runInBand", "test:ci": "jest --config jest.ci.config.ts --runInBand", "test:analyzer": "jest --config jest.analyzer.config.ts", @@ -37,6 +37,8 @@ "@types/node": "25.9.1", "@types/pg": "^8.20.0", "@types/supertest": "^7.2.0", + "dotenv": "^17.4.2", + "dotenv-cli": "^11.0.0", "eslint": "10.4.0", "jest": "30.4.2", "jest-environment-jsdom": "^30.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60ff86b3..c649b79e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,12 @@ importers: '@types/supertest': specifier: ^7.2.0 version: 7.2.0 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + dotenv-cli: + specifier: ^11.0.0 + version: 11.0.0 eslint: specifier: 10.4.0 version: 10.4.0(jiti@2.7.0) @@ -3565,6 +3571,18 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv-cli@11.0.0: + resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==} + hasBin: true + + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -10144,6 +10162,19 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dotenv-cli@11.0.0: + dependencies: + cross-spawn: 7.0.6 + dotenv: 17.4.2 + dotenv-expand: 12.0.3 + minimist: 1.2.8 + + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + dotenv@17.4.2: {} dunder-proto@1.0.1: