Compare commits

...
75 changed files with 1771 additions and 328 deletions
+9
View File
@@ -29,6 +29,12 @@ module.exports = {
from: { path: "^src/adapters" },
to: { path: "^src/(presentation|bootstrap)" },
},
{
name: "concrete-adapters-compose-only-in-bootstrap",
severity: "error",
from: { path: "^src/(domain|application|presentation|contracts|sample)" },
to: { path: "^src/adapters" },
},
{
name: "no-circular-dependencies",
severity: "error",
@@ -45,5 +51,8 @@ module.exports = {
exportsFields: ["exports"],
conditionNames: ["import", "require", "node", "default"],
},
tsConfig: {
fileName: "tsconfig.app.json",
},
},
};
+12 -1
View File
@@ -6,7 +6,8 @@ operations are executable contracts rather than conventions.
## Start locally
Requirements: Node 24 and Corepack. The repository pins pnpm in `package.json`.
Requirements: Node 24.11 or newer and Corepack. The repository pins pnpm in
`package.json`.
```bash
corepack pnpm install --frozen-lockfile
@@ -80,6 +81,9 @@ Common local checks:
```bash
corepack pnpm lint
corepack pnpm check:types
corepack pnpm check:types:app
corepack pnpm check:types:node
corepack pnpm check:types:test
corepack pnpm check:architecture
corepack pnpm test:all
corepack pnpm test:e2e
@@ -94,6 +98,13 @@ corepack pnpm drill:runbooks
corepack pnpm check:ci
```
`check:types`는 source, Node scripts/config와 tests를 분리된 TypeScript
project로 모두 검사한다. type/architecture/security/registry의 invalid
fixture는 `config/ci/gates.json`에서 “실패해야 통과”하는 negative gate로
실행된다. 도구 호환성 결정은
[VD-01](docs/architecture/decisions/VD-01-typescript-lint-tooling.md)에 기록돼
있다.
Install the pinned Playwright browser engines before the first cross-browser
run:
+15 -3
View File
@@ -74,7 +74,11 @@
"name": "typecheck",
"steps": [
{ "script": "check:types", "expect": "pass" },
{ "script": "check:types:fixture", "expect": "fail" }
{ "script": "check:types:fixture", "expect": "fail" },
{ "script": "check:types:fixture:ts-port", "expect": "fail" },
{ "script": "check:types:fixture:ts-result", "expect": "fail" },
{ "script": "check:types:fixture:application-output", "expect": "fail" },
{ "script": "check:types:fixture:application-input", "expect": "fail" }
],
"logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"],
@@ -136,9 +140,17 @@
},
"FE-GATE-010": {
"name": "architecture",
"steps": [{ "script": "check:architecture", "expect": "pass" }],
"steps": [
{ "script": "check:architecture", "expect": "pass" },
{ "script": "check:registries", "expect": "pass" },
{ "script": "check:registries:fixture", "expect": "fail" }
],
"logPath": "artifacts/quality/gates/FE-GATE-010.txt",
"evidence": ["artifacts/quality/dependency-report.json"],
"evidence": [
"artifacts/quality/dependency-report.json",
"artifacts/quality/registries.json",
"artifacts/quality/registry-fixture.json"
],
"retentionClass": "merge-cycle"
},
"FE-GATE-011": {
@@ -0,0 +1,54 @@
# VD-01: TypeScript 7과 ESLint 10의 점진적 전환 도구
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-typescript-tooling-foundation`
## 배경
저장소는 TypeScript `7.0.2`와 ESLint `10.8.0`을 고정하고 있다. 첫 전환
브랜치는 compiler를 변경하거나 production source를 일괄 변환하지 않고
JS/JSX/TS/TSX가 같은 품질 게이트를 통과하게 해야 한다.
결정 시점의 package peer contract는 다음과 같다.
- `typescript-eslint@8.65.0`과 canary는 TypeScript `<6.1.0`을 요구한다.
- `eslint-plugin-jsx-a11y@6.10.2`는 ESLint `<=9`를 요구한다.
- `eslint-plugin-react-hooks@7.1.1`은 ESLint 10을 지원한다.
- Babel 8 ESLint parser는 ESLint 10을 지원하고 Node `>=24.11.0`을 요구한다.
호환되지 않는 peer dependency를 강제 설치하면 lockfile 검증은 통과하더라도
지원되지 않는 parser와 rule 조합을 플랫폼 계약으로 만들게 된다.
## 결정
1. TypeScript `7.0.2`와 ESLint `10.8.0`을 유지한다.
2. TypeScript/TSX의 ESLint syntax parsing에는
`@babel/eslint-parser`와 TypeScript/JSX syntax plugin을 사용한다.
3. TypeScript의 이름 해석, unused 진단과 type semantics는 `tsc`가 소유한다.
Babel parser가 TypeScript scope manager를 제공하지 않으므로 TS 파일의
core `no-undef``no-unused-vars`는 끄고 분리된 app/node/test TypeScript
project를 필수 게이트로 실행한다.
4. React Hook 규칙은 호환되는 `eslint-plugin-react-hooks`로 즉시 적용한다.
5. JSX 접근성은 현재의 semantic component contract, Testing Library,
axe 기반 cross-browser gate와 수동 검토 계약이 계속 담당한다. 호환되지 않는
`eslint-plugin-jsx-a11y`는 설치하지 않는다.
6. Babel 8의 지원 범위에 맞춰 Node engine 하한을 `24.11.0`으로 명시한다.
7. production source의 대량 rename은 이 결정에 포함하지 않는다.
## 검증
- `check:types`는 app, Node scripts/config, tests project를 모두 검사한다.
- JS invalid-call, TS invalid port, TS discriminated-union fixture는 실패해야 한다.
- ESLint와 dependency-cruiser는 TS/TSX architecture fixture를 검사한다.
- registry scanner는 TS registry의 required field, uniqueness와 reference를
검증한다.
- browser security gate는 TSX의 금지된 raw HTML fixture를 거절한다.
## 후속 검토와 제거
`typescript-eslint`가 TypeScript 7을, JSX 접근성 plugin이 ESLint 10을 공식
지원하면 별도 dependency 브랜치에서 peer metadata와 전체 negative fixture를
재검증한다. 교체할 때는 Babel parser package와 TS 전용 ESLint override를
함께 제거한다. compiler downgrade나 `--force` 설치는 이 ADR의 rollback
방법이 아니다.
@@ -130,21 +130,27 @@ bootstrap은 page별 orchestration이나 업무 규칙을 소유하지 않는다
- render boundary와 async state surface
- route registry와 lazy page
그러나 현재 실행 경로에는 다음 불일치가 있다.
RP-02 구현으로 다음 경계는 실행 경로에 연결됐다.
1. `src/bootstrap/composition-root.js``application`을 만들지만
`src/bootstrap/main.jsx`는 이를 사용하지 않고 `authSession`,
`storage`, `telemetry`를 presentation에 직접 전달한다.
2. `createApplication`이 input use case 대신 cache, storage, telemetry
output port를 그대로 노출한다.
3. `QueryClientProvider`는 존재하지만 실제 product route에서
- `src/bootstrap/composition-root.js`만든 application을 production
`ApplicationProvider`가 실제 React tree에 주입한다.
- `createApplication`은 session, preference, diagnostics, runtime query의
input API만 반환하며 storage, telemetry, release output port를 숨긴다.
- bootstrap composition 결과는 raw output port를 반환하지 않고
application과 React infrastructure만 반환한다.
- presentation의 direct fetch/browser storage/concrete adapter/TanStack import와
application의 React/concrete adapter import는 negative fixture가 거절한다.
후속 브랜치에서 닫아야 할 실행 불일치는 다음과 같다.
1. `QueryClientProvider`는 존재하지만 실제 product route에서
`useQuery` 또는 `useMutation`을 연결하는 query bridge가 없다.
4. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`,
2. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`,
`errorSurface`, `chunkId` 일부는 실행 route와 연결되지 않았다.
5. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API
3. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API
operation, Zod schema, mapper, domain model과 query key는 다른 경로에
남는다.
6. runtime의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`는 검증되지만
4. runtime의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`는 검증되지만
concrete HTTP client 구성에 전달되지 않는다.
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
@@ -36,7 +36,8 @@ TypeScript 전환 목적은 확장자 변경이 아니라 이 계약을 컴파
다음 변경이 첫 브랜치에서 완료되기 전에는 source rename을 시작하지 않는다.
1. ESLint가 `js`, `jsx`, `mjs`, `ts`, `tsx`, `mts`를 모두 검사한다.
2. `typescript-eslint`, React Hooks, JSX accessibility 규칙을 추가한다.
2. React Hooks 규칙을 추가하고 TypeScript/ESLint parser와 JSX accessibility
도구는 설치된 compiler/linter의 공식 peer 범위 안에서 선택한다.
3. dependency-cruiser의 extension과 resolver가 TS/TSX를 포함한다.
4. `scripts/check-registries.mjs`가 TS/TSX를 검색한다.
5. `config/contracts/registry-governance.json`의 경로 갱신 절차를 만든다.
@@ -77,6 +78,13 @@ tsconfig.json # project references only
확정한다. 옵션을 한꺼번에 켜서 수백 개 예외를 만들지 말고, 각 단계에서 새
예외를 금지한다.
현재 저장소의 VD-01 결정은
[TypeScript 7과 ESLint 10의 점진적 전환 도구](./decisions/VD-01-typescript-lint-tooling.md)에
기록돼 있다. app, Node scripts/config, tests는 각각 독립된 project로
typecheck하며 JS에는 `checkJs`, TS에는 `strict`를 적용한다. TypeScript 7을
아직 지원하지 않는 parser plugin을 강제 설치하지 않고 Babel parser는 lint
syntax/import/security 검사, `tsc`는 type semantics를 소유한다.
### 2.3 전환 순서
| 단계 | 대상 | 이유 | 종료 조건 |
@@ -69,12 +69,12 @@ field/documentation 단계를 구성한다.
### 2.3 확인된 공백
#### 테스트 TypeScript typecheck 대상이 아니다
#### 테스트 TypeScript typecheck 기반
현재 `check:types``tsconfig.json`의 include 범위에 의존하며 tests 전체의
callback, mock, fixture, custom matcher type을 별도 프로젝트로 검사하지 않는다.
TypeScript 전환 뒤 Vitest가 test file을 변환해 실행할 수 있다는 사실은
`tsc` typecheck 대체하지 않는다.
`check:types`app, Node scripts/config, tests project를 순서대로 검사한다.
테스트 project는 JS/JSX/TS/TSX의 callback, mock, fixture config type을
검사하되 실패를 의도한 `tests/fixtures`는 별도 negative command가 소유한다.
Vitest의 변환 성공을 TypeScript typecheck 대체물로 취급하지 않는다.
#### 실제 bootstrap integration test가 없다
+126 -34
View File
@@ -1,6 +1,10 @@
import babelParser from "@babel/eslint-parser";
import eslint from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
const sourceExtensions = "{js,jsx,mjs,ts,tsx,mts}";
const layerPatterns = {
domain: [
"**/application/**",
@@ -19,7 +23,12 @@ const layerPatterns = {
"react-dom",
"@tanstack/**",
],
presentation: ["**/adapters/**", "**/bootstrap/**", "@tanstack/**"],
presentation: [
"**/adapters/**",
"**/bootstrap/**",
"**/application/ports/out/**",
"@tanstack/**",
],
adapters: ["**/presentation/**", "**/bootstrap/**"],
};
@@ -27,6 +36,38 @@ function restrictedImports(patterns) {
return ["error", { patterns }];
}
const commonLanguageOptions = {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
},
};
const commonSecurityRules = {
"no-eval": "error",
"no-new-func": "error",
"no-script-url": "error",
"no-restricted-syntax": [
"error",
{
selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']",
message: "Raw HTML injection is prohibited by FE-OC-019.",
},
{
selector:
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
message: "Runtime script construction is prohibited by FE-OC-019.",
},
],
};
const hookRules = {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
};
export default [
{
ignores: [
@@ -40,47 +81,109 @@ export default [
},
eslint.configs.recommended,
{
files: ["**/*.{js,jsx,mjs}"],
files: [`**/*.${sourceExtensions}`],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
},
...commonLanguageOptions,
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
plugins: {
"react-hooks": reactHooks,
},
rules: {
...commonSecurityRules,
...hookRules,
},
},
{
files: ["src/domain/**/*.{js,jsx}"],
files: ["**/*.{ts,mts}"],
languageOptions: {
...commonLanguageOptions,
parser: babelParser,
parserOptions: {
requireConfigFile: false,
babelOptions: {
plugins: [
["@babel/plugin-syntax-typescript", { isTSX: false }],
],
},
},
},
rules: {
"no-undef": "off",
"no-unused-vars": "off",
},
},
{
files: ["**/*.tsx"],
languageOptions: {
...commonLanguageOptions,
parser: babelParser,
parserOptions: {
requireConfigFile: false,
babelOptions: {
plugins: [
[
"@babel/plugin-syntax-typescript",
{ allExtensions: true, isTSX: true },
],
"@babel/plugin-syntax-jsx",
],
},
},
},
rules: {
"no-undef": "off",
"no-unused-vars": "off",
},
},
{
files: [`src/domain/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.domain),
"no-restricted-globals": ["error", "window", "document", "localStorage", "fetch"],
"no-restricted-globals": [
"error",
"window",
"document",
"localStorage",
"fetch",
],
},
},
{
files: ["src/application/**/*.{js,jsx}"],
files: [`src/application/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.application),
"no-restricted-globals": ["error", "window", "document", "localStorage", "fetch"],
"no-restricted-globals": [
"error",
"window",
"document",
"localStorage",
"fetch",
],
},
},
{
files: ["src/presentation/**/*.{js,jsx}"],
files: [`src/presentation/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.presentation),
"no-restricted-globals": [
"error",
"fetch",
"localStorage",
"sessionStorage",
],
},
},
{
files: ["src/adapters/**/*.{js,jsx}"],
files: [`src/adapters/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.adapters),
},
},
{
files: ["tests/**/*.{js,jsx}"],
files: [`tests/**/*.${sourceExtensions}`],
languageOptions: {
globals: {
...globals.browser,
@@ -89,33 +192,22 @@ export default [
},
},
{
files: ["tests/fixtures/architecture/forbidden/**/*.{js,jsx}"],
files: [
`tests/fixtures/architecture/forbidden/**/*.${sourceExtensions}`,
],
rules: {
"no-restricted-imports": restrictedImports([
"**/adapters/**",
"**/application/ports/out/**",
"@tanstack/**",
"react",
"react-dom",
]),
},
},
{
files: ["**/*.{js,jsx}"],
rules: {
"no-eval": "error",
"no-new-func": "error",
"no-script-url": "error",
"no-restricted-syntax": [
"no-restricted-globals": [
"error",
{
selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']",
message: "Raw HTML injection is prohibited by FE-OC-019.",
},
{
selector:
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
message: "Runtime script construction is prohibited by FE-OC-019.",
},
"fetch",
"localStorage",
"sessionStorage",
],
},
},
+16 -3
View File
@@ -5,7 +5,7 @@
"type": "module",
"packageManager": "pnpm@11.17.0",
"engines": {
"node": ">=24.0.0 <25.0.0",
"node": ">=24.11.0 <25.0.0",
"pnpm": ">=11.0.0 <12.0.0"
},
"scripts": {
@@ -15,8 +15,15 @@
"preview": "vite preview",
"lint": "eslint src scripts tests vite.config.js vitest.config.js playwright.config.js --max-warnings=0",
"check:architecture": "node scripts/check-architecture.mjs",
"check:types": "tsc --allowJs --checkJs --noEmit",
"check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test",
"check:types:app": "tsc --project tsconfig.app.json",
"check:types:node": "tsc --project tsconfig.node.json",
"check:types:test": "tsc --project tsconfig.test.json",
"check:types:fixture": "tsc --ignoreConfig --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js",
"check:types:fixture:ts-port": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-port-implementation.ts",
"check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts",
"check:types:fixture:application-output": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts",
"check:types:fixture:application-input": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts",
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
@@ -31,6 +38,7 @@
"scan:security": "node scripts/security-scan.mjs",
"check:browser-security": "node scripts/check-browser-security.mjs",
"check:registries": "node scripts/check-registries.mjs",
"check:registries:fixture": "node scripts/check-registries.mjs --governance tests/fixtures/registry/forbidden/governance.json --artifact artifacts/quality/registry-fixture.json",
"verify:compatibility": "node scripts/check-compatibility.mjs",
"verify:release": "node scripts/verify-release.mjs",
"verify:hosting-headers": "node scripts/verify-hosting-headers.mjs",
@@ -52,18 +60,23 @@
},
"devDependencies": {
"@axe-core/playwright": "4.12.1",
"@babel/core": "8.0.1",
"@babel/eslint-parser": "8.0.1",
"@babel/plugin-syntax-jsx": "8.0.1",
"@babel/plugin-syntax-typescript": "8.0.3",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.0",
"@tailwindcss/vite": "4.3.3",
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@tailwindcss/vite": "4.3.3",
"@types/node": "24.13.3",
"@types/react": "19.2.8",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.4",
"dependency-cruiser": "18.1.0",
"eslint": "10.8.0",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.7.0",
"jsdom": "29.1.1",
"msw": "2.15.0",
+495
View File
@@ -27,6 +27,18 @@ importers:
'@axe-core/playwright':
specifier: 4.12.1
version: 4.12.1(playwright-core@1.62.0)
'@babel/core':
specifier: 8.0.1
version: 8.0.1
'@babel/eslint-parser':
specifier: 8.0.1
version: 8.0.1(@babel/core@8.0.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))
'@babel/plugin-syntax-jsx':
specifier: 8.0.1
version: 8.0.1(@babel/core@8.0.1)
'@babel/plugin-syntax-typescript':
specifier: 8.0.3
version: 8.0.3(@babel/core@8.0.1)
'@eslint/js':
specifier: 10.0.1
version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))
@@ -63,6 +75,9 @@ importers:
eslint:
specifier: 10.8.0
version: 10.8.0(jiti@2.7.0)(supports-color@7.2.0)
eslint-plugin-react-hooks:
specifier: 7.1.1
version: 7.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
globals:
specifier: 17.7.0
version: 17.7.0
@@ -114,14 +129,155 @@ packages:
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
'@babel/code-frame@8.0.0':
resolution: {integrity: sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/compat-data@7.29.7':
resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
engines: {node: '>=6.9.0'}
'@babel/compat-data@8.0.0':
resolution: {integrity: sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/core@7.29.7':
resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
engines: {node: '>=6.9.0'}
'@babel/core@8.0.1':
resolution: {integrity: sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/eslint-parser@8.0.1':
resolution: {integrity: sha512-2javO8pAQv/ld6sS6OcxoLAlzZEZy+xm99bnoAfMhzKSumKhdF5wylpbZB7XTorWr3KLPtx5K95eduJPOy1mzA==}
engines: {node: ^22.18.0 || >=24.11.0}
peerDependencies:
'@babel/core': ^8.0.0
eslint: ^9.0.0 || ^10.0.0
'@babel/generator@7.29.7':
resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
engines: {node: '>=6.9.0'}
'@babel/generator@8.0.0':
resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/helper-compilation-targets@7.29.7':
resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
engines: {node: '>=6.9.0'}
'@babel/helper-compilation-targets@8.0.0':
resolution: {integrity: sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/helper-globals@7.29.7':
resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
engines: {node: '>=6.9.0'}
'@babel/helper-globals@8.0.0':
resolution: {integrity: sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/helper-module-imports@7.29.7':
resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
engines: {node: '>=6.9.0'}
'@babel/helper-module-transforms@7.29.7':
resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-plugin-utils@8.0.1':
resolution: {integrity: sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==}
engines: {node: ^22.18.0 || >=24.11.0}
peerDependencies:
'@babel/core': ^8.0.0
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@8.0.0':
resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@8.0.4':
resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/helper-validator-option@7.29.7':
resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@8.0.0':
resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/helpers@7.29.7':
resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
engines: {node: '>=6.9.0'}
'@babel/helpers@8.0.0':
resolution: {integrity: sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/parser@7.29.7':
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/parser@8.0.4':
resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==}
engines: {node: ^22.18.0 || >=24.11.0}
hasBin: true
'@babel/plugin-syntax-jsx@8.0.1':
resolution: {integrity: sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==}
engines: {node: ^22.18.0 || >=24.11.0}
peerDependencies:
'@babel/core': ^8.0.0
'@babel/plugin-syntax-typescript@8.0.3':
resolution: {integrity: sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==}
engines: {node: ^22.18.0 || >=24.11.0}
peerDependencies:
'@babel/core': ^8.0.0
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
'@babel/template@8.0.0':
resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/traverse@7.29.7':
resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
engines: {node: '>=6.9.0'}
'@babel/traverse@8.0.4':
resolution: {integrity: sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/types@7.29.7':
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@babel/types@8.0.4':
resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==}
engines: {node: ^22.18.0 || >=24.11.0}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
@@ -572,6 +728,12 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/gensync@1.0.5':
resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==}
'@types/jsesc@2.5.1':
resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -809,6 +971,11 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
baseline-browser-mapping@2.11.1:
resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==}
engines: {node: '>=6.0.0'}
hasBin: true
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -816,6 +983,14 @@ packages:
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
engines: {node: 20 || >=22}
browserslist@4.28.7:
resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
caniuse-lite@1.0.30001806:
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
@@ -902,9 +1077,16 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
electron-to-chromium@1.5.396:
resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
empathic@2.0.1:
resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==}
engines: {node: '>=14'}
enhanced-resolve@5.24.2:
resolution: {integrity: sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==}
engines: {node: '>=10.13.0'}
@@ -928,6 +1110,12 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-plugin-react-hooks@7.1.1:
resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
engines: {node: '>=18'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
eslint-scope@9.1.2:
resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -1032,6 +1220,10 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@@ -1066,6 +1258,12 @@ packages:
headers-polyfill@5.0.1:
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -1078,6 +1276,9 @@ packages:
resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
engines: {node: '>= 4'}
import-meta-resolve@4.2.0:
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
@@ -1131,6 +1332,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1143,6 +1347,11 @@ packages:
canvas:
optional: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
hasBin: true
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -1324,6 +1533,9 @@ packages:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -1370,6 +1582,10 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-releases@2.0.51:
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
engines: {node: '>=18'}
obug@2.1.4:
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
engines: {node: '>=12.20.0'}
@@ -1518,6 +1734,10 @@ packages:
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
@@ -1666,6 +1886,12 @@ packages:
until-async@3.0.2:
resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -1803,6 +2029,9 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@@ -1815,6 +2044,12 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
zod: ^3.25.0 || ^4.0.0
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
@@ -1853,10 +2088,205 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/code-frame@8.0.0':
dependencies:
'@babel/helper-validator-identifier': 8.0.4
js-tokens: 10.0.0
'@babel/compat-data@7.29.7': {}
'@babel/compat-data@8.0.0': {}
'@babel/core@7.29.7(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-compilation-targets': 7.29.7
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/traverse': 7.29.7(supports-color@7.2.0)
'@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@7.2.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/core@8.0.1':
dependencies:
'@babel/code-frame': 8.0.0
'@babel/generator': 8.0.0
'@babel/helper-compilation-targets': 8.0.0
'@babel/helpers': 8.0.0
'@babel/parser': 8.0.4
'@babel/template': 8.0.0
'@babel/traverse': 8.0.4
'@babel/types': 8.0.4
'@types/gensync': 1.0.5
convert-source-map: 2.0.0
empathic: 2.0.1
gensync: 1.0.0-beta.2
import-meta-resolve: 4.2.0
json5: 2.2.3
obug: 2.1.4
semver: 7.8.5
'@babel/eslint-parser@8.0.1(@babel/core@8.0.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))':
dependencies:
'@babel/core': 8.0.1
eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0)
eslint-scope: 9.1.2
eslint-visitor-keys: 5.0.1
semver: 7.8.5
'@babel/generator@7.29.7':
dependencies:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/generator@8.0.0':
dependencies:
'@babel/parser': 8.0.4
'@babel/types': 8.0.4
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@types/jsesc': 2.5.1
jsesc: 3.1.0
'@babel/helper-compilation-targets@7.29.7':
dependencies:
'@babel/compat-data': 7.29.7
'@babel/helper-validator-option': 7.29.7
browserslist: 4.28.7
lru-cache: 5.1.1
semver: 6.3.1
'@babel/helper-compilation-targets@8.0.0':
dependencies:
'@babel/compat-data': 8.0.0
'@babel/helper-validator-option': 8.0.0
browserslist: 4.28.7
lru-cache: 11.5.2
semver: 7.8.5
'@babel/helper-globals@7.29.7': {}
'@babel/helper-globals@8.0.0': {}
'@babel/helper-module-imports@7.29.7(supports-color@7.2.0)':
dependencies:
'@babel/traverse': 7.29.7(supports-color@7.2.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.7(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
'@babel/helper-plugin-utils@8.0.1(@babel/core@8.0.1)':
dependencies:
'@babel/core': 8.0.1
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-string-parser@8.0.0': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/helper-validator-identifier@8.0.4': {}
'@babel/helper-validator-option@7.29.7': {}
'@babel/helper-validator-option@8.0.0': {}
'@babel/helpers@7.29.7':
dependencies:
'@babel/template': 7.29.7
'@babel/types': 7.29.7
'@babel/helpers@8.0.0':
dependencies:
'@babel/template': 8.0.0
'@babel/types': 8.0.4
'@babel/parser@7.29.7':
dependencies:
'@babel/types': 7.29.7
'@babel/parser@8.0.4':
dependencies:
'@babel/types': 8.0.4
'@babel/plugin-syntax-jsx@8.0.1(@babel/core@8.0.1)':
dependencies:
'@babel/core': 8.0.1
'@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1)
'@babel/plugin-syntax-typescript@8.0.3(@babel/core@8.0.1)':
dependencies:
'@babel/core': 8.0.1
'@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1)
'@babel/runtime@7.29.7': {}
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@babel/template@8.0.0':
dependencies:
'@babel/code-frame': 8.0.0
'@babel/parser': 8.0.4
'@babel/types': 8.0.4
'@babel/traverse@7.29.7(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/types': 7.29.7
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
'@babel/traverse@8.0.4':
dependencies:
'@babel/code-frame': 8.0.0
'@babel/generator': 8.0.0
'@babel/helper-globals': 8.0.0
'@babel/parser': 8.0.4
'@babel/template': 8.0.0
'@babel/types': 8.0.4
obug: 2.1.4
'@babel/types@7.29.7':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@babel/types@8.0.4':
dependencies:
'@babel/helper-string-parser': 8.0.0
'@babel/helper-validator-identifier': 8.0.4
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
@@ -2213,6 +2643,10 @@ snapshots:
'@types/estree@1.0.9': {}
'@types/gensync@1.0.5': {}
'@types/jsesc@2.5.1': {}
'@types/json-schema@7.0.15': {}
'@types/node@24.13.3':
@@ -2383,6 +2817,8 @@ snapshots:
balanced-match@4.0.4: {}
baseline-browser-mapping@2.11.1: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -2391,6 +2827,16 @@ snapshots:
dependencies:
balanced-match: 4.0.4
browserslist@4.28.7:
dependencies:
baseline-browser-mapping: 2.11.1
caniuse-lite: 1.0.30001806
electron-to-chromium: 1.5.396
node-releases: 2.0.51
update-browserslist-db: 1.2.3(browserslist@4.28.7)
caniuse-lite@1.0.30001806: {}
chai@6.2.2: {}
chalk@4.1.2:
@@ -2479,8 +2925,12 @@ snapshots:
dom-accessibility-api@0.6.3: {}
electron-to-chromium@1.5.396: {}
emoji-regex@8.0.0: {}
empathic@2.0.1: {}
enhanced-resolve@5.24.2:
dependencies:
graceful-fs: 4.2.11
@@ -2496,6 +2946,17 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-plugin-react-hooks@7.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/parser': 7.29.7
eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0)
hermes-parser: 0.25.1
zod: 4.4.3
zod-validation-error: 4.0.2(zod@4.4.3)
transitivePeerDependencies:
- supports-color
eslint-scope@9.1.2:
dependencies:
'@types/esrecurse': 4.3.1
@@ -2612,6 +3073,8 @@ snapshots:
function-bind@1.1.2: {}
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
glob-parent@6.0.2:
@@ -2639,6 +3102,12 @@ snapshots:
'@types/set-cookie-parser': 2.4.10
set-cookie-parser: 3.1.2
hermes-estree@0.25.1: {}
hermes-parser@0.25.1:
dependencies:
hermes-estree: 0.25.1
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.15.1
@@ -2649,6 +3118,8 @@ snapshots:
ignore@7.0.6: {}
import-meta-resolve@4.2.0: {}
imurmurhash@0.1.4: {}
indent-string@4.0.0: {}
@@ -2684,6 +3155,8 @@ snapshots:
jiti@2.7.0: {}
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
jsdom@29.1.1:
@@ -2712,6 +3185,8 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {}
@@ -2835,6 +3310,10 @@ snapshots:
lru-cache@11.5.2: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
lz-string@1.5.0: {}
magic-string@0.30.21:
@@ -2884,6 +3363,8 @@ snapshots:
natural-compare@1.4.0: {}
node-releases@2.0.51: {}
obug@2.1.4: {}
optionator@0.9.4:
@@ -3030,6 +3511,8 @@ snapshots:
scheduler@0.27.0: {}
semver@6.3.1: {}
semver@7.8.5: {}
set-cookie-parser@2.7.2: {}
@@ -3166,6 +3649,12 @@ snapshots:
until-async@3.0.2: {}
update-browserslist-db@1.2.3(browserslist@4.28.7):
dependencies:
browserslist: 4.28.7
escalade: 3.2.0
picocolors: 1.1.1
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
@@ -3251,6 +3740,8 @@ snapshots:
y18n@5.0.8: {}
yallist@3.1.1: {}
yargs-parser@21.1.1: {}
yargs@17.7.3:
@@ -3265,4 +3756,8 @@ snapshots:
yocto-queue@0.1.0: {}
zod-validation-error@4.0.2(zod@4.4.3):
dependencies:
zod: 4.4.3
zod@4.4.3: {}
+44 -3
View File
@@ -1,4 +1,4 @@
import { mkdir, writeFile } from "node:fs/promises";
import { mkdir, readdir, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
await mkdir("artifacts/quality", { recursive: true });
@@ -60,10 +60,51 @@ const forbidden = runPnpm(
],
);
if (allowed.status !== 0 || forbidden.status === 0) {
/** @param {string} directory @returns {Promise<string[]>} */
async function fixtureFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map((entry) => {
const target = `${directory}/${entry.name}`;
return entry.isDirectory()
? fixtureFiles(target)
: /\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)
? [target]
: [];
}),
);
return files.flat();
}
const forbiddenResults = await Promise.all(
(await fixtureFiles("tests/fixtures/architecture/forbidden")).map((file) => ({
file,
result: runPnpm([
"exec",
"eslint",
file,
"--no-ignore",
"--max-warnings=0",
]),
})),
);
const acceptedForbidden = forbiddenResults.filter(
({ result }) => result.status === 0,
);
if (
allowed.status !== 0 ||
forbidden.status === 0 ||
acceptedForbidden.length > 0
) {
process.stderr.write(allowed.stderr || allowed.stdout);
process.stderr.write(forbidden.stderr || forbidden.stdout);
for (const { file } of acceptedForbidden) {
process.stderr.write(`Forbidden fixture was accepted: ${file}\n`);
}
process.exit(1);
}
process.stdout.write("Architecture fixtures: allowed PASS, forbidden rejected\n");
process.stdout.write(
`Architecture fixtures: allowed PASS, ${forbiddenResults.length} forbidden rejected\n`,
);
+96 -7
View File
@@ -2,12 +2,53 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
/** @param {string} name @param {string} fallback */
function argumentValue(name, fallback) {
const index = process.argv.indexOf(name);
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
}
const governancePath = argumentValue(
"--governance",
"config/contracts/registry-governance.json",
);
const artifactPath = argumentValue(
"--artifact",
"artifacts/quality/registries.json",
);
const governance = JSON.parse(
await readFile("config/contracts/registry-governance.json", "utf8"),
await readFile(governancePath, "utf8"),
);
const failures = [];
const owners = new Map();
const snapshots = [];
const rowsByRegistry = new Map();
const registryExtensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".mts"];
/** @param {string} declaredPath */
async function resolveRegistrySource(declaredPath) {
const extension = path.extname(declaredPath);
const basePath = extension
? declaredPath.slice(0, -extension.length)
: declaredPath;
const candidates = [];
for (const candidateExtension of registryExtensions) {
const candidate = `${basePath}${candidateExtension}`;
try {
await access(candidate);
candidates.push(candidate);
} catch {
// A migration may legitimately replace the declared extension.
}
}
if (candidates.length > 1) {
failures.push(
`ambiguous registry source ${declaredPath}: ${candidates.join(", ")}`,
);
return null;
}
return candidates[0] ?? null;
}
for (const specification of governance.registries) {
if (owners.has(specification.registryId)) {
@@ -16,10 +57,11 @@ for (const specification of governance.registries) {
owners.set(specification.registryId, specification.owner);
let rows = specification.declaredRows;
const sourcePath = await resolveRegistrySource(specification.path);
try {
await access(specification.path);
if (!sourcePath) throw new Error("missing registry source");
const module = await import(
`${pathToFileURL(path.resolve(specification.path)).href}?registry-check=${Date.now()}`
`${pathToFileURL(path.resolve(sourcePath)).href}?registry-check=${Date.now()}`
);
rows = module[specification.exportName];
} catch {
@@ -31,6 +73,8 @@ for (const specification of governance.registries) {
continue;
}
rowsByRegistry.set(specification.registryId, rows);
for (const [rowName, row] of Object.entries(rows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) {
failures.push(`${specification.registryId}.${rowName} is not an object`);
@@ -43,16 +87,61 @@ for (const specification of governance.registries) {
}
}
for (const field of specification.uniqueFields ?? []) {
const values = new Map();
for (const [rowName, row] of Object.entries(rows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const value = row[field];
if (value === undefined) continue;
if (values.has(value)) {
failures.push(
`${specification.registryId}.${rowName} duplicates ${field}=${String(value)} from ${values.get(value)}`,
);
} else {
values.set(value, rowName);
}
}
}
snapshots.push({
registryId: specification.registryId,
owner: specification.owner,
source: specification.path,
source: sourcePath ?? specification.path,
rowCount: Object.keys(rows).length,
rows,
});
}
const sourceFiles = [
for (const specification of governance.registries) {
const rows = rowsByRegistry.get(specification.registryId);
if (!rows) continue;
for (const reference of specification.references ?? []) {
const targetRows = rowsByRegistry.get(reference.registryId);
if (!targetRows) {
failures.push(
`${specification.registryId} references unknown registry ${reference.registryId}`,
);
continue;
}
const targetValues = new Set(
Object.values(targetRows)
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
.map((row) => row[reference.targetField])
.filter((value) => value !== undefined),
);
for (const [rowName, row] of Object.entries(rows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const value = row[reference.field];
if (value !== undefined && !targetValues.has(value)) {
failures.push(
`${specification.registryId}.${rowName}.${reference.field} references unknown ${reference.registryId}.${reference.targetField}=${String(value)}`,
);
}
}
}
}
const sourceFiles = governance.sourceDirectories ?? [
"src/application",
"src/presentation",
"src/domain",
@@ -75,7 +164,7 @@ async function scanDirectory(directory) {
await scanDirectory(target);
continue;
}
if (!/\.(js|jsx|mjs)$/.test(entry.name)) continue;
if (!/\.(js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)) continue;
const content = await readFile(target, "utf8");
for (const pattern of adHocPatterns) {
if (pattern.expression.test(content)) {
@@ -91,7 +180,7 @@ for (const sourceDirectory of sourceFiles) {
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/registries.json",
artifactPath,
`${JSON.stringify(
{
schemaVersion: 1,
+3 -16
View File
@@ -1,4 +1,4 @@
import { systemClock } from "../../application/ports/clock-port.js";
import { systemClock } from "../platform/system-clock.js";
import { getApiOperation } from "../../contracts/api-operations.js";
import {
createFailure as failure,
@@ -21,21 +21,7 @@ const noAuthSession =
onUnauthenticated: () => {},
});
/**
* @typedef {{
* kind: string,
* code: string,
* retryable: boolean,
* operationId: string,
* attemptCount: number,
* httpStatus?: number,
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
* userMessageKey: string,
* action: string
* }} HttpFailure
*/
/** @typedef {import("../../contracts/errors.js").ApiFailure} HttpFailure */
/**
* @typedef {{ ok: true, value: unknown, meta: Record<string, string> } |
@@ -74,6 +60,7 @@ export function createHttpClient(dependencies) {
* signal?: AbortSignal,
* idempotencyKey?: string
* }} [input]
* @returns {Promise<HttpResult>}
*/
async function execute(operationId, input = {}) {
const operation = getApiOperation(operationId);
+19
View File
@@ -0,0 +1,19 @@
/** @type {import("../../application/ports/clock-port.js").ClockPort} */
export const systemClock = Object.freeze({
now: () => Date.now(),
sleep(milliseconds, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(resolve, milliseconds);
const onAbort = () => {
clearTimeout(timer);
reject(signal?.reason);
};
signal?.addEventListener("abort", onAbort, { once: true });
});
},
});
-46
View File
@@ -1,46 +0,0 @@
/**
* Application facade factory. Concrete dependencies are supplied by bootstrap.
*
* @param {{
* resources?: {
* query: import("./ports/resource-ports.js").ResourceQueryPort<unknown, unknown>,
* command: import("./ports/resource-ports.js").ResourceCommandPort<unknown, unknown>
* },
* cache: import("./ports/query-cache-port.js").QueryCachePort,
* storage: import("./ports/storage-port.js").StoragePort,
* telemetry: import("./ports/telemetry-port.js").TelemetryPort
* }} ports
*/
export function createApplication(ports) {
/**
* @param {unknown} query
* @param {import("./ports/resource-ports.js").RequestContext} [context]
*/
function queryResources(query, context) {
return /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).query.execute(query, context);
}
/**
* @param {unknown} command
* @param {import("./ports/resource-ports.js").RequestContext} [context]
*/
function commandResources(command, context) {
return /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).command.execute(command, context);
}
return Object.freeze({
resources: ports.resources
? Object.freeze({
query: queryResources,
command: commandResources,
})
: null,
cache: ports.cache,
storage: ports.storage,
telemetry: ports.telemetry,
});
}
+73
View File
@@ -0,0 +1,73 @@
import { normalizeColorSchemePreference } from "./policies/color-scheme.js";
import type {
ApplicationApi,
ColorSchemePreference,
RenderFailureReport,
} from "./ports/in/application-api.js";
import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js";
export type { ApplicationApi, ApplicationOutputPorts };
/**
* Builds the driving API consumed by inbound adapters. Concrete output ports
* remain inside these closures and are never returned to React.
*/
export function createApplication(
outputPorts: ApplicationOutputPorts,
): ApplicationApi {
const session = Object.freeze({
getSnapshot: () => outputPorts.session.getState(),
subscribe: (listener: () => void) =>
outputPorts.session.subscribe(listener),
beginSignIn: (returnTo?: string) =>
outputPorts.session.beginSignIn(returnTo),
signOut: () => outputPorts.session.signOut(),
recover: () => outputPorts.session.recover(),
});
const preferences = Object.freeze({
getColorScheme(): ColorSchemePreference {
const result = outputPorts.preferences.read("COLOR_SCHEME");
return normalizeColorSchemePreference(
result.ok ? result.value : undefined,
);
},
setColorScheme(preference: ColorSchemePreference) {
const normalized = normalizeColorSchemePreference(preference);
return outputPorts.preferences.write("COLOR_SCHEME", normalized);
},
});
const diagnostics = Object.freeze({
reportRenderFailure(report: RenderFailureReport) {
try {
outputPorts.diagnostics.emit("ui.render.failed", {
route_id: report.routeId,
build_id: report.buildId,
component_boundary: report.boundaryName,
});
} catch {
// Diagnostics are best-effort and cannot become an application failure.
}
},
});
const runtime = Object.freeze({
async getReleaseSummary() {
const release = await outputPorts.releaseInfo.getCurrent();
return Object.freeze({
buildId: release.buildId,
releaseId: release.releaseId,
configSchemaVersion: release.configSchemaVersion,
apiContractVersion: release.apiContractVersion,
});
},
});
return Object.freeze({
session,
preferences,
diagnostics,
runtime,
});
}
+16 -2
View File
@@ -10,10 +10,24 @@
* subscribe(listener: () => void): () => void,
* beginSignIn(returnTo?: string): Promise<void>,
* signOut(): Promise<void>,
* recover(): Promise<"restored" | "no-session">
* }} SessionGateway
*/
/**
* Credential attachment is an HTTP-adapter collaboration, not an application
* input capability.
*
* @typedef {{
* attach(request: Request): Promise<Request>,
* recover(): Promise<"restored" | "no-session">,
* onUnauthenticated(): void
* }} AuthSessionPort
* }} CredentialAttacher
*/
/**
* External auth adapters implement both segregated capabilities.
*
* @typedef {SessionGateway & CredentialAttacher} AuthSessionPort
*/
export {};
+1 -22
View File
@@ -5,25 +5,4 @@
* }} ClockPort
*/
/** @type {ClockPort} */
export const systemClock = Object.freeze({
now: () => Date.now(),
sleep(milliseconds, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(resolve, milliseconds);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true },
);
});
},
});
export {};
@@ -0,0 +1,41 @@
import type { SessionState } from "../auth-session-port.js";
import type { StoragePort } from "../storage-port.js";
export type { SessionState } from "../auth-session-port.js";
export type ColorSchemePreference = "system" | "light" | "dark";
export type RenderFailureReport = Readonly<{
routeId: string;
buildId: string;
boundaryName: "route" | "feature";
}>;
export type ReleaseSummary = Readonly<{
buildId: string;
releaseId: string;
configSchemaVersion: string;
apiContractVersion: string;
}>;
export type ApplicationApi = Readonly<{
session: Readonly<{
getSnapshot(): SessionState;
subscribe(listener: () => void): () => void;
beginSignIn(returnTo?: string): Promise<void>;
signOut(): Promise<void>;
recover(): Promise<"restored" | "no-session">;
}>;
preferences: Readonly<{
getColorScheme(): ColorSchemePreference;
setColorScheme(
preference: ColorSchemePreference,
): ReturnType<StoragePort["write"]>;
}>;
diagnostics: Readonly<{
reportRenderFailure(report: RenderFailureReport): void;
}>;
runtime: Readonly<{
getReleaseSummary(): Promise<ReleaseSummary>;
}>;
}>;
+7
View File
@@ -0,0 +1,7 @@
export type {
ApplicationApi,
ColorSchemePreference,
ReleaseSummary,
RenderFailureReport,
SessionState,
} from "./application-api.js";
@@ -0,0 +1,18 @@
import type { AuthSessionPort } from "../auth-session-port.js";
import type { ReleaseInfoPort } from "../release-info-port.js";
import type { StoragePort } from "../storage-port.js";
import type { TelemetryPort } from "../telemetry-port.js";
/**
* Capabilities required by application use cases. Implementations live in
* outbound adapters and are selected only by bootstrap.
*/
export type ApplicationOutputPorts = Readonly<{
session: Pick<
AuthSessionPort,
"getState" | "subscribe" | "beginSignIn" | "signOut" | "recover"
>;
preferences: StoragePort;
diagnostics: TelemetryPort;
releaseInfo: ReleaseInfoPort;
}>;
+17
View File
@@ -0,0 +1,17 @@
export type { ApplicationOutputPorts } from "./application-output-ports.js";
export type {
AuthSessionPort,
CredentialAttacher,
SessionGateway,
} from "../auth-session-port.js";
export type { ClockPort } from "../clock-port.js";
export type { QueryCachePort } from "../query-cache-port.js";
export type { ReleaseInfoPort } from "../release-info-port.js";
export type {
RequestContext,
ResourceCommandPort,
ResourceQueryPort,
Result,
} from "../resource-ports.js";
export type { StoragePort } from "../storage-port.js";
export type { TelemetryPort } from "../telemetry-port.js";
+5 -1
View File
@@ -1,11 +1,15 @@
/**
* @typedef {{
* getCurrent(): Promise<{
* schemaVersion?: number,
* appVersion?: string,
* buildId: string,
* commitSha?: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string
* releaseId: string,
* builtAt?: string
* }>
* }} ReleaseInfoPort
*/
+15 -6
View File
@@ -6,27 +6,36 @@ import { createApplication } from "../application/create-application.js";
*
* @template Config
* @template Release
* @template {Parameters<typeof createApplication>[0]} Ports
* @template {Parameters<typeof createApplication>[0]} OutputPorts
* @template Infrastructure
* @param {{
* loadConfig(): Promise<Config>,
* loadRelease(config: Config): Promise<Release>,
* createAdapters(context: {
* config: Config,
* release: Release
* }): Promise<Ports>
* }): Promise<{
* outputPorts: OutputPorts,
* infrastructure: Infrastructure
* }>
* }} factories
* @returns {Promise<Readonly<{
* config: Config,
* release: Release,
* ports: Ports,
* infrastructure: Infrastructure,
* application: ReturnType<typeof createApplication>
* }>>}
*/
export async function createCompositionRoot(factories) {
const config = await factories.loadConfig();
const release = await factories.loadRelease(config);
const ports = await factories.createAdapters({ config, release });
const application = createApplication(ports);
const adapters = await factories.createAdapters({ config, release });
const application = createApplication(adapters.outputPorts);
return Object.freeze({ config, release, ports, application });
return Object.freeze({
config,
release,
infrastructure: adapters.infrastructure,
application,
});
}
+3 -4
View File
@@ -6,22 +6,21 @@ import {
/**
* Applies the persisted public preference before React paints.
*
* @param {import("../application/ports/storage-port.js").StoragePort} storage
* @param {Pick<import("../application/ports/in/application-api.js").ApplicationApi["preferences"], "getColorScheme">} preferences
* @param {{
* documentElement?: HTMLElement,
* matchMedia?: (query: string) => MediaQueryList
* }} [browser]
*/
export function initializeColorScheme(storage, browser = {}) {
export function initializeColorScheme(preferences, browser = {}) {
const documentElement = browser.documentElement ?? document.documentElement;
const matchMedia =
browser.matchMedia ??
(typeof window.matchMedia === "function"
? window.matchMedia.bind(window)
: () => /** @type {MediaQueryList} */ ({ matches: false }));
const stored = storage.read("COLOR_SCHEME");
const preference = normalizeColorSchemePreference(
stored.ok ? stored.value : undefined,
preferences.getColorScheme(),
);
const resolved = resolveColorScheme(
preference,
+3 -17
View File
@@ -1,13 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx";
import { AppRouter } from "../presentation/routes/app-router.jsx";
import { createRuntimeComposition } from "./create-runtime-composition.js";
import { initializeColorScheme } from "./initialize-color-scheme.js";
import { BootConfigError } from "./load-runtime-config.js";
import { ReleaseManifestError } from "./load-release-manifest.js";
import { RuntimeApplication } from "./runtime-application.jsx";
import "../presentation/styles/theme.css";
const rootElement = document.getElementById("root");
@@ -21,20 +19,8 @@ const root = createRoot(rootElement);
async function boot() {
try {
const composition = await createRuntimeComposition();
initializeColorScheme(composition.ports.storage);
root.render(
<StrictMode>
<QueryClientProvider client={composition.ports.queryClient}>
<AppRouter
authSession={composition.ports.authSession}
basename={composition.config.build.routerBasePath}
buildId={composition.release.buildId}
storage={composition.ports.storage}
telemetry={composition.ports.telemetry}
/>
</QueryClientProvider>
</StrictMode>,
);
initializeColorScheme(composition.application.preferences);
root.render(<RuntimeApplication composition={composition} />);
} catch (error) {
const safe =
error instanceof BootConfigError || error instanceof ReleaseManifestError
+9 -15
View File
@@ -3,9 +3,7 @@ import {
createExternalAuthSessionAdapter,
createUnavailableSessionAdapter,
} from "../adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../adapters/http/client.js";
import {
createQueryCacheAdapter,
createQueryClient,
} from "../adapters/query-cache/tanstack-query-cache.js";
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js";
@@ -61,7 +59,6 @@ export async function createRuntimeAdapters(context) {
? createExternalAuthSessionAdapter(externalOwner)
: createUnavailableSessionAdapter();
const queryClient = createQueryClient();
const cache = createQueryCacheAdapter(queryClient);
const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(host.localStorage),
sessionStorage: storageOrUndefined(host.sessionStorage),
@@ -71,11 +68,6 @@ export async function createRuntimeAdapters(context) {
endpoint: config.TELEMETRY_ENDPOINT,
fetcher: context.fetcher,
});
const http = createHttpClient({
baseUrl: config.API_BASE_URL,
authSession,
fetcher: context.fetcher,
});
const releaseInfo = Object.freeze({
async getCurrent() {
return structuredClone(context.release);
@@ -83,12 +75,14 @@ export async function createRuntimeAdapters(context) {
});
return Object.freeze({
authSession,
cache,
http,
queryClient,
releaseInfo,
storage,
telemetry,
outputPorts: Object.freeze({
session: authSession,
preferences: storage,
diagnostics: telemetry,
releaseInfo,
}),
infrastructure: Object.freeze({
queryClient,
}),
});
}
+28
View File
@@ -0,0 +1,28 @@
import { StrictMode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ApplicationProvider } from "../presentation/providers/application-provider.js";
import { AppRouter } from "../presentation/routes/app-router.jsx";
/**
* Production provider tree. Tests import this component so the validated
* composition is proven against the same provider order used by main.
*
* @param {{
* composition: Awaited<ReturnType<typeof import("./create-runtime-composition.js").createRuntimeComposition>>
* }} props
*/
export function RuntimeApplication({ composition }) {
return (
<StrictMode>
<QueryClientProvider client={composition.infrastructure.queryClient}>
<ApplicationProvider application={composition.application}>
<AppRouter
basename={composition.config.build.routerBasePath}
buildId={composition.release.buildId}
/>
</ApplicationProvider>
</QueryClientProvider>
</StrictMode>
);
}
@@ -6,7 +6,7 @@ import { Component } from "react";
* boundaryName: string,
* routeId: string,
* buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* onRenderFailure?: (report: import("../../application/ports/in/application-api.js").RenderFailureReport) => void,
* fallback?: React.ReactNode
* }} RenderBoundaryProps
* @typedef {{ hasError: boolean }} RenderBoundaryState
@@ -26,13 +26,14 @@ export class RenderErrorBoundary extends Component {
componentDidCatch() {
try {
this.props.telemetry?.emit("ui.render.failed", {
route_id: this.props.routeId,
build_id: this.props.buildId,
component_boundary: this.props.boundaryName,
this.props.onRenderFailure?.({
routeId: this.props.routeId,
buildId: this.props.buildId,
boundaryName:
/** @type {"route" | "feature"} */ (this.props.boundaryName),
});
} catch {
// Telemetry must never recurse into another render failure.
// Diagnostics must never recurse into another render failure.
}
}
+24
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { PageHeader } from "../components/page-header.jsx";
import { useApplication } from "../providers/application-provider.js";
const READINESS_ITEMS = Object.freeze([
{
@@ -19,6 +21,23 @@ const READINESS_ITEMS = Object.freeze([
]);
export default function HomePage() {
const { runtime } = useApplication();
const [release, setRelease] = useState(
/** @type {Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null} */ (
null
),
);
useEffect(() => {
let active = true;
void runtime.getReleaseSummary().then((summary) => {
if (active) setRelease(summary);
});
return () => {
active = false;
};
}, [runtime]);
return (
<section className="ui-page">
<PageHeader
@@ -34,6 +53,11 @@ export default function HomePage() {
</article>
))}
</div>
<p className="ui-runtime-summary" aria-live="polite">
{release
? `빌드 ${release.buildId} · 릴리스 ${release.releaseId}`
: "검증된 런타임 정보를 확인하고 있습니다."}
</p>
<section className="ui-panel starter-actions" aria-labelledby="starter-title">
<div>
<h2 id="starter-title">준비된 화면 살펴보기</h2>
@@ -0,0 +1,31 @@
import {
createContext,
type ReactNode,
useContext,
} from "react";
import type { ApplicationApi } from "../../application/create-application.js";
const ApplicationContext = createContext<ApplicationApi | null>(null);
export function ApplicationProvider({
application,
children,
}: Readonly<{
application: ApplicationApi;
children: ReactNode;
}>) {
return (
<ApplicationContext.Provider value={application}>
{children}
</ApplicationContext.Provider>
);
}
export function useApplication(): ApplicationApi {
const application = useContext(ApplicationContext);
if (!application) {
throw new Error("ApplicationProvider is required");
}
return application;
}
+15 -16
View File
@@ -1,11 +1,12 @@
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import { useApplication } from "./application-provider.js";
/**
* @typedef {{
* sessionState: import("../../application/ports/auth-session-port.js").SessionState,
* beginSignIn: import("../../application/ports/auth-session-port.js").AuthSessionPort["beginSignIn"],
* signOut: import("../../application/ports/auth-session-port.js").AuthSessionPort["signOut"],
* recover: import("../../application/ports/auth-session-port.js").AuthSessionPort["recover"]
* sessionState: import("../../application/ports/in/application-api.js").SessionState,
* beginSignIn: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["beginSignIn"],
* signOut: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["signOut"],
* recover: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["recover"]
* }} SessionContextValue
*/
@@ -14,26 +15,24 @@ const SessionContext = createContext(
);
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* children: React.ReactNode
* }} props
* @param {{ children: React.ReactNode }} props
*/
export function SessionProvider({ authSession, children }) {
export function SessionProvider({ children }) {
const { session } = useApplication();
const sessionState = useSyncExternalStore(
authSession.subscribe,
authSession.getState,
authSession.getState,
session.subscribe,
session.getSnapshot,
session.getSnapshot,
);
const value = useMemo(
() =>
Object.freeze({
sessionState,
beginSignIn: authSession.beginSignIn,
signOut: authSession.signOut,
recover: authSession.recover,
beginSignIn: session.beginSignIn,
signOut: session.signOut,
recover: session.recover,
}),
[authSession, sessionState],
[session, sessionState],
);
return (
+9 -13
View File
@@ -11,6 +11,7 @@ import {
normalizeColorSchemePreference,
resolveColorScheme,
} from "../../application/policies/color-scheme.js";
import { useApplication } from "./application-provider.js";
/**
* @typedef {{
@@ -30,18 +31,13 @@ function systemPrefersDark() {
}
/**
* @param {{
* storage?: import("../../application/ports/storage-port.js").StoragePort,
* children: React.ReactNode
* }} props
* @param {{ children: React.ReactNode }} props
*/
export function ThemeProvider({ storage, children }) {
const [preference, updatePreference] = useState(() => {
const result = storage?.read("COLOR_SCHEME");
return normalizeColorSchemePreference(
result?.ok ? result.value : undefined,
);
});
export function ThemeProvider({ children }) {
const { preferences } = useApplication();
const [preference, updatePreference] = useState(
preferences.getColorScheme,
);
const [darkSystemTheme, setDarkSystemTheme] = useState(systemPrefersDark);
const resolvedTheme = resolveColorScheme(preference, darkSystemTheme);
@@ -70,10 +66,10 @@ export function ThemeProvider({ storage, children }) {
setPreference(next) {
const normalized = normalizeColorSchemePreference(next);
updatePreference(normalized);
storage?.write("COLOR_SCHEME", normalized);
preferences.setColorScheme(normalized);
},
}),
[preference, resolvedTheme, storage],
[preference, preferences, resolvedTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
+9 -21
View File
@@ -10,6 +10,7 @@ import { getRoute, routePath } from "../../contracts/routes.js";
import { RouteBoundary } from "../boundaries/render-error-boundary.jsx";
import { AppShell } from "../layouts/app-shell.jsx";
import { PageHeader } from "../components/page-header.jsx";
import { useApplication } from "../providers/application-provider.js";
import { SessionProvider, useSession } from "../providers/session-provider.jsx";
import { ThemeProvider } from "../providers/theme-provider.jsx";
import { decideRouteAccess } from "./navigation-policy.js";
@@ -54,16 +55,16 @@ function RouteFailureSurface() {
* @param {{
* routeId: string,
* buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* children: React.ReactNode
* }} props
*/
function RouteSurface({ routeId, buildId, telemetry, children }) {
function RouteSurface({ routeId, buildId, children }) {
const { diagnostics } = useApplication();
return (
<RouteBoundary
routeId={routeId}
buildId={buildId}
telemetry={telemetry}
onRenderFailure={diagnostics.reportRenderFailure}
fallback={<RouteFailureSurface />}
>
<Suspense fallback={<RouteLoadingSurface routeId={routeId} />}>
@@ -155,13 +156,12 @@ function ProtectedRoute({ routeId, children }) {
* @param {{
* routeId: string,
* buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* children: React.ReactNode
* }} props
*/
function PublicRoute({ routeId, buildId, telemetry, children }) {
function PublicRoute({ routeId, buildId, children }) {
return (
<RouteSurface routeId={routeId} buildId={buildId} telemetry={telemetry}>
<RouteSurface routeId={routeId} buildId={buildId}>
{children}
</RouteSurface>
);
@@ -169,24 +169,18 @@ function PublicRoute({ routeId, buildId, telemetry, children }) {
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* basename?: string,
* buildId?: string,
* storage?: import("../../application/ports/storage-port.js").StoragePort,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort
* buildId?: string
* }} props
*/
export function AppRouter({
authSession,
basename = "/",
buildId = "local-build",
storage,
telemetry,
}) {
return (
<BrowserRouter basename={basename}>
<ThemeProvider storage={storage}>
<SessionProvider authSession={authSession}>
<ThemeProvider>
<SessionProvider>
<Routes>
<Route element={<AppShell />}>
<Route
@@ -195,7 +189,6 @@ export function AppRouter({
<PublicRoute
routeId="APP_HOME"
buildId={buildId}
telemetry={telemetry}
>
<HomePage />
</PublicRoute>
@@ -207,7 +200,6 @@ export function AppRouter({
<PublicRoute
routeId="EXAMPLES_UI"
buildId={buildId}
telemetry={telemetry}
>
<UiGalleryPage />
</PublicRoute>
@@ -219,7 +211,6 @@ export function AppRouter({
<PublicRoute
routeId="EXAMPLES_STATES"
buildId={buildId}
telemetry={telemetry}
>
<StateGalleryPage />
</PublicRoute>
@@ -231,7 +222,6 @@ export function AppRouter({
<PublicRoute
routeId="EXAMPLES_AUTH"
buildId={buildId}
telemetry={telemetry}
>
<AuthExamplePage />
</PublicRoute>
@@ -243,7 +233,6 @@ export function AppRouter({
<RouteSurface
routeId="SAMPLE_RESOURCE_LIST"
buildId={buildId}
telemetry={telemetry}
>
<ProtectedRoute routeId="SAMPLE_RESOURCE_LIST">
<SampleContractPage />
@@ -257,7 +246,6 @@ export function AppRouter({
<PublicRoute
routeId="NOT_FOUND"
buildId={buildId}
telemetry={telemetry}
>
<NotFoundPage />
</PublicRoute>
+1 -1
View File
@@ -2,7 +2,7 @@ import { getRoute } from "../../contracts/routes.js";
/**
* @param {string} routeId
* @param {import("../../application/ports/auth-session-port.js").SessionState} sessionState
* @param {import("../../application/ports/in/application-api.js").SessionState} sessionState
*/
export function decideRouteAccess(routeId, sessionState) {
const route = getRoute(routeId);
+7 -6
View File
@@ -10,28 +10,29 @@ import { AsyncSurface } from "../../src/presentation/components/async-surface.js
import { BootErrorShell } from "../../src/presentation/boundaries/boot-error-shell.jsx";
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx";
/** @returns {import("react").ReactNode} */
function Defect() {
throw new Error("raw render stack");
}
describe("render recovery boundaries", () => {
it("catches programmer defects and emits best-effort safe telemetry", () => {
const telemetry = { emit: vi.fn() };
const onRenderFailure = vi.fn();
render(
<FeatureBoundary
routeId="APP_HOME"
buildId="build-a"
telemetry={telemetry}
onRenderFailure={onRenderFailure}
>
<Defect />
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure");
expect(telemetry.emit).toHaveBeenCalledWith("ui.render.failed", {
route_id: "APP_HOME",
build_id: "build-a",
component_boundary: "feature",
expect(onRenderFailure).toHaveBeenCalledWith({
routeId: "APP_HOME",
buildId: "build-a",
boundaryName: "feature",
});
});
+18 -5
View File
@@ -8,12 +8,25 @@ import {
createAnonymousSessionAdapter,
createDemoSessionAdapter,
} from "../../src/adapters/auth/external-session-adapter.js";
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.js";
import { AppRouter } from "../../src/presentation/routes/app-router.jsx";
import { createTestApplication } from "../helpers/create-test-application.js";
/**
* @param {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} session
*/
function renderRouter(session) {
return render(
<ApplicationProvider application={createTestApplication({ session })}>
<AppRouter />
</ApplicationProvider>,
);
}
describe("application router", () => {
it("renders the app shell and not-found route without an API request", async () => {
window.history.pushState({}, "", "/missing");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
renderRouter(createAnonymousSessionAdapter());
expect(
await screen.findByRole("heading", {
@@ -27,10 +40,10 @@ describe("application router", () => {
it("navigates between registry-backed example routes", async () => {
const user = userEvent.setup();
window.history.pushState({}, "", "/");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
renderRouter(createAnonymousSessionAdapter());
await user.click(
await screen.findByRole("link", { name: "UI 구성요소", exact: true }),
await screen.findByRole("link", { name: "UI 구성요소" }),
);
expect(
@@ -43,7 +56,7 @@ describe("application router", () => {
const user = userEvent.setup();
const authSession = createDemoSessionAdapter();
window.history.pushState({}, "", "/sample/resources");
render(<AppRouter authSession={authSession} />);
renderRouter(authSession);
expect(
await screen.findByRole("heading", { name: "세션이 필요합니다." }),
@@ -59,7 +72,7 @@ describe("application router", () => {
it("fails closed when the auth integration does not change state", async () => {
const user = userEvent.setup();
window.history.pushState({}, "", "/sample/resources");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
renderRouter(createAnonymousSessionAdapter());
await user.click(
await screen.findByRole("button", { name: "로그인 시작" }),
@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { createRuntimeComposition } from "../../src/bootstrap/create-runtime-composition.js";
import { RuntimeApplication } from "../../src/bootstrap/runtime-application.jsx";
const runtimeConfig = {
APP_ENV: "local",
API_BASE_URL: "http://localhost:8080",
REQUEST_TIMEOUT_MS: 10_000,
MAX_RETRY_ATTEMPTS: 2,
TELEMETRY_ENABLED: false,
AUTH_MODE: "demo",
CONFIG_SCHEMA_VERSION: "1",
API_CONTRACT_VERSION: "1",
RELEASE_MANIFEST_URL: "/release-manifest.json",
BUILD_ID: "local-build",
RELEASE_ID: "local-release",
};
const releaseManifest = {
schemaVersion: 1,
appVersion: "0.1.0",
buildId: "local-build",
commitSha: "local",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
releaseId: "local-release",
builtAt: "2026-07-26T00:00:00.000Z",
};
describe("production runtime application tree", () => {
it("connects validated config and release through composition and ApplicationProvider", async () => {
const fetcher = vi.fn(async (input) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
return Response.json(
url.includes("release-manifest") ? releaseManifest : runtimeConfig,
);
});
const composition = await createRuntimeComposition({
fetcher,
host: {},
});
window.history.pushState({}, "", "/");
render(<RuntimeApplication composition={composition} />);
expect(
await screen.findByRole("heading", {
name: "Clean Architecture Frontend",
}),
).toBeVisible();
expect(
await screen.findByText("빌드 local-build · 릴리스 local-release"),
).toBeVisible();
expect(fetcher).toHaveBeenCalledTimes(2);
expect(composition).not.toHaveProperty("ports");
});
});
+20 -4
View File
@@ -7,9 +7,10 @@ import { SampleResourcePage } from "../../src/sample/contract-fixture/sample-res
describe("removable sample feature page", () => {
it("renders the API-to-view-model result through AsyncSurface", async () => {
/** @type {Parameters<typeof SampleResourcePage>[0]["facade"]} */
const facade = {
listResources: async () => ({
ok: true,
ok: /** @type {const} */ (true),
value: [
{
resourceId: "resource-1",
@@ -18,7 +19,14 @@ describe("removable sample feature page", () => {
},
],
}),
createResource: async () => ({ ok: true, value: {} }),
createResource: async () => ({
ok: /** @type {const} */ (true),
value: {
resourceId: "resource-created",
title: "Created",
createdAtLabel: null,
},
}),
};
render(<SampleResourcePage facade={facade} />);
@@ -27,9 +35,10 @@ describe("removable sample feature page", () => {
});
it("renders normalized terminal errors without raw DTO fields", async () => {
/** @type {Parameters<typeof SampleResourcePage>[0]["facade"]} */
const facade = {
listResources: async () => ({
ok: false,
ok: /** @type {const} */ (false),
error: {
kind: "SERVER_FAILURE",
code: "SERVER_FAILURE",
@@ -40,7 +49,14 @@ describe("removable sample feature page", () => {
action: "retry",
},
}),
createResource: async () => ({ ok: true, value: {} }),
createResource: async () => ({
ok: /** @type {const} */ (true),
value: {
resourceId: "resource-created",
title: "Created",
createdAtLabel: null,
},
}),
};
render(<SampleResourcePage facade={facade} />);
@@ -0,0 +1,20 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
type StatusProps = Readonly<{
label: string;
tone: "neutral" | "positive";
}>;
function Status({ label, tone }: StatusProps) {
return <output data-tone={tone}>{label}</output>;
}
describe("TSX test tooling", () => {
it("parses, lints, type-checks, and renders TSX", () => {
render(<Status label="ready" tone="positive" />);
expect(screen.getByText("ready")).toHaveAttribute("data-tone", "positive");
});
});
@@ -0,0 +1,3 @@
import { createRuntimeAdapters } from "../../../../src/bootstrap/runtime-adapters.js";
export const bootstrapFactory = createRuntimeAdapters;
@@ -0,0 +1,7 @@
import { createApplication } from "../../../../src/application/create-application.js";
export const applicationFactory = createApplication;
export function AllowedPresentationFixture() {
return <p>allowed</p>;
}
@@ -0,0 +1,3 @@
import { createHttpClient } from "../../../../src/adapters/http/client.js";
export const leakedHttpFactory = createHttpClient;
@@ -0,0 +1,4 @@
export function DirectFetchPage() {
void fetch("/api/forbidden");
return <p>forbidden</p>;
}
@@ -0,0 +1,5 @@
import "../../../../src/adapters/http/client.js";
export function InvalidPresentationFixture() {
return <p>invalid</p>;
}
@@ -0,0 +1,5 @@
import type { ApplicationOutputPorts } from "../../../../src/application/ports/out/application-output-ports.js";
export function OutputPortLeak(_props: ApplicationOutputPorts) {
return <p>forbidden</p>;
}
+33
View File
@@ -0,0 +1,33 @@
{
"schemaVersion": 1,
"sourceDirectories": [],
"registries": [
{
"registryId": "FIXTURE-SOURCE",
"path": "tests/fixtures/registry/forbidden/invalid-registry.js",
"exportName": "INVALID_REGISTRY",
"owner": "fixture",
"requiredFields": ["id", "target"],
"uniqueFields": ["id"],
"references": [
{
"field": "target",
"registryId": "FIXTURE-TARGET",
"targetField": "id"
}
]
},
{
"registryId": "FIXTURE-TARGET",
"path": "tests/fixtures/registry/forbidden/invalid-registry.js",
"exportName": "TARGET_REGISTRY",
"owner": "fixture",
"requiredFields": ["id"],
"uniqueFields": ["id"]
}
],
"compatibilityImpact": {
"allowed": ["none", "additive", "behavior-change", "breaking"],
"current": "breaking"
}
}
+13
View File
@@ -0,0 +1,13 @@
type InvalidRow = Readonly<{
id: string;
target?: string;
}>;
export const INVALID_REGISTRY: Record<string, InvalidRow> = {
FIRST: { id: "duplicate", target: "missing" },
SECOND: { id: "duplicate" },
};
export const TARGET_REGISTRY = {
KNOWN: { id: "known" },
} as const;
+3
View File
@@ -0,0 +1,3 @@
export function SafeTextFixture({ value }: Readonly<{ value: string }>) {
return <p>{value}</p>;
}
+3
View File
@@ -0,0 +1,3 @@
export function UnsafeHtmlFixture({ value }: Readonly<{ value: string }>) {
return <div dangerouslySetInnerHTML={{ __html: value }} />;
}
+5
View File
@@ -0,0 +1,5 @@
import type { ApplicationApi } from "../../../src/application/create-application.js";
export const incompleteApplication: ApplicationApi = {
session: {} as ApplicationApi["session"],
};
@@ -0,0 +1,7 @@
import type { ApplicationOutputPorts } from "../../../src/application/create-application.js";
export const invalidOutputPorts: ApplicationOutputPorts = {
session: {
getState: () => "signed-in",
},
};
@@ -0,0 +1,7 @@
interface ClockPort {
now(): number;
}
export const invalidClock: ClockPort = {
now: () => "not-a-number",
};
+7
View File
@@ -0,0 +1,7 @@
type Result<T, E> =
| Readonly<{ ok: true; value: T }>
| Readonly<{ ok: false; error: E }>;
export function invalidUnwrap(result: Result<number, string>): number {
return result.value;
}
+35
View File
@@ -0,0 +1,35 @@
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
import { createApplication } from "../../src/application/create-application.js";
/**
* @param {{
* session?: import("../../src/application/ports/auth-session-port.js").AuthSessionPort,
* preferences?: import("../../src/application/ports/storage-port.js").StoragePort,
* diagnostics?: import("../../src/application/ports/telemetry-port.js").TelemetryPort,
* releaseInfo?: import("../../src/application/ports/release-info-port.js").ReleaseInfoPort
* }} [overrides]
*/
export function createTestApplication(overrides = {}) {
return createApplication({
session: overrides.session ?? createAnonymousSessionAdapter(),
preferences:
overrides.preferences ??
{
read: () => ({ ok: /** @type {const} */ (true), value: "system" }),
write: () => ({ ok: /** @type {const} */ (true) }),
remove: () => ({ ok: /** @type {const} */ (true) }),
},
diagnostics: overrides.diagnostics ?? { emit: () => {} },
releaseInfo:
overrides.releaseInfo ??
{
getCurrent: async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
}),
},
});
}
+25 -10
View File
@@ -5,6 +5,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../../src/adapters/http/client.js";
/** @type {number[]} */
let responseStatuses = [];
const server = setupServer(
http.get("https://api.test/api/sample/resources", () => {
@@ -36,16 +37,32 @@ afterAll(() => server.close());
const clock = { now: () => 0, sleep: async () => {} };
/**
* @param {Partial<Parameters<typeof createExternalAuthSessionAdapter>[0]>} overrides
* @returns {Parameters<typeof createExternalAuthSessionAdapter>[0]}
*/
function createOwner(overrides = {}) {
return {
readState: () => "authenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => request,
recoverSession: async () => "restored",
notifyUnauthenticated: () => {},
...overrides,
};
}
describe("bounded 401 session recovery", () => {
it("calls recovery once and replays a safe request once", async () => {
responseStatuses = [401, 200];
const recoverSession = vi.fn(async () => "restored");
const authSession = createExternalAuthSessionAdapter({
readState: () => "authenticated",
const recoverSession = vi.fn(async () => /** @type {const} */ ("restored"));
const authSession = createExternalAuthSessionAdapter(createOwner({
attachCredential: async (request) => request,
recoverSession,
notifyUnauthenticated: vi.fn(),
});
}));
const client = createHttpClient({
baseUrl: "https://api.test",
authSession,
@@ -61,12 +78,11 @@ describe("bounded 401 session recovery", () => {
it("stops after a second 401 and notifies unauthenticated once", async () => {
responseStatuses = [401, 401];
const notifyUnauthenticated = vi.fn();
const authSession = createExternalAuthSessionAdapter({
readState: () => "authenticated",
const authSession = createExternalAuthSessionAdapter(createOwner({
attachCredential: async (request) => request,
recoverSession: async () => "restored",
notifyUnauthenticated,
});
}));
const client = createHttpClient({
baseUrl: "https://api.test",
authSession,
@@ -81,14 +97,13 @@ describe("bounded 401 session recovery", () => {
});
it("normalizes attach and invalid recovery failures", async () => {
const attachFailure = createExternalAuthSessionAdapter({
readState: () => "authenticated",
const attachFailure = createExternalAuthSessionAdapter(createOwner({
attachCredential: async () => {
throw new Error("credential detail");
},
recoverSession: async () => "restored",
notifyUnauthenticated: vi.fn(),
});
}));
const client = createHttpClient({
baseUrl: "https://api.test",
authSession: attachFailure,
+1
View File
@@ -31,6 +31,7 @@ describe("HTTP runtime schema boundary", () => {
success: true,
data: [{ id: "resource-1", additive: "accepted" }],
});
if (!result.success) throw new Error("expected valid sample payload");
expect(result.data).not.toBe(source);
});
+2 -1
View File
@@ -25,7 +25,8 @@ describe("runtime configuration boundary", () => {
[{ ...validConfig, TELEMETRY_ENABLED: "false" }, "ambiguous boolean"],
[{ ...validConfig, CONFIG_SCHEMA_VERSION: "next" }, "version"],
[{ ...validConfig, UNKNOWN_KEY: true }, "unknown key"],
])("rejects invalid config: %s (%s)", (candidate) => {
])("rejects invalid config: %s (%s)", (candidate, _reason) => {
void _reason;
expect(validateRuntimeConfig(candidate).success).toBe(false);
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it, vi } from "vitest";
import {
createApplication,
type ApplicationOutputPorts,
} from "../../src/application/create-application.js";
import { createTestApplication } from "../helpers/create-test-application.js";
describe("application input/output boundary", () => {
it("exposes intent-oriented input APIs without leaking output ports", async () => {
const application = createTestApplication();
expect(Object.keys(application)).toEqual([
"session",
"preferences",
"diagnostics",
"runtime",
]);
expect(application).not.toHaveProperty("storage");
expect(application).not.toHaveProperty("telemetry");
expect(application).not.toHaveProperty("releaseInfo");
await expect(application.runtime.getReleaseSummary()).resolves.toEqual({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
});
});
it("uses fake output ports for preference, session, and safe diagnostics flows", () => {
const write = vi.fn(() => ({ ok: true as const }));
const emit = vi.fn();
const ports = {
session: {
getState: () => "authenticated" as const,
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
recover: async () => "restored" as const,
},
preferences: {
read: () => ({ ok: true as const, value: "dark" }),
write,
remove: () => ({ ok: true as const }),
},
diagnostics: { emit },
releaseInfo: {
getCurrent: async () => ({
buildId: "build-a",
releaseId: "release-a",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "hash-a",
}),
},
} satisfies ApplicationOutputPorts;
const application = createApplication(ports);
expect(application.session.getSnapshot()).toBe("authenticated");
expect(application.preferences.getColorScheme()).toBe("dark");
expect(application.preferences.setColorScheme("light")).toEqual({ ok: true });
expect(write).toHaveBeenCalledWith("COLOR_SCHEME", "light");
application.diagnostics.reportRenderFailure({
routeId: "APP_HOME",
buildId: "build-a",
boundaryName: "route",
});
expect(emit).toHaveBeenCalledWith("ui.render.failed", {
route_id: "APP_HOME",
build_id: "build-a",
component_boundary: "route",
});
});
it("does not let a failing diagnostics output escape into presentation", () => {
const application = createTestApplication({
diagnostics: {
emit() {
throw new Error("sink details");
},
},
});
expect(() =>
application.diagnostics.reportRenderFailure({
routeId: "APP_HOME",
buildId: "build-a",
boundaryName: "feature",
}),
).not.toThrow();
});
});
+2 -1
View File
@@ -36,6 +36,7 @@ describe("external AuthSessionPort adapter", () => {
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => request,
// @ts-expect-error Deliberately violates the external-owner contract.
recoverSession: async () => "unexpected",
notifyUnauthenticated: vi.fn(),
});
@@ -57,7 +58,7 @@ describe("external AuthSessionPort adapter", () => {
});
expect(adapter.getState()).toBe("unauthenticated");
await adapter.beginSignIn("/");
await adapter.beginSignIn();
expect(adapter.getState()).toBe("authenticated");
await adapter.signOut();
expect(adapter.getState()).toBe("unauthenticated");
+6 -5
View File
@@ -3,15 +3,16 @@ import { describe, expect, it } from "vitest";
import { decideChunkRecovery } from "../../src/application/use-cases/decide-chunk-recovery.js";
function memoryStorage() {
/** @type {unknown} */
let value;
return {
read: () => ({ ok: true, value }),
return /** @type {import("../../src/application/ports/storage-port.js").StoragePort} */ ({
read: () => ({ ok: /** @type {const} */ (true), value }),
write: (_key, next) => {
value = next;
return { ok: true };
return { ok: /** @type {const} */ (true) };
},
remove: () => ({ ok: true }),
};
remove: () => ({ ok: /** @type {const} */ (true) }),
});
}
describe("controlled chunk recovery", () => {
+3 -7
View File
@@ -21,13 +21,9 @@ describe("color scheme policy", () => {
});
it("applies a persisted preference before application paint", () => {
const storage = {
read: () => ({ ok: true, value: "system" }),
write: () => ({ ok: true }),
remove: () => ({ ok: true }),
};
const result = initializeColorScheme(storage, {
const result = initializeColorScheme({
getColorScheme: () => "system",
}, {
documentElement: document.documentElement,
matchMedia: () =>
/** @type {MediaQueryList} */ ({ matches: true }),
+8 -2
View File
@@ -39,14 +39,20 @@ describe("frontend failure classification", () => {
});
it("projects only allowlisted safe fields", () => {
const result = createFailure("SERVER_FAILURE", "LIST_SAMPLE_RESOURCES", 0, {
const untrustedDetails = {
code: "TEMPORARY",
httpStatus: 503,
requestId: "request-1",
stack: "must not leak",
body: "must not leak",
authorization: "Bearer secret",
});
};
const result = createFailure(
"SERVER_FAILURE",
"LIST_SAMPLE_RESOURCES",
0,
untrustedDetails,
);
expect(result).toMatchObject({
kind: "SERVER_FAILURE",
+9 -2
View File
@@ -6,7 +6,10 @@ import {
} from "../../src/application/policies/promotion-readiness.js";
const allGateIds = Object.values(PROMOTION_FORMULA).flat();
const passing = Object.fromEntries(allGateIds.map((gateId) => [gateId, "PASS"]));
const passing =
/** @type {Record<string, "PASS" | "FAIL" | "UNVERIFIED">} */ (
Object.fromEntries(allGateIds.map((gateId) => [gateId, "PASS"]))
);
describe("promotion readiness formula", () => {
it("requires every upstream tier before downstream readiness", () => {
@@ -30,7 +33,11 @@ describe("promotion readiness formula", () => {
...passing,
[failedGate]: "FAIL",
});
expect(result[readiness]).toBe(false);
const readinessKey =
/** @type {keyof ReturnType<typeof evaluatePromotionReadiness>} */ (
readiness
);
expect(result[readinessKey]).toBe(false);
});
it("does not treat missing or unverified gates as pass", () => {
+6 -2
View File
@@ -6,11 +6,15 @@ describe("registry governance manifest", () => {
const governance = JSON.parse(
await readFile("config/contracts/registry-governance.json", "utf8"),
);
const registries =
/** @type {Array<{registryId: string, owner: string}>} */ (
governance.registries
);
expect(governance.registries).toHaveLength(8);
expect(new Set(governance.registries.map((entry) => entry.registryId)).size).toBe(
expect(new Set(registries.map((entry) => entry.registryId)).size).toBe(
8,
);
expect(governance.registries.every((entry) => entry.owner)).toBe(true);
expect(registries.every((entry) => entry.owner)).toBe(true);
expect(governance.compatibilityImpact.allowed).toEqual([
"none",
"additive",
+8 -9
View File
@@ -10,7 +10,7 @@ const runtime = {
AUTH_MODE: "demo",
},
};
const release = {
const release = /** @type {const} */ ({
schemaVersion: 1,
appVersion: "0.1.0",
buildId: "build-a",
@@ -20,7 +20,7 @@ const release = {
assetManifestHash: "hash-a",
releaseId: "release-a",
builtAt: "2026-07-25T00:00:00Z",
};
});
describe("runtime adapter composition", () => {
it("constructs the local demo seam and infrastructure adapters", async () => {
@@ -33,14 +33,13 @@ describe("runtime adapter composition", () => {
host: {},
});
expect(adapters.authSession.getState()).toBe("unauthenticated");
expect(adapters.cache.read(["missing"])).toEqual({
ok: true,
value: undefined,
});
await expect(adapters.releaseInfo.getCurrent()).resolves.toMatchObject({
expect(adapters.outputPorts.session.getState()).toBe("unauthenticated");
await expect(adapters.outputPorts.releaseInfo.getCurrent()).resolves.toMatchObject({
releaseId: "release-a",
});
expect(adapters.infrastructure.queryClient).toBeDefined();
expect(adapters).not.toHaveProperty("http");
expect(adapters).not.toHaveProperty("storage");
});
it("fails closed when an external auth owner was not installed", async () => {
@@ -52,6 +51,6 @@ describe("runtime adapter composition", () => {
release,
host: {},
});
expect(adapters.authSession.getState()).toBe("integration-failed");
expect(adapters.outputPorts.session.getState()).toBe("integration-failed");
});
});
+5 -1
View File
@@ -7,8 +7,12 @@ import {
defineStorageKey,
} from "../../src/contracts/storage-keys.js";
/**
* @param {{quota?: boolean}} [options]
* @returns {Storage}
*/
function createStorage({ quota = false } = {}) {
const values = new Map();
const values = /** @type {Map<string, string>} */ (new Map());
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => {
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { systemClock } from "../../src/application/ports/clock-port.js";
import { systemClock } from "../../src/adapters/platform/system-clock.js";
describe("systemClock", () => {
it("resolves after the requested duration", async () => {
+1 -1
View File
@@ -56,7 +56,7 @@ describe("telemetry registry and redaction", () => {
describe("best-effort telemetry adapter", () => {
it("bounds the queue using oldest-drop without blocking callers", () => {
const scheduled = [];
const scheduled = /** @type {Array<() => void>} */ ([]);
const adapter = createTelemetryAdapter({
enabled: true,
endpoint: "https://telemetry.test/events",
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
type Result<T, E> =
| Readonly<{ ok: true; value: T }>
| Readonly<{ ok: false; error: E }>;
function summarize(result: Result<number, string>): string {
return result.ok ? String(result.value) : result.error;
}
describe("TypeScript test tooling", () => {
it("type-checks and executes discriminated unions in the test project", () => {
expect(summarize({ ok: true, value: 42 })).toBe("42");
expect(summarize({ ok: false, error: "failed" })).toBe("failed");
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src"],
"exclude": ["dist", "node_modules", "tests"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": false,
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true
}
}
+6 -18
View File
@@ -1,20 +1,8 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": false,
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"types": ["vite/client", "node"]
},
"include": ["src", "scripts", "vite.config.js"],
"exclude": ["dist", "node_modules", "tests/fixtures"]
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.test.json" }
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"]
},
"include": ["scripts", "vite.config.js"],
"exclude": ["dist", "node_modules", "tests"]
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node", "vite/client"]
},
"include": [
"tests/**/*.js",
"tests/**/*.jsx",
"tests/**/*.ts",
"tests/**/*.tsx",
"vitest.config.js",
"playwright.config.js"
],
"exclude": [
"dist",
"node_modules",
"tests/fixtures"
]
}