diff --git a/apps/ui/.eslintignore b/apps/ui/.eslintignore index bd81f2bf..0ca90d43 100644 --- a/apps/ui/.eslintignore +++ b/apps/ui/.eslintignore @@ -1,4 +1,5 @@ **/node_modules/* **/out/* +**/public/docs/* **/scripts/* **/.next/* diff --git a/apps/ui/.eslintrc.json b/apps/ui/.eslintrc.json index b48e51d5..400f6442 100644 --- a/apps/ui/.eslintrc.json +++ b/apps/ui/.eslintrc.json @@ -58,6 +58,28 @@ { "allow": ["warn", "error"] } + ], + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "../features/*/*", + "!../features/*/index", + "!../features/*/index.ts", + "!../features/*/index.tsx", + "@/src/features/*/*", + "!@/src/features/*/index", + "!@/src/features/*/index.ts", + "!@/src/features/*/index.tsx", + "../features/*/internal/*", + "@/src/features/*/internal/*" + ], + "message": "Cross-feature deep imports are rejected. Import features only via their public index.ts." + } + ] + } ] }, "overrides": [ diff --git a/apps/ui/README.md b/apps/ui/README.md index 1db96490..5f847301 100644 --- a/apps/ui/README.md +++ b/apps/ui/README.md @@ -1,18 +1,22 @@ # UI ## Purpose + Provides the Next.js frontend for Holiday Peak Hub operations and workflows. ## Responsibilities + - Render admin and operations interfaces for retail workflows. - Call backend APIs for transactional and intelligence scenarios. - Provide a single web entry point for platform users. ## Key endpoints or interfaces + - Next.js app interface served through the standard web root. - API integration targets are configured through frontend environment variables. ## Run/Test commands + ```bash yarn --cwd apps/ui install yarn --cwd apps/ui dev @@ -24,12 +28,18 @@ yarn --cwd apps/ui type-check ``` ## Coverage and quality gates + - Jest enforces global coverage thresholds (`branches/functions/lines/statements >= 60%`) in `apps/ui/jest.config.js`. - Baseline critical-flow E2E coverage runs through Playwright (`apps/ui/tests/e2e/critical-flows.spec.ts`). - Executive demo and operator regression coverage now also includes `apps/ui/tests/e2e/demo-narrative.spec.ts`, `apps/ui/tests/e2e/dark-mode-regression.spec.ts`, and `apps/ui/tests/e2e/cockpit-readiness.spec.ts`. - Focused unit coverage also validates telemetry persistence for enrichment-backed product loads and graph-summary enrichment calls. +## Feature import boundaries + +- Cross-feature deep imports are rejected by ESLint. Import features only via their public `index.ts`. + ## Configuration notes + - Uses frontend environment variables for backend/API URLs and auth integration. - Build and runtime behavior are controlled by `apps/ui/package.json` scripts. - Uses Next.js and TypeScript toolchain configured in the UI app directory. diff --git a/apps/ui/tests/unit/featureIsolationLint.test.ts b/apps/ui/tests/unit/featureIsolationLint.test.ts new file mode 100644 index 00000000..7cc9ca74 --- /dev/null +++ b/apps/ui/tests/unit/featureIsolationLint.test.ts @@ -0,0 +1,142 @@ +/** + * Tests for the feature-isolation ESLint contract (Issue #991). + * + * No GoF runtime pattern applies; this is a governance contract test that + * pins ESLint configuration data for ADR-033's modular-monolith boundary. + */ + +import { existsSync, readFileSync } from 'fs'; +import { resolve } from 'path'; +import { deserialize, serialize } from 'v8'; +import { ESLint } from 'eslint'; + +const APP_ROOT = resolve(__dirname, '..', '..'); +const SYNTHETIC_FILE_PATH = resolve(APP_ROOT, 'src', 'app', 'synthetic-feature-isolation.tsx'); +const FEATURE_ISOLATION_MESSAGE = + 'Cross-feature deep imports are rejected. Import features only via their public index.ts.'; + +interface RestrictedImportPattern { + group?: string[]; + message?: string; +} + +interface EslintConfig { + rules?: Record; +} + +const isRestrictedImportPattern = (value: unknown): value is RestrictedImportPattern => { + if (typeof value !== 'object' || value === null) { + return false; + } + + const candidate = value as { group?: unknown; message?: unknown }; + return Array.isArray(candidate.group) && typeof candidate.message === 'string'; +}; + +const ensureStructuredClone = () => { + if (typeof globalThis.structuredClone === 'function') { + return; + } + + Object.defineProperty(globalThis, 'structuredClone', { + configurable: true, + value: (value: Value): Value => deserialize(serialize(value)) as Value, + }); +}; + +const lintSyntheticSource = async (source: string) => { + ensureStructuredClone(); + + const eslint = new ESLint({ + cwd: APP_ROOT, + overrideConfigFile: resolve(APP_ROOT, '.eslintrc.json'), + }); + + const [result] = await eslint.lintText(source, { + filePath: SYNTHETIC_FILE_PATH, + warnIgnored: false, + }); + + return result.messages; +}; + +describe('feature-isolation ESLint contract (Issue #991)', () => { + const config = (() => { + const path = resolve(APP_ROOT, '.eslintrc.json'); + expect(existsSync(path)).toBe(true); + return JSON.parse(readFileSync(path, 'utf-8')) as EslintConfig; + })(); + + const rule = config.rules?.['no-restricted-imports']; + + it('defines a root no-restricted-imports rule', () => { + expect(Array.isArray(rule)).toBe(true); + expect((rule as unknown[])[0]).toBe('error'); + }); + + it('rejects cross-feature deep imports while allowing public index surfaces', () => { + expect(Array.isArray(rule)).toBe(true); + + const [, options] = rule as [string, { patterns?: unknown[] }]; + const featurePattern = options.patterns?.find(isRestrictedImportPattern); + + expect(featurePattern).toBeDefined(); + expect(featurePattern?.group).toEqual( + expect.arrayContaining([ + '../features/*/*', + '!../features/*/index', + '!../features/*/index.ts', + '!../features/*/index.tsx', + '@/src/features/*/*', + '!@/src/features/*/index', + '!@/src/features/*/index.ts', + '!@/src/features/*/index.tsx', + '../features/*/internal/*', + '@/src/features/*/internal/*', + ]), + ); + }); + + it('explains that features must be imported through public index.ts', () => { + expect(Array.isArray(rule)).toBe(true); + + const [, options] = rule as [string, { patterns?: unknown[] }]; + const featurePattern = options.patterns?.find(isRestrictedImportPattern); + + expect(featurePattern?.message).toContain('Cross-feature deep imports are rejected'); + expect(featurePattern?.message).toContain('public index.ts'); + }); + + it('reports the feature-isolation rule for synthetic cross-feature deep imports', async () => { + const messages = await lintSyntheticSource(` + import { calculateInventoryRisk } from '@/src/features/inventory/internal/risk'; + + export const selectedRiskCalculator = calculateInventoryRisk; + `); + + expect(messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ruleId: 'no-restricted-imports', + message: expect.stringContaining(FEATURE_ISOLATION_MESSAGE), + }), + ]), + ); + }); + + it('allows synthetic public feature index imports', async () => { + const messages = await lintSyntheticSource(` + import { InventoryFeature } from '@/src/features/inventory/index'; + + export const selectedFeature = InventoryFeature; + `); + + expect(messages).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ruleId: 'no-restricted-imports', + }), + ]), + ); + }); +}); \ No newline at end of file