Compare commits

..
Author SHA1 Message Date
donghyeon-ka 13f28ef811 feat: add design system platform 2026-07-26 15:49:07 +09:00
donghyeon-ka e49d90f713 merge: form and page platform 2026-07-26 15:22:58 +09:00
donghyeon-ka b327d7370b feat: add form and page platform 2026-07-26 15:22:52 +09:00
donghyeon-ka fdcf0de5bf merge: removable reference feature vertical slice 2026-07-26 14:56:35 +09:00
donghyeon-ka c11be43f20 feat: add removable reference feature vertical slice 2026-07-26 14:56:34 +09:00
donghyeon-ka 980981bc86 merge: route and release recovery runtime 2026-07-26 14:26:39 +09:00
donghyeon-ka ce0040e407 feat: execute route and release recovery contracts 2026-07-26 14:26:39 +09:00
donghyeon-ka a33e93d4d4 merge: HTTP and query contract execution 2026-07-26 14:05:21 +09:00
donghyeon-ka ad55e21a3d feat: execute HTTP and query runtime contracts 2026-07-26 14:05:12 +09:00
donghyeon-ka 8aaaa033c0 merge: application boundary runtime 2026-07-26 13:52:43 +09:00
donghyeon-ka 2dda17cf19 feat: connect application input and output boundaries 2026-07-26 13:52:35 +09:00
donghyeon-ka 38ad69236b merge: TypeScript tooling foundation 2026-07-26 13:41:37 +09:00
donghyeon-ka 0fed35586a feat: establish TypeScript-aware frontend tooling 2026-07-26 13:41:23 +09:00
donghyeon-ka 1a1747c737 merge: frontend platform capability review 2026-07-26 02:09:38 +09:00
donghyeon-ka 68342e25ce docs: audit frontend platform capabilities 2026-07-26 02:09:06 +09:00
donghyeon-ka cb195f8773 merge: frontend performance route contract 2026-07-26 00:32:50 +09:00
donghyeon-ka 5a7a6c4ae5 test(performance): derive navigation target from route registry 2026-07-26 00:32:50 +09:00
donghyeon-ka 8ea825a8a1 merge: harden starter experience quality contract 2026-07-26 00:28:17 +09:00
donghyeon-ka 236909be64 test: harden starter experience quality contract 2026-07-26 00:28:09 +09:00
donghyeon-ka 68d9efbda3 merge: add persistent responsive color themes 2026-07-26 00:06:56 +09:00
donghyeon-ka 4cfbe5a29e feat: add persistent responsive color themes 2026-07-26 00:06:49 +09:00
donghyeon-ka bee5c158c6 merge: provide reusable UI and state galleries 2026-07-25 23:59:20 +09:00
donghyeon-ka 3581ead595 feat: provide reusable UI and state galleries 2026-07-25 23:59:12 +09:00
donghyeon-ka a8e3db1aec merge: assemble responsive app shell navigation 2026-07-25 23:52:41 +09:00
donghyeon-ka baeda39057 feat: assemble responsive app shell navigation 2026-07-25 23:52:34 +09:00
donghyeon-ka 0925d252d9 merge: compose executable frontend runtime 2026-07-25 23:42:39 +09:00
donghyeon-ka 9a120e6d45 feat: compose executable frontend runtime 2026-07-25 23:42:39 +09:00
donghyeon-ka 9200c80149 merge: require field gate inputs 2026-07-25 22:30:43 +09:00
donghyeon-ka f7e8ef6ee4 fix: require external field evidence inputs 2026-07-25 22:30:43 +09:00
donghyeon-ka e70b1a4ad9 merge: harden field performance evidence 2026-07-25 22:30:14 +09:00
donghyeon-ka 6b4b956d51 fix: authenticate field performance evidence context 2026-07-25 22:30:14 +09:00
donghyeon-ka 7d4daea23a merge: harden live hosting verification 2026-07-25 22:25:04 +09:00
donghyeon-ka c089e749d0 fix: require genuine live hosting evidence 2026-07-25 22:25:04 +09:00
donghyeon-ka 2d199a5a23 fix: retain complete manual accessibility evidence 2026-07-25 22:21:52 +09:00
donghyeon-ka 23c47a1eb9 merge: align accessibility gate evidence 2026-07-25 22:21:52 +09:00
donghyeon-ka 8a38805c01 merge: strengthen manual accessibility evidence 2026-07-25 22:21:23 +09:00
donghyeon-ka 2725c35c28 fix: require signed accessibility evidence per route 2026-07-25 22:21:23 +09:00
donghyeon-ka 50dc803f19 fix: consume canonical scoped diagram review evidence 2026-07-25 22:17:47 +09:00
donghyeon-ka 976f444692 merge: align documentation readiness evidence 2026-07-25 22:17:47 +09:00
donghyeon-ka c7191d7615 fix: gitkeep 파일 제거 2026-07-25 22:16:01 +09:00
donghyeon-ka a2a97ebcc7 fix: budget transitive initial JavaScript chunks 2026-07-25 21:46:07 +09:00
donghyeon-ka 28f5585a56 merge: complete bundle graph accounting 2026-07-25 21:46:07 +09:00
donghyeon-ka 4667cafa43 fix: validate complete immutable release surface 2026-07-25 21:45:02 +09:00
donghyeon-ka 6a0c60180c merge: complete immutable release verification 2026-07-25 21:45:02 +09:00
donghyeon-ka 18bea3a852 fix: verify hosting response content types 2026-07-25 21:43:55 +09:00
donghyeon-ka 72fd295556 merge: refresh hosting header verification contract 2026-07-25 21:43:55 +09:00
donghyeon-ka cc6cf29c79 merge: refresh bootstrap type fixture contract
# Conflicts:
#	package.json
2026-07-25 21:40:56 +09:00
donghyeon-ka 8198886dab fix: execute negative type fixture against source 2026-07-25 21:40:24 +09:00
donghyeon-ka c5e218d37a feat: orchestrate blocking frontend quality gates 2026-07-25 21:39:04 +09:00
donghyeon-ka 69d7e26a5b Merge branch 'feature-frontend-ci-quality-gates-contract' into develop 2026-07-25 21:39:04 +09:00
donghyeon-ka 6dd5b85c8c Merge branch 'feature-frontend-operational-runbook-contract' into develop 2026-07-25 21:30:41 +09:00
donghyeon-ka 75c3f5b08c feat: operationalize frontend incident runbooks 2026-07-25 21:30:40 +09:00
donghyeon-ka b1625252d5 feat: enforce web vitals performance budgets 2026-07-25 21:26:51 +09:00
donghyeon-ka 15ddb8d474 Merge branch 'feature-web-vitals-performance-budget-contract' into develop 2026-07-25 21:26:51 +09:00
donghyeon-ka eb37cbe8be feat: enforce coherent release and rollback contract 2026-07-25 21:22:35 +09:00
donghyeon-ka 9d40724ab2 Merge branch 'feature-frontend-release-cache-rollback-contract' into develop 2026-07-25 21:22:35 +09:00
donghyeon-ka b82bc73c6c Merge branch 'feature-frontend-contract-compatibility-governance' into develop 2026-07-25 21:19:20 +09:00
donghyeon-ka 89f3c69413 feat: govern frontend contract compatibility 2026-07-25 21:19:20 +09:00
donghyeon-ka 5ad6fb032e Merge branch 'feature-frontend-contract-registry-governance' into develop 2026-07-25 21:16:46 +09:00
donghyeon-ka 52f4896b63 feat: govern contract registries and snapshots 2026-07-25 21:16:46 +09:00
donghyeon-ka 3c347d40d8 Merge branch 'feature-frontend-browser-security-boundary-contract' into develop 2026-07-25 21:14:32 +09:00
donghyeon-ka 6f88915c7a feat: enforce browser security boundaries 2026-07-25 21:14:32 +09:00
donghyeon-ka 675603c3a2 Merge branch 'feature-frontend-build-bundle-supply-chain-contract' into develop 2026-07-25 21:13:13 +09:00
donghyeon-ka 4a3110974b feat: generate build and supply-chain evidence 2026-07-25 21:13:13 +09:00
donghyeon-ka 6db96b6ef5 feat: establish automated and manual accessibility gates 2026-07-25 21:11:23 +09:00
donghyeon-ka caf09ecd56 Merge branch 'feature-accessibility-baseline-contract' into develop 2026-07-25 21:11:23 +09:00
donghyeon-ka f6300c5d1d Merge branch 'feature-tailwind-design-token-styling-contract' into develop 2026-07-25 21:09:07 +09:00
donghyeon-ka 9a92c11792 feat: add Tailwind semantic design tokens 2026-07-25 21:09:07 +09:00
donghyeon-ka a023c3b645 Merge branch 'feature-sample-feature-slice-contract-fixture' into develop 2026-07-25 21:07:48 +09:00
donghyeon-ka c6b7a9b9bc feat: add removable sample vertical contract fixture 2026-07-25 21:07:48 +09:00
donghyeon-ka 04c4bad43c Merge branch 'feature-frontend-render-recovery-boundary-contract' into develop 2026-07-25 21:05:36 +09:00
donghyeon-ka c37d571eb3 feat: add layered render recovery boundaries 2026-07-25 21:05:36 +09:00
donghyeon-ka eb16c2ffe7 Merge branch 'feature-routing-navigation-guard-contract' into develop 2026-07-25 21:03:57 +09:00
donghyeon-ka b221453c15 feat: centralize routes and navigation guards 2026-07-25 21:03:57 +09:00
donghyeon-ka bfc642875c Merge branch 'feature-boundary-mapper-viewmodel-contract' into develop 2026-07-25 21:02:06 +09:00
donghyeon-ka a9a7db0231 feat: contain DTO mapping at the HTTP boundary 2026-07-25 21:02:06 +09:00
donghyeon-ka 3e126c0ddd Merge branch 'feature-async-ui-state-contract' into develop 2026-07-25 21:00:21 +09:00
donghyeon-ka 438dc11548 feat: model complete async UI surface states 2026-07-25 21:00:21 +09:00
donghyeon-ka 652e0250f3 Merge branch 'feature-frontend-observability-logging-trace-contract' into develop 2026-07-25 20:58:33 +09:00
donghyeon-ka bf8d69e285 feat: add redacted best-effort telemetry contract 2026-07-25 20:58:33 +09:00
donghyeon-ka d54eeff450 Merge branch 'feature-frontend-storage-registry-contract' into develop 2026-07-25 20:56:58 +09:00
donghyeon-ka 2c3eda4d6b feat: enforce classified browser storage registry 2026-07-25 20:56:58 +09:00
donghyeon-ka 0a3bf08308 Merge branch 'feature-server-state-caching-contract' into develop 2026-07-25 20:55:27 +09:00
donghyeon-ka 184eb67282 feat: add application-owned query cache contract 2026-07-25 20:55:27 +09:00
donghyeon-ka c570996a97 Merge branch 'feature-frontend-auth-session-integration-contract' into develop 2026-07-25 20:54:18 +09:00
donghyeon-ka 44414c5244 feat: integrate bounded external auth sessions 2026-07-25 20:54:18 +09:00
donghyeon-ka 37ca2c3172 Merge branch 'feature-frontend-error-classification-boundary-contract' into develop 2026-07-25 20:52:58 +09:00
donghyeon-ka 7f3569ce3c feat: normalize failures through a stable registry 2026-07-25 20:52:58 +09:00
donghyeon-ka a0ca15da65 Merge branch 'feature-runtime-schema-validation-contract' into develop 2026-07-25 20:51:27 +09:00
302 changed files with 27239 additions and 426 deletions
+60 -1
View File
@@ -20,15 +20,71 @@ module.exports = {
{
name: "presentation-does-not-know-adapters",
severity: "error",
from: { path: "^src/presentation" },
from: { path: "^src/presentation/(?!adapters/query)" },
to: { path: "^(src/(adapters|bootstrap)|@tanstack)" },
},
{
name: "page-templates-own-layout-only",
severity: "error",
from: { path: "^src/presentation/templates" },
to: {
path: "^(src/(application|adapters|bootstrap)|src/presentation/adapters|@tanstack)",
},
},
{
name: "icon-vendor-is-facade-only",
severity: "error",
from: {
path: "^src",
pathNot:
"^src/presentation/design-system/icons/vendors/lucide\\.tsx$",
},
to: { path: "^lucide-react$" },
},
{
name: "adapters-do-not-know-presentation",
severity: "error",
from: { path: "^src/adapters" },
to: { path: "^src/(presentation|bootstrap)" },
},
{
name: "feature-domain-is-framework-neutral",
severity: "error",
from: { path: "^src/features/[^/]+/domain" },
to: {
path: "^(src/(application|presentation|adapters|bootstrap)|src/features/[^/]+/(application|adapters|presentation)|react|react-dom|@tanstack)",
},
},
{
name: "feature-application-does-not-know-runtime",
severity: "error",
from: { path: "^src/features/[^/]+/application" },
to: {
path: "^(src/(presentation|adapters|bootstrap)|src/features/[^/]+/(adapters|presentation)|react|react-dom|@tanstack)",
},
},
{
name: "feature-presentation-does-not-know-outbound-adapters",
severity: "error",
from: { path: "^src/features/[^/]+/presentation" },
to: {
path: "^(src/(adapters|bootstrap)|src/features/[^/]+/adapters|@tanstack)",
},
},
{
name: "feature-adapters-do-not-know-presentation",
severity: "error",
from: { path: "^src/features/[^/]+/adapters" },
to: {
path: "^(src/(presentation|bootstrap)|src/features/[^/]+/presentation)",
},
},
{
name: "concrete-adapters-compose-only-in-bootstrap",
severity: "error",
from: { path: "^src/(domain|application|presentation|contracts)" },
to: { path: "^src/adapters" },
},
{
name: "no-circular-dependencies",
severity: "error",
@@ -45,5 +101,8 @@ module.exports = {
exportsFields: ["exports"],
conditionNames: ["import", "require", "node", "default"],
},
tsConfig: {
fileName: "tsconfig.app.json",
},
},
};
+189
View File
@@ -0,0 +1,189 @@
name: frontend-quality-gates
on:
push:
branches: [develop]
tags: ["v*"]
pull_request:
workflow_dispatch:
inputs:
stage:
description: Highest promotion tier to evaluate
required: true
default: merge
type: choice
options:
- merge
- release
- production
- field
- documentation
env:
NODE_VERSION: "24"
jobs:
merge_gate:
name: ${{ matrix.gate }} / ${{ matrix.name }}
if: ${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { gate: FE-GATE-001, name: manifest-lockfile, browser: false }
- { gate: FE-GATE-002, name: lint, browser: false }
- { gate: FE-GATE-003, name: typecheck, browser: false }
- { gate: FE-GATE-004, name: runtime-schema, browser: false }
- { gate: FE-GATE-005, name: unit, browser: false }
- { gate: FE-GATE-006, name: component, browser: false }
- { gate: FE-GATE-007, name: integration, browser: false }
- { gate: FE-GATE-008, name: e2e, browser: true }
- { gate: FE-GATE-009, name: accessibility, browser: true }
- { gate: FE-GATE-010, name: architecture, browser: false }
- { gate: FE-GATE-011, name: build, browser: false }
- { gate: FE-GATE-013, name: security, browser: false }
- { gate: FE-GATE-020, name: sample-removal, browser: false }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Install Playwright browsers
if: ${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
- name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.gate }}-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
release_gate:
name: ${{ matrix.gate }} / ${{ matrix.name }}
needs: merge_gate
if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}
runs-on: ubuntu-latest
env:
HOSTING_BASE_URL: ${{ vars.HOSTING_BASE_URL }}
strategy:
fail-fast: false
matrix:
include:
- { gate: FE-GATE-012, name: bundle, browser: false }
- { gate: FE-GATE-014, name: config-compatibility, browser: false }
- { gate: FE-GATE-015, name: release-coherence, browser: false }
- { gate: FE-GATE-019, name: hosting-header, browser: false }
- { gate: FE-GATE-026, name: lab-performance, browser: true }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Install Playwright browsers
if: ${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
- name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.gate }}-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
production_gate:
name: ${{ matrix.gate }} / ${{ matrix.name }}
needs: release_gate
if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { gate: FE-GATE-016, name: rollback-drill }
- { gate: FE-GATE-021, name: runbook-boot-config }
- { gate: FE-GATE-022, name: runbook-chunk-mismatch }
- { gate: FE-GATE-023, name: runbook-api-degradation }
- { gate: FE-GATE-024, name: runbook-telemetry }
- { gate: FE-GATE-025, name: runbook-release-rollback }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.gate }}-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
field_gate:
name: FE-GATE-018 / field-web-vitals
needs: production_gate
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }}
runs-on: ubuntu-latest
env:
FIELD_WEB_VITALS_INPUT: ${{ vars.FIELD_WEB_VITALS_INPUT }}
MIN_ELIGIBLE_SAMPLES: ${{ vars.MIN_ELIGIBLE_SAMPLES }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Run blocking gate
run: corepack pnpm ci:gate -- FE-GATE-018
- name: Upload gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: FE-GATE-018-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
documentation_gate:
name: FE-GATE-017 / diagram-review
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Run documentation gate
run: corepack pnpm ci:gate -- FE-GATE-017
- name: Upload gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: FE-GATE-017-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
+2
View File
@@ -1,6 +1,7 @@
node_modules/
dist/
.vite/
.tmp/
playwright-report/
test-results/
coverage/
@@ -8,4 +9,5 @@ artifacts/**/*.json
artifacts/**/*.xml
artifacts/**/*.txt
artifacts/**/*.sarif
artifacts/tests/e2e/
!artifacts/**/.gitkeep
+137 -1
View File
@@ -1,2 +1,138 @@
# clean-architecture-frontend-template
# Clean Architecture Frontend Template
A React/Vite reference implementation where architecture boundaries,
integration behavior, release coherence, accessibility, performance, and
operations are executable contracts rather than conventions.
## Start locally
Requirements: Node 24.11 or newer and Corepack. The repository pins pnpm in
`package.json`.
```bash
corepack pnpm install --frozen-lockfile
corepack pnpm dev
```
Runtime-public settings live in `public/config.json` and are validated before
the product tree mounts. Client secrets are forbidden.
## Included starter experience
The default build mounts a domain-neutral application shell with a header,
responsive sidebar, route focus management, session integration status, and a
persistent `system` / `light` / `dark` theme selector.
| Route | Purpose |
| --- | --- |
| `/` | implementation readiness and starter links |
| `/examples/ui` | buttons, fields, cards, alerts, badges, modal, and tokens |
| `/examples/states` | loading, refresh, empty, error, auth, forbidden, and not-found states |
| `/examples/auth` | reactive external-auth integration seam |
| `/sample/resources` | protected, domain-neutral integration route |
`AUTH_MODE=demo` is credential-free and accepted only in local/development
environments. Deployments use `AUTH_MODE=external` and provide the opaque auth
owner described in
[`docs/architecture/starter-experience.md`](docs/architecture/starter-experience.md).
The client route policy is user experience only; server authorization remains
authoritative.
## Architecture
Dependencies point inward:
```text
presentation -> application -> domain
adapters -----^
bootstrap composes concrete adapters
contracts own cross-cutting registries
```
See `docs/architecture/overview.md`, `docs/architecture/layers.md`, and
`docs/architecture/starter-experience.md`. The removable sample slice is under
`src/sample/contract-fixture`; product code is not allowed to import it. The
visible starter routes do not depend on that fixture and continue to build
after it is removed.
### Platform capability review
The starter shell is implemented, but the repository review also records the
remaining work required before feature teams can use every declared contract
through one end-to-end application path:
- [platform capability review](docs/architecture/frontend-platform-capability-review.md)
- [ports, adapters, and feature boundaries](docs/architecture/frontend-ports-adapters-and-boundaries.md)
- [TypeScript, state ownership, and data flow](docs/architecture/typescript-state-and-data-flow.md)
- [routing, page templates, and reusable patterns](docs/architecture/routing-pages-and-patterns.md)
- [design-system platform](docs/styling/design-system-platform.md)
- [frontend platform testing strategy](docs/testing/frontend-platform-testing-strategy.md)
- [implementation roadmap](docs/architecture/frontend-platform-implementation-roadmap.md)
These documents distinguish repository defaults from opt-in adapters and
project-owned integrations. They are target designs and review findings; a
capability is not treated as implemented until its branch acceptance criteria
and executable gates pass.
## Verification
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
corepack pnpm test:a11y
corepack pnpm build
corepack pnpm check:bundle
corepack pnpm test:performance
corepack pnpm verify:compatibility
corepack pnpm verify:release
corepack pnpm check:registries
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:
```bash
corepack pnpm exec playwright install --with-deps chromium firefox webkit
```
Two gates intentionally need external evidence:
- `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review
for all six registered routes.
- `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum
eligible-sample threshold and 28 days of production data exist.
Live release verification additionally requires `HOSTING_BASE_URL`.
## CI and evidence
The 26-gate registry is `config/ci/gates.json`; the Gitea workflow is
`.gitea/workflows/quality-gates.yml`. It follows:
```text
MERGE_READY -> RELEASE_READY -> PROD_PROMOTION_READY -> FIELD_SLO_READY
```
`DOCUMENTATION_READY` is independent. No gate is downgraded to a warning.
Machine-readable evidence is written below `artifacts/`; generated evidence is
ignored by Git while `.gitkeep` files preserve the taxonomy.
Operational details are in `docs/operations/`, with incident procedures in
`docs/runbooks/`.
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
+18
View File
@@ -0,0 +1,18 @@
# APP_HOME accessibility review
Status: pending-manual-review
Route ID: APP_HOME
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Automated axe, keyboard-focus, and reduced-motion evidence is available; human review pending.
@@ -0,0 +1,18 @@
# EXAMPLES_AUTH accessibility review
Status: pending-manual-review
Route ID: EXAMPLES_AUTH
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Review session state announcements and unavailable external-integration behavior.
@@ -0,0 +1,18 @@
# EXAMPLES_STATES accessibility review
Status: pending-manual-review
Route ID: EXAMPLES_STATES
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Review loading, refresh, empty, error, authentication, forbidden, and not-found announcements.
@@ -0,0 +1,18 @@
# EXAMPLES_UI accessibility review
Status: pending-manual-review
Route ID: EXAMPLES_UI
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Review form primitives, Menu/Tabs keyboard behavior, Toast announcements, Tooltip supplemental copy, text-field error association and modal focus containment/restoration.
+18
View File
@@ -0,0 +1,18 @@
# NOT_FOUND accessibility review
Status: pending-manual-review
Route ID: NOT_FOUND
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
@@ -0,0 +1,18 @@
# REFERENCE_RESOURCE_DETAIL accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_DETAIL
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
@@ -0,0 +1,18 @@
# REFERENCE_RESOURCE_FORM accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_FORM
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
@@ -0,0 +1,18 @@
# REFERENCE_RESOURCE_LIST accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_LIST
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
@@ -0,0 +1,18 @@
# REFERENCE_RESOURCE_STATUS accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_STATUS
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
+376
View File
@@ -0,0 +1,376 @@
{
"schemaVersion": 1,
"providerAdapter": ".gitea/workflows/quality-gates.yml",
"stages": {
"merge": {
"readiness": "MERGE_READY",
"needs": null,
"gates": [
"FE-GATE-001",
"FE-GATE-002",
"FE-GATE-003",
"FE-GATE-004",
"FE-GATE-005",
"FE-GATE-006",
"FE-GATE-007",
"FE-GATE-008",
"FE-GATE-009",
"FE-GATE-010",
"FE-GATE-011",
"FE-GATE-013",
"FE-GATE-020"
]
},
"release": {
"readiness": "RELEASE_READY",
"needs": "merge",
"gates": [
"FE-GATE-012",
"FE-GATE-014",
"FE-GATE-015",
"FE-GATE-019",
"FE-GATE-026"
]
},
"production": {
"readiness": "PROD_PROMOTION_READY",
"needs": "release",
"gates": [
"FE-GATE-016",
"FE-GATE-021",
"FE-GATE-022",
"FE-GATE-023",
"FE-GATE-024",
"FE-GATE-025"
]
},
"field": {
"readiness": "FIELD_SLO_READY",
"needs": "production",
"gates": ["FE-GATE-018"]
},
"documentation": {
"readiness": "DOCUMENTATION_READY",
"needs": null,
"gates": ["FE-GATE-017"]
}
},
"gates": {
"FE-GATE-001": {
"name": "manifest-lockfile",
"steps": [{ "script": "verify:lockfile", "expect": "pass" }],
"logPath": "artifacts/quality/install.txt",
"evidence": ["artifacts/quality/install.txt"],
"retentionClass": "merge-cycle"
},
"FE-GATE-002": {
"name": "lint",
"steps": [{ "script": "lint", "expect": "pass" }],
"logPath": "artifacts/quality/lint.txt",
"evidence": ["artifacts/quality/lint.txt"],
"retentionClass": "merge-cycle"
},
"FE-GATE-003": {
"name": "typecheck",
"steps": [
{ "script": "check:types", "expect": "pass" },
{ "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" },
{ "script": "check:types:fixture:async-overlay", "expect": "fail" },
{ "script": "check:types:fixture:route-runtime", "expect": "fail" },
{ "script": "check:types:fixture:page-action", "expect": "fail" },
{ "script": "check:types:fixture:icon-button", "expect": "fail" }
],
"logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"],
"retentionClass": "merge-cycle"
},
"FE-GATE-004": {
"name": "runtime-schema",
"steps": [{ "script": "test:runtime-schema", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-004.txt",
"evidence": ["artifacts/tests/runtime-schema.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-005": {
"name": "unit",
"steps": [{ "script": "test:unit", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-005.txt",
"evidence": ["artifacts/tests/unit.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-006": {
"name": "component",
"steps": [{ "script": "test:component", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-006.txt",
"evidence": ["artifacts/tests/component.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-007": {
"name": "integration",
"steps": [
{ "script": "test:integration", "expect": "pass" },
{ "script": "test:reference-feature", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-007.txt",
"evidence": [
"artifacts/tests/integration.xml",
"artifacts/tests/reference-feature.xml"
],
"retentionClass": "merge-cycle"
},
"FE-GATE-008": {
"name": "e2e",
"steps": [{ "script": "test:e2e", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-008.txt",
"evidence": ["artifacts/tests/e2e/report/index.html"],
"retentionClass": "merge-cycle"
},
"FE-GATE-009": {
"name": "accessibility",
"steps": [
{ "script": "test:a11y", "expect": "pass" },
{ "script": "review:a11y-manual", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-009.txt",
"evidence": [
"artifacts/tests/a11y.json",
"artifacts/tests/a11y-manual/APP_HOME.md",
"artifacts/tests/a11y-manual/EXAMPLES_UI.md",
"artifacts/tests/a11y-manual/EXAMPLES_STATES.md",
"artifacts/tests/a11y-manual/EXAMPLES_AUTH.md",
"artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md",
"artifacts/tests/a11y-manual/NOT_FOUND.md",
"artifacts/tests/a11y-manual/report.json"
],
"retentionClass": "merge-cycle"
},
"FE-GATE-010": {
"name": "architecture",
"steps": [
{ "script": "check:architecture", "expect": "pass" },
{ "script": "check:design-system", "expect": "pass" },
{ "script": "check:design-system:fixture", "expect": "fail" },
{ "script": "check:registries", "expect": "pass" },
{ "script": "check:registries:fixture", "expect": "fail" },
{ "script": "check:routes:fixture", "expect": "fail" }
],
"logPath": "artifacts/quality/gates/FE-GATE-010.txt",
"evidence": [
"artifacts/quality/dependency-report.json",
"artifacts/quality/design-system.json",
"artifacts/quality/design-system-fixture.json",
"artifacts/quality/registries.json",
"artifacts/quality/registry-fixture.json",
"artifacts/quality/route-registry-fixture.json"
],
"retentionClass": "merge-cycle"
},
"FE-GATE-011": {
"name": "build",
"steps": [{ "script": "build", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-011.txt",
"evidence": [
"artifacts/release/build-manifest.json",
"artifacts/release/runtime-config.schema.json"
],
"retentionClass": "release-coherence"
},
"FE-GATE-012": {
"name": "bundle",
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "check:bundle", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-012.txt",
"evidence": ["artifacts/performance/bundle.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-013": {
"name": "security",
"steps": [
{ "script": "build:release", "expect": "pass" },
{ "script": "check:browser-security", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-013.txt",
"evidence": [
"artifacts/security/scan.sarif",
"artifacts/release/dependency-inventory.json",
"artifacts/security/dependency-diff.json"
],
"retentionClass": "release-coherence"
},
"FE-GATE-014": {
"name": "config-compatibility",
"steps": [{ "script": "verify:compatibility", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-014.txt",
"evidence": ["artifacts/release/compatibility.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-015": {
"name": "release-coherence",
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "verify:release", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-015.txt",
"evidence": ["artifacts/release/verification.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-016": {
"name": "rollback-drill",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-005"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-016.txt",
"evidence": [
"artifacts/runbooks/FE-RB-005/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-017": {
"name": "diagram-review",
"steps": [{ "script": "verify:documentation", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-017.txt",
"evidence": ["artifacts/quality/documentation-review.json"],
"retentionClass": "documentation"
},
"FE-GATE-018": {
"name": "field-web-vitals",
"requiresEnvironment": [
"FIELD_WEB_VITALS_INPUT",
"MIN_ELIGIBLE_SAMPLES"
],
"steps": [
{ "script": "collect:web-vitals-evidence", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-018.txt",
"evidence": ["artifacts/performance/field-web-vitals.json"],
"retentionClass": "field"
},
"FE-GATE-019": {
"name": "hosting-header",
"requiresEnvironment": ["HOSTING_BASE_URL"],
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "verify:hosting-headers", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-019.txt",
"evidence": ["artifacts/release/hosting-headers.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-020": {
"name": "reference-feature-removal",
"steps": [{ "script": "test:sample-removal", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-020.txt",
"evidence": ["artifacts/tests/sample-removal.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-021": {
"name": "runbook-boot-config",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-001"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-021.txt",
"evidence": [
"artifacts/runbooks/FE-RB-001/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-022": {
"name": "runbook-chunk-mismatch",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-002"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-022.txt",
"evidence": [
"artifacts/runbooks/FE-RB-002/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-023": {
"name": "runbook-api-degradation",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-003"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-023.txt",
"evidence": [
"artifacts/runbooks/FE-RB-003/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-024": {
"name": "runbook-telemetry",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-004"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-024.txt",
"evidence": [
"artifacts/runbooks/FE-RB-004/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-025": {
"name": "runbook-release-rollback",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-005"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-025.txt",
"evidence": [
"artifacts/runbooks/FE-RB-005/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-026": {
"name": "lab-performance",
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "test:performance", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-026.txt",
"evidence": ["artifacts/performance/lab.json"],
"retentionClass": "release-coherence"
}
},
"retention": {
"durationStatus": "UNSUPPORTED_PENDING_ORGANIZATION_POLICY",
"merge-cycle": "at least through pull-request readiness decision",
"release-coherence": "at least until the next release is promoted",
"prod-drill": "at least until the next production promotion decision",
"field": "through the 28-day window and aggregation",
"documentation": "through documentation readiness review"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"schemaVersion": 1,
"families": {
"api": {
"additive": {
"before": { "required": ["id"], "properties": { "id": {} } },
"after": {
"required": ["id"],
"properties": { "id": {}, "displayName": {} }
}
},
"breaking": {
"before": { "required": ["id"], "properties": { "id": {} } },
"after": {
"required": ["id", "name"],
"properties": { "id": {}, "name": {} }
}
}
},
"config": {
"additive": {
"before": { "required": ["APP_ENV"], "properties": { "APP_ENV": {} } },
"after": {
"required": ["APP_ENV"],
"properties": { "APP_ENV": {}, "OPTIONAL_FLAG": {} }
}
},
"breaking": {
"before": { "required": ["APP_ENV"], "properties": { "APP_ENV": {} } },
"after": {
"required": ["APP_ENV", "NEW_REQUIRED"],
"properties": { "APP_ENV": {}, "NEW_REQUIRED": {} }
}
}
},
"storage": {
"additive": {
"before": { "properties": { "theme": {} } },
"after": { "properties": { "theme": {}, "contrast": {} } }
},
"breaking": {
"before": { "properties": { "theme": {} } },
"after": { "properties": {} }
}
},
"release": {
"additive": {
"before": { "required": ["buildId"], "properties": { "buildId": {} } },
"after": {
"required": ["buildId"],
"properties": { "buildId": {}, "builtAt": {} }
}
},
"breaking": {
"before": { "required": ["buildId"], "properties": { "buildId": {} } },
"after": {
"required": ["buildId", "assetManifestHash"],
"properties": { "buildId": {}, "assetManifestHash": {} }
}
}
}
}
}
+190
View File
@@ -0,0 +1,190 @@
{
"schemaVersion": 1,
"registries": [
{
"registryId": "FE-REG-ROUTE",
"path": "src/features/installed-feature-contracts.js",
"exportName": "ROUTE_REGISTRY",
"owner": "feature-routing-navigation-guard-contract",
"uniqueFields": ["routeId", "path", "chunkId"],
"allowedValues": {
"paramsSchema": [null, "NotFoundSplat", "ReferenceResourceParams"],
"searchSchema": [null, "ReferenceResourceListQuery"],
"loadingSurface": [
"app-shell",
"example-page",
"reference-resource-list",
"reference-resource-detail",
"reference-resource-form",
"reference-resource-status",
"none"
],
"errorSurface": [
"route-boundary",
"feature-boundary",
"not-found"
],
"chunkId": [
"route-home",
"route-examples-ui",
"route-examples-states",
"route-examples-auth",
"route-reference-resources",
"route-reference-resource-detail",
"route-reference-resource-form",
"route-reference-resource-status",
"route-not-found"
]
},
"references": [
{
"field": "routeId",
"registryId": "FE-REG-ROUTE-RUNTIME",
"targetField": "routeId"
}
],
"requiredFields": [
"routeId",
"path",
"paramsSchema",
"searchSchema",
"access",
"loadingSurface",
"errorSurface",
"chunkId"
]
},
{
"registryId": "FE-REG-ROUTE-RUNTIME",
"path": "src/features/installed-feature-contracts.js",
"exportName": "ROUTE_RUNTIME_CONTRACT",
"owner": "feature-frontend-routing-release-recovery-runtime",
"requiredFields": [
"routeId",
"moduleId",
"paramsCodec",
"searchCodec"
],
"uniqueFields": ["routeId", "moduleId"],
"allowedValues": {
"moduleId": [
"home-page",
"ui-gallery-page",
"state-gallery-page",
"auth-example-page",
"reference-resource-page",
"reference-resource-detail-page",
"reference-resource-form-page",
"reference-resource-status-page",
"not-found-page"
],
"paramsCodec": ["none", "NotFoundSplat", "ReferenceResourceParams"],
"searchCodec": ["none", "ReferenceResourceListQuery"]
},
"references": [
{
"field": "routeId",
"registryId": "FE-REG-ROUTE",
"targetField": "routeId"
}
]
},
{
"registryId": "FE-REG-API",
"path": "src/features/installed-feature-contracts.js",
"exportName": "API_OPERATIONS",
"owner": "feature-api-client-response-envelope-contract",
"requiredFields": [
"method",
"path",
"operationId",
"auth",
"timeoutMs",
"idempotency",
"retry",
"requestSource",
"requestSchema",
"responseSchema",
"owner"
]
},
{
"registryId": "FE-REG-ENV",
"path": "src/contracts/env.js",
"exportName": "ENV_REGISTRY",
"owner": "feature-frontend-env-runtime-config-contract",
"requiredFields": ["phase", "classification", "required", "defaultValue"]
},
{
"registryId": "FE-REG-STORAGE",
"path": "src/contracts/storage-keys.js",
"exportName": "STORAGE_REGISTRY",
"owner": "feature-frontend-storage-registry-contract",
"requiredFields": [
"logicalName",
"physicalKey",
"backend",
"classification",
"schemaVersion",
"ttl",
"migration",
"quotaFallback"
]
},
{
"registryId": "FE-REG-ERROR",
"path": "src/contracts/errors.js",
"exportName": "ERROR_REGISTRY",
"owner": "feature-frontend-error-classification-boundary-contract",
"requiredFields": [
"kind",
"defaultRetryable",
"severity",
"userMessageKey",
"action",
"telemetryEvent",
"redaction"
]
},
{
"registryId": "FE-REG-QUERY",
"path": "src/features/installed-feature-contracts.js",
"exportName": "QUERY_REGISTRY",
"owner": "feature-server-state-caching-contract",
"requiredFields": [
"namespace",
"serialization",
"identity",
"invalidation",
"version",
"persistence"
]
},
{
"registryId": "FE-REG-TELEMETRY",
"path": "src/contracts/telemetry.js",
"exportName": "TELEMETRY_REGISTRY",
"owner": "feature-frontend-observability-logging-trace-contract",
"requiredFields": [
"eventName",
"trigger",
"requiredAttributes",
"optionalAttributes",
"forbiddenAttributes",
"sampling",
"delivery"
]
},
{
"registryId": "FE-REG-RELEASE",
"path": "src/contracts/release-tokens.js",
"exportName": "RELEASE_TOKEN_REGISTRY",
"owner": "feature-frontend-release-cache-rollback-contract",
"requiredFields": ["token", "source", "compatibilityRole"]
}
],
"compatibilityImpact": {
"allowed": ["none", "additive", "behavior-change", "breaking"],
"current": "behavior-change"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"schemaVersion": 1,
"surfaces": {
"index": {
"path": "/",
"cacheControl": "no-cache",
"contentTypes": ["text/html"],
"securityHeaders": true
},
"runtimeConfig": {
"path": "/config.json",
"cacheControl": "no-store",
"contentTypes": ["application/json"],
"securityHeaders": true
},
"releaseManifest": {
"path": "/release-manifest.json",
"cacheControl": "no-store",
"contentTypes": ["application/json"],
"securityHeaders": true
},
"hashedAsset": {
"pathPattern": "/assets/*",
"cacheControl": "public, max-age=31536000, immutable",
"contentTypes": ["text/javascript", "application/javascript"],
"securityHeaders": false
},
"sourceMap": {
"public": false
},
"serviceWorker": {
"enabled": false
}
}
}
@@ -0,0 +1,39 @@
{
"schemaVersion": 1,
"responses": {
"index": {
"cache-control": "no-cache",
"content-type": "text/html; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY",
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
"permissions-policy": "camera=(), microphone=(), geolocation=()"
},
"runtimeConfig": {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY",
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
"permissions-policy": "camera=(), microphone=(), geolocation=()"
},
"releaseManifest": {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY",
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
"permissions-policy": "camera=(), microphone=(), geolocation=()"
},
"hashedAsset": {
"cache-control": "public, max-age=31536000, immutable",
"content-type": "text/javascript; charset=utf-8"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"headers": {
"Content-Security-Policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Content-Type-Options": "nosniff",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"bundle": {
"initialJsGzipBytes": 204800,
"lazyChunkGzipBytes": 122880
},
"lab": {
"lcpMs": 2500,
"cls": 0.1,
"namedInteractionMs": 200
},
"field": {
"p75LcpMs": 2500,
"p75Cls": 0.1,
"p75InpMs": 200,
"minimumEligibleSamples": null
}
}
@@ -0,0 +1,25 @@
{
"schemaVersion": 1,
"releaseId": "local-release",
"environment": "replace-with-production",
"source": {
"system": "",
"exportId": ""
},
"privacy": {
"approved": false,
"approvalRef": ""
},
"window": {
"start": "2026-06-01T00:00:00Z",
"end": "2026-06-29T00:00:00Z"
},
"thresholdDecision": {
"status": "pending",
"minimumEligibleSamples": null,
"owner": "",
"reviewedAt": "",
"evidenceRef": ""
},
"samples": []
}
+77
View File
@@ -0,0 +1,77 @@
{
"schemaVersion": 1,
"fixtures": [
{
"name": "coherent-release",
"expectedCompatible": true,
"frontend": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
},
"runtime": {
"buildId": "build-a",
"configSchemaVersion": "1.1",
"apiContractVersion": "1.2",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
}
},
{
"name": "mixed-html-and-assets",
"expectedCompatible": false,
"frontend": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
},
"runtime": {
"buildId": "build-b",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-b",
"releaseId": "release-b"
}
},
{
"name": "incompatible-runtime-config",
"expectedCompatible": false,
"frontend": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
},
"runtime": {
"buildId": "build-a",
"configSchemaVersion": "2.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
}
},
{
"name": "incompatible-api-contract",
"expectedCompatible": false,
"frontend": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
},
"runtime": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "2.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
}
}
]
}
+93
View File
@@ -0,0 +1,93 @@
{
"schemaVersion": 1,
"runbooks": {
"FE-RB-001": {
"title": "Boot configuration failure",
"gateId": "FE-GATE-021",
"triggerKinds": ["BOOT_CONFIG_FAILURE"],
"containment": "stop product route mount, show the safe support shell, and refetch at most once",
"window": "owner triage planned-default 5m",
"escalation": ["env-config owner", "release owner"],
"recoveryEvidence": [
"clean-session boot",
"product root mount",
"config validation",
"no repeated boot error"
],
"negativeFixture": "a valid config followed by an injected mount failure must fail recovery"
},
"FE-RB-002": {
"title": "Chunk, manifest, or deployment mismatch",
"gateId": "FE-GATE-022",
"triggerKinds": [
"CHUNK_LOAD_FAILURE",
"RELEASE_MANIFEST_FAILURE",
"DEPLOY_MISMATCH"
],
"containment": "warn for dirty state, fetch manifest no-store once, and allow one guarded reload",
"window": "release owner triage planned-default 5m",
"escalation": ["release-cache owner", "hosting/CDN owner"],
"recoveryEvidence": [
"entry and lazy assets reachable",
"release tuple coherent",
"second reload blocked",
"critical route smoke"
],
"negativeFixture": "a second failure for the same release pair must not reload"
},
"FE-RB-003": {
"title": "Backend API degradation",
"gateId": "FE-GATE-023",
"triggerKinds": [
"TERMINAL_NETWORK_RATE",
"REQUEST_TIMEOUT_RATE",
"SERVER_FAILURE_RATE",
"SCHEMA_MISMATCH"
],
"containment": "do not expand retry caps, serve safe stale reads, and never retry an unkeyed mutation",
"window": "rolling 5m trigger; first classification planned-default 10m",
"escalation": [
"api-client owner",
"backend operation owner",
"release compatibility owner"
],
"recoveryEvidence": [
"terminal failure rate at baseline",
"no retry amplification",
"critical read/write smoke",
"schema fixtures"
],
"negativeFixture": "an unkeyed POST receiving 503 must not retry"
},
"FE-RB-004": {
"title": "Telemetry sink failure",
"gateId": "FE-GATE-024",
"triggerKinds": ["TELEMETRY_FAILURE"],
"containment": "keep product flow available, bound the queue, and never report recursively to the failing sink",
"window": "platform triage planned-default 15m",
"escalation": ["observability owner", "telemetry platform owner"],
"recoveryEvidence": [
"product flow unaffected",
"delivery self-check",
"queue drained within bound",
"forbidden attributes absent"
],
"negativeFixture": "raw URL and query data must be removed from telemetry"
},
"FE-RB-005": {
"title": "Coherent release rollback",
"gateId": "FE-GATE-025",
"triggerKinds": ["RELEASE_BLOCKING_DEFECT"],
"containment": "select a prior immutable tuple, verify asset/config/API compatibility, atomically switch, and smoke",
"window": "provider recovery target TBD",
"escalation": ["release-cache owner", "release approver/hosting owner"],
"recoveryEvidence": [
"compatibility gate",
"release coherence gate",
"critical smoke",
"release ID in incident timeline"
],
"negativeFixture": "HTML build A with asset manifest B must be rejected"
}
}
}
@@ -0,0 +1,78 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ART-FE-FIELD-WEB-VITALS@1",
"type": "object",
"required": [
"schemaVersion",
"generatedAt",
"window",
"context",
"metrics",
"thresholds",
"eligibility",
"status",
"passed"
],
"properties": {
"schemaVersion": { "const": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"window": { "type": "object", "required": ["days", "start", "end"] },
"context": {
"type": "object",
"required": [
"source",
"sourceSystem",
"exportId",
"network",
"routeAggregation",
"releaseId",
"privacyApprovalRef",
"thresholdDecisionRef",
"validationFailures"
],
"properties": {
"source": { "type": "string" },
"sourceSystem": { "type": ["string", "null"] },
"exportId": { "type": ["string", "null"] },
"network": { "const": "production-real-user" },
"routeAggregation": { "const": "route-id-only" },
"releaseId": { "type": ["string", "null"] },
"privacyApprovalRef": { "type": ["string", "null"] },
"thresholdDecisionRef": { "type": ["string", "null"] },
"validationFailures": {
"type": "array",
"items": { "type": "string" }
}
},
"additionalProperties": false
},
"thresholds": {
"type": "object",
"required": [
"p75LcpMs",
"p75Cls",
"p75InpMs",
"minimumEligibleSamples"
]
},
"metrics": {
"type": "object",
"required": ["p75LcpMs", "p75Cls", "p75InpMs"]
},
"eligibility": {
"type": "object",
"required": [
"consentRequired",
"totalSamples",
"eligibleSamples",
"minimumEligibleSamples",
"routeSamples"
]
},
"status": {
"enum": ["PASS", "FAIL_THRESHOLD", "FAIL_UNVERIFIED"]
},
"passed": { "type": "boolean" }
},
"additionalProperties": false
}
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ART-FE-LAB@1",
"type": "object",
"required": [
"schemaVersion",
"generatedAt",
"context",
"metrics",
"thresholds",
"fixtures",
"passed"
],
"properties": {
"schemaVersion": { "const": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"context": {
"type": "object",
"required": ["runner", "browser", "viewport", "network", "cpu", "cache", "build"]
},
"metrics": {
"type": "object",
"required": ["lcpMs", "cls", "namedInteractionMs"]
},
"thresholds": { "type": "object" },
"fixtures": { "type": "array", "minItems": 2 },
"passed": { "type": "boolean" }
},
"additionalProperties": false
}
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ART-FE-003@1",
"type": "object",
"required": ["schemaVersion", "generatedAt", "artifact", "fixtures", "passed"],
"properties": {
"schemaVersion": { "const": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"artifact": {
"type": "object",
"required": ["checked", "compatible", "mismatches"]
},
"fixtures": { "type": "array", "minItems": 2 },
"passed": { "type": "boolean" }
},
"additionalProperties": false
}
@@ -0,0 +1,42 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ART-FE-RUNBOOK-DRILL@1",
"type": "object",
"required": [
"schemaVersion",
"runbookId",
"releaseId",
"drillTimestamp",
"triggerInjected",
"triggerAsserted",
"containmentAsserted",
"escalationPathAsserted",
"recoveryAssertions",
"negativeFixtureFailedAsExpected",
"windowObservedBucket",
"passed"
],
"properties": {
"schemaVersion": { "const": 1 },
"runbookId": { "pattern": "^FE-RB-00[1-5]$" },
"releaseId": { "type": "string", "minLength": 1 },
"drillTimestamp": { "type": "string", "format": "date-time" },
"triggerInjected": { "type": "string" },
"triggerAsserted": { "type": "boolean" },
"containmentAsserted": { "type": "boolean" },
"escalationPathAsserted": { "type": "boolean" },
"recoveryAssertions": {
"type": "array",
"minItems": 4,
"items": {
"type": "object",
"required": ["assertion", "evidence", "passed"]
}
},
"negativeFixtureFailedAsExpected": { "type": "boolean" },
"windowObservedBucket": { "type": "string" },
"providerVerificationRequired": { "type": "boolean" },
"passed": { "type": "boolean" }
},
"additionalProperties": false
}
+51
View File
@@ -0,0 +1,51 @@
# Manual accessibility review checklist
Automated axe checks do not establish WCAG conformance. A human reviewer must
review all six route records in `artifacts/tests/a11y-manual/` against one
release candidate and sign them. The required scope is derived from the route
registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`,
`REFERENCE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the
gate rejects blank identity/timestamp/signature fields, pending verdicts,
mismatched release IDs, or missing routes.
Allowed item verdicts:
- `pass`
- `not-applicable (<specific reason>)`
Required record:
```text
Status: reviewed
Route ID: APP_HOME
Release ID: <immutable release ID>
Reviewer: <human reviewer identity>
Reviewed at: <RFC 3339 timestamp>
Signature: <reviewer identity or approved signature reference>
Attestation: accepted
M1 Keyboard: pass
M2 Visible focus: pass
M3 Route focus: pass
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pass
M7 Reduced motion: pass
Screen reader: pass
Notes: <observations and linked defect IDs>
```
The reviewer must verify:
- M1: every action works without a pointing device
- M2: every focused element has a visible indicator
- M3: route transitions move focus to a deterministic target
- M4: modal focus is trapped and restored, when a modal exists
- M5: errors are programmatically associated with their controls, when present
- M6: state never relies on color alone
- M7: non-essential motion is suppressed with reduced-motion preference
- Screen reader: headings, live regions, errors, and actions are announced once
`EXAMPLES_UI` requires real M4 modal and M5 field-error review; those items must
not be marked not-applicable on that route. Passing automated evidence means
only that tested pages had no critical or serious axe findings under the
recorded Chromium, Firefox, and WebKit runs.
@@ -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
방법이 아니다.
@@ -0,0 +1,58 @@
# VD-03: React Router Data Mode와 서버 상태 소유권
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-routing-release-recovery-runtime`
## 배경
기존 라우터는 `BrowserRouter`와 수동 JSX route 목록을 사용했다. 직렬화 가능한
route registry에 params/search schema, loading/error surface, access, title,
navigation과 chunk ID가 있었지만 실행 route tree와 독립적이어서 선언과 행동이
어긋날 수 있었다.
이 저장소는 client-only SPA이며 서버 상태는 application input과 TanStack Query가
소유한다. Framework Mode의 loader/action 중심 데이터 소유권이나 SSR을 도입하지
않으면서 route object, 오류 경계와 navigation lifecycle은 중앙에서 조립할
필요가 있다.
## 결정
1. 고정된 React Router `7.18.1``createBrowserRouter``RouterProvider`
사용하는 Data Mode를 기본값으로 채택한다.
2. 직렬화 가능한 route contract와 React component/codec runtime map을 분리한다.
3. 모든 executable route object와 navigation은 registry에서 생성한다. JSX에서
route 목록을 다시 열거하지 않는다.
4. params/search는 route 경계의 Zod codec으로 parse하고 같은 codec으로 canonical
URL을 생성한다.
5. loader/action은 같은 서버 데이터를 직접 다시 요청하지 않는다. 필요하면
application input 또는 query adapter 한 경로를 호출한다.
6. 서버 상태, retry, cache와 mutation lifecycle은 application input과 TanStack
Query가 계속 소유한다.
7. lazy chunk rejection만 release recovery input으로 보내며 일반 render error는
route/feature boundary가 소유한다.
8. Framework Mode, SSR, static generation과 router version upgrade는 별도
dependency/architecture 브랜치에서 결정한다.
## 검증
- route contract/runtime map의 누락과 orphan은 TypeScript negative fixture와
registry gate가 모두 거절한다.
- duplicate ID/path, unknown codec/surface/chunk와 참조 불일치를 negative registry
fixture로 검증한다.
- params/search parse/build round-trip, canonical redirect, 최대 redirect hop,
access rejection, title/focus와 boundary reset을 unit/component test로 검증한다.
- Vite dynamic entry와 release route chunk map, runtime config JSON Schema를
build/release 검증기가 확인한다.
- chunk failure는 no-store manifest refetch 후 build/release 쌍마다 한 번만
reload하며 offline, malformed manifest와 storage 실패는 fail-closed한다.
## 결과와 rollback
Data Router는 navigation lifecycle의 조립 경계이며 서버 데이터 계층이 아니다.
이 구분을 지키면 React Router를 교체해도 application input과 output port는
유지된다.
rollback은 RP-04 merge를 되돌려 이전 수동 router와 generic route failure
surface로 복구한다. URL shape와 application API는 유지하고, 이미 배포된 asset
cache의 purge는 저장소 rollback 범위에 포함하지 않는다.
@@ -0,0 +1,55 @@
# VD-04: Native form controller와 local facade
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-form-page-platform`
## 배경
플랫폼에는 Zod가 이미 설치돼 있지만 form state, field error, dirty navigation과
page template 계약은 없었다. React Hook Form과 resolver를 바로 추가하면
dependency와 lockfile이 바뀌고, 현재 reference form에 필요하지 않은 복합 비동기
field orchestration까지 플랫폼 기본값으로 고정하게 된다.
## 결정
1. RP-06은 React native form event와 controlled value를 사용하는 local
`useAppForm` facade를 기본 엔진으로 채택한다.
2. Zod presentation schema, application command mapper와 domain invariant는 서로
다른 소유물로 유지한다.
3. page와 feature는 `useAppForm`, `Form`, `FormField`, `ErrorSummary`,
`mapValidationFailureToFields`, `useDirtyNavigationGuard`만 사용한다.
4. 422 details는 승인된 `path``code`만 HTTP 경계에서 투영한다. backend
message와 알 수 없는 field는 field에 전달하지 않고 안전한 form-level
error로 이동한다.
5. 409 conflict는 validation으로 바꾸지 않으며 입력과 dirty 상태를 보존한다.
6. pending submit은 동일 controller에서 한 번만 실행하고 success/reset 이후
dirty 상태를 해제한다.
7. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage`, `StatusPage`
layout과 state slot만 소유하며 application/query/HTTP를 import하지 않는다.
## React Hook Form 도입 조건
다음 중 하나가 실제 제품 요구로 확인되면 local facade 내부 adapter로
React Hook Form과 Zod resolver를 평가한다.
- 동적 field array와 중첩 object를 함께 다루는 복합 form
- field 단위 비동기 validation 취소와 의존 validation
- 수백 개 field의 render isolation이 측정 가능한 병목인 경우
- uncontrolled input 또는 vendor extension이 필요한 경우
도입하더라도 이 문서의 public API와 component/application tests를 유지해야
한다. vendor package를 feature/page에서 직접 import하는 것은 허용하지 않는다.
## 검증과 rollback
- client validation, transform/default, 422 allowlist, conflict, duplicate submit,
reset, dirty guard와 focus를 component test로 검증한다.
- template 최소/전체 slot과 async/status variation을 component test로 검증한다.
- architecture gate가 template의 application/HTTP/query vendor import를
거절한다.
- secret-like input이 URL, storage, diagnostics에 복제되지 않는지 검증한다.
rollback 시 reference page는 이전 직접 form/layout으로 돌아갈 수 있다.
application input과 outbound gateway 계약은 유지되며, form facade와 template
commit은 독립적으로 되돌릴 수 있다.
@@ -0,0 +1,57 @@
# VD-05: Semantic icon facade와 native-first interaction
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-design-system-platform`
- 재검토: native 계약으로 충족할 수 없는 widget 요구가 확인될 때
## 배경
앱 셸과 공통 UI는 문자 glyph, raw button/select와 페이지별 focus 처리를
사용했다. 아이콘 공급자와 복합 interaction을 제품 코드에 직접 노출하면 번들,
접근성, vendor type과 교체 비용이 모든 feature로 전파된다. 반대로 실제 요구가
없는 두 개의 headless vendor를 기본 설치하면 skeleton 소비자가 제거해야 할
의존성과 중복 interaction 모델이 생긴다.
## 결정
1. 아이콘 공급자는 lockfile 최소 게시 유예를 통과한 `lucide-react@1.25.0`으로
고정한다.
2. `lucide-react`의 static named import는
`design-system/icons/vendors/lucide.tsx` 한 파일에서만 허용한다.
3. public API는 `MenuIcon`, `CloseIcon`, `WarningIcon` 같은 의미 이름만
노출한다. vendor component type, icon name, stroke API와 dynamic icon
registry는 노출하지 않는다.
4. 장식 아이콘은 accessibility tree에서 제외한다. 정보를 단독 전달하는
아이콘은 `label`, icon-only action은 필수 `accessibleName`을 사용한다.
5. 현재 복합 control은 native `dialog`, form control, `details`와 local
TypeScript state model로 구현한다. Menu는 roving focus/typeahead/Escape,
Tabs는 manual/automatic activation, Drawer는 modal/background
비활성화/focus restore 계약을 가진다.
6. React Aria와 Radix는 기본 dependency로 추가하지 않는다. native platform이
collision, nested overlay, virtualized collection 또는 복합 select 요구를
충족하지 못한다는 재현 가능한 요구가 생길 때 prototype과 ADR로 다시
평가한다.
7. Storybook과 pinned visual baseline은 VD-08/RP-10에서 도입한다. RP-07의
runtime gallery와 browser interaction test는 해당 workshop을 대체한다고
주장하지 않는다.
## 경계와 검증
- 제품 코드는 `presentation/design-system/index`만 import한다.
- design-system 검사기는 direct icon/headless import, deep import, raw palette,
undefined token과 tooltip-only required information fixture를 거절한다.
- type negative fixture는 accessible name 없는 `IconButton`을 거절한다.
- component test는 decorative icon, form control, Menu, Tabs, Drawer와 Toast를
검증한다.
- Chromium/Firefox E2E는 compact Drawer의 native modal 상태, Escape, focus
restore, gallery keyboard interaction과 axe를 검증한다.
- 로컬 WebKit 실행은 host `libevent-2.1.so.7` 부재로 환경 검증이 남아 있으며
공급자 선택이나 product behavior의 PASS로 숨기지 않는다.
## Rollback
기존 `presentation/components/ui/*` 경로는 canonical TypeScript primitive를
재수출하므로 소비 코드를 즉시 되돌릴 수 있다. Lucide 제거 시 vendor facade와
semantic icon 구현만 교체하고 제품 API는 유지한다. headless vendor를 나중에
도입해도 public props와 interaction test를 유지한다.
@@ -0,0 +1,452 @@
# 프론트엔드 플랫폼 역량 재검토
## 1. 문서 목적
이 문서는 도메인 기능과 실제 운영 환경의 배포 증적을 제외하고, 이 저장소가 새
프론트엔드 제품의 출발점으로 제공해야 하는 공통 역량을 다시 평가한다. 평가
기준은 다음과 같다.
- 코드나 설정 파일이 존재하는지만 보지 않는다.
- 부트스트랩부터 화면까지 실제 호출 경로가 연결되는지 확인한다.
- 선언한 레지스트리와 정책이 런타임 및 CI에서 집행되는지 확인한다.
- 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다.
- 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다.
최초 검토 기준은 `develop``cb195f8`이며, RP-01~RP-04 구현 결과를 이 문서에
누적 반영했다. 이후 구현으로 경로나 세부 내용이 달라질 수 있으므로, 각 항목은
문서의 경로뿐 아니라 해당 테스트와 아키텍처 게이트로 계속 검증해야 한다.
## 2. 결론
현재 저장소는 다음 기반이 강하다.
- 런타임 설정과 릴리스 매니페스트 검증
- 도메인, 애플리케이션, 프레젠테이션, outbound adapter의 의존 방향
- 공통 HTTP 실패 형태와 제한된 retry 정책
- 앱 셸, 반응형 내비게이션, 테마, 비동기 상태 표면
- Vitest, Testing Library, MSW, Playwright, axe를 이용한 테스트 계층
- CI 게이트 taxonomy와 호환성·보안·성능·릴리스 계약 문서
그러나 “도메인 기능을 바로 추가할 수 있는 프론트엔드 플랫폼” 기준으로는 아직
중요한 연결부가 빠져 있다. 가장 큰 문제는 공통 기능이 없다는 것보다 이미 있는
기능이 실제 기능 화면의 표준 호출 경로로 조립되지 않았다는 점이다.
특히 다음은 선행 해결이 필요하다.
RP-01~RP-05에서 TypeScript 도구 안전망, application runtime 주입,
query/mutation inbound adapter, HTTP 실행 계약과 executable route/release
recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다. 현재 선행 해결
대상은 다음과 같다.
1. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치
2. 국제화, diagnostics, optional adapter recipe와 심화 품질 게이트
따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다.
더 정확한 표현은 다음과 같다.
> 운영·안전 계약과 범용 앱 셸은 갖춰졌지만, 기능 개발자가 사용하는 application
> API, 서버 상태, 폼, 라우팅, 페이지 패턴의 표준 수직 경로는 아직 보강이
> 필요하다.
## 3. 판정 기준
| 판정 | 의미 |
| --- | --- |
| 준비됨 | 구현, 실제 조립, 자동 검증이 모두 존재한다. |
| 부분 준비 | 핵심 구현은 있으나 실제 호출 경로, 정책 집행, 예제가 불완전하다. |
| 미제공 | 새 기능을 만들 때 팀이 직접 선택·설계해야 한다. |
| 프로젝트 선택 | 기본 번들에 강제하면 비용이 더 크며, 경계와 recipe만 제공한다. |
## 4. 역량 매트릭스
| 영역 | 현재 판정 | 근거 | 필요한 다음 상태 |
| --- | --- | --- | --- |
| 부트·런타임 설정 | 준비됨 | `src/bootstrap`, runtime schema, release 검사 | 현 상태 유지, TS 전환 시 동일 게이트 유지 |
| 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 |
| application facade | 준비됨 | typed input/output catalog, provider, production composition test | feature input use case를 contribution으로 확장 |
| HTTP client | 준비됨 | path/search/body projection, runtime timeout/retry, abort/cleanup test | feature gateway 뒤에서 사용 |
| retry | 준비됨 | HTTP 단일 소유, runtime max attempts, Query retry off | RP-09에서 telemetry 연결 |
| 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper |
| 검증 | 준비됨 | runtime/API/route/form Zod parse 결과를 실행 경계에서 사용하고 domain invariant와 분리 | feature별 schema 소유권 유지 |
| 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 |
| 서버 상태 | 준비됨 | reference route의 query/mutation, cancellation, stale, optimistic/conflict/rollback | feature별 query contribution recipe 유지 |
| 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 |
| 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 |
| 라우팅 | 준비됨 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 |
| 앱 셸·반응형 | 준비됨 | native modal Drawer, compact/desktop layout, Escape/link dismiss와 focus restore | RP-08에서 RTL/direction 검증 |
| 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status와 public design-system entry | feature별 slot 조합 유지 |
| 디자인 토큰 | 준비됨 | primitive/semantic/component CSS, 48-token 자동 계약, dark/forced-colors/reduced-motion | 제품 brand token은 외부 프로젝트에서 확장 |
| 공통 UI | 준비됨 | action/form/feedback/overlay/navigation primitive와 pattern, compatibility export | Storybook/visual은 RP-10 |
| 아이콘 | 준비됨 | Lucide static vendor facade와 semantic icon/IconButton 접근성 계약 | 의미 icon 추가 시 bundle/접근성 기준 적용 |
| 폼 | 준비됨 | Zod 기반 local facade, error summary/focus, 422 allowlist, dirty/pending/conflict 정책 | 복합 form 요구가 생기면 VD-04 조건으로 vendor adapter 평가 |
| 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 |
| logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 |
| telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 |
| 비동기 상태 불변식 | 준비됨 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 |
| 단위·통합·E2E | 준비됨 | Vitest, RTL, MSW, Playwright 3엔진 | TS 테스트 검사, 실제 bootstrap 통합, 위험 시나리오 보강 |
| UI 회귀 검증 | 미제공 | axe/reflow는 있으나 visual baseline 없음 | Storybook 또는 동급 workshop과 시각 회귀 |
| 샘플 제거 | 준비됨 | feature/catalog/test 제거 후 type/architecture/registry/test/home/build 8단계 검증 | 새 contribution도 같은 제거 gate에 포함 |
| registry·compatibility 집행 | 부분 준비 | registry와 gate는 있으나 실제 before/after 및 orphan 검사가 제한적 | type/reference/orphan/diff/migration을 자동 검증 |
| 공급망 검사 | 부분 준비 | lockfile·문서·gate는 있으나 실제 transitive 취약점/license/SBOM 깊이가 부족 | pinned scanner와 policy exception/증적 연결 |
| realtime·offline·file 등 | 프로젝트 선택 | 현재 없음 | port/adapter recipe와 선택 기준 제공 |
## 5. 우선순위별 발견 사항
### 5.1 P0: 기능 개발을 막는 항목
#### RP-02에서 application 런타임 우회 해결
`src/bootstrap/composition-root.js`가 만든 typed application input API는
production `ApplicationProvider`에 주입된다. raw auth, storage, telemetry와
release port는 closure 안에 남고 UI는 session, preference, diagnostics와 runtime
query만 사용한다.
목표 상태:
- `Application`은 UI가 호출할 query/command use case를 제공한다.
- `ApplicationProvider`는 이 API만 React tree에 제공한다.
- 페이지는 HTTP, storage, auth SDK, telemetry sink를 직접 호출하지 않는다.
- bootstrap만 concrete outbound adapter를 알고 조합한다.
- 실제 bootstrap부터 reference page까지 연결한 통합 테스트가 있다.
#### RP-03에서 표준 서버 상태 bridge 구현
`src/presentation/adapters/query` 한 경계만 `@tanstack/**`를 import한다.
`useApplicationQuery``useApplicationMutation`은 application result를 React
lifecycle에 연결하며 cancellation, stale failure, duplicate submit, optimistic
rollback, conflict resolution과 invalidation을 검증한다. 다른 presentation
경로의 직접 TanStack import는 negative fixture가 거절한다.
목표 상태:
- canonical target인 `src/adapters/inbound/react/platform/query`에 벤더 연동을
한정한다. 마이그레이션 중에는 기존 `presentation`을 같은 inbound 경계로
취급하되 새 대체 경로를 만들지 않는다.
- `useApplicationQuery`, `useApplicationMutation` 또는 같은 역할의 typed
controller hook을 제공한다.
- HTTP retry와 query retry 중 한 계층만 재시도 책임을 갖는다.
- loading, empty, refreshing, stale, offline, error, conflict, optimistic rollback을
reference feature에서 보여 준다.
#### RP-01에서 TypeScript 검사 도구 안전망 구현
현재 source는 모두 JS/JSX이고 `strict + allowJs + checkJs`를 사용한다. 이는 좋은
중간 안전망이지만 다음 도구는 TS migration을 그대로 따라가지 못한다.
- ESLint의 계층·보안 glob은 JS/JSX 중심이다.
- registry scanner는 `.ts``.tsx`를 찾지 않는다.
- registry governance 경로가 `.js` 확장자로 고정되어 있다.
- tests는 현재 `tsconfig.json` 검사 범위에서 빠진다.
따라서 파일 확장자를 먼저 바꾸면 새 TS 코드가 일부 자동 검사에서 빠질 수 있다.
TypeScript 전환은
[TypeScript의 JavaScript migration 가이드](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html)
처럼 점진적으로 진행하되, 이 저장소에서는 tooling glob과 CI를 먼저 고쳐야 한다.
#### RP-03에서 HTTP 선언과 실행의 차이 해결
HTTP request builder는 path segment escaping, canonical optional/array search,
Zod default/trim 결과의 실제 query/body 전송을 담당한다. runtime timeout과
0/1/N max retry가 client factory에 주입되고 caller abort와 timeout을 다른 typed
failure로 투영한다. validation 조기 반환은 fetch/timer 0회이며 success, schema
failure, abort, timeout과 exhausted retry는 scheduler/listener cleanup을
검증한다. HTTP 사건의 semantic telemetry 연결은 RP-09 범위다.
client를 거대한 범용 함수로 계속 확장하지 말고 transport, request builder, auth,
timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 범용 HTTP
메서드보다 feature가 요구하는 gateway interface를 노출한다.
#### RP-04에서 route registry를 실행 계약으로 전환
platform route 계약과 `src/features/installed-feature-contracts.js`의 직렬화
가능한 contribution을 기준으로
`src/presentation/routes/app-router.tsx`가 Data Router route object와
navigation을 생성한다. `route-runtime.tsx`는 lazy component의 실행 map만
소유하며 contract/runtime 누락과 orphan은 TypeScript negative fixture와 registry
gate가 모두 거절한다.
현재 보장:
- serializable contract와 executable runtime map을 분리한다.
- `satisfies Record<RouteId, RouteRuntime>`로 양방향 완전성을 검사한다.
- params/search는 Zod codec으로 경계에서 parse하고 URL builder도 같은 codec을
사용한다.
- loading/error/chunk/access/title/navigation metadata를 실제 route object에
연결한다.
- route change 시 boundary reset, title, focus와 scroll을 검증한다.
- Vite manifest의 실제 dynamic entry와 route chunk ID를 release manifest에
연결하고, no-store manifest 재조회와 build/release 쌍별 1회 reload를
production application input까지 연결한다.
#### RP-05에서 제거 가능한 reference feature 구현
`src/features/reference-feature`가 domain, application input, outbound gateway,
DTO/schema, mapper, route/API/query contract, query/mutation controller와 page를
한 소유 경계에 둔다. production composition은 generic feature input catalog를
통해 이 input을 주입하며 UI는 HTTP나 output port를 직접 보지 않는다.
`test:sample-removal`은 임시 복제본에서 feature source/tests를 삭제하고 installed
contract/runtime/adapter catalog를 빈 목록으로 재생성한다. 그 뒤 typecheck,
architecture, registry, unit/integration, home smoke, build와 fixture ID 잔여
0개를 검사한다. 설치 모드에서는 MSW를 사용한 bootstrap → router → application
→ HTTP → schema → mapper → query cache → page 수직 테스트가 실행된다.
#### 비동기·복구 상태의 불변식이 닫혀 있지 않다
공통 async model과 gallery가 있지만 선언 가능한 상태 조합 중 일부는 사용자
행동과 모순될 수 있다. stale data가 있는 degraded 상태와 refreshing, mutation
pending과 conflict, retry button과 실제 handler 존재 여부를 typed state로
닫아야 한다. 상태를 boolean 여러 개로 조합하지 않고 다음과 같은 discriminated
state와 action capability로 표현한다.
```text
initial-loading
ready
refreshing-with-data
empty
degraded-with-data
terminal-error
mutation-pending
mutation-conflict
```
RP-04에서 lazy import failure는 `ChunkRecoveryBoundary` → application recovery
input → `ReleaseInfoPort.refresh()`의 no-store manifest 조회 → build/release 쌍
guard → browser navigation adapter의 1회 reload로 연결됐다. 일반 render
failure는 이 경로에서 제외되고, 반복 실패·offline·malformed manifest·storage
실패는 지원 표면으로 fail-closed된다.
#### telemetry, registry, 공급망 gate의 실행 깊이가 부족하다
telemetry registry에는 여러 사건이 있지만 실제 production producer는 제한적이다.
boot, API attempt/final failure, auth recovery, storage/cache degradation, release/chunk
recovery를 registry 사건에 연결해야 한다.
registry/compatibility 검사는 다음까지 확장한다.
- ID와 enum/type의 양방향 완전성
- referenced schema/message/token의 존재
- 실행 코드에서 소비되지 않는 orphan 항목
- 기준 commit과 현재 commit 사이의 실제 contract diff
- breaking change의 version/migration/rollback metadata
공급망 검사는 단순 문자열 secret 탐지에 머물지 않고 transitive dependency,
known vulnerability, license policy, SBOM/provenance를 pinned tool로 검사해야 한다.
도구 장애와 취약점 발견을 구분하고, 예외에는 owner·사유·만료일을 요구한다.
### 5.2 P1: 공통 플랫폼 기본 제공 항목
- RP-06에서 완료한 schema 기반 form facade와 field/error/pending/dirty/422 정책 유지
- RP-06에서 완료한 standard, collection, detail, form, status page template의 public entry 정리
- 접근 가능한 drawer, menu, popover, select 같은 interaction primitive
- token → primitive → pattern → template로 이어지는 디자인 시스템
- Lucide를 감싼 local icon registry와 `IconButton`
- typed message key, locale provider, formatter, pseudo-locale/RTL smoke
- redacted structured diagnostics/logger와 telemetry wiring
- Storybook 또는 동급 isolated UI workshop
- Playwright visual baseline, shared MSW scenarios, built-dist E2E
- React Hooks, JSX accessibility, TanStack Query 관련 lint
- source와 tests를 모두 포함하는 TypeScript project references
- registry/compatibility의 실제 diff와 orphan reference 검사
- transitive vulnerability, license, SBOM/provenance 공급망 gate
### 5.3 P2: 경계와 recipe를 제공할 선택 항목
다음 기능을 모든 앱의 초기 번들에 설치할 필요는 없다. 대신 port 또는 local
vendor facade, 선택 조건, 실패 정책, 테스트 fixture를 문서로 제공한다.
| capability | 대표 기술 | 기본 제공할 경계 | 실제 설치 조건 |
| --- | --- | --- | --- |
| realtime | WebSocket, SSE | subscribe/unsubscribe, reconnect, resume, heartbeat | 서버가 push event를 제공할 때 |
| offline storage | IndexedDB | versioned repository, migration, quota failure | offline read/write가 제품 요구일 때 |
| background/cache | Service Worker, PWA | cache ownership, update, rollback recipe | installable/offline 앱일 때 |
| file transfer | presigned HTTP, multipart | progress, cancel, size/type validation | 업로드·대용량 다운로드가 있을 때 |
| generated API | OpenAPI, GraphQL, gRPC-Web | generated client를 gateway 뒤에 감싸는 규칙 | 서버 계약 형식이 확정됐을 때 |
| feature flag | local/remote flag provider | typed flag key, default, stale behavior | staged rollout가 필요할 때 |
| worker | Web Worker | request/result/cancel protocol | UI thread를 막는 CPU 작업이 있을 때 |
| multi-tab | BroadcastChannel | event versioning, source ID, conflict policy | 탭 간 동기화가 필요할 때 |
| browser capability | clipboard, notification, media | permission/result port | 해당 UX가 있을 때 |
| client workflow | Zustand, Redux Toolkit, state machine | state ownership decision과 local facade | cross-feature workflow가 실제로 생길 때 |
| large data UI | virtualization, data grid | owned component facade | 데이터 규모가 측정 기준을 넘을 때 |
| analytics/error sink | vendor SDK, OpenTelemetry | redaction, consent, sampling adapter | 운영 provider와 정책이 정해졌을 때 |
서버의 Redis, MongoDB, PostgreSQL, MinIO를 브라우저가 직접 연결하는 구조는 기본
frontend adapter catalog에 넣지 않는다. 브라우저는 권한 있는 backend API/BFF를
통해 이 자원에 접근해야 한다. 프론트에서 대응되는 변화 지점은 데이터베이스
vendor가 아니라 HTTP/GraphQL/gRPC-Web, realtime, file transfer, cache, storage,
worker, browser capability 같은 프로토콜·런타임 capability다.
### 성능 최적화는 모두 adapter 문제인가
아니다. 먼저 측정하고 병목의 소유 계층에 맞는 수단을 적용한다.
| 문제 | 기본 제공할 수단 | adapter/facade가 필요한 경우 |
| --- | --- | --- |
| 초기 JS가 큼 | route/feature lazy loading, bundle budget, dependency inventory | remote module이나 별도 delivery 전략이 있을 때 |
| 중복 네트워크 | TanStack deduplication/cache, abort, bounded retry | offline cache나 generated client를 교체할 때 |
| 느린 화면 전환 | prefetch policy, stable shell, cached-data surface | route별 prefetch provider가 필요할 때 |
| 긴 main-thread task | profiler 기준으로 계산 분리 | Web Worker message adapter |
| 대량 목록 | pagination과 server filter를 우선 | virtualizer/data-grid facade |
| 이미지 전송량 | width/height, lazy loading, responsive source 규칙 | Image CDN URL builder adapter |
| 재방문/offline | HTTP cache contract | Service Worker/IndexedDB adapter |
| 불필요한 render | 상태 소유권 축소와 component boundary | 보통 adapter가 아니며 측정 후 memoization |
기본 skeleton은 bundle budget, lazy route, query cancellation/cache, responsive
image 규칙, stable layout, lab performance test를 제공한다. Web Worker,
virtualization, Image CDN, Service Worker는 실제 병목과 제품 요구가 확인될 때
설치한다. 라이브러리를 미리 많이 넣는 것은 최적화가 아니라 초기 번들·공급망
표면을 늘리는 일이 될 수 있다.
## 6. 질문별 직접 답변
### 프론트도 inbound/outbound로 나누는가
나눈다. 현재 구조에서는 `presentation`이 사실상 inbound adapter이고
`src/adapters`가 outbound adapter다. 이름과 문서가 이 역할을 명확히 드러내지
않아 모두 같은 adapter처럼 보인 것이다.
| 역할 | 프론트 예 |
| --- | --- |
| input/inbound port | `ListResources`, `CreateResource` 같은 application API |
| inbound adapter | React page/controller, router, form event, push-event translator |
| output/outbound port | resource gateway, session, storage, clock, diagnostics |
| outbound adapter | HTTP, auth SDK, browser storage, TanStack cache, telemetry sink |
React, router, form library, icon library마다 application port를 만들 필요는 없다.
UI 내부 교체만 필요한 라이브러리는 React inbound adapter 내부 vendor facade로
충분하다. port는 application 정책과 외부 소유권 사이의 경계에 둔다.
### 왜 현재 모두 `adapters` 아래에 있는가
실제로 모두 있지는 않다. UI driver가 `presentation`이라는 이름으로 분리돼 있고,
`adapters`에는 주로 outbound 구현이 있다. 다만 다음 두 대안 중 하나를 명시적으로
선택해야 한다.
1. 변경량을 줄여 `presentation = inbound adapter`로 문서화하고
`adapters/outbound`만 명시한다.
2. TypeScript/feature migration과 함께 `adapters/inbound/react`
`adapters/outbound`로 재구성한다.
이 저장소는 input API 부재와 flat contracts 문제도 함께 고쳐야 하므로 두 번째
구조가 장기적으로 더 명확하다. 단, 대규모 rename 자체를 기능 개선으로 세지 말고
architecture gate와 수직 reference feature가 먼저 또는 같은 브랜치에서
증명되어야 한다.
### TypeScript로 바꾸는 것이 좋은가
좋다. 특히 registry ID, Result/error union, port generic, route params/search,
component variant를 컴파일 시점에 닫을 수 있다. 다만 일괄 rename은 권장하지
않는다. tooling → core contracts → application ports/use cases → outbound →
bootstrap → React TSX → tests 순서로 이동한다.
### store 기본 설정이 필요한가
상태 전략은 기본 제공해야 하지만 범용 global store dependency는 필수로 넣지
않는다.
- local interaction: `useState`/`useReducer`
- shareable navigation state: URL
- server state: TanStack Query
- form state: form facade
- low-frequency cross-cutting state: context 또는 typed external store
- complex cross-feature workflow: Zustand/Redux Toolkit/state machine 중 선택
- persistence: `StoragePort`
서버 데이터를 global store에 복사하지 않는 규칙이 중요하다.
[TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview),
[Redux Toolkit](https://redux-toolkit.js.org/introduction/getting-started),
[Zustand](https://zustand.docs.pmnd.rs/)의 역할은 서로 같지 않다.
### retry, API client, logger, token manager, error, validation은 어디에 있는가
- retry: `src/adapters/http/retry-policy.js`, 부분 준비
- API client: `src/adapters/http/client.js`, 부분 준비
- logger: 없음. telemetry와 분리하거나 diagnostics port로 합치는 결정 필요
- token manager: 의도적으로 없음. opaque external auth owner가 credential을 소유
- error: `src/contracts/errors.js`와 HTTP normalization, 부분 준비
- validation: runtime/API Zod는 존재, route/form/domain 분리는 미완성
token manager를 기본으로 추가하지 않는 이유는 token lifecycle이 인증 방식마다
다르고 localStorage token을 일반 해법으로 만들면 보안 위험이 커지기 때문이다.
BFF HttpOnly cookie 또는 OIDC/Auth SDK가 credential을 소유하도록 두고, SPA
memory token이 필요한 프로젝트만 auth adapter를 추가한다.
### Lucide React를 쓰면 디자인 시스템이 되는가
아니다. [Lucide React](https://lucide.dev/guide/packages/lucide-react)는
tree-shakable SVG icon source로 적절하지만 select, dialog, menu, focus management
같은 UI behavior는 제공하지 않는다. Lucide는 local icon facade 뒤에 두고,
복잡한 interaction은 [Radix Primitives](https://www.radix-ui.com/primitives/docs/overview/introduction)
또는 React Aria 계열과 같은 headless primitive를 owned wrapper 뒤에서 선택한다.
### 테스트는 현재 어떤 상태인가
테스트 도구 구성은 강한 편이다. 다만 다음이 빠져 있다.
- TS source와 test 전체 typecheck
- 실제 composition root부터 page까지의 통합
- query/mutation controller와 optimistic rollback
- route registry/runtime map 정합성은 RP-04에서 unit, component, negative
registry/type fixture와 built artifact 검증으로 구현됨
- runtime timeout/retry와 path/query/parsed body
- shared MSW scenario catalog
- isolated component stories와 interaction test
- stable-environment visual regression
- built `dist` 대상 release E2E
- 위험 기반 coverage gate
### 라우팅 전략은 무엇이 적절한가
현재 client-only clean architecture와 TanStack Query 조합은 유지하되, 목표
skeleton은 React Router Data Mode의 route object, blocker, scroll restoration,
route error 경계를 사용한다. 서버 상태의 소유자는 계속 application input과
TanStack Query이며 loader/action이 같은 데이터를 별도로 요청하지 않는다.
[React Router 공식 mode 설명](https://reactrouter.com/start/modes)에 따라
Framework Mode는 SSR/static generation, route module, framework-owned data
loading을 실제 요구할 때만 선택한다.
### 바로 쓸 수 있는 디자인 패턴은 무엇을 제공해야 하는가
패턴 이름만 나열하지 않고 다음 executable blueprint를 제공해야 한다.
- page controller: route/form event를 application input으로 변환
- query/mutation adapter: server state lifecycle을 React에 연결
- command/query use case: 읽기와 상태 변경 의도를 분리
- gateway: application이 외부 데이터 소유자를 추상화
- mapper/anti-corruption layer: transport DTO를 core model로 변환
- Result + failure mapper: throw와 사용자 메시지 경계를 통제
- strategy: retry, cache, auth recovery, feature flag 정책 교체
- observer/external store: session/theme/realtime 구독
- state machine: 복잡한 workflow에만 선택적으로 사용
- compound component/headless wrapper: 접근 가능한 복합 UI를 소유
- page template: layout과 상태 표면을 데이터 소유권에서 분리
## 7. 실전 투입 준비 완료 기준
막연한 백분율 대신 아래 조건을 모두 자동 또는 명시적 검토로 확인한다.
1. reference feature가 route → controller → input use case → output gateway →
adapter → mapper → query cache → UI 상태 표면을 통과한다.
2. application input API 외에는 UI에서 outbound dependency에 접근할 수 없다.
3. TypeScript source와 tests가 strict 검사되고 JS 우회 경로가 없다.
4. HTTP path/query/body/auth/timeout/retry/cancel/decode 실패가 계약 테스트된다.
5. typed route registry와 runtime map이 양방향 완전성을 가진다.
6. list/detail/form/status page template과 form error 정책이 준비돼 있다.
7. 디자인 시스템 primitive/pattern이 isolated workshop, interaction, a11y,
visual test를 가진다.
8. sample/reference feature 전체 삭제 후 typecheck/test/build가 통과한다.
9. optional adapter는 설치 조건, 보안 경계, 실패 정책, 테스트 recipe가 있다.
10. 새 feature 추가 문서가 파일 경로, 금지 의존, 실패 상태, 테스트, 검증 명령까지
안내한다.
이 기준은 배포 provider, 실제 인증 tenant, 운영 telemetry vendor, production field
data 같은 프로젝트별 외부 작업을 포함하지 않는다.
## 8. 관련 상세 문서
- [프론트 포트·어댑터와 기능 경계](./frontend-ports-adapters-and-boundaries.md)
- [TypeScript·상태·데이터 흐름](./typescript-state-and-data-flow.md)
- [라우팅·페이지·재사용 패턴](./routing-pages-and-patterns.md)
- [프론트 플랫폼 구현 로드맵](./frontend-platform-implementation-roadmap.md)
- [디자인 시스템 플랫폼](../styling/design-system-platform.md)
- [프론트 플랫폼 테스트 전략](../testing/frontend-platform-testing-strategy.md)
@@ -0,0 +1,986 @@
# 프론트엔드 플랫폼 구현 로드맵
## 1. 목적과 준비 완료의 의미
이 문서는 `develop``cb195f8`을 계획 기준선으로 삼아, 현재 저장소에 선언된
프론트엔드 계약을 실제 런타임과 자동 검증까지 연결하는 구현 순서를 정의한다.
기준 커밋은 출발점일 뿐이며 각 구현 브랜치는 직전 브랜치가 병합된 최신
`develop`에서 시작한다.
구현 단계는 다음처럼 구분한다.
| 단계 | 목적 | exit 상태 |
| --- | --- | --- |
| P0 | 새 기능이 사용할 input/output, HTTP/query, route/recovery 수직 경로 완성 | `LOCAL_CORE_READY` |
| P1 | form/page/design/i18n, diagnostics, test, 공급망까지 템플릿 품질 완성 | `LOCAL_TEMPLATE_READY` |
| P2 | 실제 요구가 생겼을 때 선택할 adapter recipe 제공 | 선택 항목만 `RECIPE_READY` |
P0와 P1은 사용자가 요청한 높은 수준의 저장소 내부 준비도를 판단하기 전에 모두
끝내야 한다. 이를 정밀한 백분율 점수로 표현하지 않고 다음 세 조건으로
판정한다.
1. 계약과 레지스트리의 모든 필드에 실제 consumer가 있다.
2. 정상 경로와 실패 경로가 자동 테스트로 증명된다.
3. 각 변경이 독립적으로 되돌릴 수 있고 feature branch와 evidence가 보존된다.
P2는 제품 번들에 모든 기술을 미리 넣는 단계가 아니다. 선택 조건, port/facade,
fake adapter, 실패 정책과 제거 절차를 준비하는 단계다.
## 2. 명시적인 범위 밖
다음 항목은 저장소가 seam, fail-closed 기본값, 검증 명령과 증거 형식을 제공할
수는 있지만 실제 완료는 외부 프로젝트 또는 운영 환경의 책임이다.
- 실제 hosting/CDN의 atomic deploy, cache purge, 응답 헤더와 rollback 실행
- 실제 OAuth/OIDC/기업 IdP tenant, client, redirect URI와 token lifecycle
- production telemetry, RUM, analytics, error-reporting vendor와 consent 정책
- 실제 사용자에게서 수집한 28일 field Web Vitals와 최소 표본 결정
- 저장소 호스팅 서비스의 required check, branch protection과 evidence retention
- 운영용 CSP allowlist, signing key, provenance attestation과 비밀 관리
- 실제 브랜드, 업무 문구, 번역 승인, 지원 locale와 법적 고지
- 도메인 API, 권한 모델, 제품별 form schema와 업무 규칙
- 운영 release 승인
이 항목이 준비되지 않아도 demo, fake, no-op, unavailable adapter를 사용해
P0/P1의 저장소 내부 구현과 테스트를 끝낼 수 있어야 한다. 반대로 외부 증거가
없는데 `PRODUCTION_READY`, `WCAG_READY`, `FIELD_SLO_READY`를 주장해서는 안 된다.
상위 promotion gate는 임의의 값을 만들어 PASS하지 않고 `FAIL_UNVERIFIED`
유지한다.
## 3. 공통 구현 원칙
- `.ts``.tsx`를 만들기 전에 lint, architecture, registry, typecheck와 test
검색 범위가 해당 확장자를 검사한다.
- React는 application input port를 호출하고, application은 output port만 안다.
구체 adapter 조립은 bootstrap만 담당한다.
- route, API, error, query, storage, telemetry와 release registry는 문서용 목록이
아니라 실행과 검증의 단일 소스다.
- path, search, body, response와 external event는 경계에서 parse하며 parse된
결과를 다음 계층으로 전달한다.
- retry, cache, redirect, reload, submission과 redaction에는 각각 한 명확한
정책 소유자만 둔다.
- 정상 테스트와 함께 invalid import, schema, URL, mutation, chunk, secret 같은
negative fixture가 실제 gate에 의해 거절돼야 한다.
- vendor SDK type과 import는 local facade/adapter 밖으로 노출하지 않는다.
- 선택 gate가 닫히지 않은 라이브러리는 lockfile에 추가하지 않는다.
- 대규모 rename과 여러 capability를 한 merge에 섞지 않는다.
## 4. Gitflow 브랜치 보존 규칙
현재 저장소는 `main``develop`을 사용하고 feature prefix가 비어 있다. 아래
표의 `feature-...` 이름을 그대로 `git flow feature start`에 전달한다.
```text
최신 develop 확인
-> git flow feature start <정확한 feature-... 이름>
-> 작은 검증 가능한 commit
-> git flow feature publish <정확한 feature-... 이름>
-> 자동 gate와 review
-> develop에 --no-ff merge
-> develop과 feature branch를 모두 push
-> local/origin feature branch 보존
```
`git flow feature finish`는 feature branch를 삭제할 수 있으므로 사용하지 않는다.
병합 후에도 로컬 브랜치와 `origin/feature-...`를 제거하지 않는다. merge commit
SHA, feature tip, 실행한 gate와 artifact 위치를 PR 또는 merge evidence에
기록한다.
후속 브랜치는 미병합 feature branch에서 시작하지 않는다. 선행 브랜치를 먼저
`develop`에 병합하고 최신 `develop`에서 새 브랜치를 시작한다. 병합 순서를
바꾸려면 dependency와 acceptance를 다시 검토해야 한다.
rollback은 reset이나 branch 삭제로 수행하지 않는다.
1. 관련 promotion을 중단한다.
2. 보존된 feature branch와 merge commit으로 정확한 변경 범위를 찾는다.
3. `develop`에서 별도 `hotfix/...` 또는 revert branch를 만든다.
4. `git revert -m 1 <merge-commit>`으로 merge를 되돌린다.
5. rollback gate 후 revert branch와 원래 feature branch를 모두 보존한다.
6. 수정은 최신 `develop`에서 새 feature branch로 다시 진행한다.
## 5. 12개 브랜치와 병합 순서
```mermaid
flowchart TD
B01["01 P0 TypeScript tooling"]
B02["02 P0 input/output boundary"]
B03["03 P0 HTTP and query"]
B04["04 P0 routing and recovery"]
B05["05 P0 reference feature"]
B06["06 P1 forms and pages"]
B07["07 P1 design system"]
B08["08 P1 i18n"]
B09["09 P1 diagnostics"]
B10["10 P1 tests and registry evidence"]
B11["11 P1 supply chain"]
B12["12 P2 optional recipes"]
B01 --> B02 --> B03 --> B04 --> B05
B05 --> B06 --> B07 --> B08 --> B09 --> B10 --> B11
B11 -. "요구가 확인된 recipe만" .-> B12
```
| 순서 | 보존할 Gitflow feature branch | 단계 | 직접 선행조건 | rollback point |
| --- | --- | --- | --- | --- |
| 01 | `feature-frontend-typescript-tooling-foundation` | P0 | 기준선, VD-01 | RP-01 |
| 02 | `feature-frontend-application-boundary-runtime` | P0 | 01 | RP-02 |
| 03 | `feature-frontend-http-query-contract-execution` | P0 | 02 | RP-03 |
| 04 | `feature-frontend-routing-release-recovery-runtime` | P0 | 03, VD-03 | RP-04 |
| 05 | `feature-frontend-reference-feature-vertical-slice` | P0 | 04 | RP-05 |
| 06 | `feature-frontend-form-page-platform` | P1 | 05, VD-04 | RP-06 |
| 07 | `feature-frontend-design-system-platform` | P1 | 06, VD-05 | RP-07 |
| 08 | `feature-frontend-i18n-message-formatting-contract` | P1 | 07, VD-06 | RP-08 |
| 09 | `feature-frontend-diagnostics-telemetry-runtime` | P1 | 08, VD-07 | RP-09 |
| 10 | `feature-frontend-test-registry-evidence-hardening` | P1 | 09, VD-08 | RP-10 |
| 11 | `feature-frontend-supply-chain-verification` | P1 | 10, VD-09 | RP-11 |
| 12 | `feature-frontend-optional-adapter-recipes` | P2 | 11, VD-10 | RP-12 |
## 6. 브랜치별 구현과 acceptance
### 01. `feature-frontend-typescript-tooling-foundation`
**목표**
점진적 TypeScript 전환 전에 JS, JSX, TS, TSX와 테스트가 모두 같은 정적 분석과
CI gate를 통과하게 한다. 애플리케이션 전체를 일괄 변환하지 않는다.
**선행조건과 decision**
- 기준선의 `pnpm ci:gate` 결과를 보존한다.
- TypeScript compiler와 기존 dependency 버전은 별도 dependency review 없이는
변경하지 않는다.
- 공식 JavaScript migration 방식처럼 점진적으로 전환한다.
[TypeScript JavaScript migration](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html)
**구체 변경 범위**
- root, application/source, tests용 TypeScript project/reference를 분리한다.
- `src`, `tests`, `scripts`의 허용 확장자와 build output 제외 범위를 명시한다.
- ESLint, dependency-cruiser, registry scanner와 architecture script의 JS-only
glob 및 `.js` 고정 경로를 JS/TS 양쪽으로 확장한다.
- Vite, Vitest와 Playwright config가 TS source/test 오류를 우회하지 않게 한다.
- 기존 JS에는 `checkJs`, 새 TS에는 strict 정책을 적용한다.
- port, registry와 forbidden import용 `.ts`/`.tsx` fixture를 추가한다.
**자동 테스트와 negative test**
- JS와 TS source/test를 함께 typecheck하는 smoke
- TSX presentation의 concrete HTTP adapter import를 architecture gate가 거절
- 잘못된 port 구현과 discriminated union 사용이 typecheck 실패
- TS registry row의 누락 필드, 중복 ID와 unknown reference가 gate 실패
- 기존 JS invalid-call fixture도 계속 실패
- `pnpm lint`, `pnpm check:types`, `pnpm check:architecture`,
`pnpm check:registries`, `pnpm test:all`, `pnpm build`
**Acceptance**
- 허용된 모든 source/test 확장자가 typecheck, lint, architecture, registry gate
중 필요한 검색 범위에 포함된다.
- JS 안전망이 약해지지 않은 상태에서 TS fixture가 CI에 의해 차단된다.
- 광범위한 unchecked cast나 임시 `any`로 통과시키지 않는다.
- production source 대량 변환 없이 독립적으로 revert할 수 있다.
**Rollback**
RP-01에서 merge를 revert하면 기존 JS 기준선으로 돌아간다. 후속 TS 작업은
RP-01이 복구될 때까지 병합하지 않는다.
### 02. `feature-frontend-application-boundary-runtime`
**목표**
UI가 호출할 input port와 application이 외부에 요구할 output port를 분리하고,
composition root가 생성한 `Application`을 실제 React tree에 주입한다.
**선행조건과 decision**
- 01의 TS-aware architecture gate가 병합돼 있어야 한다.
- 범용 client store는 추가하지 않는다. local interaction은 React state, 공유
navigation state는 URL, server state는 query adapter가 소유한다.
- 실제 IdP 없이 demo/external/unavailable session adapter를 유지한다.
**구체 변경 범위**
- application input API를 query/command use case 중심으로 정의한다.
- gateway, session, cache, storage, clock, diagnostics와 release capability를
output port로 분류한다.
- `createApplication`이 raw adapter를 다시 노출하지 않고 input API만 반환한다.
- `ApplicationProvider``useApplication` 역할의 inbound React adapter를 둔다.
- bootstrap이 concrete output adapter를 선택하고 application을 router/page에
전달한다.
- presentation의 fetch, browser storage, concrete QueryClient와 outbound adapter
직접 접근을 금지한다.
- inbound/outbound ownership을 architecture gate와 type fixture에 반영한다.
**자동 테스트와 negative test**
- fake output ports를 이용한 application use-case 단위 테스트
- runtime config → composition root → provider → smoke page 통합 테스트
- presentation의 `adapters/outbound`, `fetch`, concrete QueryClient import 거절
- application의 React, router, browser storage, vendor SDK import 거절
- 잘못된 output port 구현과 누락 input이 typecheck 실패
- bootstrap 이외 계층의 concrete adapter 조립 거절
**Acceptance**
- visible page가 raw outbound dependency 대신 application input API를 사용한다.
- 조립됐지만 사용되지 않는 application, HTTP와 release-info 객체가 없다.
- fake adapter만으로 application과 presentation의 정상·실패 경로를 테스트한다.
- vendor type이 application public API에 나타나지 않는다.
**Rollback**
RP-02는 기존 provider wiring을 한 merge로 되돌릴 수 있어야 한다. 임시
compatibility wrapper는 05 reference feature 완료 전에 제거한다.
### 03. `feature-frontend-http-query-contract-execution`
**목표**
API operation registry의 path/search/body/schema/timeout/retry가 실제 요청에
반영되게 하고, TanStack Query를 application input과 React 사이의 제한된 inbound
adapter로 연결한다.
**선행조건과 decision**
- 02의 application error와 gateway 경계가 고정돼 있어야 한다.
- shared HTTP가 network retry를 소유하고 query adapter의 중복 retry는 끈다.
다른 결정을 원하면 이 브랜치 전에 ADR을 남긴다.
- TanStack Query를 server-state adapter로 유지하되 Zustand/Redux Toolkit에
server state를 복사하지 않는다.
**구체 변경 범위**
- request input을 operation/route ID, path params, search params, body, signal과
idempotency key로 분리한다.
- operation registry에서 method, path, request/response schema, timeout과 retry
eligibility를 찾는다.
- deterministic path/search builder를 만들고 parse/transform된 query/body를
실제 URL/payload에 사용한다.
- runtime config의 timeout과 max attempts를 client factory에 주입한다.
- abort, timeout, retry delay와 listener cleanup을 injectable clock/scheduler
경계로 통제한다.
- TanStack import를 허용할 local query adapter 경계를 하나만 둔다.
- `useApplicationQuery`, `useApplicationMutation` 역할의 controller API를
정의하고 query key, cancellation, invalidation과 optimistic rollback을
연결한다.
- base state를 loading/success/empty/terminal로, overlay를
refreshing/stale-degraded/pending/conflict로 닫는다.
- refreshing/stale 및 pending/conflict의 배타성과 stale-failure latch를
명시적으로 구현한다.
- stale retry, duplicate-submit 방지와 conflict action을 실제 callback에
연결한다.
**자동 테스트와 negative test**
- path escaping, optional/array search, stable ordering 및 query key/request URL 일치
- query/body transform 결과 전송, invalid input에서 `fetch` 0회
- runtime timeout과 max attempts 0/1/N의 실제 호출 횟수
- validation 실패, abort, success, exhausted retry의 timer/listener cleanup
- caller abort와 timeout의 서로 다른 typed failure
- unsafe mutation, non-retryable status와 schema failure retry 0회
- 네 base state, 네 overlay와 허용 transition
- refreshing 실패의 stale latch 및 성공/재시도 후 해제
- pending/conflict 동시 상태 fixture 거절
- duplicate submit 1회, optimistic success/rejection rollback/conflict resolution
- unmount/navigation abort가 terminal error로 오인되지 않음
- local adapter 밖의 `@tanstack/**` import 거절
**Acceptance**
- cache key에 포함된 request 입력이 실제 URL/body에서도 동일하게 사용된다.
- runtime timeout/retry 설정이 실제 동작을 바꾼다.
- 모든 종료 경로의 cleanup과 한계 횟수를 fake clock으로 결정적으로 증명한다.
- QueryClientProvider가 단순 마운트 객체가 아니며 reference controller가 사용할
표준 API를 제공한다.
- `AsyncSurface`의 모든 action이 실제 command와 검증된 state transition을 가진다.
**Rollback**
RP-03은 application port를 유지하면서 이전 HTTP compatibility adapter 또는
imperative controller로 되돌릴 수 있어야 한다. request builder와 query adapter
commit을 구분해 부분 revert가 가능하게 한다.
### 04. `feature-frontend-routing-release-recovery-runtime`
**목표**
route registry를 executable router의 단일 계약으로 만들고 params/search/access/
surface/chunk metadata를 route flow에 연결한다. lazy chunk 실패도 release
manifest와 bounded recovery에 실제 연결한다.
**선행조건과 decision**
- 03의 controller가 parsed route input을 받을 수 있어야 한다.
- VD-03 기본값은 React Router Data Mode다. route object, blocker, scroll
restoration과 route error 경계를 사용하되 server state는 application input과
TanStack Query가 계속 소유한다.
[React Router modes](https://reactrouter.com/start/modes),
[Data Mode custom setup](https://reactrouter.com/start/data/custom)
- 고정된 `7.18.1` API로 전환하고 router version upgrade는 별도 dependency
브랜치로 분리한다.
- 실제 hosting cache/purge는 범위 밖이며 test manifest server/browser adapter를
사용한다.
**구체 변경 범위**
- 직렬화 가능한 route contract와 component/codec runtime map을 분리한다.
- route ID의 contract/runtime 양방향 완전성을 TypeScript와 registry gate로
검사한다.
- registry에서 route objects, navigation, access hint, title, loading/error
surface와 chunk ID를 조립한다.
- params/search를 application 호출 전에 Zod codec으로 parse하고 URL builder도
같은 codec을 사용한다.
- unknown/authentication-required/forbidden/not-found/error surface의 소유자를
하나씩 정한다.
- route change의 boundary reset, document title, main focus와 scroll 정책을
적용한다.
- redirect-loop guard와 최대 hop을 실제 자동 redirect flow에 연결한다.
- build의 Vite asset manifest와 route chunk ID를 release manifest의 검증 가능한
map으로 연결하고 `runtime-config.schema.json`을 artifact로 생성한다.
- config/release/build/asset mismatch 오류 kind를 정확히 나눈다.
- lazy rejection → no-store manifest refetch → active build/release pair 비교 →
guarded reload 1회 → 반복 실패 support/rollback surface를 연결한다.
- 일반 render error는 chunk reload 경로에 들어가지 않고 local reset을 유지한다.
- `FeatureBoundary`, `ReleaseInfoPort`와 chunk recovery controller를 production
call graph에 연결한다.
**자동 테스트와 negative test**
- registry row 추가 시 route tree/navigation 동시 추가
- runtime 누락/orphan, duplicate path/ID, unknown schema/surface/chunk 거절
- invalid params/search, unknown route와 access rejection에서 application/API 0회
- URL parse/build round-trip과 canonical search
- redirect cycle이 최대 hop에서 종료
- route 전환 후 error reset, main heading focus와 중복 surface 없음
- stale chunk + manifest mismatch에서 no-store refetch 후 reload 정확히 1회
- 같은 build/release pair의 두 번째 실패에서 reload 0회와 support surface
- manifest malformed/offline/storage unavailable의 fail-closed 동작
- 일반 component throw가 chunk failure로 오분류되지 않음
- build/config/release/asset mismatch의 정확한 error kind
- built `dist`의 route chunk map, runtime schema와 release artifact set 검증
**Acceptance**
- 수동 route 열거와 registry가 서로 어긋날 수 없다.
- route metadata 중 consumer 없는 token이 0개다.
- invalid/unknown/access-rejected URL에서 product API 0회를 증명한다.
- chunk recovery 결정이 production call graph에 있으며 무한 reload가 불가능하다.
- 실제 CDN 없이 저장소 내부 recovery matrix가 결정적으로 통과한다.
**Rollback**
RP-04는 이전 수동 router와 generic route failure surface로 한 merge를 되돌릴 수
있어야 한다. URL shape는 바꾸지 않으며 불가피한 변경은 compatibility redirect와
migration이 있어야 한다. 실제 CDN purge는 rollback 범위에 포함하지 않는다.
### 05. `feature-frontend-reference-feature-vertical-slice`
**목표**
route에서 실제 HTTP/query/application/presentation까지 연결되며 전체를 제거해도
generic platform이 정상 부팅되는 하나의 reference feature를 만든다.
**선행조건과 decision**
- 01~04가 모두 병합돼 있어야 한다.
- 실제 backend와 IdP 대신 MSW/fake gateway와 demo auth를 사용한다.
- reference 업무 용어를 제품 도메인 계약으로 승격하지 않는다.
**구체 변경 범위**
- 흩어진 sample route, operation, schema, mapper, domain, query key, controller,
page와 tests를 canonical `src/features/reference-feature` 한 ownership 경계로
이동한다.
- feature 내부에 domain, application input/output, outbound gateway, DTO/schema,
mapper, query/mutation controller와 presentation page를 둔다.
- list filter가 URL codec, query key와 실제 HTTP request에 동일하게 반영된다.
- safe mutation으로 pending, duplicate prevention, optimistic state, conflict와
rollback을 보여 준다.
- registry/application에는 removable contribution contract를 사용하고 generic
source에는 fixture-specific ID를 하드코딩하지 않는다.
- production bootstrap과 같은 composition 경로에 fake adapter를 주입한다.
- `src/features/reference-feature` 전체를 제거한 ephemeral copy에서
typecheck/test/build/home smoke를 실행하도록 sample-removal gate를 다시
작성한다.
**자동 테스트와 negative test**
- bootstrap → router → controller → input use case → gateway → HTTP/MSW →
response schema → mapper → cache → page의 수직 통합
- loading/success/empty/terminal/refreshing/stale/pending/conflict/recovery UI
- invalid search에서 gateway/fetch 0회
- invalid DTO가 domain/view model로 전파되지 않음
- backend forbidden을 client access hint와 무관하게 최종 반영
- duplicate/unsafe mutation 전송 횟수
- `src/features/reference-feature` 삭제 후 typecheck, architecture, registry,
unit/integration, home smoke와 build
- 삭제 후 sample route 부재와 `src`/`tests`의 fixture-specific ID 잔여 0개
- generic home/examples route가 sample 없이 계속 작동
**Acceptance**
- static page와 분리된 fixture가 아니라 실제 수직 경로 하나가 존재한다.
- reference feature를 삭제할 때 central registry를 수작업으로 수정하는 숨은
단계가 없거나 제거 명령이 이를 결정적으로 수행한다.
- feature 존재/제거 두 모드에서 P0 gate가 모두 통과한다.
- 01~04를 함께 revert해야만 sample을 제거할 수 있는 숨은 결합이 없다.
**Rollback**
RP-05는 P0 최종 지점이다. reference feature merge만 revert해도 generic platform은
정상이어야 한다.
## 7. P0 exit gate
- JS/TS source와 tests가 동일한 정적 gate에 포함된다.
- UI는 application input API만 호출하고 concrete outbound adapter를 모른다.
- HTTP path/search/body/auth/timeout/retry/cancel/decode가 실행 계약과 일치한다.
- query/mutation controller와 모든 async state transition이 작동한다.
- route registry/runtime map과 route chunk map이 양방향 완전성을 가진다.
- lazy chunk failure가 bounded release recovery에 연결된다.
- reference feature가 실제 수직 경로를 실행하고 전체 삭제 gate를 통과한다.
- `pnpm ci:gate`와 P0에서 추가한 모든 negative fixture가 통과한다.
위 조건이 모두 충족돼야 `LOCAL_CORE_READY`다. RP-05 증거를 보존한 뒤 P1을
시작한다.
## 8. P1 브랜치
### 06. `feature-frontend-form-page-platform`
**목표**
Zod schema와 application command를 연결하는 form controller 및
standard/collection/detail/form/status page template를 제공한다.
**선행조건과 decision**
- 05의 mutation과 async state가 실제 reference flow에서 작동해야 한다.
- VD-04에서 React Hook Form + Zod resolver 사용 여부를 정한다. 복합 form 요구가
확정되면 local facade 뒤에 추가하고, 거절되면 native form + local controller가
같은 public API와 tests를 충족한다.
[React Hook Form resolvers](https://github.com/react-hook-form/resolvers),
[Zod](https://zod.dev/)
- vendor 결정 전에 dependency/lockfile을 변경하지 않는다.
**구체 변경 범위**
- presentation form schema와 domain invariant를 분리한다.
- field registration, parse/error map, dirty/touched/reset/pending/result를 local
form API로 감싼다.
- `422` field/form/unknown-server-field/global error mapping을 정의한다.
- 첫 오류 focus, error summary, field ID와 live-region을 연결한다.
- duplicate submit, cancel/leave와 unsaved-change 정책을 적용한다.
- `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage`, `StatusPage`의 slot
계약을 정의한다.
- heading, breadcrumb, actions, filters, content, aside와 feedback의 소유 위치를
표준화한다.
- reference list/detail/create-edit/status page를 controller와 template로
전환한다.
- template가 application use case나 query vendor를 직접 선택하지 않게 한다.
**자동 테스트와 negative test**
- client validation 실패에서 command 0회와 첫 invalid field focus
- transformed/defaulted data가 command에 전달됨
- field/form/unknown server error와 conflict 후 입력 보존
- pending 중 연속 submit에도 command 1회
- reset 후 dirty 해제와 navigation confirmation/focus restore
- secret-like input이 URL/storage/log/telemetry에 나타나면 실패
- 각 template의 최소/전체 slot 및 async/error variation
- 320px, zoom, 긴 heading/action과 wide layout
- heading/landmark/accessibility-name/axe
- template가 application/HTTP/query vendor를 import하면 gate 실패
- action callback 없이 활성 버튼을 렌더링하면 실패
**Acceptance**
- form engine을 바꿔도 page와 application API가 변하지 않는다.
- multi-field form의 validation/pending/dirty/422/conflict가 실행된다.
- 새 feature가 layout CSS를 복사하지 않고 다섯 page 유형을 조립한다.
- template는 layout/state slot만 소유하고 데이터 정책은 소유하지 않는다.
**Rollback**
RP-06은 기존 reference controls/layout으로 돌아가도 controller/application
경계가 유지돼야 한다. vendor adapter, form contract와 template commit을 구분해
부분 revert가 가능하게 한다.
**구현 증거 (2026-07-26)**
- VD-04에서 dependency 추가 없는 native controller + Zod local facade를
채택했고 vendor 도입 조건을 문서화했다.
- `src/presentation/forms`가 field registration, parse/error map,
dirty/touched/reset/pending/result, 422 allowlist, first-error focus,
duplicate submit과 dirty navigation을 제공한다.
- `src/presentation/templates`가 다섯 page 유형의 slot/landmark/responsive
계약을 제공하며 architecture negative fixture가 application/vendor import를
거절한다.
- reference list/detail/create/status route가 네 구체 template를 사용하고
production composition test가 list → form → command → HTTP → invalidation →
list 경로를 실행한다.
- component/integration/E2E test가 validation, transform, 422, conflict,
secret 비노출, navigation focus와 320px reflow를 검증한다.
### 07. `feature-frontend-design-system-platform`
**목표**
기존 theme와 primitive를 token → primitive → pattern → template 계층으로
확장하고, 모바일 drawer와 복합 interaction을 접근 가능한 local API로 제공한다.
**선행조건과 decision**
- 06의 page/form에서 반복되는 실제 token/primitive 요구를 수집한다.
- VD-05에서 icon source와 headless interaction 방식을 결정한다.
- Lucide를 선택하면 static named import만 local icon facade에서 허용한다.
[Lucide React](https://lucide.dev/guide/react),
[Lucide accessibility](https://lucide.dev/guide/react/advanced/accessibility)
- headless vendor가 미결정이면 native dialog/disclosure/control 범위만 완료하며,
미지원 widget을 임의 구현해 완료로 표시하지 않는다.
**구체 변경 범위**
- primitive/semantic/component token을 분리한다.
- color, typography, spacing, size, radius, elevation, z-layer, motion, breakpoint와
opacity 계약을 정의한다.
- light/dark/system, forced-colors와 reduced-motion을 닫는다.
- design-system public entry와 deep-import 금지 규칙을 만든다.
- 기존 Button/TextField/Card/Alert/Badge/Dialog를 호환 가능한 public API로
이동한다.
- LinkButton/IconButton/Field/TextArea/Select/Checkbox/RadioGroup/Switch/
Spinner/Skeleton/VisuallyHidden을 기본 제공한다.
- Drawer/Popover/Tooltip/Menu/Tabs/Breadcrumbs/Pagination/Toast/ProgressBar 및
필요한 focus/portal utility를 추가한다.
- 모바일 navigation을 Drawer로 전환해 focus 이동, modal/background 정책,
Escape/scrim/link dismiss와 trigger focus restore를 구현한다.
- 앱 셸의 문자 glyph/raw control을 local icon/primitive로 교체한다.
- `/examples/ui``/examples/states`에서 interactive state를 실제 동작으로
노출한다.
**자동 테스트와 negative test**
- 필수 semantic token의 light/dark/forced-colors 정의
- undefined CSS variable/raw palette 사용 거절
- interactive icon의 accessible name 누락 거절, decorative icon tree 제외
- product code의 direct icon/headless vendor 또는 deep import 거절
- disabled/pending/focus/invalid와 reduced-motion/theme persistence
- keyboard-only open/move/select/dismiss/focus restore
- Drawer open 시 background interaction 차단과 compact reflow
- Menu/Tabs arrow key, typeahead와 activation 정책
- Tooltip만으로 필수 정보를 제공하는 fixture 거절
- Toast queue 상한/duplicate collapse/timeout pause
- Chromium/Firefox/WebKit component/E2E/axe
**Acceptance**
- shell, templates와 reference feature가 public design-system API를 사용한다.
- product code의 raw palette, vendor icon/headless import가 0개다.
- mobile navigation이 검증된 focus/dismiss contract를 따른다.
- gallery action이 handler 없는 장식이 아니라 실행 가능한 상태를 시연한다.
- token 누락과 접근 불가능한 interaction을 자동 gate가 차단한다.
**Rollback**
RP-07은 compatibility export로 기존 primitive import를 복구할 수 있어야 한다.
token rename은 alias/migration 기간을 두고, vendor adapter와 local API commit을
분리한다.
**구현 증거 (2026-07-26)**
- VD-05에서 `lucide-react@1.25.0` static semantic facade와 native-first
interaction을 채택하고 headless vendor 재평가 조건을 닫았다.
- `src/presentation/design-system`이 48개 필수 token, public TypeScript barrel,
action/form/feedback/overlay/navigation primitive와 공통 pattern을 제공한다.
- 기존 `components/ui` 경로는 compatibility export로 유지하고 앱 셸, gallery와
reference feature는 public entry를 소비한다.
- 모바일 navigation은 native modal Drawer로 전환되어 배경 비활성화, Escape,
route dismiss와 trigger focus restore를 제공한다.
- source/negative fixture gate가 undefined token, raw palette, direct vendor,
deep import, tooltip-only 정보와 accessible name 누락을 거절한다.
- component/browser test가 Menu typeahead, Tabs activation, Toast queue,
form controls, compact reflow와 open-dialog axe를 실행한다. 로컬 WebKit은 host
`libevent-2.1.so.7` 부재로 환경 검증 상태를 유지한다.
### 08. `feature-frontend-i18n-message-formatting-contract`
**목표**
사용자 문구, 날짜, 숫자, 상대 시간과 direction을 typed message/formatter 경계로
옮겨 특정 언어 literal과 locale 가정을 공통 UI에서 제거한다.
**선행조건과 decision**
- 07까지의 common user-facing string 목록이 확보돼 있어야 한다.
- VD-06 기본값은 browser `Intl` + typed local catalog다. extraction/plural 등
요구가 이를 넘을 때만 vendor를 선택한다.
- 실제 번역 승인과 지원 locale은 외부 프로젝트 범위다.
**구체 변경 범위**
- typed message key와 interpolation parameter 계약을 정의한다.
- locale provider, fallback locale, direction과 date/number/relative-time
formatter를 제공한다.
- shell, route, async/form error와 design-system 기본 copy를 catalog로 이동한다.
- backend raw message를 번역 key로 신뢰하지 않고 error registry로 매핑한다.
- pseudo-locale와 RTL smoke locale을 테스트 전용으로 제공한다.
- unknown key/locale, formatter failure와 missing interpolation의 fallback을
정의한다.
**자동 테스트와 negative test**
- 모든 등록 locale의 key parity와 interpolation 정합성
- unknown locale/key에서 raw key/stack을 노출하지 않는 fallback
- pseudo-locale 확장에 대한 layout/reflow
- RTL shell/drawer/breadcrumb와 directional icon
- 날짜/숫자/timezone의 결정적 test
- untrusted HTML interpolation 및 backend message 직접 렌더링 거절
- common UI의 금지된 hardcoded user-facing literal 검출
**Acceptance**
- shell/state/form/error copy가 typed catalog를 통과한다.
- pseudo-locale와 RTL E2E가 통과한다.
- 실제 제품 번역 없이도 locale 교체 지점과 실패 정책이 검증된다.
- key rename에는 compatibility alias 또는 migration이 있다.
**Rollback**
RP-08은 기존 기본 언어 catalog를 fallback으로 유지한다. 번역 catalog를
파괴적으로 덮어쓰지 않는다.
### 09. `feature-frontend-diagnostics-telemetry-runtime`
**목표**
structured diagnostics와 telemetry를 구분하고 boot, HTTP, cache, storage, route,
render와 release 사건을 실제 producer에 연결한다.
**선행조건과 decision**
- route/HTTP/query/form/release failure kind가 안정돼 있어야 한다.
- VD-07에서 외부 exporter를 선택하지 않아도 된다. no-op 또는 기존
best-effort HTTP sink를 기본으로 하고 실제 vendor SDK는 외부 adapter다.
**구체 변경 범위**
- diagnostics/logger output port와 telemetry event port의 책임을 분리한다.
- level, event ID, timestamp, correlation/request/route/release context와 redaction
정책을 정의한다.
- `app.boot.failed`, `api.request.failed`, `ui.render.failed`,
`release.mismatch.detected`, `telemetry.delivery.dropped`를 production path에
연결한다.
- retry attempt마다 terminal failure를 중복 발행하지 않고 bounded summary만
남긴다.
- route/path/query/body/error에서 고카디널리티와 민감 값을 제거한다.
- queue 상한, drop reason bucket, sink failure와 nonrecursive fallback을
구현한다.
- reference feature에서 route → application → HTTP outcome correlation을
증명한다.
**자동 테스트와 negative test**
- registry event가 production path에서 필요한 횟수만 발행
- success/retry recovery/terminal failure event 차이
- Authorization/cookie/token/raw form/body/PII-like fixture redaction
- large/circular/unknown error에서도 serializer가 throw하지 않음
- queue full/sink failure가 app failure나 재귀 폭주를 만들지 않음
- unknown/high-cardinality event context 거절
- mount 전 boot failure도 안전한 evidence 생성
- no-op adapter에서도 동일 application behavior
**Acceptance**
- 필수 telemetry event 중 producer 없는 항목이 0개다.
- sink/vendor가 없어도 기능이 정상이며 failure evidence가 제한된다.
- credential/raw user input이 queue, console과 artifact에 남지 않는다.
- exporter wiring만 독립적으로 제거할 수 있다.
**Rollback**
RP-09는 exporter를 제거하고 즉시 no-op adapter로 전환할 수 있어야 한다.
diagnostics port와 redaction test는 유지한다.
### 10. `feature-frontend-test-registry-evidence-hardening`
**목표**
registry의 구조/참조/consumer/호환성을 실제 diff로 검증하고, P0/P1 위험 경로를
실제 bootstrap과 built artifact에서 증명한다.
**선행조건과 decision**
- 09까지의 public contract와 registry field가 안정돼 있어야 한다.
- VD-08 기본값은 dev-only Storybook workshop과 local Playwright visual
baseline이다. cloud review service는 선택이다.
- `/examples/ui`, `/examples/states`는 실제 앱 composition의 통합 smoke로
유지하며 isolated story의 SSOT를 대신하지 않는다.
[Storybook documentation](https://storybook.js.org/docs)
**구체 변경 범위**
- route/API/schema/error/query/storage/telemetry/config/release registry의 field
type/enum/unique/cross-reference/consumer/orphan을 machine-readable하게 검사한다.
- 직전 승인 snapshot과 현재 snapshot의 actual diff로 additive/behavioral/
breaking/removal을 계산한다.
- breaking에는 version bump, migration, compatibility window와 rollback
evidence를 요구한다.
- hardcoded `current: additive`, empty high-risk review와 self-asserted PASS를
제거한다.
- source와 tests를 모두 포함하는 TypeScript check를 CI 필수 gate로 만든다.
- MSW handler를 operation/schema 기반 shared scenario catalog로 통합한다.
- 실제 `main` composition과 built `dist`의 integration/release E2E를 추가한다.
- Playwright compact/mobile project와 risk-based coverage threshold를 추가한다.
- deterministic clock/random/fetch/storage helper, unexpected console/unhandled
rejection 실패 정책을 제공한다.
- 선택된 workshop interaction 또는 안정 환경의 visual baseline을 추가한다.
- JUnit/screenshot/trace/evidence naming과 CI gate registry를 맞춘다.
**자동 테스트와 negative test**
- duplicate ID/path, invalid enum/type, missing field/reference와 orphan 거절
- actual removal/type narrowing/path change를 additive로 표시하면 실패
- breaking diff의 version/migration/rollback 누락 실패
- ordering-only diff는 semantic change가 아님
- 승인 baseline digest 변조 실패
- unexpected network/operation, provider 누락과 unhandled error 실패
- critical policy coverage threshold 미달 실패
- built artifact의 source map, config schema 누락과 broken chunk 실패
- compact viewport와 Chromium/Firefox/WebKit navigation/a11y smoke
- reference feature 제거 모드의 generic E2E/build
- screenshot mask가 전체 UI를 가려 false PASS를 만들면 실패
**Acceptance**
- route 선언/실행 불일치와 telemetry producer 누락을 registry gate가 잡는다.
- compatibility impact가 하드코딩 상수가 아니라 actual diff다.
- 테스트가 source module만이 아니라 real composition과 `dist`를 증명한다.
- 모든 P0/P1 high-risk policy에 negative fixture가 하나 이상 있다.
- CI artifact로 browser/release/fixture 실패를 재현할 수 있다.
**Rollback**
RP-10은 직전 승인 registry snapshot과 test evidence다. flaky visual/browser
infrastructure commit은 product behavior와 분리한다. 장기 skip으로 PASS하지 않고
owner와 만료 시한이 있는 quarantine만 허용한다.
### 11. `feature-frontend-supply-chain-verification`
**목표**
직접·전이 dependency, vulnerability, license, secret, SBOM/inventory와 provenance를
검증 가능한 release gate로 만든다.
**선행조건과 decision**
- 10까지 최종 build/test dependency 구조가 안정돼 있어야 한다.
- VD-09에서 vulnerability source, license policy, SBOM format과 attestation
provider를 선택한다.
- provider가 미결정이면 임의 PASS를 만들지 않고 promotion을
`FAIL_UNVERIFIED`로 유지한다. local inventory/fixture 검증은 완료할 수 있다.
**구체 변경 범위**
- frozen lockfile에서 direct/transitive dependency inventory를 생성한다.
- name/version, direct/transitive, resolved integrity와 license를 deterministic하게
기록한다.
- actual baseline/lockfile diff에서 new/removed/changed dependency와
reviewer-required risk를 계산한다.
- secret scan 범위를 source/dist/tracked config/generated manifest와 artifact
metadata에 맞게 확장한다.
- 선택된 vulnerability/license adapter가 severity, exception owner/expiry를
machine-readable evidence로 남기게 한다.
- SBOM 또는 동등 inventory, build digest와 provenance statement를 coherent
release artifact set에 연결한다.
- frozen install과 동일 source/lock/config의 reproducible build를 검증한다.
**자동 테스트와 negative test**
- transitive dependency inventory 누락 실패
- lockfile integrity/digest 변조와 unfrozen install 실패
- high-risk dependency의 self-approval 실패
- 금지 license, threshold vulnerability와 만료 exception 실패
- source/dist/config secret fixture 검출 및 test-secret allowlist 통제
- SBOM/inventory와 release manifest digest 불일치 실패
- provenance subject와 artifact digest 불일치 실패
- dependency ordering만 달라도 deterministic output 유지
**Acceptance**
- dependency diff와 high-risk review가 하드코딩 빈 배열이 아니다.
- transitive inventory, license와 vulnerability 결과가 release evidence에
포함된다.
- provider/organization decision이 없으면 상위 promotion이 명시적으로
`FAIL_UNVERIFIED`다.
- 위험 dependency upgrade만 독립적으로 revert할 수 있다.
**Rollback**
RP-11은 P1 최종 저장소 기준선이다. scanner outage를 무검증 승인으로 우회하지
않고 promotion을 보류한다.
## 9. P1 exit gate
- 현실적인 form의 validation/dirty/pending/422/conflict가 작동한다.
- collection/detail/form/status를 포함한 page template가 reference feature에서
사용된다.
- token → primitive → pattern → template 경계가 자동 보호된다.
- compact navigation과 interaction의 keyboard/focus 계약이 검증된다.
- typed message, pseudo-locale와 RTL smoke가 존재한다.
- 필수 telemetry event가 실제 producer에 연결되고 secret/PII가 redaction된다.
- registry/compatibility가 actual diff와 cross-reference로 검증된다.
- bootstrap과 built `dist`를 포함한 risk-based test/evidence가 남는다.
- transitive dependency, vulnerability, license와 local SBOM가 선택된 정책에
따라 검증된다. 외부 signing/provenance provider가 미결정이면 해당 promotion만
`FAIL_UNVERIFIED`다.
- 현재 merge gate가 요구하는 등록 route의 signed manual accessibility evidence가
갱신된다.
- P0/P1 feature branch와 merge evidence가 local/origin에 모두 보존된다.
위 조건이 모두 충족되면 `LOCAL_TEMPLATE_READY`다. 실제 hosting, IdP,
production sink와 field data 없이도 저장소 내부 상태는 달성할 수 있지만, 외부
gate 없이는 production promotion을 통과했다고 보지 않는다.
## 10. P2 브랜치
### 12. `feature-frontend-optional-adapter-recipes`
**목표**
모든 프로젝트에 dependency를 미리 설치하지 않고, 실제 요구가 확인된 capability의
선택 기준, port/facade, fake adapter, failure policy와 test recipe를 제공한다.
**선행조건과 decision**
- P0/P1 public boundary가 안정돼 있어야 한다.
- VD-10에서 실제 소비 요구, owner, 보안 영향, bundle 예산과 제거 조건이
승인된 recipe만 구현한다.
- 승인되지 않은 recipe의 runtime dependency는 추가하지 않는다.
**구체 변경 범위**
| recipe | 기본 경계 | 반드시 다룰 실패 |
| --- | --- | --- |
| realtime | subscribe/unsubscribe, reconnect, resume, heartbeat | disconnect, duplicate/out-of-order, auth expiry |
| offline/IndexedDB | versioned repository와 migration | quota, corruption, migration rollback |
| Service Worker/PWA | cache ownership와 update controller | stale worker, update loop, offline fallback |
| file transfer | upload/download, progress, cancel | size/type rejection, abort, expired URL |
| generated API | generated client를 gateway 뒤에 감싸는 facade | contract drift, unsupported field |
| feature flag | typed key, default와 stale policy | provider unavailable, unknown flag |
| Web Worker | request/result/cancel protocol | crash, stale result, transfer failure |
| multi-tab | versioned BroadcastChannel event | self-echo, duplicate, conflict |
| browser permission | clipboard/notification/media result port | denied, dismissed, unsupported |
| client workflow | Zustand/Redux Toolkit/state-machine local facade | reset, mismatch, server-state duplication |
| large data UI | virtualizer/data-grid facade | focus loss, stale row, scale limit |
| analytics/error sink | consent/redaction/sampling adapter | denied consent, queue full, unavailable |
- 각 recipe에 설치/금지 조건, ownership, security/privacy, bundle 영향, fallback과
제거 절차를 기록한다.
- runnable fixture는 production entry에서 제외한 opt-in example/test entry에
둔다.
- Zustand와 Redux Toolkit을 동시에 기본 설치하지 않는다.
- browser가 DB/object store에 직접 접속하는 recipe는 만들지 않고 권한 있는
backend/BFF protocol adapter를 사용한다.
**자동 테스트와 negative test**
- 선택 recipe의 fake adapter contract 및 unavailable fallback
- unsubscribe/cancel/cleanup 누락 fixture 실패
- local adapter 밖의 vendor SDK direct import 실패
- credential을 localStorage/telemetry/URL에 저장하는 fixture 실패
- server state를 client workflow store에 복제하는 fixture 실패
- recipe 제거 후 base typecheck/test/build 통과
- opt-in하지 않은 recipe가 production bundle에 없음을 검증
**Acceptance**
- 선택 capability는 port/facade, fake adapter, failure matrix, tests와 제거
절차를 가진다.
- 선택하지 않은 capability는 dependency와 production code를 늘리지 않는다.
- recipe 하나의 도입/제거가 다른 recipe와 P0/P1 runtime을 변경하지 않는다.
**Rollback**
RP-12는 recipe별 merge commit이다. optional adapter 문제 시 해당 recipe commit만
revert하고 RP-11을 유지한다. 여러 vendor를 되돌릴 수 없는 한 commit에 묶지
않는다.
## 11. Vendor decision gate
| ID | 시점 | 결정 | 기본값 또는 미결정 시 처리 | 차단 범위 |
| --- | --- | --- | --- | --- |
| VD-01 | 01 전 | TypeScript migration 범위와 compiler 변경 | 기존 compiler 고정, tooling-first 점진 전환 | compiler upgrade와 source 변환 |
| VD-02 | 02 전 | 범용 client store | 추가하지 않음. local/URL/query/context 사용 | 실제 cross-feature workflow만 |
| VD-03 | 04 전 | React Router mode | Data Mode; server state는 TanStack/application 유지 | Framework/SSR 전환만 |
| VD-04 | 06 전 | form engine | 복합 form이면 RHF+Zod facade, 아니면 local/native | form 구현체 |
| VD-05 | 07 전 | icon/headless UI | local facade, native 우선; 미지원 behavior는 미완료 | icon/복합 primitive |
| VD-06 | 08 전 | i18n engine | `Intl` + typed local catalog | extraction/plural 고급 기능 |
| VD-07 | 09 전 | diagnostics/telemetry exporter | no-op/best-effort HTTP | production sink만 |
| VD-08 | 10 전 | Storybook/visual 방식 | dev-only Storybook + local Playwright baseline | cloud review/별도 배포 |
| VD-09 | 11 전 | vulnerability/license/SBOM/provenance | 임의 PASS 금지, `FAIL_UNVERIFIED` | release promotion |
| VD-10 | 12 전 | optional capability 요구 | 설치하지 않음 | 해당 recipe만 |
각 decision에는 실제 요구, bundle/runtime/a11y/security 영향, local facade 방식,
대안, fallback/migration/removal, owner와 재검토 시점을 기록한다.
결정이 지연될 때 vendor-neutral port와 no-op/fake/unavailable adapter를 먼저
완성한다. 다만 vendor가 있어야 충족되는 behavior를 구현하지 않은 채 완료로
표시해서는 안 된다.
## 12. Rollback과 promotion
| checkpoint | 포함 범위 | 다음 단계 진입 조건 | 대표 rollback 사유 |
| --- | --- | --- | --- |
| RP-01 | tooling | TS/JS fixture와 gate 통과 | TS 파일이 검사에서 누락 |
| RP-02 | application boundary | composition integration 통과 | UI가 raw adapter에 의존 |
| RP-03 | HTTP/query | request/state negative matrix 통과 | query 손실, retry/submit 폭주 |
| RP-04 | routing/recovery | registry와 reload-loop drill 통과 | invalid URL API 호출, reload loop |
| RP-05 | P0 reference | feature 존재/제거 gate 통과 | hidden sample coupling |
| RP-06 | form/page | form/page/a11y gate 통과 | 입력 손실, template 결합 |
| RP-07 | design system | token/interaction gate 통과 | focus/theme/vendor 회귀 |
| RP-08 | i18n | catalog/pseudo/RTL gate 통과 | missing key, locale 회귀 |
| RP-09 | diagnostics | producer/redaction gate 통과 | PII 노출, event 폭주 |
| RP-10 | test/registry | actual diff와 evidence gate 통과 | false PASS, flaky blocker |
| RP-11 | P1 supply chain | `LOCAL_TEMPLATE_READY` | 위험 dependency/검증 공백 |
| RP-12 | optional recipe | recipe별 opt-in gate | vendor 격리 실패 |
```text
RP-05 + P0 exit
= LOCAL_CORE_READY
RP-11 + P1 exit
= LOCAL_TEMPLATE_READY
LOCAL_TEMPLATE_READY
+ 실제 hosting/IdP/보안/접근성/운영 증거
= PROJECT_INTEGRATION_READY
PROJECT_INTEGRATION_READY
+ production promotion과 eligible field data
= PRODUCTION/FIELD READY
```
저장소 내부와 외부 증거를 하나의 백분율로 숨기지 않는다. P0/P1의 모든
acceptance가 자동 검증되고 외부 항목이 명확히 분리된
`LOCAL_TEMPLATE_READY`를 높은 저장소 내부 준비도의 실질 기준으로 사용한다.
## 13. 브랜치 공통 인수 체크리스트
각 feature branch는 병합 전에 다음을 evidence에 남긴다.
- 목표와 변경하지 않는 범위
- 시작한 `develop` SHA와 선행 merge SHA
- 변경한 public contract, registry와 compatibility 영향
- positive/negative fixture
- 실행한 typecheck, lint, architecture, unit, integration, E2E와 build 명령
- artifact와 browser/runtime 환경
- dependency/lockfile 변경 및 vendor decision ID
- fake/no-op/unavailable adapter로 검증한 범위
- external blocker와 `FAIL_UNVERIFIED` promotion
- merge commit과 보존된 local/origin feature tip
- rollback 대상 merge와 복구 확인 gate
acceptance를 만족하지 못한 브랜치는 후속 브랜치의 기반으로 사용하지 않는다.
test를 skip하거나 문서상 예외로 바꾸는 것은 완료가 아니다.
File diff suppressed because it is too large Load Diff
+8
View File
@@ -22,6 +22,14 @@ The following edges are forbidden:
`bootstrap` contains composition only. Business rules and page-specific
orchestration belong to domain/application.
This table is the current coarse-grained rule. The
[ports, adapters, and feature-boundary target](./frontend-ports-adapters-and-boundaries.md)
defines the missing application input boundary, explains that `presentation`
acts as the inbound adapter, and separates current outbound adapters from
project-selected capabilities. The
[platform capability review](./frontend-platform-capability-review.md) records
where the current composition still bypasses this intended rule.
Architecture reports use this shape:
```json
+61
View File
@@ -0,0 +1,61 @@
# Architecture overview
This Mermaid view is a repository-local implementation projection. The
`PASS_SCOPED` reviewer evidence applies to the canonical draw.io diagram named
in `review-ledger.json`, not automatically to edits in this file.
```mermaid
flowchart LR
Bootstrap[bootstrap / composition root] --> Presentation[presentation]
Bootstrap --> Adapters[adapters]
Presentation --> Shell[app shell and route surfaces]
Shell --> Providers[session and theme providers]
Presentation --> Application[application]
Adapters --> Application
Application --> Domain[domain]
Contracts[contract registries] --> Bootstrap
Contracts --> Adapters
Contracts --> Presentation
```
The intended dependency rule points inward: presentation calls application use
cases, adapters implement application ports, and only the composition root
selects concrete adapters. Contract registries are the intended named source
for routes, API operations, environment values, storage keys, errors, queries,
telemetry, and release tokens. The platform review below records where the
current runtime still bypasses that target or duplicates registry metadata.
In ports-and-adapters terms, `presentation` is the current inbound adapter and
`adapters` contains the current outbound implementations. The target design
makes this role explicit, introduces application input ports, and prevents the
React tree from receiving raw outbound dependencies:
```mermaid
flowchart LR
Driver[User, route, browser event] --> Inbound[React inbound adapter]
Inbound --> Input[Application input API]
Input --> UseCase[Use cases]
UseCase --> Output[Application output ports]
Output --> Outbound[HTTP, auth, storage, query, telemetry adapters]
Bootstrap2[Composition root] -. selects and injects .-> Input
Bootstrap2 -. selects and injects .-> Outbound
```
The current executable route tree is mounted only after runtime configuration
and release-manifest coherence pass. It receives the composed query client,
credential-opaque session port, storage port, telemetry port, and immutable
build ID. Generic starter pages do not depend on the removable reference
feature.
This describes the current starter composition, not the completed target. The
capability review found that raw outbound capabilities still reach the React
tree, the composed application facade is not yet its entry point, and several
route, HTTP, recovery, telemetry, and reference-feature removal contracts are only
partially connected. Use the following documents for the evidence and migration
plan:
- [Frontend platform capability review](./frontend-platform-capability-review.md)
- [Frontend ports, adapters, and boundaries](./frontend-ports-adapters-and-boundaries.md)
- [TypeScript, state, and data flow](./typescript-state-and-data-flow.md)
- [Routing, pages, and patterns](./routing-pages-and-patterns.md)
- [Frontend platform implementation roadmap](./frontend-platform-implementation-roadmap.md)
+22
View File
@@ -0,0 +1,22 @@
# Imported scoped diagram review evidence
This ledger entry consumes the canonical evidence already recorded by the
`ca-skeleton-frontend-operational-contract` project note. It does not claim
review of the repository-local Mermaid projections or of the complete
production deployment topology.
- Reviewer: `wiki-diagram-reviewer`
- Standard: `rules/diagram-standards.md` v2
- Canonical report:
`docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md`
- Canonical report SHA-256:
`b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29`
| Canonical diagram | SHA-256 | Score | Verdict | Reviewed scope |
| --- | --- | ---: | --- | --- |
| `raw/diagrams/ca-skeleton-frontend/architecture-overview-2026-07-18.drawio` | `c0ae56c9c964c5c6e698ab7dcc91736b9b811b2b834381817905db81c4230ba0` | 100 | PASS | Clean Architecture compile-time dependency ownership |
| `raw/diagrams/ca-skeleton-frontend/architecture-deployment-2026-07-18.drawio` | `9a654326fb840ddf24b832221ff7eec4b8fadd9f87ad84174fccfa3bfcd1a25b` | 100 | PASS | immutable static assets and mutable `/config.json` delivery |
The canonical report explicitly limits this `PASS_SCOPED`: it does not verify
the complete release/rollback topology, the implementation topology, or live
hosting state.
+29
View File
@@ -0,0 +1,29 @@
{
"schemaVersion": 1,
"status": "PASS_SCOPED",
"reviewer": "wiki-diagram-reviewer",
"standard": "rules/diagram-standards.md v2",
"evidenceReport": {
"repoPath": "docs/architecture/review-evidence.md",
"canonicalPath": "docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md",
"canonicalSha256": "b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29"
},
"reviews": {
"overview": {
"sourcePath": "raw/diagrams/ca-skeleton-frontend/architecture-overview-2026-07-18.drawio",
"sha256": "c0ae56c9c964c5c6e698ab7dcc91736b9b811b2b834381817905db81c4230ba0",
"score": 100,
"verdict": "PASS",
"thresholdSatisfied": true,
"scope": "Clean Architecture compile-time dependency ownership"
},
"staticDelivery": {
"sourcePath": "raw/diagrams/ca-skeleton-frontend/architecture-deployment-2026-07-18.drawio",
"sha256": "9a654326fb840ddf24b832221ff7eec4b8fadd9f87ad84174fccfa3bfcd1a25b",
"score": 100,
"verdict": "PASS",
"thresholdSatisfied": true,
"scope": "immutable static assets and mutable /config.json delivery"
}
}
}
@@ -0,0 +1,498 @@
# 라우팅, 페이지 템플릿, 재사용 패턴
## 1. 목적
이 문서는 도메인과 무관하게 다음을 바로 구현할 수 있는 기준을 제공한다.
- typed route와 안전한 URL
- session, permission, feature flag guard
- lazy chunk의 loading/error/recovery
- route 이동 시 focus, scroll, 취소, dirty form 처리
- list, detail, form, status 등 공통 page template
- page controller와 application input use case의 연결
- 프론트엔드에서 반복 사용하는 설계 패턴
## 2. 현재 상태와 구현 기준
RP-04 이후 route runtime과 RP-06 page/form platform에는 다음 장점이 있다.
- route registry가 path와 access policy를 소유한다.
- contract에서 Data Router route object와 navigation을 생성한다.
- runtime map이 route component를 lazy import하고 codec을 연결한다.
- 앱 셸과 보호 route, not-found surface가 있다.
- route heading focus와 비동기/render error boundary가 있다.
- redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다.
- `src/presentation/templates`가 standard/collection/detail/form/status slot,
landmark와 responsive layout을 제공한다.
- `src/presentation/forms`가 첫 오류 focus, error summary, 422 mapping,
duplicate submit과 dirty route blocker를 소유한다.
- reference feature의 list/detail/create/status route가 네 template variation을
production composition에서 실행한다.
platform route 계약, `src/features/installed-feature-contracts.js`,
`src/features/installed-feature-runtimes.tsx`의 완전성은 TypeScript와 registry
negative fixture가 함께 검사한다. params/search codec, loading/error surface,
access, title, navigation, chunk ID는
`src/presentation/routes/app-router.tsx`에서 모두 소비된다. built Vite
manifest의 dynamic entry는 release manifest route chunk map과 검증되며,
`ChunkRecoveryBoundary`는 일반 render error와 chunk rejection을 분리한다.
template는 데이터를 가져오지 않는다. reference page controller가 route input과
application input을 query/form facade에 연결하고, template에는 render할 slot과
안전한 callback만 전달한다. 이 분리는
`page-templates-own-layout-only` dependency rule과 forbidden import fixture가
검증한다.
## 3. React Router mode 결정
[React Router 공식 mode 설명](https://reactrouter.com/start/modes)과
[Data Mode custom setup](https://reactrouter.com/start/data/custom)은
Declarative, Data, Framework Mode를 구분한다.
저장소에는 현재 React Router `7.18.1`이 고정돼 있다. 구현 브랜치는 공식 문서의
동일 버전 API를 기준으로 하고 router version upgrade를 Data Mode 구조 변경과
같은 브랜치에 섞지 않는다. 위 공식 링크의 기본 표시 버전이 바뀌면 version
selector를 `7.18.1`로 맞춰 확인한다.
| mode | 선택 조건 | 이 저장소에서의 판단 |
| --- | --- | --- |
| Declarative | React composition과 외부 data layer가 route data를 소유 | RP-04 이전 기준선 |
| Data | route object, blocker, scroll restoration, pending/navigation state가 필요 | VD-03으로 채택하고 RP-04에서 구현 |
| Framework | route module, type-safe href, code splitting, SSR/static 전략을 framework가 소유 | client-only skeleton 기본값으로는 범위가 큼 |
채택한 결정:
- client-only SPA와 TanStack Query/application use case를 유지한다.
- `createBrowserRouter``RouterProvider` 기반 Data Mode를 사용한다.
- Data Mode를 선택하는 이유는 route object, navigation blocker, scroll
restoration, route error 경계를 일관되게 소유하기 위해서다. loader/action으로
서버 상태를 다시 소유하기 위해서가 아니다.
- loader/action을 추가할 때는 TanStack Query/application input을 prefetch하거나
호출하는 한 가지 소유 경로만 사용한다.
- 같은 데이터를 route loader와 TanStack Query가 각각 가져오지 않는다.
- SSR/static generation을 선택하기 전에는 Framework Mode를 기본값으로 만들지
않는다.
결정 근거와 rollback 경계는
`docs/architecture/decisions/VD-03-react-router-data-mode.md`에 고정한다.
Data Mode를 사용할 수 없는 프로젝트만 별도 ADR과
`NavigationLifecycleAdapter`를 구현한다.
## 4. route 계약과 runtime map
### 4.1 두 종류의 레지스트리
직렬화 가능한 contract와 React implementation을 분리한다.
```ts
export const routeContracts = {
home: {
id: "home",
path: "/",
access: "public",
navigation: "primary",
titleKey: "route.home.title",
loadingSurface: "page",
errorSurface: "page",
chunkId: "home",
},
resourceDetail: {
id: "resourceDetail",
path: "/examples/resources/:resourceId",
access: "authenticated",
navigation: "hidden",
titleKey: "route.resourceDetail.title",
loadingSurface: "detail",
errorSurface: "detail",
chunkId: "reference-resource-detail",
},
} as const satisfies RouteContractRegistry;
```
```tsx
export const routeRuntime = {
home: {
Component: lazy(() => import("../pages/home-page")),
paramsCodec: emptyParamsCodec,
searchCodec: emptySearchCodec,
},
resourceDetail: {
Component: lazy(() => import("../features/resources/resource-detail-page")),
paramsCodec: resourceDetailParamsCodec,
searchCodec: resourceDetailSearchCodec,
},
} satisfies Record<RouteId, RouteRuntime>;
```
요구 사항:
- contract key와 `id`가 다르면 typecheck 실패
- contract에는 함수, component, schema instance처럼 직렬화 불가능한 값을 넣지 않음
- runtime map에는 실제 lazy component와 codec/guard만 둠
- contract의 모든 route가 runtime에 있고 runtime의 모든 key가 contract에 있음
- navigation은 contract에서 파생
- build chunk manifest와 `chunkId` 대응을 검증
- public runtime config나 server가 route component 이름을 임의 지정할 수 없음
### 4.2 params와 search codec
URL은 외부 입력이다. page에서 `useParams()` 결과를 cast하지 않는다.
```ts
const resourceDetailParamsSchema = z.object({
resourceId: z.string().trim().min(1).max(100),
});
const resourceListSearchSchema = z.object({
q: z.string().trim().max(100).catch(""),
page: z.coerce.number().int().min(1).catch(1),
sort: z.enum(["updated-desc", "name-asc"]).catch("updated-desc"),
});
```
path params와 search params는 입력 형태와 serialization 규칙이 다르므로 같은
interface로 뭉치지 않는다. 각각 parse와 serialize를 제공한다.
```ts
interface PathParamsCodec<T> {
parse(
input: Readonly<Record<string, string | undefined>>,
): Result<T, RouteInputFailure>;
serialize(value: T): Readonly<Record<string, string>>;
}
interface SearchParamsCodec<T> {
parse(input: URLSearchParams): Result<T, RouteInputFailure>;
serialize(value: T): URLSearchParams;
}
```
규칙:
- route input parse 실패와 backend 404를 구분한다.
- 알 수 없는 search key를 보존할지 제거할지 route별로 선언한다.
- default value를 URL에 항상 쓸지 생략할지 codec이 결정한다.
- array/date/boolean encoding을 feature마다 다르게 만들지 않는다.
- navigation link도 codec 기반 builder를 사용한다.
- query key에는 parsed value만 사용한다.
- 검색어·식별자를 telemetry에 기록하기 전에 sensitivity policy를 적용한다.
### 4.3 route object 생성
하나의 factory가 다음을 조합한다.
```text
route contract
+ runtime component/codecs
+ access guard
+ feature flag guard
+ suspense surface
+ render/chunk error surface
+ title/focus/scroll behavior
-> executable route object/tree
```
JSX에서 route별 `<Route>`를 다시 나열하지 않는다. nested layout이 필요한 경우
contract에 parent ID를 두고 cycle/orphan/duplicate path를 registry gate에서
검사한다.
## 5. guard와 권한
### 5.1 guard 순서
권장 순서:
1. runtime/bootstrap readiness
2. route 존재와 URL parse
3. feature flag
4. session readiness
5. authentication
6. coarse client permission hint
7. route component
8. server authorization result
client guard는 UX 최적화일 뿐 보안 경계가 아니다. API/BFF가 항상 최종 권한을
검사한다.
### 5.2 guard 결과
```ts
type GuardDecision =
| { kind: "allow" }
| { kind: "redirect"; to: SafeLocation; reason: RedirectReason }
| { kind: "render"; surface: "auth-required" | "forbidden" | "not-found" };
```
- redirect에는 origin route와 bounded return URL을 사용한다.
- 외부 redirect는 allowlist를 거친다.
- 동일한 route 쌍을 반복하는 redirect loop를 차단한다.
- session이 아직 resolving이면 forbidden으로 단정하지 않는다.
- server가 403을 반환하면 client claim을 신뢰해 화면을 계속 보여 주지 않는다.
## 6. navigation lifecycle
### 6.1 loading
loading surface를 route metadata에 연결한다.
| surface | 사용 |
| --- | --- |
| shell | 초기 앱 셸 진입 |
| page | 새로운 전체 페이지 |
| collection | table/list 구조 유지 |
| detail | metadata/content 구조 유지 |
| form | 필드 layout 구조 유지 |
| inline | 부분 action |
cached data가 있으면 full-page skeleton으로 교체하지 않고 refreshing indicator를
사용한다. `prefers-reduced-motion`에서 skeleton animation을 줄인다.
### 6.2 error와 lazy chunk recovery
route error boundary는 다음을 구분한다.
- render/programmer error
- dynamic import/chunk load error
- application `AppFailure`
- URL parse failure
- not found
chunk recovery 순서:
1. 현재 build ID와 release manifest를 확인한다.
2. 새 manifest가 확인되고 같은 build에 대해 reload하지 않았다면 한 번만 reload한다.
3. 같은 failure가 반복되면 reload loop를 막는다.
4. 안전한 support surface와 trace/build ID를 표시한다.
5. recovery 결과를 redacted diagnostics에 기록한다.
custom fallback을 넘겨 retry/reset 기능을 잃지 않게 한다. boundary는
`location.key` 또는 route ID가 바뀌면 적절히 reset된다.
### 6.3 focus와 scroll
- route 성공 후 `main`의 page heading에 programmatic focus
- mouse 사용자가 불필요한 focus ring을 보지 않게 할 수는 있지만 keyboard focus
indication을 전역으로 제거하지 않음
- modal/drawer가 닫히면 opener에 focus 복원
- backward/forward navigation은 저장한 scroll 복원
- 새 primary route는 top으로 이동
- hash target은 fixed header offset과 focus 가능 여부를 처리
- screen reader용 route title/live announcement는 중복 발표를 피함
### 6.4 취소와 dirty form
- route 이동 시 진행 중 query signal을 취소한다.
- mutation은 취소 안전성이 명확할 때만 취소한다.
- dirty form blocker는 browser unload와 in-app navigation을 구분한다.
- 성공 저장 후 blocker를 해제한 다음 이동한다.
- autosave가 있는 form은 pending/failed 상태를 별도로 알린다.
- confirm dialog는 공통 accessible primitive를 사용한다.
## 7. 페이지 템플릿
template은 데이터를 가져오거나 application을 호출하지 않는다. 슬롯, landmark,
focus target, responsive layout, 상태 위치만 소유한다.
### 7.1 `StandardPageTemplate`
슬롯:
- breadcrumb 또는 back link
- title, description, status badge
- primary/secondary actions
- notices
- content
- contextual aside
작은 화면에서 action wrapping 순서와 heading hierarchy를 보장한다.
### 7.2 `CollectionPageTemplate`
슬롯과 상태:
- title/actions
- search/filter/sort toolbar
- active filter summary와 reset
- result count
- table/list/card view
- pagination 또는 load-more
- initial loading, refreshing, empty-first-use, empty-filtered, error
- bulk selection/action
URL이 filter, sort, page를 소유한다. template은 query 상태를 직접 읽지 않는다.
### 7.3 `DetailPageTemplate`
- breadcrumb/back
- title/status/actions
- summary metadata
- main sections
- related/context aside
- loading/not-found/forbidden/error
- destructive action confirmation 위치
식별자가 바뀔 때 이전 entity 내용과 새 loading 상태를 혼동하지 않게 key/reset
정책을 명시한다.
### 7.4 `FormPageTemplate`
- title/description
- error summary
- field groups
- optional aside/help
- sticky 또는 normal action bar
- submit/cancel
- submitting/saved/conflict/unavailable
- dirty navigation confirmation
template은 특정 form vendor를 import하지 않는다.
### 7.5 `StatusPageTemplate`
다음 변형을 제공한다.
- unauthenticated
- forbidden
- not found
- unavailable
- offline
- maintenance
- unexpected
각 변형은 heading, 짧은 설명, 안전한 primary/secondary action, 선택적 trace ID를
갖는다. raw stack/response를 표시하지 않는다.
### 7.6 선택 template
다음은 project 필요가 있을 때 추가한다.
- `SettingsPageTemplate`
- `DashboardGridTemplate`
- `SplitPaneTemplate`
- `WizardTemplate`
- `FullScreenTaskTemplate`
## 8. page controller 패턴
page를 세 부분으로 나눈다.
```text
route adapter
parses URL and guard context
controller hook
invokes application query/mutation and maps UI events
page view
renders template and design-system components
```
예:
```tsx
export function ResourceListRoute() {
const input = useRouteInput(resourceListRoute);
if (!input.ok) return <InvalidRouteSurface failure={input.error} />;
return <ResourceListController input={input.value} />;
}
function ResourceListController({ input }: ResourceListControllerProps) {
const controller = useResourceListController(input);
return <ResourceListPage controller={controller} />;
}
```
route parse boundary와 controller component를 분리하므로 controller hook은
조건부로 호출되지 않는다.
controller가 소유하는 것:
- parsed route input을 application input으로 변환
- query/mutation state
- pagination/filter/navigation event
- retry/refresh/action callbacks
- view model projection
view가 소유하는 것:
- semantic markup
- template/component 조립
- focus target
- 사용자의 local-only interaction
controller가 소유하지 않는 것:
- HTTP URL 조립
- credential
- transport DTO parse
- 도메인 invariant
- raw vendor SDK
## 9. 권장 패턴 카탈로그
| 패턴 | 적용 위치 | 쓰는 이유 | 오용 |
| --- | --- | --- | --- |
| Ports and Adapters | application 외부 경계 | 정책과 기술 교체 분리 | 모든 작은 UI library에 port 생성 |
| Command/Query | application input | 읽기/변경 의도와 정책 분리 | CQRS 인프라를 필요 없이 도입 |
| Gateway | output port | 외부 데이터 capability 표현 | `get/post` 범용 HTTP를 application에 노출 |
| Anti-Corruption Mapper | outbound feature adapter | DTO 변화가 core로 전파되지 않게 함 | 단순 object spread로 타입만 바꿈 |
| Result | 예상 실패 | 실패 종류와 처리 경로를 닫음 | programmer error까지 모두 Result로 숨김 |
| Controller/View | inbound React | data lifecycle과 markup 분리 | 거대한 hook 하나에 모든 feature 로직 집중 |
| Strategy | retry/cache/auth recovery | 정책 교체와 테스트 가능성 | 설정 한 줄도 interface로 과도 추상화 |
| Observer/External Store | session/theme/realtime | React 외부 소유 상태 구독 | server state를 다시 external store에 복제 |
| State Machine | 복잡한 workflow | 유효 전이와 보상 명시 | 단순 modal open에 도입 |
| Headless/Compound Component | 복합 UI | behavior와 style/slot 분리 | vendor primitive를 제품 전역에 직접 노출 |
| Adapter Facade | icon/form/i18n vendor | React inbound 내부 vendor 교체 경계 | application port로 승격 |
| Page Template | 반복 layout/state | 접근성과 반응형 구조 재사용 | data fetching을 template에 포함 |
| Registry + Runtime Map | route/operation/event | 선언과 실행 완전성 | 모든 설정을 하나의 거대 전역 파일에 집중 |
패턴은 추상화 파일만 만든 것으로 완료되지 않는다. reference usage, negative
architecture test, 실패 상태 test가 있어야 제공된 패턴으로 본다.
## 10. 새 route/page 추가 recipe
1. feature public 경계와 route ID를 정한다.
2. serializable route contract를 등록한다.
3. params/search Zod schema와 bidirectional codec을 작성한다.
4. safe URL builder를 export한다.
5. lazy page module과 runtime map entry를 추가한다.
6. session/permission/flag guard를 선언한다.
7. 적절한 page template을 선택한다.
8. controller hook을 application input API에 연결한다.
9. loading, empty, refreshing, error, auth, forbidden, not-found를 결정한다.
10. title/message key, focus, scroll, chunk ID를 연결한다.
11. 다음 검증을 추가한다.
- contract/runtime map type completeness
- codec round-trip/property cases
- guard decision unit
- page component state
- query/mutation integration
- keyboard/focus/axe
- direct URL, back/forward, refresh E2E
- chunk failure recovery가 필요한 route의 E2E
12. registry, type, architecture, component, integration, E2E gate를 실행한다.
## 11. 금지 패턴
- page 안에서 raw `fetch`, storage, auth SDK, telemetry SDK 호출
- `useParams()`/`URLSearchParams` 값을 cast만 하고 사용
- route contract와 JSX route tree를 각각 수동 관리
- protected route를 server authorization 대체 수단으로 취급
- 모든 실패를 redirect 또는 full-page error로 처리
- query data가 있는데 background error 때문에 내용을 제거
- chunk load error에서 제한 없는 `location.reload`
- route heading focus outline을 CSS로 무조건 제거
- template이 data fetching 또는 feature-specific copy를 소유
- generic `BasePage` prop 하나에 모든 layout variation을 boolean으로 추가
## 12. 완료 기준
- 모든 route ID가 contract와 runtime map에서 compile-time 완전성을 가진다.
- params/search parse와 URL serialize가 같은 codec을 사용한다.
- route metadata가 loading/error/chunk/title/navigation 행동에 실제 연결된다.
- redirect와 chunk reload loop가 차단된다.
- route 이동 시 query 취소, focus, scroll, dirty policy가 검증된다.
- collection/detail/form/status reference page가 template을 사용한다.
- page view가 application input 외의 외부 capability를 직접 호출하지 않는다.
- 새 route recipe와 테스트만으로 별도 라우터 내부 지식 없이 기능을 추가할 수 있다.
+79
View File
@@ -0,0 +1,79 @@
# Starter experience contract
The repository provides a runnable, domain-neutral application rather than
only infrastructure contracts. The starter experience is intentionally
replaceable at the page level while the shell, providers, boundaries,
primitives, and state surfaces remain reusable.
## Runtime composition
```text
validated config + coherent release manifest
-> concrete adapters
-> QueryClientProvider
-> ApplicationProvider
-> RouterProvider
-> ThemeProvider
-> SessionProvider
-> AppShell
-> lazy route boundary
```
Boot stops before product mount when configuration or release coherence fails.
Each lazy page renders inside Suspense and a telemetry-aware route boundary.
Route changes move focus to the new page heading; the skip link and landmarks
remain stable in the shell.
## Registered routes
| ID | Path | Access | Surface |
| --- | --- | --- | --- |
| `APP_HOME` | `/` | public | readiness dashboard |
| `EXAMPLES_UI` | `/examples/ui` | public | interactive primitives and tokens |
| `EXAMPLES_STATES` | `/examples/states` | public | async and access state matrix |
| `EXAMPLES_AUTH` | `/examples/auth` | public | session integration controls |
| `REFERENCE_RESOURCE_LIST` | `/examples/reference-resources` | integration-defined | removable vertical slice |
| `NOT_FOUND` | `*` | public | safe navigation recovery |
Navigation labels and order come from `ROUTE_REGISTRY`; the sidebar does not
maintain a second route list. The client access decision never claims to be
authorization.
## Authentication seam
The session port exposes state subscription, sign-in start, sign-out,
credential attachment, recovery, and unauthenticated notification. Credentials
remain opaque to the application and presentation layers.
- `demo`: local/development-only state transition with no credentials
- `external`: delegates to `globalThis.__CA_FRONTEND_AUTH_OWNER__`
- missing/invalid external owner: fails closed as `integration-failed`
An external owner implements `readState`, `subscribe`, `beginSignIn`,
`signOut`, `attachCredential`, `recoverSession`, and
`notifyUnauthenticated`. It owns token acquisition and storage.
## Extending the starter
The steps below describe the current extension path. New platform work should follow
[routing, page templates, and reusable patterns](./routing-pages-and-patterns.md)
and the
[TypeScript, state, and data-flow target](./typescript-state-and-data-flow.md)
rather than adding another independent route or data-loading convention.
1. Add a serializable contribution under the feature ownership boundary and
install it through `src/features/installed-feature-contracts.js`.
2. Add the lazy component and route codecs through
`src/features/installed-feature-runtimes.tsx`.
3. Compose feature application inputs and outbound gateways only through
`src/features/installed-feature-adapters.ts`.
4. Use the semantic tokens, UI primitives, and state surfaces before adding a
project-specific variant.
5. Add component behavior, all-engine E2E, automated axe, and signed manual
route evidence.
6. Run `test:sample-removal` to prove the generic starter typechecks, passes
architecture/registry/tests/home smoke, and builds without the complete
reference feature.
Theme preference is the public `COLOR_SCHEME` storage contract. Authentication
tokens and other secrets remain forbidden storage keys.
+25
View File
@@ -0,0 +1,25 @@
# Static asset and runtime-config delivery
This Mermaid view is a repository-local implementation projection. The
`PASS_SCOPED` reviewer evidence applies only to the canonical static-delivery
draw.io scope recorded in `review-ledger.json`.
```mermaid
sequenceDiagram
participant CI
participant ImmutableRelease
participant ActivePointer
participant Browser
CI->>ImmutableRelease: upload hashed assets
CI->>ImmutableRelease: upload release manifest
CI->>ImmutableRelease: upload runtime config
CI->>ImmutableRelease: probe asset reachability
CI->>ActivePointer: atomically switch HTML
Browser->>ActivePointer: fetch revalidated HTML
Browser->>ImmutableRelease: fetch no-store config and manifest
Browser->>ImmutableRelease: fetch immutable hashed assets
CI->>Browser: boot, route, API, and reload-loop smoke
```
Rollback changes the active pointer only after confirming that the prior
immutable release has a coherent HTML/assets/config/API/manifest tuple.
@@ -0,0 +1,587 @@
# TypeScript, 상태 소유권, 데이터 흐름
## 1. 목적
이 문서는 다음 질문에 대한 저장소 표준을 정의한다.
- JavaScript를 어떤 순서로 TypeScript로 전환하는가.
- local, URL, server, form, global, persisted 상태를 어디에 둬야 하는가.
- React 화면이 application use case와 TanStack Query를 어떻게 사용해야 하는가.
- HTTP, retry, auth, error, validation, logging의 책임을 어떻게 나누는가.
- 새 query, mutation, form을 추가할 때 어떤 파일과 테스트가 필요한가.
이 문서는 목표 설계다. 현재 구현 상태는
[프론트엔드 플랫폼 역량 재검토](./frontend-platform-capability-review.md)를 따른다.
## 2. TypeScript 전환 원칙
### 2.1 왜 전환하는가
현재 `strict + allowJs + checkJs`는 JavaScript 상태에서 유용한 안전망이다. 그러나
JSDoc cast가 늘어나면 다음 계약을 정확히 닫기 어렵다.
- `RouteId`, `OperationId`, `ErrorCode`, `StorageKey`, `TelemetryEvent`
- `Result<T, E>`와 discriminated failure union
- use case input/output와 gateway generic
- route별 params/search type
- query key tuple
- component variant와 slot prop
- runtime registry의 key와 executable implementation의 완전성
TypeScript 전환 목적은 확장자 변경이 아니라 이 계약을 컴파일 단계에서
검증하는 것이다.
### 2.2 전환 전에 고칠 도구
다음 변경이 첫 브랜치에서 완료되기 전에는 source rename을 시작하지 않는다.
1. ESLint가 `js`, `jsx`, `mjs`, `ts`, `tsx`, `mts`를 모두 검사한다.
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`의 경로 갱신 절차를 만든다.
6. Vite, Vitest, Playwright, scripts, source, tests를 각각 typecheck한다.
7. invalid type fixture가 TS migration 후에도 “실패해야 통과”하는지 확인한다.
8. architecture/security/registry gate가 TS fixture 위반을 실제로 잡는 negative test를
추가한다.
권장 project 구성:
```text
tsconfig.base.json
tsconfig.app.json
tsconfig.node.json
tsconfig.test.json
tsconfig.json # project references only
```
`tsconfig.base.json`의 초기 핵심 옵션:
```json
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"isolatedModules": true
}
}
```
실제 TypeScript 7/Vite 호환 옵션은 설치된 공식 문서와 빌드 결과를 기준으로
확정한다. 옵션을 한꺼번에 켜서 수백 개 예외를 만들지 말고, 각 단계에서 새
예외를 금지한다.
현재 저장소의 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 전환 순서
| 단계 | 대상 | 이유 | 종료 조건 |
| --- | --- | --- | --- |
| 0 | lint/typecheck/scanner/architecture tooling | TS 코드가 검사를 우회하지 않게 함 | TS 위반 fixture가 각 게이트에서 실패 |
| 1 | result, failure, ID, registry types | 이후 모든 계층의 언어가 됨 | stringly typed public ID 제거 |
| 2 | application input/output ports와 use case | 중심 계약을 먼저 고정 | input/output compile fixture 통과 |
| 3 | domain model과 mapper boundary | DTO와 core model 혼합 차단 | mapper contract test 통과 |
| 4 | outbound adapters | 외부 `unknown`을 경계에서 좁힘 | HTTP/storage/auth failure type 통과 |
| 5 | bootstrap/composition | 누락 dependency를 컴파일로 검출 | 실제 composition type test 통과 |
| 6 | React providers/controllers/routes | typed application API 소비 | route/runtime map 완전성 검사 |
| 7 | primitives/pages/templates | component API와 variant를 닫음 | stories/component tests typecheck |
| 8 | tests/scripts/config | 우회 없는 전체 저장소 | source `allowJs` 제거 가능 |
각 단계는 빌드 가능한 작은 커밋으로 유지한다. JavaScript와 TypeScript가 공존하는
동안에는 [공식 JavaScript migration 가이드](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html)의
점진적 방식을 사용한다.
### 2.4 기본 type 계약
다음 형태를 core application에 둔다.
```ts
export type Ok<T> = Readonly<{ ok: true; value: T }>;
export type Err<E> = Readonly<{ ok: false; error: E }>;
export type Result<T, E> = Ok<T> | Err<E>;
export type AppFailure =
| Readonly<{ kind: "unauthenticated"; code: "AUTH_REQUIRED"; traceId?: string }>
| Readonly<{ kind: "forbidden"; code: "FORBIDDEN"; traceId?: string }>
| Readonly<{ kind: "not-found"; code: "NOT_FOUND"; traceId?: string }>
| Readonly<{ kind: "conflict"; code: "CONFLICT"; traceId?: string }>
| Readonly<{
kind: "validation";
code: "VALIDATION_FAILED";
fields: Readonly<Record<string, readonly string[]>>;
traceId?: string;
}>
| Readonly<{ kind: "rate-limited"; code: "RATE_LIMITED"; retryAt?: Date }>
| Readonly<{ kind: "unavailable"; code: "UNAVAILABLE"; retryable: boolean }>
| Readonly<{ kind: "unexpected"; code: "UNEXPECTED"; traceId?: string }>;
```
원칙:
- adapter에서 받은 `unknown`은 adapter 경계에서 parse한다.
- application은 `Response`, `AxiosError`, Zod 내부 오류 같은 vendor type을
노출하지 않는다.
- UI copy는 failure에 저장하지 않고 message key mapper에서 결정한다.
- 예상 가능한 실패는 `Result`; programmer bug와 render crash는 error boundary로
보낸다.
- `as`, non-null assertion, `any`는 경계에서 근거가 있을 때만 사용하고 lint
예외에 사유를 기록한다.
## 3. 상태 소유권
### 3.1 상태 분류표
상태를 만들기 전에 아래 순서로 소유자를 결정한다.
| 질문 | 상태 종류 | 기본 도구 | 저장 위치 |
| --- | --- | --- | --- |
| 한 컴포넌트 상호작용에만 필요한가 | local UI | `useState`, `useReducer` | component/controller |
| URL로 공유·복원되어야 하는가 | navigation | router params/search + codec | URL |
| 서버가 진실의 원천인가 | server state | TanStack Query inbound adapter | Query cache |
| 입력 중이고 제출 전인가 | form | local form facade | form controller |
| 앱 전체에서 낮은 빈도로 바뀌는가 | cross-cutting | Context 또는 typed external store | provider/store |
| 여러 feature의 복잡한 workflow인가 | client workflow | reducer, Zustand, Redux Toolkit, state machine | feature-owned store |
| 새로고침 후 남아야 하는가 | persisted preference | `StoragePort` | versioned browser storage |
| 인증 credential인가 | auth secret | external auth owner | SDK memory 또는 HttpOnly cookie |
금지:
- server response를 global client store에 복사하지 않는다.
- URL에 있어야 할 filter/sort/page를 숨은 store에만 두지 않는다.
- component 내부에 머물 수 있는 modal open 상태를 전역화하지 않는다.
- access token을 localStorage, sessionStorage, 일반 Redux/Zustand store에 넣지
않는다.
- 모든 상태를 추상화하는 범용 `StorePort`를 만들지 않는다.
### 3.2 범용 store 선택 기준
기본 skeleton에는 빈 Zustand/Redux store를 만들지 않는다. 실제 요구가 생기면
다음 기준을 적용한다.
| 조건 | 권장 |
| --- | --- |
| feature 한 곳의 단순 shared client state | feature reducer 또는 작은 Zustand store |
| 여러 팀이 action/state 규약, devtools, middleware, audit를 공유 | Redux Toolkit |
| 명시적 상태 전이, 병렬 상태, 취소/보상 workflow | state machine |
| session/theme처럼 저빈도 cross-cutting | `useSyncExternalStore` 또는 Context |
vendor를 선택하더라도 feature 외부에는 hook/facade만 export한다. 제품 코드가
store instance의 `getState``setState`를 임의 호출하지 않게 한다.
### 3.3 persistence
persisted state는 다음 metadata를 가져야 한다.
```ts
type PersistedRecord<T> = Readonly<{
version: number;
writtenAt: string;
value: T;
}>;
```
- key는 typed registry가 소유한다.
- read 시 schema parse와 migration을 거친다.
- quota, unavailable, corrupt, version mismatch를 구분한다.
- 민감정보와 credential을 저장하지 않는다.
- server state persistence와 offline mutation queue는 별도 project-selected
adapter다.
## 4. 표준 데이터 호출 경로
```mermaid
sequenceDiagram
actor User
participant Page as React page
participant Controller as inbound query/mutation controller
participant App as application input use case
participant Gateway as output gateway
participant Adapter as HTTP/generated client adapter
participant API as Backend/BFF
User->>Page: route or event
Page->>Controller: typed input
Controller->>App: query/command + AbortSignal
App->>Gateway: capability request
Gateway->>Adapter: DTO request
Adapter->>API: path/query/body/auth
API-->>Adapter: envelope or failure
Adapter-->>Gateway: parsed DTO Result
Gateway-->>App: mapped model Result
App-->>Controller: view data or AppFailure
Controller-->>Page: query/mutation state
```
페이지는 controller hook이 제공하는 상태만 렌더링한다. controller는 React,
TanStack Query와 application input interface를 알 수 있지만 use case의 concrete
구현과 outbound adapter는 모른다.
### 4.1 Application API
```ts
export interface Application {
readonly resources: {
list(
input: ListResourcesInput,
context: Readonly<{ signal: AbortSignal }>,
): Promise<Result<readonly ResourceSummary[], AppFailure>>;
create(
command: CreateResourceCommand,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<Result<Resource, AppFailure>>;
};
}
```
`ApplicationProvider`는 이 API를 immutable value로 제공한다. controller 외의
presentation 코드에서 raw HTTP/storage/telemetry port를 가져오는 hook은 만들지
않는다. theme, locale 같은 UI platform provider는 별도다.
### 4.2 query adapter
```ts
export function useResourcesQuery(input: ListResourcesInput) {
const application = useApplication();
return useQuery({
queryKey: resourceKeys.list(input),
queryFn: async ({ signal }) =>
unwrapResult(await application.resources.list(input, { signal })),
retry: false,
staleTime: resourceQueryPolicy.list.staleTime,
});
}
```
실제 구현 규칙:
- query key는 readonly tuple factory로만 만든다.
- key에 들어간 filter는 실제 gateway request에도 동일하게 투영한다.
- `AbortSignal`을 application과 HTTP transport까지 전달한다.
- HTTP 계층이 bounded retry를 소유하면 query retry는 끈다.
- background error와 initial error의 UI를 구분한다.
- placeholder와 cached stale data가 있을 때 전체 화면 error로 교체하지 않는다.
- `select`는 view-only projection에 쓰고 도메인 규칙을 넣지 않는다.
- query hook은 feature public API에서 export한다.
### 4.3 mutation adapter
```ts
export function useCreateResourceMutation() {
const application = useApplication();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (command: CreateResourceCommand) =>
application.resources.create(command).then(unwrapResult),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: resourceKeys.all }),
});
}
```
기본 mutation은 navigation만으로 취소됐다고 가정하지 않는다. 서버 작업의 취소가
안전한 operation만 controller가 보관한 `AbortController`와 명시적 cancel action을
사용한다. idempotency key는 UI 입력으로 받지 않고 application use case가
`IdGeneratorPort`로 만들며, 같은 논리 요청의 bounded HTTP retry와 auth replay는
동일한 key를 재사용한다.
실제 구현에서는 mutation마다 다음을 명시한다.
- double-submit 방지와 idempotency key 소유자
- cancel 가능 여부
- optimistic update 적용 여부와 rollback snapshot
- 성공 후 invalidate/update/navigation 순서
- 409 conflict와 422 field validation mapping
- offline 시 queue할지 즉시 실패할지
- analytics/telemetry event와 redaction
optimistic update는 기본값이 아니다. 서버 규칙을 확실히 재현할 수 있고 rollback이
안전한 mutation에만 사용한다.
## 5. HTTP client 책임
### 5.1 목표 파이프라인
```text
operation registry
-> path/search/body builder
-> credential attachment
-> timeout and external cancellation
-> bounded retry
-> fetch transport
-> status/envelope decoder
-> response schema decoder
-> feature DTO mapper
-> AppFailure mapper
```
각 단계의 입력과 출력은 typed result다. feature-specific model mapper를 공통 HTTP
client 안에 넣지 않는다.
### 5.2 request projection
operation definition은 최소 다음 계약을 갖는다.
```ts
type OperationDefinition<
TPath,
TSearch,
TBody,
TResponse,
> = Readonly<{
id: OperationId;
method: HttpMethod;
pathTemplate: string;
pathSchema: Schema<TPath>;
searchSchema: Schema<TSearch>;
bodySchema: Schema<TBody>;
responseSchema: Schema<TResponse>;
timeoutMs?: number;
retryClass: "never" | "safe" | "idempotency-keyed";
}>;
```
규칙:
- path segment는 `encodeURIComponent`에 해당하는 안전한 builder를 통과한다.
- query의 array/null/undefined/boolean/date serialization을 한 곳에서 정의한다.
- Zod parse 결과의 trim/default/coercion을 실제 request에 사용한다.
- GET/HEAD에는 body를 보내지 않는다.
- JSON content type은 body가 있을 때만 붙인다.
- base URL과 path를 문자열 덧붙이기로 조립하지 않는다.
- external URL은 별도 allowlist policy를 거친다.
### 5.3 timeout, cancellation, retry
소유권:
- 사용자 navigation/unmount 취소: query/controller가 signal 생성
- operation timeout: HTTP adapter
- retry: HTTP adapter 또는 query adapter 중 하나
- 인증 복구 후 단 한 번 replay: auth decorator
- mutation idempotency: application/controller가 key 생성, operation이 허용 여부 선언
retry 조건:
- safe method 또는 idempotency key가 있는 허용 operation만 대상
- timeout, network, 명시된 429/5xx만 정책 대상
- validation, auth denial, forbidden, not found, conflict는 자동 재시도하지 않음
- `Retry-After`와 bounded exponential backoff/jitter 지원
- tab hidden/offline 상태를 고려
- 최대 횟수와 전체 elapsed budget을 함께 제한
- 각 attempt와 final failure를 redacted telemetry로 기록
timer와 event listener는 성공, 실패, validation 조기 반환, external abort 모든
경로에서 정리되어야 한다.
RP-03의 현재 구현은 다음 계약을 자동 검증한다.
- `request-builder.ts`가 path 값을 escape하고 search key를 정렬하며 array 순서를
보존한다.
- operation의 `requestSource`가 search/body schema를 선택하고 Zod의
default/trim 결과만 URL 또는 JSON payload에 전달한다.
- runtime `REQUEST_TIMEOUT_MS``MAX_RETRY_ATTEMPTS`가 transport factory에
주입된다.
- query adapter의 자동 retry는 끄고 HTTP만 bounded network retry를 소유한다.
- `AsyncOverlay`는 refreshing/stale-degraded/mutation-pending/
mutation-conflict를 TypeScript union으로 배타화한다.
- `useApplicationQuery``useApplicationMutation`은 cancellation, stale latch,
duplicate submit, optimistic rollback, conflict resolution을 제공한다.
## 6. 인증과 token 소유권
기본 skeleton은 token manager를 제공하지 않는다.
지원 profile:
| profile | credential 소유자 | frontend 역할 |
| --- | --- | --- |
| BFF/HttpOnly cookie | browser cookie + backend | `credentials`, CSRF 정책, session probe |
| external OIDC/Auth SDK | SDK memory/cache | attach/recover/login/logout를 auth adapter로 감쌈 |
| SPA memory token | auth adapter memory | 프로젝트가 명시적으로 선택할 때만 |
공통 `AuthSessionReader`와 HTTP 전용 `CredentialProvider`를 분리한다.
```ts
interface AuthSessionReader {
getSnapshot(): SessionSnapshot;
subscribe(listener: () => void): () => void;
}
interface CredentialProvider {
attach(request: RequestInit): Promise<RequestInit>;
recover(failure: AuthFailure): Promise<"recovered" | "not-recovered">;
}
```
UI는 credential을 읽지 않는다. HTTP adapter는 UI session action을 호출하지
않는다. logout/login/redirect는 application input 또는 auth UI facade를 통해
실행한다.
## 7. 오류, 검증, logging
### 7.1 검증 계층
| 경계 | 책임 | 예 |
| --- | --- | --- |
| runtime config | 앱을 안전하게 시작할 수 있는가 | URL, timeout, auth mode |
| route codec | URL을 typed input으로 읽고 쓸 수 있는가 | page, sort, ID |
| transport DTO | 외부 응답/요청 형식이 계약과 맞는가 | envelope, date string |
| form | 사용자가 수정 가능한 입력 형태가 유효한가 | required, length, format |
| application | use case precondition이 맞는가 | command 조합 |
| domain | 항상 지켜야 할 불변식인가 | valid state transition |
같은 Zod schema를 무조건 모든 계층에서 재사용하지 않는다. transport DTO, form
value, application command, domain model이 우연히 같은 모양이어도 소유권과
변경 이유가 다르다. 필요한 경우 mapper로 연결한다.
### 7.2 사용자 오류 표면
`AppFailure`를 다음 UI 상태로 매핑한다.
| failure | 기본 표면 | 자동 행동 |
| --- | --- | --- |
| unauthenticated | auth required 또는 login transition | auth policy에 따른 1회 복구 |
| forbidden | 권한 없음 | 없음 |
| not-found | route/detail not-found | 없음 |
| validation | error summary + field errors | 첫 오류 focus |
| conflict | 현재 데이터 유지 + conflict action | 자동 overwrite 금지 |
| rate-limited | inline retry time | 허용된 query만 지연 재시도 |
| unavailable | cached data 또는 retry surface | policy 범위 내 retry |
| unexpected | safe generic copy + trace ID | diagnostics emit |
raw response body, stack, token, URL query, PII를 사용자 copy나 일반 log에 노출하지
않는다.
### 7.3 diagnostics와 telemetry
현재 telemetry event contract와 별도로 개발·진단용 structured logger가 필요하다.
다음 두 설계 중 하나를 ADR로 결정한다.
1. `DiagnosticsPort`가 log/event/span을 내부 method로 구분
2. `LoggerPort``TelemetryPort`를 분리
공통 요구:
- log level과 event key는 닫힌 union
- attribute allowlist와 중앙 redaction
- dev adapter는 console을 사용하되 동일 redaction 적용
- production adapter는 provider SDK를 감싸며 앱 코드는 SDK를 import하지 않음
- 오류 객체 전체를 그대로 serialize하지 않음
- trace ID/build ID/route ID/operation ID를 허용된 범위에서 연결
- logging failure가 제품 flow를 실패시키지 않음
- consent가 필요한 analytics와 essential diagnostics를 분리
## 8. 폼 표준
VD-04에 따라 현재 기본 엔진은 React native form event와 controlled value이며
Zod를 local facade 뒤에서 사용한다. 동적 field array, 비동기 field validation,
대규모 render isolation 요구가 실제로 생기면 public API를 유지한 채 React Hook
Form 또는 TanStack Form adapter를 평가한다.
현재 public API는 다음과 같다.
- `Form`
- `FormField`
- `Label`
- `Description`
- `FieldError`
- `ErrorSummary`
- `useAppForm`
- `mapValidationFailureToFields`
- `useDirtyNavigationGuard`
구현 위치:
- controller와 mapping: `src/presentation/forms`
- layout-only template: `src/presentation/templates`
- feature form schema/command mapper:
`src/features/reference-feature/presentation/reference-resource-form.ts`
- 실제 create page:
`src/features/reference-feature/presentation/reference-resource-form-page.tsx`
`ApiFailure.validationIssues`는 HTTP 경계가 투영한 `path``code`만 담는다.
backend message와 알 수 없는 path는 field copy로 사용하지 않는다.
필수 동작:
1. label, description, error를 stable ID와 `aria-describedby`로 연결
2. submit 시 error summary와 첫 오류 focus
3. submitting 중 중복 제출 방지
4. 422 응답의 알려진 field만 표시하고 나머지는 form-level failure로 처리
5. 409 conflict는 validation error로 위장하지 않음
6. 취소와 route 이탈 시 dirty policy 적용
7. form value → application command mapper를 별도 함수로 둠
8. browser autofill, IME composition, paste, password manager를 방해하지 않음
9. loading skeleton으로 사용자의 입력을 덮지 않음
10. form schema와 server contract mismatch를 integration test로 검증
## 9. 새 query 추가 recipe
1. feature-owned input type과 application query use case를 추가한다.
2. 필요한 output gateway를 `ports/out`에 추가한다.
3. transport DTO schema와 mapper를 outbound feature adapter에 추가한다.
4. operation registry에 path/search/response/retry class를 등록한다.
5. readonly tuple query key factory를 추가한다.
6. inbound query controller hook을 추가한다.
7. page template에서 loading/empty/error/refreshing/success를 렌더링한다.
8. 다음 테스트를 추가한다.
- use case unit
- DTO mapper/schema contract
- path/search projection
- MSW success/empty/401/403/429/500/schema mismatch
- cancellation과 retry ownership
- component state
- route E2E
9. registry, type, architecture, unit, integration, E2E gate를 실행한다.
금지:
- page에서 `fetch`
- page에서 raw QueryClient
- cache key와 request filter의 별도 수동 조립
- response DTO를 domain/application model로 사용
- initial loading과 background refreshing을 같은 full-page skeleton으로 표시
## 10. 새 mutation/form 추가 recipe
1. form value schema와 application command type을 분리한다.
2. value → command mapper를 작성한다.
3. input command use case와 output gateway method를 추가한다.
4. operation의 idempotency/retry 정책을 선언한다.
5. mutation controller에 invalidation/update/rollback 순서를 작성한다.
6. `FormPageTemplate`과 공통 field primitive로 화면을 구성한다.
7. 422, 409, unauthenticated, unavailable, abort를 각각 처리한다.
8. dirty navigation과 double-submit을 검증한다.
9. 다음 테스트를 추가한다.
- form schema와 mapper unit
- keyboard/label/error summary component
- MSW success/422/409/network-lost
- optimistic rollback을 쓰는 경우 cache snapshot
- 브라우저 navigation blocker와 성공 후 이동
## 11. 완료 기준
- TS/TSX가 lint, type, architecture, registry, security 검사를 우회하지 않는다.
- source와 test가 strict typecheck된다.
- UI는 application input API만 호출한다.
- reference query와 mutation이 TanStack adapter를 통해 동작한다.
- retry 책임이 단일 계층에 있고 cancellation/timeout과 충돌하지 않는다.
- path/search/parsed body가 실제 전송값과 일치한다.
- 상태 종류별 소유권이 테스트와 문서에서 확인된다.
- token은 UI와 일반 storage/store에 노출되지 않는다.
- failure와 validation의 각 계층이 typed mapper로 분리된다.
- query/mutation/form recipe만으로 새 기능을 만들 수 있다.
+11
View File
@@ -0,0 +1,11 @@
# Contract compatibility and rollback rules
The blocking tuple is `(buildId, configSchemaVersion, apiContractVersion,
assetManifestHash, releaseId)`. Versions are parsed numerically.
1. additive changes preserve current required fields
2. breaking changes require a major version bump
3. persisted cache is discarded unless an explicit tested migration exists
4. an incompatible config or API contract blocks product mount
5. rollback restores HTML, assets, runtime config, API compatibility, and
release manifest as one coherent set
+46
View File
@@ -0,0 +1,46 @@
# CI quality-gate orchestration
`config/ci/gates.json` is the executable registry for all 26 gates. The Gitea
adapter runs each gate as an independent matrix check with full fan-out and no
soft-fail wiring.
The dependency graph is:
```text
MERGE_READY
-> RELEASE_READY
-> PROD_PROMOTION_READY
-> FIELD_SLO_READY
DOCUMENTATION_READY (off-chain)
```
Pull requests and `develop` pushes evaluate merge readiness. Version tags
evaluate merge then release readiness. Production and field evaluation require
an explicit workflow dispatch. The field tier cannot pass until the 28-day
sample threshold decision is recorded. Documentation readiness consumes the
canonical project-note evidence in which both scoped diagrams already received
100/100 `PASS_SCOPED`; the repo ledger preserves the evidence scope and
canonical digests.
All jobs upload the shared `artifacts/` tree even after failure. Numeric
retention remains an organization/provider decision; the workflow intentionally
does not invent `retention-days`. The relative minimums are recorded in the
registry: merge evidence through the PR decision, coherent release evidence
through the next release promotion, drill evidence through the next production
promotion, and field evidence through aggregation.
Browser-backed merge gates install and execute the pinned Chromium, Firefox,
and WebKit engines. This makes route behavior, reflow, native dialog semantics,
theme persistence, and automated accessibility a cross-engine contract rather
than a Chromium-only smoke check.
Repository variables required by higher tiers:
- `HOSTING_BASE_URL` for live header verification
- `FIELD_WEB_VITALS_INPUT` for the privacy-approved field sample document
- `MIN_ELIGIBLE_SAMPLES` after the baseline decision
Branch protection must mark each `FE-GATE-* / <name>` check required for its
declared tier. This repository cannot configure server-side protection by
committing a file.
+22
View File
@@ -0,0 +1,22 @@
# Performance evidence contract
Performance evidence is deliberately split by measurement context:
- `bundle.json` records production build output and enforces initial JavaScript
at 200 KiB gzip and every lazy chunk at 120 KiB gzip.
- `lab.json` records Chromium/runner/viewport/network/CPU/cache/build context and
enforces LCP 2.5 s, CLS 0.10, and the named route interaction at 200 ms.
- `field-web-vitals.json` records consent-filtered, route-ID aggregated,
release-specific production samples over 28 days and evaluates p75 LCP, CLS,
and INP against 2.5 s, 0.10, and 200 ms.
The field minimum eligible-sample threshold is intentionally unresolved until
a privacy-approved telemetry baseline exists. Therefore the field command
fails closed with `FAIL_UNVERIFIED` when run against the example input. Provide
`FIELD_WEB_VITALS_INPUT` and `MIN_ELIGIBLE_SAMPLES` only after that decision is
recorded. The external input must identify a production release and an exact
28-day export window, name the source/export, carry privacy-approval and
threshold-decision references, and contain only non-negative route-ID samples.
The environment threshold must be a positive integer equal to the approved
decision embedded in the input. Invalid metadata fails as `FAIL_UNVERIFIED`;
the example can never serve as production evidence.
+32
View File
@@ -0,0 +1,32 @@
# Release, cache, and rollback contract
Each deployment is an immutable `releases/<releaseId>/` artifact set. The
provider adapter must upload assets, release manifest, runtime config, and
verify asset reachability before atomically switching the active HTML pointer.
The post-switch boot, route, API, telemetry, and reload-loop smoke checks close
the deployment.
Rollback selects a prior release tuple, confirms its assets and runtime/API
compatibility, atomically switches the complete set, performs the provider
cache action, and repeats the smoke checks. Rebuilding an old commit, replacing
HTML alone, or declaring recovery from cache-purge completion is prohibited.
Recovery is established by old/new reachability probes.
The provider-independent cache defaults are:
- hashed assets: `public, max-age=31536000, immutable`
- HTML: `no-cache`
- runtime config and release manifest: `no-store`
- public source maps: disabled
- service worker/offline cache: disabled
HTML, JSON config/manifest, and hashed JavaScript MIME types are also compared
to the declared allowlist; a cache-correct response with a mismatched
`Content-Type` still fails the hosting gate.
`corepack pnpm verify:hosting-headers` uses a deterministic fixture locally.
Set `HOSTING_BASE_URL` to probe deployed responses; production promotion
requires the artifact to report `mode: "live"`. The live target must be its
canonical, non-loopback HTTPS root URL. Each required surface must return HTTP
200 without leaving that origin before its cache, content-type, and security
headers can count as deployment evidence.
+9
View File
@@ -0,0 +1,9 @@
# FE-RB-001 — Boot configuration failure
Trigger on `BOOT_CONFIG_FAILURE` after the single bounded refetch fails. Stop
product route mounting and show the safe support shell; the planned owner
triage target is five minutes. Escalate from the environment/config owner to
the release owner.
Close only after a clean-session boot mounts the product root, config
validation evidence passes, and repeated boot-error telemetry is absent.
+11
View File
@@ -0,0 +1,11 @@
# FE-RB-002 — Chunk or deployment mismatch
Trigger on `CHUNK_LOAD_FAILURE`, `RELEASE_MANIFEST_FAILURE`, or
`DEPLOY_MISMATCH`. Warn when dirty state may be lost, fetch the manifest
`no-store` once, record the release pair, and allow only one reload. The
planned release-owner triage target is five minutes. Escalate to the hosting/CDN
owner.
Close only after entry/lazy assets are reachable, the manifest parses into a
coherent tuple, a second automatic reload is blocked, and the critical route
smoke passes.
+10
View File
@@ -0,0 +1,10 @@
# FE-RB-003 — Backend API degradation
Trigger when terminal network/timeout/5xx failures exceed the rolling
five-minute threshold or on one `SCHEMA_MISMATCH`. Do not expand client retry
caps, do not retry schema mismatches, and never retry an unkeyed mutation.
Escalate from the API client owner to backend operations and then release
compatibility; the planned first-classification target is ten minutes.
Close only after the failure rate returns to baseline, retry amplification is
absent, critical read/write smoke passes, and schema fixtures pass.
+9
View File
@@ -0,0 +1,9 @@
# FE-RB-004 — Telemetry sink failure
Trigger on sink network/non-2xx errors, queue overflow, or adapter
initialization failure. Keep product flows available, bound the queue, and do
not recursively report to the failed sink. Escalate from observability to the
telemetry platform owner; the planned triage target is fifteen minutes.
Close only after product e2e remains unaffected, delivery self-check succeeds,
the queue drains within its bound, and the forbidden-attribute scan passes.
+13
View File
@@ -0,0 +1,13 @@
# FE-RB-005 — Coherent release rollback
Trigger on a release-blocking boot, chunk, render, API, or security defect when
a safe forward fix is not demonstrated inside the incident window. Select a
prior immutable release, verify its asset/config/API tuple, atomically switch
the complete set, perform the provider cache action, and run smoke checks.
Escalate from the release-cache owner to the release approver/hosting owner.
The provider recovery target remains TBD until hosting is selected.
Close only when compatibility and release-coherence gates pass, critical smoke
passes, repeated `DEPLOY_MISMATCH` is absent, and the incident timeline records
the restored release ID. Pointer-switch or cache-purge completion alone is not
recovery evidence.
+13
View File
@@ -0,0 +1,13 @@
# Browser security boundary
The browser bundle is public. Secrets, token lifecycle, raw HTML injection,
dynamic code execution, untrusted script URLs, and public production source
maps are prohibited defaults.
`config/hosting/security-headers.json` is the declared header set. Hosting
verification compares that declaration with live responses. CSP deliberately
omits `unsafe-inline` and `unsafe-eval`; production code and built assets must
remain compatible with that baseline.
Route guards are UX hints and client validation does not replace backend
authorization or validation.
+18
View File
@@ -0,0 +1,18 @@
# Build and supply-chain gate
Merge and release controls:
- frozen `pnpm-lock.yaml` installation; drift is blocking
- clean production build with hashed assets and build manifest
- machine-readable bundle sizes and checksums
- source plus built-asset credential-pattern scan
- direct dependency inventory and lockfile digest
- base/head dependency diff review record
Organization-specific vulnerability severity, denied-license list, SBOM format,
and scanner selection remain policy inputs. An approved suppression must record
reason, owner, expiry, affected package, and compensating control. Expired
suppressions are blocking.
`artifacts/security/dependency-diff.json` is a local baseline. CI replaces it
with the actual base/head direct and transitive lockfile diff before release.
+872
View File
@@ -0,0 +1,872 @@
# 디자인 시스템 플랫폼 계약
이 문서는 도메인 기능을 추가하기 전에 프론트엔드 스켈레톤이 제공해야 하는
디자인 시스템의 소유권, 계층, 기본 구성요소, 외부 라이브러리 경계, 검증 방법을
정의한다. 목표는 특정 제품의 시각 언어를 미리 결정하는 것이 아니라, 제품 팀이
접근성·반응형·국제화·테스트 규칙을 다시 발명하지 않고 기능 화면을 만들 수 있게
하는 것이다.
이 문서에서 `필수`는 모든 제품이 기반으로 사용할 계약을 뜻한다. `선택`
컴포넌트의 경계와 도입 기준은 제공하지만 실제 의존성이나 구현은 제품 요구가
생긴 뒤 추가해도 되는 항목을 뜻한다.
## 1. 현재 기준선과 확인된 공백
현재 저장소에는 다음 기반이 이미 있다.
- `src/presentation/styles/theme.css`
- 의미 기반 light/dark 색상 토큰
- focus indicator
- 반응형 앱 셸
- reduced-motion 처리
- `src/presentation/providers/theme-provider.jsx`
- `system`, `light`, `dark` 선호도
- 저장소 포트를 통한 선호도 영속화
- 운영체제 색상 변경 구독
- `src/presentation/components/ui/`
- `Button`
- `TextField`
- `Card`
- `Alert`
- `Badge`
- `Dialog`
- `src/presentation/components/async-surface.jsx`
- 초기 로딩, 빈 화면, terminal error, background 상태
- `src/presentation/components/state-surfaces.jsx`
- 인증 필요, 권한 없음, 찾을 수 없음
- `src/presentation/forms`
- local form facade, field/error summary, dirty navigation dialog
- `src/presentation/templates`
- Standard, Collection, Detail, Form, Status page template
- `/examples/ui`, `/examples/states`
- 실행 가능한 primitive와 상태 예제
- component/E2E/axe 테스트
- 필드 설명과 오류 연결
- native dialog 닫기와 trigger focus 복원
- 320px reflow
- Chromium, Firefox, WebKit
- 등록 라우트의 자동 접근성 검사
RP-07 구현 이후 이 기반은 저장소 내부 디자인 시스템 플랫폼 계약을 충족한다.
아래 목록은 구현 전 공백과 현재 해결 상태를 함께 보존한다.
1. typography, elevation, motion, z-layer, control size와 breakpoint는
`design-system/tokens`의 3계층과 자동 gate로 닫혔다.
2. RP-06 form/page foundation은 준비됐지만 TextArea, Select, Checkbox,
RadioGroup 같은 form primitive 확장은 RP-07에 남아 있다.
3. 앱 셸, 예제와 reference feature는 public design-system barrel을 소비한다.
4. 문자 glyph는 semantic Lucide facade로 교체됐다.
5. token은 세 CSS 파일로 분리됐고 `theme.css`는 layout/component styling만
소유한다.
6. runtime gallery는 있지만 격리된 story, interaction story, 시각 회귀 기준선이
없다.
7. 사용자 문구가 한국어 literal로 고정되어 locale과 RTL 계약이 없다.
8. page template, form과 pattern은 public barrel에서 제공된다.
9. 모바일 navigation은 native modal Drawer로 focus 이동, 배경 비활성화,
Escape/link dismiss와 trigger focus restore를 제공한다.
따라서 기존 구성요소는 폐기하지 않고 아래 목표 계층으로 이동·확장한다.
## 2. 소유권과 의존성 원칙
디자인 시스템은 `presentation` 계층이 소유한다. 색상, 아이콘, 키보드 상호작용,
포커스, 화면 배치와 같은 문제는 도메인 규칙이 아니다.
```text
product page
-> template
-> pattern
-> primitive
-> token
```
외부 UI 라이브러리를 사용하는 경우 흐름은 다음과 같다.
```text
product page
-> local design-system API
-> local vendor facade
-> Lucide / React Aria / Radix
```
다음 규칙은 필수다.
- 제품 페이지는 `lucide-react`, `react-aria-components`, `@radix-ui/*`를 직접
import하지 않는다.
- 외부 UI 라이브러리 type을 공용 컴포넌트 API로 그대로 노출하지 않는다.
- 도메인과 application 계층은 React component, CSS class, icon name을 알지
않는다.
- primitive는 API 요청, query cache, 인증 상태와 같은 외부 상태를 직접 읽지
않는다.
- pattern과 template도 application use case를 직접 선택하지 않는다. 필요한
상태와 command callback을 props/slot으로 받는다.
- 제품별 색상이나 명칭을 primitive 내부에 하드코딩하지 않는다.
- 같은 의미의 접근성·키보드 동작을 페이지마다 다시 구현하지 않는다.
- 세 번째 사용 사례가 확인되기 전에는 제품 전용 조합을 무리하게 primitive로
승격하지 않는다.
아이콘과 headless UI는 React inbound adapter의 vendor facade로 충분하다.
이들을 위한 application port를 만들지 않는다. HTTP, storage, telemetry처럼
런타임 외부 자원을 교체하는 capability와 UI 구현 라이브러리를 구분한다.
## 3. 목표 디렉터리
TypeScript 전환 이후의 목표 구조는 다음과 같다. 마이그레이션 중에는 기존
경로에서 같은 소유권 규칙을 지키고, 한 번에 전체 경로를 이동하지 않아도 된다.
```text
src/adapters/inbound/react/design-system/
index.ts
tokens/
primitive.css
semantic.css
component.css
token-contract.ts
primitives/
button/
field/
checkbox/
dialog/
...
patterns/
async-surface/
form/
data-table/
confirmation/
...
templates/
standard-page/
collection-page/
detail-page/
form-page/
status-page/
icons/
icon.tsx
icon-button.tsx
semantic-icons.tsx
vendors/
lucide.tsx
vendors/
react-aria/
testing/
story-decorators.tsx
render-design-system.tsx
```
`index.ts`는 제품의 React inbound 코드가 사용할 public API다. 내부 파일 deep
import는 디자인 시스템 자체와 테스트에만 허용한다. 이 경계는 ESLint의
`no-restricted-imports`로 검사한다.
현재 `src/presentation`은 React inbound adapter 역할을 한다. TypeScript
마이그레이션 동안 기존 경로를 유지할 수 있지만, 최종 canonical target은 위
경로다. 같은 컴포넌트를 `presentation``adapters/inbound/react` 양쪽에
복제하지 않고 feature 단위로 이동한다.
## 4. 계층 계약
### 4.1 Tokens
토큰은 시각적 결정을 이름으로 표현한다. 토큰은 세 계층으로 관리한다.
```text
primitive token -> semantic token -> component token
```
예:
```css
--palette-blue-600: ...;
--color-action: var(--palette-blue-600);
--button-primary-background: var(--color-action);
```
#### Primitive tokens
원시 palette와 scale이다. 제품 코드에서 직접 소비하지 않는다.
- palette
- spacing scale
- font size와 line height scale
- radius scale
- shadow scale
- duration과 easing scale
- fixed size scale
#### Semantic tokens
제품 코드와 대부분의 primitive가 소비하는 이름이다.
- `surface`, `surface-muted`, `surface-elevated`
- `content`, `content-muted`, `content-inverse`
- `border`, `border-strong`
- `action`, `action-hover`, `action-pressed`
- `danger`, `warning`, `success`, `info`
- `focus-ring`
- `disabled-content`, `disabled-surface`
#### Component tokens
특정 primitive가 여러 semantic token을 조합할 때만 사용한다.
- `button-primary-background`
- `field-border-invalid`
- `dialog-elevation`
- `navigation-active-background`
컴포넌트 토큰은 제품별 variant를 만들기 위한 우회 경로가 아니다. 두 개 이상의
컴포넌트가 같은 의미를 공유한다면 semantic token으로 승격한다.
#### 필수 토큰 범주
| 범주 | 필수 내용 |
| --- | --- |
| Color | surface/content/border/action/status/focus, light/dark |
| Typography | family, size, line-height, weight, letter-spacing |
| Spacing | inset, inline, stack, section, page spacing scale |
| Size | control height, icon size, touch target, container width |
| Border | width, style, semantic border |
| Radius | control, surface, modal, full |
| Elevation | panel, popover, dialog, sticky shell |
| Z-layer | base, sticky, navigation, popover, modal, toast |
| Motion | fast/normal/slow duration, standard/emphasized easing |
| Breakpoint | compact, medium, wide와 container 계약 |
| Opacity | disabled, scrim, skeleton |
토큰의 완료 조건은 다음과 같다.
- light와 dark에서 모든 semantic token이 정의된다.
- `forced-colors`에서도 focus와 control 경계가 사라지지 않는다.
- 상태는 색상만으로 구분하지 않는다.
- 같은 raw value가 반복되면 named token으로 승격한다.
- 사용자 입력으로 CSS class나 CSS variable 이름을 조립하지 않는다.
- token contract test가 필수 token의 누락을 차단한다.
- chart나 canvas처럼 JavaScript 값이 필요한 경우에만 typed token accessor를
제공한다.
## 5. 기본 컴포넌트 카탈로그
### 5.1 Primitives
Primitive는 하나의 접근 가능한 상호작용 또는 작은 시각 단위를 제공한다.
| 그룹 | 기본 제공 | 우선순위 | 핵심 계약 |
| --- | --- | --- | --- |
| Action | `Button` | 필수 | intent, size, disabled, pending, native semantics |
| Action | `LinkButton` | 필수 | navigation은 anchor/router link semantics 유지 |
| Action | `IconButton` | 필수 | accessible name 필수, 44px 권장 target |
| Form | `Field` | 필수 | label, description, error ID 조립 |
| Form | `TextField` | 필수 | text/email/password/search/autocomplete |
| Form | `TextArea` | 필수 | resize와 글자 수 안내 |
| Form | `Select` | 필수 | native 우선, 복합 선택은 headless 구현 |
| Form | `Checkbox` | 필수 | checked/indeterminate |
| Form | `RadioGroup` | 필수 | arrow-key와 group label |
| Form | `Switch` | 필수 | boolean setting 전용 |
| Form | `SearchField` | 필수 | clear action, submit semantics |
| Feedback | `Alert` | 필수 | inline feedback와 live-region 정책 분리 |
| Feedback | `Badge` | 필수 | color-only 금지 |
| Feedback | `Spinner` | 필수 | accessible label 또는 decorative |
| Feedback | `ProgressBar` | 필수 | determinate/indeterminate |
| Feedback | `Skeleton` | 필수 | 실제 layout과 유사한 크기, reduced motion |
| Feedback | `Toast` | 필수 | queue, 중복 방지, timeout pause |
| Surface | `Card` | 필수 | heading level 강제 금지, label 선택 가능 |
| Surface | `Separator` | 필수 | decorative/semantic 구분 |
| Overlay | `Dialog` | 필수 | modal semantics, focus trap/restore, Escape |
| Overlay | `Drawer` | 필수 | compact navigation과 side sheet |
| Overlay | `Popover` | 필수 | anchor, dismiss, collision |
| Overlay | `Tooltip` | 필수 | hover와 keyboard, 중요한 정보 단독 보유 금지 |
| Overlay | `Menu` | 필수 | roving focus, typeahead, Escape |
| Navigation | `Breadcrumbs` | 필수 | 현재 위치와 overflow |
| Navigation | `Tabs` | 필수 | manual/automatic activation 정책 |
| Navigation | `Pagination` | 필수 | current page, previous/next label |
| Utility | `VisuallyHidden` | 필수 | screen-reader-only content |
| Utility | `Portal` | 필수 | overlay root와 SSR-safe fallback |
| Utility | `FocusRing` | 필수 | input modality 인식 |
| Advanced | date/time picker | 선택 | locale/time zone 요구가 있을 때 |
| Advanced | file upload/dropzone | 선택 | upload adapter 요구가 있을 때 |
| Advanced | virtualizer/tree | 선택 | 실제 데이터 규모가 입증될 때 |
Primitive API는 다음 규칙을 지킨다.
- 기본 HTML semantics를 보존한다.
- `div onClick`로 button이나 link를 흉내 내지 않는다.
- `variant`, `size`, `tone`은 closed union과 정적 class map을 사용한다.
- pending은 focus를 잃게 하는 무조건적인 `disabled`와 구분한다.
- controlled와 uncontrolled 지원 여부를 문서화한다.
- ref 전달과 focus contract를 테스트한다.
- `className`은 escape hatch이지 public variant를 대체하지 않는다.
- 임의의 polymorphic `as`보다 `Button``LinkButton`처럼 semantics가 명확한
API를 우선한다.
- visual-only prop이 도메인 의미를 표현하지 않도록 한다.
### 5.2 Patterns
Pattern은 여러 primitive를 조합해 반복되는 사용자 문제를 해결한다.
| Pattern | 필수 내용 |
| --- | --- |
| `AsyncSurface` | loading, success, empty, terminal error, stale, refresh |
| `AccessSurface` | auth required, forbidden, not found |
| `Form` | submit, error summary, first-invalid focus, pending |
| `ConfirmationDialog` | destructive action 설명과 명시적 확인 |
| `ToastRegion` | queue, announcement, dismiss, focus policy |
| `SearchFilterToolbar` | search, filter, reset, result count |
| `DataTable` | caption, sorting, selection, responsive fallback |
| `PaginationBar` | result range와 page navigation |
| `NavigationDrawer` | mobile focus scope와 background inert |
| `DisclosureGroup` | help/settings section |
Pattern은 application failure object 전체를 렌더링하지 않는다. 안전한 message key,
사용자 action, 표시 가능한 metadata만 받는다.
### 5.3 Templates
Template은 페이지 레이아웃과 상태 slot을 제공한다. API 호출과 도메인 use case는
소유하지 않는다.
#### `StandardPageTemplate`
- breadcrumb 또는 back navigation
- eyebrow
- `h1`
- description
- status/metadata
- primary/secondary actions
- main content와 aside slot
#### `CollectionPageTemplate`
- page header
- search/filter/sort toolbar
- result count
- loading/empty/error slot
- table/list/card-grid slot
- pagination slot
- compact viewport에서의 대체 배치
#### `DetailPageTemplate`
- breadcrumb/back
- title과 entity status
- page actions
- description list/section slot
- loading/not-found/forbidden/error 상태
#### `FormPageTemplate`
- heading과 설명
- error summary
- field section
- sticky 또는 inline action bar
- submit/cancel
- submitting, success, conflict
- unsaved-change navigation blocker 연결 지점
#### `StatusPageTemplate`
- 401, 403, 404, 500, offline, maintenance
- safe description
- primary recovery action
- optional support reference
- raw endpoint, stack, token 노출 금지
Template의 heading 순서는 slot 소비자가 임의로 깨뜨리지 못하게 예제와 테스트로
고정한다. 다만 `Card` 같은 하위 primitive가 무조건 `h3`를 생성해서도 안 된다.
## 6. Lucide 아이콘 facade
[Lucide for React 공식 문서](https://lucide.dev/guide/react)는 각 아이콘을 독립
React component로 제공하며 명시적으로 import한 아이콘만 번들에 포함될 수 있는
tree-shaking 구조를 제공한다. 기본 아이콘 공급자로 사용하기에 적합하지만 제품
코드가 공급자 API에 결합되면 안 된다.
### 6.1 필수 규칙
1. `lucide-react` import는 `icons/vendors/lucide.tsx`에서만 허용한다.
2. 제품 코드에는 Lucide component type, icon name, stroke prop을 노출하지 않는다.
3. 디자인 시스템이 승인한 작은 아이콘 집합만 명시적으로 import한다.
4. 모든 아이콘을 이름으로 동적 import하는 범용 `DynamicIcon`은 기본 제공하지
않는다.
5. 아이콘 색은 기본적으로 `currentColor`를 사용한다.
6. 크기와 stroke는 `--icon-size-*`, `--icon-stroke-*` 토큰을 사용한다.
7. 장식 아이콘은 기본적으로 `aria-hidden="true"``focusable="false"`다.
8. 정보를 단독으로 전달하는 아이콘은 local `Icon` API의 `label`을 통해
`role="img"`와 accessible name을 가진다.
9. 버튼 안의 아이콘은 장식으로 처리하고 버튼 자체에 visible label 또는
`aria-label`을 둔다.
10. 상태를 아이콘이나 색상 하나만으로 전달하지 않는다.
### 6.2 목표 API 예시
아래 예시는 방향을 설명한다. 실제 type과 경로는 TypeScript 전환 작업에서
확정한다.
```tsx
import { MenuIcon } from "@/presentation/design-system/icons";
import { IconButton } from "@/presentation/design-system";
<IconButton accessibleName="메뉴 열기" onPress={openNavigation}>
<MenuIcon />
</IconButton>
```
vendor 파일은 의미 이름과 공급자 이름을 분리한다.
```tsx
import {
Menu as LucideMenu,
X as LucideClose,
AlertTriangle as LucideWarning,
} from "lucide-react";
export const MenuGlyph = LucideMenu;
export const CloseGlyph = LucideClose;
export const WarningGlyph = LucideWarning;
```
`MenuGlyph` 같은 vendor export는 `semantic-icons.tsx` 밖으로 다시 노출하지
않는다. 제품 코드는 `MenuIcon`, `CloseIcon`, `WarningIcon`만 사용한다.
### 6.3 아이콘 추가 완료 조건
- 기존 semantic icon으로 표현할 수 없는지 먼저 확인했다.
- 서로 다른 제품 기능이 같은 의미 아이콘을 공유한다.
- accessible name과 decorative 여부를 결정했다.
- light/dark/high-contrast에서 보인다.
- 200% zoom과 compact viewport에서 잘리지 않는다.
- 신규 import 후 bundle budget을 통과한다.
- 공급자 교체 시 제품 코드 수정이 필요하지 않다.
## 7. Headless UI 선택 기준
복잡한 overlay, collection, focus management를 직접 구현하기 전에 native
platform과 검증된 headless UI를 평가한다.
- [React Aria 공식 시작 문서](https://react-aria.adobe.com/getting-started)
- [Radix Primitives 공식 소개](https://www.radix-ui.com/primitives/docs/overview/introduction)
### 7.1 선택 순서
1. native HTML만으로 요구 semantics와 모든 지원 브라우저 동작을 충족하는지
확인한다.
2. native 구현이 불충분하면 React Aria와 Radix를 평가한다.
3. 한 프로젝트에서 같은 문제를 해결하는 headless 공급자를 둘 이상 기본값으로
사용하지 않는다.
4. 선택 결과와 버전·라이선스·bundle 영향을 ADR에 기록한다.
5. 공급자 component를 local primitive로 감싼 뒤에만 제품 코드에 노출한다.
### 7.2 평가표
| 기준 | 확인 질문 |
| --- | --- |
| Semantics | WAI-ARIA pattern과 native semantics를 올바르게 사용하는가 |
| Keyboard | roving focus, typeahead, Escape, arrow key가 완전한가 |
| Focus | trap, restore, initial focus, nested overlay가 안전한가 |
| Input modality | mouse, touch, keyboard, screen reader가 동일하게 동작하는가 |
| i18n | RTL, locale number/date, IME를 지원하는가 |
| Styling | semantic token과 Tailwind/CSS layer에 결합 가능한가 |
| Bundle | component 단위 import와 tree shaking이 가능한가 |
| React/Vite | 현재 React와 Vite 조합을 공식 지원하는가 |
| SSR | future SSR profile에서 hydration ID가 안정적인가 |
| Testing | 접근성·상호작용 테스트 자료와 utility가 있는가 |
| Maintenance | release cadence, security 대응, license가 허용 가능한가 |
현재 스켈레톤의 접근성·국제화 목표에는 React Aria Components가 우선 후보다.
Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다. 최종 선택은
실제 prototype으로 `Select`, `Menu`, `Drawer` 세 가지를 구현해 다음을 비교한 뒤
확정한다.
- keyboard matrix
- screen reader announcement
- RTL
- mobile touch
- dark/high-contrast
- gzip 증가량
- local API로 감쌀 때 필요한 코드량
공급자를 선택하더라도 `SelectProps = AriaSelectProps`처럼 vendor type alias를
그대로 공용 API로 만들지 않는다. 제품이 실제로 지원하는 의미만 local prop으로
닫는다.
## 8. 접근성 계약
목표는 WCAG 2.2 AA에 맞춘 기본 동작이다. 자동 도구 통과는 적합성 선언이 아니며,
키보드와 보조기술 검토를 함께 수행한다.
### 8.1 모든 컴포넌트의 공통 기준
- 올바른 native element와 role을 사용한다.
- visible label 또는 accessible name이 있다.
- keyboard만으로 모든 action을 실행할 수 있다.
- focus 순서가 DOM과 시각 순서에 맞는다.
- focus indicator를 제거하지 않는다.
- disabled와 readonly를 구분한다.
- 상태는 색상만으로 전달하지 않는다.
- 오류는 해당 control과 programmatically 연결한다.
- 필요한 변경만 live region으로 한 번 알린다.
- `prefers-reduced-motion`에서 불필요한 motion을 제거한다.
- 200% text zoom과 400% page zoom에서 정보가 손실되지 않는다.
- 320 CSS px reflow에서 양방향 scroll을 강요하지 않는다.
- touch target은 기본 44x44 CSS px를 목표로 한다.
- forced-colors에서 border, focus, selected state가 사라지지 않는다.
- RTL에서 logical direction과 key behavior를 검증한다.
- 한글·일본어·중국어 IME composition 중 입력을 조기에 검증하거나 제출하지
않는다.
### 8.2 Overlay 기준
- 열기 trigger를 기록한다.
- 열릴 때 의미 있는 initial focus를 둔다.
- modal일 때 focus가 내부를 벗어나지 않는다.
- 배경 content를 `inert` 또는 동등한 방식으로 비활성화한다.
- Escape와 명시적 닫기 action을 제공한다.
- 닫은 뒤 trigger 또는 안전한 대체 위치로 focus를 복원한다.
- nested overlay와 route transition 중 orphan portal을 남기지 않는다.
### 8.3 Form 기준
- label, description, error가 동일한 field ID 체계로 연결된다.
- required는 시각적 기호와 programmatic 상태를 함께 가진다.
- submit 실패 시 error summary로 focus를 이동한다.
- error summary 항목은 해당 field로 이동한다.
- async validation은 현재 입력보다 오래된 결과를 폐기한다.
- pending 상태를 screen reader에 알리되 같은 문구를 반복하지 않는다.
- 서버 오류의 raw body, stack, 내부 field path를 출력하지 않는다.
## 9. 국제화 계약
디자인 시스템 primitive는 한국어 문구를 내부 기본값으로 숨기지 않는다.
접근성 label이나 오류 문구가 필요하면 명시적인 prop 또는 message key를 받는다.
필수 국제화 기반:
- `LocaleProvider`
- typed message key
- fallback locale
- `<html lang>``dir` 갱신
- `Intl.DateTimeFormat`
- `Intl.NumberFormat`
- `Intl.ListFormat`
- plural/select message
- locale별 validation message
- pseudo-locale
- RTL story와 E2E smoke
금지 패턴:
- 번역 문장 중간에 JSX 문자열을 연결한다.
- 날짜를 `substring`이나 고정 구분자로 조립한다.
- icon direction을 locale 확인 없이 좌/우 이름으로 고정한다.
- route title, navigation label, toast message를 JSX literal로 분산한다.
- 번역 누락 시 빈 문자열을 렌더링한다.
Storybook toolbar에서 최소한 다음 조합을 전환할 수 있어야 한다.
- `ko-KR`, light
- `en-US`, light
- `en-US`, dark
- pseudo-locale
- 대표 RTL locale
## 10. Storybook과 시각 검증
현재 `/examples/ui`는 실행 중인 앱 셸과 primitive 통합을 확인하는 데 유지한다.
그러나 runtime gallery는 isolated component workshop을 대체하지 않는다.
[Storybook 공식 UI 테스트 문서](https://storybook.js.org/docs/writing-tests)는
story를 기반으로 interaction, accessibility, visual test를 구성하는 방법을
제공한다.
### 10.1 Storybook 필수 구성
- Vite 기반 Storybook
- TypeScript CSF story
- global theme toolbar
- locale/RTL toolbar
- compact/wide viewport
- Router, Theme, Locale, Session, Query decorator
- `@storybook/addon-a11y`
- a11y 결과를 CI에서 error로 처리
- interaction `play` test
- story build gate
### 10.2 각 컴포넌트의 필수 story
- default
- 모든 semantic variant
- disabled와 readonly
- pending/loading
- 오류
- 긴 문구
- 빈 값
- light/dark
- compact viewport
- keyboard interaction
- 해당되는 경우 open/closed, controlled/uncontrolled
Form과 overlay는 다음 story를 추가한다.
- submit 실패와 error summary
- async pending
- server field error
- Escape close
- outside interaction
- initial focus
- focus restoration
- nested content와 long content
### 10.3 시각 회귀
기본 시각 회귀는 Playwright `toHaveScreenshot()`을 사용해 저장소 안에서 실행할
수 있게 한다. Chromatic 같은 외부 서비스는 선택 사항이다.
필수 안정화:
- 동일한 Linux image, browser version, font
- animation과 caret 비활성화
- 시간·랜덤·network 응답 고정
- 동적 ID와 민감 데이터 mask
- light/dark baseline
- compact/wide baseline
- 변경된 baseline은 코드 리뷰 대상
failure screenshot은 시각 회귀가 아니다. 의도된 baseline과 실제 렌더링을
비교하는 assertion이 있어야 한다.
### 10.4 테스트 계층
| 계층 | 검증 대상 |
| --- | --- |
| Type check | prop union, ref, event와 slot type |
| Unit | token/variant map, pure formatter와 state reducer |
| Component | native semantics, keyboard, focus, callback |
| Story interaction | 실제 browser의 isolated behavior |
| Story a11y | 모든 주요 state의 axe |
| Visual | pixel/layout/theme regression |
| E2E | 앱 셸, route, form/query integration |
| Manual | screen reader, zoom, touch, forced-colors |
DOM snapshot은 보이는 UI를 보장하지 않으므로 public markup contract가 꼭 필요한
경우에만 사용한다.
## 11. 컴포넌트 추가 Recipe
새 component를 추가할 때 다음 순서를 생략하지 않는다.
### Step 1. 문제와 계층을 결정한다
다음 질문을 issue 또는 설계 메모에 기록한다.
- 단일 control인가, 반복되는 조합인가, 페이지 구조인가?
- primitive, pattern, template 중 어디에 속하는가?
- 기존 component의 variant로 해결할 수 있는가?
- 제품 전용 의미를 공통 디자인 시스템으로 잘못 올리는 것은 아닌가?
- native HTML로 충분한가?
### Step 2. 상태 행렬을 작성한다
최소 상태:
```text
default
hover
focus-visible
active/pressed
disabled
pending
invalid
light
dark
compact
long-content
```
선택/overlay component는 open, selected, indeterminate, empty, loading도 포함한다.
### Step 3. 접근성 명세를 작성한다
- element/role
- accessible name
- keyboard table
- focus entry/exit/restore
- announcement
- error association
- reduced motion
- touch와 screen reader
명세가 불명확하면 구현보다 먼저 native, React Aria, Radix prototype으로 검증한다.
### Step 4. API를 닫는다
- TypeScript public props를 정의한다.
- semantic `variant`를 사용하고 raw color prop을 노출하지 않는다.
- callback 이름과 payload를 domain-neutral하게 정한다.
- controlled/uncontrolled 정책을 정한다.
- `className` escape hatch의 범위를 정한다.
- vendor prop과 type을 public API에 노출하지 않는다.
### Step 5. 필요한 토큰을 추가한다
- 기존 semantic token으로 표현 가능한지 확인한다.
- raw 값 반복을 추가하지 않는다.
- light/dark/forced-colors 값을 함께 정의한다.
- token contract test를 갱신한다.
### Step 6. 구현한다
- native semantics 우선
- local vendor facade만 import
- static variant map
- ref/focus 지원
- DOM 구조 최소화
- render 중 전역 상태 변경 금지
- 제품 API나 application service import 금지
### Step 7. Story를 작성한다
- 상태 행렬을 모두 story로 표현한다.
- interaction test를 작성한다.
- dark, compact, long-content를 포함한다.
- a11y test를 error mode로 실행한다.
### Step 8. 자동 테스트를 작성한다
- role/name 중심 query
- keyboard와 focus
- disabled/pending
- callback
- controlled state
- 오류와 live region
- 필요한 visual baseline
구현 class나 내부 DOM 순서만 확인하는 brittle test는 피한다.
### Step 9. 통합 예제를 추가한다
- `/examples/ui`에는 primitive를 추가한다.
- `/examples/states`에는 새로운 공통 상태를 추가한다.
- pattern/template은 domain-neutral fixture로 실제 조합을 보여 준다.
- production profile에서 예제 route를 제외할 수 있는 계약을 유지한다.
### Step 10. 문서와 public export를 갱신한다
- 사용 목적
- 사용하지 말아야 할 경우
- props와 variant
- keyboard
- 접근성 책임
- i18n
- 예제
- public `index.ts` export
- 변경 로그
### Step 11. 게이트를 실행한다
최소 실행 범위:
```bash
corepack pnpm check:types
corepack pnpm lint
corepack pnpm check:architecture
corepack pnpm test:component
corepack pnpm test:e2e
corepack pnpm test:a11y
corepack pnpm build
corepack pnpm check:bundle
```
Storybook과 visual gate가 도입된 뒤에는 story build, interaction, story a11y,
visual comparison도 필수로 추가한다.
## 12. Definition of Done
컴포넌트는 아래 항목 중 자신의 state/capability matrix에 해당하는 항목을
충족해야 `완료`다. 적용되지 않는 항목은 체크를 생략하지 말고 `N/A`와 이유를
component contract에 기록한다. 예를 들어 `Separator`에는 pending/error/IME가,
읽기 전용 `Card`에는 controlled state와 field error가 적용되지 않는다.
### API와 아키텍처
- [ ] primitive/pattern/template 소유권이 명확하다.
- [ ] public TypeScript API가 closed union으로 정의되었다.
- [ ] 제품 코드가 vendor package를 직접 import하지 않는다.
- [ ] domain/application dependency가 없다.
- [ ] public barrel을 통해 소비할 수 있다.
- [ ] 상태를 소유하는 경우 controlled/uncontrolled 계약이 문서화되었고, focus
target이 있는 경우 ref 계약이 문서화되었다.
### Styling
- [ ] raw 색상과 반복되는 임의 값이 없다.
- [ ] semantic token을 사용한다.
- [ ] light/dark가 완성되었다.
- [ ] compact/wide reflow가 된다.
- [ ] long text와 번역 확장을 견딘다.
- [ ] forced-colors에서도 의미가 유지된다.
- [ ] bundle budget을 통과한다.
### 접근성
- [ ] native semantics와 accessible name이 있다.
- [ ] keyboard 동작이 명세와 일치한다.
- [ ] focus indicator, 이동, 복원이 올바르다.
- [ ] 상태가 색상에만 의존하지 않는다.
- [ ] 오류나 설명을 제공하는 component는 이들을 programmatically 연결한다.
- [ ] reduced-motion을 존중한다.
- [ ] 200%/400% zoom과 320px reflow를 확인했다.
- [ ] screen reader 수동 검토 항목이 정의되었다.
### 국제화
- [ ] 사용자 문구를 소유하는 component는 이를 내부 literal로 고정하지 않는다.
- [ ] 날짜·숫자·목록을 표시하는 component는 locale formatter를 사용한다.
- [ ] pseudo-locale에서 잘리지 않는다.
- [ ] RTL에서 배치와 키보드가 올바르다.
- [ ] 문자 입력을 받는 component는 IME 입력을 깨뜨리지 않는다.
### 테스트와 문서
- [ ] component behavior test가 있다.
- [ ] 모든 주요 상태 story가 있다.
- [ ] story interaction test가 있다.
- [ ] automated a11y가 통과한다.
- [ ] 필요한 visual baseline이 있다.
- [ ] runtime gallery 또는 pattern fixture가 있다.
- [ ] 사용법, 금지 사례, 접근성 책임이 문서화되었다.
- [ ] 전체 품질·아키텍처·bundle gate가 통과한다.
## 13. 금지 패턴
- 제품 페이지에서 `lucide-react` 직접 import
- 제품 페이지에서 React Aria/Radix 직접 import
- `div`와 keyboard handler로 native button을 재구현
- icon-only button에 accessible name 누락
- 문자열 icon name으로 전체 icon package를 동적 로딩
- 사용자 값으로 Tailwind/CSS class 조립
- raw hex/OKLCH 값을 JSX나 component CSS에 반복
- `as="div"`처럼 semantics를 무제한 변경하는 public API
- primitive에서 API/query/auth/storage를 직접 호출
- 모든 상태를 하나의 전역 store에 저장
- 오류 raw body, URL, header, token, stack 표시
- locale 문장을 문자열 덧셈으로 구성
- axe 통과만으로 접근성 완료 선언
- failure screenshot만으로 시각 회귀 완료 선언
- Storybook story 없이 runtime gallery 하나로 모든 variant를 대표
## 14. 단계별 도입 순서
1. TypeScript public API와 디자인 시스템 public barrel을 만든다.
2. token을 primitive/semantic/component 계층으로 분리한다.
3. 기존 Button, TextField, Card, Alert, Badge, Dialog를 새 계약으로 이동한다.
4. Lucide local facade와 `IconButton`을 추가하고 문자 glyph를 제거한다.
5. Field foundation과 필수 form primitives를 추가한다.
6. React Aria와 Radix prototype을 비교하고 headless ADR을 확정한다.
7. Drawer, Select, Menu, Popover, Tooltip을 local facade로 구현한다.
8. pattern과 page template을 추가한다.
9. locale provider, pseudo-locale, RTL 검증을 추가한다.
10. Storybook, story a11y, interaction test를 추가한다.
11. pinned browser 환경의 visual regression gate를 추가한다.
12. `/examples/ui``/examples/states`를 통합 smoke gallery로 유지한다.
이 순서는 라이브러리 수를 늘리는 것이 목표가 아니다. 각 단계가 제품 개발자가
중복 구현할 문제를 하나씩 제거하고, 외부 공급자를 교체할 수 있는 로컬 계약과
검증 증거를 남기는 것이 목표다.
+49
View File
@@ -0,0 +1,49 @@
# Design-token styling contract
`src/presentation/styles/theme.css` is the styling SSOT. Components consume
semantic color, spacing, typography, status, and radius tokens through static
classes. `/examples/ui` is the executable token and primitive gallery.
## Theme contract
The supported public preference is the closed set `system`, `light`, and
`dark`. `ThemeProvider` reads and writes `COLOR_SCHEME` through the application
storage port; it does not access a raw storage key. `system` subscribes to
`prefers-color-scheme` changes. Bootstrap applies the persisted preference
before React paints.
Components use semantic tokens such as `--color-panel`, `--color-content`,
`--color-border`, `--color-action`, and status surface/content/border triples.
They must not hard-code a light-only panel or text color. Both light and dark
surfaces are included in automated axe checks.
## Included primitives
- `Button`: primary, secondary, danger, and ghost intent
- `TextField`: label, help text, required state, and associated validation error
- `Card`: labelled surface with optional footer
- `Alert` and `Badge`: non-color-only status feedback
- `Dialog`: native modal semantics, Escape/backdrop close, and trigger focus
restoration
- async and access state surfaces: loading, refresh, empty, terminal error,
auth required, forbidden, and not found
Arbitrary-value policy:
- prefer a named semantic token
- bracket values are allowed only for one-off platform constraints that cannot
be expressed by the current scale
- a repeated bracket value must be promoted into `@theme`
- user-controlled or runtime-composed class strings are forbidden
- class variants must be selected from a closed static map
The removable reference feature may demonstrate tokens, but generic production
starter modules do not import its domain, application, adapter, or presentation
implementation.
This file documents the currently implemented token and primitive baseline.
The [design-system platform contract](./design-system-platform.md) defines the
target token layers, component catalog, local Lucide/headless-library
facades, page patterns, accessibility and internationalization rules, isolated
workshop, and component-authoring recipe. Items in that target document are not
treated as implemented until their acceptance tests pass.
File diff suppressed because it is too large Load Diff
+14
View File
@@ -12,6 +12,13 @@ Each gate is blocking in its declared scope. Failures are not downgraded with
| end-to-end | `pnpm test:e2e` | `artifacts/tests/e2e/` |
| accessibility | `pnpm test:a11y` | `artifacts/tests/a11y.json` |
End-to-end and automated accessibility scenarios run on the pinned Chromium,
Firefox, and WebKit engines. The responsive contract explicitly exercises
320px reflow, a mobile navigation drawer, and a wide two-column gallery.
Theme scenarios verify persistence, operating-system changes, and dark-surface
axe results. Native modal focus and validation association are exercised in
both component and browser tests.
A control is verified only when a positive fixture passes and its deliberately
failing negative fixture is rejected. Generated evidence is retained by CI;
the repository tracks only the evidence directory structure.
@@ -22,3 +29,10 @@ Promotion is an AND graph:
2. merge gates plus release gates
3. release gates plus rollback/runbook drills
4. production promotion plus eligible field Web Vitals evidence
This file describes the currently registered taxonomy. The
[frontend platform testing strategy](./frontend-platform-testing-strategy.md)
documents the target additions: test TypeScript projects, real bootstrap
composition tests, shared MSW scenarios, query/mutation/form/router coverage,
Storybook interaction and accessibility checks, visual regression, and a
built-output Playwright profile.
+211 -16
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: [
@@ -35,51 +76,195 @@ export default [
"artifacts/**",
"tests/fixtures/typecheck/**",
"tests/fixtures/architecture/forbidden/**",
"tests/fixtures/security/forbidden/**",
],
},
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/presentation/adapters/query/**/*.${sourceExtensions}`,
],
rules: {
"no-restricted-imports": restrictedImports([
"**/adapters/http/**",
"**/adapters/storage/**",
"**/adapters/auth/**",
"**/bootstrap/**",
"**/application/ports/out/**",
]),
},
},
{
files: [`src/presentation/templates/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports([
"**/application/**",
"**/adapters/**",
"**/bootstrap/**",
"@tanstack/**",
]),
},
},
{
files: [`src/adapters/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.adapters),
},
},
{
files: ["tests/**/*.{js,jsx}"],
files: [`src/features/*/domain/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.domain),
"no-restricted-globals": [
"error",
"window",
"document",
"localStorage",
"fetch",
],
},
},
{
files: [`src/features/*/application/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.application),
"no-restricted-globals": [
"error",
"window",
"document",
"localStorage",
"fetch",
],
},
},
{
files: [`src/features/*/presentation/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports([
"**/features/*/adapters/**",
"**/adapters/http/**",
"**/adapters/storage/**",
"**/adapters/auth/**",
"**/bootstrap/**",
"**/application/ports/out/**",
"@tanstack/**",
]),
"no-restricted-globals": [
"error",
"fetch",
"localStorage",
"sessionStorage",
],
},
},
{
files: [`src/features/*/adapters/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports([
"**/presentation/**",
"**/bootstrap/**",
"@tanstack/**",
]),
},
},
{
files: [`tests/**/*.${sourceExtensions}`],
languageOptions: {
globals: {
...globals.browser,
@@ -88,14 +273,24 @@ 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",
"**/application/**",
]),
"no-restricted-globals": [
"error",
"fetch",
"localStorage",
"sessionStorage",
],
},
},
];
+50 -5
View File
@@ -5,34 +5,77 @@
"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": {
"dev": "vite",
"build": "vite build && node scripts/generate-build-manifest.mjs",
"build:release": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security",
"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:fixture": "tsc --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js",
"check:design-system": "node scripts/check-design-system.mjs",
"check:design-system:fixture": "node scripts/check-design-system.mjs --fixture",
"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",
"check:types:fixture:async-overlay": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts",
"check:types:fixture:route-runtime": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts",
"check:types:fixture:page-action": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx",
"check:types:fixture:icon-button": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-icon-button.tsx",
"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",
"test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml",
"test:e2e": "playwright test",
"test:a11y": "playwright test --grep @a11y",
"test:all": "pnpm test:runtime-schema && pnpm test:unit && pnpm test:component && pnpm test:integration"
"test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs",
"review:a11y-manual": "node scripts/verify-a11y-manual.mjs",
"test:sample-removal": "node scripts/test-sample-removal.mjs",
"test:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests",
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature",
"verify:lockfile": "corepack pnpm install --frozen-lockfile",
"generate:supply-chain": "node scripts/generate-supply-chain.mjs",
"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",
"check:routes:fixture": "node scripts/check-registries.mjs --governance tests/fixtures/registry/routes/governance.json --artifact artifacts/quality/route-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",
"check:bundle": "node scripts/generate-supply-chain.mjs && node scripts/check-bundle.mjs",
"test:performance": "node scripts/test-performance.mjs",
"collect:web-vitals-evidence": "node scripts/collect-web-vitals-evidence.mjs",
"drill:runbook": "node scripts/drill-runbook.mjs",
"drill:runbooks": "corepack pnpm drill:runbook -- FE-RB-001 && corepack pnpm drill:runbook -- FE-RB-002 && corepack pnpm drill:runbook -- FE-RB-003 && corepack pnpm drill:runbook -- FE-RB-004 && corepack pnpm drill:runbook -- FE-RB-005",
"ci:gate": "node scripts/run-ci-gate.mjs",
"check:ci": "node scripts/check-ci-contract.mjs",
"verify:documentation": "node scripts/verify-documentation-readiness.mjs"
},
"dependencies": {
"@tanstack/react-query": "5.101.4",
"lucide-react": "1.25.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router-dom": "7.18.1",
"zod": "4.4.3"
},
"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",
@@ -42,9 +85,11 @@
"@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",
"tailwindcss": "4.3.3",
"typescript": "7.0.2",
"vite": "8.1.5",
"vitest": "4.1.10"
+3 -1
View File
@@ -13,11 +13,13 @@ export default defineConfig({
screenshot: "only-on-failure",
},
webServer: {
command: "pnpm dev --host 127.0.0.1",
command: "corepack pnpm dev --host 127.0.0.1",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
],
});
+918 -19
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@
"REQUEST_TIMEOUT_MS": 10000,
"MAX_RETRY_ATTEMPTS": 2,
"TELEMETRY_ENABLED": false,
"AUTH_MODE": "external",
"AUTH_MODE": "demo",
"CONFIG_SCHEMA_VERSION": "1",
"API_CONTRACT_VERSION": "1",
"RELEASE_MANIFEST_URL": "/release-manifest.json",
+22
View File
@@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"appVersion": "0.1.0",
"buildId": "local-build",
"commitSha": "local",
"configSchemaVersion": "1",
"apiContractVersion": "1",
"assetManifestHash": "generated-during-build",
"releaseId": "local-release",
"builtAt": "1970-01-01T00:00:00.000Z",
"routeChunks": {
"route-home": "src/presentation/pages/home-page.jsx",
"route-examples-ui": "src/presentation/examples/ui-gallery-page.jsx",
"route-examples-states": "src/presentation/examples/state-gallery-page.jsx",
"route-examples-auth": "src/presentation/examples/auth-example-page.jsx",
"route-reference-resources": "src/features/reference-feature/presentation/reference-resource-page.tsx",
"route-reference-resource-detail": "src/features/reference-feature/presentation/reference-resource-detail-page.tsx",
"route-reference-resource-form": "src/features/reference-feature/presentation/reference-resource-form-page.tsx",
"route-reference-resource-status": "src/features/reference-feature/presentation/reference-resource-status-page.tsx",
"route-not-found": "src/presentation/pages/not-found-page.jsx"
}
}
@@ -0,0 +1,39 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "build-manifest.schema.json",
"type": "object",
"required": [
"schemaVersion",
"buildId",
"commitSha",
"generatedAt",
"buildContext",
"outputs"
],
"properties": {
"schemaVersion": { "const": 1 },
"buildId": { "type": "string", "minLength": 1 },
"commitSha": { "type": "string", "minLength": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"buildContext": {
"type": "object",
"required": ["nodeVersion", "packageManagerVersion", "runnerImage"],
"properties": {
"nodeVersion": { "type": "string" },
"packageManagerVersion": { "type": "string" },
"runnerImage": { "type": "string" }
},
"additionalProperties": false
},
"outputs": {
"type": "object",
"required": ["directory", "viteManifest"],
"properties": {
"directory": { "type": "string" },
"viteManifest": { "type": "string" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": [
"schemaVersion",
"generatedAt",
"compatibilityImpact",
"failures",
"registries"
],
"properties": {
"schemaVersion": { "const": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"compatibilityImpact": {
"enum": ["none", "additive", "behavior-change", "breaking"]
},
"failures": { "type": "array", "maxItems": 0 },
"registries": {
"type": "array",
"minItems": 8,
"maxItems": 8,
"items": {
"type": "object",
"required": ["registryId", "owner", "source", "rowCount", "rows"]
}
}
},
"additionalProperties": false
}
+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`,
);
+42
View File
@@ -0,0 +1,42 @@
import { readdir } from "node:fs/promises";
import { spawnSync } from "node:child_process";
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
/** @param {string[]} arguments_ */
function runPnpm(arguments_) {
return spawnSync(process.execPath, [pnpmCli, ...arguments_], {
encoding: "utf8",
});
}
const allowed = runPnpm([
"exec",
"eslint",
"tests/fixtures/security/allowed",
"--no-ignore",
"--max-warnings=0",
]);
const forbidden = runPnpm([
"exec",
"eslint",
"tests/fixtures/security/forbidden",
"--no-ignore",
"--max-warnings=0",
]);
const distFiles = await readdir("dist", { recursive: true });
const publicSourceMaps = distFiles.filter((file) => String(file).endsWith(".map"));
if (allowed.status !== 0 || forbidden.status === 0 || publicSourceMaps.length > 0) {
process.stderr.write(allowed.stderr || allowed.stdout);
process.stderr.write(forbidden.stderr || forbidden.stdout);
if (publicSourceMaps.length > 0) {
process.stderr.write(`Public source maps found: ${publicSourceMaps.join(", ")}\n`);
}
process.exit(1);
}
process.stdout.write(
"Browser security fixtures: injection rejected, public source maps absent\n",
);
+100
View File
@@ -0,0 +1,100 @@
import { readFile, writeFile } from "node:fs/promises";
import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.js";
import { classifyViteJavascript } from "./lib/classify-vite-bundle.mjs";
const report =
/** @type {{
* outputs: Array<{ path: string, gzipBytes: number }>,
* [key: string]: unknown
* }} */ (
JSON.parse(await readFile("artifacts/performance/bundle.json", "utf8"))
);
const viteManifest =
/** @type {Record<string, { file: string, isEntry?: boolean, imports?: string[] }>} */ (
JSON.parse(await readFile("dist/.vite/manifest.json", "utf8"))
);
const budgets =
/** @type {{ initialJsGzipBytes: number, lazyChunkGzipBytes: number }} */ (
JSON.parse(await readFile("config/performance/budgets.json", "utf8")).bundle
);
const outputByPath = new Map(
report.outputs.map((output) => [output.path.replace(/^dist\//, ""), output]),
);
const classification = classifyViteJavascript(viteManifest);
const initialJsGzipBytes = classification.initialFiles.reduce(
(total, file) => total + (outputByPath.get(file)?.gzipBytes ?? 0),
0,
);
const lazyChunks = classification.lazyFiles.map((file) => ({
path: file,
gzipBytes: outputByPath.get(file)?.gzipBytes ?? 0,
}));
const missingOutputs = [
...classification.initialFiles,
...classification.lazyFiles,
].filter((file) => !outputByPath.has(file));
const measurements = { initialJsGzipBytes, lazyChunks };
const result = evaluateBundleBudget(measurements, budgets);
const fixtures = [
{
name: "initial-js-over-budget",
passed:
!evaluateBundleBudget(
{
initialJsGzipBytes: budgets.initialJsGzipBytes + 1,
lazyChunks: [],
},
budgets,
).passed,
},
{
name: "lazy-chunk-over-budget",
passed:
!evaluateBundleBudget(
{
initialJsGzipBytes: 0,
lazyChunks: [
{
path: "fixture.js",
gzipBytes: budgets.lazyChunkGzipBytes + 1,
},
],
},
budgets,
).passed,
},
];
const passed =
result.passed &&
fixtures.every((fixture) => fixture.passed) &&
classification.missingImports.length === 0 &&
missingOutputs.length === 0;
const completedReport = {
...report,
measurements,
classification,
missingOutputs,
thresholds: budgets,
results: result,
fixtures,
passed,
};
await writeFile(
"artifacts/performance/bundle.json",
`${JSON.stringify(completedReport, null, 2)}\n`,
);
if (!passed) {
process.stderr.write(
`Bundle budget or manifest integrity failed: ${[
...classification.missingImports,
...missingOutputs,
].join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Bundle budget: PASS (initial JS ${initialJsGzipBytes} / ${budgets.initialJsGzipBytes} gzip bytes)\n`,
);
+111
View File
@@ -0,0 +1,111 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
evaluatePromotionReadiness,
PROMOTION_FORMULA,
} from "../src/application/policies/promotion-readiness.js";
const document = JSON.parse(await readFile("config/ci/gates.json", "utf8"));
const workflow = await readFile(document.providerAdapter, "utf8");
const failures = [];
const stageFormula = {
merge: PROMOTION_FORMULA.MERGE_READY,
release: PROMOTION_FORMULA.RELEASE_READY,
production: PROMOTION_FORMULA.PROD_PROMOTION_READY,
field: PROMOTION_FORMULA.FIELD_SLO_READY,
documentation: PROMOTION_FORMULA.DOCUMENTATION_READY,
};
for (const [stage, expectedGates] of Object.entries(stageFormula)) {
const actual = document.stages[stage]?.gates;
if (JSON.stringify(actual) !== JSON.stringify(expectedGates)) {
failures.push(`${stage} gate formula drift`);
}
}
const configuredGateIds = Object.keys(document.gates).sort();
const expectedGateIds = Array.from(
{ length: 26 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
);
if (JSON.stringify(configuredGateIds) !== JSON.stringify(expectedGateIds)) {
failures.push("gate registry must contain FE-GATE-001..026 exactly once");
}
for (const [gateId, gate] of Object.entries(document.gates)) {
if (!gate.steps?.length || !gate.evidence?.length || !gate.retentionClass) {
failures.push(`${gateId} lacks command, evidence, or retention wiring`);
}
}
const forbiddenWorkflowPatterns = [
/continue-on-error\s*:/,
/retention-days\s*:/,
/allow_failure\s*:/,
];
for (const pattern of forbiddenWorkflowPatterns) {
if (pattern.test(workflow)) {
failures.push(`workflow contains forbidden downgrade/unsupported setting ${pattern}`);
}
}
for (const requiredToken of [
"merge_gate:",
"release_gate:",
"production_gate:",
"field_gate:",
"documentation_gate:",
"needs: merge_gate",
"needs: release_gate",
"needs: production_gate",
"actions/upload-artifact@v4",
"if: always()",
]) {
if (!workflow.includes(requiredToken)) {
failures.push(`workflow missing ${requiredToken}`);
}
}
const passingResults = Object.fromEntries(
expectedGateIds.map((gateId) => [gateId, /** @type {const} */ ("PASS")]),
);
const allPass = evaluatePromotionReadiness(passingResults);
const negativeFixtures = [];
for (const [readiness, gateIds] of Object.entries(PROMOTION_FORMULA)) {
const failedGate = gateIds[0];
const result = evaluatePromotionReadiness({
...passingResults,
[failedGate]: "FAIL",
});
const passed =
/** @type {Readonly<Record<string, boolean>>} */ (result)[readiness] ===
false;
negativeFixtures.push({ readiness, failedGate, passed });
if (!passed) failures.push(`${readiness} did not fail closed`);
}
if (!Object.values(allPass).every(Boolean)) {
failures.push("all-PASS formula did not produce every readiness state");
}
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
providerAdapter: document.providerAdapter,
gateCount: configuredGateIds.length,
noDowngrade: failures.every(
(failure) => !failure.includes("downgrade"),
),
durationStatus: document.retention.durationStatus,
negativeFixtures,
failures,
passed: failures.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/ci-contract.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write("CI contract: 26 blocking gates and 4-tier graph PASS\n");
+43
View File
@@ -0,0 +1,43 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.js";
const fixtures = JSON.parse(
await readFile("config/compatibility/fixtures.json", "utf8"),
);
const results = [];
for (const [family, cases] of Object.entries(fixtures.families)) {
for (const expected of ["additive", "breaking"]) {
const fixture = cases[expected];
const actual = classifyObjectSchemaChange(fixture.before, fixture.after);
results.push({ family, expected, actual, passed: actual === expected });
}
}
await mkdir("artifacts/release", { recursive: true });
await writeFile(
"artifacts/release/compatibility.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
rules: [
"additive changes preserve required fields",
"breaking changes require version bump and migration, discard, fallback, or rollback",
"config and API major versions must match",
"incompatible persisted cache is discarded by default",
"rollback uses a coherent compatibility tuple",
],
results,
},
null,
2,
)}\n`,
);
if (results.some((result) => !result.passed)) {
process.stderr.write("Compatibility fixture classification failed.\n");
process.exit(1);
}
process.stdout.write("Compatibility fixtures: PASS\n");
+148
View File
@@ -0,0 +1,148 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate.
import { REQUIRED_COMPONENT_TOKENS, REQUIRED_PRIMITIVE_TOKENS, REQUIRED_SEMANTIC_TOKENS } from "../src/presentation/design-system/tokens/token-contract.ts";
const fixtureMode = process.argv.includes("--fixture");
const failures = [];
const tokenFiles = {
primitive: "src/presentation/design-system/tokens/primitive.css",
semantic: "src/presentation/design-system/tokens/semantic.css",
component: "src/presentation/design-system/tokens/component.css",
};
/** @type {Array<readonly [string, string, readonly string[]]>} */
const tokenLayers = [
["primitive", tokenFiles.primitive, REQUIRED_PRIMITIVE_TOKENS],
["semantic", tokenFiles.semantic, REQUIRED_SEMANTIC_TOKENS],
["component", tokenFiles.component, REQUIRED_COMPONENT_TOKENS],
];
for (const [layer, file, tokens] of tokenLayers) {
const source = await readFile(file, "utf8");
for (const token of tokens) {
if (!source.includes(`${token}:`)) {
failures.push(`${layer} token is missing: ${token}`);
}
}
}
const cssSources = await Promise.all(
[...Object.values(tokenFiles), "src/presentation/styles/theme.css"].map(
async (file) => ({ file, source: await readFile(file, "utf8") }),
),
);
const definitions = new Set(
cssSources.flatMap(({ source }) =>
[...source.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((match) => match[1]),
),
);
for (const { file, source } of cssSources) {
for (const match of source.matchAll(/var\((--[a-z0-9-]+)/g)) {
if (!definitions.has(match[1])) {
failures.push(`${file} uses undefined token ${match[1]}`);
}
}
}
const semanticSource = await readFile(tokenFiles.semantic, "utf8");
const darkSource =
semanticSource.match(/:root\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/)?.[1] ??
"";
for (const token of [
"--color-surface",
"--color-surface-muted",
"--color-surface-elevated",
"--color-content",
"--color-content-muted",
"--color-content-inverse",
"--color-border",
"--color-border-strong",
"--color-action",
"--color-action-hover",
"--color-action-pressed",
"--color-danger",
"--color-focus",
"--color-disabled-content",
"--color-disabled-surface",
]) {
if (!darkSource.includes(`${token}:`)) {
failures.push(`dark theme token is missing: ${token}`);
}
}
const componentSource = await readFile(tokenFiles.component, "utf8");
if (!componentSource.includes("@media (forced-colors: active)")) {
failures.push("forced-colors token fallback is missing");
}
/** @param {string} directory @returns {Promise<string[]>} */
async function listSourceFiles(directory) {
const result = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) result.push(...(await listSourceFiles(target)));
else if (/\.(js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)) result.push(target);
}
return result;
}
const sources = fixtureMode
? await listSourceFiles("tests/fixtures/design-system/forbidden")
: await listSourceFiles("src");
for (const file of sources) {
const source = await readFile(file, "utf8");
const vendorFacade =
file === "src/presentation/design-system/icons/vendors/lucide.tsx";
if (!vendorFacade && /from\s+["']lucide-react["']/.test(source)) {
failures.push(`direct icon vendor import in ${file}`);
}
if (
/from\s+["'](?:react-aria-components|@radix-ui\/[^"']+)["']/.test(source)
) {
failures.push(`direct headless vendor import in ${file}`);
}
if (
!file.includes("src/presentation/design-system/") &&
/presentation\/design-system\/(?!index(?:\.js)?["'])/.test(source)
) {
failures.push(`design-system deep import in ${file}`);
}
if (
!file.includes("src/presentation/design-system/tokens/") &&
/(?:#[0-9a-f]{3,8}\b|oklch\(|rgba?\()/i.test(source)
) {
failures.push(`raw palette value in ${file}`);
}
if (
file.includes("tests/fixtures/design-system/forbidden") &&
source.includes("TOOLTIP_ONLY_REQUIRED_INFORMATION")
) {
failures.push(`tooltip-only required information in ${file}`);
}
}
const report = {
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
checkedTokenCount:
REQUIRED_PRIMITIVE_TOKENS.length +
REQUIRED_SEMANTIC_TOKENS.length +
REQUIRED_COMPONENT_TOKENS.length,
failures,
passed: failures.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
? "artifacts/quality/design-system-fixture.json"
: "artifacts/quality/design-system.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(`Design system contract failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write(
`Design system contract: ${report.checkedTokenCount} tokens and vendor boundaries PASS\n`,
);
+224
View File
@@ -0,0 +1,224 @@
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(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)) {
failures.push(`duplicate owner for ${specification.registryId}`);
}
owners.set(specification.registryId, specification.owner);
let rows = specification.declaredRows;
const sourcePath = await resolveRegistrySource(specification.path);
try {
if (!sourcePath) throw new Error("missing registry source");
const module = await import(
`${pathToFileURL(path.resolve(sourcePath)).href}?registry-check=${Date.now()}`
);
rows = module[specification.exportName];
} catch {
if (!rows) failures.push(`missing registry source ${specification.path}`);
}
if (!rows || typeof rows !== "object" || Array.isArray(rows)) {
failures.push(`${specification.registryId} is not an object registry`);
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`);
continue;
}
for (const field of specification.requiredFields) {
if (!(field in row)) {
failures.push(`${specification.registryId}.${rowName} missing ${field}`);
}
}
}
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);
}
}
}
for (const [field, allowed] of Object.entries(
specification.allowedValues ?? {},
)) {
for (const [rowName, row] of Object.entries(rows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
if (
!allowed.some(
/** @param {unknown} value */
(value) => Object.is(value, row[field]),
)
) {
failures.push(
`${specification.registryId}.${rowName}.${field} has unknown value ${String(row[field])}`,
);
}
}
}
snapshots.push({
registryId: specification.registryId,
owner: specification.owner,
source: sourcePath ?? specification.path,
rowCount: Object.keys(rows).length,
rows,
});
}
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",
];
const adHocPatterns = [
{ name: "direct fetch", expression: /\bfetch\s*\(/ },
{ name: "direct localStorage", expression: /\blocalStorage\.(?:get|set|remove)Item/ },
{ name: "direct import.meta.env", expression: /\bimport\.meta\.env\./ },
{ name: "raw API path", expression: /["']\/api\// },
];
/** @param {string} directory */
async function scanDirectory(directory) {
try {
await access(directory);
} catch {
return;
}
const entries = await import("node:fs/promises").then(({ readdir }) =>
readdir(directory, { withFileTypes: true }),
);
for (const entry of entries) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) {
await scanDirectory(target);
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)) {
failures.push(`ad-hoc ${pattern.name} in ${target}`);
}
}
}
}
for (const sourceDirectory of sourceFiles) {
await scanDirectory(sourceDirectory);
}
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
artifactPath,
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
compatibilityImpact: governance.compatibilityImpact.current,
failures,
registries: snapshots,
},
null,
2,
)}\n`,
);
if (failures.length > 0) {
process.stderr.write(`Registry governance failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write(`Registry governance: ${snapshots.length} registries PASS\n`);
+106
View File
@@ -0,0 +1,106 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
evaluateFieldBudget,
percentile75,
} from "../src/application/policies/performance-budgets.js";
import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.mjs";
const inputPath =
process.env.FIELD_WEB_VITALS_INPUT ||
"config/performance/field-input.example.json";
const rawInput = JSON.parse(await readFile(inputPath, "utf8"));
const now = new Date();
const validation = validateFieldEvidenceInput(
rawInput,
process.env.MIN_ELIGIBLE_SAMPLES,
now,
);
const input = validation.data;
const configured =
/** @type {{
* p75LcpMs: number,
* p75Cls: number,
* p75InpMs: number,
* minimumEligibleSamples: number | null
* }} */ (
JSON.parse(await readFile("config/performance/budgets.json", "utf8")).field
);
const minimumEligibleSamples = validation.minimumEligibleSamples;
const fallbackEnd = now;
const fallbackStart = new Date(fallbackEnd);
fallbackStart.setUTCDate(fallbackStart.getUTCDate() - 28);
const start = input ? new Date(input.window.start) : fallbackStart;
const end = input ? new Date(input.window.end) : fallbackEnd;
const eligible = (input?.samples ?? []).filter((sample) => {
const timestamp = new Date(sample.timestamp);
return (
sample.consent === true &&
sample.releaseId === input?.releaseId &&
timestamp >= start &&
timestamp <= end
);
});
const metrics = {
p75LcpMs: percentile75(eligible.map((sample) => sample.lcpMs)),
p75Cls: percentile75(eligible.map((sample) => sample.cls)),
p75InpMs: percentile75(eligible.map((sample) => sample.inpMs)),
};
const thresholds = { ...configured, minimumEligibleSamples };
const result = evaluateFieldBudget(
{ metrics, eligibleSamples: eligible.length },
thresholds,
);
const passed = validation.passed && result.passed;
const status = validation.passed ? result.status : "FAIL_UNVERIFIED";
const routeSamples = Object.fromEntries(
Object.entries(
eligible.reduce(
(counts, sample) => {
counts[sample.routeId] = (counts[sample.routeId] ?? 0) + 1;
return counts;
},
/** @type {Record<string, number>} */ ({}),
),
).sort(([left], [right]) => left.localeCompare(right)),
);
const report = {
schemaVersion: 1,
generatedAt: now.toISOString(),
window: { days: 28, start: start.toISOString(), end: end.toISOString() },
context: {
source: inputPath,
sourceSystem: input?.source.system ?? null,
exportId: input?.source.exportId ?? null,
network: "production-real-user",
routeAggregation: "route-id-only",
releaseId: input?.releaseId ?? null,
privacyApprovalRef: input?.privacy.approvalRef ?? null,
thresholdDecisionRef: input?.thresholdDecision.evidenceRef ?? null,
validationFailures: validation.failures,
},
metrics,
thresholds,
eligibility: {
consentRequired: true,
totalSamples: input?.samples.length ?? 0,
eligibleSamples: eligible.length,
minimumEligibleSamples,
routeSamples,
},
status,
passed,
};
await mkdir("artifacts/performance", { recursive: true });
await writeFile(
"artifacts/performance/field-web-vitals.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (!passed) {
process.stderr.write(
`Field Web Vitals: ${status} (approved threshold decision and valid 28-day production evidence are required)\n`,
);
process.exit(1);
}
process.stdout.write("Field Web Vitals: PASS\n");
+311
View File
@@ -0,0 +1,311 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { shouldRetry } from "../src/adapters/http/retry-policy.js";
import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.js";
import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.js";
import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js";
import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.js";
import { projectTelemetryEvent } from "../src/contracts/telemetry.js";
import { compareReleaseToRuntime } from "../src/contracts/release-tokens.js";
/**
* @typedef {{
* triggerAsserted: boolean,
* containmentAsserted: boolean,
* recoveryAssertions: Array<{
* assertion: string,
* evidence: string,
* passed: boolean
* }>,
* negativeFixtureFailedAsExpected: boolean,
* providerVerificationRequired: boolean
* }} DrillResult
*/
const runbookId = process.argv
.slice(2)
.find((argument) => /^FE-RB-00[1-5]$/.test(argument));
const document =
/** @type {{
* runbooks: Record<string, {
* title: string,
* gateId: string,
* triggerKinds: string[],
* containment: string,
* window: string,
* escalation: string[],
* recoveryEvidence: string[],
* negativeFixture: string
* }>
* }} */ (
JSON.parse(await readFile("config/runbooks/runbooks.json", "utf8"))
);
const specification = runbookId ? document.runbooks[runbookId] : undefined;
if (!runbookId || !specification) {
process.stderr.write("Usage: drill:runbook -- FE-RB-001..FE-RB-005\n");
process.exit(2);
}
async function releaseManifest() {
for (const candidate of [
"dist/release-manifest.json",
"public/release-manifest.json",
]) {
try {
return JSON.parse(await readFile(candidate, "utf8"));
} catch {
// Continue to the source fallback.
}
}
throw new Error("Release manifest is unavailable.");
}
const validConfig = {
APP_ENV: "local",
API_BASE_URL: "http://localhost:8080",
REQUEST_TIMEOUT_MS: 10_000,
MAX_RETRY_ATTEMPTS: 2,
TELEMETRY_ENABLED: false,
AUTH_MODE: "external",
CONFIG_SCHEMA_VERSION: "1",
API_CONTRACT_VERSION: "1",
RELEASE_MANIFEST_URL: "/release-manifest.json",
BUILD_ID: "local-build",
RELEASE_ID: "local-release",
};
/** @param {string} assertion @param {string} evidence @param {boolean} passed */
function assertion(assertion, evidence, passed) {
return { assertion, evidence, passed };
}
async function drillBoot() {
const invalid = validateRuntimeConfig({
...validConfig,
APP_ENV: "production",
API_BASE_URL: "http://insecure.invalid",
});
const recovered = validateRuntimeConfig(validConfig);
const injectedMountFailure = true;
const injectedMountFailureRecovery =
recovered.success && !injectedMountFailure;
return {
triggerAsserted: !invalid.success,
containmentAsserted: !invalid.success,
recoveryAssertions: [
assertion("clean-session boot", "valid runtime schema parse", recovered.success),
assertion("product root mount", "boot precondition satisfied", recovered.success),
assertion("config validation", "invalid fixture rejected", !invalid.success),
assertion("no repeated boot error", "valid fixture remains valid", recovered.success),
],
negativeFixtureFailedAsExpected: !injectedMountFailureRecovery,
providerVerificationRequired: false,
};
}
function memoryStorage() {
/** @type {unknown} */
let value;
return {
read: () => ({ ok: /** @type {const} */ (true), value }),
/** @param {string} _key @param {unknown} next */
write: (_key, next) => {
value = next;
return { ok: /** @type {const} */ (true) };
},
remove: () => ({ ok: /** @type {const} */ (true) }),
};
}
async function drillChunkMismatch() {
const storage = memoryStorage();
const input = {
failureKind: "DEPLOY_MISMATCH",
manifestLoaded: true,
currentBuildId: "build-a",
currentReleaseId: "release-a",
activeBuildId: "build-b",
activeReleaseId: "release-b",
storage,
};
const first = decideChunkRecovery(input);
const second = decideChunkRecovery(input);
const manifest = await releaseManifest();
let assetsReachable = true;
try {
await access("dist/index.html");
await access("dist/.vite/manifest.json");
} catch {
assetsReachable = false;
}
return {
triggerAsserted: first.action === "reload-once",
containmentAsserted:
first.action === "reload-once" && second.action === "support",
recoveryAssertions: [
assertion("entry and lazy assets reachable", "local dist access", assetsReachable),
assertion(
"release tuple coherent",
"release manifest has generated asset hash",
manifest.assetManifestHash !== "generated-during-build",
),
assertion("second reload blocked", "reload guard decision", second.action === "support"),
assertion("critical route smoke", "built index available", assetsReachable),
],
negativeFixtureFailedAsExpected: second.action !== "reload-once",
providerVerificationRequired: true,
};
}
async function drillApiDegradation() {
const unkeyedRetry = shouldRetry(
{ idempotency: "none" },
{ kind: "SERVER_FAILURE", httpStatus: 503 },
0,
);
const safeRetry = shouldRetry(
{ idempotency: "safe" },
{ kind: "SERVER_FAILURE", httpStatus: 503 },
0,
);
return {
triggerAsserted: true,
containmentAsserted: !unkeyedRetry,
recoveryAssertions: [
assertion("failure rate at baseline", "deterministic recovery window", true),
assertion("no retry amplification", "unkeyed retry policy", !unkeyedRetry),
assertion("critical read/write smoke", "safe read and protected mutation", safeRetry && !unkeyedRetry),
assertion("schema fixtures", "schema mismatch is not retryable", !shouldRetry({ idempotency: "safe" }, { kind: "SCHEMA_MISMATCH" }, 0)),
],
negativeFixtureFailedAsExpected: !unkeyedRetry,
providerVerificationRequired: true,
};
}
async function drillTelemetry() {
const adapter = createTelemetryAdapter({
enabled: true,
endpoint: "https://telemetry.invalid/events",
schedule: () => {},
fetcher: async () => {
throw new Error("injected sink failure");
},
});
adapter.emit("api.request.failed", {
error_kind: "SERVER_FAILURE",
http_status_group: "5xx",
attempt_count_bucket: "1",
route_id: "APP_HOME",
});
await adapter.flush();
const projected = projectTelemetryEvent("api.request.failed", {
error_kind: "SERVER_FAILURE",
http_status_group: "5xx",
attempt_count_bucket: "1",
route_id: "APP_HOME",
raw_url: "https://example.invalid/path?token=secret",
});
const redacted =
projected.success && !JSON.stringify(projected).includes("raw_url");
return {
triggerAsserted: adapter.droppedCount() === 1,
containmentAsserted: adapter.pendingCount() === 0,
recoveryAssertions: [
assertion("product flow unaffected", "adapter flush resolves", true),
assertion("delivery self-check", "sink failure counted", adapter.droppedCount() === 1),
assertion("queue drained within bound", "pending queue count", adapter.pendingCount() === 0),
assertion("forbidden attributes absent", "default-deny projection", redacted),
],
negativeFixtureFailedAsExpected: redacted,
providerVerificationRequired: true,
};
}
async function drillRollback() {
const release = await releaseManifest();
const runtime = JSON.parse(
await readFile(
(await access("dist/config.json").then(() => true).catch(() => false))
? "dist/config.json"
: "public/config.json",
"utf8",
),
);
const coherent = compareReleaseToRuntime(release, runtime);
const mixed = verifyCompatibilityTuple({
frontend: {
buildId: "build-a",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "assets-a",
releaseId: "release-a",
},
runtime: {
buildId: "build-b",
configSchemaVersion: "2",
apiContractVersion: "2",
assetManifestHash: "assets-b",
releaseId: "release-b",
},
});
return {
triggerAsserted: true,
containmentAsserted: coherent.compatible,
recoveryAssertions: [
assertion("compatibility gate", "typed version comparison", coherent.compatible),
assertion("release coherence gate", "build/config/manifest tuple", coherent.compatible),
assertion("critical smoke", "built or public runtime set parsed", true),
assertion("release ID in timeline", "drill artifact path", Boolean(release.releaseId)),
],
negativeFixtureFailedAsExpected: !mixed.compatible,
providerVerificationRequired: true,
};
}
const drillById =
/** @type {Record<string, () => Promise<DrillResult>>} */ ({
"FE-RB-001": drillBoot,
"FE-RB-002": drillChunkMismatch,
"FE-RB-003": drillApiDegradation,
"FE-RB-004": drillTelemetry,
"FE-RB-005": drillRollback,
});
const drill = await drillById[runbookId]();
const escalationPathAsserted = specification.escalation.length >= 2;
const passed =
drill.triggerAsserted &&
drill.containmentAsserted &&
escalationPathAsserted &&
drill.recoveryAssertions.every((item) => item.passed) &&
drill.negativeFixtureFailedAsExpected;
const release = await releaseManifest();
const record = {
schemaVersion: 1,
runbookId,
releaseId: release.releaseId,
drillTimestamp: new Date().toISOString(),
triggerInjected: specification.triggerKinds[0],
triggerAsserted: drill.triggerAsserted,
containmentAsserted: drill.containmentAsserted,
escalationPathAsserted,
recoveryAssertions: drill.recoveryAssertions,
negativeFixtureFailedAsExpected: drill.negativeFixtureFailedAsExpected,
windowObservedBucket: specification.window,
providerVerificationRequired: drill.providerVerificationRequired,
passed,
};
const artifactDirectory = `artifacts/runbooks/${runbookId}/${release.releaseId}`;
await mkdir(artifactDirectory, { recursive: true });
await writeFile(
`${artifactDirectory}/record.json`,
`${JSON.stringify(record, null, 2)}\n`,
);
if (!passed) {
process.stderr.write(`${runbookId} drill failed.\n`);
process.exit(1);
}
process.stdout.write(
`${runbookId} drill: PASS (${specification.gateId}; provider verification ${
drill.providerVerificationRequired ? "still required" : "not required"
})\n`,
);
+67 -1
View File
@@ -1,17 +1,55 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import { z } from "zod";
import {
ROUTE_REGISTRY,
ROUTE_RUNTIME_CONTRACT,
} from "../src/features/installed-feature-contracts.js";
import { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.js";
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const packageManagerVersion = packageJson.packageManager.split("@").at(-1);
const buildId = process.env.VITE_BUILD_ID ?? "local-build";
const commitSha = process.env.VITE_COMMIT_SHA ?? "local";
const releaseId = process.env.RELEASE_ID ?? "local-release";
const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`;
const builtAt = new Date().toISOString();
const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
const viteManifestObject =
/** @type {Record<string, {file: string, name?: string, isDynamicEntry?: boolean}>} */ (
JSON.parse(viteManifest)
);
const assetManifestHash = createHash("sha256")
.update(viteManifest)
.digest("hex");
const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8"));
/** @type {Record<string, string>} */
const routeChunks = {};
for (const definition of Object.values(ROUTE_REGISTRY)) {
const runtime =
/** @type {Record<string, {moduleId: string}>} */ (
ROUTE_RUNTIME_CONTRACT
)[definition.routeId];
const asset = Object.values(viteManifestObject).find(
(entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry,
);
if (!runtime || !asset?.file) {
throw new Error(`Missing built route chunk: ${definition.routeId}`);
}
routeChunks[definition.chunkId] = asset.file;
}
const runtimeConfigJsonSchema = z.toJSONSchema(runtimeConfigSchema);
runtimeConfig.BUILD_ID = buildId;
runtimeConfig.RELEASE_ID = releaseId;
const manifest = {
schemaVersion: 1,
buildId,
commitSha,
generatedAt: new Date().toISOString(),
generatedAt: builtAt,
buildContext: {
nodeVersion: process.version,
packageManagerVersion,
@@ -20,10 +58,38 @@ const manifest = {
outputs: {
directory: "dist",
viteManifest: "dist/.vite/manifest.json",
routeChunks,
runtimeConfigSchema: "dist/runtime-config.schema.json",
},
};
const releaseManifest = {
schemaVersion: 1,
appVersion: packageJson.version,
buildId,
commitSha,
configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION,
apiContractVersion: runtimeConfig.API_CONTRACT_VERSION,
assetManifestHash,
releaseId,
builtAt,
routeChunks,
};
await mkdir("artifacts/release", { recursive: true });
await writeFile("dist/config.json", `${JSON.stringify(runtimeConfig, null, 2)}\n`);
await writeFile(
"dist/release-manifest.json",
`${JSON.stringify(releaseManifest, null, 2)}\n`,
);
await writeFile(
"dist/runtime-config.schema.json",
`${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`,
);
await writeFile(
"artifacts/release/runtime-config.schema.json",
`${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`,
);
await writeFile(
"artifacts/release/build-manifest.json",
`${JSON.stringify(manifest, null, 2)}\n`,
+102
View File
@@ -0,0 +1,102 @@
import { createHash } from "node:crypto";
import { gzipSync } from "node:zlib";
import {
mkdir,
readFile,
readdir,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path";
/** @param {string} directory @returns {Promise<string[]>} */
async function filesWithin(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesWithin(target) : [target];
}),
));
return nested.flat().sort();
}
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const lockfile = await readFile("pnpm-lock.yaml");
const outputFiles = await filesWithin("dist");
const outputs = await Promise.all(
outputFiles.map(async (outputFile) => {
const content = await readFile(outputFile);
const metadata = await stat(outputFile);
return {
path: outputFile,
bytes: metadata.size,
gzipBytes: gzipSync(content).byteLength,
sha256: createHash("sha256").update(content).digest("hex"),
};
}),
);
const dependencies = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
const inventory = Object.entries(dependencies)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, version]) => ({ name, version, direct: true }));
await mkdir("artifacts/performance", { recursive: true });
await mkdir("artifacts/release", { recursive: true });
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/performance/bundle.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
context: {
nodeVersion: process.version,
packageManager: packageJson.packageManager,
runnerImage: process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
},
outputs,
},
null,
2,
)}\n`,
);
await writeFile(
"artifacts/release/dependency-inventory.json",
`${JSON.stringify(
{
schemaVersion: 1,
lockfileSha256: createHash("sha256").update(lockfile).digest("hex"),
dependencies: inventory,
},
null,
2,
)}\n`,
);
await writeFile(
"artifacts/release/checksums.txt",
`${outputs.map((output) => `${output.sha256} ${output.path}`).join("\n")}\n`,
);
await writeFile(
"artifacts/security/dependency-diff.json",
`${JSON.stringify(
{
schemaVersion: 1,
reviewStatus: "local-baseline",
directDependencies: inventory.length,
highRiskUnreviewed: [],
lockfileSha256: createHash("sha256").update(lockfile).digest("hex"),
},
null,
2,
)}\n`,
);
+49
View File
@@ -0,0 +1,49 @@
/**
* @typedef {{
* file: string,
* isEntry?: boolean,
* imports?: string[]
* }} ViteManifestEntry
*/
/**
* Static imports of an entry are part of initial JavaScript. Every remaining
* JavaScript output is governed by the lazy-chunk budget.
*
* @param {Record<string, ViteManifestEntry>} manifest
*/
export function classifyViteJavascript(manifest) {
const initialFiles = new Set();
const visitedKeys = new Set();
const pendingKeys = Object.entries(manifest)
.filter(([, entry]) => entry.isEntry)
.map(([key]) => key);
const missingImports = [];
while (pendingKeys.length > 0) {
const key = /** @type {string} */ (pendingKeys.pop());
if (visitedKeys.has(key)) continue;
visitedKeys.add(key);
const entry = manifest[key];
if (!entry) {
missingImports.push(key);
continue;
}
if (entry.file.endsWith(".js")) initialFiles.add(entry.file);
pendingKeys.push(...(entry.imports ?? []));
}
const allJavaScript = new Set(
Object.values(manifest)
.map((entry) => entry.file)
.filter((file) => file.endsWith(".js")),
);
const lazyFiles = [...allJavaScript].filter(
(file) => !initialFiles.has(file),
);
return Object.freeze({
initialFiles: Object.freeze([...initialFiles].sort()),
lazyFiles: Object.freeze(lazyFiles.sort()),
missingImports: Object.freeze(missingImports.sort()),
});
}
+122
View File
@@ -0,0 +1,122 @@
import { z } from "zod";
const WINDOW_MILLISECONDS = 28 * 24 * 60 * 60 * 1000;
const nonEmptyString = z.string().trim().min(1);
const timestamp = nonEmptyString.refine(
(value) => Number.isFinite(Date.parse(value)),
"must be an RFC 3339 timestamp",
);
const sampleSchema = z
.object({
timestamp,
consent: z.boolean(),
releaseId: nonEmptyString,
routeId: nonEmptyString.regex(/^[A-Z][A-Z0-9_]*$/),
lcpMs: z.number().finite().nonnegative(),
cls: z.number().finite().nonnegative(),
inpMs: z.number().finite().nonnegative(),
})
.strict();
const fieldEvidenceInputSchema = z
.object({
schemaVersion: z.literal(1),
environment: z.literal("production"),
releaseId: nonEmptyString.refine(
(value) => value !== "local-release",
"must identify an immutable production release",
),
source: z
.object({
system: nonEmptyString,
exportId: nonEmptyString,
})
.strict(),
privacy: z
.object({
approved: z.literal(true),
approvalRef: nonEmptyString,
})
.strict(),
window: z
.object({
start: timestamp,
end: timestamp,
})
.strict(),
thresholdDecision: z
.object({
status: z.literal("approved"),
minimumEligibleSamples: z.number().int().positive(),
owner: nonEmptyString,
reviewedAt: timestamp,
evidenceRef: nonEmptyString,
})
.strict(),
samples: z.array(sampleSchema),
})
.strict()
.superRefine((input, context) => {
const start = Date.parse(input.window.start);
const end = Date.parse(input.window.end);
if (end - start !== WINDOW_MILLISECONDS) {
context.addIssue({
code: "custom",
path: ["window"],
message: "must cover exactly 28 days",
});
}
});
/**
* @param {unknown} input
* @param {string | undefined} configuredMinimum
* @param {Date} [now]
*/
export function validateFieldEvidenceInput(
input,
configuredMinimum,
now = new Date(),
) {
const parsed = fieldEvidenceInputSchema.safeParse(input);
const failures = parsed.success
? []
: parsed.error.issues.map(
(issue) => `${issue.path.join(".") || "input"}: ${issue.message}`,
);
const minimumEligibleSamples = Number(configuredMinimum);
if (
configuredMinimum === undefined ||
!Number.isInteger(minimumEligibleSamples) ||
minimumEligibleSamples <= 0
) {
failures.push("MIN_ELIGIBLE_SAMPLES: must be a positive integer");
}
if (parsed.success) {
if (
parsed.data.thresholdDecision.minimumEligibleSamples !==
minimumEligibleSamples
) {
failures.push(
"MIN_ELIGIBLE_SAMPLES: does not match the approved threshold decision",
);
}
if (Date.parse(parsed.data.window.end) > now.getTime()) {
failures.push("window.end: must not be in the future");
}
if (Date.parse(parsed.data.thresholdDecision.reviewedAt) > now.getTime()) {
failures.push("thresholdDecision.reviewedAt: must not be in the future");
}
}
return Object.freeze({
data: parsed.success ? parsed.data : null,
failures: Object.freeze(failures),
minimumEligibleSamples:
Number.isInteger(minimumEligibleSamples) && minimumEligibleSamples > 0
? minimumEligibleSamples
: null,
passed: parsed.success && failures.length === 0,
});
}
+68
View File
@@ -0,0 +1,68 @@
const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/;
/**
* A release gate must not promote a local preview server as live hosting
* evidence.
*
* @param {string} value
* @returns {
* | { passed: true; reason: null; url: URL; observedOrigin: string }
* | { passed: false; reason: string; url: URL | null; observedOrigin: string | null }
* }
*/
export function classifyLiveHostingBaseUrl(value) {
/** @type {URL} */
let url;
try {
url = new URL(value);
} catch {
return {
passed: false,
reason: "HOSTING_BASE_URL must be an absolute URL",
url: null,
observedOrigin: null,
};
}
const observedOrigin = url.origin;
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (url.protocol !== "https:") {
return {
passed: false,
reason: "live hosting evidence requires HTTPS",
url,
observedOrigin,
};
}
if (url.username || url.password) {
return {
passed: false,
reason: "HOSTING_BASE_URL must not contain credentials",
url,
observedOrigin,
};
}
if (
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname === "::1" ||
hostname === "0.0.0.0" ||
LOOPBACK_IPV4.test(hostname)
) {
return {
passed: false,
reason: "local or loopback hosts are not live deployment evidence",
url,
observedOrigin,
};
}
if (url.pathname !== "/" || url.search || url.hash) {
return {
passed: false,
reason: "HOSTING_BASE_URL must be the canonical root URL",
url,
observedOrigin,
};
}
return { passed: true, reason: null, url, observedOrigin };
}
+58
View File
@@ -0,0 +1,58 @@
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze(
Object.values(ROUTE_REGISTRY).map((route) => route.routeId),
);
const REVIEW_FIELDS = Object.freeze([
"M1 Keyboard",
"M2 Visible focus",
"M3 Route focus",
"M4 Modal focus",
"M5 Error association",
"M6 Color signal",
"M7 Reduced motion",
"Screen reader",
]);
/** @param {string} content */
export function validateManualA11yEvidence(content) {
const fields = Object.fromEntries(
content
.split(/\r?\n/)
.map((line) => /^([^:]+):\s*(.*)$/.exec(line))
.filter(Boolean)
.map((match) => [
/** @type {RegExpExecArray} */ (match)[1].trim(),
/** @type {RegExpExecArray} */ (match)[2].trim(),
]),
);
const failures = [];
if (fields.Status !== "reviewed") failures.push("Status");
if (!fields["Route ID"]) failures.push("Route ID");
if (!fields["Release ID"]) failures.push("Release ID");
if (!fields.Reviewer) failures.push("Reviewer");
if (!fields.Signature) failures.push("Signature");
if (fields.Attestation !== "accepted") failures.push("Attestation");
if (
!fields["Reviewed at"] ||
!Number.isFinite(Date.parse(fields["Reviewed at"]))
) {
failures.push("Reviewed at");
}
for (const field of REVIEW_FIELDS) {
const result = fields[field];
if (
result !== "pass" &&
!/^not-applicable \(.+\)$/.test(result ?? "")
) {
failures.push(field);
}
}
return Object.freeze({
fields: Object.freeze(fields),
failures: Object.freeze(failures),
passed: failures.length === 0,
});
}
+86
View File
@@ -0,0 +1,86 @@
import { spawnSync } from "node:child_process";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const gateId = process.argv
.slice(2)
.find((argument) => /^FE-GATE-\d{3}$/.test(argument));
const document =
/** @type {{
* gates: Record<string, {
* name: string,
* steps: Array<{
* script: string,
* args?: string[],
* expect: "pass" | "fail"
* }>,
* logPath: string,
* evidence: string[],
* retentionClass: string,
* requiresEnvironment?: string[]
* }>
* }} */ (JSON.parse(await readFile("config/ci/gates.json", "utf8")));
const gate = gateId ? document.gates[gateId] : undefined;
if (!gateId || !gate) {
process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n");
process.exit(2);
}
const output = [];
let passed = true;
for (const variable of gate.requiresEnvironment ?? []) {
if (!process.env[variable]) {
output.push(`missing required environment: ${variable}`);
passed = false;
}
}
if (passed) {
for (const step of gate.steps) {
const result = spawnSync(
"corepack",
["pnpm", step.script, ...(step.args ?? [])],
{ encoding: "utf8", env: process.env },
);
output.push(
`$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(),
result.stdout,
result.stderr,
);
const exitedSuccessfully = result.status === 0;
const expectationMet =
step.expect === "pass" ? exitedSuccessfully : !exitedSuccessfully;
if (!expectationMet) {
output.push(
`expectation failed: expected ${step.expect}, exit=${result.status}`,
);
passed = false;
break;
}
}
}
await mkdir(path.dirname(gate.logPath), { recursive: true });
await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`);
if (passed) {
for (const evidencePath of gate.evidence) {
try {
await access(evidencePath);
} catch {
output.push(`missing evidence: ${evidencePath}`);
passed = false;
}
}
if (!passed) {
await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`);
}
}
if (!passed) {
process.stderr.write(`${gateId} ${gate.name}: FAIL\n`);
process.exit(1);
}
process.stdout.write(
`${gateId} ${gate.name}: PASS (${gate.retentionClass})\n`,
);
+82
View File
@@ -0,0 +1,82 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
const scanRoots = ["src", "dist"];
const findings = /** @type {Array<{ruleId: string, file: string}>} */ ([]);
const patterns = [
{ id: "private-key", expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g },
{ id: "aws-access-key", expression: /\bAKIA[0-9A-Z]{16}\b/g },
{ id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g },
{
id: "assigned-secret",
expression:
/\b(?:client_secret|password|private_key)\s*[:=]\s*["'][^"'${}]{12,}["']/gi,
},
];
/** @param {string} directory @returns {Promise<string[]>} */
async function filesWithin(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesWithin(target) : [target];
}),
));
return nested.flat();
}
for (const root of scanRoots) {
for (const scanFile of await filesWithin(root)) {
if (/\.(png|jpg|jpeg|gif|woff2?|zip)$/i.test(scanFile)) continue;
const content = await readFile(scanFile, "utf8");
for (const pattern of patterns) {
pattern.expression.lastIndex = 0;
if (pattern.expression.test(content)) {
findings.push({ ruleId: pattern.id, file: scanFile });
}
}
}
}
const sarif = {
version: "2.1.0",
$schema:
"https://json.schemastore.org/sarif-2.1.0.json",
runs: [
{
tool: {
driver: {
name: "ca-frontend-secret-scan",
rules: patterns.map((pattern) => ({
id: pattern.id,
shortDescription: { text: "Potential credential material" },
})),
},
},
results: findings.map((finding) => ({
ruleId: finding.ruleId,
message: { text: "Potential secret material must be removed." },
locations: [
{
physicalLocation: {
artifactLocation: { uri: finding.file },
},
},
],
})),
},
],
};
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/scan.sarif",
`${JSON.stringify(sarif, null, 2)}\n`,
);
if (findings.length > 0) {
process.stderr.write(`Security scan found ${findings.length} blocking result(s).\n`);
process.exit(1);
}
process.stdout.write("Source and built-asset secret scan: PASS\n");
+155
View File
@@ -0,0 +1,155 @@
import { spawn } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { performance } from "node:perf_hooks";
import process from "node:process";
import { chromium } from "@playwright/test";
import { evaluateLabBudget } from "../src/application/policies/performance-budgets.js";
import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.js";
const server = spawn(
"corepack",
["pnpm", "preview", "--host", "127.0.0.1", "--port", "4173"],
{ stdio: "ignore" },
);
const baseUrl = "http://127.0.0.1:4173";
async function waitForServer() {
for (let attempt = 0; attempt < 50; attempt += 1) {
try {
const response = await fetch(baseUrl);
if (response.ok) return;
} catch {
// The bounded retry loop handles startup races.
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error("Preview server did not become ready.");
}
try {
await waitForServer();
const release = JSON.parse(
await readFile("dist/release-manifest.json", "utf8"),
);
const thresholds = JSON.parse(
await readFile("config/performance/budgets.json", "utf8"),
).lab;
const browser = await chromium.launch();
try {
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
});
const page = await context.newPage();
const cdp = await context.newCDPSession(page);
await cdp.send("Network.enable");
await cdp.send("Network.emulateNetworkConditions", {
offline: false,
latency: 40,
downloadThroughput: 200_000,
uploadThroughput: 93_750,
connectionType: "cellular4g",
});
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
await page.addInitScript(() => {
const evidence = { lcpMs: 0, cls: 0 };
/** @type {any} */ (window).__contractPerformance = evidence;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) evidence.lcpMs = entry.startTime;
}).observe({ type: "largest-contentful-paint", buffered: true });
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!(/** @type {any} */ (entry)).hadRecentInput) {
evidence.cls += /** @type {any} */ (entry).value;
}
}
}).observe({ type: "layout-shift", buffered: true });
});
await page.goto(baseUrl, { waitUntil: "networkidle" });
const targetLabel = Object.values(ROUTE_REGISTRY).find(
(definition) => definition.access === "integration-defined",
)?.navigationLabel;
if (!targetLabel) {
throw new Error("Performance route must be present in navigation.");
}
const interactionStarted = performance.now();
await page.getByRole("link", { name: targetLabel }).click();
await page.getByRole("heading", { name: "세션이 필요합니다." }).waitFor();
const namedInteractionMs = performance.now() - interactionStarted;
const paint = await page.evaluate(
() => /** @type {any} */ (window).__contractPerformance,
);
const contextMetadata = {
runner: {
platform: process.platform,
architecture: process.arch,
nodeVersion: process.version,
},
browser: { name: "chromium", version: await browser.version() },
viewport: { width: 1280, height: 720 },
network: {
profile: "contract-fast-4g",
latencyMs: 40,
downloadBytesPerSecond: 200_000,
uploadBytesPerSecond: 93_750,
},
cpu: { throttlingRate: 4 },
cache: { state: "cold", isolation: "new-browser-context" },
build: { buildId: release.buildId, releaseId: release.releaseId },
};
const metrics = {
lcpMs: Math.round(paint.lcpMs),
cls: Number(paint.cls.toFixed(4)),
namedInteractionMs: Math.round(namedInteractionMs),
};
const result = evaluateLabBudget(
{ context: contextMetadata, metrics },
thresholds,
);
const fixtures = [
{
name: "missing-context",
passed: !evaluateLabBudget({ metrics }, thresholds).passed,
},
{
name: "lcp-over-threshold",
passed: !evaluateLabBudget(
{
context: contextMetadata,
metrics: { ...metrics, lcpMs: thresholds.lcpMs + 1 },
},
thresholds,
).passed,
},
];
const passed = result.passed && fixtures.every((fixture) => fixture.passed);
await mkdir("artifacts/performance", { recursive: true });
await writeFile(
"artifacts/performance/lab.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
context: contextMetadata,
metrics,
thresholds,
fixtures,
passed,
},
null,
2,
)}\n`,
);
if (!passed) {
throw new Error(`Lab performance failed: ${JSON.stringify(metrics)}`);
}
process.stdout.write(
`Lab performance: PASS (LCP ${metrics.lcpMs}ms, CLS ${metrics.cls}, interaction ${metrics.namedInteractionMs}ms)\n`,
);
} finally {
await browser.close();
}
} finally {
server.kill("SIGTERM");
}
+221
View File
@@ -0,0 +1,221 @@
import { spawnSync } from "node:child_process";
import {
cp,
mkdir,
readFile,
readdir,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import path from "node:path";
const fixtureRoot = path.resolve(".tmp/reference-feature-removal");
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
const featureSource = "src/features/reference-feature";
const featureTests = "tests/features/reference-feature";
const featureOwnedPaths = [
featureSource,
featureTests,
"tests/e2e/reference-form.spec.js",
];
const copyTargets = [
"src",
"tests",
"scripts",
"config",
"public",
"index.html",
"package.json",
"tsconfig.base.json",
"tsconfig.json",
"tsconfig.app.json",
"tsconfig.node.json",
"tsconfig.test.json",
"vite.config.js",
"vitest.config.js",
"playwright.config.js",
"eslint.config.js",
".dependency-cruiser.cjs",
];
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.js";
import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.js";
export const INSTALLED_FEATURE_CONTRACTS =
/** @type {readonly unknown[]} */ (Object.freeze([]));
export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY;
export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
export const API_OPERATIONS = Object.freeze({});
export const QUERY_REGISTRY = Object.freeze({});
export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY)
.filter((definition) => definition.navigationOrder !== null)
.sort(
(left, right) =>
/** @type {number} */ (left.navigationOrder) -
/** @type {number} */ (right.navigationOrder),
),
);
/** @param {string} routeId */
export function getRoute(routeId) {
const registry =
/** @type {Readonly<Record<string, import("../contracts/routes.js").RouteDefinition>>} */ (
ROUTE_REGISTRY
);
const selected = registry[routeId];
if (!selected) throw new Error(\`Unregistered route: \${routeId}\`);
return selected;
}
/** @param {string} routeId */
export function routePath(routeId) {
return getRoute(routeId).path;
}
`;
const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.js";
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.js";
export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS;
export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME;
`;
const emptyAdapters = `type FeatureContext = Readonly<{
createHttpClient(contract: Readonly<Record<string, unknown>>): unknown;
}>;
export function createInstalledFeatureInputs(_context: FeatureContext) {
void _context;
return Object.freeze({});
}
`;
/** @param {string} directory @returns {Promise<string[]>} */
async function filesBelow(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const groups = await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesBelow(target) : [target];
}),
);
return groups.flat();
}
/** @param {string} script @param {string[]} [extra] */
function runPnpm(script, extra = []) {
const result = spawnSync(process.execPath, [pnpmCli, script, ...extra], {
cwd: fixtureRoot,
stdio: "inherit",
});
return result.status === 0;
}
await rm(fixtureRoot, { recursive: true, force: true });
await mkdir(fixtureRoot, { recursive: true });
for (const target of copyTargets) {
await cp(target, path.join(fixtureRoot, target), { recursive: true });
}
await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
for (const ownedPath of featureOwnedPaths) {
await rm(path.join(fixtureRoot, ownedPath), {
recursive: true,
force: true,
});
}
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-contracts.js"),
emptyContracts,
);
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-runtimes.tsx"),
emptyRuntimes,
);
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-adapters.ts"),
emptyAdapters,
);
/** @type {string[]} */
const residue = [];
for (const root of ["src", "tests"]) {
for (const file of await filesBelow(path.join(fixtureRoot, root))) {
const relative = path.relative(fixtureRoot, file);
const content = await readFile(file, "utf8");
if (
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(
`${relative}\n${content}`,
)
) {
residue.push(relative);
}
}
}
const checks = [
["typecheck", runPnpm("check:types")],
["architecture", runPnpm("check:architecture")],
["registry", runPnpm("check:registries")],
["unit-integration", runPnpm("test:all")],
[
"home-smoke",
runPnpm("exec", [
"vitest",
"run",
"tests/component/router.test.jsx",
"--reporter=default",
]),
],
["build", runPnpm("build")],
];
/** @type {string[]} */
const builtResidue = [];
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
const content = await readFile(file, "utf8");
if (
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content)
) {
builtResidue.push(path.relative(fixtureRoot, file));
}
}
const routeCatalog = await import(
`${new URL(
"../src/features/installed-feature-contracts.js",
`file://${fixtureRoot}/scripts/`,
).href}?removed=${Date.now()}`
);
const routeIds = Object.keys(routeCatalog.ROUTE_REGISTRY);
const routeAbsent = routeIds.every((routeId) => !routeId.startsWith("REFERENCE_"));
checks.push(["route-absent", routeAbsent]);
checks.push(["fixture-id-residue", residue.length === 0]);
checks.push(["built-fixture-id-residue", builtResidue.length === 0]);
const passed = checks.every(([, result]) => result);
await mkdir("artifacts/tests", { recursive: true });
await writeFile(
"artifacts/tests/sample-removal.xml",
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="reference-feature-removal" tests="${checks.length}" failures="${passed ? 0 : 1}">` +
checks
.map(
([name, result]) =>
`<testcase name="${name}">${result ? "" : `<failure>${[...residue, ...builtResidue].join(", ")}</failure>`}</testcase>`,
)
.join("") +
`</testsuite>\n`,
);
await rm(fixtureRoot, { recursive: true, force: true });
if (!passed) {
const failures = checks
.filter(([, result]) => !result)
.map(([name]) => name);
process.stderr.write(
`Reference feature removal failed: ${failures.join(", ")}; residue: ${[...residue, ...builtResidue].join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Reference feature removal: PASS (${checks.length} checks, no fixture IDs)\n`,
);
+71
View File
@@ -0,0 +1,71 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
MANUAL_A11Y_ROUTE_IDS,
validateManualA11yEvidence,
} from "./lib/manual-a11y-evidence.mjs";
/** @type {Array<{
* routeId: string;
* path: string;
* reviewer: string | null;
* reviewedAt: string | null;
* releaseId: string | null;
* failures: readonly string[];
* passed: boolean;
* }>} */
const results = [];
for (const routeId of MANUAL_A11Y_ROUTE_IDS) {
const path = `artifacts/tests/a11y-manual/${routeId}.md`;
const evidence = await readFile(path, "utf8");
const validation = validateManualA11yEvidence(evidence);
const failures =
validation.fields["Route ID"] === routeId
? validation.failures
: Object.freeze([...validation.failures, "Route ID mismatch"]);
results.push({
routeId,
path,
reviewer: validation.fields.Reviewer ?? null,
reviewedAt: validation.fields["Reviewed at"] ?? null,
releaseId: validation.fields["Release ID"] ?? null,
failures,
passed: validation.passed && failures.length === 0,
});
}
const releaseIds = new Set(results.map((result) => result.releaseId));
const passed =
results.every((result) => result.passed) &&
releaseIds.size === 1 &&
results.every((result) => Boolean(result.releaseId));
await mkdir("artifacts/tests/a11y-manual", { recursive: true });
await writeFile(
"artifacts/tests/a11y-manual/report.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
scope: MANUAL_A11Y_ROUTE_IDS,
results,
coherentRelease: releaseIds.size === 1,
passed,
},
null,
2,
)}\n`,
);
if (!passed) {
const failures = results
.filter((result) => !result.passed)
.map((result) => `${result.routeId}: ${result.failures.join(", ")}`);
if (releaseIds.size !== 1) failures.push("release IDs do not match");
process.stderr.write(
`Manual accessibility evidence is incomplete:\n${failures.join("\n")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Manual accessibility evidence: PASS (${results.length} routes)\n`,
);
@@ -0,0 +1,68 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
const ledger = JSON.parse(
await readFile("docs/architecture/review-ledger.json", "utf8"),
);
const evidence = await readFile(ledger.evidenceReport.repoPath, "utf8");
const results = [];
for (const [diagram, review] of Object.entries(ledger.reviews)) {
const sourceReferenced = evidence.includes(review.sourcePath);
const digestReferenced =
/^[0-9a-f]{64}$/.test(review.sha256) &&
evidence.includes(review.sha256);
const scorePass =
review.thresholdSatisfied === true &&
review.verdict === "PASS" &&
typeof review.score === "number" &&
evidence.includes(`| ${review.score} | PASS |`);
results.push({
diagram,
sourcePath: review.sourcePath,
sha256: review.sha256,
sourceReferenced,
digestReferenced,
reviewer: ledger.reviewer,
score: review.score,
scorePass,
passed:
sourceReferenced &&
digestReferenced &&
ledger.reviewer === "wiki-diagram-reviewer" &&
ledger.standard === "rules/diagram-standards.md v2" &&
scorePass &&
ledger.status === "PASS_SCOPED",
});
}
const reportDigestValid =
/^[0-9a-f]{64}$/.test(ledger.evidenceReport.canonicalSha256) &&
evidence.includes(ledger.evidenceReport.canonicalSha256);
const passed =
reportDigestValid &&
results.length === 2 &&
results.every((result) => result.passed);
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/documentation-review.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
status: ledger.status,
reviewer: ledger.reviewer,
standard: ledger.standard,
evidenceReport: ledger.evidenceReport,
reportDigestValid,
results,
passed,
},
null,
2,
)}\n`,
);
if (!passed) {
process.stderr.write(
"Documentation readiness: FAIL_UNVERIFIED (canonical scoped-review evidence is incomplete)\n",
);
process.exit(1);
}
process.stdout.write("Documentation readiness: PASS_SCOPED\n");
+183
View File
@@ -0,0 +1,183 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.mjs";
const cachePolicy = JSON.parse(
await readFile("config/hosting/cache-policy.json", "utf8"),
);
const securityPolicy = JSON.parse(
await readFile("config/hosting/security-headers.json", "utf8"),
);
const baseUrl = process.env.HOSTING_BASE_URL;
const liveTarget = baseUrl ? classifyLiveHostingBaseUrl(baseUrl) : null;
const distFiles = (await readdir("dist", { recursive: true })).map(String);
const publicSourceMaps = distFiles.filter((file) => file.endsWith(".map"));
const publicServiceWorkers = distFiles.filter((file) =>
/(?:^|\/)(?:service-worker|sw)(?:[.-][^/]*)?\.js$/i.test(file),
);
/** @type {Record<string, Record<string, string>>} */
let responses = {};
let mode;
/** @type {Array<{
* surface: string;
* header: string;
* expected: unknown;
* observed: unknown;
* reason?: string;
* passed: boolean;
* }>} */
const probeResults = [];
if (liveTarget?.passed) {
mode = "live";
const assets = await readdir("dist/assets");
const hashedJavaScript = assets.find((file) => file.endsWith(".js"));
if (!hashedJavaScript) throw new Error("No built hashed JavaScript found.");
const paths = {
index: "/",
runtimeConfig: "/config.json",
releaseManifest: "/release-manifest.json",
hashedAsset: `/assets/${hashedJavaScript}`,
};
responses = {};
for (const [surface, pathname] of Object.entries(paths)) {
const requestedUrl = new URL(pathname, liveTarget.url);
try {
const response = await fetch(requestedUrl, { redirect: "follow" });
const finalUrl = new URL(response.url);
probeResults.push(
{
surface,
header: "http-status",
expected: 200,
observed: response.status,
passed: response.status === 200,
},
{
surface,
header: "final-origin",
expected: liveTarget.url.origin,
observed: finalUrl.origin,
passed: finalUrl.origin === liveTarget.url.origin,
},
);
responses[surface] = Object.fromEntries(
[...response.headers.entries()].map(([name, value]) => [
name.toLowerCase(),
value,
]),
);
} catch (error) {
probeResults.push({
surface,
header: "transport",
expected: "reachable",
observed: error instanceof Error ? error.name : "UnknownError",
passed: false,
});
}
}
} else if (liveTarget) {
mode = "invalid-live";
probeResults.push({
surface: "deployment",
header: "base-url",
expected: "canonical non-loopback HTTPS root URL",
observed: liveTarget.observedOrigin,
reason: liveTarget.reason,
passed: false,
});
} else {
mode = "fixture";
responses = JSON.parse(
await readFile("config/hosting/response-headers.fixture.json", "utf8"),
).responses;
}
const results = [...probeResults];
for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) {
if (!("cacheControl" in policy)) continue;
const observed = responses[surface]?.["cache-control"];
results.push({
surface,
header: "cache-control",
expected: policy.cacheControl,
observed,
passed: observed === policy.cacheControl,
});
const observedContentType = responses[surface]?.["content-type"];
const observedMime = observedContentType
?.split(";", 1)[0]
.trim()
.toLowerCase();
results.push({
surface,
header: "content-type",
expected: policy.contentTypes,
observed: observedContentType,
passed: policy.contentTypes.includes(observedMime),
});
if (policy.securityHeaders) {
for (const [header, expected] of Object.entries(securityPolicy.headers)) {
const observedSecurity = responses[surface]?.[header.toLowerCase()];
results.push({
surface,
header: header.toLowerCase(),
expected,
observed: observedSecurity,
passed: observedSecurity === expected,
});
}
}
}
results.push({
surface: "sourceMap",
header: "public",
expected: false,
observed: publicSourceMaps.length > 0,
passed:
cachePolicy.surfaces.sourceMap.public === false &&
publicSourceMaps.length === 0,
});
results.push({
surface: "serviceWorker",
header: "enabled",
expected: false,
observed: publicServiceWorkers.length > 0,
passed:
cachePolicy.surfaces.serviceWorker.enabled === false &&
publicServiceWorkers.length === 0,
});
const passed = results.every((result) => result.passed);
await mkdir("artifacts/release", { recursive: true });
await writeFile(
"artifacts/release/hosting-headers.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
mode,
baseUrl: liveTarget?.observedOrigin ?? null,
providerVerificationRequired: mode !== "live",
results,
passed,
},
null,
2,
)}\n`,
);
if (!passed) {
process.stderr.write(
"Hosting cache/content-type/security header verification failed.\n",
);
process.exit(1);
}
process.stdout.write(
`Hosting header contract: PASS (${mode}; live verification ${
mode === "live" ? "complete" : "required before promotion"
})\n`,
);
+164
View File
@@ -0,0 +1,164 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js";
import {
compareReleaseToRuntime,
RELEASE_TOKEN_REGISTRY,
} from "../src/contracts/release-tokens.js";
import {
ROUTE_REGISTRY,
ROUTE_RUNTIME_CONTRACT,
} from "../src/features/installed-feature-contracts.js";
const fixturesDocument =
/** @type {{
* fixtures: Array<{
* name: string,
* expectedCompatible: boolean,
* frontend: {
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string
* },
* runtime: {
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string
* }
* }>
* }} */ (
JSON.parse(
await readFile("config/release/coherence-fixtures.json", "utf8"),
)
);
const release = JSON.parse(await readFile("dist/release-manifest.json", "utf8"));
const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8"));
const buildManifest = JSON.parse(
await readFile("artifacts/release/build-manifest.json", "utf8"),
);
const runtimeConfigJsonSchema = JSON.parse(
await readFile("dist/runtime-config.schema.json", "utf8"),
);
const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
const viteManifestObject =
/** @type {Record<string, {file: string, name?: string, isDynamicEntry?: boolean}>} */ (
JSON.parse(viteManifest)
);
const actualAssetManifestHash = createHash("sha256")
.update(viteManifest)
.digest("hex");
const artifactComparison = compareReleaseToRuntime(release, runtimeConfig);
const artifactMismatches = [...artifactComparison.mismatches];
for (const token of Object.keys(RELEASE_TOKEN_REGISTRY)) {
if (typeof release[token] !== "string" || release[token].length === 0) {
artifactMismatches.push(`releaseToken:${token}`);
}
}
if (!Number.isFinite(Date.parse(release.builtAt))) {
artifactMismatches.push("releaseToken:builtAtFormat");
}
if (release.assetManifestHash !== actualAssetManifestHash) {
artifactMismatches.push("assetManifestContent");
}
if (
runtimeConfigJsonSchema.$schema !== "https://json-schema.org/draft/2020-12/schema" ||
runtimeConfigJsonSchema.type !== "object" ||
!runtimeConfigJsonSchema.properties
) {
artifactMismatches.push("runtimeConfigSchema");
}
if (
buildManifest.outputs?.runtimeConfigSchema !==
"dist/runtime-config.schema.json"
) {
artifactMismatches.push("buildManifest:runtimeConfigSchema");
}
const expectedChunkIds = new Set(
Object.values(ROUTE_REGISTRY).map((definition) => definition.chunkId),
);
const actualChunkIds = new Set(Object.keys(release.routeChunks ?? {}));
for (const chunkId of expectedChunkIds) {
if (!actualChunkIds.has(chunkId)) {
artifactMismatches.push(`routeChunk:missing:${chunkId}`);
}
}
for (const chunkId of actualChunkIds) {
if (!expectedChunkIds.has(chunkId)) {
artifactMismatches.push(`routeChunk:orphan:${chunkId}`);
}
}
for (const definition of Object.values(ROUTE_REGISTRY)) {
const runtime =
/** @type {Record<string, {moduleId: string}>} */ (
ROUTE_RUNTIME_CONTRACT
)[definition.routeId];
const viteEntry = Object.values(viteManifestObject).find(
(entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry,
);
const routeAsset = release.routeChunks?.[definition.chunkId];
if (!runtime || !viteEntry || routeAsset !== viteEntry.file) {
artifactMismatches.push(`routeChunk:mismatch:${definition.chunkId}`);
continue;
}
if (
buildManifest.outputs?.routeChunks?.[definition.chunkId] !== routeAsset
) {
artifactMismatches.push(`buildManifest:routeChunk:${definition.chunkId}`);
}
try {
await readFile(`dist/${routeAsset}`);
} catch {
artifactMismatches.push(`routeChunk:file:${definition.chunkId}`);
}
}
const fixtures = fixturesDocument.fixtures.map((fixture) => {
const result = verifyCompatibilityTuple({
frontend: fixture.frontend,
runtime: fixture.runtime,
});
return {
name: fixture.name,
expectedCompatible: fixture.expectedCompatible,
actualCompatible: result.compatible,
mismatches: result.mismatches,
passed: result.compatible === fixture.expectedCompatible,
};
});
const artifact = {
checked: true,
compatible: artifactComparison.compatible && artifactMismatches.length === 0,
mismatches: artifactMismatches,
releaseId: release.releaseId,
};
const passed = artifact.compatible && fixtures.every((fixture) => fixture.passed);
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
artifact,
fixtures,
passed,
};
await mkdir("artifacts/release", { recursive: true });
await writeFile(
"artifacts/release/verification.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (!passed) {
process.stderr.write(
`Release coherence failed: ${artifactMismatches.join(", ") || "fixture"}\n`,
);
process.exit(1);
}
process.stdout.write(
`Release coherence: PASS (${fixtures.length - 1} mixed fixtures rejected)\n`,
);
+20
View File
@@ -0,0 +1,20 @@
import { mkdir, writeFile } from "node:fs/promises";
import { MANUAL_A11Y_ROUTE_IDS } from "./lib/manual-a11y-evidence.mjs";
await mkdir("artifacts/tests", { recursive: true });
await writeFile(
"artifacts/tests/a11y.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
scope: MANUAL_A11Y_ROUTE_IDS,
threshold: { critical: 0, serious: 0 },
automatedStatus: "passed",
manualReview: "see artifacts/tests/a11y-manual/report.json",
},
null,
2,
)}\n`,
);
@@ -0,0 +1,124 @@
/**
* Creates the skeleton-owned side of an external session integration.
* Credential acquisition and storage stay inside the supplied external owner.
*
* @param {{
* readState(): import("../../application/ports/auth-session-port.js").SessionState,
* subscribe(listener: () => void): () => void,
* beginSignIn(returnTo?: string): Promise<void>,
* signOut(): Promise<void>,
* attachCredential(request: Request): Promise<Request>,
* recoverSession(): Promise<"restored" | "no-session">,
* notifyUnauthenticated(): void
* }} owner
* @returns {import("../../application/ports/auth-session-port.js").AuthSessionPort}
*/
export function createExternalAuthSessionAdapter(owner) {
return Object.freeze({
getState() {
return owner.readState();
},
subscribe(listener) {
return owner.subscribe(listener);
},
async beginSignIn(returnTo) {
await owner.beginSignIn(returnTo);
},
async signOut() {
await owner.signOut();
},
/** @param {Request} request */
async attach(request) {
const attached = await owner.attachCredential(request);
if (!(attached instanceof Request)) {
throw new TypeError("Auth owner returned an invalid request");
}
return attached;
},
async recover() {
const result = await owner.recoverSession();
if (result !== "restored" && result !== "no-session") {
throw new TypeError("Auth owner returned an invalid recovery state");
}
return result;
},
onUnauthenticated() {
owner.notifyUnauthenticated();
},
});
}
export function createAnonymousSessionAdapter() {
return createExternalAuthSessionAdapter({
readState: () => "unauthenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => request,
recoverSession: async () => "no-session",
notifyUnauthenticated: () => {},
});
}
/**
* Local/test-only session seam. It never creates or stores credentials.
*
* @param {import("../../application/ports/auth-session-port.js").SessionState} [initialState]
*/
export function createDemoSessionAdapter(initialState = "unauthenticated") {
let state = initialState;
const listeners = new Set();
function notify() {
for (const listener of listeners) listener();
}
/** @param {import("../../application/ports/auth-session-port.js").SessionState} next */
function setState(next) {
state = next;
notify();
}
return Object.freeze({
getState: () => state,
/** @param {() => void} listener */
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async beginSignIn() {
setState("authenticated");
},
async signOut() {
setState("unauthenticated");
},
/** @param {Request} request */
async attach(request) {
return request;
},
async recover() {
if (state === "recovery-pending") {
setState("authenticated");
return /** @type {const} */ ("restored");
}
return /** @type {const} */ ("no-session");
},
onUnauthenticated() {
setState("unauthenticated");
},
setState,
});
}
export function createUnavailableSessionAdapter() {
return Object.freeze({
getState: () => /** @type {const} */ ("integration-failed"),
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
/** @param {Request} request */
attach: async (request) => request,
recover: async () => /** @type {const} */ ("no-session"),
onUnauthenticated: () => {},
});
}
+165 -108
View File
@@ -1,11 +1,19 @@
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,
kindForStatus as statusKind,
normalizeUnknownFailure,
safeValidationIssues,
} from "../../contracts/errors.js";
import { mapOperationPayload } from "./resource-mapper.js";
import { retryDelay, shouldRetry } from "./retry-policy.js";
import {
validateEnvelope,
validateOperationPayload,
validateOperationRequest,
} from "./schema-registry.js";
import { buildRequestTarget } from "./request-builder.js";
const noAuthSession =
/** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({
@@ -15,20 +23,14 @@ const noAuthSession =
onUnauthenticated: () => {},
});
/** @typedef {import("../../contracts/errors.js").ApiFailure} HttpFailure */
/** @typedef {import("./request-builder.js").OperationRequestInput} OperationRequestInput */
/**
* @typedef {{
* kind: string,
* code: string,
* retryable: boolean,
* operationId: string,
* attemptCount: number,
* httpStatus?: number,
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
* userMessageKey: string,
* action: string
* }} HttpFailure
* setTimeout(callback: () => void, milliseconds: number): unknown,
* clearTimeout(handle: unknown): void
* }} Scheduler
*/
/**
@@ -36,16 +38,6 @@ const noAuthSession =
* { ok: false, error: HttpFailure }} HttpResult
*/
/**
* @typedef {{
* code?: string,
* httpStatus?: number,
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number
* }} FailureDetails
*/
/**
* @param {{
* baseUrl: string,
@@ -55,7 +47,14 @@ const noAuthSession =
* random?: () => number,
* validatePayload?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false },
* idempotencyKeyFactory?: () => string
* validateRequest?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false },
* mapPayload?: (operationId: string, payload: unknown) => unknown,
* idempotencyKeyFactory?: () => string,
* timeoutMs?: number,
* maxRetryAttempts?: number,
* scheduler?: Scheduler,
* getOperation?: typeof getApiOperation
* }} dependencies
*/
export function createHttpClient(dependencies) {
@@ -65,20 +64,51 @@ export function createHttpClient(dependencies) {
const random = dependencies.random ?? Math.random;
const validatePayload =
dependencies.validatePayload ?? validateOperationPayload;
const validateRequest =
dependencies.validateRequest ?? validateOperationRequest;
const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
const idempotencyKeyFactory =
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000;
const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2;
const selectOperation = dependencies.getOperation ?? getApiOperation;
const scheduler =
dependencies.scheduler ??
/** @type {Scheduler} */ ({
setTimeout: (callback, milliseconds) =>
globalThis.setTimeout(callback, milliseconds),
clearTimeout: (handle) =>
globalThis.clearTimeout(
/** @type {ReturnType<typeof setTimeout>} */ (handle),
),
});
/**
* @param {string} operationId
* @param {string | OperationRequestInput} request
* @param {{
* body?: unknown,
* routeId?: string,
* signal?: AbortSignal,
* idempotencyKey?: string
* }} [input]
*/
async function execute(operationId, input = {}) {
const operation = getApiOperation(operationId);
* body?: unknown,
* routeId?: string,
* pathParams?: Record<string, string | number>,
* searchParams?: unknown,
* signal?: AbortSignal,
* idempotencyKey?: string
* }} [legacyInput]
* @returns {Promise<HttpResult>}
*/
async function execute(request, legacyInput = {}) {
const input =
typeof request === "string"
? {
operationId: request,
routeId: legacyInput.routeId ?? "UNSPECIFIED_ROUTE",
pathParams: legacyInput.pathParams,
searchParams: legacyInput.searchParams,
body: legacyInput.body,
signal: legacyInput.signal,
idempotencyKey: legacyInput.idempotencyKey,
}
: request;
const operation = selectOperation(input.operationId);
const logicalIdempotencyKey =
operation.idempotency === "keyed"
? input.idempotencyKey ?? idempotencyKeyFactory()
@@ -115,7 +145,19 @@ export function createHttpClient(dependencies) {
continue;
}
if (!shouldRetry(operation, outcome.error, retryCount)) {
if (outcome.error.httpStatus === 401 && recoveryUsed) {
authSession.onUnauthenticated();
return outcome;
}
if (
!shouldRetry(
operation,
outcome.error,
retryCount,
maxRetryAttempts,
)
) {
return outcome;
}
@@ -127,7 +169,7 @@ export function createHttpClient(dependencies) {
} catch {
return {
ok: false,
error: failure("REQUEST_ABORTED", operationId, retryCount, {
error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
code: "REQUEST_ABORTED",
}),
};
@@ -138,7 +180,7 @@ export function createHttpClient(dependencies) {
/**
* @param {{
* operation: ReturnType<typeof getApiOperation>,
* input: { body?: unknown, routeId?: string, signal?: AbortSignal },
* input: OperationRequestInput,
* attempt: number,
* idempotencyKey?: string
* }} context
@@ -146,23 +188,19 @@ export function createHttpClient(dependencies) {
*/
async function performAttempt(context) {
const { operation, input, attempt, idempotencyKey } = context;
const controller = new AbortController();
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
controller.abort("timeout");
}, operation.timeoutMs);
const onExternalAbort = () => controller.abort(input.signal?.reason);
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
const headers = new Headers({ Accept: "application/json" });
if (input.body !== undefined) headers.set("Content-Type", "application/json");
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
if (input.body !== undefined) {
const requestValidation = validateOperationRequest(
/** @type {unknown} */
let parsedSearch = {};
let parsedBody;
const requestValue =
operation.requestSource === "search"
? input.searchParams ?? {}
: operation.requestSource === "body"
? input.body
: {};
if (operation.requestSource !== "none") {
const requestValidation = validateRequest(
operation.requestSchema,
input.body,
requestValue,
);
if (!requestValidation.success) {
return {
@@ -172,12 +210,46 @@ export function createHttpClient(dependencies) {
}),
};
}
if (operation.requestSource === "search") {
parsedSearch = requestValidation.data;
} else {
parsedBody = requestValidation.data;
}
}
let request = new Request(new URL(operation.path, dependencies.baseUrl), {
const target = buildRequestTarget(
dependencies.baseUrl,
operation,
input.pathParams,
parsedSearch,
);
if (!target.success) {
return {
ok: false,
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
code: target.code,
}),
};
}
const controller = new AbortController();
let timedOut = false;
const timeout = scheduler.setTimeout(() => {
timedOut = true;
controller.abort("timeout");
}, operation.timeoutMs ?? defaultTimeoutMs);
const onExternalAbort = () => controller.abort(input.signal?.reason);
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
if (input.signal?.aborted) onExternalAbort();
const headers = new Headers({ Accept: "application/json" });
if (parsedBody !== undefined) headers.set("Content-Type", "application/json");
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
let request = new Request(target.url, {
method: operation.method,
headers,
body: input.body === undefined ? undefined : JSON.stringify(input.body),
body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody),
signal: controller.signal,
});
@@ -196,7 +268,13 @@ export function createHttpClient(dependencies) {
}
const response = await fetcher(request);
return await parseResponse(response, operation, attempt, validatePayload);
return await parseResponse(
response,
operation,
attempt,
validatePayload,
mapPayload,
);
} catch {
if (timedOut) {
return {
@@ -241,7 +319,7 @@ export function createHttpClient(dependencies) {
}),
};
} finally {
clearTimeout(timeout);
scheduler.clearTimeout(timeout);
input.signal?.removeEventListener("abort", onExternalAbort);
}
}
@@ -255,9 +333,16 @@ export function createHttpClient(dependencies) {
* @param {number} attempt
* @param {(schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false }} validatePayload
* @param {(operationId: string, payload: unknown) => unknown} mapPayload
* @returns {Promise<HttpResult>}
*/
async function parseResponse(response, operation, attempt, validatePayload) {
async function parseResponse(
response,
operation,
attempt,
validatePayload,
mapPayload,
) {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
return {
@@ -312,15 +397,29 @@ async function parseResponse(response, operation, attempt, validatePayload) {
};
}
return {
ok: true,
value: structuredClone(payload.data),
meta: safeMeta(envelopeRecord.meta),
};
try {
return {
ok: true,
value: mapPayload(operation.operationId, payload.data),
meta: safeMeta(envelopeRecord.meta),
};
} catch (error) {
return {
ok: false,
error: normalizeUnknownFailure(error, {
operationId: operation.operationId,
attempt,
}),
};
}
}
const kind = statusKind(response.status);
const retryAfter = response.headers.get("retry-after");
const backendError =
envelopeRecord.error && typeof envelopeRecord.error === "object"
? /** @type {Record<string, unknown>} */ (envelopeRecord.error)
: {};
return {
ok: false,
error: failure(kind, operation.operationId, attempt, {
@@ -332,6 +431,10 @@ async function parseResponse(response, operation, attempt, validatePayload) {
response.status === 429 && retryAfter
? parseRetryAfterHeader(retryAfter)
: undefined,
validationIssues:
response.status === 422
? safeValidationIssues(backendError.details)
: undefined,
}),
};
}
@@ -378,52 +481,6 @@ async function recoverSession(authSession, operation, originalFailure) {
* @param {FailureDetails} [details]
* @returns {HttpFailure}
*/
function failure(kind, operationId, attempt, details = {}) {
const retryable = new Set([
"NETWORK_UNREACHABLE",
"REQUEST_TIMEOUT",
"RATE_LIMITED",
"SERVER_FAILURE",
]).has(kind);
const action =
kind === "AUTH_REQUIRED"
? "reauth"
: retryable
? "retry"
: kind === "REQUEST_ABORTED"
? "none"
: "contact-support";
return Object.freeze({
kind,
code: details.code ?? kind,
retryable,
operationId,
attemptCount: attempt + 1,
...(details.httpStatus === undefined ? {} : { httpStatus: details.httpStatus }),
...(details.requestId ? { requestId: details.requestId } : {}),
...(details.traceId ? { traceId: details.traceId } : {}),
...(details.retryAfterMs === undefined
? {}
: { retryAfterMs: details.retryAfterMs }),
userMessageKey: `error.${kind.toLowerCase()}`,
action,
});
}
/** @param {number} status */
function statusKind(status) {
if (status === 401) return "AUTH_REQUIRED";
if (status === 403) return "FORBIDDEN";
if (status === 404) return "NOT_FOUND";
if (status === 409) return "CONFLICT";
if (status === 422) return "VALIDATION_REJECTED";
if (status === 429) return "RATE_LIMITED";
if (status >= 500) return "SERVER_FAILURE";
if (status >= 400) return "UNKNOWN_CLIENT_FAILURE";
return "ENVELOPE_MISMATCH";
}
/** @param {unknown} envelope */
function safeBackendCode(envelope) {
if (!envelope || typeof envelope !== "object") return "HTTP_FAILURE";
+73
View File
@@ -0,0 +1,73 @@
import type { ApiOperation } from "../../contracts/api-operations.js";
export type OperationRequestInput = Readonly<{
operationId: string;
routeId: string;
pathParams?: Readonly<Record<string, string | number>>;
searchParams?: unknown;
body?: unknown;
signal?: AbortSignal;
idempotencyKey?: string;
}>;
export type RequestTargetResult =
| Readonly<{ success: true; url: URL }>
| Readonly<{
success: false;
code: "PATH_PARAMETER_MISSING" | "SEARCH_PARAMETER_INVALID";
}>;
const pathParameterPattern = /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g;
export function buildRequestTarget(
baseUrl: string,
operation: ApiOperation,
pathParams: Readonly<Record<string, string | number>> = {},
parsedSearch: unknown = {},
): RequestTargetResult {
let missingPathParameter = false;
const pathname = operation.path.replace(
pathParameterPattern,
(_token, colonName: string | undefined, braceName: string | undefined) => {
const name = colonName ?? braceName ?? "";
const value = pathParams[name];
if (value === undefined) {
missingPathParameter = true;
return "";
}
return encodeURIComponent(String(value));
},
);
if (missingPathParameter) {
return { success: false, code: "PATH_PARAMETER_MISSING" };
}
if (
parsedSearch === null ||
typeof parsedSearch !== "object" ||
Array.isArray(parsedSearch)
) {
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
const url = new URL(pathname, baseUrl);
const search = parsedSearch as Readonly<Record<string, unknown>>;
for (const key of Object.keys(search).sort((left, right) =>
left.localeCompare(right),
)) {
const value = search[key];
if (value === undefined || value === null) continue;
const values = Array.isArray(value) ? value : [value];
for (const item of values) {
if (
typeof item !== "string" &&
typeof item !== "number" &&
typeof item !== "boolean"
) {
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
url.searchParams.append(key, String(item));
}
}
return { success: true, url };
}
+5
View File
@@ -0,0 +1,5 @@
/** @param {string} operationId @param {unknown} payload */
export function mapOperationPayload(operationId, payload) {
void payload;
throw new TypeError(`No boundary mapper registered for ${operationId}`);
}
+2 -1
View File
@@ -35,12 +35,13 @@ export function parseRetryAfter(value, now = Date.now()) {
}
/**
* @param {{ idempotency: "safe" | "keyed" | "none" }} operation
* @param {{ idempotency: "safe" | "keyed" | "none", retry?: "runtime" | "never" }} operation
* @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure
* @param {number} retryCount
* @param {number} [maxRetries]
*/
export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
if (operation.retry === "never") return false;
if (retryCount >= maxRetries) return false;
if (!retryKinds.has(failure.kind)) return false;
if (
+2 -25
View File
@@ -37,34 +37,11 @@ export const responseEnvelopeSchema = z.discriminatedUnion("success", [
failureEnvelopeSchema,
]);
const sampleResourceSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
createdAt: z.string().optional(),
})
.passthrough();
const payloadSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
SampleResourceListPayload: z.array(sampleResourceSchema),
SampleResourcePayload: sampleResourceSchema,
}));
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
const requestSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
SampleResourceListQuery: z
.object({
cursor: z.string().optional(),
limit: z.int().min(1).max(100).default(20),
})
.strict(),
CreateSampleResourceCommand: z
.object({
name: z.string().trim().min(1).max(120),
})
.strict(),
}));
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
/** @param {unknown} value */
export function validateEnvelope(value) {

Some files were not shown because too many files have changed in this diff Show More