diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 4290d0c..a1e20df 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -45,5 +45,8 @@ module.exports = { exportsFields: ["exports"], conditionNames: ["import", "require", "node", "default"], }, + tsConfig: { + fileName: "tsconfig.app.json", + }, }, }; diff --git a/README.md b/README.md index 0c67f8c..12477ad 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/config/ci/gates.json b/config/ci/gates.json index 2de046d..7d8d4ed 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -74,7 +74,9 @@ "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" } ], "logPath": "artifacts/quality/check-types.txt", "evidence": ["artifacts/quality/check-types.txt"], @@ -136,9 +138,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": { diff --git a/docs/architecture/decisions/VD-01-typescript-lint-tooling.md b/docs/architecture/decisions/VD-01-typescript-lint-tooling.md new file mode 100644 index 0000000..83c1e23 --- /dev/null +++ b/docs/architecture/decisions/VD-01-typescript-lint-tooling.md @@ -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 +방법이 아니다. diff --git a/docs/architecture/typescript-state-and-data-flow.md b/docs/architecture/typescript-state-and-data-flow.md index 83f859b..76972f3 100644 --- a/docs/architecture/typescript-state-and-data-flow.md +++ b/docs/architecture/typescript-state-and-data-flow.md @@ -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 전환 순서 | 단계 | 대상 | 이유 | 종료 조건 | diff --git a/docs/testing/frontend-platform-testing-strategy.md b/docs/testing/frontend-platform-testing-strategy.md index 5f3e6c6..5885924 100644 --- a/docs/testing/frontend-platform-testing-strategy.md +++ b/docs/testing/frontend-platform-testing-strategy.md @@ -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가 없다 diff --git a/eslint.config.js b/eslint.config.js index 715beee..f3e3d75 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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/**", @@ -27,6 +31,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 +76,103 @@ 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), }, }, { - 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,7 +181,9 @@ export default [ }, }, { - files: ["tests/fixtures/architecture/forbidden/**/*.{js,jsx}"], + files: [ + `tests/fixtures/architecture/forbidden/**/*.${sourceExtensions}`, + ], rules: { "no-restricted-imports": restrictedImports([ "**/adapters/**", @@ -99,24 +193,4 @@ export default [ ]), }, }, - { - files: ["**/*.{js,jsx}"], - rules: { - "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.", - }, - ], - }, - }, ]; diff --git a/package.json b/package.json index bbac592..73d6348 100644 --- a/package.json +++ b/package.json @@ -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,13 @@ "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", "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 +36,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 +58,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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1069817..d70fc0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/scripts/check-registries.mjs b/scripts/check-registries.mjs index d32e03c..ea9d40a 100644 --- a/scripts/check-registries.mjs +++ b/scripts/check-registries.mjs @@ -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, diff --git a/src/adapters/http/client.js b/src/adapters/http/client.js index 7d0b8b2..5ff03bb 100644 --- a/src/adapters/http/client.js +++ b/src/adapters/http/client.js @@ -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 } | @@ -74,6 +60,7 @@ export function createHttpClient(dependencies) { * signal?: AbortSignal, * idempotencyKey?: string * }} [input] + * @returns {Promise} */ async function execute(operationId, input = {}) { const operation = getApiOperation(operationId); diff --git a/tests/component/render-boundary.test.jsx b/tests/component/render-boundary.test.jsx index 65696dc..a920c16 100644 --- a/tests/component/render-boundary.test.jsx +++ b/tests/component/render-boundary.test.jsx @@ -10,6 +10,7 @@ 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"); } diff --git a/tests/component/router.test.jsx b/tests/component/router.test.jsx index fc7782d..d9fdfc0 100644 --- a/tests/component/router.test.jsx +++ b/tests/component/router.test.jsx @@ -30,7 +30,7 @@ describe("application router", () => { render(); await user.click( - await screen.findByRole("link", { name: "UI 구성요소", exact: true }), + await screen.findByRole("link", { name: "UI 구성요소" }), ); expect( diff --git a/tests/component/sample-resource-page.test.jsx b/tests/component/sample-resource-page.test.jsx index 9910cac..63dc553 100644 --- a/tests/component/sample-resource-page.test.jsx +++ b/tests/component/sample-resource-page.test.jsx @@ -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[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(); @@ -27,9 +35,10 @@ describe("removable sample feature page", () => { }); it("renders normalized terminal errors without raw DTO fields", async () => { + /** @type {Parameters[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(); diff --git a/tests/component/typescript-tooling-smoke.test.tsx b/tests/component/typescript-tooling-smoke.test.tsx new file mode 100644 index 0000000..38226ad --- /dev/null +++ b/tests/component/typescript-tooling-smoke.test.tsx @@ -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 {label}; +} + +describe("TSX test tooling", () => { + it("parses, lints, type-checks, and renders TSX", () => { + render(); + expect(screen.getByText("ready")).toHaveAttribute("data-tone", "positive"); + }); +}); diff --git a/tests/fixtures/architecture/allowed/presentation-facade.tsx b/tests/fixtures/architecture/allowed/presentation-facade.tsx new file mode 100644 index 0000000..41bbf99 --- /dev/null +++ b/tests/fixtures/architecture/allowed/presentation-facade.tsx @@ -0,0 +1,7 @@ +import { createApplication } from "../../../../src/application/create-application.js"; + +export const applicationFactory = createApplication; + +export function AllowedPresentationFixture() { + return

allowed

; +} diff --git a/tests/fixtures/architecture/forbidden/presentation-imports-adapter.tsx b/tests/fixtures/architecture/forbidden/presentation-imports-adapter.tsx new file mode 100644 index 0000000..7adc59e --- /dev/null +++ b/tests/fixtures/architecture/forbidden/presentation-imports-adapter.tsx @@ -0,0 +1,5 @@ +import "../../../../src/adapters/http/client.js"; + +export function InvalidPresentationFixture() { + return

invalid

; +} diff --git a/tests/fixtures/registry/forbidden/governance.json b/tests/fixtures/registry/forbidden/governance.json new file mode 100644 index 0000000..f2815f8 --- /dev/null +++ b/tests/fixtures/registry/forbidden/governance.json @@ -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" + } +} diff --git a/tests/fixtures/registry/forbidden/invalid-registry.ts b/tests/fixtures/registry/forbidden/invalid-registry.ts new file mode 100644 index 0000000..df63144 --- /dev/null +++ b/tests/fixtures/registry/forbidden/invalid-registry.ts @@ -0,0 +1,13 @@ +type InvalidRow = Readonly<{ + id: string; + target?: string; +}>; + +export const INVALID_REGISTRY: Record = { + FIRST: { id: "duplicate", target: "missing" }, + SECOND: { id: "duplicate" }, +}; + +export const TARGET_REGISTRY = { + KNOWN: { id: "known" }, +} as const; diff --git a/tests/fixtures/security/allowed/safe-text.tsx b/tests/fixtures/security/allowed/safe-text.tsx new file mode 100644 index 0000000..5d6f978 --- /dev/null +++ b/tests/fixtures/security/allowed/safe-text.tsx @@ -0,0 +1,3 @@ +export function SafeTextFixture({ value }: Readonly<{ value: string }>) { + return

{value}

; +} diff --git a/tests/fixtures/security/forbidden/raw-html.tsx b/tests/fixtures/security/forbidden/raw-html.tsx new file mode 100644 index 0000000..4b8964f --- /dev/null +++ b/tests/fixtures/security/forbidden/raw-html.tsx @@ -0,0 +1,3 @@ +export function UnsafeHtmlFixture({ value }: Readonly<{ value: string }>) { + return
; +} diff --git a/tests/fixtures/typecheck/invalid-port-implementation.ts b/tests/fixtures/typecheck/invalid-port-implementation.ts new file mode 100644 index 0000000..f24f583 --- /dev/null +++ b/tests/fixtures/typecheck/invalid-port-implementation.ts @@ -0,0 +1,7 @@ +interface ClockPort { + now(): number; +} + +export const invalidClock: ClockPort = { + now: () => "not-a-number", +}; diff --git a/tests/fixtures/typecheck/invalid-result-narrowing.ts b/tests/fixtures/typecheck/invalid-result-narrowing.ts new file mode 100644 index 0000000..d211b54 --- /dev/null +++ b/tests/fixtures/typecheck/invalid-result-narrowing.ts @@ -0,0 +1,7 @@ +type Result = + | Readonly<{ ok: true; value: T }> + | Readonly<{ ok: false; error: E }>; + +export function invalidUnwrap(result: Result): number { + return result.value; +} diff --git a/tests/integration/auth-recovery.test.js b/tests/integration/auth-recovery.test.js index bb04397..72955b0 100644 --- a/tests/integration/auth-recovery.test.js +++ b/tests/integration/auth-recovery.test.js @@ -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[0]>} overrides + * @returns {Parameters[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, diff --git a/tests/runtime-schema/http-schema.test.js b/tests/runtime-schema/http-schema.test.js index d9b4b1d..9fd8d6e 100644 --- a/tests/runtime-schema/http-schema.test.js +++ b/tests/runtime-schema/http-schema.test.js @@ -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); }); diff --git a/tests/runtime-schema/runtime-config.test.js b/tests/runtime-schema/runtime-config.test.js index d94b105..7630ee9 100644 --- a/tests/runtime-schema/runtime-config.test.js +++ b/tests/runtime-schema/runtime-config.test.js @@ -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); }); diff --git a/tests/unit/auth-session-adapter.test.js b/tests/unit/auth-session-adapter.test.js index f138e5e..c3b8257 100644 --- a/tests/unit/auth-session-adapter.test.js +++ b/tests/unit/auth-session-adapter.test.js @@ -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"); diff --git a/tests/unit/chunk-recovery.test.js b/tests/unit/chunk-recovery.test.js index 41fd3ab..66b6c0b 100644 --- a/tests/unit/chunk-recovery.test.js +++ b/tests/unit/chunk-recovery.test.js @@ -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", () => { diff --git a/tests/unit/color-scheme.test.js b/tests/unit/color-scheme.test.js index 065aa96..8b70c72 100644 --- a/tests/unit/color-scheme.test.js +++ b/tests/unit/color-scheme.test.js @@ -21,11 +21,15 @@ 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 storage = + /** @type {import("../../src/application/ports/storage-port.js").StoragePort} */ ({ + read: () => ({ + ok: /** @type {const} */ (true), + value: "system", + }), + write: () => ({ ok: /** @type {const} */ (true) }), + remove: () => ({ ok: /** @type {const} */ (true) }), + }); const result = initializeColorScheme(storage, { documentElement: document.documentElement, diff --git a/tests/unit/error-classification.test.js b/tests/unit/error-classification.test.js index 4de3b47..600ba64 100644 --- a/tests/unit/error-classification.test.js +++ b/tests/unit/error-classification.test.js @@ -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", diff --git a/tests/unit/promotion-readiness.test.js b/tests/unit/promotion-readiness.test.js index dbf5ac2..44ed0e2 100644 --- a/tests/unit/promotion-readiness.test.js +++ b/tests/unit/promotion-readiness.test.js @@ -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} */ ( + 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} */ ( + readiness + ); + expect(result[readinessKey]).toBe(false); }); it("does not treat missing or unverified gates as pass", () => { diff --git a/tests/unit/registry-governance.test.js b/tests/unit/registry-governance.test.js index ad4688f..fa9a553 100644 --- a/tests/unit/registry-governance.test.js +++ b/tests/unit/registry-governance.test.js @@ -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", diff --git a/tests/unit/runtime-adapters.test.js b/tests/unit/runtime-adapters.test.js index 9ba653f..fe7ffb4 100644 --- a/tests/unit/runtime-adapters.test.js +++ b/tests/unit/runtime-adapters.test.js @@ -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 () => { diff --git a/tests/unit/storage-registry.test.js b/tests/unit/storage-registry.test.js index fa75250..f5af2de 100644 --- a/tests/unit/storage-registry.test.js +++ b/tests/unit/storage-registry.test.js @@ -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} */ (new Map()); return { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => { diff --git a/tests/unit/telemetry.test.js b/tests/unit/telemetry.test.js index 3400abd..67ccb0a 100644 --- a/tests/unit/telemetry.test.js +++ b/tests/unit/telemetry.test.js @@ -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", diff --git a/tests/unit/typescript-tooling-smoke.test.ts b/tests/unit/typescript-tooling-smoke.test.ts new file mode 100644 index 0000000..15b318f --- /dev/null +++ b/tests/unit/typescript-tooling-smoke.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +type Result = + | Readonly<{ ok: true; value: T }> + | Readonly<{ ok: false; error: E }>; + +function summarize(result: Result): 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"); + }); +}); diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..056ff7e --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "tests"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..29dde9f --- /dev/null +++ b/tsconfig.base.json @@ -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 + } +} diff --git a/tsconfig.json b/tsconfig.json index d3c8554..01490aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" } + ] } diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..efc73b2 --- /dev/null +++ b/tsconfig.node.json @@ -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"] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..5f97341 --- /dev/null +++ b/tsconfig.test.json @@ -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" + ] +}