From 8235c012f237bc20b22d8a5aef31c8534b7bd582 Mon Sep 17 00:00:00 2001
From: Dip Hung Thinh
Date: Thu, 25 Jun 2026 13:10:05 +0700
Subject: [PATCH 01/35] chore(audit): fix env smoke scripts and proof pack
status
---
.github/workflows/ci.yml | 9 ++++++++-
README.md | 5 ++---
apps/api/package.json | 4 ++--
docs/demo/visual-proof-pack.md | 8 ++++----
4 files changed, 16 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3bcca15a..5146deca 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,8 +2,15 @@ name: ci
"on":
push:
- branches: ["main"]
+ branches:
+ - "**"
pull_request:
+ branches:
+ - main
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
jobs:
quality-and-tests:
diff --git a/README.md b/README.md
index 1f659968..be9c269a 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ pnpm demo:golden-path
```
**Visual Case Study:**
-For a step-by-step visual walkthrough of this workflow, see the [Demo Case Study](docs/portfolio/case-study.md), which features an 8-screen proof pack demonstrating the full end-to-end audit and lifecycle process.
+For a step-by-step visual walkthrough of this workflow, see the [Demo Case Study](docs/portfolio/case-study.md), which features a partial visual proof pack demonstrating key milestones of the audit and lifecycle process.
**Sample Requirement:**
> "When a paid booking is cancelled, the system must refund the tenant, prevent double refunds, update booking/payment state, and notify relevant parties."
@@ -112,8 +112,7 @@ pnpm install
Create the environment files from their examples. The examples contain safe, pre-configured local placeholders (including a fake AI provider).
```bash
-cp apps/api/.env.example apps/api/.env
-cp apps/web/.env.example apps/web/.env.local
+cp .env.example .env
```
For containerized web runtime, keep two URLs straight:
diff --git a/apps/api/package.json b/apps/api/package.json
index 5fd002ea..20f968cc 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -8,8 +8,8 @@
"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": "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",
+ "smoke:public-github:real-llm": "dotenv -e ../../.env -v REAL_LLM_SMOKE=true -- tsx src/smoke-e2e.ts",
+ "smoke:public-github:real-path": "dotenv -e ../../.env -v REAL_PATH_SMOKE=true -- tsx src/smoke-e2e.ts",
"test": "jest",
"typecheck": "tsc --noEmit",
"prisma:generate": "dotenv -e ../../.env -- prisma generate",
diff --git a/docs/demo/visual-proof-pack.md b/docs/demo/visual-proof-pack.md
index b01e8ad6..45a6dd40 100644
--- a/docs/demo/visual-proof-pack.md
+++ b/docs/demo/visual-proof-pack.md
@@ -1,4 +1,4 @@
-# Visual Demo Proof Pack
+# Visual Demo Proof Pack [PARTIAL]
This document contains text-based and Mermaid visual artifacts to help reviewers understand the Requirement-to-Code Impact Analyzer in under 60 seconds without relying on external image hosting.
@@ -68,13 +68,13 @@ The following visual assets demonstrate the finalized UI flows and capabilities:

- **evidence appendix/report**: Detailed view of specific code lines cited by the LLM.
- 
+ *[PENDING: 03-evidence-backed-artifacts.png]*
- **unknown/risk diagnostics**: View showing unknown components properly isolated.
- 
+ *[PENDING: 04-unknown-risk-diagnostics.png]*
- **human review panel**: Reviewer gate for confirming/rejecting insights.
- 
+ *[PENDING: 05-human-review-panel.png]*
- **traceability report preview**: Approved markdown report.

From 8f99a85e9f60636c7764300cdd7f72e1e0d1d4d4 Mon Sep 17 00:00:00 2001
From: Dip Hung Thinh
Date: Thu, 25 Jun 2026 14:54:19 +0700
Subject: [PATCH 02/35] chore: harden post-mvp runtime safety and quality gates
---
.eslintrc.cjs | 7 -
apps/api/package.json | 2 +-
apps/api/src/bootstrap/runtime-config.spec.ts | 48 +++++
apps/api/src/bootstrap/runtime-config.ts | 14 ++
.../ai/infrastructure/anthropic.provider.ts | 7 +-
.../ai/infrastructure/openai.provider.ts | 7 +-
.../src/modules/auth/api/auth.controller.ts | 6 +-
apps/web/src/lib/auth-options.ts | 4 +-
apps/web/src/lib/auth-secret.ts | 15 ++
apps/web/src/lib/runtime-config.spec.ts | 9 +-
apps/web/src/lib/runtime-config.ts | 15 +-
apps/web/src/proxy.ts | 7 +-
apps/worker/package.json | 6 +-
apps/worker/src/main.ts | 5 +-
eslint.config.mjs | 40 ++++
package.json | 4 +
packages/analyzer/package.json | 6 +-
packages/contracts/package.json | 6 +-
packages/shared/package.json | 6 +-
pnpm-lock.yaml | 193 ++++++++++++++++++
20 files changed, 363 insertions(+), 44 deletions(-)
delete mode 100644 .eslintrc.cjs
create mode 100644 apps/web/src/lib/auth-secret.ts
create mode 100644 eslint.config.mjs
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
deleted file mode 100644
index 0f0fa945..00000000
--- a/.eslintrc.cjs
+++ /dev/null
@@ -1,7 +0,0 @@
-module.exports = {
- root: true,
- env: { node: true, es2020: true },
- parserOptions: { ecmaVersion: 2020, sourceType: "module" },
- ignorePatterns: ["**/dist/**", "**/build/**", "node_modules"],
- extends: ["eslint:recommended"],
-};
diff --git a/apps/api/package.json b/apps/api/package.json
index 20f968cc..c937ee39 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -6,7 +6,7 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "dotenv -e ../../.env -- pnpm exec ts-node -r tsconfig-paths/register --project tsconfig.json src/main.ts",
- "lint": "echo \"lint api\"",
+ "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"smoke:public-github": "dotenv -e ../../.env -- tsx src/smoke-e2e.ts",
"smoke:public-github:real-llm": "dotenv -e ../../.env -v REAL_LLM_SMOKE=true -- tsx src/smoke-e2e.ts",
"smoke:public-github:real-path": "dotenv -e ../../.env -v REAL_PATH_SMOKE=true -- tsx src/smoke-e2e.ts",
diff --git a/apps/api/src/bootstrap/runtime-config.spec.ts b/apps/api/src/bootstrap/runtime-config.spec.ts
index 02dfbba2..d8e6f482 100644
--- a/apps/api/src/bootstrap/runtime-config.spec.ts
+++ b/apps/api/src/bootstrap/runtime-config.spec.ts
@@ -42,5 +42,53 @@ describe('runtime-config', () => {
expect(() => validateRuntimeConfig(config)).not.toThrow();
expect(config.corsAllowedOrigins).toEqual(['https://web.example.com']);
});
+
+ describe('Dev Login Policy', () => {
+ it('throws if ENABLE_DEV_LOGIN is true in production', () => {
+ const config = getRuntimeConfig({
+ NODE_ENV: 'production',
+ ENABLE_DEV_LOGIN: 'true',
+ CORS_ALLOWED_ORIGINS: 'https://web.example.com',
+ });
+ expect(() => validateRuntimeConfig(config)).toThrow('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in production/staging environments.');
+ });
+
+ it('throws if ENABLE_DEV_LOGIN is true in staging', () => {
+ const config = getRuntimeConfig({
+ NODE_ENV: 'staging',
+ ENABLE_DEV_LOGIN: 'true',
+ CORS_ALLOWED_ORIGINS: 'https://web.example.com',
+ });
+ expect(() => validateRuntimeConfig(config)).toThrow('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in production/staging environments.');
+ });
+
+ it('throws if ENABLE_DEV_LOGIN is true in public preview', () => {
+ const config = getRuntimeConfig({
+ NODE_ENV: 'development',
+ ENABLE_DEV_LOGIN: 'true',
+ PUBLIC_PREVIEW_MODE: 'true',
+ });
+ expect(() => validateRuntimeConfig(config)).toThrow('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in PUBLIC_PREVIEW_MODE.');
+ });
+
+ it('throws if ENABLE_DEV_LOGIN is true in team workspace mode', () => {
+ const config = getRuntimeConfig({
+ NODE_ENV: 'development',
+ ENABLE_DEV_LOGIN: 'true',
+ WORKSPACE_MODE: 'team-dev',
+ });
+ expect(() => validateRuntimeConfig(config)).toThrow("BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden when workspace mode is 'team-dev'.");
+ });
+
+ it('allows dev-login in development with dev-single-user mode', () => {
+ const config = getRuntimeConfig({
+ NODE_ENV: 'development',
+ ENABLE_DEV_LOGIN: 'true',
+ WORKSPACE_MODE: 'dev-single-user',
+ });
+ expect(() => validateRuntimeConfig(config)).not.toThrow();
+ expect(config.enableDevLogin).toBe(true);
+ });
+ });
});
diff --git a/apps/api/src/bootstrap/runtime-config.ts b/apps/api/src/bootstrap/runtime-config.ts
index 0675e02b..2fe09e8e 100644
--- a/apps/api/src/bootstrap/runtime-config.ts
+++ b/apps/api/src/bootstrap/runtime-config.ts
@@ -18,6 +18,7 @@ export interface RuntimeConfig {
workspaceMode: string;
publicPreviewMode: boolean;
aiProvider: string;
+ enableDevLogin: boolean;
}
export function isProductionLikeEnv(nodeEnv?: string): boolean {
@@ -121,6 +122,7 @@ export function getRuntimeConfig(
workspaceMode: env.WORKSPACE_MODE ?? DEFAULT_WORKSPACE_MODE,
publicPreviewMode: env.PUBLIC_PREVIEW_MODE === 'true',
aiProvider: env.AI_PROVIDER || 'fake',
+ enableDevLogin: env.ENABLE_DEV_LOGIN === 'true',
};
}
@@ -144,5 +146,17 @@ export function validateRuntimeConfig(config: RuntimeConfig, env: NodeJS.Process
if (env.ANTHROPIC_API_KEY) throw new Error('BOOT GUARD: ANTHROPIC_API_KEY is forbidden in PUBLIC_PREVIEW_MODE.');
if (env.DEEPSEEK_API_KEY) throw new Error('BOOT GUARD: DEEPSEEK_API_KEY is forbidden in PUBLIC_PREVIEW_MODE.');
}
+
+ if (config.enableDevLogin) {
+ if (config.isProductionLike) {
+ throw new Error('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in production/staging environments.');
+ }
+ if (config.publicPreviewMode) {
+ throw new Error('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in PUBLIC_PREVIEW_MODE.');
+ }
+ if (config.workspaceMode !== DEFAULT_WORKSPACE_MODE) {
+ throw new Error(`BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden when workspace mode is '${config.workspaceMode}'.`);
+ }
+ }
}
diff --git a/apps/api/src/modules/ai/infrastructure/anthropic.provider.ts b/apps/api/src/modules/ai/infrastructure/anthropic.provider.ts
index 97943cd8..9edda2c1 100644
--- a/apps/api/src/modules/ai/infrastructure/anthropic.provider.ts
+++ b/apps/api/src/modules/ai/infrastructure/anthropic.provider.ts
@@ -5,6 +5,7 @@ import { z } from 'zod';
import { LlmProvider, LlmRequest, LlmResult } from '../domain/llm-provider.interface';
import { AiConfig, AI_CONFIG_TOKEN } from '../domain/ai-config';
import { parseStructuredLlmOutput } from './structured-output';
+import { AiPolicy } from '../domain/ai.policy';
@Injectable()
export class AnthropicLlmProvider extends LlmProvider {
@@ -23,13 +24,17 @@ export class AnthropicLlmProvider extends LlmProvider {
const model = request.options?.model ?? this.config.defaultModel;
const start = Date.now();
+ const safeUserPrompt = this.config.redactSecrets
+ ? AiPolicy.redactPayload(request.userPrompt).redactedPayload
+ : request.userPrompt;
+
let response;
try {
response = await this.client.messages.create({
model,
max_tokens: request.options?.maxTokens ?? this.config.maxTokens,
system: request.systemPrompt,
- messages: [{ role: 'user', content: request.userPrompt }],
+ messages: [{ role: 'user', content: safeUserPrompt }],
});
} catch (error: any) {
const msg = error?.message?.toLowerCase() || '';
diff --git a/apps/api/src/modules/ai/infrastructure/openai.provider.ts b/apps/api/src/modules/ai/infrastructure/openai.provider.ts
index b2bf9162..5a8c820e 100644
--- a/apps/api/src/modules/ai/infrastructure/openai.provider.ts
+++ b/apps/api/src/modules/ai/infrastructure/openai.provider.ts
@@ -5,6 +5,7 @@ import { z } from 'zod';
import { LlmProvider, LlmRequest, LlmResult } from '../domain/llm-provider.interface';
import { AiConfig, AI_CONFIG_TOKEN } from '../domain/ai-config';
import { parseStructuredLlmOutput } from './structured-output';
+import { AiPolicy } from '../domain/ai.policy';
@Injectable()
export class OpenAiLlmProvider extends LlmProvider {
@@ -23,6 +24,10 @@ export class OpenAiLlmProvider extends LlmProvider {
const model = request.options?.model ?? this.config.defaultModel;
const start = Date.now();
+ const safeUserPrompt = this.config.redactSecrets
+ ? AiPolicy.redactPayload(request.userPrompt).redactedPayload
+ : request.userPrompt;
+
let response;
try {
response = await this.client.chat.completions.create({
@@ -32,7 +37,7 @@ export class OpenAiLlmProvider extends LlmProvider {
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: request.systemPrompt },
- { role: 'user', content: request.userPrompt },
+ { role: 'user', content: safeUserPrompt },
],
});
} catch (error: any) {
diff --git a/apps/api/src/modules/auth/api/auth.controller.ts b/apps/api/src/modules/auth/api/auth.controller.ts
index 953214bc..e01cca04 100644
--- a/apps/api/src/modules/auth/api/auth.controller.ts
+++ b/apps/api/src/modules/auth/api/auth.controller.ts
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../../prisma/prisma.service';
import { Public } from '../application/jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
+import { getRuntimeConfig } from '../../../bootstrap/runtime-config';
import { loginRequestSchema, RequestUser, type LoginRequest, type LoginResponse } from '@ba-helper/contracts';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
@@ -19,8 +20,9 @@ export class AuthController {
@ApiOperation({ summary: 'Login or create a dev user (MVP only)' })
@ApiResponse({ status: 200, description: 'Returns JWT and user profile' })
async devLogin(@Body() body: unknown): Promise {
- if (process.env.ENABLE_DEV_LOGIN !== 'true') {
- throw new ForbiddenException('Dev login is disabled');
+ const config = getRuntimeConfig();
+ if (!config.enableDevLogin) {
+ throw new ForbiddenException('Dev login is disabled by runtime policy. Set ENABLE_DEV_LOGIN=true and ensure mode allows it.');
}
const parsed = loginRequestSchema.safeParse(body);
diff --git a/apps/web/src/lib/auth-options.ts b/apps/web/src/lib/auth-options.ts
index 31aed1ac..26541040 100644
--- a/apps/web/src/lib/auth-options.ts
+++ b/apps/web/src/lib/auth-options.ts
@@ -9,7 +9,7 @@ import {
import { ApiError } from "@/lib/api-error"
import { normalizeAuthErrorCode } from "@/lib/auth-errors"
import { getApiBaseUrl } from "@/lib/runtime-config"
-
+import { resolveNextAuthSecret } from "@/lib/auth-secret"
type AuthorizedUser = {
id: string
name: string | null
@@ -146,5 +146,5 @@ export const authOptions: AuthOptions = {
strategy: "jwt",
maxAge: 24 * 60 * 60,
},
- secret: process.env.NEXTAUTH_SECRET || "dev-super-secret-key-nextauth",
+ secret: resolveNextAuthSecret(),
}
diff --git a/apps/web/src/lib/auth-secret.ts b/apps/web/src/lib/auth-secret.ts
new file mode 100644
index 00000000..903f1fc1
--- /dev/null
+++ b/apps/web/src/lib/auth-secret.ts
@@ -0,0 +1,15 @@
+export function resolveNextAuthSecret(env = process.env): string {
+ const secret = env.NEXTAUTH_SECRET?.trim()
+ const productionLike =
+ env.NODE_ENV === "production" ||
+ (env.NODE_ENV as string) === "staging" ||
+ env.PREVIEW_AUTH_ENABLED === "true"
+
+ if (!secret && productionLike) {
+ throw new Error(
+ "NEXTAUTH_SECRET is required for production, staging, or preview-auth deployments.",
+ )
+ }
+
+ return secret || "dev-super-secret-key-nextauth"
+}
diff --git a/apps/web/src/lib/runtime-config.spec.ts b/apps/web/src/lib/runtime-config.spec.ts
index 2a2a1b3e..69f8e4f3 100644
--- a/apps/web/src/lib/runtime-config.spec.ts
+++ b/apps/web/src/lib/runtime-config.spec.ts
@@ -2,8 +2,11 @@ import { ApiError } from "./api-error"
import { getApiBaseUrl } from "./runtime-config"
describe("runtime-config", () => {
- it("uses localhost fallback in non-production env when API URL is missing", () => {
- expect(getApiBaseUrl({ nodeEnv: "development" })).toBe("http://localhost:3000")
+ it("throws API_URL_MISSING when API URL is missing", () => {
+ expect(() => getApiBaseUrl({ nodeEnv: "development" })).toThrow(ApiError)
+ expect(() => getApiBaseUrl({ nodeEnv: "development" })).toThrow(
+ "API URL is missing. Set INTERNAL_API_URL for server-side calls or NEXT_PUBLIC_API_URL for browser-visible calls.",
+ )
})
it("prefers INTERNAL_API_URL when provided", () => {
@@ -19,7 +22,7 @@ describe("runtime-config", () => {
it("requires explicit API URL in production", () => {
expect(() => getApiBaseUrl({ nodeEnv: "production" })).toThrow(ApiError)
expect(() => getApiBaseUrl({ nodeEnv: "production" })).toThrow(
- "NEXT_PUBLIC_API_URL is required",
+ "API URL is missing. Set INTERNAL_API_URL for server-side calls or NEXT_PUBLIC_API_URL for browser-visible calls.",
)
})
diff --git a/apps/web/src/lib/runtime-config.ts b/apps/web/src/lib/runtime-config.ts
index 594376a3..93273128 100644
--- a/apps/web/src/lib/runtime-config.ts
+++ b/apps/web/src/lib/runtime-config.ts
@@ -1,6 +1,5 @@
import { ApiError } from "./api-error"
-const DEFAULT_DEV_API_URL = "http://localhost:3000"
interface RuntimeEnv {
apiUrl?: string
@@ -47,13 +46,9 @@ export function getApiBaseUrl(env: RuntimeEnv = {
return validateApiUrl(env.apiUrl.trim())
}
- if (env.nodeEnv === "production") {
- throw new ApiError({
- status: 500,
- code: "API_URL_MISSING",
- message: "NEXT_PUBLIC_API_URL is required for production deployments.",
- })
- }
-
- return DEFAULT_DEV_API_URL
+ throw new ApiError({
+ status: 500,
+ code: "API_URL_MISSING",
+ message: "API URL is missing. Set INTERNAL_API_URL for server-side calls or NEXT_PUBLIC_API_URL for browser-visible calls.",
+ })
}
diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts
index eac9ee91..8f785f15 100644
--- a/apps/web/src/proxy.ts
+++ b/apps/web/src/proxy.ts
@@ -2,8 +2,7 @@ import { NextResponse, type NextRequest } from "next/server"
import { getToken } from "next-auth/jwt"
import { buildLoginRedirect, getSafeNext, isProtectedAppPath, isPublicWebPath } from "@/lib/auth-routing"
-const DEFAULT_NEXTAUTH_SECRET = "dev-super-secret-key-nextauth"
-
+import { resolveNextAuthSecret } from "@/lib/auth-secret"
export async function proxy(request: NextRequest) {
// --- PREVIEW BASIC AUTH GUARD ---
if (process.env.PREVIEW_AUTH_ENABLED === 'true') {
@@ -44,7 +43,7 @@ export async function proxy(request: NextRequest) {
if (pathname === "/login") {
const token = await getToken({
req: request,
- secret: process.env.NEXTAUTH_SECRET || DEFAULT_NEXTAUTH_SECRET,
+ secret: resolveNextAuthSecret(),
})
if (token) {
@@ -62,7 +61,7 @@ export async function proxy(request: NextRequest) {
const token = await getToken({
req: request,
- secret: process.env.NEXTAUTH_SECRET || DEFAULT_NEXTAUTH_SECRET,
+ secret: resolveNextAuthSecret(),
})
if (!token) {
diff --git a/apps/worker/package.json b/apps/worker/package.json
index 00c06910..35af74d7 100644
--- a/apps/worker/package.json
+++ b/apps/worker/package.json
@@ -6,9 +6,9 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"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\""
+ "lint": "eslint \"src/**/*.ts\"",
+ "test": "jest --passWithNoTests",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.99.0",
diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts
index 731f1482..549dca14 100644
--- a/apps/worker/src/main.ts
+++ b/apps/worker/src/main.ts
@@ -7,4 +7,7 @@ const bootstrap = async () => {
await app.init();
};
-bootstrap();
+bootstrap().catch((err) => {
+ console.error('Worker failed to start', err);
+ process.exit(1);
+});
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 00000000..88bcf7ec
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,40 @@
+import eslint from '@eslint/js';
+import tseslint from 'typescript-eslint';
+
+export default tseslint.config(
+ eslint.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ languageOptions: {
+ parserOptions: {
+ project: [
+ './tsconfig.base.json',
+ './apps/*/tsconfig.json',
+ './packages/*/tsconfig.json',
+ './tests/tsconfig.json'
+ ],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ rules: {
+ '@typescript-eslint/no-floating-promises': 'error',
+ '@typescript-eslint/no-misused-promises': 'error',
+ '@typescript-eslint/consistent-type-imports': 'warn',
+ 'no-console': ['warn', { allow: ['warn', 'error'] }],
+ '@typescript-eslint/no-explicit-any': 'off',
+ '@typescript-eslint/no-unused-vars': 'off',
+ 'no-unused-vars': 'off',
+ '@typescript-eslint/no-require-imports': 'off',
+ '@typescript-eslint/ban-ts-comment': 'off',
+ 'no-useless-escape': 'off',
+ 'prefer-const': 'off',
+ 'no-useless-assignment': 'off',
+ '@typescript-eslint/no-empty-object-type': 'off',
+ 'no-empty': 'off',
+ 'preserve-caught-error': 'off'
+ },
+ },
+ {
+ ignores: ['**/dist/**', '**/build/**', 'node_modules/**', '.next/**'],
+ }
+);
diff --git a/package.json b/package.json
index d5595bfd..859df444 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
+ "@eslint/js": "^10.0.1",
"@jest/globals": "^30.4.1",
"@nestjs/testing": "^11.1.24",
"@prisma/client": "7.8.0",
@@ -37,6 +38,8 @@
"@types/node": "25.9.1",
"@types/pg": "^8.20.0",
"@types/supertest": "^7.2.0",
+ "@typescript-eslint/eslint-plugin": "^8.62.0",
+ "@typescript-eslint/parser": "^8.62.0",
"dotenv": "^17.4.2",
"dotenv-cli": "^11.0.0",
"eslint": "10.4.0",
@@ -48,6 +51,7 @@
"ts-node": "10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "5.4.5",
+ "typescript-eslint": "^8.62.0",
"undici": "^8.5.0",
"whatwg-fetch": "^3.6.20",
"zod": "3.23.8"
diff --git a/packages/analyzer/package.json b/packages/analyzer/package.json
index 94245327..cbd2caf8 100644
--- a/packages/analyzer/package.json
+++ b/packages/analyzer/package.json
@@ -6,9 +6,9 @@
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
- "lint": "echo \"lint analyzer\"",
- "test": "echo \"test analyzer\"",
- "typecheck": "echo \"typecheck analyzer\""
+ "lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\"",
+ "test": "jest --config ../../jest.analyzer.config.ts",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
"simple-git": "3.36.0",
diff --git a/packages/contracts/package.json b/packages/contracts/package.json
index 4dcf0a11..a8b3469b 100644
--- a/packages/contracts/package.json
+++ b/packages/contracts/package.json
@@ -6,9 +6,9 @@
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
- "lint": "echo \"lint contracts\"",
- "test": "echo \"test contracts\"",
- "typecheck": "echo \"typecheck contracts\""
+ "lint": "eslint \"src/**/*.ts\"",
+ "test": "jest --passWithNoTests",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "3.23.8"
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 653e99e6..157b33e0 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -6,8 +6,8 @@
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
- "lint": "echo \"lint shared\"",
- "test": "echo \"test shared\"",
- "typecheck": "echo \"typecheck shared\""
+ "lint": "eslint \"src/**/*.ts\"",
+ "test": "jest --passWithNoTests",
+ "typecheck": "tsc --noEmit"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c649b79e..dbafe877 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -18,6 +18,9 @@ importers:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
devDependencies:
+ '@eslint/js':
+ specifier: ^10.0.1
+ version: 10.0.1(eslint@10.4.0(jiti@2.7.0))
'@jest/globals':
specifier: ^30.4.1
version: 30.4.1
@@ -54,6 +57,12 @@ importers:
'@types/supertest':
specifier: ^7.2.0
version: 7.2.0
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^8.62.0
+ version: 8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5))(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)
+ '@typescript-eslint/parser':
+ specifier: ^8.62.0
+ version: 8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)
dotenv:
specifier: ^17.4.2
version: 17.4.2
@@ -87,6 +96,9 @@ importers:
typescript:
specifier: 5.4.5
version: 5.4.5
+ typescript-eslint:
+ specifier: ^8.62.0
+ version: 8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)
undici:
specifier: ^8.5.0
version: 8.5.0
@@ -920,6 +932,15 @@ packages:
resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/js@10.0.1':
+ resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ peerDependencies:
+ eslint: ^10.0.0
+ peerDependenciesMeta:
+ eslint:
+ optional: true
+
'@eslint/js@9.39.4':
resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2477,6 +2498,14 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
+ '@typescript-eslint/eslint-plugin@8.62.0':
+ resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.62.0
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/parser@8.60.0':
resolution: {integrity: sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2484,22 +2513,45 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
+ '@typescript-eslint/parser@8.62.0':
+ resolution: {integrity: sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/project-service@8.60.0':
resolution: {integrity: sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
+ '@typescript-eslint/project-service@8.62.0':
+ resolution: {integrity: sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/scope-manager@8.60.0':
resolution: {integrity: sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/scope-manager@8.62.0':
+ resolution: {integrity: sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/tsconfig-utils@8.60.0':
resolution: {integrity: sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
+ '@typescript-eslint/tsconfig-utils@8.62.0':
+ resolution: {integrity: sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/type-utils@8.60.0':
resolution: {integrity: sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2507,16 +2559,33 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
+ '@typescript-eslint/type-utils@8.62.0':
+ resolution: {integrity: sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/types@8.60.0':
resolution: {integrity: sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/types@8.62.0':
+ resolution: {integrity: sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/typescript-estree@8.60.0':
resolution: {integrity: sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
+ '@typescript-eslint/typescript-estree@8.62.0':
+ resolution: {integrity: sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/utils@8.60.0':
resolution: {integrity: sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2524,10 +2593,21 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
+ '@typescript-eslint/utils@8.62.0':
+ resolution: {integrity: sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/visitor-keys@8.60.0':
resolution: {integrity: sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/visitor-keys@8.62.0':
+ resolution: {integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@ungap/structured-clone@1.3.1':
resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
@@ -6624,6 +6704,13 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
+ typescript-eslint@8.62.0:
+ resolution: {integrity: sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
typescript@5.4.5:
resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
engines: {node: '>=14.17'}
@@ -7509,6 +7596,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@eslint/js@10.0.1(eslint@10.4.0(jiti@2.7.0))':
+ optionalDependencies:
+ eslint: 10.4.0(jiti@2.7.0)
+
'@eslint/js@9.39.4': {}
'@eslint/object-schema@2.1.7': {}
@@ -9079,6 +9170,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5))(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)
+ '@typescript-eslint/scope-manager': 8.62.0
+ '@typescript-eslint/type-utils': 8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)
+ '@typescript-eslint/utils': 8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)
+ '@typescript-eslint/visitor-keys': 8.62.0
+ eslint: 10.4.0(jiti@2.7.0)
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.5.0(typescript@5.4.5)
+ typescript: 5.4.5
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.4.5)':
dependencies:
'@typescript-eslint/scope-manager': 8.60.0
@@ -9091,6 +9198,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/parser@8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.62.0
+ '@typescript-eslint/types': 8.62.0
+ '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.4.5)
+ '@typescript-eslint/visitor-keys': 8.62.0
+ debug: 4.4.3
+ eslint: 10.4.0(jiti@2.7.0)
+ typescript: 5.4.5
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/project-service@8.60.0(typescript@5.4.5)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.60.0(typescript@5.4.5)
@@ -9100,15 +9219,33 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/project-service@8.62.0(typescript@5.4.5)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.4.5)
+ '@typescript-eslint/types': 8.62.0
+ debug: 4.4.3
+ typescript: 5.4.5
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/scope-manager@8.60.0':
dependencies:
'@typescript-eslint/types': 8.60.0
'@typescript-eslint/visitor-keys': 8.60.0
+ '@typescript-eslint/scope-manager@8.62.0':
+ dependencies:
+ '@typescript-eslint/types': 8.62.0
+ '@typescript-eslint/visitor-keys': 8.62.0
+
'@typescript-eslint/tsconfig-utils@8.60.0(typescript@5.4.5)':
dependencies:
typescript: 5.4.5
+ '@typescript-eslint/tsconfig-utils@8.62.0(typescript@5.4.5)':
+ dependencies:
+ typescript: 5.4.5
+
'@typescript-eslint/type-utils@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.4.5)':
dependencies:
'@typescript-eslint/types': 8.60.0
@@ -9121,8 +9258,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/type-utils@8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)':
+ dependencies:
+ '@typescript-eslint/types': 8.62.0
+ '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.4.5)
+ '@typescript-eslint/utils': 8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)
+ debug: 4.4.3
+ eslint: 10.4.0(jiti@2.7.0)
+ ts-api-utils: 2.5.0(typescript@5.4.5)
+ typescript: 5.4.5
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/types@8.60.0': {}
+ '@typescript-eslint/types@8.62.0': {}
+
'@typescript-eslint/typescript-estree@8.60.0(typescript@5.4.5)':
dependencies:
'@typescript-eslint/project-service': 8.60.0(typescript@5.4.5)
@@ -9138,6 +9289,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/typescript-estree@8.62.0(typescript@5.4.5)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.62.0(typescript@5.4.5)
+ '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.4.5)
+ '@typescript-eslint/types': 8.62.0
+ '@typescript-eslint/visitor-keys': 8.62.0
+ debug: 4.4.3
+ minimatch: 10.2.5
+ semver: 7.8.1
+ tinyglobby: 0.2.16
+ ts-api-utils: 2.5.0(typescript@5.4.5)
+ typescript: 5.4.5
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/utils@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.4.5)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
@@ -9149,11 +9315,27 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/utils@8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0))
+ '@typescript-eslint/scope-manager': 8.62.0
+ '@typescript-eslint/types': 8.62.0
+ '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.4.5)
+ eslint: 10.4.0(jiti@2.7.0)
+ typescript: 5.4.5
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/visitor-keys@8.60.0':
dependencies:
'@typescript-eslint/types': 8.60.0
eslint-visitor-keys: 5.0.1
+ '@typescript-eslint/visitor-keys@8.62.0':
+ dependencies:
+ '@typescript-eslint/types': 8.62.0
+ eslint-visitor-keys: 5.0.1
+
'@ungap/structured-clone@1.3.1': {}
'@unrs/resolver-binding-android-arm-eabi@1.12.2':
@@ -14060,6 +14242,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ typescript-eslint@8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5))(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)
+ '@typescript-eslint/parser': 8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)
+ '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.4.5)
+ '@typescript-eslint/utils': 8.62.0(eslint@10.4.0(jiti@2.7.0))(typescript@5.4.5)
+ eslint: 10.4.0(jiti@2.7.0)
+ typescript: 5.4.5
+ transitivePeerDependencies:
+ - supports-color
+
typescript@5.4.5: {}
uglify-js@3.19.3:
From 3c4e5ccbd391c9bebeb235aa275af325370942b1 Mon Sep 17 00:00:00 2001
From: Dip Hung Thinh
Date: Thu, 25 Jun 2026 16:31:50 +0700
Subject: [PATCH 03/35] refactor: extract shared runtime and policy utilities
---
apps/api/src/bootstrap/runtime-config.ts | 173 ++----------------
apps/api/src/modules/ai/ai.module.spec.ts | 2 +-
apps/api/src/modules/ai/ai.module.ts | 40 +---
.../src/modules/ai/application/ai.service.ts | 2 +-
apps/api/src/modules/ai/domain/ai-config.ts | 9 +-
.../ai/infrastructure/anthropic.provider.ts | 6 +-
.../ai/infrastructure/deepseek.provider.ts | 8 +-
.../google-provider-env.spec.ts | 23 ---
.../ai/infrastructure/google-provider-env.ts | 18 --
.../ai/infrastructure/google.provider.ts | 7 +-
.../ai/infrastructure/openai.provider.ts | 6 +-
.../application/list-artifacts.usecase.ts | 2 +-
.../artifact/domain/artifact.policy.ts | 2 +-
.../answer-clarification.usecase.ts | 2 +-
.../application/clarification.spec.ts | 2 +-
...nvert-clarification-to-revision.usecase.ts | 2 +-
.../dismiss-clarification.usecase.ts | 2 +-
.../ensure-clarification.usecase.ts | 2 +-
.../commands/enqueue-document-job.usecase.ts | 2 +-
.../export-approved-report.usecase.spec.ts | 2 +-
.../export-approved-report.usecase.ts | 2 +-
.../get-approved-report.usecase.ts | 2 +-
.../jobs/run-document-job.usecase.ts | 2 +-
.../application/pdf-export.renderer.spec.ts | 2 +-
.../application/pdf-export.renderer.ts | 2 +-
.../get-final-reviewed-report.usecase.spec.ts | 2 +-
.../get-final-reviewed-report.usecase.ts | 2 +-
...latest-reviewed-report-snapshot.usecase.ts | 2 +-
.../application/domain-pack.registry.spec.ts | 2 +-
.../application/domain-pack.registry.ts | 2 +-
.../embed-snapshot-artifacts.usecase.ts | 4 +-
.../embedding/domain/embedding.policy.ts | 2 +-
.../embedding/embedding.module.spec.ts | 2 +-
.../src/modules/embedding/embedding.module.ts | 23 +--
.../google-embedding.provider.ts | 7 +-
.../openai-embedding.provider.ts | 6 +-
.../event-log/domain/event-log.policy.spec.ts | 2 +-
.../event-log/domain/event-log.policy.ts | 2 +-
.../application/list-evidence.usecase.ts | 2 +-
.../evidence/domain/evidence.policy.ts | 2 +-
.../graph/application/get-graph.usecase.ts | 2 +-
.../src/modules/graph/domain/graph.policy.ts | 2 +-
...ved-analysis-from-clarification.usecase.ts | 2 +-
.../create-impact-analysis.usecase.ts | 2 +-
.../finalize-impact-analysis.usecase.ts | 2 +-
.../lifecycle/get-impact-analysis.usecase.ts | 2 +-
.../lifecycle/list-impact-analyses.usecase.ts | 2 +-
.../run-impact-analysis.usecase.spec.ts | 2 +-
.../lifecycle/run-impact-analysis.usecase.ts | 2 +-
...lti-repo-report-review-decision.usecase.ts | 2 +-
...eate-multi-repo-impact-analyses.usecase.ts | 2 +-
...approved-multi-repo-report.usecase.spec.ts | 2 +-
...port-approved-multi-repo-report.usecase.ts | 2 +-
.../finalize-multi-repo-report.usecase.ts | 2 +-
.../get-approved-multi-repo-report.usecase.ts | 2 +-
...lti-repo-report-review-decision.usecase.ts | 2 +-
...-merged-multi-repo-report-draft.usecase.ts | 2 +-
.../get-multi-repo-analysis-run.usecase.ts | 2 +-
...ti-repo-report-review-decisions.usecase.ts | 2 +-
.../application/qa/get-qa-coverage.usecase.ts | 2 +-
.../queries/get-analysis-workspace.usecase.ts | 2 +-
...et-impact-analysis-lineage.usecase.spec.ts | 2 +-
.../get-impact-analysis-lineage.usecase.ts | 2 +-
.../queries/get-impact-diff.usecase.ts | 2 +-
.../queries/get-impact-graph.usecase.spec.ts | 2 +-
.../impact-graph-read-model.builder.ts | 2 +-
.../answer-review-clarification.usecase.ts | 2 +-
...e-analysis-review-decision.usecase.spec.ts | 2 +-
...create-analysis-review-decision.usecase.ts | 2 +-
...reate-review-clarification.usecase.spec.ts | 2 +-
.../create-review-clarification.usecase.ts | 2 +-
.../get-latest-review-decision.usecase.ts | 2 +-
.../review/get-review-notes.usecase.ts | 2 +-
.../review/get-review-queue.usecase.ts | 2 +-
.../list-review-clarifications.usecase.ts | 2 +-
.../review/list-review-decisions.usecase.ts | 2 +-
.../review/save-review-note.usecase.spec.ts | 2 +-
.../review/save-review-note.usecase.ts | 2 +-
.../review-insight.usecase.spec.ts | 2 +-
.../application/review-insight.usecase.ts | 2 +-
.../modules/insight/domain/insight.policy.ts | 2 +-
.../application/create-project.usecase.ts | 2 +-
.../get-current-workspace.usecase.ts | 2 +-
.../remove-project-member.usecase.ts | 2 +-
.../application/select-project.usecase.ts | 2 +-
.../update-project-member.usecase.ts | 2 +-
.../upsert-project-member.usecase.ts | 2 +-
.../src/modules/queue/domain/queue.policy.ts | 2 +-
.../create-repository.usecase.spec.ts | 2 +-
.../application/create-repository.usecase.ts | 2 +-
.../get-repository-snapshot-drift.usecase.ts | 2 +-
.../application/get-repository.usecase.ts | 2 +-
.../application/list-repositories.usecase.ts | 2 +-
.../repository/domain/repository.policy.ts | 2 +-
.../application/create-requirement.usecase.ts | 2 +-
.../create-revision.usecase.spec.ts | 2 +-
.../application/create-revision.usecase.ts | 2 +-
.../application/get-requirement.usecase.ts | 2 +-
.../application/list-requirements.usecase.ts | 2 +-
.../qualify-revision.usecase.spec.ts | 2 +-
.../application/qualify-revision.usecase.ts | 2 +-
.../requirement/domain/requirement.policy.ts | 2 +-
.../modules/review/domain/review.policy.ts | 2 +-
.../scanner/api/scan-job.controller.ts | 2 +-
.../create-scan-job.usecase.spec.ts | 2 +-
.../application/create-scan-job.usecase.ts | 2 +-
.../application/run-scan-job.usecase.spec.ts | 2 +-
.../application/run-scan-job.usecase.ts | 2 +-
.../modules/scanner/domain/scan-job.policy.ts | 2 +-
...te-traceability-review-decision.usecase.ts | 2 +-
.../get-review-completion.usecase.ts | 2 +-
.../review-traceability.usecase.spec.ts | 2 +-
.../review-traceability.usecase.ts | 2 +-
...te-traceability-review-decision.usecase.ts | 2 +-
apps/api/src/shared/app-exception.filter.ts | 2 +-
.../test/e2e/multi-repo-analysis.e2e-spec.ts | 2 +-
packages/shared/src/config/ai-config.ts | 64 +++++++
.../shared/src/config/embedding-config.ts | 28 +++
packages/shared/src/config/runtime-config.ts | 162 ++++++++++++++++
.../shared/src/errors}/app-error.ts | 0
packages/shared/src/index.ts | 7 +-
.../shared/src/policies}/ai.policy.ts | 0
tests/api/impact-analysis.review.spec.ts | 2 +-
tests/api/input-gates.spec.ts | 2 +-
tests/embedding/embedding-policy.spec.ts | 2 +-
.../domain-quality-evaluation.spec.ts | 2 +-
.../run-impact-analysis.spec.ts | 2 +-
127 files changed, 414 insertions(+), 393 deletions(-)
delete mode 100644 apps/api/src/modules/ai/infrastructure/google-provider-env.spec.ts
delete mode 100644 apps/api/src/modules/ai/infrastructure/google-provider-env.ts
create mode 100644 packages/shared/src/config/ai-config.ts
create mode 100644 packages/shared/src/config/embedding-config.ts
create mode 100644 packages/shared/src/config/runtime-config.ts
rename {apps/api/src/shared => packages/shared/src/errors}/app-error.ts (100%)
rename {apps/api/src/modules/ai/domain => packages/shared/src/policies}/ai.policy.ts (100%)
diff --git a/apps/api/src/bootstrap/runtime-config.ts b/apps/api/src/bootstrap/runtime-config.ts
index 2fe09e8e..dc49606a 100644
--- a/apps/api/src/bootstrap/runtime-config.ts
+++ b/apps/api/src/bootstrap/runtime-config.ts
@@ -1,162 +1,11 @@
-const DEFAULT_WORKSPACE_MODE = 'dev-single-user' as const;
-const DEFAULT_API_VERSION = process.env.APP_VERSION ?? '0.1.0';
-const DEFAULT_DEV_CORS_ALLOWED_ORIGINS = [
- 'http://localhost:3000',
- 'http://localhost:3001',
- 'http://127.0.0.1:3000',
- 'http://127.0.0.1:3001',
-];
-
-export type WorkspaceMode = typeof DEFAULT_WORKSPACE_MODE;
-
-export interface RuntimeConfig {
- apiVersion: string;
- corsAllowedOrigins: string[];
- isProductionLike: boolean;
- nodeEnv: string;
- port: number;
- workspaceMode: string;
- publicPreviewMode: boolean;
- aiProvider: string;
- enableDevLogin: boolean;
-}
-
-export function isProductionLikeEnv(nodeEnv?: string): boolean {
- return nodeEnv === 'production' || nodeEnv === 'staging';
-}
-
-export function isWeakSecret(secret?: string): boolean {
- if (!secret) return true;
- const normalized = secret.trim();
- if (!normalized) return true;
-
- const weakSecrets = new Set([
- 'dev-secret-change-me',
- 'dev-super-secret-key',
- 'dev-only-local-jwt-secret',
- 'change-me',
- 'replace-with-a-long-random-secret',
- 'postgresql://localhost/ba_helper',
- 'postgresql://ba_helper:ba_helper@localhost/ba_helper',
- 'redis://localhost:6379',
- 'dev-secret',
- 'secret',
- ]);
-
- return weakSecrets.has(normalized);
-}
-
-export function requireEnv(key: string, devFallback?: string, nodeEnv?: string): string {
- const env = nodeEnv ?? process.env.NODE_ENV ?? 'development';
- const isProd = isProductionLikeEnv(env);
- const value = process.env[key];
-
- if (isProd) {
- if (!value) {
- throw new Error(`Environment variable ${key} is required in production.`);
- }
- if (isWeakSecret(value)) {
- throw new Error(`Environment variable ${key} must not use a weak or default value in production.`);
- }
- }
-
- return value || devFallback || '';
-}
-
-export function normalizeOrigin(origin: string): string {
- const value = origin.trim();
-
- if (!value) {
- throw new Error('CORS origin entries must not be empty.');
- }
-
- let parsed: URL;
- try {
- parsed = new URL(value);
- } catch {
- throw new Error(`Invalid CORS origin: ${origin}`);
- }
-
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
- throw new Error(`Unsupported CORS origin protocol: ${origin}`);
- }
-
- if (parsed.pathname !== '/' || parsed.search || parsed.hash) {
- throw new Error(`CORS origins must not include path, query, or hash: ${origin}`);
- }
-
- return parsed.origin;
-}
-
-export function parseCorsAllowedOrigins(raw?: string): string[] {
- if (!raw || !raw.trim()) {
- return [];
- }
-
- const normalized = raw
- .split(',')
- .map((entry) => normalizeOrigin(entry))
- .filter((entry, index, list) => list.indexOf(entry) === index);
-
- return normalized;
-}
-
-export function getRuntimeConfig(
- env: NodeJS.ProcessEnv = process.env,
-): RuntimeConfig {
- const nodeEnv = env.NODE_ENV ?? 'development';
- const isProductionLike = isProductionLikeEnv(nodeEnv);
- const configuredOrigins = parseCorsAllowedOrigins(env.CORS_ALLOWED_ORIGINS);
-
- return {
- apiVersion: env.APP_VERSION ?? DEFAULT_API_VERSION,
- corsAllowedOrigins:
- configuredOrigins.length > 0
- ? configuredOrigins
- : isProductionLike
- ? []
- : DEFAULT_DEV_CORS_ALLOWED_ORIGINS,
- isProductionLike,
- nodeEnv,
- port: Number(env.PORT ?? '3001'),
- workspaceMode: env.WORKSPACE_MODE ?? DEFAULT_WORKSPACE_MODE,
- publicPreviewMode: env.PUBLIC_PREVIEW_MODE === 'true',
- aiProvider: env.AI_PROVIDER || 'fake',
- enableDevLogin: env.ENABLE_DEV_LOGIN === 'true',
- };
-}
-
-export function validateRuntimeConfig(config: RuntimeConfig, env: NodeJS.ProcessEnv = process.env): void {
- if (Number.isNaN(config.port) || config.port <= 0) {
- throw new Error(`Invalid PORT: ${config.port}`);
- }
-
- if (config.isProductionLike && config.corsAllowedOrigins.length === 0) {
- throw new Error(
- 'CORS_ALLOWED_ORIGINS must be configured for production-like deploys.',
- );
- }
-
- if (config.publicPreviewMode) {
- if (config.aiProvider !== 'fake') {
- throw new Error(`BOOT GUARD: PUBLIC_PREVIEW_MODE is active, but AI_PROVIDER is '${config.aiProvider}'. It must be 'fake'.`);
- }
- if (env.OPENAI_API_KEY) throw new Error('BOOT GUARD: OPENAI_API_KEY is forbidden in PUBLIC_PREVIEW_MODE.');
- if (env.GEMINI_API_KEY || env.GOOGLE_API_KEY) throw new Error('BOOT GUARD: GEMINI/GOOGLE API keys are forbidden in PUBLIC_PREVIEW_MODE.');
- if (env.ANTHROPIC_API_KEY) throw new Error('BOOT GUARD: ANTHROPIC_API_KEY is forbidden in PUBLIC_PREVIEW_MODE.');
- if (env.DEEPSEEK_API_KEY) throw new Error('BOOT GUARD: DEEPSEEK_API_KEY is forbidden in PUBLIC_PREVIEW_MODE.');
- }
-
- if (config.enableDevLogin) {
- if (config.isProductionLike) {
- throw new Error('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in production/staging environments.');
- }
- if (config.publicPreviewMode) {
- throw new Error('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in PUBLIC_PREVIEW_MODE.');
- }
- if (config.workspaceMode !== DEFAULT_WORKSPACE_MODE) {
- throw new Error(`BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden when workspace mode is '${config.workspaceMode}'.`);
- }
- }
-}
-
+export {
+ WorkspaceMode,
+ RuntimeConfig,
+ isProductionLikeEnv,
+ isWeakSecret,
+ requireEnv,
+ normalizeOrigin,
+ parseCorsAllowedOrigins,
+ getRuntimeConfig,
+ validateRuntimeConfig
+} from '@ba-helper/shared';
diff --git a/apps/api/src/modules/ai/ai.module.spec.ts b/apps/api/src/modules/ai/ai.module.spec.ts
index a7424605..d2874c26 100644
--- a/apps/api/src/modules/ai/ai.module.spec.ts
+++ b/apps/api/src/modules/ai/ai.module.spec.ts
@@ -1,4 +1,4 @@
-import { resolveAiProvider } from './ai.module';
+import { resolveAiProvider } from '@ba-helper/shared';
describe('resolveAiProvider', () => {
it('normalizes whitespace and casing', () => {
diff --git a/apps/api/src/modules/ai/ai.module.ts b/apps/api/src/modules/ai/ai.module.ts
index 62fb53ca..88b620d1 100644
--- a/apps/api/src/modules/ai/ai.module.ts
+++ b/apps/api/src/modules/ai/ai.module.ts
@@ -1,48 +1,22 @@
import { Module, DynamicModule } from '@nestjs/common';
+import { AiConfig, resolveAiConfig } from '@ba-helper/shared';
import { LlmProvider } from './domain/llm-provider.interface';
-import { AiConfig, AI_CONFIG_TOKEN } from './domain/ai-config';
+import { AI_CONFIG_TOKEN } from './domain/ai-config';
import { FakeLlmProvider } from './infrastructure/fake-ai.provider';
import { OpenAiLlmProvider } from './infrastructure/openai.provider';
import { AnthropicLlmProvider } from './infrastructure/anthropic.provider';
import { GoogleLlmProvider } from './infrastructure/google.provider';
import { DeepseekLlmProvider } from './infrastructure/deepseek.provider';
-const AI_PROVIDERS = ['fake', 'openai', 'anthropic', 'google', 'deepseek'] as const;
-
-export function resolveAiProvider(rawProvider?: string): AiConfig['provider'] {
- const provider = (rawProvider ?? 'fake').trim().toLowerCase();
- if ((AI_PROVIDERS as readonly string[]).includes(provider)) {
- return provider as AiConfig['provider'];
- }
- throw new Error(`Unsupported AI_PROVIDER "${rawProvider}". Expected one of: ${AI_PROVIDERS.join(', ')}.`);
-}
-
@Module({})
export class AiModule {
static forRoot(config?: Partial): DynamicModule {
- const provider = resolveAiProvider(process.env.AI_PROVIDER ?? config?.provider);
-
- if (process.env.NODE_ENV === 'production' && provider === 'fake') {
- throw new Error('FakeLlmProvider is forbidden in production. Please set AI_PROVIDER.');
- }
-
- let defaultModel = process.env.AI_MODEL ?? config?.defaultModel;
- if (!defaultModel) {
- switch (provider) {
- case 'google': defaultModel = process.env.GOOGLE_MODEL ?? process.env.GEMINI_MODEL ?? 'gemini-2.5-flash'; break;
- case 'anthropic': defaultModel = process.env.ANTHROPIC_MODEL ?? 'claude-3-5-sonnet-20241022'; break;
- case 'openai': defaultModel = process.env.OPENAI_MODEL ?? 'gpt-4o'; break;
- case 'deepseek': defaultModel = process.env.DEEPSEEK_MODEL ?? 'deepseek-chat'; break;
- default: defaultModel = 'gpt-4o';
- }
- }
-
+ const envConfig = resolveAiConfig(process.env);
+
+ // Allow manual overrides via config parameter
const resolvedConfig: AiConfig = {
- provider,
- defaultModel,
- temperature: Number(process.env.AI_TEMPERATURE ?? config?.temperature ?? 0.2),
- maxTokens: Number(process.env.AI_MAX_TOKENS ?? config?.maxTokens ?? 8192),
- redactSecrets: process.env.NODE_ENV !== 'test',
+ ...envConfig,
+ ...config,
};
return {
diff --git a/apps/api/src/modules/ai/application/ai.service.ts b/apps/api/src/modules/ai/application/ai.service.ts
index 37741702..5fff0768 100644
--- a/apps/api/src/modules/ai/application/ai.service.ts
+++ b/apps/api/src/modules/ai/application/ai.service.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { impactAnalysisAiSchema, type ImpactAnalysisAiResponse } from '../domain/ai.schema';
export class AiService {
diff --git a/apps/api/src/modules/ai/domain/ai-config.ts b/apps/api/src/modules/ai/domain/ai-config.ts
index 80eb2299..c0ef70dd 100644
--- a/apps/api/src/modules/ai/domain/ai-config.ts
+++ b/apps/api/src/modules/ai/domain/ai-config.ts
@@ -1,10 +1,3 @@
-export interface AiConfig {
- provider: 'fake' | 'openai' | 'anthropic' | 'google' | 'deepseek';
- defaultModel: string;
- temperature: number;
- maxTokens: number;
- /** Có bật secret redaction không (always true in prod) */
- redactSecrets: boolean;
-}
+export type { AiConfig } from '@ba-helper/shared';
export const AI_CONFIG_TOKEN = Symbol('AI_CONFIG');
diff --git a/apps/api/src/modules/ai/infrastructure/anthropic.provider.ts b/apps/api/src/modules/ai/infrastructure/anthropic.provider.ts
index 9edda2c1..c85d0c39 100644
--- a/apps/api/src/modules/ai/infrastructure/anthropic.provider.ts
+++ b/apps/api/src/modules/ai/infrastructure/anthropic.provider.ts
@@ -1,11 +1,11 @@
import { Injectable, Inject } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { z } from 'zod';
import { LlmProvider, LlmRequest, LlmResult } from '../domain/llm-provider.interface';
import { AiConfig, AI_CONFIG_TOKEN } from '../domain/ai-config';
import { parseStructuredLlmOutput } from './structured-output';
-import { AiPolicy } from '../domain/ai.policy';
+import { AiPolicy } from '@ba-helper/shared';
@Injectable()
export class AnthropicLlmProvider extends LlmProvider {
@@ -14,7 +14,7 @@ export class AnthropicLlmProvider extends LlmProvider {
constructor(@Inject(AI_CONFIG_TOKEN) private config: AiConfig) {
super();
- this.client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
+ this.client = new Anthropic({ apiKey: this.config.apiKey });
}
async generateStructured(
diff --git a/apps/api/src/modules/ai/infrastructure/deepseek.provider.ts b/apps/api/src/modules/ai/infrastructure/deepseek.provider.ts
index 5067ffe0..c975b440 100644
--- a/apps/api/src/modules/ai/infrastructure/deepseek.provider.ts
+++ b/apps/api/src/modules/ai/infrastructure/deepseek.provider.ts
@@ -1,6 +1,6 @@
import { Injectable, Inject } from '@nestjs/common';
import OpenAI from 'openai';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { z } from 'zod';
import { LlmProvider, LlmRequest, LlmResult } from '../domain/llm-provider.interface';
import { AiConfig, AI_CONFIG_TOKEN } from '../domain/ai-config';
@@ -14,8 +14,8 @@ export class DeepseekLlmProvider extends LlmProvider {
constructor(@Inject(AI_CONFIG_TOKEN) private config: AiConfig) {
super();
this.client = new OpenAI({
- baseURL: process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com',
- apiKey: process.env.DEEPSEEK_API_KEY
+ baseURL: this.config.baseUrl,
+ apiKey: this.config.apiKey
});
}
@@ -23,7 +23,7 @@ export class DeepseekLlmProvider extends LlmProvider {
request: LlmRequest,
schema: z.ZodSchema,
): Promise> {
- const model = request.options?.model ?? process.env.DEEPSEEK_MODEL ?? this.config.defaultModel;
+ const model = request.options?.model ?? this.config.defaultModel;
const start = Date.now();
let response;
diff --git a/apps/api/src/modules/ai/infrastructure/google-provider-env.spec.ts b/apps/api/src/modules/ai/infrastructure/google-provider-env.spec.ts
deleted file mode 100644
index badaba1d..00000000
--- a/apps/api/src/modules/ai/infrastructure/google-provider-env.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { resolveGoogleProviderApiKey } from './google-provider-env';
-
-describe('resolveGoogleProviderApiKey', () => {
- it('skips blank GOOGLE_API_KEY and falls back to GEMINI_API_KEY', () => {
- expect(
- resolveGoogleProviderApiKey({
- GOOGLE_API_KEY: ' ',
- GEMINI_API_KEY: 'gemini-secret',
- GOOGLE_AI_API_KEY: 'google-ai-secret',
- } as NodeJS.ProcessEnv),
- ).toBe('gemini-secret');
- });
-
- it('returns null when all configured keys are blank', () => {
- expect(
- resolveGoogleProviderApiKey({
- GOOGLE_API_KEY: '',
- GEMINI_API_KEY: ' ',
- GOOGLE_AI_API_KEY: '\n',
- } as NodeJS.ProcessEnv),
- ).toBeNull();
- });
-});
diff --git a/apps/api/src/modules/ai/infrastructure/google-provider-env.ts b/apps/api/src/modules/ai/infrastructure/google-provider-env.ts
deleted file mode 100644
index fa229473..00000000
--- a/apps/api/src/modules/ai/infrastructure/google-provider-env.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export const GOOGLE_PROVIDER_KEY_ENV_PRIORITY = [
- 'GOOGLE_API_KEY',
- 'GEMINI_API_KEY',
- 'GOOGLE_AI_API_KEY',
-] as const;
-
-export function resolveGoogleProviderApiKey(
- env: NodeJS.ProcessEnv = process.env,
-): string | null {
- for (const key of GOOGLE_PROVIDER_KEY_ENV_PRIORITY) {
- const value = env[key]?.trim();
- if (value) {
- return value;
- }
- }
-
- return null;
-}
diff --git a/apps/api/src/modules/ai/infrastructure/google.provider.ts b/apps/api/src/modules/ai/infrastructure/google.provider.ts
index 3fa34ee4..28878d72 100644
--- a/apps/api/src/modules/ai/infrastructure/google.provider.ts
+++ b/apps/api/src/modules/ai/infrastructure/google.provider.ts
@@ -3,10 +3,9 @@ import { GoogleGenerativeAI } from '@google/generative-ai';
import { z } from 'zod';
import { LlmProvider, LlmRequest, LlmResult } from '../domain/llm-provider.interface';
import { AiConfig, AI_CONFIG_TOKEN } from '../domain/ai-config';
-import { AiPolicy } from '../domain/ai.policy';
+import { AiPolicy } from '@ba-helper/shared';
import { parseStructuredLlmOutput } from './structured-output';
-import { AppError } from '../../../shared/app-error';
-import { resolveGoogleProviderApiKey } from './google-provider-env';
+import { AppError } from '@ba-helper/shared';
import { AiOutputError } from '../domain/ai.errors';
@Injectable()
@@ -16,7 +15,7 @@ export class GoogleLlmProvider extends LlmProvider {
constructor(@Inject(AI_CONFIG_TOKEN) private config: AiConfig) {
super();
- const apiKey = resolveGoogleProviderApiKey();
+ const apiKey = this.config.apiKey;
if (!apiKey) {
throw new AppError(
diff --git a/apps/api/src/modules/ai/infrastructure/openai.provider.ts b/apps/api/src/modules/ai/infrastructure/openai.provider.ts
index 5a8c820e..1042b5c8 100644
--- a/apps/api/src/modules/ai/infrastructure/openai.provider.ts
+++ b/apps/api/src/modules/ai/infrastructure/openai.provider.ts
@@ -1,11 +1,11 @@
import { Injectable, Inject } from '@nestjs/common';
import OpenAI from 'openai';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { z } from 'zod';
import { LlmProvider, LlmRequest, LlmResult } from '../domain/llm-provider.interface';
import { AiConfig, AI_CONFIG_TOKEN } from '../domain/ai-config';
import { parseStructuredLlmOutput } from './structured-output';
-import { AiPolicy } from '../domain/ai.policy';
+import { AiPolicy } from '@ba-helper/shared';
@Injectable()
export class OpenAiLlmProvider extends LlmProvider {
@@ -14,7 +14,7 @@ export class OpenAiLlmProvider extends LlmProvider {
constructor(@Inject(AI_CONFIG_TOKEN) private config: AiConfig) {
super();
- this.client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
+ this.client = new OpenAI({ apiKey: this.config.apiKey });
}
async generateStructured(
diff --git a/apps/api/src/modules/artifact/application/list-artifacts.usecase.ts b/apps/api/src/modules/artifact/application/list-artifacts.usecase.ts
index 6bbd3860..d1157c8d 100644
--- a/apps/api/src/modules/artifact/application/list-artifacts.usecase.ts
+++ b/apps/api/src/modules/artifact/application/list-artifacts.usecase.ts
@@ -1,6 +1,6 @@
import { ArtifactRepository } from '../infrastructure/artifact.repository';
import { PrismaService } from '../../prisma/prisma.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export class ListArtifactsUseCase {
constructor(
diff --git a/apps/api/src/modules/artifact/domain/artifact.policy.ts b/apps/api/src/modules/artifact/domain/artifact.policy.ts
index 789eefed..dae04e36 100644
--- a/apps/api/src/modules/artifact/domain/artifact.policy.ts
+++ b/apps/api/src/modules/artifact/domain/artifact.policy.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export const ArtifactPolicy = {
validateArtifactPayload: (params: {
diff --git a/apps/api/src/modules/clarification/application/answer-clarification.usecase.ts b/apps/api/src/modules/clarification/application/answer-clarification.usecase.ts
index eab93381..44296058 100644
--- a/apps/api/src/modules/clarification/application/answer-clarification.usecase.ts
+++ b/apps/api/src/modules/clarification/application/answer-clarification.usecase.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ClarificationRepository } from '../infrastructure/clarification.repository';
import { ImpactAnalysisRepository } from '../../impact-analysis/infrastructure/impact-analysis.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
@Injectable()
export class AnswerClarificationUseCase {
diff --git a/apps/api/src/modules/clarification/application/clarification.spec.ts b/apps/api/src/modules/clarification/application/clarification.spec.ts
index dd73676b..fe59873d 100644
--- a/apps/api/src/modules/clarification/application/clarification.spec.ts
+++ b/apps/api/src/modules/clarification/application/clarification.spec.ts
@@ -6,7 +6,7 @@ import { ClarificationRepository } from '../infrastructure/clarification.reposit
import { InsightRepository } from '../../insight/infrastructure/insight.repository';
import { ImpactAnalysisRepository } from '../../impact-analysis/infrastructure/impact-analysis.repository';
import { RequirementRepository } from '../../requirement/infrastructure/requirement.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
describe('Clarification Use Cases', () => {
let clarificationRepo: jest.Mocked;
diff --git a/apps/api/src/modules/clarification/application/convert-clarification-to-revision.usecase.ts b/apps/api/src/modules/clarification/application/convert-clarification-to-revision.usecase.ts
index 3f8d8647..7615f173 100644
--- a/apps/api/src/modules/clarification/application/convert-clarification-to-revision.usecase.ts
+++ b/apps/api/src/modules/clarification/application/convert-clarification-to-revision.usecase.ts
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { ClarificationRepository } from '../infrastructure/clarification.repository';
import { ImpactAnalysisRepository } from '../../impact-analysis/infrastructure/impact-analysis.repository';
import { RequirementRepository } from '../../requirement/infrastructure/requirement.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
@Injectable()
export class ConvertClarificationToRevisionUseCase {
diff --git a/apps/api/src/modules/clarification/application/dismiss-clarification.usecase.ts b/apps/api/src/modules/clarification/application/dismiss-clarification.usecase.ts
index 888e0a3c..1d0c8e51 100644
--- a/apps/api/src/modules/clarification/application/dismiss-clarification.usecase.ts
+++ b/apps/api/src/modules/clarification/application/dismiss-clarification.usecase.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ClarificationRepository } from '../infrastructure/clarification.repository';
import { ImpactAnalysisRepository } from '../../impact-analysis/infrastructure/impact-analysis.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
@Injectable()
export class DismissClarificationUseCase {
diff --git a/apps/api/src/modules/clarification/application/ensure-clarification.usecase.ts b/apps/api/src/modules/clarification/application/ensure-clarification.usecase.ts
index ab02e60e..c415552a 100644
--- a/apps/api/src/modules/clarification/application/ensure-clarification.usecase.ts
+++ b/apps/api/src/modules/clarification/application/ensure-clarification.usecase.ts
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { ClarificationRepository } from '../infrastructure/clarification.repository';
import { ImpactAnalysisRepository } from '../../impact-analysis/infrastructure/impact-analysis.repository';
import { InsightRepository } from '../../insight/infrastructure/insight.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
@Injectable()
export class EnsureClarificationUseCase {
diff --git a/apps/api/src/modules/document/application/commands/enqueue-document-job.usecase.ts b/apps/api/src/modules/document/application/commands/enqueue-document-job.usecase.ts
index 50824b1b..c879d5df 100644
--- a/apps/api/src/modules/document/application/commands/enqueue-document-job.usecase.ts
+++ b/apps/api/src/modules/document/application/commands/enqueue-document-job.usecase.ts
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { QueueService } from '../../../queue/queue.service';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
type DocumentJobTx = Prisma.TransactionClient | PrismaService;
diff --git a/apps/api/src/modules/document/application/export-approved-report.usecase.spec.ts b/apps/api/src/modules/document/application/export-approved-report.usecase.spec.ts
index e4bb4825..851d4610 100644
--- a/apps/api/src/modules/document/application/export-approved-report.usecase.spec.ts
+++ b/apps/api/src/modules/document/application/export-approved-report.usecase.spec.ts
@@ -1,7 +1,7 @@
import { RequestUser } from '@ba-helper/contracts';
import { EventLogService } from '../../event-log/application/event-log.service';
import { DocumentRepository } from '../infrastructure/document.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ApprovedReportProjectionService } from './approved-report-projection.service';
import { ExportApprovedReportUseCase } from './export-approved-report.usecase';
import { MarkdownExportRenderer } from './markdown-export.renderer';
diff --git a/apps/api/src/modules/document/application/export-approved-report.usecase.ts b/apps/api/src/modules/document/application/export-approved-report.usecase.ts
index 2522bebd..d4625706 100644
--- a/apps/api/src/modules/document/application/export-approved-report.usecase.ts
+++ b/apps/api/src/modules/document/application/export-approved-report.usecase.ts
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { RequestUser } from '@ba-helper/contracts';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { EventLogService } from '../../event-log/application/event-log.service';
import { DocumentRepository } from '../infrastructure/document.repository';
import { ApprovedReportProjectionService } from './approved-report-projection.service';
diff --git a/apps/api/src/modules/document/application/get-approved-report.usecase.ts b/apps/api/src/modules/document/application/get-approved-report.usecase.ts
index b558abb5..9fd29373 100644
--- a/apps/api/src/modules/document/application/get-approved-report.usecase.ts
+++ b/apps/api/src/modules/document/application/get-approved-report.usecase.ts
@@ -1,5 +1,5 @@
import { DocumentRepository } from '../infrastructure/document.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ApprovedReportProjectionService } from './approved-report-projection.service';
export class GetApprovedReportUseCase {
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 b21b1ece..3e9c8347 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
@@ -11,7 +11,7 @@ import { ReviewDecisionRepository } from '../../../impact-analysis/infrastructur
import { GetImpactDiffUseCase } from '../../../impact-analysis/application/queries/get-impact-diff.usecase';
import { DocumentRepository } from '../../infrastructure/document.repository';
import { ReviewedSnapshotReportContextAdapter } from '../render/reviewed-snapshot-report-context.adapter';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
@Injectable()
export class RunDocumentJobUseCase {
diff --git a/apps/api/src/modules/document/application/pdf-export.renderer.spec.ts b/apps/api/src/modules/document/application/pdf-export.renderer.spec.ts
index 1daf7d29..d52c5378 100644
--- a/apps/api/src/modules/document/application/pdf-export.renderer.spec.ts
+++ b/apps/api/src/modules/document/application/pdf-export.renderer.spec.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { PdfExportRenderer } from './pdf-export.renderer';
describe('PdfExportRenderer', () => {
diff --git a/apps/api/src/modules/document/application/pdf-export.renderer.ts b/apps/api/src/modules/document/application/pdf-export.renderer.ts
index 7d30d938..7380859d 100644
--- a/apps/api/src/modules/document/application/pdf-export.renderer.ts
+++ b/apps/api/src/modules/document/application/pdf-export.renderer.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import sanitizeHtml from 'sanitize-html';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { sanitizeReportFilename } from '../domain/sanitize-filename.util';
import { ApprovedReportMetadata } from '../domain/approved-report-metadata';
import { DocumentExportRenderer, RenderedExport } from './document-export.renderer';
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 ffdbdd8b..6b33f0c6 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
@@ -1,5 +1,5 @@
import { GetFinalReviewedReportUseCase } from './get-final-reviewed-report.usecase';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
describe('GetFinalReviewedReportUseCase', () => {
let useCase: GetFinalReviewedReportUseCase;
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 ed69c6da..62e397bb 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
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { DocumentJobStatus } from '@prisma/client';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { GetReviewCompletionUseCase } from '../../../traceability/application/get-review-completion.usecase';
import { GetLatestReviewedReportSnapshotUseCase } from './get-latest-reviewed-report-snapshot.usecase';
import { FinalReviewedReportResponse } from '@ba-helper/contracts';
diff --git a/apps/api/src/modules/document/application/queries/get-latest-reviewed-report-snapshot.usecase.ts b/apps/api/src/modules/document/application/queries/get-latest-reviewed-report-snapshot.usecase.ts
index b10665b7..62d31c24 100644
--- a/apps/api/src/modules/document/application/queries/get-latest-reviewed-report-snapshot.usecase.ts
+++ b/apps/api/src/modules/document/application/queries/get-latest-reviewed-report-snapshot.usecase.ts
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
@Injectable()
export class GetLatestReviewedReportSnapshotUseCase {
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 cc34ad92..8279b6ab 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
@@ -1,5 +1,5 @@
import { DomainPackRegistry } from './domain-pack.registry';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
describe('DomainPackRegistry', () => {
let registry: DomainPackRegistry;
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 1c5f564e..83e6da67 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
@@ -3,7 +3,7 @@ import { DomainPack, DomainProfileRegistryEntry } from '@ba-helper/contracts';
import { GeneralDomainPack } from '../packs/general.v0.0.0';
import { BookingDomainPack } from '../packs/booking.v0.1.0';
import { RentalDomainPack } from '../packs/rental.v0.1.0';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export type DomainPackSelectionInput = {
manualPackId?: string | null;
diff --git a/apps/api/src/modules/embedding/application/embed-snapshot-artifacts.usecase.ts b/apps/api/src/modules/embedding/application/embed-snapshot-artifacts.usecase.ts
index 2c7dfa7b..c510525b 100644
--- a/apps/api/src/modules/embedding/application/embed-snapshot-artifacts.usecase.ts
+++ b/apps/api/src/modules/embedding/application/embed-snapshot-artifacts.usecase.ts
@@ -1,12 +1,12 @@
import { Injectable } from '@nestjs/common';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { EmbeddingChunkRepository } from '../infrastructure/embedding-chunk.repository';
import { EmbeddingProvider } from '../domain/embedding-provider.interface';
import { PrismaService } from '../../prisma/prisma.service';
import { ArtifactChunkBuilder, CHUNK_BUILDER_VERSION } from '../domain/artifact-chunk.builder';
import { matchChunksForReuse, CurrentChunkItem, MatchResult } from '../domain/embedding-reuse-matcher';
import { createHash } from 'node:crypto';
-import { AiPolicy } from '../../ai/domain/ai.policy';
+import { AiPolicy } from '@ba-helper/shared';
import type {
DiagnosticItem,
EmbeddingReusePlanPayload,
diff --git a/apps/api/src/modules/embedding/domain/embedding.policy.ts b/apps/api/src/modules/embedding/domain/embedding.policy.ts
index 5a5a2f86..6626ce38 100644
--- a/apps/api/src/modules/embedding/domain/embedding.policy.ts
+++ b/apps/api/src/modules/embedding/domain/embedding.policy.ts
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto';
-import { AiPolicy } from '../../ai/domain/ai.policy';
+import { AiPolicy } from '@ba-helper/shared';
export const EmbeddingPolicy = {
/** Build text content from an artifact for embedding */
diff --git a/apps/api/src/modules/embedding/embedding.module.spec.ts b/apps/api/src/modules/embedding/embedding.module.spec.ts
index 3bc8760e..c8d2db1f 100644
--- a/apps/api/src/modules/embedding/embedding.module.spec.ts
+++ b/apps/api/src/modules/embedding/embedding.module.spec.ts
@@ -1,4 +1,4 @@
-import { resolveEmbeddingProvider } from './embedding.module';
+import { resolveEmbeddingProvider } from '@ba-helper/shared';
describe('resolveEmbeddingProvider', () => {
it('normalizes whitespace and casing', () => {
diff --git a/apps/api/src/modules/embedding/embedding.module.ts b/apps/api/src/modules/embedding/embedding.module.ts
index 676ea39e..8b89476a 100644
--- a/apps/api/src/modules/embedding/embedding.module.ts
+++ b/apps/api/src/modules/embedding/embedding.module.ts
@@ -7,16 +7,7 @@ import { EmbeddingChunkRepository } from './infrastructure/embedding-chunk.repos
import { EmbedSnapshotArtifactsUseCase } from './application/embed-snapshot-artifacts.usecase';
import { PrismaModule } from '../prisma/prisma.module';
-const EMBEDDING_PROVIDERS = ['fake', 'openai', 'google'] as const;
-type EmbeddingProviderName = (typeof EMBEDDING_PROVIDERS)[number];
-
-export function resolveEmbeddingProvider(rawProvider?: string): EmbeddingProviderName {
- const provider = (rawProvider || 'fake').trim().toLowerCase();
- if ((EMBEDDING_PROVIDERS as readonly string[]).includes(provider)) {
- return provider as EmbeddingProviderName;
- }
- throw new Error(`Unsupported EMBEDDING_PROVIDER "${rawProvider}". Expected one of: ${EMBEDDING_PROVIDERS.join(', ')}.`);
-}
+import { resolveEmbeddingConfig } from '@ba-helper/shared';
@Module({
imports: [PrismaModule],
@@ -27,17 +18,17 @@ export function resolveEmbeddingProvider(rawProvider?: string): EmbeddingProvide
provide: EmbeddingProvider,
useFactory: () => {
// By default, use fake provider if not in production and not explicitly requested
- const provider = resolveEmbeddingProvider(process.env.EMBEDDING_PROVIDER);
+ const config = resolveEmbeddingConfig(process.env);
- if (process.env.NODE_ENV === 'production' && provider === 'fake') {
+ if (process.env.NODE_ENV === 'production' && config.provider === 'fake') {
throw new Error('FakeEmbeddingProvider is forbidden in production. Please set EMBEDDING_PROVIDER.');
}
- if (provider === 'openai') {
- return new OpenAiEmbeddingProvider();
+ if (config.provider === 'openai') {
+ return new OpenAiEmbeddingProvider(config);
}
- if (provider === 'google') {
- return new GoogleEmbeddingProvider();
+ if (config.provider === 'google') {
+ return new GoogleEmbeddingProvider(config);
}
return new FakeEmbeddingProvider();
},
diff --git a/apps/api/src/modules/embedding/infrastructure/google-embedding.provider.ts b/apps/api/src/modules/embedding/infrastructure/google-embedding.provider.ts
index 68084c6c..bc06d755 100644
--- a/apps/api/src/modules/embedding/infrastructure/google-embedding.provider.ts
+++ b/apps/api/src/modules/embedding/infrastructure/google-embedding.provider.ts
@@ -1,8 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { EmbeddingProvider, EmbeddingRequest, EmbeddingResult } from '../domain/embedding-provider.interface';
-import { AppError } from '../../../shared/app-error';
-import { resolveGoogleProviderApiKey } from '../../ai/infrastructure/google-provider-env';
+import { AppError, EmbeddingConfig } from '@ba-helper/shared';
const DEFAULT_MODEL = 'gemini-embedding-001';
const EXPECTED_DIMENSIONS = 1536;
@@ -14,9 +13,9 @@ export class GoogleEmbeddingProvider extends EmbeddingProvider {
private readonly client: GoogleGenerativeAI;
private readonly logger = new Logger(GoogleEmbeddingProvider.name);
- constructor() {
+ constructor(private readonly config: EmbeddingConfig) {
super();
- const apiKey = resolveGoogleProviderApiKey();
+ const apiKey = this.config.apiKey;
if (!apiKey) {
throw new AppError(
'AI_PROVIDER_CONFIG_INVALID',
diff --git a/apps/api/src/modules/embedding/infrastructure/openai-embedding.provider.ts b/apps/api/src/modules/embedding/infrastructure/openai-embedding.provider.ts
index 87866d6e..3faa44d3 100644
--- a/apps/api/src/modules/embedding/infrastructure/openai-embedding.provider.ts
+++ b/apps/api/src/modules/embedding/infrastructure/openai-embedding.provider.ts
@@ -1,7 +1,7 @@
import { Injectable, Inject } from '@nestjs/common';
import OpenAI from 'openai';
import { EmbeddingProvider, EmbeddingRequest, EmbeddingResult } from '../domain/embedding-provider.interface';
-import { AppError } from '../../../shared/app-error';
+import { AppError, EmbeddingConfig } from '@ba-helper/shared';
const DEFAULT_MODEL = 'text-embedding-3-small';
const DIMENSIONS = 1536;
@@ -11,9 +11,9 @@ export class OpenAiEmbeddingProvider extends EmbeddingProvider {
readonly providerName = 'openai';
private readonly client: OpenAI;
- constructor() {
+ constructor(private readonly config: EmbeddingConfig) {
super();
- this.client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
+ this.client = new OpenAI({ apiKey: this.config.apiKey });
}
async embed(request: EmbeddingRequest): Promise {
diff --git a/apps/api/src/modules/event-log/domain/event-log.policy.spec.ts b/apps/api/src/modules/event-log/domain/event-log.policy.spec.ts
index eb6732d6..6ba7b25c 100644
--- a/apps/api/src/modules/event-log/domain/event-log.policy.spec.ts
+++ b/apps/api/src/modules/event-log/domain/event-log.policy.spec.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { EventLogPolicy } from './event-log.policy';
describe('EventLogPolicy', () => {
diff --git a/apps/api/src/modules/event-log/domain/event-log.policy.ts b/apps/api/src/modules/event-log/domain/event-log.policy.ts
index 1ab0bb5d..57f55d05 100644
--- a/apps/api/src/modules/event-log/domain/event-log.policy.ts
+++ b/apps/api/src/modules/event-log/domain/event-log.policy.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export const EventLogPolicy = {
validateEventPayload: (params: {
diff --git a/apps/api/src/modules/evidence/application/list-evidence.usecase.ts b/apps/api/src/modules/evidence/application/list-evidence.usecase.ts
index a1957759..12b43f12 100644
--- a/apps/api/src/modules/evidence/application/list-evidence.usecase.ts
+++ b/apps/api/src/modules/evidence/application/list-evidence.usecase.ts
@@ -1,6 +1,6 @@
import { EvidenceRepository } from '../infrastructure/evidence.repository';
import { PrismaService } from '../../prisma/prisma.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export class ListEvidenceUseCase {
constructor(
diff --git a/apps/api/src/modules/evidence/domain/evidence.policy.ts b/apps/api/src/modules/evidence/domain/evidence.policy.ts
index 017ace65..95b5871c 100644
--- a/apps/api/src/modules/evidence/domain/evidence.policy.ts
+++ b/apps/api/src/modules/evidence/domain/evidence.policy.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export const EvidencePolicy = {
validateEvidenceOrigin: (evidence: {
diff --git a/apps/api/src/modules/graph/application/get-graph.usecase.ts b/apps/api/src/modules/graph/application/get-graph.usecase.ts
index c7a271b7..5ab8e101 100644
--- a/apps/api/src/modules/graph/application/get-graph.usecase.ts
+++ b/apps/api/src/modules/graph/application/get-graph.usecase.ts
@@ -1,6 +1,6 @@
import { GraphRepository } from '../infrastructure/graph.repository';
import { PrismaService } from '../../prisma/prisma.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export class GetGraphUseCase {
constructor(
diff --git a/apps/api/src/modules/graph/domain/graph.policy.ts b/apps/api/src/modules/graph/domain/graph.policy.ts
index 9ec572a0..24984325 100644
--- a/apps/api/src/modules/graph/domain/graph.policy.ts
+++ b/apps/api/src/modules/graph/domain/graph.policy.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export const ACYCLIC_EDGE_TYPES = [
'REQUIREMENT_TO_ANALYSIS',
diff --git a/apps/api/src/modules/impact-analysis/application/lifecycle/create-derived-analysis-from-clarification.usecase.ts b/apps/api/src/modules/impact-analysis/application/lifecycle/create-derived-analysis-from-clarification.usecase.ts
index 81134215..55a582e0 100644
--- a/apps/api/src/modules/impact-analysis/application/lifecycle/create-derived-analysis-from-clarification.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/lifecycle/create-derived-analysis-from-clarification.usecase.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ReviewClarificationRepository } from '../../infrastructure/review-clarification.repository';
import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { CreateImpactAnalysisUseCase } from './create-impact-analysis.usecase';
import { CreateRequirementRevisionUseCase } from '../../../requirement/application/create-revision.usecase';
import * as crypto from 'crypto';
diff --git a/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.ts b/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.ts
index 23d39fe8..a83d6c90 100644
--- a/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.ts
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository';
import { RequirementRepository } from '../../../requirement/infrastructure/requirement.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ImpactAnalysisPolicy } from '../../domain/impact-analysis.policy';
import { EventLogService } from '../../../event-log/application/event-log.service';
import { PrismaService } from '../../../prisma/prisma.service';
diff --git a/apps/api/src/modules/impact-analysis/application/lifecycle/finalize-impact-analysis.usecase.ts b/apps/api/src/modules/impact-analysis/application/lifecycle/finalize-impact-analysis.usecase.ts
index c5f61ab3..c7037ce8 100644
--- a/apps/api/src/modules/impact-analysis/application/lifecycle/finalize-impact-analysis.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/lifecycle/finalize-impact-analysis.usecase.ts
@@ -1,7 +1,7 @@
import { Injectable } from "@nestjs/common";
import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ReviewPolicy } from '../../../review/domain/review.policy';
import { TraceabilityRepository } from '../../../traceability/infrastructure/traceability.repository';
diff --git a/apps/api/src/modules/impact-analysis/application/lifecycle/get-impact-analysis.usecase.ts b/apps/api/src/modules/impact-analysis/application/lifecycle/get-impact-analysis.usecase.ts
index bd54e587..b8d78b26 100644
--- a/apps/api/src/modules/impact-analysis/application/lifecycle/get-impact-analysis.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/lifecycle/get-impact-analysis.usecase.ts
@@ -1,7 +1,7 @@
import { Injectable } from "@nestjs/common";
import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
diff --git a/apps/api/src/modules/impact-analysis/application/lifecycle/list-impact-analyses.usecase.ts b/apps/api/src/modules/impact-analysis/application/lifecycle/list-impact-analyses.usecase.ts
index 65516cc8..f3d84c3c 100644
--- a/apps/api/src/modules/impact-analysis/application/lifecycle/list-impact-analyses.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/lifecycle/list-impact-analyses.usecase.ts
@@ -1,6 +1,6 @@
import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository';
import { ProjectRepository } from '../../../project/infrastructure/project.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export class ListImpactAnalysesUseCase {
constructor(
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 a3c9c047..558357ff 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
@@ -9,7 +9,7 @@ import { InsightRepository } from '../../../insight/infrastructure/insight.repos
import { TraceabilityRepository } from '../../../traceability/infrastructure/traceability.repository';
import { LlmProvider } from '../../../ai/domain/llm-provider.interface';
import { HybridRetrievalService } from '../../../retrieval/application/hybrid-retrieval.service';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { renderPrompt } from '../../../ai/domain/prompt-registry';
import { DomainPackRegistry } from '../../../domain-pack/application/domain-pack.registry';
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 51b87131..98114c49 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
@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository';
import { InsightRepository } from '../../../insight/infrastructure/insight.repository';
import { DomainPackRegistry } from '../../../domain-pack/application/domain-pack.registry';
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/create-merged-multi-repo-report-review-decision.usecase.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/create-merged-multi-repo-report-review-decision.usecase.ts
index 51d62d8f..88976695 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/create-merged-multi-repo-report-review-decision.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/create-merged-multi-repo-report-review-decision.usecase.ts
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { AnalysisReviewDecisionValue } from '@prisma/client';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { GetApprovedMultiRepoReportUseCase } from './get-approved-multi-repo-report.usecase';
import { MultiRepoMergedReportRepository } from '../../infrastructure/multi-repo-merged-report.repository';
import { MergedMultiRepoReportReviewDecisionRepository } from '../../infrastructure/merged-multi-repo-report-review-decision.repository';
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.ts
index 1f53c32c..d027ba76 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.ts
@@ -1,6 +1,6 @@
import { createHash } from 'crypto';
import { Injectable } from '@nestjs/common';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { PrismaService } from '../../../prisma/prisma.service';
import { CreateImpactAnalysisUseCase } from '../lifecycle/create-impact-analysis.usecase';
import { RequirementRepository } from '../../../requirement/infrastructure/requirement.repository';
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/export-approved-multi-repo-report.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/export-approved-multi-repo-report.usecase.spec.ts
index bad29cf0..d2ced1d3 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/export-approved-multi-repo-report.usecase.spec.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/export-approved-multi-repo-report.usecase.spec.ts
@@ -1,5 +1,5 @@
import { RequestUser } from '@ba-helper/contracts';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { EventLogService } from '../../../event-log/application/event-log.service';
import { MarkdownExportRenderer } from '../../../document/application/markdown-export.renderer';
import { PdfExportRenderer } from '../../../document/application/pdf-export.renderer';
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/export-approved-multi-repo-report.usecase.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/export-approved-multi-repo-report.usecase.ts
index 37758c26..ba2b333d 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/export-approved-multi-repo-report.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/export-approved-multi-repo-report.usecase.ts
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { RequestUser } from '@ba-helper/contracts';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { EventLogService } from '../../../event-log/application/event-log.service';
import { MarkdownExportRenderer } from '../../../document/application/markdown-export.renderer';
import { PdfExportRenderer } from '../../../document/application/pdf-export.renderer';
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/finalize-multi-repo-report.usecase.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/finalize-multi-repo-report.usecase.ts
index 19e7b17a..c982eaf2 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/finalize-multi-repo-report.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/finalize-multi-repo-report.usecase.ts
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { GetMergedMultiRepoReportDraftUseCase } from './get-merged-multi-repo-report-draft.usecase';
import { MultiRepoAnalysisRunRepository } from '../../infrastructure/multi-repo-analysis-run.repository';
import { MultiRepoMergedReportRepository } from '../../infrastructure/multi-repo-merged-report.repository';
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/get-approved-multi-repo-report.usecase.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/get-approved-multi-repo-report.usecase.ts
index 43b154ff..4bd2537a 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/get-approved-multi-repo-report.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/get-approved-multi-repo-report.usecase.ts
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { MultiRepoAnalysisRunRepository } from '../../infrastructure/multi-repo-analysis-run.repository';
import { MultiRepoMergedReportRepository } from '../../infrastructure/multi-repo-merged-report.repository';
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/get-latest-merged-multi-repo-report-review-decision.usecase.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/get-latest-merged-multi-repo-report-review-decision.usecase.ts
index de6a20ad..4c47277e 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/get-latest-merged-multi-repo-report-review-decision.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/get-latest-merged-multi-repo-report-review-decision.usecase.ts
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { MultiRepoMergedReportRepository } from '../../infrastructure/multi-repo-merged-report.repository';
import { MergedMultiRepoReportReviewDecisionRepository } from '../../infrastructure/merged-multi-repo-report-review-decision.repository';
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/get-merged-multi-repo-report-draft.usecase.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/get-merged-multi-repo-report-draft.usecase.ts
index c022979b..00126d35 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/get-merged-multi-repo-report-draft.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/get-merged-multi-repo-report-draft.usecase.ts
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { deriveMultiRepoRunAggregates } from './multi-repo-run-readiness';
import { InsightRepository } from '../../../insight/infrastructure/insight.repository';
import { TraceabilityRepository } from '../../../traceability/infrastructure/traceability.repository';
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/get-multi-repo-analysis-run.usecase.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/get-multi-repo-analysis-run.usecase.ts
index ce40f701..b4899799 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/get-multi-repo-analysis-run.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/get-multi-repo-analysis-run.usecase.ts
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { MultiRepoAnalysisRunRepository } from '../../infrastructure/multi-repo-analysis-run.repository';
@Injectable()
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/list-merged-multi-repo-report-review-decisions.usecase.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/list-merged-multi-repo-report-review-decisions.usecase.ts
index 09c79f84..b9086b43 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/list-merged-multi-repo-report-review-decisions.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/list-merged-multi-repo-report-review-decisions.usecase.ts
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { MultiRepoMergedReportRepository } from '../../infrastructure/multi-repo-merged-report.repository';
import { MergedMultiRepoReportReviewDecisionRepository } from '../../infrastructure/merged-multi-repo-report-review-decision.repository';
diff --git a/apps/api/src/modules/impact-analysis/application/qa/get-qa-coverage.usecase.ts b/apps/api/src/modules/impact-analysis/application/qa/get-qa-coverage.usecase.ts
index 8b34f3b7..d7205176 100644
--- a/apps/api/src/modules/impact-analysis/application/qa/get-qa-coverage.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/qa/get-qa-coverage.usecase.ts
@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { QaCoverageResponse } from '@ba-helper/contracts';
import { ImpactGraphReadModelBuilder } from '../queries/impact-graph-read-model.builder';
import { QaCoverageDeriver } from './qa-coverage.deriver';
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
index 955d9ae5..9e76696a 100644
--- 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
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { PrismaService } from '../../../prisma/prisma.service';
import { mapAnalysisWorkspace } from '../mappers/analysis-workspace.mapper';
diff --git a/apps/api/src/modules/impact-analysis/application/queries/get-impact-analysis-lineage.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/queries/get-impact-analysis-lineage.usecase.spec.ts
index eac28778..c7762af5 100644
--- a/apps/api/src/modules/impact-analysis/application/queries/get-impact-analysis-lineage.usecase.spec.ts
+++ b/apps/api/src/modules/impact-analysis/application/queries/get-impact-analysis-lineage.usecase.spec.ts
@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GetImpactAnalysisLineageUseCase } from './get-impact-analysis-lineage.usecase';
import { PrismaService } from '../../../prisma/prisma.service';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
describe('GetImpactAnalysisLineageUseCase', () => {
let useCase: GetImpactAnalysisLineageUseCase;
diff --git a/apps/api/src/modules/impact-analysis/application/queries/get-impact-analysis-lineage.usecase.ts b/apps/api/src/modules/impact-analysis/application/queries/get-impact-analysis-lineage.usecase.ts
index 6f46eb22..fb325993 100644
--- a/apps/api/src/modules/impact-analysis/application/queries/get-impact-analysis-lineage.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/queries/get-impact-analysis-lineage.usecase.ts
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { LineageTimelineEvent, LineageTimelineResponse } from '@ba-helper/contracts';
const EVENT_ORDER: Record = {
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 4ceef5af..ebb7f7e5 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
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
-import { AppError, AppErrorCode } from '../../../../shared/app-error';
+import { AppError, AppErrorCode } from '@ba-helper/shared';
import { ImpactAnalysisDiffResponse, DiffArtifact, DiffInsight, DiagnosticItem } from '@ba-helper/contracts';
import { InsightType } from '@prisma/client';
diff --git a/apps/api/src/modules/impact-analysis/application/queries/get-impact-graph.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/queries/get-impact-graph.usecase.spec.ts
index 7ffc4ee6..b1a9e08b 100644
--- a/apps/api/src/modules/impact-analysis/application/queries/get-impact-graph.usecase.spec.ts
+++ b/apps/api/src/modules/impact-analysis/application/queries/get-impact-graph.usecase.spec.ts
@@ -1,7 +1,7 @@
import { GetImpactGraphUseCase } from './get-impact-graph.usecase';
import { ImpactGraphReadModelBuilder } from './impact-graph-read-model.builder';
import { PrismaService } from '../../../prisma/prisma.service';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
// ── Helper builders ──────────────────────────────────────────────────────────
diff --git a/apps/api/src/modules/impact-analysis/application/queries/impact-graph-read-model.builder.ts b/apps/api/src/modules/impact-analysis/application/queries/impact-graph-read-model.builder.ts
index 53ef3d08..d96ee3b9 100644
--- a/apps/api/src/modules/impact-analysis/application/queries/impact-graph-read-model.builder.ts
+++ b/apps/api/src/modules/impact-analysis/application/queries/impact-graph-read-model.builder.ts
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import {
ImpactGraphResponse,
ImpactGraphNode,
diff --git a/apps/api/src/modules/impact-analysis/application/review/answer-review-clarification.usecase.ts b/apps/api/src/modules/impact-analysis/application/review/answer-review-clarification.usecase.ts
index f6ead8bc..666e2910 100644
--- a/apps/api/src/modules/impact-analysis/application/review/answer-review-clarification.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/review/answer-review-clarification.usecase.ts
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { ReviewClarificationRepository } from '../../infrastructure/review-clarification.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { RequestUser } from '@ba-helper/contracts';
@Injectable()
diff --git a/apps/api/src/modules/impact-analysis/application/review/create-analysis-review-decision.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/review/create-analysis-review-decision.usecase.spec.ts
index 0455d4d0..d4ae68a1 100644
--- a/apps/api/src/modules/impact-analysis/application/review/create-analysis-review-decision.usecase.spec.ts
+++ b/apps/api/src/modules/impact-analysis/application/review/create-analysis-review-decision.usecase.spec.ts
@@ -9,7 +9,7 @@ import { ReviewNoteRepository } from '../../infrastructure/review-note.repositor
import { ClarificationRepository } from '../../../clarification/infrastructure/clarification.repository';
import { CreateReviewedReportSnapshotUseCase } from '../../../document/application/commands/create-reviewed-report-snapshot.usecase';
import { EnqueueDocumentJobUseCase } from '../../../document/application/commands/enqueue-document-job.usecase';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
describe('CreateAnalysisReviewDecisionUseCase', () => {
let useCase: CreateAnalysisReviewDecisionUseCase;
diff --git a/apps/api/src/modules/impact-analysis/application/review/create-analysis-review-decision.usecase.ts b/apps/api/src/modules/impact-analysis/application/review/create-analysis-review-decision.usecase.ts
index f154468c..cd093817 100644
--- a/apps/api/src/modules/impact-analysis/application/review/create-analysis-review-decision.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/review/create-analysis-review-decision.usecase.ts
@@ -10,7 +10,7 @@ import { ReviewNoteRepository } from '../../infrastructure/review-note.repositor
import { ClarificationRepository } from '../../../clarification/infrastructure/clarification.repository';
import { CreateReviewedReportSnapshotUseCase } from '../../../document/application/commands/create-reviewed-report-snapshot.usecase';
import { EnqueueDocumentJobUseCase } from '../../../document/application/commands/enqueue-document-job.usecase';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { AnalysisReviewDecisionValue } from '@prisma/client';
import { RequestUser } from '@ba-helper/contracts';
diff --git a/apps/api/src/modules/impact-analysis/application/review/create-review-clarification.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/review/create-review-clarification.usecase.spec.ts
index c213fe03..fd5903b1 100644
--- a/apps/api/src/modules/impact-analysis/application/review/create-review-clarification.usecase.spec.ts
+++ b/apps/api/src/modules/impact-analysis/application/review/create-review-clarification.usecase.spec.ts
@@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { CreateReviewClarificationRequestUseCase } from './create-review-clarification.usecase';
import { ReviewClarificationRepository } from '../../infrastructure/review-clarification.repository';
import { ReviewDecisionRepository } from '../../infrastructure/review-decision.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
describe('CreateReviewClarificationRequestUseCase', () => {
let useCase: CreateReviewClarificationRequestUseCase;
diff --git a/apps/api/src/modules/impact-analysis/application/review/create-review-clarification.usecase.ts b/apps/api/src/modules/impact-analysis/application/review/create-review-clarification.usecase.ts
index 4d052ce0..b93cddd9 100644
--- a/apps/api/src/modules/impact-analysis/application/review/create-review-clarification.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/review/create-review-clarification.usecase.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ReviewClarificationRepository } from '../../infrastructure/review-clarification.repository';
import { ReviewDecisionRepository } from '../../infrastructure/review-decision.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ReviewClarificationCreateRequest, RequestUser } from '@ba-helper/contracts';
@Injectable()
diff --git a/apps/api/src/modules/impact-analysis/application/review/get-latest-review-decision.usecase.ts b/apps/api/src/modules/impact-analysis/application/review/get-latest-review-decision.usecase.ts
index 86b798a2..e4a6fc83 100644
--- a/apps/api/src/modules/impact-analysis/application/review/get-latest-review-decision.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/review/get-latest-review-decision.usecase.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ReviewDecisionRepository } from '../../infrastructure/review-decision.repository';
import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
@Injectable()
export class GetLatestReviewDecisionUseCase {
diff --git a/apps/api/src/modules/impact-analysis/application/review/get-review-notes.usecase.ts b/apps/api/src/modules/impact-analysis/application/review/get-review-notes.usecase.ts
index c787bc4b..f36c12f2 100644
--- a/apps/api/src/modules/impact-analysis/application/review/get-review-notes.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/review/get-review-notes.usecase.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ReviewNoteRepository } from '../../infrastructure/review-note.repository';
import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
@Injectable()
export class GetReviewNotesUseCase {
diff --git a/apps/api/src/modules/impact-analysis/application/review/get-review-queue.usecase.ts b/apps/api/src/modules/impact-analysis/application/review/get-review-queue.usecase.ts
index 61e8efd0..90ea6c42 100644
--- a/apps/api/src/modules/impact-analysis/application/review/get-review-queue.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/review/get-review-queue.usecase.ts
@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ReviewQueueResponse, ReviewQueueItem, QaCoverageSeverity } from '@ba-helper/contracts';
import { ImpactGraphReadModelBuilder } from '../queries/impact-graph-read-model.builder';
import { QaCoverageDeriver } from '../qa/qa-coverage.deriver';
diff --git a/apps/api/src/modules/impact-analysis/application/review/list-review-clarifications.usecase.ts b/apps/api/src/modules/impact-analysis/application/review/list-review-clarifications.usecase.ts
index 65428039..e7ff7ac3 100644
--- a/apps/api/src/modules/impact-analysis/application/review/list-review-clarifications.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/review/list-review-clarifications.usecase.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ReviewClarificationRepository } from '../../infrastructure/review-clarification.repository';
import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
@Injectable()
export class ListReviewClarificationsUseCase {
diff --git a/apps/api/src/modules/impact-analysis/application/review/list-review-decisions.usecase.ts b/apps/api/src/modules/impact-analysis/application/review/list-review-decisions.usecase.ts
index a826b71b..08244616 100644
--- a/apps/api/src/modules/impact-analysis/application/review/list-review-decisions.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/review/list-review-decisions.usecase.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ReviewDecisionRepository } from '../../infrastructure/review-decision.repository';
import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
@Injectable()
export class ListReviewDecisionsUseCase {
diff --git a/apps/api/src/modules/impact-analysis/application/review/save-review-note.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/review/save-review-note.usecase.spec.ts
index 576173aa..2a5d090b 100644
--- a/apps/api/src/modules/impact-analysis/application/review/save-review-note.usecase.spec.ts
+++ b/apps/api/src/modules/impact-analysis/application/review/save-review-note.usecase.spec.ts
@@ -3,7 +3,7 @@ import { ReviewNoteRepository } from '../../infrastructure/review-note.repositor
import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository';
import { InsightRepository } from '../../../insight/infrastructure/insight.repository';
import { TraceabilityRepository } from '../../../traceability/infrastructure/traceability.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
describe('SaveReviewNoteUseCase', () => {
let useCase: SaveReviewNoteUseCase;
diff --git a/apps/api/src/modules/impact-analysis/application/review/save-review-note.usecase.ts b/apps/api/src/modules/impact-analysis/application/review/save-review-note.usecase.ts
index 1d3f26bf..e965f553 100644
--- a/apps/api/src/modules/impact-analysis/application/review/save-review-note.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/review/save-review-note.usecase.ts
@@ -4,7 +4,7 @@ import { ReviewNoteRepository } from '../../infrastructure/review-note.repositor
import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository';
import { InsightRepository } from '../../../insight/infrastructure/insight.repository';
import { TraceabilityRepository } from '../../../traceability/infrastructure/traceability.repository';
-import { AppError } from '../../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
@Injectable()
export class SaveReviewNoteUseCase {
diff --git a/apps/api/src/modules/insight/application/review-insight.usecase.spec.ts b/apps/api/src/modules/insight/application/review-insight.usecase.spec.ts
index eb46e85c..fdda375f 100644
--- a/apps/api/src/modules/insight/application/review-insight.usecase.spec.ts
+++ b/apps/api/src/modules/insight/application/review-insight.usecase.spec.ts
@@ -1,7 +1,7 @@
import { ReviewInsightUseCase } from './review-insight.usecase';
import { InsightRepository } from '../infrastructure/insight.repository';
import { EventLogService } from '../../event-log/application/event-log.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
describe('ReviewInsightUseCase', () => {
let useCase: ReviewInsightUseCase;
diff --git a/apps/api/src/modules/insight/application/review-insight.usecase.ts b/apps/api/src/modules/insight/application/review-insight.usecase.ts
index ab730c1c..b44c475d 100644
--- a/apps/api/src/modules/insight/application/review-insight.usecase.ts
+++ b/apps/api/src/modules/insight/application/review-insight.usecase.ts
@@ -1,6 +1,6 @@
import { InsightRepository } from '../infrastructure/insight.repository';
import { EventLogService } from '../../event-log/application/event-log.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ReviewPolicy } from '../../review/domain/review.policy';
diff --git a/apps/api/src/modules/insight/domain/insight.policy.ts b/apps/api/src/modules/insight/domain/insight.policy.ts
index c46b75d1..1ad6806b 100644
--- a/apps/api/src/modules/insight/domain/insight.policy.ts
+++ b/apps/api/src/modules/insight/domain/insight.policy.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export const InsightPolicy = {
validateInsight: (insight: { certainty: string; evidenceCount: number }) => {
diff --git a/apps/api/src/modules/project/application/create-project.usecase.ts b/apps/api/src/modules/project/application/create-project.usecase.ts
index 383035ad..9400860d 100644
--- a/apps/api/src/modules/project/application/create-project.usecase.ts
+++ b/apps/api/src/modules/project/application/create-project.usecase.ts
@@ -1,7 +1,7 @@
import type { RequestUser } from '@ba-helper/contracts';
import { ProjectRepository } from '../infrastructure/project.repository';
import { EventLogService } from '../../event-log/application/event-log.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { mapGlobalRoleToProjectRole } from '../domain/project-membership.policy';
export class CreateProjectUseCase {
diff --git a/apps/api/src/modules/project/application/get-current-workspace.usecase.ts b/apps/api/src/modules/project/application/get-current-workspace.usecase.ts
index 19a7f862..90cdd7f9 100644
--- a/apps/api/src/modules/project/application/get-current-workspace.usecase.ts
+++ b/apps/api/src/modules/project/application/get-current-workspace.usecase.ts
@@ -1,5 +1,5 @@
import type { RequestUser } from '@ba-helper/contracts';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import {
CurrentWorkspaceResolver,
} from './current-workspace.resolver';
diff --git a/apps/api/src/modules/project/application/remove-project-member.usecase.ts b/apps/api/src/modules/project/application/remove-project-member.usecase.ts
index 0343c40a..62ed3769 100644
--- a/apps/api/src/modules/project/application/remove-project-member.usecase.ts
+++ b/apps/api/src/modules/project/application/remove-project-member.usecase.ts
@@ -1,5 +1,5 @@
import type { RequestUser } from '@ba-helper/contracts';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { EventLogService } from '../../event-log/application/event-log.service';
import { ProjectPermissionService } from './project-permission.service';
import { ProjectRepository } from '../infrastructure/project.repository';
diff --git a/apps/api/src/modules/project/application/select-project.usecase.ts b/apps/api/src/modules/project/application/select-project.usecase.ts
index d1ed3bda..e79ee3f9 100644
--- a/apps/api/src/modules/project/application/select-project.usecase.ts
+++ b/apps/api/src/modules/project/application/select-project.usecase.ts
@@ -1,5 +1,5 @@
import type { RequestUser } from '@ba-helper/contracts';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { EventLogService } from '../../event-log/application/event-log.service';
import { GetCurrentWorkspaceUseCase } from './get-current-workspace.usecase';
import { ProjectRepository } from '../infrastructure/project.repository';
diff --git a/apps/api/src/modules/project/application/update-project-member.usecase.ts b/apps/api/src/modules/project/application/update-project-member.usecase.ts
index 1cee0690..36a51d98 100644
--- a/apps/api/src/modules/project/application/update-project-member.usecase.ts
+++ b/apps/api/src/modules/project/application/update-project-member.usecase.ts
@@ -2,7 +2,7 @@ import type {
ProjectMemberUpdateRequest,
RequestUser,
} from '@ba-helper/contracts';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { EventLogService } from '../../event-log/application/event-log.service';
import { ProjectPermissionService } from './project-permission.service';
import { ProjectRepository } from '../infrastructure/project.repository';
diff --git a/apps/api/src/modules/project/application/upsert-project-member.usecase.ts b/apps/api/src/modules/project/application/upsert-project-member.usecase.ts
index 069e2e0c..6c84a37d 100644
--- a/apps/api/src/modules/project/application/upsert-project-member.usecase.ts
+++ b/apps/api/src/modules/project/application/upsert-project-member.usecase.ts
@@ -2,7 +2,7 @@ import type {
ProjectMemberUpsertRequest,
RequestUser,
} from '@ba-helper/contracts';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { EventLogService } from '../../event-log/application/event-log.service';
import { ProjectPermissionService } from './project-permission.service';
import { ProjectRepository } from '../infrastructure/project.repository';
diff --git a/apps/api/src/modules/queue/domain/queue.policy.ts b/apps/api/src/modules/queue/domain/queue.policy.ts
index a5832278..8840df6c 100644
--- a/apps/api/src/modules/queue/domain/queue.policy.ts
+++ b/apps/api/src/modules/queue/domain/queue.policy.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export const QueuePolicy = {
assertRetryableJob: (params: {
diff --git a/apps/api/src/modules/repository/application/create-repository.usecase.spec.ts b/apps/api/src/modules/repository/application/create-repository.usecase.spec.ts
index b12cfe33..8aae21ac 100644
--- a/apps/api/src/modules/repository/application/create-repository.usecase.spec.ts
+++ b/apps/api/src/modules/repository/application/create-repository.usecase.spec.ts
@@ -2,7 +2,7 @@ import { CreateRepositoryUseCase } from './create-repository.usecase';
import { RepositoryRepository } from '../infrastructure/repository.repository';
import { ProjectRepository } from '../../project/infrastructure/project.repository';
import { EventLogService } from '../../event-log/application/event-log.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { RepositoryPolicy } from '../domain/repository.policy';
jest.mock('../domain/repository.policy');
diff --git a/apps/api/src/modules/repository/application/create-repository.usecase.ts b/apps/api/src/modules/repository/application/create-repository.usecase.ts
index 60bee248..9bd358e5 100644
--- a/apps/api/src/modules/repository/application/create-repository.usecase.ts
+++ b/apps/api/src/modules/repository/application/create-repository.usecase.ts
@@ -1,7 +1,7 @@
import { RepositoryRepository } from '../infrastructure/repository.repository';
import { ProjectRepository } from '../../project/infrastructure/project.repository';
import { RepositoryPolicy } from '../domain/repository.policy';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { EventLogService } from '../../event-log/application/event-log.service';
export class CreateRepositoryUseCase {
diff --git a/apps/api/src/modules/repository/application/get-repository-snapshot-drift.usecase.ts b/apps/api/src/modules/repository/application/get-repository-snapshot-drift.usecase.ts
index 7737f771..a9c96f5e 100644
--- a/apps/api/src/modules/repository/application/get-repository-snapshot-drift.usecase.ts
+++ b/apps/api/src/modules/repository/application/get-repository-snapshot-drift.usecase.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { PrismaService } from '../../prisma/prisma.service';
import {
DriftStatus,
diff --git a/apps/api/src/modules/repository/application/get-repository.usecase.ts b/apps/api/src/modules/repository/application/get-repository.usecase.ts
index 854ff758..88ae3776 100644
--- a/apps/api/src/modules/repository/application/get-repository.usecase.ts
+++ b/apps/api/src/modules/repository/application/get-repository.usecase.ts
@@ -1,5 +1,5 @@
import { RepositoryRepository } from '../infrastructure/repository.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export class GetRepositoryUseCase {
constructor(private readonly repositoryRepo: RepositoryRepository) {}
diff --git a/apps/api/src/modules/repository/application/list-repositories.usecase.ts b/apps/api/src/modules/repository/application/list-repositories.usecase.ts
index 4d6c25b5..6d031587 100644
--- a/apps/api/src/modules/repository/application/list-repositories.usecase.ts
+++ b/apps/api/src/modules/repository/application/list-repositories.usecase.ts
@@ -1,6 +1,6 @@
import { RepositoryRepository } from '../infrastructure/repository.repository';
import { ProjectRepository } from '../../project/infrastructure/project.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export class ListRepositoriesUseCase {
constructor(
diff --git a/apps/api/src/modules/repository/domain/repository.policy.ts b/apps/api/src/modules/repository/domain/repository.policy.ts
index 10125574..e85d0b75 100644
--- a/apps/api/src/modules/repository/domain/repository.policy.ts
+++ b/apps/api/src/modules/repository/domain/repository.policy.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export type CanonicalRepositoryInput = {
url: string;
diff --git a/apps/api/src/modules/requirement/application/create-requirement.usecase.ts b/apps/api/src/modules/requirement/application/create-requirement.usecase.ts
index 18e8fc76..7f180009 100644
--- a/apps/api/src/modules/requirement/application/create-requirement.usecase.ts
+++ b/apps/api/src/modules/requirement/application/create-requirement.usecase.ts
@@ -1,7 +1,7 @@
import { RequirementRepository } from '../infrastructure/requirement.repository';
import { RequirementPolicy } from '../domain/requirement.policy';
import { EventLogService } from '../../event-log/application/event-log.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ProjectRepository } from '../../project/infrastructure/project.repository';
export class CreateRequirementUseCase {
diff --git a/apps/api/src/modules/requirement/application/create-revision.usecase.spec.ts b/apps/api/src/modules/requirement/application/create-revision.usecase.spec.ts
index 397cc75d..e2c5984b 100644
--- a/apps/api/src/modules/requirement/application/create-revision.usecase.spec.ts
+++ b/apps/api/src/modules/requirement/application/create-revision.usecase.spec.ts
@@ -1,6 +1,6 @@
import { CreateRequirementRevisionUseCase } from './create-revision.usecase';
import { RequirementRepository } from '../infrastructure/requirement.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
describe('CreateRequirementRevisionUseCase', () => {
let useCase: CreateRequirementRevisionUseCase;
diff --git a/apps/api/src/modules/requirement/application/create-revision.usecase.ts b/apps/api/src/modules/requirement/application/create-revision.usecase.ts
index 16d79e79..dac6f11f 100644
--- a/apps/api/src/modules/requirement/application/create-revision.usecase.ts
+++ b/apps/api/src/modules/requirement/application/create-revision.usecase.ts
@@ -1,6 +1,6 @@
import { RequirementRepository } from '../infrastructure/requirement.repository';
import { RequirementPolicy } from '../domain/requirement.policy';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export class CreateRequirementRevisionUseCase {
constructor(private readonly repository: RequirementRepository) {}
diff --git a/apps/api/src/modules/requirement/application/get-requirement.usecase.ts b/apps/api/src/modules/requirement/application/get-requirement.usecase.ts
index 525f5c1b..7a335919 100644
--- a/apps/api/src/modules/requirement/application/get-requirement.usecase.ts
+++ b/apps/api/src/modules/requirement/application/get-requirement.usecase.ts
@@ -1,5 +1,5 @@
import { RequirementRepository } from '../infrastructure/requirement.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export class GetRequirementUseCase {
constructor(private readonly requirementRepo: RequirementRepository) {}
diff --git a/apps/api/src/modules/requirement/application/list-requirements.usecase.ts b/apps/api/src/modules/requirement/application/list-requirements.usecase.ts
index ad81c325..732466c4 100644
--- a/apps/api/src/modules/requirement/application/list-requirements.usecase.ts
+++ b/apps/api/src/modules/requirement/application/list-requirements.usecase.ts
@@ -1,6 +1,6 @@
import { RequirementRepository } from '../infrastructure/requirement.repository';
import { ProjectRepository } from '../../project/infrastructure/project.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export class ListRequirementsUseCase {
constructor(
diff --git a/apps/api/src/modules/requirement/application/qualify-revision.usecase.spec.ts b/apps/api/src/modules/requirement/application/qualify-revision.usecase.spec.ts
index d0f8ebfd..904d67ba 100644
--- a/apps/api/src/modules/requirement/application/qualify-revision.usecase.spec.ts
+++ b/apps/api/src/modules/requirement/application/qualify-revision.usecase.spec.ts
@@ -1,6 +1,6 @@
import { QualifyRequirementRevisionUseCase } from './qualify-revision.usecase';
import { RequirementRepository } from '../infrastructure/requirement.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
describe('QualifyRequirementRevisionUseCase', () => {
let useCase: QualifyRequirementRevisionUseCase;
diff --git a/apps/api/src/modules/requirement/application/qualify-revision.usecase.ts b/apps/api/src/modules/requirement/application/qualify-revision.usecase.ts
index 0b1dc7dc..d458ac44 100644
--- a/apps/api/src/modules/requirement/application/qualify-revision.usecase.ts
+++ b/apps/api/src/modules/requirement/application/qualify-revision.usecase.ts
@@ -1,6 +1,6 @@
import { RequirementRepository } from '../infrastructure/requirement.repository';
import { RequirementPolicy } from '../domain/requirement.policy';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export class QualifyRequirementRevisionUseCase {
constructor(private readonly repository: RequirementRepository) {}
diff --git a/apps/api/src/modules/requirement/domain/requirement.policy.ts b/apps/api/src/modules/requirement/domain/requirement.policy.ts
index 5551b05e..94f2b6c0 100644
--- a/apps/api/src/modules/requirement/domain/requirement.policy.ts
+++ b/apps/api/src/modules/requirement/domain/requirement.policy.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
const secretPatterns = [
/AKIA[0-9A-Z]{16}/,
diff --git a/apps/api/src/modules/review/domain/review.policy.ts b/apps/api/src/modules/review/domain/review.policy.ts
index ef13dd0d..64f86c06 100644
--- a/apps/api/src/modules/review/domain/review.policy.ts
+++ b/apps/api/src/modules/review/domain/review.policy.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export const ReviewPolicy = {
assertCanReview: (analysis: {
diff --git a/apps/api/src/modules/scanner/api/scan-job.controller.ts b/apps/api/src/modules/scanner/api/scan-job.controller.ts
index d59faed7..aeb1535d 100644
--- a/apps/api/src/modules/scanner/api/scan-job.controller.ts
+++ b/apps/api/src/modules/scanner/api/scan-job.controller.ts
@@ -5,7 +5,7 @@ import {
} from '@ba-helper/contracts';
import { CreateScanJobUseCase } from '../application/create-scan-job.usecase';
import { ScanJobRepository } from '../infrastructure/scan-job.repository';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { CurrentUser } from '../../auth/api/current-user.decorator';
import { RequestUser } from '@ba-helper/contracts';
diff --git a/apps/api/src/modules/scanner/application/create-scan-job.usecase.spec.ts b/apps/api/src/modules/scanner/application/create-scan-job.usecase.spec.ts
index 67602745..8bc63d26 100644
--- a/apps/api/src/modules/scanner/application/create-scan-job.usecase.spec.ts
+++ b/apps/api/src/modules/scanner/application/create-scan-job.usecase.spec.ts
@@ -3,7 +3,7 @@ import { ScanJobRepository } from '../infrastructure/scan-job.repository';
import { RepositoryRepository } from '../../repository/infrastructure/repository.repository';
import { EventLogService } from '../../event-log/application/event-log.service';
import { QueueService } from '../../queue/queue.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ScanJobPolicy } from '../domain/scan-job.policy';
jest.mock('../domain/scan-job.policy');
diff --git a/apps/api/src/modules/scanner/application/create-scan-job.usecase.ts b/apps/api/src/modules/scanner/application/create-scan-job.usecase.ts
index 8f912c3a..89f21291 100644
--- a/apps/api/src/modules/scanner/application/create-scan-job.usecase.ts
+++ b/apps/api/src/modules/scanner/application/create-scan-job.usecase.ts
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { ScanJobRepository } from '../infrastructure/scan-job.repository';
import { RepositoryRepository } from '../../repository/infrastructure/repository.repository';
import { ScanJobPolicy } from '../domain/scan-job.policy';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { EventLogService } from '../../event-log/application/event-log.service';
import { QueueService } from '../../queue/queue.service';
diff --git a/apps/api/src/modules/scanner/application/run-scan-job.usecase.spec.ts b/apps/api/src/modules/scanner/application/run-scan-job.usecase.spec.ts
index 6377832b..1ba561c7 100644
--- a/apps/api/src/modules/scanner/application/run-scan-job.usecase.spec.ts
+++ b/apps/api/src/modules/scanner/application/run-scan-job.usecase.spec.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { RunScanJobUseCase } from './run-scan-job.usecase';
import * as fs from 'node:fs/promises';
import { ScanJobStage, ScanJobStatus } from '@prisma/client';
diff --git a/apps/api/src/modules/scanner/application/run-scan-job.usecase.ts b/apps/api/src/modules/scanner/application/run-scan-job.usecase.ts
index d91ad52e..bb139cac 100644
--- a/apps/api/src/modules/scanner/application/run-scan-job.usecase.ts
+++ b/apps/api/src/modules/scanner/application/run-scan-job.usecase.ts
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { ScanJobRepository } from '../infrastructure/scan-job.repository';
import { EventLogService } from '../../event-log/application/event-log.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ScanJobStatus, ScanJobStage, DependencyEdgeType } from '@prisma/client';
import { ArtifactRepository } from '../../artifact/infrastructure/artifact.repository';
import { GraphRepository } from '../../graph/infrastructure/graph.repository';
diff --git a/apps/api/src/modules/scanner/domain/scan-job.policy.ts b/apps/api/src/modules/scanner/domain/scan-job.policy.ts
index 0d8f649c..df45e275 100644
--- a/apps/api/src/modules/scanner/domain/scan-job.policy.ts
+++ b/apps/api/src/modules/scanner/domain/scan-job.policy.ts
@@ -1,4 +1,4 @@
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
export const ScanJobPolicy = {
validateRef: (ref?: string) => {
diff --git a/apps/api/src/modules/traceability/application/delete-traceability-review-decision.usecase.ts b/apps/api/src/modules/traceability/application/delete-traceability-review-decision.usecase.ts
index 81509cfd..d89536ce 100644
--- a/apps/api/src/modules/traceability/application/delete-traceability-review-decision.usecase.ts
+++ b/apps/api/src/modules/traceability/application/delete-traceability-review-decision.usecase.ts
@@ -1,6 +1,6 @@
import { TraceabilityRepository } from '../infrastructure/traceability.repository';
import { EventLogService } from '../../event-log/application/event-log.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ReviewPolicy } from '../../review/domain/review.policy';
export class DeleteTraceabilityReviewDecisionUseCase {
diff --git a/apps/api/src/modules/traceability/application/get-review-completion.usecase.ts b/apps/api/src/modules/traceability/application/get-review-completion.usecase.ts
index 3eed80c6..31196e96 100644
--- a/apps/api/src/modules/traceability/application/get-review-completion.usecase.ts
+++ b/apps/api/src/modules/traceability/application/get-review-completion.usecase.ts
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { TraceabilityRepository } from '../infrastructure/traceability.repository';
import { ReviewCompletionResponse } from '@ba-helper/contracts';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
@Injectable()
export class GetReviewCompletionUseCase {
diff --git a/apps/api/src/modules/traceability/application/review-traceability.usecase.spec.ts b/apps/api/src/modules/traceability/application/review-traceability.usecase.spec.ts
index ce81dfd4..c4a7fdc4 100644
--- a/apps/api/src/modules/traceability/application/review-traceability.usecase.spec.ts
+++ b/apps/api/src/modules/traceability/application/review-traceability.usecase.spec.ts
@@ -1,7 +1,7 @@
import { ReviewTraceabilityUseCase } from './review-traceability.usecase';
import { TraceabilityRepository } from '../infrastructure/traceability.repository';
import { EventLogService } from '../../event-log/application/event-log.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
describe('ReviewTraceabilityUseCase', () => {
let useCase: ReviewTraceabilityUseCase;
diff --git a/apps/api/src/modules/traceability/application/review-traceability.usecase.ts b/apps/api/src/modules/traceability/application/review-traceability.usecase.ts
index 1fa29366..c708c391 100644
--- a/apps/api/src/modules/traceability/application/review-traceability.usecase.ts
+++ b/apps/api/src/modules/traceability/application/review-traceability.usecase.ts
@@ -1,6 +1,6 @@
import { TraceabilityRepository } from '../infrastructure/traceability.repository';
import { EventLogService } from '../../event-log/application/event-log.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ReviewPolicy } from '../../review/domain/review.policy';
diff --git a/apps/api/src/modules/traceability/application/update-traceability-review-decision.usecase.ts b/apps/api/src/modules/traceability/application/update-traceability-review-decision.usecase.ts
index 809da665..9d4f26ba 100644
--- a/apps/api/src/modules/traceability/application/update-traceability-review-decision.usecase.ts
+++ b/apps/api/src/modules/traceability/application/update-traceability-review-decision.usecase.ts
@@ -1,6 +1,6 @@
import { TraceabilityRepository } from '../infrastructure/traceability.repository';
import { EventLogService } from '../../event-log/application/event-log.service';
-import { AppError } from '../../../shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { ReviewPolicy } from '../../review/domain/review.policy';
export class UpdateTraceabilityReviewDecisionUseCase {
diff --git a/apps/api/src/shared/app-exception.filter.ts b/apps/api/src/shared/app-exception.filter.ts
index 8984fbdf..7dda0e0e 100644
--- a/apps/api/src/shared/app-exception.filter.ts
+++ b/apps/api/src/shared/app-exception.filter.ts
@@ -5,7 +5,7 @@ import {
HttpStatus,
Logger,
} from '@nestjs/common';
-import { AppError } from './app-error';
+import { AppError } from '@ba-helper/shared';
@Catch(AppError)
export class AppExceptionFilter implements ExceptionFilter {
diff --git a/apps/api/test/e2e/multi-repo-analysis.e2e-spec.ts b/apps/api/test/e2e/multi-repo-analysis.e2e-spec.ts
index 60bf62d4..b98007f3 100644
--- a/apps/api/test/e2e/multi-repo-analysis.e2e-spec.ts
+++ b/apps/api/test/e2e/multi-repo-analysis.e2e-spec.ts
@@ -4,7 +4,7 @@ import * as crypto from 'crypto';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../../src/modules/prisma/prisma.service';
import { PdfExportRenderer } from '../../src/modules/document/application/pdf-export.renderer';
-import { AppError } from '../../src/shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { createTestApp } from './helpers/test-app';
import { resetDatabase } from './helpers/reset-db';
import { grantProjectMembership } from './helpers/grant-project-membership';
diff --git a/packages/shared/src/config/ai-config.ts b/packages/shared/src/config/ai-config.ts
new file mode 100644
index 00000000..b63f2cdf
--- /dev/null
+++ b/packages/shared/src/config/ai-config.ts
@@ -0,0 +1,64 @@
+export interface AiConfig {
+ provider: 'fake' | 'openai' | 'anthropic' | 'google' | 'deepseek';
+ defaultModel: string;
+ temperature: number;
+ maxTokens: number;
+ /** Có bật secret redaction không (always true in prod) */
+ redactSecrets: boolean;
+ /** Configured API key if applicable */
+ apiKey?: string;
+ /** Configured base URL if applicable */
+ baseUrl?: string;
+}
+
+const AI_PROVIDERS = ['fake', 'openai', 'anthropic', 'google', 'deepseek'] as const;
+
+export function resolveAiProvider(rawProvider?: string): AiConfig['provider'] {
+ const provider = (rawProvider ?? 'fake').trim().toLowerCase();
+ if ((AI_PROVIDERS as readonly string[]).includes(provider)) {
+ return provider as AiConfig['provider'];
+ }
+ throw new Error(`Unsupported AI_PROVIDER "${rawProvider}". Expected one of: ${AI_PROVIDERS.join(', ')}.`);
+}
+
+export function resolveAiConfig(env: NodeJS.ProcessEnv): AiConfig {
+ const provider = resolveAiProvider(env.AI_PROVIDER);
+
+ if (env.NODE_ENV === 'production' && provider === 'fake') {
+ throw new Error('FakeLlmProvider is forbidden in production. Please set AI_PROVIDER.');
+ }
+
+ let defaultModel = env.AI_MODEL;
+ if (!defaultModel) {
+ switch (provider) {
+ case 'google': defaultModel = env.GOOGLE_MODEL ?? env.GEMINI_MODEL ?? 'gemini-2.5-flash'; break;
+ case 'anthropic': defaultModel = env.ANTHROPIC_MODEL ?? 'claude-3-5-sonnet-20241022'; break;
+ case 'openai': defaultModel = env.OPENAI_MODEL ?? 'gpt-4o'; break;
+ case 'deepseek': defaultModel = env.DEEPSEEK_MODEL ?? 'deepseek-chat'; break;
+ default: defaultModel = 'gpt-4o';
+ }
+ }
+
+ let apiKey: string | undefined;
+ let baseUrl: string | undefined;
+
+ switch (provider) {
+ case 'openai': apiKey = env.OPENAI_API_KEY; break;
+ case 'anthropic': apiKey = env.ANTHROPIC_API_KEY; break;
+ case 'google': apiKey = env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY ?? env.GOOGLE_AI_API_KEY; break;
+ case 'deepseek':
+ apiKey = env.DEEPSEEK_API_KEY;
+ baseUrl = env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com';
+ break;
+ }
+
+ return {
+ provider,
+ defaultModel,
+ temperature: Number(env.AI_TEMPERATURE ?? 0.2),
+ maxTokens: Number(env.AI_MAX_TOKENS ?? 8192),
+ redactSecrets: env.NODE_ENV !== 'test',
+ apiKey,
+ baseUrl,
+ };
+}
diff --git a/packages/shared/src/config/embedding-config.ts b/packages/shared/src/config/embedding-config.ts
new file mode 100644
index 00000000..1dd7fc21
--- /dev/null
+++ b/packages/shared/src/config/embedding-config.ts
@@ -0,0 +1,28 @@
+const EMBEDDING_PROVIDERS = ['fake', 'openai', 'google'] as const;
+export type EmbeddingProviderName = (typeof EMBEDDING_PROVIDERS)[number];
+
+export interface EmbeddingConfig {
+ provider: EmbeddingProviderName;
+ apiKey?: string;
+}
+
+export function resolveEmbeddingProvider(rawProvider?: string): EmbeddingProviderName {
+ const provider = (rawProvider || 'fake').trim().toLowerCase();
+
+ if ((EMBEDDING_PROVIDERS as readonly string[]).includes(provider)) {
+ return provider as EmbeddingProviderName;
+ }
+ throw new Error(`Unsupported EMBEDDING_PROVIDER "${rawProvider}". Expected one of: ${EMBEDDING_PROVIDERS.join(', ')}.`);
+}
+
+export function resolveEmbeddingConfig(env: NodeJS.ProcessEnv): EmbeddingConfig {
+ const provider = resolveEmbeddingProvider(env.EMBEDDING_PROVIDER);
+ let apiKey: string | undefined;
+
+ switch (provider) {
+ case 'openai': apiKey = env.OPENAI_API_KEY; break;
+ case 'google': apiKey = env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY ?? env.GOOGLE_AI_API_KEY; break;
+ }
+
+ return { provider, apiKey };
+}
diff --git a/packages/shared/src/config/runtime-config.ts b/packages/shared/src/config/runtime-config.ts
new file mode 100644
index 00000000..2fe09e8e
--- /dev/null
+++ b/packages/shared/src/config/runtime-config.ts
@@ -0,0 +1,162 @@
+const DEFAULT_WORKSPACE_MODE = 'dev-single-user' as const;
+const DEFAULT_API_VERSION = process.env.APP_VERSION ?? '0.1.0';
+const DEFAULT_DEV_CORS_ALLOWED_ORIGINS = [
+ 'http://localhost:3000',
+ 'http://localhost:3001',
+ 'http://127.0.0.1:3000',
+ 'http://127.0.0.1:3001',
+];
+
+export type WorkspaceMode = typeof DEFAULT_WORKSPACE_MODE;
+
+export interface RuntimeConfig {
+ apiVersion: string;
+ corsAllowedOrigins: string[];
+ isProductionLike: boolean;
+ nodeEnv: string;
+ port: number;
+ workspaceMode: string;
+ publicPreviewMode: boolean;
+ aiProvider: string;
+ enableDevLogin: boolean;
+}
+
+export function isProductionLikeEnv(nodeEnv?: string): boolean {
+ return nodeEnv === 'production' || nodeEnv === 'staging';
+}
+
+export function isWeakSecret(secret?: string): boolean {
+ if (!secret) return true;
+ const normalized = secret.trim();
+ if (!normalized) return true;
+
+ const weakSecrets = new Set([
+ 'dev-secret-change-me',
+ 'dev-super-secret-key',
+ 'dev-only-local-jwt-secret',
+ 'change-me',
+ 'replace-with-a-long-random-secret',
+ 'postgresql://localhost/ba_helper',
+ 'postgresql://ba_helper:ba_helper@localhost/ba_helper',
+ 'redis://localhost:6379',
+ 'dev-secret',
+ 'secret',
+ ]);
+
+ return weakSecrets.has(normalized);
+}
+
+export function requireEnv(key: string, devFallback?: string, nodeEnv?: string): string {
+ const env = nodeEnv ?? process.env.NODE_ENV ?? 'development';
+ const isProd = isProductionLikeEnv(env);
+ const value = process.env[key];
+
+ if (isProd) {
+ if (!value) {
+ throw new Error(`Environment variable ${key} is required in production.`);
+ }
+ if (isWeakSecret(value)) {
+ throw new Error(`Environment variable ${key} must not use a weak or default value in production.`);
+ }
+ }
+
+ return value || devFallback || '';
+}
+
+export function normalizeOrigin(origin: string): string {
+ const value = origin.trim();
+
+ if (!value) {
+ throw new Error('CORS origin entries must not be empty.');
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(value);
+ } catch {
+ throw new Error(`Invalid CORS origin: ${origin}`);
+ }
+
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ throw new Error(`Unsupported CORS origin protocol: ${origin}`);
+ }
+
+ if (parsed.pathname !== '/' || parsed.search || parsed.hash) {
+ throw new Error(`CORS origins must not include path, query, or hash: ${origin}`);
+ }
+
+ return parsed.origin;
+}
+
+export function parseCorsAllowedOrigins(raw?: string): string[] {
+ if (!raw || !raw.trim()) {
+ return [];
+ }
+
+ const normalized = raw
+ .split(',')
+ .map((entry) => normalizeOrigin(entry))
+ .filter((entry, index, list) => list.indexOf(entry) === index);
+
+ return normalized;
+}
+
+export function getRuntimeConfig(
+ env: NodeJS.ProcessEnv = process.env,
+): RuntimeConfig {
+ const nodeEnv = env.NODE_ENV ?? 'development';
+ const isProductionLike = isProductionLikeEnv(nodeEnv);
+ const configuredOrigins = parseCorsAllowedOrigins(env.CORS_ALLOWED_ORIGINS);
+
+ return {
+ apiVersion: env.APP_VERSION ?? DEFAULT_API_VERSION,
+ corsAllowedOrigins:
+ configuredOrigins.length > 0
+ ? configuredOrigins
+ : isProductionLike
+ ? []
+ : DEFAULT_DEV_CORS_ALLOWED_ORIGINS,
+ isProductionLike,
+ nodeEnv,
+ port: Number(env.PORT ?? '3001'),
+ workspaceMode: env.WORKSPACE_MODE ?? DEFAULT_WORKSPACE_MODE,
+ publicPreviewMode: env.PUBLIC_PREVIEW_MODE === 'true',
+ aiProvider: env.AI_PROVIDER || 'fake',
+ enableDevLogin: env.ENABLE_DEV_LOGIN === 'true',
+ };
+}
+
+export function validateRuntimeConfig(config: RuntimeConfig, env: NodeJS.ProcessEnv = process.env): void {
+ if (Number.isNaN(config.port) || config.port <= 0) {
+ throw new Error(`Invalid PORT: ${config.port}`);
+ }
+
+ if (config.isProductionLike && config.corsAllowedOrigins.length === 0) {
+ throw new Error(
+ 'CORS_ALLOWED_ORIGINS must be configured for production-like deploys.',
+ );
+ }
+
+ if (config.publicPreviewMode) {
+ if (config.aiProvider !== 'fake') {
+ throw new Error(`BOOT GUARD: PUBLIC_PREVIEW_MODE is active, but AI_PROVIDER is '${config.aiProvider}'. It must be 'fake'.`);
+ }
+ if (env.OPENAI_API_KEY) throw new Error('BOOT GUARD: OPENAI_API_KEY is forbidden in PUBLIC_PREVIEW_MODE.');
+ if (env.GEMINI_API_KEY || env.GOOGLE_API_KEY) throw new Error('BOOT GUARD: GEMINI/GOOGLE API keys are forbidden in PUBLIC_PREVIEW_MODE.');
+ if (env.ANTHROPIC_API_KEY) throw new Error('BOOT GUARD: ANTHROPIC_API_KEY is forbidden in PUBLIC_PREVIEW_MODE.');
+ if (env.DEEPSEEK_API_KEY) throw new Error('BOOT GUARD: DEEPSEEK_API_KEY is forbidden in PUBLIC_PREVIEW_MODE.');
+ }
+
+ if (config.enableDevLogin) {
+ if (config.isProductionLike) {
+ throw new Error('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in production/staging environments.');
+ }
+ if (config.publicPreviewMode) {
+ throw new Error('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in PUBLIC_PREVIEW_MODE.');
+ }
+ if (config.workspaceMode !== DEFAULT_WORKSPACE_MODE) {
+ throw new Error(`BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden when workspace mode is '${config.workspaceMode}'.`);
+ }
+ }
+}
+
diff --git a/apps/api/src/shared/app-error.ts b/packages/shared/src/errors/app-error.ts
similarity index 100%
rename from apps/api/src/shared/app-error.ts
rename to packages/shared/src/errors/app-error.ts
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 95f4e188..828fad61 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1,2 +1,5 @@
-// Shared utilities live here (placeholder).
-export const sharedPlaceholder = true;
+export * from './errors/app-error';
+export * from './config/runtime-config';
+export * from './config/ai-config';
+export * from './config/embedding-config';
+export * from './policies/ai.policy';
diff --git a/apps/api/src/modules/ai/domain/ai.policy.ts b/packages/shared/src/policies/ai.policy.ts
similarity index 100%
rename from apps/api/src/modules/ai/domain/ai.policy.ts
rename to packages/shared/src/policies/ai.policy.ts
diff --git a/tests/api/impact-analysis.review.spec.ts b/tests/api/impact-analysis.review.spec.ts
index d45fd765..034c1c85 100644
--- a/tests/api/impact-analysis.review.spec.ts
+++ b/tests/api/impact-analysis.review.spec.ts
@@ -1,5 +1,5 @@
import { FinalizeImpactAnalysisUseCase } from '../../apps/api/src/modules/impact-analysis/application/lifecycle/finalize-impact-analysis.usecase';
-import { AppError } from '../../apps/api/src/shared/app-error';
+import { AppError } from '@ba-helper/shared';
const defaultAnalysisState = {
id: 'analysis-1',
diff --git a/tests/api/input-gates.spec.ts b/tests/api/input-gates.spec.ts
index fb81f689..d57e3b60 100644
--- a/tests/api/input-gates.spec.ts
+++ b/tests/api/input-gates.spec.ts
@@ -1,7 +1,7 @@
import { RepositoryPolicy } from '../../apps/api/src/modules/repository/domain/repository.policy';
import { ScanJobPolicy } from '../../apps/api/src/modules/scanner/domain/scan-job.policy';
import { RequirementPolicy } from '../../apps/api/src/modules/requirement/domain/requirement.policy';
-import { AppError } from '../../apps/api/src/shared/app-error';
+import { AppError } from '@ba-helper/shared';
describe('input gate policies', () => {
const getErrorCode = (fn: () => void) => {
diff --git a/tests/embedding/embedding-policy.spec.ts b/tests/embedding/embedding-policy.spec.ts
index e2c56e07..f67fb83c 100644
--- a/tests/embedding/embedding-policy.spec.ts
+++ b/tests/embedding/embedding-policy.spec.ts
@@ -1,5 +1,5 @@
import { EmbeddingPolicy } from '../../apps/api/src/modules/embedding/domain/embedding.policy';
-import { AiPolicy } from '../../apps/api/src/modules/ai/domain/ai.policy';
+import { AiPolicy } from '@ba-helper/shared';
describe('EmbeddingPolicy', () => {
describe('buildArtifactContent', () => {
diff --git a/tests/impact-analysis/domain-quality-evaluation.spec.ts b/tests/impact-analysis/domain-quality-evaluation.spec.ts
index 5384dbb2..482b8572 100644
--- a/tests/impact-analysis/domain-quality-evaluation.spec.ts
+++ b/tests/impact-analysis/domain-quality-evaluation.spec.ts
@@ -8,7 +8,7 @@ import { AppModule } from '../../apps/api/src/app.module';
import { prepareIsolatedTestEnv } from '../../apps/api/test/e2e/helpers/prepare-test-env';
import { resetDatabase } from '../../apps/api/test/e2e/helpers/reset-db';
import { ALL_EVALUATION_CASES } from '../evaluation/cases';
-import { AppError } from '../../apps/api/src/shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { EmbeddingChunkRepository } from '../../apps/api/src/modules/embedding/infrastructure/embedding-chunk.repository';
// @ts-ignore
import * as dotenv from 'dotenv';
diff --git a/tests/impact-analysis/run-impact-analysis.spec.ts b/tests/impact-analysis/run-impact-analysis.spec.ts
index 5fe73bdd..9b90a7b2 100644
--- a/tests/impact-analysis/run-impact-analysis.spec.ts
+++ b/tests/impact-analysis/run-impact-analysis.spec.ts
@@ -1,5 +1,5 @@
import { RunImpactAnalysisUseCase } from '../../apps/api/src/modules/impact-analysis/application/lifecycle/run-impact-analysis.usecase';
-import { AppError } from '../../apps/api/src/shared/app-error';
+import { AppError } from '@ba-helper/shared';
import { FakeLlmProvider } from '../../apps/api/src/modules/ai/infrastructure/fake-ai.provider';
import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry';
import { ImpactEvidenceCollectionStep } from '../../apps/api/src/modules/impact-analysis/application/lifecycle/steps/impact-evidence-collection.step';
From b909a691cd4acf9e932589e63f415909800c258b Mon Sep 17 00:00:00 2001
From: Dip Hung Thinh
Date: Thu, 25 Jun 2026 17:54:49 +0700
Subject: [PATCH 04/35] refactor(auth): isolate dev-login policy and explicit
auth mode
---
apps/api/src/bootstrap/runtime-config.spec.ts | 76 ++++++++++---
apps/api/src/bootstrap/runtime-config.ts | 4 +-
.../modules/auth/api/auth.controller.spec.ts | 9 +-
apps/web/package.json | 1 +
apps/web/src/app/login/page.tsx | 4 +-
apps/web/src/components/auth/login-form.tsx | 106 ++++++++++--------
apps/web/src/lib/auth-options.ts | 7 ++
packages/shared/src/config/runtime-config.ts | 27 ++++-
pnpm-lock.yaml | 13 ++-
9 files changed, 177 insertions(+), 70 deletions(-)
diff --git a/apps/api/src/bootstrap/runtime-config.spec.ts b/apps/api/src/bootstrap/runtime-config.spec.ts
index d8e6f482..3b347783 100644
--- a/apps/api/src/bootstrap/runtime-config.spec.ts
+++ b/apps/api/src/bootstrap/runtime-config.spec.ts
@@ -2,6 +2,7 @@ import {
getRuntimeConfig,
parseCorsAllowedOrigins,
validateRuntimeConfig,
+ resolveAuthMode,
} from './runtime-config';
describe('runtime-config', () => {
@@ -45,50 +46,95 @@ describe('runtime-config', () => {
describe('Dev Login Policy', () => {
it('throws if ENABLE_DEV_LOGIN is true in production', () => {
- const config = getRuntimeConfig({
+ const env = {
NODE_ENV: 'production',
ENABLE_DEV_LOGIN: 'true',
CORS_ALLOWED_ORIGINS: 'https://web.example.com',
- });
- expect(() => validateRuntimeConfig(config)).toThrow('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in production/staging environments.');
+ };
+ const config = getRuntimeConfig(env);
+ expect(() => validateRuntimeConfig(config, env)).toThrow('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in production/staging environments.');
});
it('throws if ENABLE_DEV_LOGIN is true in staging', () => {
- const config = getRuntimeConfig({
+ const env = {
NODE_ENV: 'staging',
ENABLE_DEV_LOGIN: 'true',
CORS_ALLOWED_ORIGINS: 'https://web.example.com',
- });
- expect(() => validateRuntimeConfig(config)).toThrow('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in production/staging environments.');
+ };
+ const config = getRuntimeConfig(env);
+ expect(() => validateRuntimeConfig(config, env)).toThrow('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in production/staging environments.');
});
it('throws if ENABLE_DEV_LOGIN is true in public preview', () => {
- const config = getRuntimeConfig({
+ const env = {
NODE_ENV: 'development',
ENABLE_DEV_LOGIN: 'true',
PUBLIC_PREVIEW_MODE: 'true',
- });
- expect(() => validateRuntimeConfig(config)).toThrow('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in PUBLIC_PREVIEW_MODE.');
+ };
+ const config = getRuntimeConfig(env);
+ expect(() => validateRuntimeConfig(config, env)).toThrow('BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden in PUBLIC_PREVIEW_MODE.');
});
it('throws if ENABLE_DEV_LOGIN is true in team workspace mode', () => {
- const config = getRuntimeConfig({
+ const env = {
NODE_ENV: 'development',
ENABLE_DEV_LOGIN: 'true',
WORKSPACE_MODE: 'team-dev',
- });
- expect(() => validateRuntimeConfig(config)).toThrow("BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden when workspace mode is 'team-dev'.");
+ };
+ const config = getRuntimeConfig(env);
+ expect(() => validateRuntimeConfig(config, env)).toThrow("BOOT GUARD: ENABLE_DEV_LOGIN=true is forbidden when workspace mode is 'team-dev'.");
});
it('allows dev-login in development with dev-single-user mode', () => {
- const config = getRuntimeConfig({
+ const env = {
NODE_ENV: 'development',
ENABLE_DEV_LOGIN: 'true',
WORKSPACE_MODE: 'dev-single-user',
- });
- expect(() => validateRuntimeConfig(config)).not.toThrow();
+ };
+ const config = getRuntimeConfig(env);
+ expect(() => validateRuntimeConfig(config, env)).not.toThrow();
expect(config.enableDevLogin).toBe(true);
});
+
+ it('defaults to dev-login true in development when not explicitly set', () => {
+ const env = {
+ NODE_ENV: 'development',
+ WORKSPACE_MODE: 'dev-single-user',
+ };
+ const config = getRuntimeConfig(env);
+ expect(() => validateRuntimeConfig(config, env)).not.toThrow();
+ expect(config.enableDevLogin).toBe(true);
+ });
+ });
+
+ describe('resolveAuthMode', () => {
+ it('resolves dev-login for development when not explicitly set', () => {
+ expect(resolveAuthMode({ NODE_ENV: 'development' })).toBe('dev-login');
+ });
+
+ it('resolves unsupported for development when explicitly disabled', () => {
+ expect(resolveAuthMode({ NODE_ENV: 'development', ENABLE_DEV_LOGIN: 'false' })).toBe('unsupported');
+ });
+
+ it('resolves dev-login for development when explicitly enabled', () => {
+ expect(resolveAuthMode({ NODE_ENV: 'development', ENABLE_DEV_LOGIN: 'true' })).toBe('dev-login');
+ });
+
+ it('resolves unsupported for production even if enabled', () => {
+ expect(resolveAuthMode({ NODE_ENV: 'production', ENABLE_DEV_LOGIN: 'true' })).toBe('unsupported');
+ });
+
+ it('resolves unsupported for staging even if enabled', () => {
+ expect(resolveAuthMode({ NODE_ENV: 'staging', ENABLE_DEV_LOGIN: 'true' })).toBe('unsupported');
+ });
+
+ it('resolves unsupported if PREVIEW_AUTH_ENABLED=true', () => {
+ expect(resolveAuthMode({ NODE_ENV: 'development', PREVIEW_AUTH_ENABLED: 'true', ENABLE_DEV_LOGIN: 'true' })).toBe('unsupported');
+ });
+
+ it('resolves unsupported if PUBLIC_PREVIEW_MODE=true', () => {
+ expect(resolveAuthMode({ NODE_ENV: 'development', PUBLIC_PREVIEW_MODE: 'true', ENABLE_DEV_LOGIN: 'true' })).toBe('unsupported');
+ });
});
});
diff --git a/apps/api/src/bootstrap/runtime-config.ts b/apps/api/src/bootstrap/runtime-config.ts
index dc49606a..ec7088ed 100644
--- a/apps/api/src/bootstrap/runtime-config.ts
+++ b/apps/api/src/bootstrap/runtime-config.ts
@@ -1,5 +1,6 @@
export {
WorkspaceMode,
+ AuthMode,
RuntimeConfig,
isProductionLikeEnv,
isWeakSecret,
@@ -7,5 +8,6 @@ export {
normalizeOrigin,
parseCorsAllowedOrigins,
getRuntimeConfig,
- validateRuntimeConfig
+ validateRuntimeConfig,
+ resolveAuthMode
} from '@ba-helper/shared';
diff --git a/apps/api/src/modules/auth/api/auth.controller.spec.ts b/apps/api/src/modules/auth/api/auth.controller.spec.ts
index 35e5f703..7566bbf9 100644
--- a/apps/api/src/modules/auth/api/auth.controller.spec.ts
+++ b/apps/api/src/modules/auth/api/auth.controller.spec.ts
@@ -54,12 +54,19 @@ describe('AuthController', () => {
}
});
- it('should throw ForbiddenException if ENABLE_DEV_LOGIN is not true', async () => {
+ it('should throw ForbiddenException if explicitly disabled', async () => {
process.env.ENABLE_DEV_LOGIN = 'false';
await expect(controller.devLogin({ email: 'test@example.com' })).rejects.toThrow(ForbiddenException);
+ });
+ it('should throw ForbiddenException if not enabled and not in local dev', async () => {
delete process.env.ENABLE_DEV_LOGIN;
+ const originalNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production';
+
await expect(controller.devLogin({ email: 'test@example.com' })).rejects.toThrow(ForbiddenException);
+
+ process.env.NODE_ENV = originalNodeEnv;
});
it('should create user and issue token if ENABLE_DEV_LOGIN is true', async () => {
diff --git a/apps/web/package.json b/apps/web/package.json
index dc4cc932..84142ff7 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -10,6 +10,7 @@
},
"dependencies": {
"@ba-helper/contracts": "workspace:*",
+ "@ba-helper/shared": "workspace:*",
"@base-ui/react": "^1.5.0",
"@tanstack/react-query": "^5.100.14",
"@types/dagre": "^0.7.54",
diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx
index 1e09c3dd..ffd02124 100644
--- a/apps/web/src/app/login/page.tsx
+++ b/apps/web/src/app/login/page.tsx
@@ -1,10 +1,12 @@
import { Suspense } from "react"
import { LoginForm } from "@/components/auth/login-form"
+import { resolveAuthMode } from "@ba-helper/shared"
export default function LoginPage() {
+ const authMode = resolveAuthMode(process.env)
return (
Loading...}>
-
+
)
}
diff --git a/apps/web/src/components/auth/login-form.tsx b/apps/web/src/components/auth/login-form.tsx
index 5878ceaa..5d609e9b 100644
--- a/apps/web/src/components/auth/login-form.tsx
+++ b/apps/web/src/components/auth/login-form.tsx
@@ -12,9 +12,11 @@ import { getAuthErrorMessage, normalizeAuthErrorCode } from "@/lib/auth-errors"
import { getSafeNext } from "@/lib/auth-routing"
import { useAuth } from "@/hooks/use-auth"
+import type { AuthMode } from "@ba-helper/shared"
+
const ROLE_OPTIONS = ["ADMIN", "REVIEWER", "VIEWER"] as const
-export function LoginForm() {
+export function LoginForm({ authMode }: { authMode: AuthMode }) {
const searchParams = useSearchParams()
const { login } = useAuth()
const [email, setEmail] = useState("")
@@ -69,55 +71,69 @@ export function LoginForm() {
-
diff --git a/apps/web/src/components/workspace/shared/status-badges.tsx b/apps/web/src/components/workspace/shared/status-badges.tsx
index a1ea13eb..42f97577 100644
--- a/apps/web/src/components/workspace/shared/status-badges.tsx
+++ b/apps/web/src/components/workspace/shared/status-badges.tsx
@@ -210,17 +210,15 @@ export function CoverageStatusBadge({ status, className }: { status: string; cla
}
export function DomainStatusBadge({
- domainProfileId,
domainPackStatus,
locale,
className
}: {
- domainProfileId?: string | null
domainPackStatus?: string | null
locale?: SupportedLocale
className?: string
}) {
- const badgeData = getDomainCapabilityBadge({ domainProfileId, domainPackStatus, locale })
+ const badgeData = getDomainCapabilityBadge({ domainPackStatus, locale })
let tone: BadgeTone = "muted"
if (badgeData.status === "STABLE") tone = "success"
diff --git a/apps/web/src/lib/i18n/analysis-labels.ts b/apps/web/src/lib/i18n/analysis-labels.ts
index b5adc940..f021106f 100644
--- a/apps/web/src/lib/i18n/analysis-labels.ts
+++ b/apps/web/src/lib/i18n/analysis-labels.ts
@@ -25,7 +25,7 @@ export const analysisWorkspaceLabels = {
reportAndDrift: "Report & Drift",
requirementRevision: "Requirement revision",
language: "Language",
- domainProfile: "Domain profile",
+ domainProfile: "Scanner profile",
snapshot: "Snapshot",
commit: "Commit",
analyzer: "Analyzer",
@@ -177,7 +177,7 @@ export const analysisWorkspaceLabels = {
reportAndDrift: "Báo cáo & độ lệch",
requirementRevision: "Phiên bản yêu cầu",
language: "Ngôn ngữ",
- domainProfile: "Hồ sơ domain",
+ domainProfile: "Hồ sơ scanner",
snapshot: "Snapshot",
commit: "Commit",
analyzer: "Analyzer",
diff --git a/apps/web/src/lib/i18n/status-labels.test.ts b/apps/web/src/lib/i18n/status-labels.test.ts
index 83fe9d21..673c94c3 100644
--- a/apps/web/src/lib/i18n/status-labels.test.ts
+++ b/apps/web/src/lib/i18n/status-labels.test.ts
@@ -4,8 +4,9 @@ import {
driftStatusLabels,
evidenceBasisLabels,
exportStatusLabels,
- formatFallbackLabel,
- getLocalizedLabel,
+ formatFallbackLabel,
+ getDomainCapabilityBadge,
+ getLocalizedLabel,
reportStatusLabels,
reviewDecisionLabels,
reviewStatusLabels,
@@ -36,9 +37,20 @@ describe("analysis workspace i18n labels", () => {
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")
- })
-})
+ 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")
+ })
+
+ it("uses backend-authored domain pack status without deriving from domain id", () => {
+ expect(getDomainCapabilityBadge({ domainPackStatus: "PARTIAL", locale: "vi" })).toMatchObject({
+ status: "PARTIAL",
+ label: "Phạm vi một phần",
+ })
+ expect(getDomainCapabilityBadge({ domainPackStatus: null, locale: "en" })).toMatchObject({
+ status: "UNKNOWN",
+ label: "Unknown capability",
+ })
+ })
+ })
diff --git a/apps/web/src/lib/i18n/status-labels.ts b/apps/web/src/lib/i18n/status-labels.ts
index 872b0994..fd2676fb 100644
--- a/apps/web/src/lib/i18n/status-labels.ts
+++ b/apps/web/src/lib/i18n/status-labels.ts
@@ -188,22 +188,13 @@ export const domainPackStatusTooltips = {
} 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 resolvedStatus = (domainPackStatus as DomainPackStatusType | undefined | null) || "UNKNOWN"
const safeStatus = (domainPackStatusLabels[locale] as Record)[resolvedStatus] ? resolvedStatus : "UNKNOWN"
return {
diff --git a/docs/adr/0006-domain-profile-strategy.md b/docs/adr/0006-domain-profile-strategy.md
index 27e31aa2..661d817c 100644
--- a/docs/adr/0006-domain-profile-strategy.md
+++ b/docs/adr/0006-domain-profile-strategy.md
@@ -1,111 +1,32 @@
# ADR-0006: Domain Profile Strategy
-- **Status**: Accepted
+- **Status**: Superseded by `docs/agent/domain-packs.md`
- **Date**: 2026-05-28
-- **Deciders**: Engineering + BA
-
----
+- **Superseded**: 2026-06-27
## Context
-The core impact analysis engine (`Requirement → Evidence → Artifact → Insight`) is
-domain-agnostic. However, different business domains (Booking, Logistics, Healthcare,
-Finance) have distinct vocabulary, risk categories, and QA scenario patterns.
-
-Two wrong approaches were explicitly rejected:
-
-1. **Separate UI per domain** — hard to maintain, duplicates the core workspace.
-2. **Static generic risk generation** — generating risks based on domain category alone,
- without grounding in code evidence, produces hallucinated, low-value output.
-
----
-
-## Decision
-
-### Two-layer DomainProfile architecture
-
-**MVP (current):**
-
-```text
-Static DomainProfile config file per domain
-No Prisma model for DomainProfile
-Injected into AI reasoning context and retrieval pipeline
-```
-
-**Future (B2B scale):**
-
-```text
-DB-backed DomainProfile (seed from static configs)
-ProjectDomainOverride per company/project (e.g., "consignment" overrides "shipment")
-Managed via API, not hardcoded
-```
-
-### Prompt injection rule
-
-`DomainProfile` is injected into prompts in a **targeted, minimal** way:
-
-```text
-systemPrompt =
- base AI rules
- + DomainProfile.promptContext ← short domain context paragraph only
-
-userPrompt =
- changeRequest
- + retrieved evidence
- + DomainProfile.riskCategories ← as focus areas/hints, not full glossary
-```
-
-**Glossary is NOT dumped into prompts.** It is used exclusively for:
-
-- Lexical search keyword expansion
-- Artifact/domain concept matching in retrieval
-- Surfacing domain-relevant symbols during evidence selection
-
-### Evidence-grounding invariant
-
-> **Domain risks MUST be grounded in persisted Evidence.**
-> `DomainProfile.riskCategories` are retrieval and reasoning hints only.
-> No domain risk insight (`BaInsight`) is valid without at least one linked `Evidence` record.
-
-**Wrong:**
-```text
-domain = healthcare
-→ auto-generate privacy risk ✗
-```
-
-**Correct:**
-```text
-domain = healthcare
-+ evidence: change touches PatientRecordService (persisted Evidence)
-→ AI generates privacy/audit risk with evidence link ✓
-```
-
-This is a concrete application of the existing global invariant in `AGENTS.md`:
-> An `EVIDENCED` insight must link to at least one persisted `Evidence` record.
-
-### File location
-
-```text
-apps/api/src/modules/domain-profile/profiles/.domain-profile.ts
-```
+This ADR recorded the original static `DomainProfile` direction for domain
+terminology, prompt context, retrieval hints, risk categories, and QA scenario
+patterns.
-`DomainProfile` is **not** an AI concern. It lives in its own `domain-profile` module.
-When FE or other packages need it, move to `packages/shared/domain-profiles/`.
+That design has been replaced by the Domain Pack registry.
-### What NOT to do
+## Current Decision
-- Do not create a Prisma model for `DomainProfile` in MVP.
-- Do not build separate UI workspaces per domain.
-- Do not inject the full glossary into prompts.
-- Do not generate `BaInsight` with `EVIDENCED` basis without an `Evidence` link.
+Runtime domain terminology, capability status, and bounded hint metadata come
+from `apps/api/src/modules/domain-pack/application/domain-pack.registry.ts`.
----
+The legacy `domain-profile` helpers and duplicated static profile modules were
+removed so the system has one runtime registry and no BOOKING-by-default
+fallback path.
-## Consequences
+## Preserved Invariants
-- Adding a new domain = add one profile file + no DB migration.
-- Domain risks are always traceable back to specific code evidence.
-- Core UI layout (`Impact Analysis Workspace`) remains the same across all domains.
-- Domain-specific sections (`Domain Risks`, `QA Scenarios`) are rendered using the
- active domain profile config, not hardcoded per domain.
-- Future B2B customization path is clear without rewriting core logic.
+- Domain packs are hints, not evidence.
+- `EVIDENCED` claims still require persisted source evidence.
+- Unknown or unsupported domains use `general@0.0.0` with `FALLBACK` status.
+- Frontend components render backend-authored domain-pack status; they do not
+ infer capability from scanner profile strings or domain ids.
+- Adding a new domain requires explicit status, limits, evaluation cases, and
+ documentation in `docs/agent/domain-packs.md`.
diff --git a/docs/agent/api-contracts.md b/docs/agent/api-contracts.md
index 2ab964b0..a61cc102 100644
--- a/docs/agent/api-contracts.md
+++ b/docs/agent/api-contracts.md
@@ -72,6 +72,28 @@ GET /api/v1/impact-analyses/:analysisId/approved-report/export.pdf
GET /api/v1/impact-analyses/:analysisId/final-reviewed-report?locale=en|vi
```
+Analysis workspace responses expose backend-authored domain capability metadata
+when an analysis has applied a domain pack:
+
+```json
+{
+ "overview": {
+ "requirement": {
+ "domainProfileId": "booking@repo-profile@0.1.0",
+ "domainPack": {
+ "id": "booking",
+ "version": "0.1.0",
+ "status": "STABLE",
+ "selectedBy": "repository_profile"
+ }
+ }
+ }
+}
+```
+
+If no domain pack metadata has been applied yet, `domainPack` is `null`. The
+frontend must not infer `status` from `domainProfileId`.
+
Deferred until after the Markdown report/review completion gate:
```http
diff --git a/docs/agent/domain-packs.md b/docs/agent/domain-packs.md
index 09ffb19e..ebef54bf 100644
--- a/docs/agent/domain-packs.md
+++ b/docs/agent/domain-packs.md
@@ -75,7 +75,10 @@ terminology and evaluation cases, but the product does not claim full rental
domain support. Current coverage is limited to deposit payment consistency,
room availability through a booking request, and contract cancellation effects
on payment records plus tenant/landlord notification. Maintenance request terms
-exist only as terminology/noise coverage in this revision.
+exist only as terminology/noise coverage in this revision. Rental is not
+auto-detected by the scanner and is not user-selectable through the analysis
+create API in this revision; it is a bounded registry/evaluation capability
+until an explicit runtime-selection phase is approved.
## Glossary Metadata
@@ -99,6 +102,11 @@ version, and term count. Glossary assets remain terminology references. Domain
profile additions do not introduce Vietnamese runtime output, scanner changes,
or new AI behavior.
+Workspace and report UI must render capability status from backend-authored
+domain-pack metadata. Frontend components may localize labels for `STABLE`,
+`PARTIAL`, `EXPERIMENTAL`, and `FALLBACK`, but must not infer capability status
+from a domain id or scanner profile string.
+
## Adding A Profile
Before adding a new `PARTIAL` or `STABLE` profile:
diff --git a/docs/agent/ui-tech-stack.md b/docs/agent/ui-tech-stack.md
index 9ea7a937..b38754e6 100644
--- a/docs/agent/ui-tech-stack.md
+++ b/docs/agent/ui-tech-stack.md
@@ -10,7 +10,7 @@
- **Brand / Visual Identity:** Color tokens, light/dark theme, landing style, workspace style, status colors, evidence/code surfaces. shadcn defaults are not distinct enough.
- **App Layout:** AppShell, Sidebar, Topbar, ImpactAnalysisWorkspace, EvidenceInspector, ArtifactList, InsightCard, TraceabilityMatrix, GeneratedReportView. (Do NOT use generic admin layouts or dashboard templates).
- **Product-Specific Components:** EvidenceCard, CodeEvidenceBlock, AffectedArtifactCard, UnknownInsightCard, BAQuestionList, QAScenarioList, CommitSnapshotBadge, ReviewActionPanel, ImpactGraphPanel.
-- **Domain-Specific UI Labels:** Risk labels, question templates, QA scenario sections, report sections, domain glossary display (Dynamic based on DomainProfile).
+- **Domain-Specific UI Labels:** Risk labels, question templates, QA scenario sections, report sections, domain glossary display (render backend-authored Domain Pack state; do not infer capability in the frontend).
- **RAG / Evidence UX (The Moat):** Retrieval results, evidence ranking, file path + line range, confidence, confirmed/inferred/unknown, traceability links.
**Do NOT Custom Build (Use shadcn):**
diff --git a/packages/application/src/impact-analysis/domain-profile/index.ts b/packages/application/src/impact-analysis/domain-profile/index.ts
deleted file mode 100644
index aec310f2..00000000
--- a/packages/application/src/impact-analysis/domain-profile/index.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { DomainProfile, BookingDomainProfile } from './profiles/booking.domain-profile';
-import { PaymentDomainProfile } from './profiles/payment.domain-profile';
-import { RefundDomainProfile } from './profiles/refund.domain-profile';
-import { NotificationDomainProfile } from './profiles/notification.domain-profile';
-import { UnknownDomainProfile } from './profiles/unknown.domain-profile';
-
-export { DomainProfile };
-
-export const SUPPORTED_DOMAINS = ['BOOKING', 'PAYMENT', 'REFUND', 'NOTIFICATION'] as const;
-export const KNOWN_DOMAINS = [...SUPPORTED_DOMAINS, 'UNKNOWN'] as const;
-export type SupportedDomain = typeof SUPPORTED_DOMAINS[number];
-export type KnownDomain = typeof KNOWN_DOMAINS[number];
-
-const DOMAIN_PROFILES: Record = {
- BOOKING: BookingDomainProfile,
- PAYMENT: PaymentDomainProfile,
- REFUND: RefundDomainProfile,
- NOTIFICATION: NotificationDomainProfile,
- UNKNOWN: UnknownDomainProfile,
-};
-
-/**
- * Returns the DomainProfile for the given domain key.
- *
- * Rules:
- * - `undefined` / missing / empty domain → defaults to BOOKING (MVP default)
- * - explicit unrecognized domain key → falls back to UNKNOWN (no throw)
- *
- * See: docs/adr/0006-domain-profile-strategy.md
- */
-export function getDomainProfile(domain?: string): DomainProfile {
- if (domain === undefined || domain === null || domain === '') {
- return BookingDomainProfile;
- }
- const normalizedDomain = domain.toUpperCase();
- return DOMAIN_PROFILES[normalizedDomain] ?? UnknownDomainProfile;
-}
-
-/**
- * Returns true if the domain has an explicit known profile.
- * Used by diagnostics to indicate whether a fallback was applied.
- */
-export function isDomainSupported(domain?: string): boolean {
- if (!domain) return false;
- const normalizedDomain = domain.toUpperCase();
- return normalizedDomain in DOMAIN_PROFILES && normalizedDomain !== 'UNKNOWN';
-}
-
-/**
- * Returns glossary terms for the given domain — used for lexical search keyword expansion.
- * Not for prompt injection.
- */
-export function getDomainGlossary(domain?: string): string[] {
- return getDomainProfile(domain).glossary;
-}
-
-/**
- * Returns which glossary terms from the domain profile appear in the given text.
- * Used for diagnostics — bounded, deterministic, never dumps full registry.
- */
-export function matchDomainTerms(text: string, domain?: string): string[] {
- const glossary = getDomainGlossary(domain);
- const lowerText = text.toLowerCase();
- return glossary.filter((term) => lowerText.includes(term.toLowerCase()));
-}
-
-/**
- * Builds a compact, bounded domain context string for LLM prompt injection.
- *
- * Rules:
- * - At most 5 glossary terms, 4 risk categories, 3 QA focus areas.
- * - UNKNOWN domain produces a generic advisory, not domain-specific hints.
- * - Never dumps the full profile into the prompt.
- */
-export function buildCompactDomainContext(domain?: string): string {
- const profile = getDomainProfile(domain);
- const glossaryHints = profile.glossary.slice(0, 5).join(', ');
- const riskHints = profile.riskCategories.slice(0, 4).map((r) => `- ${r}`).join('\n');
- const qaHints = profile.qaScenarioTemplates.slice(0, 3).map((q) => `- ${q}`).join('\n');
-
- return [
- `Domain: ${profile.domain}`,
- `Key terms: ${glossaryHints}`,
- `Risk focus:\n${riskHints}`,
- `QA focus:\n${qaHints}`,
- ].join('\n');
-}
diff --git a/packages/application/src/impact-analysis/domain-profile/profiles/booking.domain-profile.ts b/packages/application/src/impact-analysis/domain-profile/profiles/booking.domain-profile.ts
deleted file mode 100644
index e26d2613..00000000
--- a/packages/application/src/impact-analysis/domain-profile/profiles/booking.domain-profile.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * Booking Domain Profile
- *
- * Static configuration for the Booking/Payment/Refund domain.
- * Used to inject domain context into AI reasoning and to expand
- * retrieval search terms. NOT persisted in DB in MVP.
- *
- * See: docs/adr/0006-domain-profile-strategy.md
- */
-
-export interface DomainProfile {
- /** Human-readable domain name */
- domain: string;
-
- /**
- * Short context paragraph injected into systemPrompt.
- * Keep concise — this is not a glossary dump.
- */
- promptContext: string;
-
- /**
- * Domain-specific risk categories injected into userPrompt as focus hints.
- * AI MUST ground any generated risk in retrieved Evidence.
- * These are hints, not automatic outputs.
- */
- riskCategories: string[];
-
- /**
- * Domain vocabulary used for lexical search expansion and artifact matching.
- * NOT injected into prompts directly.
- */
- glossary: string[];
-
- /**
- * Template questions a BA should validate for this domain.
- * Injected into report generation and BA question output.
- */
- questionTemplates: string[];
-
- /**
- * QA scenario templates specific to this domain.
- * Parameterized — filled in by AI based on evidence context.
- */
- qaScenarioTemplates: string[];
-
- /**
- * Report section ordering for this domain.
- * Core sections always present; domain-specific sections follow.
- */
- reportSections: string[];
-}
-
-export const BookingDomainProfile: DomainProfile = {
- domain: 'BOOKING',
-
- promptContext: `
- This analysis targets a booking, payment, and refund system.
- Key domain concerns include booking lifecycle state transitions,
- payment integrity, refund eligibility rules, and idempotency of
- financial operations. Policy rules govern which states allow cancellation
- and under what conditions a refund is triggered.
- `.trim(),
-
- riskCategories: [
- 'Booking state machine violation (invalid transition)',
- 'Double charge or duplicate payment processing',
- 'Refund issued without valid cancellation record',
- 'Payment not rolled back after failed booking',
- 'Idempotency key missing or misused in payment/refund flow',
- 'Race condition between concurrent booking/cancellation requests',
- 'Stale booking data returned after state change',
- 'Missing audit trail for financial state change',
- 'Partial refund not handled correctly',
- 'Notification not sent after cancellation/refund',
- ],
-
- glossary: [
- 'booking',
- 'reservation',
- 'cancellation',
- 'refund',
- 'payment',
- 'checkout',
- 'confirmation',
- 'availability',
- 'slot',
- 'schedule',
- 'seat',
- 'ticket',
- 'invoice',
- 'receipt',
- 'transaction',
- 'charge',
- 'capture',
- 'authorize',
- 'void',
- 'rollback',
- 'idempotency',
- 'booking status',
- 'payment status',
- 'refund status',
- 'PENDING',
- 'CONFIRMED',
- 'CANCELLED',
- 'PAID',
- 'REFUNDED',
- 'FAILED',
- ],
-
- questionTemplates: [
- 'What booking states allow cancellation?',
- 'Is the refund amount always equal to the amount paid, or can it be partial?',
- 'What happens if the refund payment gateway call fails — is the booking re-opened?',
- 'Is there a time window after which cancellation is no longer allowed?',
- 'Should a notification (email/SMS) be sent upon successful cancellation and refund?',
- 'Does cancellation affect inventory/slot availability immediately or asynchronously?',
- 'Who is authorized to cancel a booking — user, admin, or both?',
- 'Is there a cooldown period before the same slot can be re-booked?',
- 'How should concurrent cancellation requests for the same booking be handled?',
- 'Are refund records stored separately from payment records for audit purposes?',
- ],
-
- qaScenarioTemplates: [
- 'Cancel a CONFIRMED booking → verify state changes to CANCELLED and refund is initiated.',
- 'Attempt to cancel an already CANCELLED booking → verify error is returned.',
- 'Cancel booking when payment gateway is unavailable → verify booking state is not changed.',
- 'Submit duplicate cancellation request with same idempotency key → verify only one refund is processed.',
- 'Cancel booking outside allowed time window → verify rejection with appropriate message.',
- 'Verify refund amount matches original payment amount.',
- 'Verify slot/seat availability is restored after cancellation.',
- 'Verify audit log entry is created for the cancellation event.',
- 'Verify notification is sent to the user after successful refund.',
- 'Cancel booking as unauthorized user → verify 403 is returned.',
- ],
-
- reportSections: [
- 'Summary',
- 'Affected Artifacts',
- 'Evidence',
- 'Domain Risks',
- 'State Machine Impact',
- 'Data Entity Impact',
- 'Process Flow Changes',
- 'Unknowns',
- 'Stakeholder Questions',
- 'Acceptance Criteria',
- 'QA Scenarios',
- 'Review Notes',
- ],
-};
diff --git a/packages/application/src/impact-analysis/domain-profile/profiles/notification.domain-profile.ts b/packages/application/src/impact-analysis/domain-profile/profiles/notification.domain-profile.ts
deleted file mode 100644
index 5848096a..00000000
--- a/packages/application/src/impact-analysis/domain-profile/profiles/notification.domain-profile.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * Notification Domain Profile
- *
- * Deterministic hints for the Notification domain.
- * Used for retrieval glossary expansion and prompt context injection.
- */
-import type { DomainProfile } from './booking.domain-profile';
-
-export const NotificationDomainProfile: DomainProfile = {
- domain: 'NOTIFICATION',
-
- promptContext: `
- This analysis targets a notification and event messaging system.
- Key domain concerns include delivery reliability, event ordering,
- idempotency of notification dispatch, and handling delivery failures.
- `.trim(),
-
- riskCategories: [
- 'Duplicate notification sent to same recipient',
- 'Notification sent for wrong event or wrong recipient',
- 'Notification delivery failure not retried',
- 'Missing notification after critical state change',
- 'Notification content contains stale or incorrect data',
- 'Race condition between event dispatch and state commit',
- 'Notification bypasses user communication preferences',
- 'Email/SMS template rendering failure silently suppressed',
- ],
-
- glossary: [
- 'notification',
- 'email',
- 'sms',
- 'push',
- 'alert',
- 'event',
- 'dispatch',
- 'delivery',
- 'webhook',
- 'template',
- 'recipient',
- 'channel',
- 'preference',
- 'subscription',
- 'unsubscribe',
- 'idempotency',
- 'retry',
- 'queue',
- 'SENT',
- 'FAILED',
- 'PENDING',
- 'DELIVERED',
- ],
-
- questionTemplates: [
- 'Which events trigger notifications and to which recipients?',
- 'What happens if a notification delivery fails — is it retried?',
- 'Are user notification preferences respected before dispatch?',
- 'Is there a deduplication mechanism to prevent duplicate notifications?',
- 'How are notification failures surfaced — silently logged or raised as alerts?',
- 'Are notifications sent synchronously or asynchronously after state changes?',
- ],
-
- qaScenarioTemplates: [
- 'Trigger booking cancellation → verify notification is sent to user.',
- 'Send duplicate notification with same idempotency key → verify only one is delivered.',
- 'Notification delivery fails → verify retry is queued and failure is logged.',
- 'User has notifications disabled → verify no notification is dispatched.',
- 'Verify notification content matches current booking/refund state.',
- 'Verify audit log created for each notification dispatch attempt.',
- ],
-
- reportSections: [
- 'Summary',
- 'Affected Artifacts',
- 'Evidence',
- 'Domain Risks',
- 'Notification Flow Impact',
- 'Delivery Reliability',
- 'Unknowns',
- 'Stakeholder Questions',
- 'Acceptance Criteria',
- 'QA Scenarios',
- 'Review Notes',
- ],
-};
diff --git a/packages/application/src/impact-analysis/domain-profile/profiles/payment.domain-profile.ts b/packages/application/src/impact-analysis/domain-profile/profiles/payment.domain-profile.ts
deleted file mode 100644
index 80429324..00000000
--- a/packages/application/src/impact-analysis/domain-profile/profiles/payment.domain-profile.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * Payment Domain Profile
- *
- * Deterministic hints for the Payment domain.
- * Used for retrieval glossary expansion and prompt context injection.
- */
-import type { DomainProfile } from './booking.domain-profile';
-
-export const PaymentDomainProfile: DomainProfile = {
- domain: 'PAYMENT',
-
- promptContext: `
- This analysis targets a payment processing system.
- Key domain concerns include transaction integrity, charge/capture sequencing,
- payment gateway error handling, idempotency of payment operations, and
- consistency between payment status and external gateway state.
- `.trim(),
-
- riskCategories: [
- 'Duplicate payment or double charge',
- 'Inconsistent payment status between internal DB and payment gateway',
- 'Missing rollback after partial payment failure',
- 'Idempotency key missing or reused incorrectly',
- 'Race condition in concurrent payment attempts',
- 'Payment captured without authorization',
- 'Missing audit trail for payment state changes',
- 'Retry storm causing multiple charges',
- 'Silent failure — payment fails but no error is surfaced',
- 'Charge processed for expired or revoked authorization',
- ],
-
- glossary: [
- 'payment',
- 'transaction',
- 'charge',
- 'capture',
- 'authorize',
- 'void',
- 'invoice',
- 'receipt',
- 'paid',
- 'failed',
- 'pending payment',
- 'payment status',
- 'payment gateway',
- 'idempotency key',
- 'retry',
- 'settlement',
- 'acquirer',
- 'merchant',
- 'refund',
- 'chargeback',
- 'PENDING',
- 'PAID',
- 'FAILED',
- 'CANCELLED',
- ],
-
- questionTemplates: [
- 'What happens if the payment gateway returns a timeout — is the charge retried?',
- 'How is idempotency enforced to prevent duplicate charges on retry?',
- 'Is there a reconciliation process between internal payment state and the gateway?',
- 'What payment states allow a refund to be initiated?',
- 'Are failed payments surfaced to users immediately or after a retry window?',
- 'Who is notified when a payment fails — user, admin, or both?',
- 'Are partial payments supported, and how is the remaining balance tracked?',
- 'Is there a maximum retry count for failed payment attempts?',
- ],
-
- qaScenarioTemplates: [
- 'Submit payment successfully → verify transaction record created and status is PAID.',
- 'Submit duplicate payment with same idempotency key → verify only one charge is processed.',
- 'Payment gateway times out → verify payment status remains PENDING and no charge is recorded.',
- 'Payment fails → verify user is notified and booking/order status remains unchanged.',
- 'Retry a failed payment → verify exactly one successful charge upon successful retry.',
- 'Void an authorized payment → verify no charge is captured.',
- 'Unauthorized payment attempt → verify 403 is returned.',
- 'Verify audit log created for each payment state transition.',
- ],
-
- reportSections: [
- 'Summary',
- 'Affected Artifacts',
- 'Evidence',
- 'Domain Risks',
- 'Payment Flow Impact',
- 'Idempotency Analysis',
- 'Unknowns',
- 'Stakeholder Questions',
- 'Acceptance Criteria',
- 'QA Scenarios',
- 'Review Notes',
- ],
-};
diff --git a/packages/application/src/impact-analysis/domain-profile/profiles/refund.domain-profile.ts b/packages/application/src/impact-analysis/domain-profile/profiles/refund.domain-profile.ts
deleted file mode 100644
index 7b48a396..00000000
--- a/packages/application/src/impact-analysis/domain-profile/profiles/refund.domain-profile.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * Refund Domain Profile
- *
- * Deterministic hints for the Refund domain.
- * Used for retrieval glossary expansion and prompt context injection.
- */
-import type { DomainProfile } from './booking.domain-profile';
-
-export const RefundDomainProfile: DomainProfile = {
- domain: 'REFUND',
-
- promptContext: `
- This analysis targets a refund and reversal system.
- Key domain concerns include refund eligibility rules, refund idempotency,
- partial refund handling, ledger consistency, and failed reversal recovery.
- `.trim(),
-
- riskCategories: [
- 'Double refund — same booking refunded more than once',
- 'Inconsistent refund ledger — refund recorded without gateway confirmation',
- 'Failed reversal not retried — customer charged but refund never issued',
- 'Partial refund amount mismatch with original charge',
- 'Refund issued for non-refundable bookings',
- 'Refund processed after cancellation policy window',
- 'Missing idempotency check on refund endpoint',
- 'Race condition between concurrent refund requests',
- 'Refund notification not sent after successful reversal',
- 'Audit trail missing for refund state transitions',
- ],
-
- glossary: [
- 'refund',
- 'reversal',
- 'compensation',
- 'refund status',
- 'partial refund',
- 'full refund',
- 'refundable',
- 'non-refundable',
- 'refund eligibility',
- 'cancellation policy',
- 'REFUNDED',
- 'REFUND_PENDING',
- 'REFUND_FAILED',
- 'reversal',
- 'credit',
- 'chargeback',
- 'idempotency',
- 'ledger',
- 'reconciliation',
- ],
-
- questionTemplates: [
- 'Under what conditions is a refund automatically triggered upon cancellation?',
- 'Is the refund amount always equal to the amount paid, or can it be partial?',
- 'What happens if the refund gateway call fails — is it retried automatically?',
- 'Is there a maximum number of refund retries before manual intervention is required?',
- 'Are partial refunds supported and how is the remaining balance tracked?',
- 'How is the refund ledger kept consistent with the payment gateway state?',
- 'Who receives notification upon successful or failed refund — user, finance, or both?',
- 'Are refund records stored separately from payment records for audit purposes?',
- ],
-
- qaScenarioTemplates: [
- 'Cancel a CONFIRMED booking → verify refund is initiated and status changes to REFUNDED.',
- 'Submit duplicate refund request with same idempotency key → verify only one reversal is processed.',
- 'Refund gateway call fails → verify booking status is not altered and retry is queued.',
- 'Attempt partial refund → verify refund amount matches expected partial amount.',
- 'Attempt refund on a non-refundable booking → verify rejection with appropriate message.',
- 'Verify refund ledger entry created and amount matches original charge.',
- 'Verify audit log entry is created for each refund state transition.',
- 'Verify user notification sent after successful refund.',
- 'Attempt refund after cancellation policy window → verify rejection.',
- ],
-
- reportSections: [
- 'Summary',
- 'Affected Artifacts',
- 'Evidence',
- 'Domain Risks',
- 'Refund Flow Impact',
- 'Ledger Consistency',
- 'Unknowns',
- 'Stakeholder Questions',
- 'Acceptance Criteria',
- 'QA Scenarios',
- 'Review Notes',
- ],
-};
diff --git a/packages/application/src/impact-analysis/domain-profile/profiles/unknown.domain-profile.ts b/packages/application/src/impact-analysis/domain-profile/profiles/unknown.domain-profile.ts
deleted file mode 100644
index 3deec1df..00000000
--- a/packages/application/src/impact-analysis/domain-profile/profiles/unknown.domain-profile.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * Unknown Domain Profile
- *
- * Safe fallback profile when domain is unrecognized or not provided.
- * Contains minimal generic hints that apply broadly without
- * biasing retrieval toward any specific domain.
- *
- * Rules:
- * - glossary is intentionally minimal to avoid false domain boosting.
- * - riskCategories are generic engineering risks.
- * - qaScenarioTemplates are generic smoke-test patterns.
- * - This profile must never throw or cause diagnostic failures.
- */
-import type { DomainProfile } from './booking.domain-profile';
-
-export const UnknownDomainProfile: DomainProfile = {
- domain: 'UNKNOWN',
-
- promptContext: `
- This analysis targets a system with an unrecognized or unspecified domain.
- Apply generic engineering best practices. Flag any domain-specific assumptions
- as unknowns requiring stakeholder clarification.
- `.trim(),
-
- riskCategories: [
- 'Unhandled error path causing silent failure',
- 'Inconsistent state between service and persistence layer',
- 'Missing idempotency protection on state-mutating operations',
- 'Missing audit trail for critical state changes',
- 'Race condition in concurrent request handling',
- 'Authorization check missing or incorrectly scoped',
- ],
-
- glossary: [
- 'status',
- 'state',
- 'event',
- 'service',
- 'repository',
- 'controller',
- 'handler',
- 'workflow',
- ],
-
- questionTemplates: [
- 'What are the primary domain entities and their lifecycle states?',
- 'What are the key state transitions that must be validated?',
- 'Are there idempotency requirements for any operations?',
- 'Are audit logs required for state-changing operations?',
- 'Who is authorized to perform each operation?',
- ],
-
- qaScenarioTemplates: [
- 'Perform the primary operation → verify expected state change occurs.',
- 'Repeat the same operation → verify idempotency is respected.',
- 'Perform operation as unauthorized user → verify rejection.',
- 'Verify audit log created for the state-changing operation.',
- ],
-
- reportSections: [
- 'Summary',
- 'Affected Artifacts',
- 'Evidence',
- 'Domain Risks',
- 'Unknowns',
- 'Stakeholder Questions',
- 'Acceptance Criteria',
- 'QA Scenarios',
- 'Review Notes',
- ],
-};
diff --git a/packages/application/src/impact-analysis/index.ts b/packages/application/src/impact-analysis/index.ts
index 790d639a..e8dde30a 100644
--- a/packages/application/src/impact-analysis/index.ts
+++ b/packages/application/src/impact-analysis/index.ts
@@ -29,7 +29,3 @@ export type { ImpactAnalysisAiResponse } from './ai/ai.schema';
export { EvidencePackFormatter } from './ai/evidence-pack.formatter';
export type { EvidenceCandidate } from './ai/evidence-pack.formatter';
export { renderPrompt } from './ai/prompt-registry';
-
-// Domain profile utilities (needed by retrieval + prompt building)
-export { buildCompactDomainContext, getDomainProfile, getDomainGlossary, matchDomainTerms, isDomainSupported } from './domain-profile/index';
-export type { DomainProfile } from './domain-profile/profiles/booking.domain-profile';
diff --git a/packages/application/src/impact-analysis/ports/impact-analysis.repository.port.ts b/packages/application/src/impact-analysis/ports/impact-analysis.repository.port.ts
index fc97b8fe..e631a647 100644
--- a/packages/application/src/impact-analysis/ports/impact-analysis.repository.port.ts
+++ b/packages/application/src/impact-analysis/ports/impact-analysis.repository.port.ts
@@ -1,3 +1,5 @@
+import type { DomainProfileCapabilityStatus } from '@ba-helper/contracts';
+
/** Minimal analysis record for RunImpactAnalysisUseCase */
export type ImpactAnalysisRecord = {
id: string;
@@ -49,8 +51,8 @@ export type ImpactAnalysisStatusUpdate = {
domainPack?: {
id: string;
version: string;
- status: string;
- selectedBy: string;
+ status: DomainProfileCapabilityStatus;
+ selectedBy: 'manual_config' | 'repository_profile' | 'safe_default';
};
diagnostics?: Array<{
code: string;
diff --git a/packages/contracts/analysis-workspace.contract.spec.ts b/packages/contracts/analysis-workspace.contract.spec.ts
index 8784054d..9c23f5b2 100644
--- a/packages/contracts/analysis-workspace.contract.spec.ts
+++ b/packages/contracts/analysis-workspace.contract.spec.ts
@@ -11,10 +11,16 @@ describe('analysisWorkspaceResponseSchema', () => {
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',
- },
+ summary: 'Cancel paid bookings and prevent duplicate refunds.',
+ language: 'en',
+ domainProfileId: 'booking@0.1.0',
+ domainPack: {
+ id: 'booking',
+ version: '0.1.0',
+ status: 'STABLE',
+ selectedBy: 'repository_profile',
+ },
+ },
snapshot: {
snapshotId: '00000000-0000-4000-8000-000000000003',
repositoryId: '00000000-0000-4000-8000-000000000004',
diff --git a/packages/contracts/src/analysis-workspace.contract.ts b/packages/contracts/src/analysis-workspace.contract.ts
index bead3746..b0a86214 100644
--- a/packages/contracts/src/analysis-workspace.contract.ts
+++ b/packages/contracts/src/analysis-workspace.contract.ts
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { universalArtifactKindSchema } from './artifact.contract';
import { impactAnalysisStatusSchema } from './impact-analysis.contract';
+import { domainProfileCapabilityStatusSchema } from './domain-pack.contract';
export const analysisWorkspaceLanguageSchema = z.enum([
'en',
@@ -47,6 +48,19 @@ export const analysisWorkspaceEvidenceBasisSchema = z.enum([
'conflicting',
]);
+export const analysisWorkspaceDomainPackSelectedBySchema = z.enum([
+ 'manual_config',
+ 'repository_profile',
+ 'safe_default',
+]);
+
+export const analysisWorkspaceDomainPackSchema = z.object({
+ id: z.string(),
+ version: z.string(),
+ status: domainProfileCapabilityStatusSchema,
+ selectedBy: analysisWorkspaceDomainPackSelectedBySchema,
+});
+
export const analysisOverviewSchema = z.object({
analysisId: z.string().uuid(),
requirement: z.object({
@@ -55,6 +69,7 @@ export const analysisOverviewSchema = z.object({
summary: z.string(),
language: analysisWorkspaceLanguageSchema,
domainProfileId: z.string(),
+ domainPack: analysisWorkspaceDomainPackSchema.nullable(),
}),
snapshot: z.object({
snapshotId: z.string().uuid(),
@@ -237,6 +252,9 @@ export type AnalysisWorkspaceReportStatus = z.infer<
export type AnalysisWorkspaceDriftStatus = z.infer<
typeof analysisWorkspaceDriftStatusSchema
>;
+export type AnalysisWorkspaceDomainPack = z.infer<
+ typeof analysisWorkspaceDomainPackSchema
+>;
export type AnalysisOverview = z.infer;
export type ImpactGroup = z.infer;
export type ImpactArtifactCard = z.infer;
diff --git a/tests/domain-pack/concept-matching.spec.ts b/tests/domain-pack/concept-matching.spec.ts
index 27b3a191..39694251 100644
--- a/tests/domain-pack/concept-matching.spec.ts
+++ b/tests/domain-pack/concept-matching.spec.ts
@@ -91,6 +91,15 @@ describe('Domain Pack Concept Matching', () => {
expect(keys).toEqual(['maintenance_request']);
expect(pack.status).toBe('PARTIAL');
});
+
+ it('does not match rental concepts from generic workflow words alone', () => {
+ const keys = registry.matchConcepts(
+ 'Update contract payment status when a request is approved.',
+ pack,
+ );
+
+ expect(keys).toEqual([]);
+ });
});
describe('general@0.0.0 (fallback)', () => {
diff --git a/tests/domain-profile/domain-profile.spec.ts b/tests/domain-profile/domain-profile.spec.ts
deleted file mode 100644
index 3678222c..00000000
--- a/tests/domain-profile/domain-profile.spec.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { describe, it, expect, beforeEach, jest } from '@jest/globals';
-import { getDomainProfile, getDomainGlossary } from '../../apps/api/src/modules/domain-profile';
-import { BookingDomainProfile } from '../../apps/api/src/modules/domain-profile/profiles/booking.domain-profile';
-import { HybridRetrievalService } from '../../apps/api/src/modules/retrieval/application/hybrid-retrieval.service';
-import { FakeEmbeddingProvider } from '../../apps/api/src/modules/embedding/infrastructure/fake-embedding.provider';
-import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry';
-
-describe('getDomainProfile', () => {
- it('returns BookingDomainProfile when domain is "BOOKING"', () => {
- const profile = getDomainProfile('BOOKING');
- expect(profile).toBe(BookingDomainProfile);
- expect(profile.domain).toBe('BOOKING');
- });
-
- it('returns BookingDomainProfile when domain is undefined (missing)', () => {
- const profile = getDomainProfile(undefined);
- expect(profile).toBe(BookingDomainProfile);
- });
-
- it('returns BookingDomainProfile when domain is empty string (missing)', () => {
- const profile = getDomainProfile('');
- expect(profile).toBe(BookingDomainProfile);
- });
-
- it('does not throw for an explicit unknown domain but falls back to UNKNOWN', () => {
- expect(getDomainProfile('HEALTHCARE').domain).toBe('UNKNOWN');
- expect(getDomainProfile('LOGISTICS').domain).toBe('UNKNOWN');
- });
-
- it('includes non-empty glossary, riskCategories, promptContext, questionTemplates, qaScenarioTemplates', () => {
- const profile = getDomainProfile('BOOKING');
- expect(profile.glossary.length).toBeGreaterThan(0);
- expect(profile.riskCategories.length).toBeGreaterThan(0);
- expect(profile.promptContext.trim().length).toBeGreaterThan(0);
- expect(profile.questionTemplates.length).toBeGreaterThan(0);
- expect(profile.qaScenarioTemplates.length).toBeGreaterThan(0);
- });
-});
-
-describe('getDomainGlossary', () => {
- it('returns BOOKING glossary terms for "BOOKING"', () => {
- const glossary = getDomainGlossary('BOOKING');
- expect(Array.isArray(glossary)).toBe(true);
- expect(glossary.length).toBeGreaterThan(0);
- expect(glossary).toContain('booking');
- expect(glossary).toContain('refund');
- expect(glossary).toContain('cancellation');
- expect(glossary).toContain('payment');
- });
-
- it('returns BOOKING glossary when domain is undefined', () => {
- const glossary = getDomainGlossary(undefined);
- expect(glossary.length).toBeGreaterThan(0);
- });
-
- it('returns generic glossary for unsupported explicit domain', () => {
- expect(getDomainGlossary('HEALTHCARE')).toEqual(getDomainGlossary('UNKNOWN'));
- });
-});
-
-describe('HybridRetrievalService — domain-aware keyword extraction', () => {
- let service: HybridRetrievalService;
- let prismaMock: any;
-
- beforeEach(() => {
- const chunkRepoMock = { searchSimilar: jest.fn().mockResolvedValue([]) };
- const provider = new FakeEmbeddingProvider();
- const artifactRepoMock = { findById: jest.fn() };
- const graphRepoMock = { expandFromSeeds: jest.fn().mockResolvedValue([]) };
- prismaMock = {
- $queryRaw: jest.fn().mockResolvedValue([]),
- codeArtifact: { findMany: jest.fn().mockResolvedValue([]) },
- repositorySnapshot: { findUnique: jest.fn().mockResolvedValue({ indexStatus: 'LEXICAL_READY' }) },
- };
- service = new HybridRetrievalService(
- chunkRepoMock as any,
- provider as any,
- artifactRepoMock as any,
- graphRepoMock as any,
- prismaMock as any,
- new DomainPackRegistry(),
- );
- });
-
- it('uses domain glossary for keyword expansion — not hardcoded booking words', async () => {
- // Use a change request with terms only from the BOOKING glossary, no generic words
- await service.retrieve({
- projectId: 'proj-1',
- repositoryId: 'repo-1',
- snapshotId: 'snap-1',
- changeRequest: 'user wants to issue a refund for their booking cancellation',
- domain: 'BOOKING',
- });
-
- expect(prismaMock.$queryRaw as jest.Mock).toHaveBeenCalledTimes(1);
- const sqlArgs = (prismaMock.$queryRaw as jest.Mock).mock.calls[0];
- const sql = sqlArgs[0] as { values?: unknown[] };
- const keywordFlat = (sql.values ?? []).join(' ');
- // These terms come from the BOOKING glossary, not a hardcoded list
- expect(keywordFlat).toContain('refund');
- expect(keywordFlat).toContain('booking');
- expect(keywordFlat).toContain('cancellation');
- });
-
- it('does not throw when domain is explicitly unsupported (falls back safely)', async () => {
- await expect(
- service.retrieve({
- projectId: 'proj-1',
- repositoryId: 'repo-1',
- snapshotId: 'snap-1',
- changeRequest: 'cancel booking',
- domain: 'HEALTHCARE', // not supported
- }),
- ).resolves.toBeDefined();
- });
-
- it('does not inject glossary into prompt — only snapshotId-scoped SQL search', async () => {
- await service.retrieve({
- projectId: 'proj-1',
- repositoryId: 'repo-1',
- snapshotId: 'snap-1',
- changeRequest: 'cancellation of booking refund',
- domain: 'BOOKING',
- });
-
- const sqlArgs = (prismaMock.$queryRaw as jest.Mock).mock.calls[0];
- const sql = sqlArgs[0] as { strings?: string[] };
- const text = Array.isArray(sql?.strings) ? sql.strings.join(' ') : String(sql);
- // SQL must scope to snapshotId — no global search
- expect(text).toContain('"snapshotId"');
- // SQL searches name, filePath, artifactKey — not arbitrary columns
- expect(text).toContain('"filePath"');
- expect(text).toContain('"artifactKey"');
- });
-});
From 5afebce34eec407eb4a5c82dc3a3df0bd8a542e0 Mon Sep 17 00:00:00 2001
From: Dip Hung Thinh
Date: Sat, 27 Jun 2026 16:22:28 +0700
Subject: [PATCH 12/35] chore(ci): add stability verification gate
---
.github/workflows/ci.yml | 3 +++
docs/agent/testing-strategy.md | 16 ++++++++++++----
docs/demo/public-beta-release-checklist.md | 1 +
docs/demo/public-demo-checklist.md | 5 ++++-
docs/deployment/smoke-checklist.md | 6 ++++++
package.json | 3 ++-
6 files changed, 28 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5146deca..c6ad95d9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -87,3 +87,6 @@ jobs:
- name: Golden path demo
run: pnpm demo:golden-path
+
+ - name: Multi-repo golden path demo
+ run: pnpm demo:multi-repo-golden-path
diff --git a/docs/agent/testing-strategy.md b/docs/agent/testing-strategy.md
index 84ff08d9..f7b85bf8 100644
--- a/docs/agent/testing-strategy.md
+++ b/docs/agent/testing-strategy.md
@@ -112,14 +112,22 @@ the fixture explicitly encodes that behavior.
### 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:
+they share the same test database or schema. The same rule applies to
+`pnpm demo:multi-repo-golden-path`. These 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
+pnpm demo:multi-repo-golden-path
+```
+
+For a local release-candidate pass with Docker available, prefer the sequential
+wrapper. It starts Postgres/Redis, applies migrations, then runs the checks:
+
+```bash
+pnpm verify:stability
```
### Scanner fixture tests
diff --git a/docs/demo/public-beta-release-checklist.md b/docs/demo/public-beta-release-checklist.md
index de690f88..9226a972 100644
--- a/docs/demo/public-beta-release-checklist.md
+++ b/docs/demo/public-beta-release-checklist.md
@@ -8,6 +8,7 @@ This document acts as the final gate to verify that the Requirement-to-Code Impa
- [x] **Typecheck Passed:** `pnpm run typecheck` executes cleanly with zero errors.
- [x] **Tests Passed:** The full automated test suite (`pnpm test`) runs successfully without failing assertions.
- [x] **Golden Path Passed:** The integration demo (`pnpm demo:golden-path`) passes deterministically, proving the impact analyzer flow works end-to-end.
+- [x] **Multi-Repo Golden Path Passed:** `pnpm demo:multi-repo-golden-path` passes deterministically, proving the merged-report workflow remains covered.
- [x] **Links Audited:** All documentation and README links (e.g. to demo docs, limitations) resolve correctly and are not broken.
- [x] **Secrets Audited:** `.env.example`, documentation, templates, and test fixtures contain zero real API keys, private URLs, or personal tokens. Fake AI flags and placeholder credentials are used.
- [x] **Public Claims Audited:** We properly classify TypeScript/NestJS as the primary stable demo path, Java Spring as `PARTIAL`, other pilot adapters as `EXPERIMENTAL`, Domain Packs as hints, and Evaluation Metrics as internal quality signals rather than benchmark claims.
diff --git a/docs/demo/public-demo-checklist.md b/docs/demo/public-demo-checklist.md
index 36b33605..4fc6fb8d 100644
--- a/docs/demo/public-demo-checklist.md
+++ b/docs/demo/public-demo-checklist.md
@@ -42,11 +42,12 @@ Use this checklist before publishing a portfolio walkthrough or recording a live
- [x] `pnpm lint`
- [x] `pnpm test:e2e`
- [x] `pnpm demo:golden-path`
+- [x] `pnpm demo:multi-repo-golden-path`
If a command is skipped, write the reason in the demo notes before publishing.
Run DB-backed suites sequentially; `pnpm test:e2e` and `pnpm demo:golden-path`
both reset/use the isolated test database and should not be launched in
-parallel.
+parallel. The multi-repo golden path follows the same rule.
## Known Limits To State
@@ -69,6 +70,7 @@ pnpm test PASS
pnpm lint PASS
pnpm test:e2e PASS
pnpm demo:golden-path PASS
+pnpm demo:multi-repo-golden-path PASS
```
Operational note:
@@ -76,4 +78,5 @@ Operational note:
```text
Do not run DB-backed suites in parallel. `pnpm test:e2e` and
`pnpm demo:golden-path` both reset/use the isolated test database.
+`pnpm demo:multi-repo-golden-path` must also run separately.
```
diff --git a/docs/deployment/smoke-checklist.md b/docs/deployment/smoke-checklist.md
index d5b543b7..9aaae210 100644
--- a/docs/deployment/smoke-checklist.md
+++ b/docs/deployment/smoke-checklist.md
@@ -74,6 +74,12 @@ Use this checklist before a demo, handoff, or release candidate tag.
- `pnpm --dir apps/api smoke:public-github:real-llm` (explicit manual run)
- `pnpm --dir apps/api smoke:public-github:real-path` (explicit manual run)
+For local release-candidate verification with Docker available, run:
+
+```bash
+pnpm verify:stability
+```
+
Run full Jest, golden-path demos, and public smoke commands separately when
they share the same database/schema. They reset and seed test data and can
produce false failures if run concurrently.
diff --git a/package.json b/package.json
index d27d2854..0e3c2e4a 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,8 @@
"test:integration": "jest --config jest.integration.config.ts",
"test:e2e": "jest --config jest.e2e.config.ts --runInBand",
"test:e2e:watch": "jest --config jest.e2e.config.ts --watch",
- "typecheck": "tsc --noEmit"
+ "typecheck": "tsc --noEmit",
+ "verify:stability": "pnpm infra:up && pnpm db:migrate && pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e && pnpm demo:golden-path && pnpm demo:multi-repo-golden-path"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
From 1f4e90d054bc7fe3ae4e2b13f4073775c65a5f6d Mon Sep 17 00:00:00 2001
From: Dip Hung Thinh
Date: Sat, 27 Jun 2026 16:55:32 +0700
Subject: [PATCH 13/35] feat(contracts): add domain pack selection metadata
---
.../analysis-workspace.contract.spec.ts | 2 +-
.../contracts/domain-pack.contract.spec.ts | 11 +++++--
.../src/analysis-workspace.contract.ts | 6 ++--
packages/contracts/src/diagnostic.contract.ts | 2 +-
.../contracts/src/domain-pack.contract.ts | 33 ++++++++++++++++++-
.../contracts/src/impact-analysis.contract.ts | 9 +++++
6 files changed, 54 insertions(+), 9 deletions(-)
diff --git a/packages/contracts/analysis-workspace.contract.spec.ts b/packages/contracts/analysis-workspace.contract.spec.ts
index 9c23f5b2..e05107c6 100644
--- a/packages/contracts/analysis-workspace.contract.spec.ts
+++ b/packages/contracts/analysis-workspace.contract.spec.ts
@@ -18,7 +18,7 @@ describe('analysisWorkspaceResponseSchema', () => {
id: 'booking',
version: '0.1.0',
status: 'STABLE',
- selectedBy: 'repository_profile',
+ selectedBy: 'REPOSITORY_PROFILE',
},
},
snapshot: {
diff --git a/packages/contracts/domain-pack.contract.spec.ts b/packages/contracts/domain-pack.contract.spec.ts
index 541dd5d7..bec7d958 100644
--- a/packages/contracts/domain-pack.contract.spec.ts
+++ b/packages/contracts/domain-pack.contract.spec.ts
@@ -53,10 +53,15 @@ describe('domain pack contracts', () => {
it('accepts a fallback registry entry without executable hint bodies', () => {
const payload: DomainProfileRegistryEntry = {
id: 'general',
- name: 'General',
version: '0.0.0',
+ canonicalId: 'general@0.0.0',
+ displayName: 'General Fallback',
status: 'FALLBACK',
description: 'Safe fallback used when no specific profile is selected.',
+ supportedConcepts: [],
+ knownLimits: ['Generic fallback only.'],
+ requiresExplicitSelection: false,
+ aliases: ['general', 'general@0.0.0'],
glossaryMetadata: [],
};
@@ -68,7 +73,7 @@ describe('domain pack contracts', () => {
domainPackId: 'booking',
domainPackVersion: '0.1.0',
domainPackStatus: 'STABLE',
- selectedBy: 'repository_profile',
+ selectedBy: 'REPOSITORY_PROFILE',
conceptCount: 5,
retrievalHintCount: 6,
riskTemplateCount: 10,
@@ -84,7 +89,7 @@ describe('domain pack contracts', () => {
domainPackId: 'rental',
domainPackVersion: '0.1.0',
domainPackStatus: 'PARTIAL',
- selectedBy: 'repository_profile',
+ selectedBy: 'REPOSITORY_PROFILE',
conceptCount: 9,
retrievalHintCount: 5,
riskTemplateCount: 4,
diff --git a/packages/contracts/src/analysis-workspace.contract.ts b/packages/contracts/src/analysis-workspace.contract.ts
index b0a86214..b874ba0e 100644
--- a/packages/contracts/src/analysis-workspace.contract.ts
+++ b/packages/contracts/src/analysis-workspace.contract.ts
@@ -49,9 +49,9 @@ export const analysisWorkspaceEvidenceBasisSchema = z.enum([
]);
export const analysisWorkspaceDomainPackSelectedBySchema = z.enum([
- 'manual_config',
- 'repository_profile',
- 'safe_default',
+ 'EXPLICIT',
+ 'REPOSITORY_PROFILE',
+ 'FALLBACK',
]);
export const analysisWorkspaceDomainPackSchema = z.object({
diff --git a/packages/contracts/src/diagnostic.contract.ts b/packages/contracts/src/diagnostic.contract.ts
index dd0151b8..8cf394d2 100644
--- a/packages/contracts/src/diagnostic.contract.ts
+++ b/packages/contracts/src/diagnostic.contract.ts
@@ -131,7 +131,7 @@ 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']),
+ selectedBy: z.enum(['EXPLICIT', 'REPOSITORY_PROFILE', 'FALLBACK']),
conceptCount: z.number().int().nonnegative(),
retrievalHintCount: z.number().int().nonnegative(),
riskTemplateCount: z.number().int().nonnegative(),
diff --git a/packages/contracts/src/domain-pack.contract.ts b/packages/contracts/src/domain-pack.contract.ts
index c2667adb..52682ff9 100644
--- a/packages/contracts/src/domain-pack.contract.ts
+++ b/packages/contracts/src/domain-pack.contract.ts
@@ -32,6 +32,21 @@ export const domainGlossaryMetadataSchema = z.object({
termCount: z.number().int().nonnegative(),
});
+export const domainPackSelectedBySchema = z.enum([
+ 'EXPLICIT',
+ 'REPOSITORY_PROFILE',
+ 'FALLBACK',
+]);
+
+export const resolvedDomainPackSelectionSchema = z.object({
+ requestedDomainPackId: z.string().nullable(),
+ resolvedDomainPackId: z.string(),
+ resolvedDomainPackVersion: z.string(),
+ resolvedDomainPackStatus: domainProfileCapabilityStatusSchema,
+ selectedBy: domainPackSelectedBySchema,
+ resolvedAt: z.string(),
+});
+
export const domainPackSchema = z.object({
id: z.string(),
name: z.string(),
@@ -48,11 +63,24 @@ export const domainPackSchema = z.object({
export const domainProfileRegistryEntrySchema = domainPackSchema.pick({
id: true,
- name: true,
version: true,
status: true,
description: true,
glossaryMetadata: true,
+}).extend({
+ canonicalId: z.string(),
+ displayName: z.string(),
+ supportedConcepts: z.array(z.object({
+ key: z.string(),
+ label: z.string(),
+ })),
+ knownLimits: z.array(z.string()),
+ requiresExplicitSelection: z.boolean(),
+ aliases: z.array(z.string()),
+});
+
+export const domainPackRegistryResponseSchema = z.object({
+ items: z.array(domainProfileRegistryEntrySchema),
});
export type DomainConcept = z.infer;
@@ -63,5 +91,8 @@ export type DomainUnknownTemplate = z.infer;
export type DomainProfileCapabilityStatus = z.infer;
export type DomainGlossaryLocale = z.infer;
export type DomainGlossaryMetadata = z.infer;
+export type DomainPackSelectedBy = z.infer;
+export type ResolvedDomainPackSelection = z.infer;
export type DomainPack = z.infer;
export type DomainProfileRegistryEntry = z.infer;
+export type DomainPackRegistryResponse = z.infer;
diff --git a/packages/contracts/src/impact-analysis.contract.ts b/packages/contracts/src/impact-analysis.contract.ts
index 84621d32..87ed9c5c 100644
--- a/packages/contracts/src/impact-analysis.contract.ts
+++ b/packages/contracts/src/impact-analysis.contract.ts
@@ -2,12 +2,14 @@ import { z } from 'zod';
import { snapshotIndexStatusSchema } from './repository.contract';
import { diagnosticItemSchema, DiagnosticItem } from './diagnostic.contract';
import { universalArtifactKindSchema } from './artifact.contract';
+import { domainPackSelectedBySchema, domainProfileCapabilityStatusSchema } from './domain-pack.contract';
export const impactAnalysisCreateRequestSchema = z.object({
snapshotId: z.string().uuid(),
sourceTargetId: z.string().uuid(),
allowPartialSnapshot: z.boolean().default(false),
requestKey: z.string().uuid(),
+ domainPackId: z.string().min(1).optional(),
derivedFromAnalysisId: z.string().uuid().optional(),
sourceClarificationId: z.string().uuid().optional(),
});
@@ -17,6 +19,7 @@ export const multiRepoImpactAnalysisCreateRequestSchema = z.object({
repositoryIds: z.array(z.string().uuid()).min(2).max(20),
allowPartialSnapshot: z.boolean().default(false),
requestKey: z.string().uuid(),
+ domainPackId: z.string().min(1).optional(),
}).superRefine((data, ctx) => {
const unique = new Set(data.repositoryIds);
if (unique.size !== data.repositoryIds.length) {
@@ -310,6 +313,12 @@ export const multiRepoApprovedReportResponseSchema = z.object({
isStale: z.boolean(),
staleReason: z.string().optional(),
provenance: z.object({
+ domainPack: z.object({
+ domainPackId: z.string(),
+ domainPackVersion: z.string(),
+ domainPackStatus: domainProfileCapabilityStatusSchema,
+ selectedBy: domainPackSelectedBySchema,
+ }).nullable().optional(),
childAnalyses: z.array(z.object({
analysisId: z.string().uuid(),
latestReviewDecisionId: z.string().uuid(),
From 96d2bdc163b2fb3721dd8f02d18c5c34dce75258 Mon Sep 17 00:00:00 2001
From: Dip Hung Thinh
Date: Sat, 27 Jun 2026 16:56:23 +0700
Subject: [PATCH 14/35] feat(api): resolve and persist selected domain pack
---
.../domain-pack/api/domain-pack.controller.ts | 15 ++
.../application/domain-pack.registry.spec.ts | 68 +++++--
.../application/domain-pack.registry.ts | 188 ++++++++++++++++--
.../modules/domain-pack/domain-pack.module.ts | 2 +
.../domain-pack/packs/healthcare.v0.1.0.ts | 112 +++++++++++
.../impact-analysis-lifecycle.controller.ts | 1 +
...act-analysis-read-model.controller.spec.ts | 2 +-
.../api/multi-repo-analysis.controller.ts | 1 +
.../create-impact-analysis.usecase.spec.ts | 81 ++++++++
.../create-impact-analysis.usecase.ts | 78 +++++++-
.../run-impact-analysis.usecase.spec.ts | 31 ++-
.../analysis-workspace.mapper.helpers.ts | 21 +-
...multi-repo-impact-analyses.usecase.spec.ts | 89 +++++++++
...eate-multi-repo-impact-analyses.usecase.ts | 44 +++-
...approved-multi-repo-report.usecase.spec.ts | 1 +
.../finalize-multi-repo-report.usecase.ts | 77 +++++++
.../get-approved-multi-repo-report.usecase.ts | 29 +++
.../get-analysis-workspace.usecase.spec.ts | 4 +-
.../risks/diagnostic-risk-propagation.spec.ts | 2 +-
.../domain/impact-analysis.types.ts | 9 +
.../impact-analysis.repository.ts | 2 +
.../application/hybrid-retrieval.service.ts | 5 +-
.../retrieval/domain/retrieval.types.ts | 3 +-
.../application/analysis-run-metadata.ts | 7 +
.../run-impact-analysis.usecase.ts | 36 +++-
.../ports/domain-pack-selection.port.ts | 10 +-
.../ports/impact-analysis.repository.port.ts | 11 +-
tests/api/impact-analysis.partial.spec.ts | 2 +
tests/demo/golden-path-demo.spec.ts | 6 +-
.../create-impact-analysis-queue.spec.ts | 2 +
.../run-impact-analysis.spec.ts | 20 +-
31 files changed, 889 insertions(+), 70 deletions(-)
create mode 100644 apps/api/src/modules/domain-pack/api/domain-pack.controller.ts
create mode 100644 apps/api/src/modules/domain-pack/packs/healthcare.v0.1.0.ts
create mode 100644 apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.spec.ts
diff --git a/apps/api/src/modules/domain-pack/api/domain-pack.controller.ts b/apps/api/src/modules/domain-pack/api/domain-pack.controller.ts
new file mode 100644
index 00000000..3b3afa19
--- /dev/null
+++ b/apps/api/src/modules/domain-pack/api/domain-pack.controller.ts
@@ -0,0 +1,15 @@
+import { Controller, Get } from '@nestjs/common';
+import { domainPackRegistryResponseSchema } from '@ba-helper/contracts';
+import { DomainPackRegistry } from '../application/domain-pack.registry';
+
+@Controller('/api/v1/domain-packs')
+export class DomainPackController {
+ constructor(private readonly registry: DomainPackRegistry) {}
+
+ @Get()
+ listDomainPacks() {
+ return domainPackRegistryResponseSchema.parse({
+ items: this.registry.listProfiles(),
+ });
+ }
+}
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 bca6ca58..69f3d6bc 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
@@ -25,29 +25,40 @@ describe('DomainPackRegistry', () => {
});
describe('selectPack', () => {
- it('selects manual booking with selectedBy manual_config', () => {
+ it('selects manual booking with selectedBy EXPLICIT', () => {
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');
+ expect(result.selectedBy).toBe('EXPLICIT');
+ expect(result.resolved).toMatchObject({
+ requestedDomainPackId: 'booking',
+ resolvedDomainPackId: 'booking',
+ resolvedDomainPackVersion: '0.1.0',
+ resolvedDomainPackStatus: 'STABLE',
+ selectedBy: 'EXPLICIT',
+ });
});
- it('selects repository BOOKING with selectedBy repository_profile', () => {
+ it('selects repository BOOKING with selectedBy REPOSITORY_PROFILE', () => {
const result = registry.selectPack({ repositoryProfileDomain: 'BOOKING' });
expect(result.pack.id).toBe('booking');
expect(result.normalizedPackId).toBe('booking');
- expect(result.selectedBy).toBe('repository_profile');
+ expect(result.selectedBy).toBe('REPOSITORY_PROFILE');
});
- it('selects repository RENTAL as rental@0.1.0 PARTIAL', () => {
+ it('requires explicit selection for repository RENTAL partial profile', () => {
const result = registry.selectPack({ repositoryProfileDomain: 'RENTAL' });
- expect(result.pack.id).toBe('rental');
- expect(result.pack.version).toBe('0.1.0');
- expect(result.pack.status).toBe('PARTIAL');
- expect(result.normalizedPackId).toBe('rental');
- expect(result.selectedBy).toBe('repository_profile');
+ expect(result.pack.id).toBe('general');
+ expect(result.selectedBy).toBe('FALLBACK');
+
+ const explicit = registry.selectPack({ manualPackId: 'rental' });
+ expect(explicit.pack.id).toBe('rental');
+ expect(explicit.pack.version).toBe('0.1.0');
+ expect(explicit.pack.status).toBe('PARTIAL');
+ expect(explicit.normalizedPackId).toBe('rental');
+ expect(explicit.selectedBy).toBe('EXPLICIT');
});
it('manual config overrides repository profile', () => {
@@ -56,38 +67,40 @@ describe('DomainPackRegistry', () => {
repositoryProfileDomain: 'UNKNOWN',
});
expect(result.pack.id).toBe('booking');
- expect(result.selectedBy).toBe('manual_config');
+ expect(result.selectedBy).toBe('EXPLICIT');
});
- it('undefined or null selects general@0.0.0 with safe_default', () => {
+ it('undefined or null selects general@0.0.0 with FALLBACK', () => {
const result1 = registry.selectPack({});
expect(result1.pack.id).toBe('general');
expect(result1.pack.status).toBe('FALLBACK');
- expect(result1.selectedBy).toBe('safe_default');
+ expect(result1.selectedBy).toBe('FALLBACK');
const result2 = registry.selectPack({ manualPackId: null, repositoryProfileDomain: null });
expect(result2.pack.id).toBe('general');
- expect(result2.selectedBy).toBe('safe_default');
+ expect(result2.selectedBy).toBe('FALLBACK');
});
it('UNKNOWN profile selects general@0.0.0', () => {
const result = registry.selectPack({ repositoryProfileDomain: 'UNKNOWN' });
expect(result.pack.id).toBe('general');
expect(result.normalizedPackId).toBe('general');
- expect(result.selectedBy).toBe('safe_default');
+ expect(result.selectedBy).toBe('FALLBACK');
});
- it('unsupported repository profile selects general@0.0.0', () => {
+ it('unsupported or explicit-only repository profile selects general@0.0.0', () => {
const result = registry.selectPack({ repositoryProfileDomain: 'HEALTHCARE' });
expect(result.pack.id).toBe('general');
expect(result.normalizedPackId).toBe('general'); // It falls back and normalizes the fallback ID
- expect(result.selectedBy).toBe('safe_default'); // But safe_default replaces it with General
+ expect(result.selectedBy).toBe('FALLBACK'); // Explicit-only partial packs do not auto-select from scanner profile.
});
- it('unsupported manual pack throws controlled error', () => {
- expect(() => {
- registry.selectPack({ manualPackId: 'HEALTHCARE' });
- }).toThrow(AppError);
+ it('manual healthcare alias resolves to healthcare@0.1.0', () => {
+ const result = registry.selectPack({ manualPackId: 'HEALTHCARE' });
+ expect(result.pack.id).toBe('healthcare');
+ expect(result.pack.version).toBe('0.1.0');
+ expect(result.pack.status).toBe('PARTIAL');
+ expect(result.selectedBy).toBe('EXPLICIT');
});
it('unsupported manual pack version throws controlled error', () => {
@@ -117,6 +130,19 @@ describe('DomainPackRegistry', () => {
status: 'FALLBACK',
glossaryMetadata: [],
}),
+ expect.objectContaining({
+ id: 'healthcare',
+ canonicalId: 'healthcare@0.1.0',
+ displayName: 'Healthcare Admin Workflows (PARTIAL)',
+ version: '0.1.0',
+ status: 'PARTIAL',
+ requiresExplicitSelection: true,
+ aliases: ['healthcare', 'healthcare@0.1.0'],
+ glossaryMetadata: [
+ { locale: 'en', status: 'foundation', version: '1.0.0', termCount: 8 },
+ { locale: 'vi', status: 'foundation', version: '1.0.0', termCount: 8 },
+ ],
+ }),
expect.objectContaining({
id: 'rental',
version: '0.1.0',
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 83e6da67..3db1e14b 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,8 +1,14 @@
import { Injectable } from '@nestjs/common';
-import { DomainPack, DomainProfileRegistryEntry } from '@ba-helper/contracts';
+import {
+ DomainPack,
+ DomainPackSelectedBy,
+ DomainProfileRegistryEntry,
+ ResolvedDomainPackSelection,
+} from '@ba-helper/contracts';
import { GeneralDomainPack } from '../packs/general.v0.0.0';
import { BookingDomainPack } from '../packs/booking.v0.1.0';
import { RentalDomainPack } from '../packs/rental.v0.1.0';
+import { HealthcareDomainPack } from '../packs/healthcare.v0.1.0';
import { AppError } from '@ba-helper/shared';
export type DomainPackSelectionInput = {
@@ -13,24 +19,74 @@ export type DomainPackSelectionInput = {
export type DomainPackSelectionResult = {
pack: DomainPack;
normalizedPackId: string;
- selectedBy: 'manual_config' | 'repository_profile' | 'safe_default';
+ selectedBy: DomainPackSelectedBy;
+ resolved: ResolvedDomainPackSelection;
+};
+
+type DomainPackCatalogEntry = {
+ pack: DomainPack;
+ aliases: string[];
+ displayName: string;
+ knownLimits: string[];
+ requiresExplicitSelection: boolean;
};
@Injectable()
export class DomainPackRegistry {
private readonly builtInPacks = new Map();
+ private readonly catalog = new Map();
+ private readonly aliasToPackId = new Map();
constructor() {
- this.register(GeneralDomainPack);
- this.register(BookingDomainPack);
- this.register(RentalDomainPack);
+ this.register(GeneralDomainPack, {
+ aliases: ['general', 'general@0.0.0'],
+ displayName: 'General Fallback',
+ knownLimits: [
+ 'Generic fallback only; it has no domain-specific concepts, templates, or glossary.',
+ ],
+ requiresExplicitSelection: false,
+ });
+ this.register(BookingDomainPack, {
+ aliases: ['booking', 'booking@0.1.0'],
+ displayName: 'Booking, Payment, Refund',
+ knownLimits: [
+ 'Stable only for the covered booking/payment/refund evaluation cases.',
+ ],
+ requiresExplicitSelection: false,
+ });
+ this.register(RentalDomainPack, {
+ aliases: ['rental', 'rental@0.1.0'],
+ displayName: 'Rental Workflows (PARTIAL)',
+ knownLimits: [
+ 'Partial rental coverage only; source evidence is required for every claim.',
+ ],
+ requiresExplicitSelection: true,
+ });
+ this.register(HealthcareDomainPack, {
+ aliases: ['healthcare', 'healthcare@0.1.0'],
+ displayName: 'Healthcare Admin Workflows (PARTIAL)',
+ knownLimits: [
+ 'Domain hints are limited and require source evidence.',
+ 'This pack supports administrative workflow impact analysis only.',
+ 'It does not provide medical advice, clinical decision support, or compliance validation.',
+ ],
+ requiresExplicitSelection: true,
+ });
}
/**
* Registers a domain pack into the registry.
*/
- private register(pack: DomainPack): void {
+ private register(
+ pack: DomainPack,
+ metadata: Omit,
+ ): void {
this.builtInPacks.set(pack.id, pack);
+ this.catalog.set(pack.id, { pack, ...metadata });
+
+ for (const alias of metadata.aliases) {
+ this.aliasToPackId.set(alias.toLowerCase().trim(), pack.id);
+ }
}
listProfiles(): DomainProfileRegistryEntry[] {
@@ -52,18 +108,27 @@ export class DomainPackRegistry {
return GeneralDomainPack;
}
- const normalizedId = id.toLowerCase();
+ const normalizedId = this.normalizePackId(id);
return this.builtInPacks.get(normalizedId) ?? GeneralDomainPack;
}
private toProfileEntry(pack: DomainPack): DomainProfileRegistryEntry {
+ const catalogEntry = this.catalog.get(pack.id);
return {
id: pack.id,
- name: pack.name,
version: pack.version,
+ canonicalId: `${pack.id}@${pack.version}`,
+ displayName: catalogEntry?.displayName ?? pack.name,
status: pack.status,
description: pack.description,
+ supportedConcepts: pack.concepts.map((concept) => ({
+ key: concept.key,
+ label: concept.label,
+ })),
+ knownLimits: catalogEntry?.knownLimits ?? [],
+ requiresExplicitSelection: catalogEntry?.requiresExplicitSelection ?? true,
+ aliases: catalogEntry?.aliases ?? [`${pack.id}@${pack.version}`],
glossaryMetadata: pack.glossaryMetadata,
};
}
@@ -74,36 +139,61 @@ export class DomainPackRegistry {
*/
normalizePackId(packId: string): string {
const lower = packId.toLowerCase().trim();
- const withoutVersion = lower.split('@')[0];
- return withoutVersion;
+ return this.aliasToPackId.get(lower) ?? lower.split('@')[0];
+ }
+
+ listSupportedCanonicalIds(): string[] {
+ return Array.from(this.builtInPacks.values())
+ .map((pack) => `${pack.id}@${pack.version}`)
+ .sort();
}
/**
* Selects the appropriate domain pack based on deterministic priority.
* 1. manualPackId
* 2. repositoryProfileDomain
- * 3. safe_default (general)
+ * 3. fallback (general)
*/
selectPack(input: DomainPackSelectionInput): DomainPackSelectionResult {
if (input.manualPackId) {
- const normalized = this.normalizePackId(input.manualPackId);
+ const requested = input.manualPackId.trim();
+ const normalized = this.normalizePackId(requested);
const foundPack = this.builtInPacks.get(normalized);
if (!foundPack) {
- throw new AppError('UNSUPPORTED_DOMAIN_PACK', `Unsupported manual domain pack: ${input.manualPackId}`);
+ throw new AppError(
+ 'UNSUPPORTED_DOMAIN_PACK',
+ `Unsupported manual domain pack: ${input.manualPackId}`,
+ {
+ requested: input.manualPackId,
+ supported: this.listSupportedCanonicalIds(),
+ },
+ );
}
- if (input.manualPackId.includes('@')) {
- const providedVersion = input.manualPackId.split('@')[1];
+ if (requested.includes('@')) {
+ const providedVersion = requested.split('@')[1];
if (providedVersion !== foundPack.version) {
- throw new AppError('UNSUPPORTED_DOMAIN_PACK_VERSION', `Unsupported domain pack version for ${normalized}: ${providedVersion}`);
+ throw new AppError(
+ 'UNSUPPORTED_DOMAIN_PACK_VERSION',
+ `Unsupported domain pack version for ${normalized}: ${providedVersion}`,
+ {
+ requested: input.manualPackId,
+ supported: this.listSupportedCanonicalIds(),
+ },
+ );
}
}
return {
pack: foundPack,
normalizedPackId: normalized,
- selectedBy: 'manual_config',
+ selectedBy: 'EXPLICIT',
+ resolved: this.buildResolved({
+ requestedDomainPackId: input.manualPackId,
+ pack: foundPack,
+ selectedBy: 'EXPLICIT',
+ }),
};
}
@@ -114,16 +204,27 @@ export class DomainPackRegistry {
return {
pack: GeneralDomainPack,
normalizedPackId: 'general',
- selectedBy: 'safe_default',
+ selectedBy: 'FALLBACK',
+ resolved: this.buildResolved({
+ requestedDomainPackId: null,
+ pack: GeneralDomainPack,
+ selectedBy: 'FALLBACK',
+ }),
};
}
const foundPack = this.builtInPacks.get(normalized);
- if (foundPack) {
+ const catalogEntry = foundPack ? this.catalog.get(foundPack.id) : null;
+ if (foundPack && !catalogEntry?.requiresExplicitSelection) {
return {
pack: foundPack,
normalizedPackId: normalized,
- selectedBy: 'repository_profile',
+ selectedBy: 'REPOSITORY_PROFILE',
+ resolved: this.buildResolved({
+ requestedDomainPackId: null,
+ pack: foundPack,
+ selectedBy: 'REPOSITORY_PROFILE',
+ }),
};
}
}
@@ -131,7 +232,52 @@ export class DomainPackRegistry {
return {
pack: GeneralDomainPack,
normalizedPackId: 'general',
- selectedBy: 'safe_default',
+ selectedBy: 'FALLBACK',
+ resolved: this.buildResolved({
+ requestedDomainPackId: null,
+ pack: GeneralDomainPack,
+ selectedBy: 'FALLBACK',
+ }),
+ };
+ }
+
+ selectResolvedPack(selection: ResolvedDomainPackSelection): DomainPackSelectionResult {
+ const normalized = this.normalizePackId(
+ `${selection.resolvedDomainPackId}@${selection.resolvedDomainPackVersion}`,
+ );
+ const pack = this.builtInPacks.get(normalized);
+
+ if (!pack || pack.version !== selection.resolvedDomainPackVersion) {
+ throw new AppError(
+ 'UNSUPPORTED_DOMAIN_PACK_VERSION',
+ `Unsupported persisted domain pack version for ${selection.resolvedDomainPackId}: ${selection.resolvedDomainPackVersion}`,
+ {
+ requested: `${selection.resolvedDomainPackId}@${selection.resolvedDomainPackVersion}`,
+ supported: this.listSupportedCanonicalIds(),
+ },
+ );
+ }
+
+ return {
+ pack,
+ normalizedPackId: normalized,
+ selectedBy: selection.selectedBy,
+ resolved: selection,
+ };
+ }
+
+ private buildResolved(params: {
+ requestedDomainPackId: string | null;
+ pack: DomainPack;
+ selectedBy: DomainPackSelectedBy;
+ }): ResolvedDomainPackSelection {
+ return {
+ requestedDomainPackId: params.requestedDomainPackId,
+ resolvedDomainPackId: params.pack.id,
+ resolvedDomainPackVersion: params.pack.version,
+ resolvedDomainPackStatus: params.pack.status,
+ selectedBy: params.selectedBy,
+ resolvedAt: new Date().toISOString(),
};
}
diff --git a/apps/api/src/modules/domain-pack/domain-pack.module.ts b/apps/api/src/modules/domain-pack/domain-pack.module.ts
index 19bbbd3b..b91eb282 100644
--- a/apps/api/src/modules/domain-pack/domain-pack.module.ts
+++ b/apps/api/src/modules/domain-pack/domain-pack.module.ts
@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { DomainPackRegistry } from './application/domain-pack.registry';
+import { DomainPackController } from './api/domain-pack.controller';
@Module({
+ controllers: [DomainPackController],
providers: [DomainPackRegistry],
exports: [DomainPackRegistry],
})
diff --git a/apps/api/src/modules/domain-pack/packs/healthcare.v0.1.0.ts b/apps/api/src/modules/domain-pack/packs/healthcare.v0.1.0.ts
new file mode 100644
index 00000000..9dabc05a
--- /dev/null
+++ b/apps/api/src/modules/domain-pack/packs/healthcare.v0.1.0.ts
@@ -0,0 +1,112 @@
+import type { DomainPack } from '@ba-helper/contracts';
+
+export const HealthcareDomainPack: DomainPack = {
+ id: 'healthcare',
+ name: 'Healthcare Admin Workflows',
+ version: '0.1.0',
+ status: 'PARTIAL',
+ description: 'Partial domain pack for healthcare administrative workflows such as scheduling, records, billing, claims, and authorization.',
+ glossaryMetadata: [
+ {
+ locale: 'en',
+ status: 'foundation',
+ version: '1.0.0',
+ termCount: 8,
+ },
+ {
+ locale: 'vi',
+ status: 'foundation',
+ version: '1.0.0',
+ termCount: 8,
+ },
+ ],
+
+ concepts: [
+ {
+ key: 'appointment_scheduling',
+ label: 'Appointment Scheduling',
+ aliases: ['appointment scheduling', 'appointment', 'reschedule appointment', 'visit scheduling'],
+ relatedArtifactKeywords: ['appointment', 'schedule', 'reschedule', 'visit'],
+ relatedKinds: ['SERVICE', 'API_ENDPOINT', 'DATABASE_MODEL'],
+ },
+ {
+ key: 'patient_record',
+ label: 'Patient Record',
+ aliases: ['patient record', 'patient profile', 'medical record', 'chart record'],
+ relatedArtifactKeywords: ['patient-record', 'patient-profile', 'medical-record', 'chart'],
+ relatedKinds: ['SERVICE', 'DATABASE_MODEL'],
+ },
+ {
+ key: 'provider',
+ label: 'Provider',
+ aliases: ['provider', 'clinician', 'doctor', 'practitioner'],
+ relatedArtifactKeywords: ['provider', 'clinician', 'doctor', 'practitioner'],
+ relatedKinds: ['SERVICE', 'DATABASE_MODEL'],
+ },
+ {
+ key: 'patient_notification',
+ label: 'Patient Notification',
+ aliases: ['patient notification', 'appointment reminder', 'patient reminder', 'notification'],
+ relatedArtifactKeywords: ['notification', 'reminder', 'patient-message'],
+ relatedKinds: ['SERVICE', 'API_ENDPOINT'],
+ },
+ {
+ key: 'insurance_claim',
+ label: 'Insurance Claim',
+ aliases: ['insurance claim', 'claim status', 'claim submission', 'payer claim'],
+ relatedArtifactKeywords: ['insurance-claim', 'claim-status', 'payer-claim'],
+ relatedKinds: ['SERVICE', 'DATABASE_MODEL'],
+ },
+ {
+ key: 'billing_record',
+ label: 'Billing Record',
+ aliases: ['billing record', 'patient balance', 'invoice', 'payment record'],
+ relatedArtifactKeywords: ['billing', 'invoice', 'patient-balance', 'payment-record'],
+ relatedKinds: ['SERVICE', 'DATABASE_MODEL'],
+ },
+ {
+ key: 'prior_authorization',
+ label: 'Prior Authorization',
+ aliases: ['prior authorization', 'preauthorization', 'authorization decision', 'authorization request'],
+ relatedArtifactKeywords: ['prior-authorization', 'preauthorization', 'authorization'],
+ relatedKinds: ['SERVICE', 'DATABASE_MODEL', 'API_ENDPOINT'],
+ },
+ {
+ key: 'lab_order_tracking',
+ label: 'Lab/Order Tracking',
+ aliases: ['lab order', 'order tracking', 'order result', 'lab result'],
+ relatedArtifactKeywords: ['lab-order', 'order-tracking', 'lab-result', 'order-result'],
+ relatedKinds: ['SERVICE', 'DATABASE_MODEL'],
+ },
+ ],
+
+ retrievalHints: [
+ 'appointment scheduling state transition',
+ 'patient record update audit trail',
+ 'provider patient notification workflow',
+ 'insurance claim status billing consistency',
+ 'prior authorization scheduling dependency',
+ 'lab order tracking result workflow',
+ ],
+
+ riskTemplates: [
+ 'PARTIAL healthcare admin hint: appointment state and provider availability may become inconsistent without source-backed transition evidence.',
+ 'PARTIAL healthcare admin hint: claim status changes may not update billing records or patient balance consistently.',
+ 'PARTIAL healthcare admin hint: prior authorization decisions may block scheduling or order workflows, but policy rules require source evidence.',
+ 'PARTIAL healthcare admin hint: this pack does not provide medical advice, clinical decision support, or compliance validation.',
+ ],
+
+ qaTemplates: [
+ 'PARTIAL healthcare admin hint: verify appointment rescheduling updates only source-backed appointment, availability, and notification behavior.',
+ 'PARTIAL healthcare admin hint: verify claim status changes keep billing records and patient balance consistent.',
+ 'PARTIAL healthcare admin hint: verify prior authorization approval and denial paths through scheduling or order workflows.',
+ ],
+
+ unknownTemplates: [
+ 'Which appointment states allow rescheduling and provider availability changes?',
+ 'Which claim statuses should update billing records or patient balance?',
+ 'Does prior authorization approval or denial block appointment scheduling or order fulfillment?',
+ 'Which patient/provider notifications are required by source-backed workflow rules?',
+ 'Are lab/order tracking workflows in scope for this healthcare admin profile revision?',
+ ],
+};
diff --git a/apps/api/src/modules/impact-analysis/api/impact-analysis-lifecycle.controller.ts b/apps/api/src/modules/impact-analysis/api/impact-analysis-lifecycle.controller.ts
index 3727f67d..8bb06b5d 100644
--- a/apps/api/src/modules/impact-analysis/api/impact-analysis-lifecycle.controller.ts
+++ b/apps/api/src/modules/impact-analysis/api/impact-analysis-lifecycle.controller.ts
@@ -96,6 +96,7 @@ export class ImpactAnalysisLifecycleController {
sourceTargetId: input.sourceTargetId,
allowPartialSnapshot: input.allowPartialSnapshot,
requestKey: input.requestKey,
+ domainPackId: input.domainPackId,
});
const response = impactAnalysisResponseSchema.parse(
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 d4ef19dd..3fc0e882 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
@@ -78,7 +78,7 @@ describe('ImpactAnalysisReadModelController - driftFreshness', () => {
id: 'booking',
version: '0.1.0',
status: 'STABLE',
- selectedBy: 'repository_profile',
+ selectedBy: 'REPOSITORY_PROFILE',
},
},
snapshot: {
diff --git a/apps/api/src/modules/impact-analysis/api/multi-repo-analysis.controller.ts b/apps/api/src/modules/impact-analysis/api/multi-repo-analysis.controller.ts
index c930d123..d74c0a78 100644
--- a/apps/api/src/modules/impact-analysis/api/multi-repo-analysis.controller.ts
+++ b/apps/api/src/modules/impact-analysis/api/multi-repo-analysis.controller.ts
@@ -106,6 +106,7 @@ export class MultiRepoAnalysisController {
repositoryIds: input.repositoryIds,
requestKey: input.requestKey,
allowPartialSnapshot: input.allowPartialSnapshot,
+ domainPackId: input.domainPackId,
});
return multiRepoImpactAnalysisCreateResponseSchema.parse(result);
diff --git a/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.spec.ts
index c2b86197..4b99d364 100644
--- a/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.spec.ts
+++ b/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.spec.ts
@@ -7,6 +7,7 @@ import type { PrismaService } from '../../../prisma/prisma.service';
import type { EventLogService } from '../../../event-log/application/event-log.service';
import type { QueueService } from '../../../queue/queue.service';
import { Prisma } from '@prisma/client';
+import { DomainPackRegistry } from '../../../domain-pack/application/domain-pack.registry';
describe('CreateImpactAnalysisUseCase', () => {
let useCase: CreateImpactAnalysisUseCase;
@@ -49,6 +50,7 @@ describe('CreateImpactAnalysisUseCase', () => {
prisma,
eventLog,
queue,
+ new DomainPackRegistry(),
);
});
@@ -233,6 +235,85 @@ describe('CreateImpactAnalysisUseCase', () => {
});
});
+ it('persists canonical resolved domain pack metadata for explicit healthcare alias', async () => {
+ mockValidState();
+
+ await useCase.execute({
+ ...validParams,
+ domainPackId: 'healthcare',
+ });
+
+ expect(impactRepo.createQueued).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ selectedDomainPack: expect.objectContaining({
+ requestedDomainPackId: 'healthcare',
+ resolvedDomainPackId: 'healthcare',
+ resolvedDomainPackVersion: '0.1.0',
+ resolvedDomainPackStatus: 'PARTIAL',
+ selectedBy: 'EXPLICIT',
+ resolvedAt: expect.any(String),
+ }),
+ domainPack: {
+ id: 'healthcare',
+ version: '0.1.0',
+ status: 'PARTIAL',
+ selectedBy: 'EXPLICIT',
+ },
+ reportProvenance: {
+ domainPackId: 'healthcare',
+ domainPackVersion: '0.1.0',
+ domainPackStatus: 'PARTIAL',
+ selectedBy: 'EXPLICIT',
+ },
+ }),
+ }),
+ );
+ });
+
+ it('rejects unsupported explicit domain pack version with supported canonical ids', async () => {
+ mockValidState();
+
+ await expect(useCase.execute({
+ ...validParams,
+ domainPackId: 'healthcare@0.2.0',
+ })).rejects.toMatchObject({
+ code: 'UNSUPPORTED_DOMAIN_PACK_VERSION',
+ details: {
+ requested: 'healthcare@0.2.0',
+ supported: expect.arrayContaining(['healthcare@0.1.0']),
+ },
+ });
+ });
+
+ it('requestKey reused with different domain pack is rejected', async () => {
+ mockValidState();
+ impactRepo.findByRequestKey.mockResolvedValue({
+ id: 'existing-1',
+ requirementRevisionId: 'rev-1',
+ snapshotId: 'snap-1',
+ sourceTargetId: 'target-1',
+ requestKey: 'req-key',
+ metadata: {
+ selectedDomainPack: {
+ requestedDomainPackId: 'booking',
+ resolvedDomainPackId: 'booking',
+ resolvedDomainPackVersion: '0.1.0',
+ resolvedDomainPackStatus: 'STABLE',
+ selectedBy: 'EXPLICIT',
+ resolvedAt: '2026-06-27T00:00:00.000Z',
+ },
+ },
+ } as any);
+
+ await expect(useCase.execute({
+ ...validParams,
+ domainPackId: 'healthcare',
+ })).rejects.toMatchObject({
+ code: 'REQUEST_KEY_MISMATCH',
+ });
+ });
+
describe('Lineage Validation', () => {
const lineageParams = {
...validParams,
diff --git a/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.ts b/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.ts
index a83d6c90..a15623cb 100644
--- a/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase.ts
@@ -7,6 +7,8 @@ import { ImpactAnalysisPolicy } from '../../domain/impact-analysis.policy';
import { EventLogService } from '../../../event-log/application/event-log.service';
import { PrismaService } from '../../../prisma/prisma.service';
import { QueueService } from '../../../queue/queue.service';
+import { DomainPackRegistry } from '../../../domain-pack/application/domain-pack.registry';
+import type { ResolvedDomainPackSelection } from '@ba-helper/contracts';
@Injectable()
@@ -19,6 +21,7 @@ export class CreateImpactAnalysisUseCase {
private readonly prisma: PrismaService,
private readonly eventLog: EventLogService,
private readonly queue: QueueService,
+ private readonly domainPacks: DomainPackRegistry,
) {}
async execute(params: {
@@ -31,6 +34,8 @@ export class CreateImpactAnalysisUseCase {
derivedFromAnalysisId?: string;
sourceClarificationId?: string;
reviewClarificationRequestId?: string;
+ domainPackId?: string | null;
+ selectedDomainPack?: ResolvedDomainPackSelection;
}) {
const revision = await this.requirementRepo.findRevisionById(
params.requirementRevisionId,
@@ -51,7 +56,7 @@ export class CreateImpactAnalysisUseCase {
const snapshot = await this.prisma.repositorySnapshot.findUnique({
where: { id: params.snapshotId },
- include: { repository: true },
+ include: { repository: true, profile: true },
});
if (!snapshot) {
@@ -152,6 +157,13 @@ export class CreateImpactAnalysisUseCase {
);
}
+ const selectedDomainPack =
+ params.selectedDomainPack ??
+ this.domainPacks.selectPack({
+ manualPackId: params.domainPackId ?? null,
+ repositoryProfileDomain: snapshot.profile?.domain ?? null,
+ }).resolved;
+
const existingByRequestKey = await this.impactRepo.findByRequestKey({
requestKey: params.requestKey,
});
@@ -160,7 +172,11 @@ export class CreateImpactAnalysisUseCase {
existingByRequestKey &&
(existingByRequestKey.snapshotId !== params.snapshotId ||
existingByRequestKey.sourceTargetId !== params.sourceTargetId ||
- existingByRequestKey.requirementRevisionId !== params.requirementRevisionId)
+ existingByRequestKey.requirementRevisionId !== params.requirementRevisionId ||
+ !sameResolvedDomainPack(
+ readSelectedDomainPack(existingByRequestKey.metadata),
+ selectedDomainPack,
+ ))
) {
throw new AppError(
'REQUEST_KEY_MISMATCH',
@@ -192,6 +208,21 @@ export class CreateImpactAnalysisUseCase {
derivedFromAnalysisId: params.derivedFromAnalysisId,
sourceClarificationId: params.sourceClarificationId,
reviewClarificationRequestId: params.reviewClarificationRequestId,
+ metadata: {
+ selectedDomainPack,
+ domainPack: {
+ id: selectedDomainPack.resolvedDomainPackId,
+ version: selectedDomainPack.resolvedDomainPackVersion,
+ status: selectedDomainPack.resolvedDomainPackStatus,
+ selectedBy: selectedDomainPack.selectedBy,
+ },
+ reportProvenance: {
+ domainPackId: selectedDomainPack.resolvedDomainPackId,
+ domainPackVersion: selectedDomainPack.resolvedDomainPackVersion,
+ domainPackStatus: selectedDomainPack.resolvedDomainPackStatus,
+ selectedBy: selectedDomainPack.selectedBy,
+ },
+ },
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
@@ -235,3 +266,46 @@ export class CreateImpactAnalysisUseCase {
return analysis;
}
}
+
+function sameResolvedDomainPack(
+ existing: ResolvedDomainPackSelection | null,
+ next: ResolvedDomainPackSelection,
+) {
+ return (
+ existing?.resolvedDomainPackId === next.resolvedDomainPackId &&
+ existing?.resolvedDomainPackVersion === next.resolvedDomainPackVersion &&
+ existing?.resolvedDomainPackStatus === next.resolvedDomainPackStatus &&
+ existing?.selectedBy === next.selectedBy
+ );
+}
+
+function readSelectedDomainPack(metadata: unknown): ResolvedDomainPackSelection | null {
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
+ return null;
+ }
+
+ const selected = (metadata as Record).selectedDomainPack;
+ if (!selected || typeof selected !== 'object' || Array.isArray(selected)) {
+ return null;
+ }
+
+ const data = selected as Record;
+ if (
+ typeof data.requestedDomainPackId !== 'string' &&
+ data.requestedDomainPackId !== null
+ ) {
+ return null;
+ }
+
+ if (
+ typeof data.resolvedDomainPackId !== 'string' ||
+ typeof data.resolvedDomainPackVersion !== 'string' ||
+ typeof data.resolvedDomainPackStatus !== 'string' ||
+ typeof data.selectedBy !== 'string' ||
+ typeof data.resolvedAt !== 'string'
+ ) {
+ return null;
+ }
+
+ return data as ResolvedDomainPackSelection;
+}
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 7ca03da4..5d030a48 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
@@ -62,8 +62,11 @@ describe('RunImpactAnalysisUseCase', () => {
selectPack: jest.fn().mockReturnValue({
pack: {
id: 'test-pack',
+ name: 'Test Pack',
version: '1.0',
status: 'EXPERIMENTAL',
+ description: 'Test pack',
+ glossaryMetadata: [],
concepts: [],
retrievalHints: [],
riskTemplates: [],
@@ -71,8 +74,34 @@ describe('RunImpactAnalysisUseCase', () => {
unknownTemplates: [],
},
normalizedPackId: 'test-pack',
- selectedBy: 'safe_default',
+ selectedBy: 'FALLBACK',
+ resolved: {
+ requestedDomainPackId: null,
+ resolvedDomainPackId: 'test-pack',
+ resolvedDomainPackVersion: '1.0',
+ resolvedDomainPackStatus: 'EXPERIMENTAL',
+ selectedBy: 'FALLBACK',
+ resolvedAt: '2026-06-27T00:00:00.000Z',
+ },
}),
+ selectResolvedPack: jest.fn((selection) => ({
+ pack: {
+ id: selection.resolvedDomainPackId,
+ name: 'Test Pack',
+ version: selection.resolvedDomainPackVersion,
+ status: selection.resolvedDomainPackStatus,
+ description: 'Test pack',
+ glossaryMetadata: [],
+ concepts: [],
+ retrievalHints: [],
+ riskTemplates: [],
+ qaTemplates: [],
+ unknownTemplates: [],
+ },
+ normalizedPackId: selection.resolvedDomainPackId,
+ selectedBy: selection.selectedBy,
+ resolved: selection,
+ })),
} as unknown as jest.Mocked;
const evidenceStep = new ImpactEvidenceCollectionStep(
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
index 65fa4ba2..89a61346 100644
--- 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
@@ -162,11 +162,12 @@ export function buildWorkspaceDomainPack(
}
const data = domainPack as Record;
+ const selectedBy = normalizeDomainPackSelectedBy(data.selectedBy);
if (
typeof data.id !== 'string' ||
typeof data.version !== 'string' ||
!isDomainPackStatus(data.status) ||
- !isDomainPackSelectedBy(data.selectedBy)
+ !selectedBy
) {
return null;
}
@@ -175,7 +176,7 @@ export function buildWorkspaceDomainPack(
id: data.id,
version: data.version,
status: data.status,
- selectedBy: data.selectedBy,
+ selectedBy,
};
}
@@ -274,8 +275,18 @@ function isDomainPackSelectedBy(
value: unknown,
): value is NonNullable['selectedBy'] {
return (
- value === 'manual_config' ||
- value === 'repository_profile' ||
- value === 'safe_default'
+ value === 'EXPLICIT' ||
+ value === 'REPOSITORY_PROFILE' ||
+ value === 'FALLBACK'
);
}
+
+function normalizeDomainPackSelectedBy(
+ value: unknown,
+): NonNullable['selectedBy'] | null {
+ if (isDomainPackSelectedBy(value)) return value;
+ if (value === 'manual_config') return 'EXPLICIT';
+ if (value === 'repository_profile') return 'REPOSITORY_PROFILE';
+ if (value === 'safe_default') return 'FALLBACK';
+ return null;
+}
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.spec.ts
new file mode 100644
index 00000000..485fc413
--- /dev/null
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.spec.ts
@@ -0,0 +1,89 @@
+import { CreateMultiRepoImpactAnalysesUseCase } from './create-multi-repo-impact-analyses.usecase';
+import { DomainPackRegistry } from '../../../domain-pack/application/domain-pack.registry';
+
+describe('CreateMultiRepoImpactAnalysesUseCase domain pack selection', () => {
+ it('copies run-level explicit healthcare selection to all child analyses', async () => {
+ const createImpactAnalysis = {
+ execute: jest.fn(async (params) => ({
+ id: `analysis-${params.snapshotId}`,
+ multiRepoRunId: 'run-1',
+ status: 'QUEUED',
+ })),
+ };
+ const impactAnalyses = {
+ attachToMultiRepoRun: jest.fn(),
+ };
+ const runs = {
+ findByProjectRequestKey: jest.fn().mockResolvedValue(null),
+ create: jest.fn().mockResolvedValue({ id: 'run-1' }),
+ };
+ const requirements = {
+ findRevisionById: jest.fn().mockResolvedValue({
+ id: 'rev-1',
+ requirementId: 'req-1',
+ readinessStatus: 'READY_FOR_ANALYSIS',
+ }),
+ };
+ const prisma = {
+ requirement: {
+ findUnique: jest.fn().mockResolvedValue({
+ id: 'req-1',
+ projectId: 'project-1',
+ }),
+ },
+ repository: {
+ findUnique: jest.fn(async ({ where }: { where: { id: string } }) => ({
+ id: where.id,
+ projectId: 'project-1',
+ canonicalUrl: `https://github.com/example/${where.id}`,
+ targets: [
+ {
+ id: `target-${where.id}`,
+ latestObservedCommitSha: `commit-${where.id}`,
+ },
+ ],
+ })),
+ },
+ repositorySnapshot: {
+ findFirst: jest.fn(async ({ where }: { where: { repositoryId: string } }) => ({
+ id: `snapshot-${where.repositoryId}`,
+ repositoryId: where.repositoryId,
+ })),
+ },
+ };
+
+ const useCase = new CreateMultiRepoImpactAnalysesUseCase(
+ createImpactAnalysis as any,
+ impactAnalyses as any,
+ runs as any,
+ prisma as any,
+ requirements as any,
+ new DomainPackRegistry(),
+ );
+
+ await useCase.execute({
+ actorId: 'user-1',
+ projectId: 'project-1',
+ requirementRevisionId: 'rev-1',
+ repositoryIds: ['repo-a', 'repo-b'],
+ requestKey: '00000000-0000-4000-8000-000000000001',
+ allowPartialSnapshot: false,
+ domainPackId: 'healthcare',
+ });
+
+ expect(createImpactAnalysis.execute).toHaveBeenCalledTimes(2);
+ for (const call of createImpactAnalysis.execute.mock.calls) {
+ expect(call[0]).toMatchObject({
+ domainPackId: 'healthcare',
+ selectedDomainPack: {
+ requestedDomainPackId: 'healthcare',
+ resolvedDomainPackId: 'healthcare',
+ resolvedDomainPackVersion: '0.1.0',
+ resolvedDomainPackStatus: 'PARTIAL',
+ selectedBy: 'EXPLICIT',
+ resolvedAt: expect.any(String),
+ },
+ });
+ }
+ });
+});
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.ts
index d027ba76..3b9b437d 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/create-multi-repo-impact-analyses.usecase.ts
@@ -6,6 +6,8 @@ import { CreateImpactAnalysisUseCase } from '../lifecycle/create-impact-analysis
import { RequirementRepository } from '../../../requirement/infrastructure/requirement.repository';
import { ImpactAnalysisRepository } from '../../infrastructure/impact-analysis.repository';
import { MultiRepoAnalysisRunRepository } from '../../infrastructure/multi-repo-analysis-run.repository';
+import { DomainPackRegistry } from '../../../domain-pack/application/domain-pack.registry';
+import type { ResolvedDomainPackSelection } from '@ba-helper/contracts';
type PlannedRepositoryAnalysis = {
repositoryId: string;
@@ -22,6 +24,7 @@ export class CreateMultiRepoImpactAnalysesUseCase {
private readonly runs: MultiRepoAnalysisRunRepository,
private readonly prisma: PrismaService,
private readonly requirements: RequirementRepository,
+ private readonly domainPacks: DomainPackRegistry,
) {}
async execute(params: {
@@ -31,6 +34,7 @@ export class CreateMultiRepoImpactAnalysesUseCase {
repositoryIds: string[];
requestKey: string;
allowPartialSnapshot: boolean;
+ domainPackId?: string | null;
}) {
const revision = await this.requirements.findRevisionById(
params.requirementRevisionId,
@@ -63,6 +67,9 @@ export class CreateMultiRepoImpactAnalysesUseCase {
this.planRepositoryAnalysis(params.projectId, repositoryId),
),
);
+ const explicitDomainPack = params.domainPackId
+ ? this.domainPacks.selectPack({ manualPackId: params.domainPackId }).resolved
+ : null;
const existingRun = await this.runs.findByProjectRequestKey(
params.projectId,
@@ -80,7 +87,8 @@ export class CreateMultiRepoImpactAnalysesUseCase {
existingRepositoryIds.length !== requestedRepositoryIds.length ||
existingRepositoryIds.some(
(repositoryId, index) => repositoryId !== requestedRepositoryIds[index],
- )
+ ) ||
+ !existingRunMatchesDomainPack(existingRun.analyses, explicitDomainPack)
) {
throw new AppError(
'REQUEST_KEY_MISMATCH',
@@ -107,6 +115,8 @@ export class CreateMultiRepoImpactAnalysesUseCase {
multiRepoRunId: run.id,
requestKey: deriveChildRequestKey(params.requestKey, plan.repositoryId),
allowPartialSnapshot: params.allowPartialSnapshot,
+ domainPackId: params.domainPackId,
+ selectedDomainPack: explicitDomainPack ?? undefined,
});
if (analysis.multiRepoRunId !== run.id) {
@@ -182,6 +192,38 @@ export class CreateMultiRepoImpactAnalysesUseCase {
}
}
+function existingRunMatchesDomainPack(
+ analyses: Array<{ metadata?: unknown }>,
+ explicitDomainPack: ResolvedDomainPackSelection | null,
+) {
+ if (!explicitDomainPack) {
+ return true;
+ }
+
+ return analyses.every((analysis) => {
+ const selected = readSelectedDomainPack(analysis.metadata);
+ return (
+ selected?.resolvedDomainPackId === explicitDomainPack.resolvedDomainPackId &&
+ selected?.resolvedDomainPackVersion === explicitDomainPack.resolvedDomainPackVersion &&
+ selected?.resolvedDomainPackStatus === explicitDomainPack.resolvedDomainPackStatus &&
+ selected?.selectedBy === explicitDomainPack.selectedBy
+ );
+ });
+}
+
+function readSelectedDomainPack(metadata: unknown): ResolvedDomainPackSelection | null {
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
+ return null;
+ }
+
+ const selected = (metadata as Record).selectedDomainPack;
+ if (!selected || typeof selected !== 'object' || Array.isArray(selected)) {
+ return null;
+ }
+
+ return selected as ResolvedDomainPackSelection;
+}
+
function deriveChildRequestKey(batchRequestKey: string, repositoryId: string): string {
const hash = createHash('sha1')
.update(`${batchRequestKey}:${repositoryId}`)
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/export-approved-multi-repo-report.usecase.spec.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/export-approved-multi-repo-report.usecase.spec.ts
index 5f5f1c12..98cd310e 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/export-approved-multi-repo-report.usecase.spec.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/export-approved-multi-repo-report.usecase.spec.ts
@@ -40,6 +40,7 @@ describe('ExportApprovedMultiRepoReportUseCase', () => {
isStale: false,
staleReason: undefined,
provenance: {
+ domainPack: null,
childAnalyses: [
{
analysisId: 'analysis-1',
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/finalize-multi-repo-report.usecase.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/finalize-multi-repo-report.usecase.ts
index 0ac9f91a..7586629d 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/finalize-multi-repo-report.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/finalize-multi-repo-report.usecase.ts
@@ -5,6 +5,7 @@ import { MultiRepoAnalysisRunRepository } from '../../infrastructure/multi-repo-
import { MultiRepoMergedReportRepository } from '../../infrastructure/multi-repo-merged-report.repository';
import { GetApprovedMultiRepoReportUseCase } from './get-approved-multi-repo-report.usecase';
import { RequestUser } from '@ba-helper/contracts';
+import type { DomainPackSelectedBy, DomainProfileCapabilityStatus } from '@ba-helper/contracts';
import {
deriveMergedReportState,
MultiRepoChildState,
@@ -86,6 +87,7 @@ export class FinalizeMultiRepoReportUseCase {
runId,
content: draft.markdown,
provenance: {
+ domainPack: readRunDomainPackProvenance(revalidatedRun.analyses),
childAnalyses: revalidatedProvenance,
},
});
@@ -110,6 +112,7 @@ function toChildStates(
resolvedRefType: MultiRepoChildState['sourceTarget']['resolvedRefType'];
latestObservedCommitSha: string;
};
+ metadata?: unknown;
}>,
): MultiRepoChildState[] {
return analyses.map((analysis) => {
@@ -130,6 +133,80 @@ function toChildStates(
});
}
+function readRunDomainPackProvenance(
+ analyses: Array<{ metadata?: unknown }>,
+): {
+ domainPackId: string;
+ domainPackVersion: string;
+ domainPackStatus: DomainProfileCapabilityStatus;
+ selectedBy: DomainPackSelectedBy;
+} | null {
+ const first = analyses[0] ? readDomainPackProvenance(analyses[0].metadata) : null;
+ if (!first) return null;
+
+ const allSame = analyses.every((analysis) => {
+ const next = readDomainPackProvenance(analysis.metadata);
+ return (
+ next?.domainPackId === first.domainPackId &&
+ next?.domainPackVersion === first.domainPackVersion &&
+ next?.domainPackStatus === first.domainPackStatus &&
+ next?.selectedBy === first.selectedBy
+ );
+ });
+
+ return allSame ? first : null;
+}
+
+function readDomainPackProvenance(metadata: unknown): {
+ domainPackId: string;
+ domainPackVersion: string;
+ domainPackStatus: DomainProfileCapabilityStatus;
+ selectedBy: DomainPackSelectedBy;
+} | null {
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
+ return null;
+ }
+
+ const provenance = (metadata as Record).reportProvenance;
+ if (!provenance || typeof provenance !== 'object' || Array.isArray(provenance)) {
+ return null;
+ }
+
+ const data = provenance as Record;
+ if (
+ typeof data.domainPackId !== 'string' ||
+ typeof data.domainPackVersion !== 'string' ||
+ !isDomainPackStatus(data.domainPackStatus) ||
+ !isDomainPackSelectedBy(data.selectedBy)
+ ) {
+ return null;
+ }
+
+ return {
+ domainPackId: data.domainPackId,
+ domainPackVersion: data.domainPackVersion,
+ domainPackStatus: data.domainPackStatus,
+ selectedBy: data.selectedBy,
+ };
+}
+
+function isDomainPackStatus(value: unknown): value is DomainProfileCapabilityStatus {
+ return (
+ value === 'STABLE' ||
+ value === 'PARTIAL' ||
+ value === 'EXPERIMENTAL' ||
+ value === 'FALLBACK'
+ );
+}
+
+function isDomainPackSelectedBy(value: unknown): value is DomainPackSelectedBy {
+ return (
+ value === 'EXPLICIT' ||
+ value === 'REPOSITORY_PROFILE' ||
+ value === 'FALLBACK'
+ );
+}
+
function buildApprovedChildProvenance(
children: MultiRepoChildState[],
): StoredChildProvenance[] {
diff --git a/apps/api/src/modules/impact-analysis/application/multi-repo/get-approved-multi-repo-report.usecase.ts b/apps/api/src/modules/impact-analysis/application/multi-repo/get-approved-multi-repo-report.usecase.ts
index 381cd201..3cb6fde1 100644
--- a/apps/api/src/modules/impact-analysis/application/multi-repo/get-approved-multi-repo-report.usecase.ts
+++ b/apps/api/src/modules/impact-analysis/application/multi-repo/get-approved-multi-repo-report.usecase.ts
@@ -61,8 +61,37 @@ export class GetApprovedMultiRepoReportUseCase {
isStale: mergedReportState.staleness.isStale,
staleReason: mergedReportState.staleness.staleReason,
provenance: {
+ domainPack: readStoredDomainPackProvenance(report.provenance),
childAnalyses: mergedReportState.storedChildProvenance,
},
};
}
}
+
+function readStoredDomainPackProvenance(provenance: unknown) {
+ if (!provenance || typeof provenance !== 'object' || Array.isArray(provenance)) {
+ return null;
+ }
+
+ const domainPack = (provenance as Record).domainPack;
+ if (!domainPack || typeof domainPack !== 'object' || Array.isArray(domainPack)) {
+ return null;
+ }
+
+ const data = domainPack as Record;
+ if (
+ typeof data.domainPackId !== 'string' ||
+ typeof data.domainPackVersion !== 'string' ||
+ typeof data.domainPackStatus !== 'string' ||
+ typeof data.selectedBy !== 'string'
+ ) {
+ return null;
+ }
+
+ return {
+ domainPackId: data.domainPackId,
+ domainPackVersion: data.domainPackVersion,
+ domainPackStatus: data.domainPackStatus,
+ selectedBy: data.selectedBy,
+ };
+}
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
index 58cdec49..696b35cb 100644
--- 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
@@ -27,7 +27,7 @@ describe('GetAnalysisWorkspaceUseCase', () => {
id: 'booking',
version: '0.1.0',
status: 'STABLE',
- selectedBy: 'repository_profile',
+ selectedBy: 'REPOSITORY_PROFILE',
});
expect(result.impactGroups[0].artifacts[0].artifactKey).toBe(
'api:booking.controller.cancel',
@@ -144,7 +144,7 @@ function createAnalysis(overrides: Record = {}) {
id: 'booking',
version: '0.1.0',
status: 'STABLE',
- selectedBy: 'repository_profile',
+ selectedBy: 'REPOSITORY_PROFILE',
},
},
snapshot: {
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 abf7506d..45642d73 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
@@ -110,7 +110,7 @@ describe('Diagnostic Risk Propagation', () => {
qaTemplates: [],
unknownTemplates: [],
},
- selectedBy: 'safe_default',
+ selectedBy: 'FALLBACK',
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 3b00b30b..33432b37 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
@@ -1,3 +1,5 @@
+import type { ResolvedDomainPackSelection } from '@ba-helper/contracts';
+
export type ImpactAnalysisMetadata = {
llm?: {
provider: string;
@@ -25,6 +27,13 @@ export type ImpactAnalysisMetadata = {
status: 'STABLE' | 'PARTIAL' | 'EXPERIMENTAL' | 'FALLBACK';
selectedBy: string;
};
+ selectedDomainPack?: ResolvedDomainPackSelection;
+ reportProvenance?: {
+ domainPackId: string;
+ domainPackVersion: string;
+ domainPackStatus: 'STABLE' | 'PARTIAL' | 'EXPERIMENTAL' | 'FALLBACK';
+ selectedBy: string;
+ };
diagnostics?: Array<{
code: string;
severity: string;
diff --git a/apps/api/src/modules/impact-analysis/infrastructure/impact-analysis.repository.ts b/apps/api/src/modules/impact-analysis/infrastructure/impact-analysis.repository.ts
index 8bcd1d71..ed016fe5 100644
--- a/apps/api/src/modules/impact-analysis/infrastructure/impact-analysis.repository.ts
+++ b/apps/api/src/modules/impact-analysis/infrastructure/impact-analysis.repository.ts
@@ -129,6 +129,7 @@ export class ImpactAnalysisRepository {
derivedFromAnalysisId?: string | null;
sourceClarificationId?: string | null;
reviewClarificationRequestId?: string | null;
+ metadata?: ImpactAnalysisMetadata | null;
}) {
return this.prisma.impactAnalysis.create({
data: {
@@ -145,6 +146,7 @@ export class ImpactAnalysisRepository {
derivedFromAnalysisId: params.derivedFromAnalysisId,
sourceClarificationId: params.sourceClarificationId,
reviewClarificationRequestId: params.reviewClarificationRequestId,
+ ...(params.metadata ? { metadata: params.metadata as any } : {}),
},
include: IMPACT_ANALYSIS_INCLUDE,
});
diff --git a/apps/api/src/modules/retrieval/application/hybrid-retrieval.service.ts b/apps/api/src/modules/retrieval/application/hybrid-retrieval.service.ts
index b4a3539b..a699187a 100644
--- a/apps/api/src/modules/retrieval/application/hybrid-retrieval.service.ts
+++ b/apps/api/src/modules/retrieval/application/hybrid-retrieval.service.ts
@@ -63,7 +63,10 @@ export class HybridRetrievalService {
const indexStatus = snapshot?.indexStatus ?? 'NOT_INDEXED';
const profileDomain = snapshot?.profile?.domain;
const domainPackSelection = this.domainPackRegistry.selectPack({
- repositoryProfileDomain: profileDomain ?? request.domain,
+ manualPackId: request.domain && request.domain !== 'UNKNOWN'
+ ? request.domain
+ : null,
+ repositoryProfileDomain: profileDomain,
});
const domainPack = domainPackSelection.pack;
diff --git a/apps/api/src/modules/retrieval/domain/retrieval.types.ts b/apps/api/src/modules/retrieval/domain/retrieval.types.ts
index 574d200a..888d6556 100644
--- a/apps/api/src/modules/retrieval/domain/retrieval.types.ts
+++ b/apps/api/src/modules/retrieval/domain/retrieval.types.ts
@@ -1,4 +1,5 @@
import type { RetrievalSuggestion } from './retrieval-suggestion';
+import type { DomainPackSelectedBy } from '@ba-helper/contracts';
export interface RetrievalDiagnostics {
version: 'retrieval-diagnostics@0.1.0';
@@ -22,7 +23,7 @@ export interface RetrievalDiagnostics {
id: string;
version: string;
status: 'STABLE' | 'PARTIAL' | 'EXPERIMENTAL' | 'FALLBACK';
- selectedBy: 'manual_config' | 'repository_profile' | 'safe_default';
+ selectedBy: DomainPackSelectedBy;
};
finalScore: number;
}
diff --git a/packages/application/src/impact-analysis/application/analysis-run-metadata.ts b/packages/application/src/impact-analysis/application/analysis-run-metadata.ts
index 431a2f5e..b565128d 100644
--- a/packages/application/src/impact-analysis/application/analysis-run-metadata.ts
+++ b/packages/application/src/impact-analysis/application/analysis-run-metadata.ts
@@ -31,6 +31,13 @@ export const buildCompletedAnalysisMetadata = (params: {
status: domainPack.status,
selectedBy: domainPackResult.selectedBy,
},
+ selectedDomainPack: domainPackResult.resolved,
+ reportProvenance: {
+ domainPackId: domainPack.id,
+ domainPackVersion: domainPack.version,
+ domainPackStatus: domainPack.status,
+ selectedBy: domainPackResult.selectedBy,
+ },
diagnostics: [
{
code: 'DOMAIN_PACK_APPLIED',
diff --git a/packages/application/src/impact-analysis/application/run-impact-analysis.usecase.ts b/packages/application/src/impact-analysis/application/run-impact-analysis.usecase.ts
index c3040034..35799383 100644
--- a/packages/application/src/impact-analysis/application/run-impact-analysis.usecase.ts
+++ b/packages/application/src/impact-analysis/application/run-impact-analysis.usecase.ts
@@ -8,6 +8,7 @@ import { buildCompletedAnalysisMetadata } from './analysis-run-metadata';
import type { ImpactEvidenceCollectionStep } from './steps/impact-evidence-collection.step';
import type { ImpactDiagnosticPropagationStep } from './steps/impact-diagnostic-propagation.step';
import type { ImpactAiReasoningStep } from './steps/impact-ai-reasoning.step';
+import type { ResolvedDomainPackSelection } from '@ba-helper/contracts';
export class RunImpactAnalysisUseCase {
constructor(
@@ -73,10 +74,13 @@ export class RunImpactAnalysisUseCase {
try {
const snapshotDomain = analysis.snapshot.profile?.domain;
- const domainPackResult = this.domainPackSelection.selectPack({
- manualPackId: params.domain,
- repositoryProfileDomain: snapshotDomain,
- });
+ const persistedDomainPack = readSelectedDomainPack(analysis.metadata);
+ const domainPackResult = persistedDomainPack
+ ? this.domainPackSelection.selectResolvedPack(persistedDomainPack)
+ : this.domainPackSelection.selectPack({
+ manualPackId: params.domain,
+ repositoryProfileDomain: snapshotDomain,
+ });
// Step 1: Collect Evidence and Traceability Links
const evidenceResult = await this.evidenceStep.execute(
@@ -263,3 +267,27 @@ export class RunImpactAnalysisUseCase {
}
}
}
+
+function readSelectedDomainPack(metadata: unknown): ResolvedDomainPackSelection | null {
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
+ return null;
+ }
+
+ const selected = (metadata as Record).selectedDomainPack;
+ if (!selected || typeof selected !== 'object' || Array.isArray(selected)) {
+ return null;
+ }
+
+ const data = selected as Record;
+ if (
+ typeof data.resolvedDomainPackId !== 'string' ||
+ typeof data.resolvedDomainPackVersion !== 'string' ||
+ typeof data.resolvedDomainPackStatus !== 'string' ||
+ typeof data.selectedBy !== 'string' ||
+ typeof data.resolvedAt !== 'string'
+ ) {
+ return null;
+ }
+
+ return data as ResolvedDomainPackSelection;
+}
diff --git a/packages/application/src/impact-analysis/ports/domain-pack-selection.port.ts b/packages/application/src/impact-analysis/ports/domain-pack-selection.port.ts
index 196b1360..1402c833 100644
--- a/packages/application/src/impact-analysis/ports/domain-pack-selection.port.ts
+++ b/packages/application/src/impact-analysis/ports/domain-pack-selection.port.ts
@@ -1,4 +1,8 @@
-import type { DomainPack } from '@ba-helper/contracts';
+import type {
+ DomainPack,
+ DomainPackSelectedBy,
+ ResolvedDomainPackSelection,
+} from '@ba-helper/contracts';
export type DomainPackSelectionInput = {
manualPackId?: string | null;
@@ -8,9 +12,11 @@ export type DomainPackSelectionInput = {
export type DomainPackSelectionResult = {
pack: DomainPack;
normalizedPackId: string;
- selectedBy: 'manual_config' | 'repository_profile' | 'safe_default';
+ selectedBy: DomainPackSelectedBy;
+ resolved: ResolvedDomainPackSelection;
};
export interface DomainPackSelectionPort {
selectPack(input: DomainPackSelectionInput): DomainPackSelectionResult;
+ selectResolvedPack(selection: ResolvedDomainPackSelection): DomainPackSelectionResult;
}
diff --git a/packages/application/src/impact-analysis/ports/impact-analysis.repository.port.ts b/packages/application/src/impact-analysis/ports/impact-analysis.repository.port.ts
index e631a647..163aa55c 100644
--- a/packages/application/src/impact-analysis/ports/impact-analysis.repository.port.ts
+++ b/packages/application/src/impact-analysis/ports/impact-analysis.repository.port.ts
@@ -1,4 +1,5 @@
import type { DomainProfileCapabilityStatus } from '@ba-helper/contracts';
+import type { ResolvedDomainPackSelection } from '@ba-helper/contracts';
/** Minimal analysis record for RunImpactAnalysisUseCase */
export type ImpactAnalysisRecord = {
@@ -21,6 +22,7 @@ export type ImpactAnalysisRecord = {
requirement?: { projectId: string } | null;
};
multiRepoRun?: { createdByUserId?: string | null } | null;
+ metadata?: unknown;
};
export type ImpactAnalysisStatusUpdate = {
@@ -52,7 +54,14 @@ export type ImpactAnalysisStatusUpdate = {
id: string;
version: string;
status: DomainProfileCapabilityStatus;
- selectedBy: 'manual_config' | 'repository_profile' | 'safe_default';
+ selectedBy: 'EXPLICIT' | 'REPOSITORY_PROFILE' | 'FALLBACK';
+ };
+ selectedDomainPack?: ResolvedDomainPackSelection;
+ reportProvenance?: {
+ domainPackId: string;
+ domainPackVersion: string;
+ domainPackStatus: DomainProfileCapabilityStatus;
+ selectedBy: 'EXPLICIT' | 'REPOSITORY_PROFILE' | 'FALLBACK';
};
diagnostics?: Array<{
code: string;
diff --git a/tests/api/impact-analysis.partial.spec.ts b/tests/api/impact-analysis.partial.spec.ts
index e7652365..46ad79e3 100644
--- a/tests/api/impact-analysis.partial.spec.ts
+++ b/tests/api/impact-analysis.partial.spec.ts
@@ -1,4 +1,5 @@
import { CreateImpactAnalysisUseCase } from '../../apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase';
+import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry';
class StubImpactRepo {
created: Array<{ coverageWarning?: string | null; acceptedPartialCoverage: boolean }> = [];
@@ -79,6 +80,7 @@ describe('CreateImpactAnalysisUseCase partial snapshot', () => {
new StubPrisma() as any,
new StubEventLog() as any,
new StubQueue() as any,
+ new DomainPackRegistry(),
);
await useCase.execute({
diff --git a/tests/demo/golden-path-demo.spec.ts b/tests/demo/golden-path-demo.spec.ts
index d56cb3dd..92c0d4a7 100644
--- a/tests/demo/golden-path-demo.spec.ts
+++ b/tests/demo/golden-path-demo.spec.ts
@@ -174,7 +174,7 @@ describe('Golden Path Demo', () => {
expect(packDiagnostic).toBeDefined();
expect(packDiagnostic.payload.domainPackId).toBe('booking');
expect(packDiagnostic.payload.domainPackVersion).toBeDefined();
- expect(packDiagnostic.payload.selectedBy).toBe('manual_config');
+ expect(packDiagnostic.payload.selectedBy).toBe('EXPLICIT');
// Ensure Boundedness
expect(packDiagnostic.payload.rawPrompts).toBeUndefined();
expect(packDiagnostic.payload.sourceCode).toBeUndefined();
@@ -213,6 +213,10 @@ describe('Golden Path Demo', () => {
where: { impactAnalysisId: analysisId },
data: { reviewStatus: 'CONFIRMED' },
});
+ await prisma.traceabilityLink.updateMany({
+ where: { impactAnalysisId: analysisId },
+ data: { reviewStatus: 'CONFIRMED' },
+ });
const unreviewedCount = await prisma.baInsight.count({
where: { impactAnalysisId: analysisId, reviewStatus: 'NEEDS_REVIEW' },
diff --git a/tests/impact-analysis/create-impact-analysis-queue.spec.ts b/tests/impact-analysis/create-impact-analysis-queue.spec.ts
index d92221f4..dbd69955 100644
--- a/tests/impact-analysis/create-impact-analysis-queue.spec.ts
+++ b/tests/impact-analysis/create-impact-analysis-queue.spec.ts
@@ -1,4 +1,5 @@
import { CreateImpactAnalysisUseCase } from '../../apps/api/src/modules/impact-analysis/application/lifecycle/create-impact-analysis.usecase';
+import { DomainPackRegistry } from '../../apps/api/src/modules/domain-pack/application/domain-pack.registry';
class StubImpactRepo {
findByRequestKey = async () => null;
@@ -77,6 +78,7 @@ describe('CreateImpactAnalysisUseCase', () => {
new StubPrisma() as any,
new StubEventLog() as any,
queue as any,
+ new DomainPackRegistry(),
);
await useCase.execute({
diff --git a/tests/impact-analysis/run-impact-analysis.spec.ts b/tests/impact-analysis/run-impact-analysis.spec.ts
index 1f8382b0..dad9cbf7 100644
--- a/tests/impact-analysis/run-impact-analysis.spec.ts
+++ b/tests/impact-analysis/run-impact-analysis.spec.ts
@@ -382,7 +382,7 @@ describe('RunImpactAnalysisUseCase', () => {
expect(diagnostic.payload).toMatchObject({
domainPackId: 'booking',
domainPackVersion: expect.any(String),
- selectedBy: 'manual_config',
+ selectedBy: 'EXPLICIT',
conceptCount: expect.any(Number),
retrievalHintCount: expect.any(Number),
riskTemplateCount: expect.any(Number),
@@ -447,7 +447,7 @@ describe('RunImpactAnalysisUseCase', () => {
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');
+ expect(diagnostic.payload.selectedBy).toBe('FALLBACK');
});
it('booking profile emits booking@0.1.0', async () => {
@@ -499,10 +499,10 @@ describe('RunImpactAnalysisUseCase', () => {
const diagnostic = finalUpdateCall![0].metadata.diagnostics.find((d: any) => d.code === 'DOMAIN_PACK_APPLIED');
expect(diagnostic.payload.domainPackId).toBe('booking');
- expect(diagnostic.payload.selectedBy).toBe('repository_profile');
+ expect(diagnostic.payload.selectedBy).toBe('REPOSITORY_PROFILE');
});
- it('rental profile emits rental@0.1.0 as PARTIAL', async () => {
+ it('persisted rental selection emits rental@0.1.0 as PARTIAL', async () => {
class RentalProfileRepo extends StubImpactRepo {
findById = async () => ({
id: 'analysis-1',
@@ -521,6 +521,16 @@ describe('RunImpactAnalysisUseCase', () => {
rawText: 'Update tenant deposit payment for rental contract.',
requirement: { projectId: 'project-1' },
},
+ metadata: {
+ selectedDomainPack: {
+ requestedDomainPackId: 'rental',
+ resolvedDomainPackId: 'rental',
+ resolvedDomainPackVersion: '0.1.0',
+ resolvedDomainPackStatus: 'PARTIAL',
+ selectedBy: 'EXPLICIT',
+ resolvedAt: '2026-06-27T00:00:00.000Z',
+ },
+ },
});
}
@@ -553,7 +563,7 @@ describe('RunImpactAnalysisUseCase', () => {
expect(diagnostic.payload.domainPackId).toBe('rental');
expect(diagnostic.payload.domainPackVersion).toBe('0.1.0');
expect(diagnostic.payload.domainPackStatus).toBe('PARTIAL');
- expect(diagnostic.payload.selectedBy).toBe('repository_profile');
+ expect(diagnostic.payload.selectedBy).toBe('EXPLICIT');
});
it('rejects non-runnable analyses', async () => {
From ef20fbb1eb723c0790d302efacd3f30c64d2bb4a Mon Sep 17 00:00:00 2001
From: Dip Hung Thinh
Date: Sat, 27 Jun 2026 16:57:11 +0700
Subject: [PATCH 15/35] feat(web): add backend-driven domain pack selector
---
.../runs/[runId]/merged-report/page.tsx | 16 ++++++-
.../analysis/analysis-workspace-shell.tsx | 12 +++++
.../new-analysis/confirmation-step.tsx | 47 +++++++++++++++++++
.../new-analysis/new-analysis-dialog.tsx | 12 +++++
.../new-analysis/new-analysis-types.ts | 12 ++++-
apps/web/src/hooks/api/use-domain-packs.ts | 19 ++++++++
apps/web/src/hooks/api/use-multi-repo-runs.ts | 2 +
apps/web/src/lib/api/query-keys.ts | 3 ++
8 files changed, 121 insertions(+), 2 deletions(-)
create mode 100644 apps/web/src/hooks/api/use-domain-packs.ts
diff --git a/apps/web/src/app/(app)/analyses/runs/[runId]/merged-report/page.tsx b/apps/web/src/app/(app)/analyses/runs/[runId]/merged-report/page.tsx
index bdcb96f4..491cc462 100644
--- a/apps/web/src/app/(app)/analyses/runs/[runId]/merged-report/page.tsx
+++ b/apps/web/src/app/(app)/analyses/runs/[runId]/merged-report/page.tsx
@@ -3,7 +3,7 @@
import { use } from "react"
import Link from "next/link"
import { notFound } from "next/navigation"
-import { AlertCircle, CheckCircle2, FileWarning, MessageSquareWarning, XCircle } from "lucide-react"
+import { AlertCircle, AlertTriangle, CheckCircle2, FileWarning, MessageSquareWarning, XCircle } from "lucide-react"
import { WorkspacePageHeader } from "@/components/workspace/shared/page-header"
import { Skeleton } from "@/components/ui/skeleton"
import { Button } from "@/components/ui/button"
@@ -206,6 +206,20 @@ export default function ApprovedMultiRepoReportPage({
)}
+ {data.provenance.domainPack?.domainPackStatus === "PARTIAL" && (
+
+
+
+
+ {data.provenance.domainPack.domainPackId}@{data.provenance.domainPack.domainPackVersion} is PARTIAL
+
+ Domain hints are limited and require source evidence.
+ This pack supports administrative workflow impact analysis only.
+ It does not provide medical advice, clinical decision support, or compliance validation.
+
+
+ )}
+
Run: {data.runId}Requirement: {data.requirementTitle}
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 fe9bb1d4..2871b22d 100644
--- a/apps/web/src/components/workspace/analysis/analysis-workspace-shell.tsx
+++ b/apps/web/src/components/workspace/analysis/analysis-workspace-shell.tsx
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react"
import type { AnalysisWorkspaceResponse } from "@ba-helper/contracts"
+import { AlertTriangle } from "lucide-react"
import { cn } from "@/lib/utils"
import {
DEFAULT_ANALYSIS_WORKSPACE_LOCALE,
@@ -93,6 +94,17 @@ export function AnalysisWorkspaceShell({