Compare commits

...
Author SHA1 Message Date
donghyeon-ka 6c73b845bd feat: add optional frontend adapter recipes 2026-07-26 17:57:04 +09:00
donghyeon-ka 638f5f71bd merge: frontend supply chain verification 2026-07-26 17:38:00 +09:00
donghyeon-ka 8b4f875c1c feat: verify frontend supply chain 2026-07-26 17:37:51 +09:00
donghyeon-ka a64708f3de merge: test and registry evidence hardening 2026-07-26 17:15:32 +09:00
donghyeon-ka 98d4fd4960 feat: harden test and registry evidence 2026-07-26 17:15:26 +09:00
donghyeon-ka 3f634eb655 merge: diagnostics and telemetry runtime 2026-07-26 16:42:31 +09:00
donghyeon-ka 5173b6c8d6 feat: add diagnostics and telemetry runtime 2026-07-26 16:42:27 +09:00
donghyeon-ka 2fa0baa577 merge: internationalization message platform 2026-07-26 16:17:45 +09:00
donghyeon-ka 668bf05b48 feat: add internationalization message platform 2026-07-26 16:17:39 +09:00
donghyeon-ka b8c0444217 merge: design system platform 2026-07-26 15:49:13 +09:00
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
355 changed files with 39388 additions and 1940 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",
},
},
};
+4 -4
View File
@@ -53,9 +53,9 @@ jobs:
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Install Chromium
- name: Install Playwright browsers
if: ${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium
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
@@ -91,9 +91,9 @@ jobs:
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Install Chromium
- name: Install Playwright browsers
if: ${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium
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
+3
View File
@@ -10,4 +10,7 @@ artifacts/**/*.xml
artifacts/**/*.txt
artifacts/**/*.sarif
artifacts/tests/e2e/
artifacts/storybook/
artifacts/tests/storybook/
artifacts/tests/visual/
!artifacts/**/.gitkeep
+15
View File
@@ -0,0 +1,15 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: ["@storybook/addon-a11y"],
framework: {
name: "@storybook/react-vite",
options: {},
},
core: {
disableTelemetry: true,
},
};
export default config;
+88
View File
@@ -0,0 +1,88 @@
import type { Preview } from "@storybook/react-vite";
import { QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { createAnonymousSessionAdapter } from "../src/adapters/auth/external-session-adapter.js";
import { createQueryClient } from "../src/adapters/query-cache/tanstack-query-cache.js";
import { createApplication } from "../src/application/create-application.js";
import { LocaleProvider } from "../src/presentation/i18n/index.js";
import { ApplicationProvider } from "../src/presentation/providers/application-provider.js";
import { SessionProvider } from "../src/presentation/providers/session-provider.jsx";
import { ThemeProvider } from "../src/presentation/providers/theme-provider.jsx";
import "../src/presentation/styles/theme.css";
const preferences = new Map<string, unknown>();
const application = createApplication({
session: createAnonymousSessionAdapter(),
preferences: {
read: (name) => ({ ok: true, value: preferences.get(name) }),
write: (name, value) => {
preferences.set(name, structuredClone(value));
return { ok: true };
},
remove: (name) => {
preferences.delete(name);
return { ok: true };
},
},
diagnostics: { record() {} },
telemetry: { emit() {} },
releaseInfo: {
getCurrent: async () => ({
buildId: "storybook-build",
releaseId: "storybook-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "storybook-assets",
routeChunks: {},
}),
refresh: async () => ({
buildId: "storybook-build",
releaseId: "storybook-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "storybook-assets",
routeChunks: {},
}),
},
navigation: { reload() {} },
});
const queryClient = createQueryClient();
const preview: Preview = {
decorators: [
(Story) => (
<ApplicationProvider application={application}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<LocaleProvider>
<ThemeProvider>
<SessionProvider>
<div id="portal-root" />
<main className="ui-page" style={{ padding: "1rem" }}>
<Story />
</main>
</SessionProvider>
</ThemeProvider>
</LocaleProvider>
</MemoryRouter>
</QueryClientProvider>
</ApplicationProvider>
),
],
parameters: {
a11y: {
test: "error",
},
controls: {
expanded: true,
},
options: {
storySort: {
order: ["Platform"],
},
},
},
};
export default preview;
+66 -5
View File
@@ -6,7 +6,8 @@ operations are executable contracts rather than conventions.
## Start locally
Requirements: Node 24 and Corepack. The repository pins pnpm in `package.json`.
Requirements: Node 24.11 or newer and Corepack. The repository pins pnpm in
`package.json`.
```bash
corepack pnpm install --frozen-lockfile
@@ -16,6 +17,27 @@ 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:
@@ -27,9 +49,30 @@ bootstrap composes concrete adapters
contracts own cross-cutting registries
```
See `docs/architecture/overview.md` and `docs/architecture/layers.md`. The
removable sample slice is under `src/sample/contract-fixture`; product code is
not allowed to import it.
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
@@ -38,6 +81,9 @@ Common local checks:
```bash
corepack pnpm lint
corepack pnpm check:types
corepack pnpm check:types:app
corepack pnpm check:types:node
corepack pnpm check:types:test
corepack pnpm check:architecture
corepack pnpm test:all
corepack pnpm test:e2e
@@ -52,9 +98,24 @@ 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.
- `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.
@@ -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.
@@ -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.
@@ -1,7 +1,7 @@
# SAMPLE_RESOURCE_LIST accessibility review
# REFERENCE_RESOURCE_LIST accessibility review
Status: pending-manual-review
Route ID: SAMPLE_RESOURCE_LIST
Route ID: REFERENCE_RESOURCE_LIST
Release ID:
Reviewer:
Reviewed at:
@@ -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.
+145 -22
View File
@@ -58,7 +58,10 @@
"gates": {
"FE-GATE-001": {
"name": "manifest-lockfile",
"steps": [{ "script": "verify:lockfile", "expect": "pass" }],
"steps": [
{ "script": "verify:lockfile", "expect": "pass" },
{ "script": "check:frozen-lockfile:fixture", "expect": "pass" }
],
"logPath": "artifacts/quality/install.txt",
"evidence": ["artifacts/quality/install.txt"],
"retentionClass": "merge-cycle"
@@ -74,7 +77,19 @@
"name": "typecheck",
"steps": [
{ "script": "check:types", "expect": "pass" },
{ "script": "check:types:fixture", "expect": "fail" }
{ "script": "check:types:recipes", "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" },
{ "script": "check:types:fixture:i18n-key", "expect": "fail" },
{ "script": "check:types:fixture:i18n-params", "expect": "fail" },
{ "script": "check:types:fixture:diagnostics", "expect": "fail" }
],
"logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"],
@@ -89,9 +104,19 @@
},
"FE-GATE-005": {
"name": "unit",
"steps": [{ "script": "test:unit", "expect": "pass" }],
"steps": [
{ "script": "test:unit", "expect": "pass" },
{ "script": "test:coverage", "expect": "pass" },
{ "script": "check:coverage:fixture", "expect": "fail" }
],
"logPath": "artifacts/quality/gates/FE-GATE-005.txt",
"evidence": ["artifacts/tests/unit.xml"],
"evidence": [
"artifacts/tests/unit.xml",
"artifacts/tests/coverage.xml",
"artifacts/tests/coverage/coverage-summary.json",
"artifacts/quality/risk-coverage.json",
"artifacts/quality/risk-coverage-fixture.json"
],
"retentionClass": "merge-cycle"
},
"FE-GATE-006": {
@@ -103,16 +128,39 @@
},
"FE-GATE-007": {
"name": "integration",
"steps": [{ "script": "test:integration", "expect": "pass" }],
"steps": [
{ "script": "test:integration", "expect": "pass" },
{ "script": "test:reference-feature", "expect": "pass" },
{ "script": "test:recipes", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-007.txt",
"evidence": ["artifacts/tests/integration.xml"],
"evidence": [
"artifacts/tests/integration.xml",
"artifacts/tests/reference-feature.xml",
"artifacts/tests/optional-recipes.xml"
],
"retentionClass": "merge-cycle"
},
"FE-GATE-008": {
"name": "e2e",
"steps": [{ "script": "test:e2e", "expect": "pass" }],
"steps": [
{ "script": "test:e2e", "expect": "pass" },
{ "script": "test:storybook", "expect": "pass" },
{ "script": "test:visual", "expect": "pass" },
{ "script": "check:test-evidence", "expect": "pass" },
{ "script": "check:test-evidence:fixture", "expect": "fail" }
],
"logPath": "artifacts/quality/gates/FE-GATE-008.txt",
"evidence": ["artifacts/tests/e2e/report/index.html"],
"evidence": [
"artifacts/tests/e2e/report/index.html",
"artifacts/tests/e2e/results.xml",
"artifacts/tests/storybook/report/index.html",
"artifacts/tests/storybook/results.xml",
"artifacts/tests/visual/report/index.html",
"artifacts/tests/visual/results.xml",
"artifacts/quality/test-evidence.json",
"artifacts/quality/test-evidence-fixture.json"
],
"retentionClass": "merge-cycle"
},
"FE-GATE-009": {
@@ -125,7 +173,10 @@
"evidence": [
"artifacts/tests/a11y.json",
"artifacts/tests/a11y-manual/APP_HOME.md",
"artifacts/tests/a11y-manual/SAMPLE_RESOURCE_LIST.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"
],
@@ -133,39 +184,101 @@
},
"FE-GATE-010": {
"name": "architecture",
"steps": [{ "script": "check:architecture", "expect": "pass" }],
"steps": [
{ "script": "check:architecture", "expect": "pass" },
{ "script": "check:design-system", "expect": "pass" },
{ "script": "check:design-system:fixture", "expect": "fail" },
{ "script": "check:i18n", "expect": "pass" },
{ "script": "check:i18n:fixture", "expect": "fail" },
{ "script": "check:diagnostics", "expect": "pass" },
{ "script": "check:diagnostics:fixture", "expect": "fail" },
{ "script": "check:optional-recipes:source", "expect": "pass" },
{ "script": "check:optional-recipe-fixtures", "expect": "pass" },
{ "script": "check:registries", "expect": "pass" },
{
"script": "check:registries:compatibility-fixtures",
"expect": "pass"
},
{ "script": "check:registries:baseline-fixture", "expect": "fail" },
{ "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"],
"evidence": [
"artifacts/quality/dependency-report.json",
"artifacts/quality/design-system.json",
"artifacts/quality/design-system-fixture.json",
"artifacts/quality/i18n.json",
"artifacts/quality/i18n-fixture.json",
"artifacts/quality/diagnostics.json",
"artifacts/quality/diagnostics-fixture.json",
"artifacts/quality/optional-recipes.json",
"artifacts/quality/optional-recipe-fixtures.json",
"artifacts/quality/registries.json",
"artifacts/quality/registry-compatibility-fixtures.json",
"artifacts/quality/registry-baseline-fixture.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" }],
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "build:storybook", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-011.txt",
"evidence": ["artifacts/release/build-manifest.json"],
"evidence": [
"artifacts/release/build-manifest.json",
"artifacts/release/runtime-config.schema.json",
"artifacts/storybook/static/index.html"
],
"retentionClass": "release-coherence"
},
"FE-GATE-012": {
"name": "bundle",
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "check:bundle", "expect": "pass" }
{ "script": "check:bundle", "expect": "pass" },
{ "script": "check:optional-recipes", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-012.txt",
"evidence": ["artifacts/performance/bundle.json"],
"evidence": [
"artifacts/performance/bundle.json",
"artifacts/quality/optional-recipes.json"
],
"retentionClass": "release-coherence"
},
"FE-GATE-013": {
"name": "security",
"steps": [
{ "script": "verify:reproducible-build", "expect": "pass" },
{ "script": "build:release", "expect": "pass" },
{ "script": "verify:supply-chain", "expect": "pass" },
{ "script": "check:supply-chain:fixtures", "expect": "pass" },
{
"script": "check:supply-chain:provider-fixtures",
"expect": "pass"
},
{ "script": "scan:security:fixture", "expect": "fail" },
{ "script": "check:browser-security", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-013.txt",
"evidence": [
"artifacts/security/scan.sarif",
"artifacts/security/scan-fixture.sarif",
"artifacts/release/dependency-inventory.json",
"artifacts/security/dependency-diff.json"
"artifacts/release/sbom.cdx.json",
"artifacts/release/provenance.json",
"artifacts/release/reproducible-build.json",
"artifacts/security/dependency-diff.json",
"artifacts/security/license-report.json",
"artifacts/security/vulnerability-report.json",
"artifacts/security/supply-chain-verification.json",
"artifacts/security/supply-chain-coherence.json",
"artifacts/security/supply-chain-fixtures.json",
"artifacts/security/supply-chain-provider-fixtures.json"
],
"retentionClass": "release-coherence"
},
@@ -179,11 +292,15 @@
"FE-GATE-015": {
"name": "release-coherence",
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "verify:release", "expect": "pass" }
{ "script": "build:release", "expect": "pass" },
{ "script": "verify:release", "expect": "pass" },
{ "script": "verify:supply-chain:promotion", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-015.txt",
"evidence": ["artifacts/release/verification.json"],
"evidence": [
"artifacts/release/verification.json",
"artifacts/security/promotion-verification.json"
],
"retentionClass": "release-coherence"
},
"FE-GATE-016": {
@@ -234,10 +351,16 @@
"retentionClass": "release-coherence"
},
"FE-GATE-020": {
"name": "sample-removal",
"steps": [{ "script": "test:sample-removal", "expect": "pass" }],
"name": "removability",
"steps": [
{ "script": "test:sample-removal", "expect": "pass" },
{ "script": "test:optional-recipe-removal", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-020.txt",
"evidence": ["artifacts/tests/sample-removal.xml"],
"evidence": [
"artifacts/tests/sample-removal.xml",
"artifacts/tests/optional-recipe-removal.xml"
],
"retentionClass": "merge-cycle"
},
"FE-GATE-021": {
@@ -0,0 +1,7 @@
{
"schemaVersion": 1,
"snapshotDigest": "e8448e46bc65326e9b2eb23cdce0870242faedb7a354942389c4a95b0e392d90",
"owner": "frontend-platform",
"reason": "RP-10 initial approved executable registry baseline",
"approvedAt": "2026-07-26T07:53:45.969Z"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
{
"schemaVersion": 1,
"changes": []
}
+376 -16
View File
@@ -1,11 +1,17 @@
{
"schemaVersion": 1,
"schemaVersion": 2,
"sourceDirectories": [
"src/application",
"src/presentation",
"src/domain"
],
"registries": [
{
"registryId": "FE-REG-ROUTE",
"path": "src/contracts/routes.js",
"path": "src/features/installed-feature-contracts.js",
"exportName": "ROUTE_REGISTRY",
"owner": "feature-routing-navigation-guard-contract",
"owner": "feature-frontend-routing-release-recovery-runtime",
"keyField": "routeId",
"requiredFields": [
"routeId",
"path",
@@ -14,14 +20,131 @@
"access",
"loadingSurface",
"errorSurface",
"chunkId",
"title",
"navigationLabel",
"navigationOrder"
],
"fieldTypes": {
"routeId": "string",
"path": "string",
"paramsSchema": "string|null",
"searchSchema": "string|null",
"access": "string",
"loadingSurface": "string",
"errorSurface": "string",
"chunkId": "string",
"title": "string",
"navigationLabel": "string|null",
"navigationOrder": "integer|null"
},
"uniqueFields": ["routeId", "path", "chunkId"],
"allowedValues": {
"access": ["public", "session-required", "integration-defined"],
"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"
]
},
"references": [
{
"field": "routeId",
"registryId": "FE-REG-ROUTE-RUNTIME",
"targetField": "routeId"
},
{
"field": "paramsSchema",
"registryId": "FE-REG-SCHEMA",
"targetField": "schemaId"
},
{
"field": "searchSchema",
"registryId": "FE-REG-SCHEMA",
"targetField": "schemaId"
}
],
"consumers": [
{
"path": "src/presentation/routes/app-router.tsx",
"token": "ROUTE_REGISTRY"
}
],
"breakingFields": [
"routeId",
"path",
"paramsSchema",
"searchSchema",
"access",
"chunkId"
]
},
{
"registryId": "FE-REG-ROUTE-RUNTIME",
"path": "src/features/installed-feature-contracts.js",
"exportName": "ROUTE_RUNTIME_CONTRACT",
"owner": "feature-frontend-routing-release-recovery-runtime",
"keyField": "routeId",
"requiredFields": [
"routeId",
"moduleId",
"paramsCodec",
"searchCodec"
],
"fieldTypes": {
"routeId": "string",
"moduleId": "string",
"paramsCodec": "string",
"searchCodec": "string"
},
"uniqueFields": ["routeId", "moduleId"],
"references": [
{
"field": "routeId",
"registryId": "FE-REG-ROUTE",
"targetField": "routeId"
},
{
"field": "paramsCodec",
"registryId": "FE-REG-SCHEMA",
"targetField": "schemaId"
},
{
"field": "searchCodec",
"registryId": "FE-REG-SCHEMA",
"targetField": "schemaId"
}
],
"consumers": [
{
"path": "src/presentation/routes/route-codecs.ts",
"token": "ROUTE_RUNTIME_CONTRACT"
}
],
"breakingFields": [
"routeId",
"moduleId",
"paramsCodec",
"searchCodec"
]
},
{
"registryId": "FE-REG-API",
"path": "src/contracts/api-operations.js",
"path": "src/features/installed-feature-contracts.js",
"exportName": "API_OPERATIONS",
"owner": "feature-api-client-response-envelope-contract",
"owner": "feature-frontend-api-client-response-envelope-contract",
"keyField": "operationId",
"requiredFields": [
"method",
"path",
@@ -29,23 +152,132 @@
"auth",
"timeoutMs",
"idempotency",
"retry",
"requestSource",
"requestSchema",
"responseSchema",
"owner"
],
"fieldTypes": {
"method": "string",
"path": "string",
"operationId": "string",
"auth": "string",
"timeoutMs": "integer|null",
"idempotency": "string",
"retry": "string",
"requestSource": "string",
"requestSchema": "string",
"responseSchema": "string",
"owner": "string"
},
"uniqueFields": ["operationId"],
"allowedValues": {
"method": ["GET", "POST", "PUT", "PATCH", "DELETE"],
"auth": ["none", "external-session"],
"idempotency": ["safe", "keyed", "none"],
"retry": ["runtime", "never"],
"requestSource": ["none", "search", "body"]
},
"references": [
{
"field": "requestSchema",
"registryId": "FE-REG-SCHEMA",
"targetField": "schemaId"
},
{
"field": "responseSchema",
"registryId": "FE-REG-SCHEMA",
"targetField": "schemaId"
}
],
"consumerIdentityField": "operationId",
"consumerDirectories": [
"src/features/reference-feature/adapters",
"src/features/reference-feature/application"
],
"breakingFields": [
"method",
"path",
"operationId",
"auth",
"idempotency",
"requestSource",
"requestSchema",
"responseSchema"
]
},
{
"registryId": "FE-REG-SCHEMA",
"path": "src/features/installed-feature-contracts.js",
"exportName": "SCHEMA_REGISTRY",
"owner": "feature-frontend-contract-schema-registry",
"keyField": "schemaId",
"requiredFields": ["schemaId", "boundary", "owner", "runtime"],
"fieldTypes": {
"schemaId": "string",
"boundary": "string",
"owner": "string",
"runtime": "string"
},
"uniqueFields": ["schemaId"],
"allowedValues": {
"boundary": [
"route-params",
"route-search",
"route-search-api-request",
"api-request",
"api-response"
],
"runtime": ["zod"]
},
"consumerIdentityField": "schemaId",
"consumerDirectories": [
"src/presentation/routes",
"src/features/reference-feature/presentation",
"src/features/reference-feature/contracts"
],
"breakingFields": ["schemaId", "boundary", "runtime"]
},
{
"registryId": "FE-REG-ENV",
"path": "src/contracts/env.js",
"exportName": "ENV_REGISTRY",
"owner": "feature-frontend-env-runtime-config-contract",
"requiredFields": ["phase", "classification", "required", "defaultValue"]
"requiredFields": ["phase", "classification", "required", "defaultValue"],
"fieldTypes": {
"phase": "string",
"classification": "string",
"required": "boolean",
"defaultValue": "string|integer|boolean|null"
},
"allowedValues": {
"phase": ["build", "runtime"],
"classification": [
"public",
"public-sensitive",
"public-metadata",
"compile-time"
]
},
"consumers": [
{
"path": "src/bootstrap/runtime-config-schema.js",
"token": "APP_ENV"
},
{
"path": "src/contracts/env.js",
"token": "getBuildConfig"
}
],
"breakingFields": ["phase", "classification", "required"]
},
{
"registryId": "FE-REG-STORAGE",
"path": "src/contracts/storage-keys.js",
"exportName": "STORAGE_REGISTRY",
"owner": "feature-frontend-storage-registry-contract",
"keyField": "logicalName",
"requiredFields": [
"logicalName",
"physicalKey",
@@ -55,6 +287,44 @@
"ttl",
"migration",
"quotaFallback"
],
"fieldTypes": {
"logicalName": "string",
"physicalKey": "string",
"backend": "string",
"classification": "string",
"schemaVersion": "integer",
"ttl": "integer|string|null",
"migration": "string|function",
"quotaFallback": "string"
},
"uniqueFields": ["logicalName", "physicalKey"],
"allowedValues": {
"backend": [
"memory",
"sessionStorage",
"localStorage",
"indexedDB",
"disabled",
"forbidden"
],
"classification": [
"public-preference",
"opaque-cache",
"sensitive-forbidden"
],
"quotaFallback": ["memory", "no-persist", "feature-disable"]
},
"consumerIdentityField": "logicalName",
"consumerDirectories": ["src", "tests"],
"orphanExemptRows": ["QUERY_PERSISTENCE", "AUTH_TOKEN"],
"breakingFields": [
"logicalName",
"physicalKey",
"backend",
"classification",
"schemaVersion",
"migration"
]
},
{
@@ -62,6 +332,7 @@
"path": "src/contracts/errors.js",
"exportName": "ERROR_REGISTRY",
"owner": "feature-frontend-error-classification-boundary-contract",
"keyField": "kind",
"requiredFields": [
"kind",
"defaultRetryable",
@@ -70,13 +341,41 @@
"action",
"telemetryEvent",
"redaction"
]
],
"fieldTypes": {
"kind": "string",
"defaultRetryable": "boolean",
"severity": "string",
"userMessageKey": "string",
"action": "string",
"telemetryEvent": "string",
"redaction": "array"
},
"uniqueFields": ["kind"],
"allowedValues": {
"severity": ["info", "warning", "error"],
"action": [
"retry",
"reauth",
"navigate",
"reload-once",
"contact-support",
"none"
]
},
"consumers": [
{
"path": "src/adapters/http/client.js",
"token": "failure("
}
],
"breakingFields": ["kind", "userMessageKey", "action", "telemetryEvent"]
},
{
"registryId": "FE-REG-QUERY",
"path": "src/contracts/query-keys.js",
"path": "src/features/installed-feature-contracts.js",
"exportName": "QUERY_REGISTRY",
"owner": "feature-server-state-caching-contract",
"owner": "feature-frontend-server-state-caching-contract",
"requiredFields": [
"namespace",
"serialization",
@@ -84,13 +383,39 @@
"invalidation",
"version",
"persistence"
],
"fieldTypes": {
"namespace": "array",
"serialization": "string",
"identity": "string",
"invalidation": "string",
"version": "integer",
"persistence": "string"
},
"uniqueFields": ["namespace"],
"allowedValues": {
"persistence": ["disabled"]
},
"consumers": [
{
"path": "src/features/reference-feature/contracts/reference-feature-contract.js",
"token": "referenceQueryKeys"
}
],
"breakingFields": [
"namespace",
"serialization",
"identity",
"version",
"persistence"
]
},
{
"registryId": "FE-REG-TELEMETRY",
"path": "src/contracts/telemetry.js",
"exportName": "TELEMETRY_REGISTRY",
"owner": "feature-frontend-observability-logging-trace-contract",
"owner": "feature-frontend-diagnostics-telemetry-runtime",
"keyField": "eventName",
"requiredFields": [
"eventName",
"trigger",
@@ -99,6 +424,31 @@
"forbiddenAttributes",
"sampling",
"delivery"
],
"fieldTypes": {
"eventName": "string",
"trigger": "string",
"requiredAttributes": "array",
"optionalAttributes": "array",
"forbiddenAttributes": "array",
"sampling": "string",
"delivery": "string"
},
"uniqueFields": ["eventName"],
"allowedValues": {
"delivery": ["best-effort"]
},
"consumers": [
{
"path": "scripts/check-diagnostics.mjs",
"token": "TELEMETRY_REGISTRY"
}
],
"breakingFields": [
"eventName",
"requiredAttributes",
"forbiddenAttributes",
"delivery"
]
},
{
@@ -106,11 +456,21 @@
"path": "src/contracts/release-tokens.js",
"exportName": "RELEASE_TOKEN_REGISTRY",
"owner": "feature-frontend-release-cache-rollback-contract",
"requiredFields": ["token", "source", "compatibilityRole"]
"keyField": "token",
"requiredFields": ["token", "source", "compatibilityRole"],
"fieldTypes": {
"token": "string",
"source": "string",
"compatibilityRole": "string"
},
"uniqueFields": ["token"],
"consumers": [
{
"path": "src/bootstrap/load-release-manifest.js",
"token": "assetManifestHash"
}
],
"breakingFields": ["token", "source", "compatibilityRole"]
}
],
"compatibilityImpact": {
"allowed": ["none", "additive", "behavior-change", "breaking"],
"current": "additive"
}
]
}
@@ -0,0 +1,233 @@
{
"$schema": "../../schemas/config/frontend-capability-recipes.schema.json",
"schemaVersion": 1,
"decisionId": "VD-10",
"defaultStatus": "NOT_INSTALLED",
"productionRuntimeDependencies": [],
"catalogOwner": "frontend-platform",
"reviewOn": "project-capability-selection",
"vendorPackagePatterns": [
"@launchdarkly/*",
"@sentry/*",
"@opentelemetry/*",
"@openapitools/openapi-generator-cli",
"@reduxjs/toolkit",
"@tanstack/react-virtual",
"@uppy/*",
"firebase",
"idb",
"react-window",
"redux",
"socket.io-client",
"tus-js-client",
"workbox-window",
"xstate",
"zustand"
],
"recipes": [
{
"id": "realtime",
"status": "RECIPE_AVAILABLE",
"trigger": "The backend exposes ordered push events with a documented resume and authorization protocol.",
"forbiddenWhen": ["Polling satisfies the measured freshness requirement.", "Event ordering and reconnect ownership are undefined."],
"boundary": "outbound connection plus inbound validated event adapter",
"port": "RealtimePort",
"fake": "FakeRealtimeAdapter",
"failureKinds": ["disconnect", "duplicate", "out-of-order", "auth-expiry"],
"lifecycleMethods": ["unsubscribe"],
"owner": "project-owner-required",
"securityPrivacy": ["Validate every event envelope.", "Never place credentials in URLs or telemetry.", "Refresh authorization through the session boundary."],
"bundleBudgetGzipBytes": 12000,
"fallback": "Bounded polling or explicitly stale UI.",
"removal": ["Remove composition registration.", "Remove adapter and vendor dependency.", "Run recipe-removal and production-bundle gates."],
"serverStatePolicy": "query-cache-owned"
},
{
"id": "offline-indexeddb",
"status": "RECIPE_AVAILABLE",
"trigger": "A product requirement needs durable offline data or a durable command queue beyond small public preferences.",
"forbiddenWhen": ["The data contains credentials.", "The browser would connect directly to a database or object store.", "A normal HTTP cache is sufficient."],
"boundary": "application-owned versioned repository output port",
"port": "VersionedOfflineRepository",
"fake": "MemoryOfflineRepository",
"failureKinds": ["quota", "corruption", "migration-rollback"],
"lifecycleMethods": ["close"],
"owner": "project-owner-required",
"securityPrivacy": ["Classify persisted fields.", "Encrypting in the same client is not a credential protection boundary.", "Version and test every migration."],
"bundleBudgetGzipBytes": 8000,
"fallback": "Online-only query path with an explicit offline state.",
"removal": ["Stop writes.", "Migrate or purge owned stores.", "Remove repository composition and dependency."],
"serverStatePolicy": "reference-or-command-only"
},
{
"id": "service-worker-pwa",
"status": "RECIPE_AVAILABLE",
"trigger": "Installability or a measured offline-shell requirement is approved with cache ownership.",
"forbiddenWhen": ["Hosting cache and worker cache ownership conflict.", "Update and rollback UX is undefined."],
"boundary": "bootstrap update controller and cache policy adapter",
"port": "ServiceWorkerUpdatePort",
"fake": "FakeServiceWorkerUpdateAdapter",
"failureKinds": ["stale-worker", "update-loop", "offline-fallback"],
"lifecycleMethods": ["unregister", "rollback"],
"owner": "project-owner-required",
"securityPrivacy": ["Never cache authenticated API responses by default.", "Bind cache names to release identity.", "Fail closed on malformed update metadata."],
"bundleBudgetGzipBytes": 10000,
"fallback": "Normal network application with hosting cache headers.",
"removal": ["Deploy an unregister migration.", "Delete owned caches.", "Remove worker registration and manifest."],
"serverStatePolicy": "network-cache-policy-only"
},
{
"id": "file-transfer",
"status": "RECIPE_AVAILABLE",
"trigger": "The product accepts or delivers files with progress and cancellation requirements.",
"forbiddenWhen": ["Allowed size and MIME policy is missing.", "Long-lived credentials would be embedded in URLs."],
"boundary": "application file transfer output port behind an authorized backend protocol",
"port": "FileTransferPort",
"fake": "FakeFileTransferAdapter",
"failureKinds": ["size-rejection", "type-rejection", "abort", "expired-url"],
"lifecycleMethods": ["cancel-via-AbortSignal"],
"owner": "project-owner-required",
"securityPrivacy": ["Treat MIME as untrusted metadata.", "Use short-lived opaque resource identifiers.", "Redact file names when classified as personal data."],
"bundleBudgetGzipBytes": 6000,
"fallback": "Standard request with bounded size and no background continuation.",
"removal": ["Cancel active transfers.", "Remove route actions and composition.", "Remove transfer dependency."],
"serverStatePolicy": "query-cache-metadata-only"
},
{
"id": "generated-api",
"status": "RECIPE_AVAILABLE",
"trigger": "A versioned backend contract justifies generated transport code.",
"forbiddenWhen": ["Generated DTOs would escape into domain or presentation.", "Contract drift cannot block CI."],
"boundary": "generated client wrapped by a feature gateway facade and mapper",
"port": "GeneratedApiFacade",
"fake": "FakeGeneratedApiAdapter",
"failureKinds": ["contract-drift", "unsupported-field"],
"lifecycleMethods": ["cancel-via-AbortSignal"],
"owner": "project-owner-required",
"securityPrivacy": ["Generate from an authenticated source.", "Review generator execution and output.", "Do not log request bodies."],
"bundleBudgetGzipBytes": 16000,
"fallback": "Existing typed request builder and runtime response schema.",
"removal": ["Restore handwritten gateway.", "Remove generated output and generator.", "Verify DTOs do not remain in public types."],
"serverStatePolicy": "query-cache-owned"
},
{
"id": "feature-flag",
"status": "RECIPE_AVAILABLE",
"trigger": "A staged rollout or kill switch has a named owner, default and stale policy.",
"forbiddenWhen": ["A flag is used as authorization.", "Unknown and unavailable behavior is undefined."],
"boundary": "application feature policy output port",
"port": "FeatureFlagPort",
"fake": "FakeFeatureFlagAdapter",
"failureKinds": ["provider-unavailable", "unknown-flag", "stale-value"],
"lifecycleMethods": ["dispose-provider-if-installed"],
"owner": "project-owner-required",
"securityPrivacy": ["Flags are hints, never access control.", "Minimize targeting attributes.", "Apply consent rules to personal attributes."],
"bundleBudgetGzipBytes": 10000,
"fallback": "Typed local default with an explicit stale decision.",
"removal": ["Resolve the rollout permanently.", "Delete flag key and branches.", "Remove provider composition and dependency."],
"serverStatePolicy": "policy-cache-only"
},
{
"id": "web-worker",
"status": "RECIPE_AVAILABLE",
"trigger": "Profiling shows CPU work blocking the main thread beyond the performance budget.",
"forbiddenWhen": ["The task is primarily network I/O.", "Cancellation and stale-result ownership are undefined."],
"boundary": "request/result/cancel output port with a validated message adapter",
"port": "WorkerTaskPort",
"fake": "FakeWorkerTaskAdapter",
"failureKinds": ["crash", "stale-result", "transfer-failure"],
"lifecycleMethods": ["cancel", "dispose"],
"owner": "project-owner-required",
"securityPrivacy": ["Validate worker messages.", "Do not send credentials.", "Bound transferred data and worker count."],
"bundleBudgetGzipBytes": 14000,
"fallback": "Chunked or deferred main-thread execution within a measured limit.",
"removal": ["Stop and dispose workers.", "Restore synchronous facade implementation.", "Remove worker entry and chunk."],
"serverStatePolicy": "no-server-state"
},
{
"id": "multi-tab",
"status": "RECIPE_AVAILABLE",
"trigger": "A documented workflow must synchronize non-sensitive events across tabs.",
"forbiddenWhen": ["The server is the correct conflict authority.", "Event version and source identity are undefined."],
"boundary": "versioned browser event output/input adapter",
"port": "MultiTabPort",
"fake": "FakeMultiTabAdapter",
"failureKinds": ["self-echo", "duplicate", "conflict"],
"lifecycleMethods": ["unsubscribe", "close"],
"owner": "project-owner-required",
"securityPrivacy": ["Broadcast no credentials or personal payload.", "Validate versions.", "Treat events as hints rather than authorization."],
"bundleBudgetGzipBytes": 4000,
"fallback": "Refresh from the authoritative server on focus.",
"removal": ["Close channels.", "Remove event registry entries.", "Restore focus-based refresh."],
"serverStatePolicy": "invalidation-only"
},
{
"id": "browser-permission",
"status": "RECIPE_AVAILABLE",
"trigger": "A user-initiated flow requires clipboard, notification or media access.",
"forbiddenWhen": ["Permission would be requested at boot.", "Denied, dismissed and unsupported UX are not designed."],
"boundary": "presentation input action through a browser capability output port",
"port": "BrowserPermissionPort",
"fake": "FakeBrowserPermissionAdapter",
"failureKinds": ["denied", "dismissed", "unsupported"],
"lifecycleMethods": ["stop-media-tracks-if-opened"],
"owner": "project-owner-required",
"securityPrivacy": ["Require an explicit user gesture.", "Minimize requested scope.", "Do not persist permission as authorization."],
"bundleBudgetGzipBytes": 3000,
"fallback": "Manual input or copy/download instruction.",
"removal": ["Stop acquired resources.", "Remove permission action and adapter.", "Retest denied-path accessibility."],
"serverStatePolicy": "no-server-state"
},
{
"id": "client-workflow",
"status": "RECIPE_AVAILABLE",
"trigger": "A measured cross-page client-only workflow cannot be represented by URL, local state, context or query cache.",
"forbiddenWhen": ["The store would duplicate server response collections.", "A library is selected before state ownership is documented.", "Zustand and Redux Toolkit would both be installed."],
"boundary": "workflow-specific local facade; vendor types remain in its adapter",
"port": "ClientWorkflowPort",
"fake": "FakeClientWorkflowAdapter",
"failureKinds": ["reset", "version-mismatch", "server-state-duplication"],
"lifecycleMethods": ["unsubscribe", "reset"],
"owner": "project-owner-required",
"securityPrivacy": ["Persist only explicitly classified workflow fields.", "Never persist credentials.", "Define logout and version reset."],
"bundleBudgetGzipBytes": 9000,
"fallback": "URL, component state, context and TanStack Query ownership.",
"removal": ["Move remaining state to its natural owner.", "Remove facade and one selected store dependency.", "Verify logout/reset."],
"serverStatePolicy": "reference-only"
},
{
"id": "large-data-ui",
"status": "RECIPE_AVAILABLE",
"trigger": "Production-like profiling proves a list or grid exceeds interaction and rendering budgets.",
"forbiddenWhen": ["Pagination solves the scale requirement.", "Keyboard and screen-reader focus behavior is undefined."],
"boundary": "presentation facade around virtualizer or data-grid behavior",
"port": "LargeDataUiFacade",
"fake": "FakeLargeDataUiAdapter",
"failureKinds": ["focus-loss", "stale-row", "scale-limit"],
"lifecycleMethods": ["dispose-observers-if-installed"],
"owner": "project-owner-required",
"securityPrivacy": ["Render only authorized rows.", "Do not expose hidden row data to telemetry.", "Preserve accessible row identity."],
"bundleBudgetGzipBytes": 30000,
"fallback": "Accessible pagination and bounded result sets.",
"removal": ["Restore paginated primitive.", "Remove facade adapter and dependency.", "Run keyboard and performance evidence."],
"serverStatePolicy": "query-cache-owned"
},
{
"id": "analytics-error-sink",
"status": "RECIPE_AVAILABLE",
"trigger": "A production provider, consent policy, retention owner and event registry are approved.",
"forbiddenWhen": ["Consent and essential diagnostics are not separated.", "Arbitrary message or attribute keys can bypass redaction."],
"boundary": "closed diagnostics/analytics port with provider adapter",
"port": "AnalyticsErrorSink",
"fake": "RecordingAnalyticsAdapter",
"failureKinds": ["consent-denied", "queue-full", "provider-unavailable"],
"lifecycleMethods": ["flush", "dispose"],
"owner": "project-owner-required",
"securityPrivacy": ["Allowlist events and attributes.", "Redact before queueing.", "Apply consent, sampling and retention policy."],
"bundleBudgetGzipBytes": 25000,
"fallback": "Existing bounded local diagnostics and best-effort telemetry port.",
"removal": ["Disable provider delivery.", "Flush or discard by policy.", "Remove adapter, runtime config and dependency."],
"serverStatePolicy": "no-server-state"
}
]
}
@@ -0,0 +1,7 @@
{
"schemaVersion": 1,
"snapshotDigest": "ce4fa9b7944f27553067228bd6c9e73e7dc05875c283255b50d7eb3ad2923f6d",
"owner": "frontend-platform",
"reason": "RP-11-initial-transitive-inventory",
"approvedAt": "2026-07-26T08:27:17.874Z"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
{
"schemaVersion": 1,
"changes": []
}
+24
View File
@@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"allowedLicenses": [
"(MIT OR CC0-1.0)",
"0BSD",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"BlueOak-1.0.0",
"CC-BY-4.0",
"CC0-1.0",
"ISC",
"MIT",
"MIT-0",
"MPL-2.0"
],
"deniedLicensePatterns": [
"(^|\\s)AGPL",
"(^|\\s)GPL",
"SSPL",
"BUSL"
],
"unknownLicensePolicy": "allow-only-unmaterialized-platform-optional"
}
+31
View File
@@ -0,0 +1,31 @@
{
"schemaVersion": 1,
"trackedRoots": [
"src",
"recipes",
"scripts",
"tests",
"config",
"public",
"schemas",
".storybook",
"package.json",
"pnpm-lock.yaml",
"vite.config.js",
"vitest.config.js",
"playwright.config.js"
],
"generatedRoots": ["dist", "artifacts/release"],
"excludedPaths": [
"tests/fixtures/security/secret-detection/forbidden"
],
"allowlist": [
{
"path": "tests/fixtures/security/secret-detection/allowed/test-credentials.ts",
"ruleId": "assigned-secret",
"owner": "frontend-platform",
"reason": "Synthetic credential verifies the scoped test-only allowlist.",
"expiresAt": "2027-07-26T00:00:00.000Z"
}
]
}
@@ -0,0 +1,4 @@
{
"schemaVersion": 1,
"exceptions": []
}
@@ -0,0 +1,8 @@
{
"schemaVersion": 1,
"providerMode": "external-file",
"inputEnvironment": "VULNERABILITY_REPORT_PATH",
"blockAtSeverity": "high",
"allowedSeverities": ["unknown", "low", "moderate", "high", "critical"],
"missingProviderStatus": "FAIL_UNVERIFIED"
}
+92
View File
@@ -0,0 +1,92 @@
{
"schemaVersion": 1,
"summary": {
"lines": 80,
"statements": 78,
"functions": 85,
"branches": 68
},
"criticalModules": [
{
"path": "src/adapters/http/retry-policy.js",
"minimum": {
"lines": 80,
"statements": 78,
"functions": 95,
"branches": 78
}
},
{
"path": "src/adapters/storage/browser-storage-adapter.js",
"minimum": {
"lines": 60,
"statements": 60,
"functions": 70,
"branches": 60
}
},
{
"path": "src/adapters/telemetry/best-effort-telemetry.js",
"minimum": {
"lines": 85,
"statements": 85,
"functions": 70,
"branches": 75
}
},
{
"path": "src/application/policies/compatibility.js",
"minimum": {
"lines": 95,
"statements": 95,
"functions": 95,
"branches": 75
}
},
{
"path": "src/application/policies/performance-budgets.js",
"minimum": {
"lines": 80,
"statements": 80,
"functions": 80,
"branches": 40
}
},
{
"path": "src/application/policies/promotion-readiness.js",
"minimum": {
"lines": 95,
"statements": 95,
"functions": 95,
"branches": 95
}
},
{
"path": "src/application/use-cases/decide-chunk-recovery.js",
"minimum": {
"lines": 90,
"statements": 90,
"functions": 95,
"branches": 85
}
},
{
"path": "src/contracts/diagnostics.ts",
"minimum": {
"lines": 68,
"statements": 68,
"functions": 95,
"branches": 58
}
},
{
"path": "scripts/lib/registry-compatibility.mjs",
"minimum": {
"lines": 80,
"statements": 80,
"functions": 85,
"branches": 60
}
}
]
}
+10 -6
View File
@@ -1,10 +1,12 @@
# Manual accessibility review checklist
Automated axe checks do not establish WCAG conformance. A human reviewer must
review all three route records in `artifacts/tests/a11y-manual/` against one
release candidate and sign them. Copy the template fields exactly; the gate
rejects blank identity/timestamp/signature fields, pending verdicts, mismatched
release IDs, or missing routes.
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:
@@ -43,5 +45,7 @@ The reviewer must verify:
- M7: non-essential motion is suppressed with reduced-motion preference
- Screen reader: headings, live regions, errors, and actions are announced once
Passing automated evidence means only that tested pages had no critical or
serious axe findings under the recorded browser run.
`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,96 @@
# VD-06: Intl과 typed local message catalog
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-i18n-message-formatting-contract`
- 재검토: 승인 locale·복수형 문법·번역 추출 workflow가 local catalog 범위를 넘을 때
## 배경
공통 셸, route surface, async/form 상태와 디자인 시스템 기본 문구가 JSX와
JavaScript에 분산돼 있었다. 날짜는 일부 application mapper에서 고정 locale로
가공되어 presentation이 locale을 바꿀 수 없었고, direction·누락 key·보간 실패
정책도 없었다. 반면 현재 skeleton에는 번역 관리 서비스, 실제 번역 승인 절차,
복잡한 ICU 문법이라는 제품 요구가 아직 없다. 이 단계에서 i18n vendor를 기본
번들에 넣으면 소비 프로젝트가 제거하거나 다시 감싸야 할 의존성만 늘어난다.
## 결정
1. 표준 `Intl.DateTimeFormat`, `NumberFormat`, `RelativeTimeFormat`,
`ListFormat`, `PluralRules`와 typed local catalog를 기본 엔진으로 사용한다.
2. `MessageKey`는 한국어 canonical catalog에서 도출하며 영어와 RTL smoke
catalog는 `satisfies Record<MessageKey, string>`으로 compile-time parity를
강제한다.
3. 보간이 필요한 key는 `MessageParameters`에 key별 parameter object를
선언한다. 잘못된 key, 누락·초과 parameter는 TypeScript negative fixture가
거절한다.
4. 기본 locale은 `ko-KR`, fallback locale도 `ko-KR`이다. 알려지지 않은 locale은
language fallback 후 `ko-KR`로 정규화한다. 알려지지 않은 key와 누락 보간은
raw key나 외부 값을 출력하지 않고 안전한 공통 fallback을 반환한다.
5. `en-XA`는 영어 문구를 확장·accent 처리하는 pseudo locale이고 `ar-EG`
RTL 동작 smoke locale이다. 이 두 locale은 실제 제품 번역 완료를 의미하지
않는다.
6. 날짜 formatter의 기본 timezone은 테스트와 SSR/브라우저 결과가 흔들리지
않도록 `UTC`다. 제품 timezone이 필요하면 호출자가 명시한다. invalid
date/number/timezone은 `—`를 반환하고 throw하지 않는다.
7. locale state는 React inbound concern이다. `LocaleProvider`가 copy,
formatter와 `<html lang/dir>`을 제공하며 application/domain은 미리 번역된
문자열 대신 의미 값과 timestamp를 반환한다.
8. backend `message`, raw HTML, stack과 내부 key를 catalog 입력으로 신뢰하지
않는다. transport/application failure kind를 등록된 사용자 message key로
매핑한 뒤 presentation이 해석한다.
9. key rename은 즉시 제거하지 않고 `MESSAGE_KEY_ALIASES`에 compatibility alias를
둔다. alias는 새 호출의 타입에 포함하지 않아 신규 코드는 canonical key만
사용한다.
10. extraction, ICU rich message, 번역 SaaS 또는 framework adapter가 필요해지면
`presentation/i18n` public API 뒤에서 교체한다. vendor type은 feature와
design-system public prop으로 노출하지 않는다.
## 실행 경계
```text
route/form/failure 의미 값
-> presentation message key
-> LocaleProvider
-> typed catalog / Intl formatter
-> text node와 accessible name
```
- canonical catalog: `src/presentation/i18n/catalog.ts`
- feature contribution: `src/features/*/contracts/*-message-catalog.js`
`src/features/installed-feature-messages.js`에서 조립
- key/보간/fallback/alias: `message-contract.ts`
- locale-safe value formatting: `formatters.ts`
- React composition과 document metadata: `locale-provider.tsx`
- public entry: `src/presentation/i18n/index.ts`
## 검증
- `check:i18n`은 catalog key와 placeholder parity, common UI의 한국어 literal,
backend message JSX 렌더링과 raw HTML 사용을 검사한다.
- `check:i18n:fixture`는 세 금지 사례를 실제로 거절해야 성공으로 인정된다.
- type negative fixture는 unknown key와 잘못된 parameter shape를 거절한다.
- unit test는 fallback, alias, pseudo 확장, direction과 timezone/number/relative/
list/plural/select의 결정성을 검증한다.
- component test는 document `lang/dir`, RTL Tabs와 direction-aware pagination,
Drawer semantics를 검증한다.
- Playwright는 320px pseudo reflow와 RTL compact shell/Drawer/focus restore를
Chromium, Firefox, WebKit project에서 실행한다.
## 한계와 재검토 조건
현재 catalog는 실제 번역 승인, ICU rich text, locale별 plural 문장 전체 조합,
메시지 추출/번역 메모리와 서버 locale negotiation을 제공하지 않는다. 다음 중
하나가 확인되면 별도 ADR로 엔진을 재평가한다.
- 세 개 이상의 실제 승인 locale과 번역 담당 workflow
- 복수형·성별·select가 한 문장 안에서 중첩되는 제품 copy
- server/client extraction, namespace lazy-loading 또는 번역 SaaS 연동
- SSR locale negotiation과 hydration 일치가 필요한 rendering mode
## Rollback
`ko-KR` catalog가 기존 기본 문구를 보존하므로 provider를 고정 locale adapter로
되돌려도 기본 UX를 유지한다. formatter/vendor 교체 시 public `message`,
`date`, `number`, `relativeTime`, `list`, `plural`, `select` 계약과 negative
fixture는 유지한다. alias는 migration window 종료 근거 없이 제거하지 않는다.
@@ -0,0 +1,97 @@
# VD-07: Diagnostics와 telemetry exporter 경계
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-diagnostics-telemetry-runtime`
- 재검토: 실제 운영 sink, consent가 필요한 analytics 또는 분산 tracing provider가
선정될 때
## 배경
기존 telemetry registry와 best-effort HTTP queue는 있었지만 운영 진단 record와
semantic event의 책임이 하나의 telemetry port에 섞여 있었다. boot, HTTP,
cache, storage, route와 release failure의 선언도 실제 production producer와
완전히 연결되지 않았다. 이 상태에서는 retry attempt마다 같은 사건을 발행하거나
raw URL, query, request body와 오류 객체가 queue에 들어갈 위험이 있다.
반면 skeleton 단계에는 실제 관측 vendor, endpoint의 운영 보안 정책, analytics
consent와 보존 기간이 결정되지 않았다. 특정 SDK를 기본 번들에 설치하는 것은
vendor 결정 전에는 안전한 기본값이 아니다.
## 결정
1. level 기반 운영 진단은 `DiagnosticsPort`, registry 기반 semantic event는
`TelemetryPort`로 분리한다. application은 두 port의 concrete adapter나
exporter SDK를 알지 못한다.
2. diagnostics의 level, event ID와 context key는 닫힌 registry/allowlist다.
telemetry도 event별 required/optional attribute와 value policy를 적용한다.
등록되지 않은 event·context·고카디널리티 값은 전송하지 않는다.
3. 기본 diagnostics adapter는 bounded in-memory evidence이고 telemetry는
설정이 없으면 true no-op이다. endpoint가 있을 때만 bounded oldest-drop
queue와 best-effort HTTP sink를 사용한다.
4. raw path/URL/query/body/response/storage value, credential, cookie, email,
stack과 오류 객체 전체는 context에 넣지 않는다. route ID, operation ID,
correlation ID, release ID, error kind, status/attempt/duration bucket만
허용한다.
5. HTTP logical execution은 success, retry recovery, terminal failure 또는
abort마다 `http.request.completed` diagnostics를 정확히 한 번 남긴다.
`api.request.failed` telemetry는 retry가 끝난 terminal non-abort failure에만
정확히 한 번 발행한다.
6. `app.boot.failed`, `ui.render.failed`, `release.mismatch.detected`,
`telemetry.delivery.dropped`를 production path에 연결한다. cache와 storage
실패는 diagnostics로 기록하되 raw key/value를 기록하지 않는다.
7. queue full, invalid event/context, serialization과 sink failure는 제한된
reason bucket으로 집계한다. drop observer의 failure는 다시 telemetry를
발행하지 않는 nonrecursive 경계다.
8. diagnostics와 telemetry failure는 제품 흐름, HTTP 결과, route transition,
storage/cache fallback과 React error surface를 바꾸지 않는다.
9. mount 전 bootstrap failure는 안전한 build/config/error kind만 별도 evidence로
만들며 untrusted error message, stack과 support 입력을 serialize하지 않는다.
10. 실제 error reporter, RUM, analytics나 tracing SDK는 같은 port 뒤의 외부
adapter로만 추가한다. SDK type과 event API를 application/feature/presentation
public contract에 노출하지 않는다.
## 실행 경계
```text
route/application/HTTP/cache/storage/bootstrap
-> typed DiagnosticsPort 또는 TelemetryPort
-> registry + allowlist + value policy
-> bounded memory/no-op 또는 best-effort HTTP adapter
-> 프로젝트가 선택한 외부 sink
```
- diagnostics contract: `src/contracts/diagnostics.ts`
- telemetry contract: `src/contracts/telemetry.js`
- application ports: `src/application/ports/diagnostics-port.ts`,
`telemetry-port.ts`
- bounded diagnostics: `src/adapters/diagnostics/bounded-diagnostics.ts`
- best-effort telemetry: `src/adapters/telemetry/best-effort-telemetry.js`
- composition: `src/bootstrap/runtime-adapters.js`
## 검증
- `check:diagnostics`는 모든 registry event에 production producer가 있는지와
source의 direct console/sensitive context 우회를 검사한다.
- negative source fixture는 direct console, unknown event와 raw context를 실제로
거절하며 TypeScript fixture는 잘못된 level/event ID를 거절한다.
- unit test는 allowlist, hostile/circular error, bounded diagnostics, no-op,
queue full, sink/observer failure와 pre-mount boot evidence를 검증한다.
- HTTP integration은 success, retry recovery, terminal failure와 abort의 producer
횟수, route/operation/correlation context와 요청 값 비노출을 검증한다.
- cache/storage/release/application/runtime test는 각 production wiring과
diagnostics failure isolation을 검증한다.
## 한계와 재검토 조건
기본 adapter는 운영 log 검색, source map 연계, session replay, distributed span,
analytics consent, sampling budget과 장기 보존을 제공하지 않는다. 실제 sink를
선정할 때 데이터 처리 지역, 보존 기간, consent, CSP, source map 접근 제어,
sampling과 비용 상한을 별도 결정해야 한다.
## Rollback
telemetry exporter는 runtime 설정을 끄거나 adapter wiring을 `noOpTelemetry`
바꾸어 독립적으로 제거할 수 있다. 이때도 `DiagnosticsPort`, registry,
redaction/value policy, producer-count와 negative fixture는 유지한다. 외부 SDK
문제로 application producer와 안전 계약을 함께 되돌리지 않는다.
@@ -0,0 +1,71 @@
# VD-08: 개발용 Storybook과 로컬 시각 회귀 증적
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-test-registry-evidence-hardening`
- 재검토: 제품이 cloud visual review, 다중 OS baseline 또는 별도 디자인 시스템
배포를 요구할 때
## 배경
`/examples/ui``/examples/states`는 실제 application composition 안에서 공통
UI와 상태 표면을 보여 주지만, primitive를 격리해 interaction과 접근성을 검증하는
workshop은 아니었다. 실패 시 screenshot도 디버깅 증거일 뿐 의도된 UI 기준선과
현재 렌더의 차이를 차단하지 못했다.
외부 visual review 서비스, 별도 Storybook 배포와 브랜드별 baseline은 아직
선정되지 않았다. 이 결정을 기다리며 UI 회귀 검증을 비워 두거나 production
application bundle에 workshop runtime을 포함하는 것 모두 적절하지 않다.
## 결정
1. Storybook은 development dependency와 별도 static artifact로만 사용한다.
production entry와 application `dist`에는 Storybook runtime, story 또는
테스트 selector를 포함하지 않는다.
2. story는 public design-system entry를 소비하고 실제 locale, theme, session,
router와 query provider 계약으로 렌더한다. production component를 복제한
story 전용 구현을 만들지 않는다.
3. interaction과 story-level axe는 Playwright가 정적 Storybook을 대상으로
실행한다. unexpected console, page error와 request failure는 테스트 실패다.
4. 시각 회귀는 production `build``preview`를 대상으로 pinned Chromium,
locale, color scheme과 viewport에서 `toHaveScreenshot()`으로 실행한다.
5. 최초 기준선은 wide shell, compact pseudo-locale drawer, dark design-system
gallery, loading/empty/error/access 상태 표면을 포함한다.
6. animation과 caret만 결정적으로 비활성화한다. `html`, `body`, `main` 또는
application 전체를 mask해 false PASS를 만드는 설정은 gate가 거절한다.
7. snapshot 갱신은 `test:visual:update`라는 명시적 명령으로 분리하고 PNG diff를
review한다. 일반 `test:visual`은 승인 기준선을 변경하지 않는다.
8. local visual threshold는 작은 rasterization 차이만 허용하며 실제 layout,
copy, theme 또는 상태 변화가 숨겨지도록 확대하지 않는다.
9. `/examples/*`는 production composition smoke로 유지하고 Storybook story의
대체물로 취급하지 않는다. 반대로 Storybook만 통과해 application shell
integration을 완료 처리하지 않는다.
10. cloud service가 선정되지 않아도 repository-local workshop, interaction,
a11y와 visual baseline gate는 완전하게 실행 가능해야 한다.
## 실행과 증적
- workshop config: `.storybook/main.ts`, `.storybook/preview.tsx`
- story: `src/presentation/design-system/design-system.stories.tsx`
- interaction/a11y: `tests/storybook/workshop.spec.ts`
- visual: `tests/visual/platform.visual.spec.ts`
- baseline: `tests/visual/__snapshots__/`
- production E2E: `playwright.config.js`
- local dev E2E: `playwright.dev.config.js`
- evidence policy: `scripts/check-test-evidence.mjs`
CI는 JUnit, HTML report, failure trace/screenshot, visual baseline 존재 여부와
금지된 full-screen mask/무소유 skip fixture를 함께 검사한다.
## 한계와 재검토 조건
로컬 기준선은 실제 iOS/Android 기기, 여러 운영체제의 font rasterization,
디자인 승인 workflow와 다중 브랜드를 증명하지 않는다. 이를 요구하면 동일
public component와 story를 입력으로 사용하는 외부 review adapter를 추가하되,
provider 결과가 없을 때 임의 PASS로 대체하지 않는다.
## Rollback
Storybook dependency/config, workshop test와 visual config/baseline은 production
runtime 변경 없이 독립적으로 제거할 수 있다. rollback 후에도 `/examples/*`,
component behavior, automated accessibility와 built-dist E2E는 유지한다.
@@ -0,0 +1,103 @@
# VD-09: 공급망 inventory, license, vulnerability, SBOM과 provenance
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-supply-chain-verification`
- 재검토: 조직 vulnerability scanner, signing/attestation provider와 dependency
exception 승인 체계가 선정될 때
## 배경
기존 release script는 `package.json`의 직접 dependency 이름과 버전, lockfile
전체 digest, `dist` checksum만 기록했다. 전이 dependency, 패키지별 integrity와
license, 실제 baseline diff가 없었고 `highRiskUnreviewed: []`는 계산 결과가 아닌
고정값이었다. secret scan도 `src``dist`만 검사해 config, scripts, test와
generated release metadata를 놓쳤다.
반면 저장소에는 조직이 선택한 vulnerability source, severity exception 승인자,
signing identity와 attestation 저장소가 없다. 외부 provider가 없는 상태를 빈
finding과 서명 성공으로 표현하면 local 검증과 release promotion을 혼동한다.
## 결정
1. `pnpm-lock.yaml`의 모든 `packages` row와 `pnpm list --depth Infinity`의 실제
graph를 결합해 직접/전이, production/development, required/platform-optional,
version, SHA-512 SRI, license와 dependency edge를 기록한다.
2. inventory row 수는 lockfile package row 수와 같아야 한다. 누락된 전이
dependency, malformed integrity와 non-optional `NOASSERTION`은 local gate를
실패시킨다.
3. license는 설치된 package manifest에서 읽고 closed allow/deny policy로
검사한다. 현재 OS에 materialize되지 않은 platform optional만
`NOASSERTION`과 그 이유를 명시적으로 허용한다.
4. 승인 dependency baseline과 approval digest를 보존하고 현재 lock inventory와
actual add/remove/change/upgrade diff를 계산한다. 새 direct production
dependency는 owner와 서로 다른 reviewer, reason과 rollback evidence가
필요하다.
5. inventory를 CycloneDX 1.6 SBOM으로 투영한다. component 수, lockfile digest,
SRI, license와 dependency edge가 inventory와 일치해야 한다.
6. local in-toto/SLSA 형태 provenance statement는 source set, lockfile, SBOM과
`dist` digest를 연결하되 `LOCAL_UNSIGNED`로 표시한다. 외부 attestation은
provider, signer와 동일 dist subject digest가 있어야 한다.
7. vulnerability adapter는 `VULNERABILITY_REPORT_PATH`가 가리키는
machine-readable provider report를 검증한다. report의 lock digest, provider,
severity와 exception owner/reviewer/reason/expiry가 유효해야 한다.
8. provider report가 없으면 local inventory/license/SBOM/coherence는 `PASS`,
promotion은 `FAIL_UNVERIFIED`다. 빈 finding을 만들어 vulnerability PASS로
표시하지 않는다.
9. secret scan은 source, scripts, tests, tracked config/schema, public, `dist`
generated release metadata를 검사한다. allowlist는 test path에만 허용하며
owner, reason과 expiry가 필요하다. 발견한 secret 원문은 artifact에 쓰지 않고
rule, path, line과 fingerprint만 남긴다.
10. `SOURCE_DATE_EPOCH`를 지원하고 같은 source/lock/config의 production build를
두 번 실행해 전체 dist digest 일치를 검증한 뒤 일반 build를 복원한다.
## 실행 경계와 증적
```text
package.json + frozen pnpm-lock.yaml + installed graph
-> deterministic dependency inventory
-> license policy + approved actual baseline diff
-> CycloneDX SBOM
source/config/lock + production dist
-> local provenance statement
-> optional vulnerability/attestation provider inputs
-> LOCAL PASS | promotion PASS/FAIL_UNVERIFIED
```
- policy: `config/security/`
- generator: `scripts/generate-supply-chain.mjs`
- coherence: `scripts/verify-supply-chain-artifacts.mjs`
- secret scan: `scripts/security-scan.mjs`
- reproducibility: `scripts/verify-reproducible-build.mjs`
- inventory: `artifacts/release/dependency-inventory.json`
- SBOM/provenance: `artifacts/release/sbom.cdx.json`,
`artifacts/release/provenance.json`
- local/promotion status:
`artifacts/security/supply-chain-verification.json`
## 검증
- 현재 lockfile의 561개 package row와 inventory row가 양방향 일치한다.
- ordering-only digest, removal, integrity tamper, baseline tamper, high-risk
self approval, denied license, critical vulnerability와 만료 exception,
provider/digest 오류, SBOM/provenance 불일치 fixture를 검사한다.
- synthetic provider/attestation fixture는 promotion `PASS`를 증명한 후 기본
`FAIL_UNVERIFIED` 상태를 복원한다.
- frozen install은 manifest/lock mismatch fixture를 실제 pnpm으로 거절한다.
- source/config/dist 각각의 synthetic secret fixture가 실제 scan을 실패시키고
scoped test allowlist만 통과한다.
## 한계와 재검토 조건
로컬 manifest license는 법률 검토가 아니며 vulnerability report도 외부 scanner가
제공한 데이터의 최신성 자체를 보증하지 않는다. 실제 프로젝트는 provider 버전,
database freshness, network outage, exception 승인 조직, signing identity,
attestation transparency/retention과 비밀 관리를 결정해야 한다.
## Rollback
외부 scanner/attestor adapter는 환경 입력을 제거하면 즉시
`FAIL_UNVERIFIED`로 돌아간다. local inventory, lock integrity, license, SBOM,
secret, reproducibility와 actual diff gate는 유지한다. scanner 장애를 이유로
promotion을 PASS로 변경하지 않는다.
@@ -0,0 +1,99 @@
# VD-10: 선택형 frontend capability recipe
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-optional-adapter-recipes`
- 현재 선택 capability: 없음
- 재검토: 실제 프로젝트가 realtime, offline, PWA, file, generated API,
feature flag, worker, multi-tab, browser permission, client workflow,
large-data UI 또는 production analytics/error provider를 요구할 때
## 배경
서버의 PostgreSQL, MongoDB, Redis, Kafka, MinIO 같은 기술을 브라우저가 직접
소비하지는 않는다. 프론트의 변화 지점은 권한 있는 HTTP/BFF, push event,
offline persistence, file protocol, browser runtime, 사용자 동의와 UI 성능
경계다. 이 capability를 “언젠가 필요할 수 있다”는 이유로 모두 설치하면 초기
bundle, 공급망, runtime config, 보안 표면과 업데이트 비용만 늘어난다.
반대로 문서에 이름만 적으면 실제 프로젝트에서 port 위치, cancellation,
fallback, fake와 제거 기준을 다시 설계해야 한다. 따라서 production runtime에
아무것도 설치하지 않되 검증 가능한 vendor-neutral recipe를 저장소 밖이 아닌
별도 opt-in 경계에 유지한다.
## 결정
1. `config/recipes/frontend-capability-recipes.json`이 12개 recipe의 선택 기준,
금지 조건, port/fake, failure matrix, lifecycle cleanup, owner,
security/privacy, gzip budget, fallback, server-state 정책과 제거 절차의
machine-readable SSOT다.
2. 현재 실제 소비 요구와 project owner가 없으므로 12개 상태는 모두
`RECIPE_AVAILABLE`이며 `INSTALLED`가 아니다. production runtime dependency와
composition registration은 0개다.
3. `recipes/frontend-capabilities`의 TypeScript port와 fake/unavailable adapter는
실행 가능한 설계 예시다. `src` 또는 production entry가 이 디렉터리를 import할
수 없다.
4. 프로젝트가 capability를 선택하면 필요한 최소 contract를
application-owned output port 또는 presentation facade로 이동하고, concrete
vendor adapter는 local adapter 경계에 둔다. recipe 디렉터리를 production에서
그대로 import하지 않는다.
5. WebSocket/SSE처럼 연결은 outbound이고 수신 event는 inbound인 양방향 기술도
한 종류의 “adapter”로 뭉개지 않는다. 연결·credential·reconnect 정책과
event validation·input invocation을 분리한다.
6. Zustand/Redux Toolkit/state machine은 실제 cross-page client-only workflow가
확인된 경우 하나만 선택한다. URL, component state, Context, TanStack Query가
이미 소유한 상태를 복제하지 않는다.
7. browser credential은 localStorage, URL, recipe store, telemetry 또는
BroadcastChannel에 넣지 않는다. 브라우저가 database/object store에 직접
접속하는 recipe도 금지한다.
8. lifecycle이 있는 capability는 unsubscribe, close, unregister, dispose,
cancel 또는 `AbortSignal`을 계약과 contract test에 포함해야 한다.
9. 선택하지 않은 recipe sentinel이나 vendor dependency가 production bundle에
들어가면 gate를 실패시킨다.
10. recipe 전체를 제거한 임시 worktree에서 base typecheck, architecture,
unit/component/integration test와 production build가 통과해야 한다.
## 선택과 설치 절차
```text
measured product/runtime need
-> project owner + security/privacy classification
-> recipe trigger/forbidden/fallback review
-> VD-10 amendment with one selected capability
-> application port or presentation facade copied into src
-> one concrete adapter under local adapter boundary
-> composition-only wiring
-> contract/failure/cleanup/integration tests
-> bundle + dependency baseline approval
-> INSTALLED only after all evidence passes
```
도입 커밋에는 owner, 선택 이유, 대안, gzip 차이, runtime config, browser support,
failure UX, observability, rollback과 제거 명령을 기록한다. vendor가 필요한
behavior를 fake만으로 확인하고 `INSTALLED`로 바꾸지 않는다.
## 증적
- catalog: `config/recipes/frontend-capability-recipes.json`
- contracts/fakes: `recipes/frontend-capabilities`
- 상세 runbook: `docs/architecture/optional-adapter-recipes.md`
- contract test: `tests/recipes/optional-capability-contracts.test.ts`
- negative fixture:
`tests/fixtures/optional-recipes/forbidden`
- validation:
`scripts/check-optional-recipes.mjs`
- removal:
`scripts/test-optional-recipe-removal.mjs`
- evidence:
`artifacts/quality/optional-recipes.json`,
`artifacts/quality/optional-recipe-fixtures.json`,
`artifacts/tests/optional-recipes.xml`,
`artifacts/tests/optional-recipe-removal.xml`
## Rollback
현재 branch는 runtime dependency나 production composition을 바꾸지 않으므로
recipe catalog, example과 gate를 함께 revert하면 RP-11 상태로 돌아간다. 실제
프로젝트에서 선택한 capability는 그 capability의 port/adapter/composition/
dependency commit만 revert한다. 여러 vendor 도입을 하나의 되돌릴 수 없는
commit으로 묶지 않는다.
@@ -0,0 +1,476 @@
# 프론트엔드 플랫폼 역량 재검토
## 1. 문서 목적
이 문서는 도메인 기능과 실제 운영 환경의 배포 증적을 제외하고, 이 저장소가 새
프론트엔드 제품의 출발점으로 제공해야 하는 공통 역량을 다시 평가한다. 평가
기준은 다음과 같다.
- 코드나 설정 파일이 존재하는지만 보지 않는다.
- 부트스트랩부터 화면까지 실제 호출 경로가 연결되는지 확인한다.
- 선언한 레지스트리와 정책이 런타임 및 CI에서 집행되는지 확인한다.
- 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다.
- 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다.
최초 검토 기준은 `develop``cb195f8`이며, RP-01~RP-12 구현 결과를 이 문서에
누적 반영했다. 이후 구현으로 경로나 세부 내용이 달라질 수 있으므로, 각 항목은
문서의 경로뿐 아니라 해당 테스트와 아키텍처 게이트로 계속 검증해야 한다.
## 2. 결론
현재 저장소는 다음 기반이 강하다.
- 런타임 설정과 릴리스 매니페스트 검증
- 도메인, 애플리케이션, 프레젠테이션, outbound adapter의 의존 방향
- 공통 HTTP 실패 형태와 제한된 retry 정책
- 앱 셸, 반응형 내비게이션, 테마, 비동기 상태 표면
- Vitest, Testing Library, MSW, Playwright, axe를 이용한 테스트 계층
- CI 게이트 taxonomy와 호환성·보안·성능·릴리스 계약 문서
RP-01~RP-12에서 TypeScript 도구 안전망, application runtime 주입,
query/mutation inbound adapter, HTTP 실행 계약과 executable route/release
recovery 계약, 제거 가능한 reference 수직 슬라이스, form/page, design system과
i18n 실행 경계, diagnostics/telemetry production wiring, registry/test 증거,
local 공급망 검증과 제거 가능한 optional adapter recipe가 구현됐다. 저장소 내부
P0/P1 acceptance와 P2 recipe 기본값은 `LOCAL_TEMPLATE_READY`다. 다만 실제 제품
도메인과 hosting, IdP, vulnerability/signing provider, analytics consent/provider,
지원 browser/접근성·field 증거는 프로젝트가 선택하고 검증해야 한다.
따라서 더 정확한 표현은 다음과 같다.
> application API, 서버 상태, 폼, 라우팅, 페이지, 디자인 시스템, 테스트와
> local 공급망 증적의 표준 수직 경로와 opt-in adapter recipe는 갖춰졌다.
> 실제 capability 설치와 hosting·IdP·취약점/서명/운영 provider는 프로젝트
> 통합 범위이며, 없는 외부 증거를 완료로 표시하지 않는다.
## 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, logical execution당 bounded diagnostics | terminal event 중복 방지 계약 유지 |
| 오류 모델 | 부분 준비 | 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/URL/query/context 소유권, session external store, typed workflow recipe | 실제 cross-page workflow가 생길 때 하나의 store 선택 |
| 범용 global store | 프로젝트 선택 | runtime library 없음, typed facade/fake와 server-state duplication gate | VD-10 조건에 따라 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, pseudo reflow와 RTL direction | compact browser matrix 유지 |
| 페이지 템플릿 | 준비됨 | 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 | public story와 visual state matrix 유지 |
| 아이콘 | 준비됨 | 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 평가 |
| 국제화 | 준비됨 | 137-key typed catalog, locale provider, Intl formatter, safe fallback/alias, pseudo·RTL gate | 실제 locale·번역 승인은 프로젝트에서 연결 |
| logging/diagnostics | 준비됨 | 별도 `DiagnosticsPort`, 8-event registry, allowlist, bounded/no-op adapter와 production producer | 실제 프로젝트의 remote sink는 port 뒤에서 선택 |
| telemetry | 준비됨/프로젝트 선택 | 5-event registry, bounded queue, redaction/value policy, boot·HTTP·render·release·drop producer | analytics/RUM/error vendor와 consent는 프로젝트에서 선택 |
| 비동기 상태 불변식 | 준비됨 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 |
| 단위·통합·E2E | 준비됨 | source/test strict typecheck, shared MSW 19개 scenario, 실제 bootstrap, built-dist 3엔진·compact E2E | 제품별 critical flow를 같은 catalog/gate에 추가 |
| UI 회귀 검증 | 준비됨 | dev-only Storybook interaction/axe와 pinned Chromium visual baseline 4종 | cloud review와 다중 OS/device는 프로젝트 선택 |
| 샘플 제거 | 준비됨 | feature/catalog/test 제거 후 type/architecture/registry/test/home/build 9단계 검증 | 새 contribution도 같은 제거 gate에 포함 |
| registry·compatibility 집행 | 준비됨 | 10개 registry type/reference/consumer/orphan, 승인 digest와 actual semantic diff, breaking evidence | public 계약 변경 시 baseline review 유지 |
| 공급망 검사 | 준비됨/프로젝트 선택 | 561개 transitive inventory/integrity/license, actual diff, CycloneDX, local provenance, secret/reproducible build gate | 실제 vulnerability scanner와 signed attestation 없이는 promotion `FAIL_UNVERIFIED` |
| realtime·offline·file 등 | 준비됨/프로젝트 선택 | 12개 opt-in TypeScript port/fake/unavailable, failure/security/bundle/removal gate | 실제 요구·owner 승인 시 해당 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된다.
#### RP-09에서 diagnostics/telemetry 실행 깊이 보강
`DiagnosticsPort``TelemetryPort`를 분리하고 boot, HTTP logical outcome,
render, cache, storage, route, release mismatch와 delivery drop을 production
producer에 연결했다. HTTP retry는 attempt별 terminal event를 발행하지 않고
logical execution 종료 시 한 번만 bounded outcome을 남긴다. allowlist와
value policy가 raw URL/query/body/storage value/error object를 거절하고 queue와
sink failure는 nonrecursive drop evidence로 제한된다.
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
- RP-09에서 완료한 redacted structured diagnostics와 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
#### RP-08에서 국제화 실행 경계 구현
`src/presentation/i18n`은 shell, route, async/form error, page template와
design-system 기본 copy의 canonical 경계다. `MessageKey`와 key별
`MessageParameters`가 잘못된 key/보간을 compile time에 막고, runtime
`resolveMessage`는 unknown locale/key와 누락 보간에서 raw 값 대신 안전한
fallback을 반환한다. application mapper는 locale-formatted date를 반환하지
않고 timestamp를 유지하며 presentation formatter가 `UTC` 또는 명시 timezone을
적용한다.
`LocaleProvider``ko-KR`, `en-US`, `en-XA`, `ar-EG` smoke set과 document
`lang/dir`을 동기화한다. `en-XA`는 긴 문구 reflow, `ar-EG`는 logical CSS,
Drawer, Tabs arrow와 pagination 방향 icon을 검증하기 위한 개발 locale이다.
실제 아랍어 번역 완료를 뜻하지 않는다. `check:i18n`과 negative fixture는 common
UI literal, backend raw message render와 raw HTML interpolation을 거절한다.
새 key rename은 canonical type에는 넣지 않고 runtime alias/migration window로
호환한다.
### 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와 정책이 정해졌을 때 |
12개 항목의 현재 상태는 모두 `RECIPE_AVAILABLE / NOT_INSTALLED`다.
`config/recipes/frontend-capability-recipes.json`이 선택/금지 조건, failure,
cleanup, security/privacy, bundle budget, fallback과 제거 절차의 SSOT이며,
`recipes/frontend-capabilities`에 production-excluded TypeScript port와
fake/unavailable adapter가 있다. 도입 절차는
`docs/architecture/optional-adapter-recipes.md`를 따른다.
서버의 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: `DiagnosticsPort`로 telemetry와 분리해 구현. closed event/level,
allowlist와 bounded/no-op adapter 제공
- 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)
File diff suppressed because it is too large Load Diff
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
@@ -0,0 +1,156 @@
# Optional frontend adapter recipes
이 문서는 도메인과 무관한 선택형 frontend capability를 실제 프로젝트에
도입하는 실행 가이드다. 기본 스켈레톤에는 vendor runtime을 설치하지 않는다.
`RECIPE_AVAILABLE`은 계약·fake·failure policy가 준비됐다는 뜻이며 실제 provider,
runtime behavior 또는 production readiness를 뜻하지 않는다.
## 1. 현재 상태와 파일 지도
| 항목 | 경로 | production 포함 |
| --- | --- | --- |
| 선택/금지/예산 SSOT | `config/recipes/frontend-capability-recipes.json` | 정책만 |
| catalog JSON schema | `schemas/config/frontend-capability-recipes.schema.json` | 아니오 |
| TypeScript port | `recipes/frontend-capabilities/contracts.ts` | 아니오 |
| fake/unavailable | `recipes/frontend-capabilities/fake-adapters.ts` | 아니오 |
| contract test | `tests/recipes/optional-capability-contracts.test.ts` | 아니오 |
| 정적/번들 gate | `scripts/check-optional-recipes.mjs` | build 도구 |
| negative fixture | `scripts/check-optional-recipe-fixtures.mjs` | 아니오 |
| 완전 제거 gate | `scripts/test-optional-recipe-removal.mjs` | 아니오 |
현재 `productionRuntimeDependencies`는 빈 배열이며 12개 recipe 모두 선택되지
않았다. TypeScript example은 product source가 import할 library가 아니라 선택
시 복사하고 좁힐 출발점이다.
## 2. 어느 경계에 두는가
| capability 성격 | port 소유자 | adapter 방향 | concrete 위치 예 |
| --- | --- | --- | --- |
| application이 외부 결과를 요청 | application | outbound | `src/adapters/<capability>` |
| URL/browser event가 의도를 전달 | application input | inbound | `src/presentation/adapters` |
| React rendering behavior만 교체 | presentation | local facade | `src/presentation/<capability>` |
| feature 전용 protocol | feature application | in/out 분리 | `src/features/<name>/adapters` |
WebSocket 연결 생성, reconnect와 credential attachment는 outbound다. 수신 JSON
검증과 application input 호출은 inbound다. Service Worker update event,
BroadcastChannel event도 같은 원칙을 적용한다. generated DTO와 vendor SDK
type은 facade 밖으로 노출하지 않는다.
## 3. 12개 recipe 선택표
| recipe | 설치하는 경우 | 설치하면 안 되는 경우 | 핵심 fallback |
| --- | --- | --- | --- |
| realtime | ordered push/resume protocol이 확정됨 | polling이 충분하거나 ordering owner 없음 | bounded polling/stale UI |
| offline/IndexedDB | durable offline data/queue가 제품 요구 | credential 저장, DB 직접 연결, HTTP cache로 충분 | online-only + offline state |
| Service Worker/PWA | install/offline shell과 cache owner 승인 | update/rollback UX 없음 | hosting cache 기반 network app |
| file transfer | progress/cancel/size/type 정책 필요 | long-lived credential URL | bounded normal request |
| generated API | versioned source와 drift CI가 있음 | DTO가 domain/UI로 노출됨 | typed request builder + schema |
| feature flag | rollout/kill switch owner와 default 있음 | authorization에 사용 | typed local default |
| Web Worker | profiler가 main-thread 병목을 증명 | 단순 network I/O | chunked/deferred execution |
| multi-tab | 비민감 event 동기화가 필요 | server가 conflict authority | focus 시 authoritative refresh |
| browser permission | user gesture 기반 기능 필요 | boot 요청, denied UX 없음 | manual input/instruction |
| client workflow | cross-page client-only state가 실재 | query/server state 복제 | URL/local/context/query |
| large data UI | 실측 scale이 budget 초과 | pagination으로 충분, a11y 미정 | accessible pagination |
| analytics/error sink | provider·consent·retention 승인 | arbitrary payload/redaction 우회 | bounded local diagnostics |
정확한 failure matrix, security/privacy, gzip budget과 제거 순서는 JSON catalog가
SSOT다. 문서와 catalog가 다르면 gate가 검사하는 catalog를 우선 고치고 이 표도
같이 갱신한다.
## 4. 공통 구현 순서
1. 문제를 vendor 이름이 아닌 capability와 측정값으로 기록한다.
2. catalog의 trigger와 forbidden 조건을 모두 검토한다.
3. project owner, security/privacy reviewer, gzip budget과 재검토 날짜를 VD-10
amendment에 기록한다.
4. existing URL/local/context/query/application port로 해결되지 않는지 확인한다.
5. 필요한 contract만 `recipes`에서 해당 application/presentation 경계로 복사해
실제 payload와 failure union으로 좁힌다.
6. concrete SDK는 `src/adapters/...` 또는 local presentation facade adapter에서만
import한다.
7. composition root가 concrete adapter를 주입한다. page/use case가 constructor를
직접 호출하지 않는다.
8. fake, unavailable, timeout/cancel, cleanup, malformed input, redaction과
integration test를 작성한다.
9. runtime config schema, dependency inventory/approval, SBOM, bundle budget,
browser support와 runbook을 갱신한다.
10. 실제 provider integration과 negative behavior가 통과한 뒤에만 catalog 상태를
별도 project catalog에서 `INSTALLED`로 바꾼다.
## 5. capability별 필수 검증
### Realtime
- runtime schema로 envelope/version/event ID/sequence/timestamp를 검증한다.
- reconnect는 exponential backoff 상한, visibility/offline 상태, auth refresh와
resume token expiry를 정의한다.
- duplicate/out-of-order는 domain use case에 전달하기 전에 정책화한다.
- route unmount/logout에서 unsubscribe하고 heartbeat timer를 종료한다.
### Offline/Service Worker
- store/cache 이름과 schema는 release와 독립적인 migration version을 가진다.
- quota, corrupt row, partial migration, downgrade/rollback을 fixture로 만든다.
- authenticated response와 credential은 기본 cache 대상이 아니다.
- stale worker loop를 막고 unregister 후 owned cache 삭제가 가능한지 검증한다.
### File/generated API
- upload는 client MIME을 신뢰하지 않고 size/type/server rejection을 모두 다룬다.
- progress는 unknown total을 허용하며 navigation/unmount에서 AbortSignal로
취소한다.
- generated code는 facade 뒤 DTO이며 runtime response schema와 contract drift
gate를 유지한다.
### Flag/worker/multi-tab/browser
- flag unknown/unavailable/stale에서 명시적 typed fallback을 사용하고 access
control로 사용하지 않는다.
- worker는 task ID/generation/cancel을 사용해 stale result를 폐기하고 crash를
normalized failure로 바꾼다.
- multi-tab은 source/event/version으로 self-echo와 duplicate를 막고 payload를
비민감 invalidation hint로 제한한다.
- browser permission은 user gesture에서만 요청하고 denied/dismissed/unsupported를
서로 다른 UX 결과로 처리한다.
### Client workflow/large data/analytics
- workflow store는 server entity/collection을 복제하지 않고 query key나 ID 참조만
보관한다. logout/reset/version mismatch 정책을 테스트한다.
- virtualization은 profiler와 production-like row count로 정당화하며 keyboard,
focus restoration, screen reader와 stale row identity를 검증한다.
- analytics는 essential diagnostics와 consent-required event를 분리하고 closed
event/attribute registry, pre-queue redaction, sampling, bounded queue와
retention을 적용한다.
## 6. 검증 명령
```bash
corepack pnpm check:types:recipes
corepack pnpm test:recipes
corepack pnpm build
corepack pnpm check:optional-recipes
corepack pnpm check:optional-recipe-fixtures
corepack pnpm test:optional-recipe-removal
```
negative gate는 cleanup 누락, unselected dependency, local adapter 밖 vendor
import, credential localStorage/URL/telemetry 경로, workflow store의 server-state
복제와 production source의 recipe import를 거절한다. removal gate는 recipe와
recipe test를 삭제한 임시 사본에서 base typecheck, architecture, test와 build를
실행한다.
## 7. 제거 체크리스트
1. 신규 호출과 background 작업을 중지한다.
2. subscription, worker, channel, media track, observer를 cleanup한다.
3. persisted store/cache/event queue의 migrate 또는 purge 정책을 실행한다.
4. composition registration과 runtime config를 제거한다.
5. concrete adapter, facade/port와 vendor dependency를 제거한다.
6. dependency baseline, SBOM과 bundle baseline을 갱신한다.
7. typecheck/test/build, production bundle absence와 도메인 기능 fallback을
검증한다.
provider 장애 시 fake로 바꾸어 production을 PASS 처리하지 않는다. 문서화된
unavailable fallback만 사용하고 provider가 필수인 promotion은
`FAIL_UNVERIFIED` 또는 blocked 상태로 유지한다.
+43 -5
View File
@@ -8,6 +8,8 @@ in `review-ledger.json`, not automatically to edits in this file.
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]
@@ -16,8 +18,44 @@ flowchart LR
Contracts --> Presentation
```
Dependencies point inward. Presentation calls application use cases, adapters
implement application ports, and only the composition root selects concrete
adapters. Contract registries are the single named source for routes, API
operations, environment values, storage keys, errors, queries, telemetry, and
release tokens.
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)
@@ -0,0 +1,505 @@
# 라우팅, 페이지 템플릿, 재사용 패턴
## 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 | 선언과 실행 완전성 | 모든 설정을 하나의 거대 전역 파일에 집중 |
RP-08 이후 route contract의 `title`/`navigation` 필드는 fallback metadata이며
실제 document title, navigation, loading/error/access surface는
`route.<ROUTE_ID>.title|navigation` typed catalog key를 해석한다. route params,
search, backend message를 translation key로 조립하지 않는다. locale 변경은
현재 route를 재요청하거나 query key를 바꾸지 않고 document title과 화면 copy만
다시 렌더링한다.
패턴은 추상화 파일만 만든 것으로 완료되지 않는다. 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.
@@ -0,0 +1,627 @@
# 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`를 임의 호출하지 않게 한다.
현재 `recipes/frontend-capabilities``ClientWorkflowPort`
`FakeClientWorkflowAdapter`가 vendor-neutral opt-in 예제를 제공한다. 기본
production에는 Zustand/Redux Toolkit/state-machine dependency가 없고,
`check:optional-recipe-fixtures`가 server response collection을 client workflow
store에 복제하는 패턴을 거절한다.
### 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
VD-07에서 level/event 기반 `DiagnosticsPort`와 semantic
`TelemetryPort`를 분리했다. diagnostics는 8개 event ID와 safe context
allowlist를, telemetry는 event별 required/optional attribute와 value policy를
사용한다.
공통 요구:
- log level과 event key는 닫힌 union
- attribute allowlist와 중앙 redaction
- 기본 adapter는 bounded memory/no-op이고 endpoint가 있을 때만 best-effort
HTTP queue를 사용
- production provider SDK를 추가할 때도 port 뒤에서 감싸며 앱 코드는 SDK를
import하지 않음
- 오류 객체 전체를 그대로 serialize하지 않음
- trace ID/build ID/route ID/operation ID를 허용된 범위에서 연결
- logging failure가 제품 flow를 실패시키지 않음
- consent가 필요한 analytics와 essential diagnostics를 분리
HTTP는 route/operation/correlation ID를 logical execution context로 생성하고
success/recovered/failed/aborted 종료 시 diagnostics를 한 번만 기록한다.
terminal non-abort failure만 telemetry를 한 번 발행한다. cache/storage와 boot
producer는 raw key, value, message, stack을 버리고 error kind와 bounded
operation만 남긴다. queue full과 sink failure는 제한된 reason bucket이며 drop
observer가 실패해도 재귀 발행하지 않는다.
### 7.4 locale, message와 표시 값
RP-08부터 locale은 presentation-owned React context다. server/application
state에 번역된 문자열을 저장하거나 query key에 locale을 넣는 것은 응답 자체가
locale별 데이터인 경우에만 허용한다. 공통 UI copy 변경 때문에 query cache를
복제하지 않는다.
```text
API timestamp/number/failure kind
-> schema + mapper (의미 값 유지)
-> application result
-> presentation controller
-> useLocale().date/number/message
```
- message key는 `MessageKey` union이며 interpolation은 key별 tuple type이다.
- unknown external key는 `resolveMessage`에 전달해도 raw key가 표시되지 않는다.
- backend `message`는 diagnostic input일 수 있지만 사용자 copy가 아니다.
- form validation code는 `ParameterlessMessageKey` allowlist로 mapping한다.
- date의 기본 timezone은 UTC이고 제품 timezone은 presentation 호출자가
명시한다.
- pseudo/RTL locale state는 local interaction state이며 persistence와 server
synchronization을 기본 제공하지 않는다.
- key rename은 typed canonical key를 먼저 이동하고 runtime alias에 migration
기간을 둔다.
## 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로 분리된다.
- common UI copy, locale formatter와 direction이 typed i18n facade를 통과한다.
- query/mutation/form recipe만으로 새 기능을 만들 수 있다.
+5
View File
@@ -30,6 +30,11 @@ 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
+42 -13
View File
@@ -1,18 +1,47 @@
# Build and supply-chain gate
Merge and release controls:
## Local blocking 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
- `pnpm install --frozen-lockfile` and a real manifest/lock mismatch fixture
- all direct and transitive lockfile rows with package SHA-512 integrity
- production/development, direct/transitive and platform-optional classification
- package-manifest license allow/deny policy
- approved inventory baseline digest and actual add/remove/change/upgrade diff
- independent review for new direct production dependencies
- CycloneDX 1.6 SBOM and inventory component/edge coherence
- source/lock/SBOM/dist-linked local provenance statement
- source, opt-in recipes, scripts, tests, tracked config/schema, public, built asset and generated
release metadata secret scan
- two-build `SOURCE_DATE_EPOCH` reproducibility check
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.
The canonical commands are:
`artifacts/security/dependency-diff.json` is a local baseline. CI replaces it
with the actual base/head direct and transitive lockfile diff before release.
```bash
corepack pnpm verify:lockfile
corepack pnpm verify:reproducible-build
corepack pnpm build:release
corepack pnpm verify:supply-chain
corepack pnpm check:supply-chain:fixtures
```
`config/security/dependency-baseline.json` is the approved local baseline.
Changing it requires `DEPENDENCY_BASELINE_OWNER` and
`DEPENDENCY_BASELINE_REASON`; editing the digest or hardcoding an empty diff is
rejected.
## External promotion controls
The vulnerability adapter reads the file named by
`VULNERABILITY_REPORT_PATH`. It requires a provider, the exact lockfile digest,
severity findings and valid independent, unexpired exception evidence.
`PROVENANCE_ATTESTATION_PATH` must name a provider, signer and the exact built
dist subject digest.
If either provider input is absent, local verification remains meaningful but
`artifacts/security/supply-chain-verification.json` records
`promotionStatus: FAIL_UNVERIFIED`. `verify:supply-chain:promotion` then exits
non-zero. Scanner or signing outages are not converted to an empty PASS.
Approved vulnerability exceptions require vulnerability/package identity,
owner, a different reviewer, reason and expiry. Expired or self-approved
exceptions are blocking.
+884
View File
@@ -0,0 +1,884 @@
# 디자인 시스템 플랫폼 계약
이 문서는 도메인 기능을 추가하기 전에 프론트엔드 스켈레톤이 제공해야 하는
디자인 시스템의 소유권, 계층, 기본 구성요소, 외부 라이브러리 경계, 검증 방법을
정의한다. 목표는 특정 제품의 시각 언어를 미리 결정하는 것이 아니라, 제품 팀이
접근성·반응형·국제화·테스트 규칙을 다시 발명하지 않고 기능 화면을 만들 수 있게
하는 것이다.
이 문서에서 `필수`는 모든 제품이 기반으로 사용할 계약을 뜻한다. `선택`
컴포넌트의 경계와 도입 기준은 제공하지만 실제 의존성이나 구현은 제품 요구가
생긴 뒤 추가해도 되는 항목을 뜻한다.
## 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. 국제화 계약
RP-08에서 디자인 시스템 primitive의 닫기, alert, toast, 글자 수 같은 기본
문구는 `useLocale()` typed catalog로 이동했다. 제품 의미를 가진 label은 여전히
명시적인 prop으로 받으며 primitive가 feature 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
현재 구현 위치와 실패 정책:
- `presentation/i18n/catalog.ts`: 137개 canonical common key와 locale parity
- `message-contract.ts`: key별 interpolation, fallback과 compatibility alias
- `formatters.ts`: UTC-default date, number, relative time, list, plural/select
- `locale-provider.tsx`: document `lang/dir`과 React consumer API
- missing key/parameter와 formatter 예외: 빈 문자열이나 raw key 대신 안전한
fallback
- `en-XA`: 긴 문자열/320px reflow, `ar-EG`: logical layout/RTL keyboard smoke
- backend raw message와 translated HTML: 정적 negative gate에서 거절
금지 패턴:
- 번역 문장 중간에 JSX 문자열을 연결한다.
- 날짜를 `substring`이나 고정 구분자로 조립한다.
- icon direction을 locale 확인 없이 좌/우 이름으로 고정한다.
- route title, navigation label, toast message를 JSX literal로 분산한다.
- 번역 누락 시 빈 문자열을 렌더링한다.
RP-10의 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로 유지한다.
이 순서는 라이브러리 수를 늘리는 것이 목표가 아니다. 각 단계가 제품 개발자가
중복 구현할 문제를 하나씩 제거하고, 외부 공급자를 교체할 수 있는 로컬 계약과
검증 증거를 남기는 것이 목표다.
+36 -4
View File
@@ -1,8 +1,32 @@
# Design-token styling contract
`src/presentation/styles/theme.css` is the styling SSOT. Components consume
semantic color, spacing, typography, and radius tokens through static Tailwind
classes.
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:
@@ -13,5 +37,13 @@ Arbitrary-value policy:
- user-controlled or runtime-composed class strings are forbidden
- class variants must be selected from a closed static map
The removable sample may demonstrate tokens, but product modules must not
import from `src/sample/contract-fixture`.
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.
+238 -36
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,52 +76,217 @@ export default [
"artifacts/**",
"tests/fixtures/typecheck/**",
"tests/fixtures/architecture/forbidden/**",
"tests/fixtures/diagnostics/forbidden/**",
"tests/fixtures/i18n/forbidden/**",
"tests/fixtures/security/forbidden/**",
"tests/fixtures/optional-recipes/**",
],
},
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: ["**/*.d.ts"],
languageOptions: {
...commonLanguageOptions,
parser: babelParser,
parserOptions: {
requireConfigFile: false,
babelOptions: {
plugins: [
["@babel/plugin-syntax-typescript", { dts: true }],
],
},
},
},
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,
@@ -89,33 +295,29 @@ export default [
},
},
{
files: ["tests/fixtures/architecture/forbidden/**/*.{js,jsx}"],
files: [`tests/support/browser/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports([
"**/adapters/**",
"@tanstack/**",
"react",
"react-dom",
]),
"react-hooks/rules-of-hooks": "off",
},
},
{
files: ["**/*.{js,jsx}"],
files: [
`tests/fixtures/architecture/forbidden/**/*.${sourceExtensions}`,
],
rules: {
"no-eval": "error",
"no-new-func": "error",
"no-script-url": "error",
"no-restricted-syntax": [
"no-restricted-imports": restrictedImports([
"**/adapters/**",
"**/application/ports/out/**",
"@tanstack/**",
"react",
"react-dom",
"**/application/**",
]),
"no-restricted-globals": [
"error",
{
selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']",
message: "Raw HTML injection is prohibited by FE-OC-019.",
},
{
selector:
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
message: "Runtime script construction is prohibited by FE-OC-019.",
},
"fetch",
"localStorage",
"sessionStorage",
],
},
},
+65 -5
View File
@@ -5,7 +5,7 @@
"type": "module",
"packageManager": "pnpm@11.17.0",
"engines": {
"node": ">=24.0.0 <25.0.0",
"node": ">=24.11.0 <25.0.0",
"pnpm": ">=11.0.0 <12.0.0"
},
"scripts": {
@@ -13,24 +13,74 @@
"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",
"lint": "eslint src scripts tests recipes .storybook 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:design-system": "node scripts/check-design-system.mjs",
"check:design-system:fixture": "node scripts/check-design-system.mjs --fixture",
"check:i18n": "node scripts/check-i18n.mjs",
"check:i18n:fixture": "node scripts/check-i18n.mjs --fixture",
"check:diagnostics": "node scripts/check-diagnostics.mjs",
"check:diagnostics:fixture": "node scripts/check-diagnostics.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:recipes": "tsc --project tsconfig.recipes.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",
"check:types:fixture:i18n-key": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-key.ts",
"check:types:fixture:i18n-params": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-params.ts",
"check:types:fixture:diagnostics": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-diagnostics-port.ts",
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
"test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml",
"test:recipes": "vitest run tests/recipes --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/optional-recipes.xml --passWithNoTests",
"test:e2e": "playwright test",
"test:e2e:dev": "playwright test --config playwright.dev.config.js",
"storybook": "storybook dev -p 6006",
"build:storybook": "storybook build -o artifacts/storybook/static",
"test:storybook": "playwright test --config playwright.storybook.config.js",
"test:visual": "playwright test --config playwright.visual.config.js",
"test:visual:update": "playwright test --config playwright.visual.config.js --update-snapshots",
"check:test-evidence": "node scripts/check-test-evidence.mjs",
"check:test-evidence:fixture": "node scripts/check-test-evidence.mjs --source-root tests/fixtures/test-evidence/forbidden --artifact artifacts/quality/test-evidence-fixture.json",
"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:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration",
"test:optional-recipe-removal": "node scripts/test-optional-recipe-removal.mjs",
"test:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests",
"test:coverage": "vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature --coverage --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.mjs",
"check:coverage:fixture": "node scripts/check-risk-coverage.mjs --summary tests/fixtures/coverage/below-threshold.json --artifact artifacts/quality/risk-coverage-fixture.json",
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:recipes",
"verify:lockfile": "corepack pnpm install --frozen-lockfile",
"check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.mjs",
"generate:supply-chain": "node scripts/generate-supply-chain.mjs",
"verify:supply-chain": "node scripts/verify-supply-chain-artifacts.mjs",
"update:dependency-baseline": "node scripts/update-dependency-baseline.mjs",
"check:supply-chain:fixtures": "node scripts/check-supply-chain-fixtures.mjs",
"check:supply-chain:provider-fixtures": "node scripts/check-supply-chain-provider-fixtures.mjs",
"verify:supply-chain:promotion": "node scripts/verify-supply-chain-promotion.mjs",
"verify:reproducible-build": "node scripts/verify-reproducible-build.mjs",
"scan:security": "node scripts/security-scan.mjs",
"scan:security:fixture": "node scripts/security-scan.mjs --policy tests/fixtures/security/secret-detection/forbidden-policy.json --artifact artifacts/security/scan-fixture.sarif",
"check:browser-security": "node scripts/check-browser-security.mjs",
"check:optional-recipes": "node scripts/check-optional-recipes.mjs --require-dist",
"check:optional-recipes:source": "node scripts/check-optional-recipes.mjs",
"check:optional-recipe-fixtures": "node scripts/check-optional-recipe-fixtures.mjs",
"check:registries": "node scripts/check-registries.mjs",
"check:registries:structure": "node scripts/check-registries.mjs --no-baseline",
"check:registries:compatibility-fixtures": "node scripts/check-registry-compatibility-fixtures.mjs",
"check:registries:baseline-fixture": "node scripts/check-registries.mjs --approval tests/fixtures/registry/compatibility/tampered-approval.json --artifact artifacts/quality/registry-baseline-fixture.json",
"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",
@@ -45,6 +95,7 @@
},
"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",
@@ -52,21 +103,30 @@
},
"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",
"@storybook/addon-a11y": "10.5.4",
"@storybook/react-vite": "10.5.4",
"@tailwindcss/vite": "4.3.3",
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@tailwindcss/vite": "4.3.3",
"@types/node": "24.13.3",
"@types/react": "19.2.8",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.4",
"@vitest/coverage-v8": "4.1.10",
"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",
"storybook": "10.5.4",
"tailwindcss": "4.3.3",
"typescript": "7.0.2",
"vite": "8.1.5",
+31 -5
View File
@@ -6,18 +6,44 @@ export default defineConfig({
reporter: [
["list"],
["html", { outputFolder: "./artifacts/tests/e2e/report", open: "never" }],
["junit", { outputFile: "./artifacts/tests/e2e/results.xml" }],
],
use: {
baseURL: "http://127.0.0.1:5173",
baseURL: "http://127.0.0.1:4173",
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
webServer: {
command: "corepack pnpm dev --host 127.0.0.1",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
command:
"corepack pnpm build && corepack pnpm preview --host 127.0.0.1 --port 4173",
url: "http://127.0.0.1:4173",
reuseExistingServer: false,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{
name: "chromium",
testIgnore: "**/compact-smoke.spec.js",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: "**/compact-smoke.spec.js",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: "**/compact-smoke.spec.js",
use: { ...devices["Desktop Safari"] },
},
{
name: "chromium-compact",
testMatch: "**/compact-smoke.spec.js",
use: {
...devices["Desktop Chrome"],
viewport: { width: 390, height: 844 },
hasTouch: true,
isMobile: true,
},
},
],
});
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "@playwright/test";
import releaseConfig from "./playwright.config.js";
export default defineConfig({
...releaseConfig,
use: {
...releaseConfig.use,
baseURL: "http://127.0.0.1:5173",
},
webServer: {
command: "corepack pnpm dev --host 127.0.0.1",
url: "http://127.0.0.1:5173",
reuseExistingServer: true,
},
});
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/storybook",
outputDir: "./artifacts/tests/storybook/results",
reporter: [
["list"],
[
"html",
{
outputFolder: "./artifacts/tests/storybook/report",
open: "never",
},
],
[
"junit",
{ outputFile: "./artifacts/tests/storybook/results.xml" },
],
],
use: {
baseURL: "http://127.0.0.1:6006",
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
webServer: {
command:
"corepack pnpm build:storybook && node scripts/serve-static.mjs artifacts/storybook/static 6006",
url: "http://127.0.0.1:6006",
reuseExistingServer: false,
},
projects: [
{ name: "chromium-workshop", use: { ...devices["Desktop Chrome"] } },
],
});
+37
View File
@@ -0,0 +1,37 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/visual",
snapshotDir: "./tests/visual/__snapshots__",
outputDir: "./artifacts/tests/visual/results",
reporter: [
["list"],
[
"html",
{ outputFolder: "./artifacts/tests/visual/report", open: "never" },
],
["junit", { outputFile: "./artifacts/tests/visual/results.xml" }],
],
expect: {
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.002,
scale: "css",
},
},
use: {
...devices["Desktop Chrome"],
baseURL: "http://127.0.0.1:4174",
colorScheme: "light",
locale: "en-US",
trace: "retain-on-failure",
},
webServer: {
command:
"corepack pnpm build && corepack pnpm preview --host 127.0.0.1 --port 4174",
url: "http://127.0.0.1:4174",
reuseExistingServer: false,
},
projects: [{ name: "chromium-visual" }],
});
+1977 -16
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,4 +1,5 @@
allowBuilds:
esbuild: true
msw: true
minimumReleaseAgeExclude:
- '@playwright/test@1.62.0'
+12 -1
View File
@@ -7,5 +7,16 @@
"apiContractVersion": "1",
"assetManifestHash": "generated-during-build",
"releaseId": "local-release",
"builtAt": "1970-01-01T00:00:00.000Z"
"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"
}
}
+207
View File
@@ -0,0 +1,207 @@
/**
* Opt-in capability contracts.
*
* This directory is a copyable recipe source, not a production entry. A project
* moves only the selected contract into its application-owned boundary and puts
* a concrete implementation behind that port.
*/
export const OPTIONAL_RECIPE_RUNTIME_SENTINEL =
"frontend-optional-recipe-must-not-reach-production";
export type CapabilityFailureCode =
| "ABORTED"
| "AUTH_EXPIRED"
| "CONFLICT"
| "CONSENT_DENIED"
| "CONTRACT_DRIFT"
| "CORRUPT_DATA"
| "DISCONNECTED"
| "EXPIRED_RESOURCE"
| "INVALID_INPUT"
| "LIMIT_EXCEEDED"
| "MIGRATION_FAILED"
| "NOT_FOUND"
| "PROVIDER_UNAVAILABLE"
| "QUOTA_EXCEEDED"
| "STALE_RESULT"
| "UNSUPPORTED";
export type CapabilityFailure = Readonly<{
code: CapabilityFailureCode;
retryable: boolean;
safeMessage: string;
}>;
export type CapabilityResult<T> =
| Readonly<{ ok: true; value: T }>
| Readonly<{ ok: false; failure: CapabilityFailure }>;
export type Cleanup = () => void;
export type RealtimeEvent<T> = Readonly<{
id: string;
sequence: number;
occurredAt: string;
payload: T;
}>;
export interface RealtimeSubscription {
readonly resumeToken: string | null;
unsubscribe(): void;
}
export interface RealtimePort<T> {
subscribe(input: {
channel: string;
resumeToken?: string;
signal?: AbortSignal;
onEvent(event: CapabilityResult<RealtimeEvent<T>>): void;
}): Promise<CapabilityResult<RealtimeSubscription>>;
heartbeat(signal?: AbortSignal): Promise<CapabilityResult<void>>;
}
export interface VersionedOfflineRepository<T extends { id: string }> {
open(input: {
schemaVersion: number;
signal?: AbortSignal;
}): Promise<CapabilityResult<void>>;
get(id: string, signal?: AbortSignal): Promise<CapabilityResult<T | null>>;
put(value: T, signal?: AbortSignal): Promise<CapabilityResult<void>>;
migrate(input: {
from: number;
to: number;
signal?: AbortSignal;
}): Promise<CapabilityResult<void>>;
close(): void;
}
export interface ServiceWorkerUpdatePort {
inspect(signal?: AbortSignal): Promise<
CapabilityResult<Readonly<{ updateAvailable: boolean; version: string | null }>>
>;
activate(version: string, signal?: AbortSignal): Promise<CapabilityResult<void>>;
rollback(signal?: AbortSignal): Promise<CapabilityResult<void>>;
unregister(): Promise<CapabilityResult<void>>;
}
export type TransferProgress = Readonly<{
transferredBytes: number;
totalBytes: number | null;
}>;
export interface FileTransferPort {
upload(input: {
file: Readonly<{ name: string; size: number; type: string }>;
signal: AbortSignal;
onProgress(progress: TransferProgress): void;
}): Promise<CapabilityResult<Readonly<{ resourceId: string }>>>;
download(input: {
resourceId: string;
signal: AbortSignal;
onProgress(progress: TransferProgress): void;
}): Promise<CapabilityResult<Uint8Array>>;
}
export interface GeneratedApiFacade {
execute<TOutput>(input: {
operationId: string;
contractVersion: string;
body?: unknown;
signal?: AbortSignal;
}): Promise<CapabilityResult<TOutput>>;
}
export interface FeatureFlagPort<TFlags extends Record<string, boolean | string | number>> {
evaluate<TKey extends keyof TFlags>(input: {
key: TKey;
fallback: TFlags[TKey];
maxAgeMs: number;
}): Promise<CapabilityResult<TFlags[TKey]>>;
}
export interface WorkerTaskPort<TInput, TOutput> {
run(input: {
taskId: string;
generation: number;
payload: TInput;
signal: AbortSignal;
}): Promise<CapabilityResult<TOutput>>;
cancel(taskId: string): void;
dispose(): void;
}
export type MultiTabEvent<T> = Readonly<{
eventId: string;
sourceId: string;
version: number;
payload: T;
}>;
export interface MultiTabPort<T> {
publish(event: MultiTabEvent<T>): CapabilityResult<void>;
subscribe(input: {
sourceId: string;
onEvent(event: CapabilityResult<MultiTabEvent<T>>): void;
}): Cleanup;
close(): void;
}
export type BrowserCapability =
| "clipboard-read"
| "clipboard-write"
| "media"
| "notification";
export type PermissionDecision = "granted" | "denied" | "dismissed";
export interface BrowserPermissionPort {
request(input: {
capability: BrowserCapability;
signal?: AbortSignal;
}): Promise<CapabilityResult<PermissionDecision>>;
}
export interface ClientWorkflowPort<TState, TEvent> {
snapshot(): Readonly<TState>;
dispatch(event: TEvent): CapabilityResult<Readonly<TState>>;
reset(): void;
subscribe(listener: (state: Readonly<TState>) => void): Cleanup;
}
export interface LargeDataUiFacade<TRow extends { id: string }> {
window(input: {
offset: number;
limit: number;
generation: number;
}): CapabilityResult<ReadonlyArray<TRow>>;
focus(rowId: string): CapabilityResult<void>;
replace(rows: ReadonlyArray<TRow>, generation: number): void;
}
export type SafeAnalyticsValue = boolean | number | string | null;
export interface AnalyticsErrorSink {
record(input: {
kind: "analytics" | "error";
eventId: string;
consent: "granted" | "denied" | "not-required";
attributes: Readonly<Record<string, SafeAnalyticsValue>>;
}): CapabilityResult<void>;
flush(signal?: AbortSignal): Promise<CapabilityResult<void>>;
dispose(): void;
}
export type OptionalCapabilityPorts = Readonly<{
realtime: RealtimePort<unknown>;
offline: VersionedOfflineRepository<{ id: string }>;
serviceWorker: ServiceWorkerUpdatePort;
fileTransfer: FileTransferPort;
generatedApi: GeneratedApiFacade;
featureFlag: FeatureFlagPort<Record<string, boolean | string | number>>;
worker: WorkerTaskPort<unknown, unknown>;
multiTab: MultiTabPort<unknown>;
browserPermission: BrowserPermissionPort;
clientWorkflow: ClientWorkflowPort<unknown, unknown>;
largeDataUi: LargeDataUiFacade<{ id: string }>;
analytics: AnalyticsErrorSink;
}>;
@@ -0,0 +1,542 @@
import type {
AnalyticsErrorSink,
BrowserCapability,
BrowserPermissionPort,
CapabilityFailure,
CapabilityResult,
ClientWorkflowPort,
FeatureFlagPort,
FileTransferPort,
GeneratedApiFacade,
LargeDataUiFacade,
MultiTabEvent,
MultiTabPort,
OptionalCapabilityPorts,
PermissionDecision,
RealtimeEvent,
RealtimePort,
RealtimeSubscription,
SafeAnalyticsValue,
ServiceWorkerUpdatePort,
VersionedOfflineRepository,
WorkerTaskPort,
} from "./contracts.js";
export function success<T>(value: T): CapabilityResult<T> {
return Object.freeze({ ok: true, value });
}
export function failure(
code: CapabilityFailure["code"],
retryable = false,
safeMessage = "Optional capability is unavailable.",
): CapabilityResult<never> {
return Object.freeze({
ok: false,
failure: Object.freeze({ code, retryable, safeMessage }),
});
}
function aborted(signal?: AbortSignal): CapabilityResult<never> | null {
return signal?.aborted
? failure("ABORTED", false, "The operation was cancelled.")
: null;
}
export class FakeRealtimeAdapter<T> implements RealtimePort<T> {
readonly #subscriptions = new Map<
string,
{
lastSequence: number;
onEvent(event: CapabilityResult<RealtimeEvent<T>>): void;
}
>();
async subscribe(input: {
channel: string;
resumeToken?: string;
signal?: AbortSignal;
onEvent(event: CapabilityResult<RealtimeEvent<T>>): void;
}): Promise<CapabilityResult<RealtimeSubscription>> {
const cancelled = aborted(input.signal);
if (cancelled) return cancelled;
const key = `${input.channel}:${this.#subscriptions.size + 1}`;
this.#subscriptions.set(key, { lastSequence: -1, onEvent: input.onEvent });
const unsubscribe = () => this.#subscriptions.delete(key);
input.signal?.addEventListener("abort", unsubscribe, { once: true });
return success(
Object.freeze({
resumeToken: input.resumeToken ?? null,
unsubscribe,
}),
);
}
async heartbeat(signal?: AbortSignal): Promise<CapabilityResult<void>> {
return aborted(signal) ?? success(undefined);
}
emit(channel: string, event: RealtimeEvent<T>): void {
for (const [key, subscription] of this.#subscriptions) {
if (!key.startsWith(`${channel}:`)) continue;
if (event.sequence <= subscription.lastSequence) {
subscription.onEvent(
failure(
"STALE_RESULT",
false,
"A duplicate or out-of-order event was ignored.",
),
);
continue;
}
subscription.lastSequence = event.sequence;
subscription.onEvent(success(event));
}
}
get activeSubscriptionCount(): number {
return this.#subscriptions.size;
}
}
export class MemoryOfflineRepository<T extends { id: string }>
implements VersionedOfflineRepository<T>
{
readonly #records = new Map<string, T>();
#openVersion: number | null = null;
async open(input: {
schemaVersion: number;
signal?: AbortSignal;
}): Promise<CapabilityResult<void>> {
const cancelled = aborted(input.signal);
if (cancelled) return cancelled;
if (!Number.isInteger(input.schemaVersion) || input.schemaVersion < 1) {
return failure("CORRUPT_DATA", false, "Invalid offline schema version.");
}
this.#openVersion = input.schemaVersion;
return success(undefined);
}
async get(id: string, signal?: AbortSignal): Promise<CapabilityResult<T | null>> {
const cancelled = aborted(signal);
if (cancelled) return cancelled;
if (this.#openVersion === null) {
return failure("PROVIDER_UNAVAILABLE", false, "Repository is closed.");
}
return success(this.#records.get(id) ?? null);
}
async put(value: T, signal?: AbortSignal): Promise<CapabilityResult<void>> {
const cancelled = aborted(signal);
if (cancelled) return cancelled;
if (this.#openVersion === null) {
return failure("PROVIDER_UNAVAILABLE", false, "Repository is closed.");
}
this.#records.set(value.id, structuredClone(value));
return success(undefined);
}
async migrate(input: {
from: number;
to: number;
signal?: AbortSignal;
}): Promise<CapabilityResult<void>> {
const cancelled = aborted(input.signal);
if (cancelled) return cancelled;
if (this.#openVersion !== input.from || input.to <= input.from) {
return failure("MIGRATION_FAILED", false, "Offline migration was rejected.");
}
this.#openVersion = input.to;
return success(undefined);
}
close(): void {
this.#openVersion = null;
}
}
export class FakeServiceWorkerUpdateAdapter implements ServiceWorkerUpdatePort {
#activeVersion: string | null;
#candidateVersion: string | null;
constructor(activeVersion: string | null, candidateVersion: string | null) {
this.#activeVersion = activeVersion;
this.#candidateVersion = candidateVersion;
}
async inspect(signal?: AbortSignal) {
return (
aborted(signal) ??
success({
updateAvailable: this.#candidateVersion !== null,
version: this.#candidateVersion,
})
);
}
async activate(version: string, signal?: AbortSignal) {
const cancelled = aborted(signal);
if (cancelled) return cancelled;
if (version !== this.#candidateVersion) {
return failure("STALE_RESULT", false, "Worker update is no longer current.");
}
this.#activeVersion = version;
this.#candidateVersion = null;
return success(undefined);
}
async rollback(signal?: AbortSignal) {
const cancelled = aborted(signal);
if (cancelled) return cancelled;
if (!this.#activeVersion) {
return failure("NOT_FOUND", false, "No active worker can be rolled back.");
}
this.#activeVersion = null;
return success(undefined);
}
async unregister() {
this.#activeVersion = null;
this.#candidateVersion = null;
return success(undefined);
}
}
export class FakeFileTransferAdapter implements FileTransferPort {
constructor(
private readonly maxBytes = 5_000_000,
private readonly acceptedTypes: ReadonlySet<string> = new Set([
"application/pdf",
"image/png",
]),
) {}
async upload(input: Parameters<FileTransferPort["upload"]>[0]) {
const cancelled = aborted(input.signal);
if (cancelled) return cancelled;
if (
input.file.size > this.maxBytes ||
!this.acceptedTypes.has(input.file.type)
) {
return failure("LIMIT_EXCEEDED", false, "File size or type is not allowed.");
}
input.onProgress({
transferredBytes: input.file.size,
totalBytes: input.file.size,
});
return success({ resourceId: `fake:${input.file.name}` });
}
async download(input: Parameters<FileTransferPort["download"]>[0]) {
const cancelled = aborted(input.signal);
if (cancelled) return cancelled;
if (input.resourceId.startsWith("expired:")) {
return failure("EXPIRED_RESOURCE", true, "The download link expired.");
}
const bytes = new TextEncoder().encode(input.resourceId);
input.onProgress({
transferredBytes: bytes.byteLength,
totalBytes: bytes.byteLength,
});
return success(bytes);
}
}
export class FakeGeneratedApiAdapter implements GeneratedApiFacade {
constructor(
private readonly contractVersion: string,
private readonly handlers: Readonly<
Record<string, (body: unknown) => unknown | Promise<unknown>>
>,
) {}
async execute<TOutput>(
input: Parameters<GeneratedApiFacade["execute"]>[0],
): Promise<CapabilityResult<TOutput>> {
const cancelled = aborted(input.signal);
if (cancelled) return cancelled;
if (input.contractVersion !== this.contractVersion) {
return failure("CONTRACT_DRIFT", false, "API contract version is unsupported.");
}
const handler = this.handlers[input.operationId];
if (!handler) {
return failure("UNSUPPORTED", false, "API operation is unsupported.");
}
return success((await handler(input.body)) as TOutput);
}
}
export class FakeFeatureFlagAdapter<
TFlags extends Record<string, boolean | string | number>,
> implements FeatureFlagPort<TFlags>
{
constructor(
private readonly values: Readonly<Partial<TFlags>>,
private readonly available = true,
) {}
async evaluate<TKey extends keyof TFlags>(input: {
key: TKey;
fallback: TFlags[TKey];
maxAgeMs: number;
}): Promise<CapabilityResult<TFlags[TKey]>> {
if (!this.available) {
return failure("PROVIDER_UNAVAILABLE", true, "Flag provider is unavailable.");
}
const value = this.values[input.key];
return success((value ?? input.fallback) as TFlags[TKey]);
}
}
export class FakeWorkerTaskAdapter<TInput, TOutput>
implements WorkerTaskPort<TInput, TOutput>
{
readonly #cancelled = new Set<string>();
constructor(
private readonly handler: (input: TInput) => TOutput | Promise<TOutput>,
) {}
async run(input: {
taskId: string;
generation: number;
payload: TInput;
signal: AbortSignal;
}): Promise<CapabilityResult<TOutput>> {
if (input.signal.aborted || this.#cancelled.has(input.taskId)) {
return failure("ABORTED", false, "Worker task was cancelled.");
}
const output = await this.handler(input.payload);
if (input.signal.aborted || this.#cancelled.has(input.taskId)) {
return failure("STALE_RESULT", false, "Stale worker result was discarded.");
}
return success(output);
}
cancel(taskId: string): void {
this.#cancelled.add(taskId);
}
dispose(): void {
this.#cancelled.clear();
}
}
export class FakeMultiTabAdapter<T> implements MultiTabPort<T> {
readonly #seen = new Set<string>();
readonly #listeners = new Set<{
sourceId: string;
onEvent(event: CapabilityResult<MultiTabEvent<T>>): void;
}>();
publish(event: MultiTabEvent<T>): CapabilityResult<void> {
if (this.#seen.has(event.eventId)) {
return failure("CONFLICT", false, "Duplicate multi-tab event was ignored.");
}
this.#seen.add(event.eventId);
for (const listener of this.#listeners) {
if (listener.sourceId !== event.sourceId) {
listener.onEvent(success(event));
}
}
return success(undefined);
}
subscribe(input: {
sourceId: string;
onEvent(event: CapabilityResult<MultiTabEvent<T>>): void;
}) {
this.#listeners.add(input);
return () => this.#listeners.delete(input);
}
close(): void {
this.#listeners.clear();
this.#seen.clear();
}
}
export class FakeBrowserPermissionAdapter implements BrowserPermissionPort {
constructor(
private readonly decisions: Readonly<
Partial<Record<BrowserCapability, PermissionDecision>>
>,
) {}
async request(input: {
capability: BrowserCapability;
signal?: AbortSignal;
}): Promise<CapabilityResult<PermissionDecision>> {
const cancelled = aborted(input.signal);
if (cancelled) return cancelled;
const decision = this.decisions[input.capability];
return decision
? success(decision)
: failure("UNSUPPORTED", false, "Browser capability is unsupported.");
}
}
export class FakeClientWorkflowAdapter<TState, TEvent>
implements ClientWorkflowPort<TState, TEvent>
{
readonly #initial: TState;
readonly #listeners = new Set<(state: Readonly<TState>) => void>();
#state: TState;
constructor(
initial: TState,
private readonly transition: (state: TState, event: TEvent) => TState,
) {
this.#initial = structuredClone(initial);
this.#state = structuredClone(initial);
}
snapshot(): Readonly<TState> {
return structuredClone(this.#state);
}
dispatch(event: TEvent): CapabilityResult<Readonly<TState>> {
this.#state = this.transition(this.#state, event);
const snapshot = this.snapshot();
this.#listeners.forEach((listener) => listener(snapshot));
return success(snapshot);
}
reset(): void {
this.#state = structuredClone(this.#initial);
const snapshot = this.snapshot();
this.#listeners.forEach((listener) => listener(snapshot));
}
subscribe(listener: (state: Readonly<TState>) => void) {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
}
export class FakeLargeDataUiAdapter<TRow extends { id: string }>
implements LargeDataUiFacade<TRow>
{
#rows: ReadonlyArray<TRow> = [];
#generation = 0;
window(input: { offset: number; limit: number; generation: number }) {
if (input.generation !== this.#generation) {
return failure("STALE_RESULT", false, "Stale row window was discarded.");
}
if (input.offset < 0 || input.limit < 1) {
return failure("INVALID_INPUT", false, "Invalid row window.");
}
return success(this.#rows.slice(input.offset, input.offset + input.limit));
}
focus(rowId: string) {
return this.#rows.some((row) => row.id === rowId)
? success(undefined)
: failure("NOT_FOUND", false, "Row is no longer available.");
}
replace(rows: ReadonlyArray<TRow>, generation: number): void {
this.#rows = rows;
this.#generation = generation;
}
}
const sensitiveAttribute = /credential|authorization|cookie|password|secret|token/i;
export class RecordingAnalyticsAdapter implements AnalyticsErrorSink {
readonly records: Array<
Readonly<{
kind: "analytics" | "error";
eventId: string;
attributes: Readonly<Record<string, SafeAnalyticsValue>>;
}>
> = [];
constructor(private readonly capacity = 100) {}
record(input: Parameters<AnalyticsErrorSink["record"]>[0]) {
if (input.kind === "analytics" && input.consent !== "granted") {
return failure("CONSENT_DENIED", false, "Analytics consent was not granted.");
}
if (this.records.length >= this.capacity) {
return failure("LIMIT_EXCEEDED", true, "Analytics queue is full.");
}
const attributes = Object.fromEntries(
Object.entries(input.attributes).filter(([key]) => !sensitiveAttribute.test(key)),
);
this.records.push(
Object.freeze({ kind: input.kind, eventId: input.eventId, attributes }),
);
return success(undefined);
}
async flush(signal?: AbortSignal) {
return aborted(signal) ?? success(undefined);
}
dispose(): void {
this.records.length = 0;
}
}
const unavailableAsync = async () =>
failure("PROVIDER_UNAVAILABLE", true, "Capability was not installed.");
const unavailableSync = () =>
failure("PROVIDER_UNAVAILABLE", true, "Capability was not installed.");
export function createUnavailableAdapters(): OptionalCapabilityPorts {
return Object.freeze({
realtime: {
subscribe: unavailableAsync,
heartbeat: unavailableAsync,
},
offline: {
open: unavailableAsync,
get: unavailableAsync,
put: unavailableAsync,
migrate: unavailableAsync,
close() {},
},
serviceWorker: {
inspect: unavailableAsync,
activate: unavailableAsync,
rollback: unavailableAsync,
unregister: unavailableAsync,
},
fileTransfer: {
upload: unavailableAsync,
download: unavailableAsync,
},
generatedApi: { execute: unavailableAsync },
featureFlag: { evaluate: unavailableAsync },
worker: {
run: unavailableAsync,
cancel() {},
dispose() {},
},
multiTab: {
publish: unavailableSync,
subscribe: () => () => {},
close() {},
},
browserPermission: { request: unavailableAsync },
clientWorkflow: {
snapshot: () => Object.freeze({ unavailable: true }),
dispatch: unavailableSync,
reset() {},
subscribe: () => () => {},
},
largeDataUi: {
window: unavailableSync,
focus: unavailableSync,
replace() {},
},
analytics: {
record: unavailableSync,
flush: unavailableAsync,
dispose() {},
},
});
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./contracts.js";
export * from "./fake-adapters.js";
@@ -0,0 +1,60 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://clean-architecture-frontend.local/schemas/dependency-inventory.schema.json",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"packageManager",
"lockfileSha256",
"dependencyCount",
"directDependencyCount",
"dependencies"
],
"properties": {
"schemaVersion": { "const": 2 },
"packageManager": { "type": "string", "minLength": 1 },
"lockfileSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"dependencyCount": { "type": "integer", "minimum": 1 },
"directDependencyCount": { "type": "integer", "minimum": 1 },
"dependencies": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"version",
"direct",
"scope",
"optional",
"license",
"integrity",
"dependencies"
],
"properties": {
"name": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 },
"direct": { "type": "boolean" },
"scope": {
"enum": ["production", "development"]
},
"optional": { "type": "boolean" },
"license": { "type": "string", "minLength": 1 },
"integrity": {
"type": "string",
"pattern": "^sha512-"
},
"dependencies": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}
}
}
}
@@ -4,24 +4,49 @@
"required": [
"schemaVersion",
"generatedAt",
"compatibilityImpact",
"baselineDigest",
"currentDigest",
"compatibility",
"failures",
"registries"
],
"properties": {
"schemaVersion": { "const": 1 },
"schemaVersion": { "const": 2 },
"generatedAt": { "type": "string", "format": "date-time" },
"compatibilityImpact": {
"enum": ["none", "additive", "behavior-change", "breaking"]
"baselineDigest": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"currentDigest": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"compatibility": {
"type": "object",
"required": ["impact", "changes"],
"properties": {
"impact": {
"enum": ["none", "additive", "behavior-change", "breaking"]
},
"changes": { "type": "array" }
},
"additionalProperties": false
},
"failures": { "type": "array", "maxItems": 0 },
"registries": {
"type": "array",
"minItems": 8,
"maxItems": 8,
"minItems": 10,
"maxItems": 10,
"items": {
"type": "object",
"required": ["registryId", "owner", "source", "rowCount", "rows"]
"required": [
"registryId",
"owner",
"source",
"rowCount",
"contract",
"rows"
]
}
}
},
@@ -0,0 +1,55 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://clean-architecture-frontend.local/schemas/supply-chain-verification.schema.json",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"localStatus",
"promotionStatus",
"lockfileSha256",
"sourceSetSha256",
"distSha256",
"sbomSha256",
"dependencyDiff",
"highRiskReview",
"vulnerabilityStatus",
"provenanceAttestationStatus",
"failures"
],
"properties": {
"schemaVersion": { "const": 1 },
"localStatus": { "enum": ["PASS", "FAIL"] },
"promotionStatus": {
"enum": ["PASS", "FAIL_UNVERIFIED"]
},
"lockfileSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"sourceSetSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"distSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"sbomSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"dependencyDiff": { "type": "object" },
"highRiskReview": { "type": "array" },
"vulnerabilityStatus": {
"enum": ["PASS", "FAIL", "FAIL_UNVERIFIED"]
},
"provenanceAttestationStatus": {
"enum": ["PASS", "FAIL_UNVERIFIED"]
},
"failures": {
"type": "array",
"items": { "type": "string" }
}
}
}
@@ -0,0 +1,87 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "frontend-capability-recipes.schema.json",
"title": "Frontend optional capability recipe catalog",
"type": "object",
"additionalProperties": false,
"required": [
"$schema",
"schemaVersion",
"decisionId",
"defaultStatus",
"productionRuntimeDependencies",
"catalogOwner",
"reviewOn",
"vendorPackagePatterns",
"recipes"
],
"properties": {
"$schema": { "type": "string", "minLength": 1 },
"schemaVersion": { "const": 1 },
"decisionId": { "const": "VD-10" },
"defaultStatus": { "const": "NOT_INSTALLED" },
"productionRuntimeDependencies": {
"type": "array",
"maxItems": 0
},
"catalogOwner": { "type": "string", "minLength": 1 },
"reviewOn": { "type": "string", "minLength": 1 },
"vendorPackagePatterns": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"recipes": {
"type": "array",
"minItems": 12,
"maxItems": 12,
"items": { "$ref": "#/$defs/recipe" }
}
},
"$defs": {
"recipe": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"status",
"trigger",
"forbiddenWhen",
"boundary",
"port",
"fake",
"failureKinds",
"lifecycleMethods",
"owner",
"securityPrivacy",
"bundleBudgetGzipBytes",
"fallback",
"removal",
"serverStatePolicy"
],
"properties": {
"id": { "type": "string", "minLength": 1 },
"status": { "const": "RECIPE_AVAILABLE" },
"trigger": { "type": "string", "minLength": 1 },
"forbiddenWhen": { "$ref": "#/$defs/nonEmptyStrings" },
"boundary": { "type": "string", "minLength": 1 },
"port": { "type": "string", "minLength": 1 },
"fake": { "type": "string", "minLength": 1 },
"failureKinds": { "$ref": "#/$defs/nonEmptyStrings" },
"lifecycleMethods": { "$ref": "#/$defs/nonEmptyStrings" },
"owner": { "type": "string", "minLength": 1 },
"securityPrivacy": { "$ref": "#/$defs/nonEmptyStrings" },
"bundleBudgetGzipBytes": { "type": "integer", "minimum": 1 },
"fallback": { "type": "string", "minLength": 1 },
"removal": { "$ref": "#/$defs/nonEmptyStrings" },
"serverStatePolicy": { "type": "string", "minLength": 1 }
}
},
"nonEmptyStrings": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 }
}
}
}
+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`,
);
+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`,
);
+120
View File
@@ -0,0 +1,120 @@
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 { DIAGNOSTIC_EVENT_REGISTRY } from "../src/contracts/diagnostics.ts";
import { TELEMETRY_REGISTRY } from "../src/contracts/telemetry.js";
const fixtureMode = process.argv.includes("--fixture");
const failures = [];
const extensions = /\.(?:js|jsx|mjs|ts|tsx|mts)$/;
/** @param {string} directory @returns {Promise<string[]>} */
async function filesBelow(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 filesBelow(target)));
else if (extensions.test(entry.name)) result.push(target);
}
return result;
}
const telemetryProducerFiles =
/** @type {Readonly<Record<string, string>>} */ ({
"app.boot.failed": "src/adapters/diagnostics/bounded-diagnostics.ts",
"api.request.failed": "src/adapters/http/client.js",
"ui.render.failed": "src/application/create-application.ts",
"release.mismatch.detected": "src/application/create-application.ts",
"telemetry.delivery.dropped":
"src/adapters/telemetry/best-effort-telemetry.js",
});
const diagnosticProducerFiles =
/** @type {Readonly<Record<string, string>>} */ ({
"app.boot.failed": "src/adapters/diagnostics/bounded-diagnostics.ts",
"http.request.completed": "src/adapters/http/client.js",
"cache.operation.failed":
"src/adapters/query-cache/tanstack-query-cache.js",
"storage.operation.failed":
"src/adapters/storage/browser-storage-adapter.js",
"route.changed": "src/application/create-application.ts",
"ui.render.failed": "src/application/create-application.ts",
"release.mismatch.detected": "src/application/create-application.ts",
"telemetry.delivery.dropped": "src/bootstrap/runtime-adapters.js",
});
if (!fixtureMode) {
for (const eventName of Object.keys(TELEMETRY_REGISTRY)) {
const producer = telemetryProducerFiles[eventName];
if (!producer) {
failures.push(`telemetry event has no declared producer: ${eventName}`);
continue;
}
const source = await readFile(producer, "utf8");
if (!source.includes(`"${eventName}"`)) {
failures.push(`telemetry producer is not executable: ${eventName}`);
}
}
for (const eventId of Object.keys(DIAGNOSTIC_EVENT_REGISTRY)) {
const producer = diagnosticProducerFiles[eventId];
if (!producer) {
failures.push(`diagnostic event has no declared producer: ${eventId}`);
continue;
}
const source = await readFile(producer, "utf8");
if (!source.includes(`"${eventId}"`)) {
failures.push(`diagnostic producer is not executable: ${eventId}`);
}
}
}
const sources = fixtureMode
? await filesBelow("tests/fixtures/diagnostics/forbidden")
: await filesBelow("src");
const sensitiveContext =
/\b(?:authorization|cookie|access_token|refresh_token|request_body|response_body|raw_url|query_string|email|user_name)\b/i;
for (const file of sources) {
const source = await readFile(file, "utf8");
if (file.includes("contracts/telemetry.js")) continue;
if (source.includes("console.")) {
failures.push(`direct console diagnostics bypass in ${file}`);
}
const calls = source.match(
/(?:\.record\(\{|\.emit\()[\s\S]{0,700}?(?:\}\)|\}\);)/g,
) ?? [];
if (calls.some((call) => sensitiveContext.test(call))) {
failures.push(`sensitive diagnostic or telemetry context in ${file}`);
}
if (
file.includes("tests/fixtures/diagnostics/forbidden") &&
source.includes("UNKNOWN_DIAGNOSTIC_EVENT")
) {
failures.push(`unknown diagnostic event in ${file}`);
}
}
const report = {
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
telemetryEventCount: Object.keys(TELEMETRY_REGISTRY).length,
diagnosticEventCount: Object.keys(DIAGNOSTIC_EVENT_REGISTRY).length,
checkedFiles: sources.length,
failures,
passed: failures.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
? "artifacts/quality/diagnostics-fixture.json"
: "artifacts/quality/diagnostics.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(
`Diagnostics contract failed:\n${failures.join("\n")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Diagnostics contract: ${report.diagnosticEventCount} diagnostics and ${report.telemetryEventCount} telemetry producers PASS\n`,
);
+39
View File
@@ -0,0 +1,39 @@
import { spawnSync } from "node:child_process";
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
const fixtureRoot = await mkdtemp(
path.join(tmpdir(), "ca-frontend-frozen-lockfile-"),
);
try {
await cp("pnpm-lock.yaml", path.join(fixtureRoot, "pnpm-lock.yaml"));
const manifest = JSON.parse(await readFile("package.json", "utf8"));
manifest.dependencies.react = "0.0.0-invalid-fixture";
await writeFile(
path.join(fixtureRoot, "package.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
const result = spawnSync(
"corepack",
[
"pnpm",
"install",
"--frozen-lockfile",
"--lockfile-only",
"--ignore-scripts",
],
{
cwd: fixtureRoot,
encoding: "utf8",
},
);
if (result.status === 0) {
process.stderr.write("Tampered manifest unexpectedly passed frozen install.\n");
process.exitCode = 1;
} else {
process.stdout.write("Frozen lockfile mismatch fixture: rejected PASS\n");
}
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
+108
View File
@@ -0,0 +1,108 @@
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 { EN_MESSAGES, KO_MESSAGES, MESSAGE_CATALOGS } from "../src/presentation/i18n/catalog.ts";
const fixtureMode = process.argv.includes("--fixture");
const failures = [];
const sourceExtensions = /\.(?:js|jsx|mjs|ts|tsx|mts)$/;
const koreanLiteral = /[가-힣]/;
const rawFailureRender =
/(?<!\$)\{\s*(?:failure|error|response|backend)(?:\?\.|\.)[\w?.]*message\s*\}/;
/** @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 (sourceExtensions.test(entry.name)) result.push(target);
}
return result;
}
/** @param {string} template */
function placeholders(template) {
return [...template.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)]
.map((match) => match[1])
.sort();
}
if (!fixtureMode) {
const canonicalKeys = Object.keys(KO_MESSAGES).sort();
for (const [locale, catalog] of Object.entries(MESSAGE_CATALOGS)) {
const readableCatalog =
/** @type {Readonly<Record<string, string>>} */ (catalog);
const canonicalCatalog =
/** @type {Readonly<Record<string, string>>} */ (KO_MESSAGES);
const keys = Object.keys(catalog).sort();
if (JSON.stringify(keys) !== JSON.stringify(canonicalKeys)) {
failures.push(`${locale} catalog keys do not match ko-KR`);
}
for (const key of canonicalKeys) {
if (
JSON.stringify(placeholders(readableCatalog[key] ?? "")) !==
JSON.stringify(placeholders(canonicalCatalog[key] ?? ""))
) {
failures.push(`${locale}:${key} interpolation parameters do not match`);
}
}
}
if (Object.keys(EN_MESSAGES).length !== canonicalKeys.length) {
failures.push("en-US catalog key count does not match ko-KR");
}
}
const commonDirectories = [
"src/presentation/boundaries",
"src/presentation/components",
"src/presentation/design-system",
"src/presentation/forms",
"src/presentation/layouts",
"src/presentation/routes",
"src/presentation/templates",
];
const sources = fixtureMode
? await listSourceFiles("tests/fixtures/i18n/forbidden")
: (
await Promise.all(commonDirectories.map((directory) => listSourceFiles(directory)))
).flat();
for (const file of sources) {
const source = await readFile(file, "utf8");
if (koreanLiteral.test(source)) {
failures.push(`hardcoded common user-facing locale literal in ${file}`);
}
if (rawFailureRender.test(source)) {
failures.push(`raw backend/error message rendered in ${file}`);
}
if (source.includes("dangerouslySetInnerHTML")) {
failures.push(`untrusted HTML interpolation boundary in ${file}`);
}
}
const report = {
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
localeCount: Object.keys(MESSAGE_CATALOGS).length + 1,
messageKeyCount: Object.keys(KO_MESSAGES).length,
checkedFiles: sources.length,
failures,
passed: failures.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
? "artifacts/quality/i18n-fixture.json"
: "artifacts/quality/i18n.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(`I18n contract failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write(
`I18n contract: ${report.messageKeyCount} keys across ${report.localeCount} locales PASS\n`,
);
@@ -0,0 +1,93 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
scanOptionalRecipeSources,
validateRecipeCatalog,
} from "./lib/optional-recipes.mjs";
const catalog = JSON.parse(
await readFile("config/recipes/frontend-capability-recipes.json", "utf8"),
);
const packageDocument = JSON.parse(await readFile("package.json", "utf8"));
const cleanupCatalog = structuredClone(catalog);
cleanupCatalog.recipes.find(
/** @param {{id: string}} recipe */ (recipe) => recipe.id === "realtime",
).lifecycleMethods = [];
const dependencyCatalog = structuredClone(catalog);
dependencyCatalog.productionRuntimeDependencies = ["zustand"];
const workflowCatalog = structuredClone(catalog);
workflowCatalog.recipes.find(
/** @param {{id: string}} recipe */ (recipe) => recipe.id === "client-workflow",
).serverStatePolicy = "copied-server-state";
const sourceViolations = await scanOptionalRecipeSources(
"tests/fixtures/optional-recipes/forbidden",
{ scanProductionBoundary: false },
);
const productionViolations = await scanOptionalRecipeSources(
"tests/fixtures/optional-recipes/forbidden/production-import",
{ scanProductionBoundary: true },
);
sourceViolations.push(...productionViolations);
const ruleIds = new Set(sourceViolations.map(({ ruleId }) => ruleId));
const results = [
{
id: "cleanup-omission",
passed: validateRecipeCatalog(cleanupCatalog, packageDocument).some(
(violation) => violation === "realtime:CLEANUP_CONTRACT_MISSING",
),
},
{
id: "unselected-runtime-dependency",
passed: validateRecipeCatalog(dependencyCatalog, packageDocument).includes(
"UNSELECTED_RUNTIME_DEPENDENCY",
),
},
{
id: "server-state-policy",
passed: validateRecipeCatalog(workflowCatalog, packageDocument).includes(
"client-workflow:SERVER_STATE_DUPLICATION_POLICY",
),
},
{
id: "vendor-direct-import",
passed: ruleIds.has("VENDOR_IMPORT_OUTSIDE_ADAPTER"),
},
{
id: "credential-leak",
passed: ruleIds.has("CREDENTIAL_LEAK_PATH"),
},
{
id: "server-state-source-duplication",
passed: ruleIds.has("CLIENT_STORE_DUPLICATES_SERVER_STATE"),
},
{
id: "production-imports-recipe",
passed: ruleIds.has("PRODUCTION_IMPORTS_RECIPE"),
},
];
const report = {
schemaVersion: 1,
results,
passed: results.every(({ passed }) => passed),
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/optional-recipe-fixtures.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (!report.passed) {
process.stderr.write(
`Optional recipe negative fixtures failed: ${results
.filter(({ passed }) => !passed)
.map(({ id }) => id)
.join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Optional recipe negative fixtures: PASS (${results.length} forbidden cases rejected)\n`,
);
+74
View File
@@ -0,0 +1,74 @@
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import {
scanOptionalRecipeSources,
scanProductionBundle,
validateRecipeCatalog,
} from "./lib/optional-recipes.mjs";
/** @param {string} name @param {string} fallback */
const argument = (name, fallback) => {
const index = process.argv.indexOf(name);
return index === -1 ? fallback : process.argv[index + 1];
};
const catalogPath = argument(
"--catalog",
"config/recipes/frontend-capability-recipes.json",
);
const sourceRoot = argument("--source-root", "src");
const distRoot = argument("--dist-root", "dist");
const artifactPath = argument(
"--artifact",
"artifacts/quality/optional-recipes.json",
);
const requireDist = process.argv.includes("--require-dist");
const catalog = JSON.parse(await readFile(catalogPath, "utf8"));
const packageDocument = JSON.parse(await readFile("package.json", "utf8"));
const catalogViolations = validateRecipeCatalog(catalog, packageDocument);
const sourceViolations = await scanOptionalRecipeSources(sourceRoot);
const bundlePresent = await stat(`${distRoot}/.vite/manifest.json`)
.then(() => true)
.catch(() => false);
const bundleViolations = await scanProductionBundle(distRoot);
const violations = [
...catalogViolations.map((ruleId) => ({ ruleId, path: catalogPath })),
...sourceViolations,
...bundleViolations.map((path) => ({
ruleId: "UNSELECTED_RECIPE_IN_PRODUCTION_BUNDLE",
path,
})),
...(requireDist && !bundlePresent
? [{ ruleId: "PRODUCTION_BUNDLE_MISSING", path: distRoot }]
: []),
];
const report = {
schemaVersion: 1,
decisionId: "VD-10",
selectedCapabilities: [],
recipeCount: Array.isArray(catalog.recipes) ? catalog.recipes.length : 0,
productionRuntimeDependencies:
catalog.productionRuntimeDependencies ?? null,
bundleStatus: bundlePresent
? bundleViolations.length === 0
? "PASS"
: "FAIL"
: "NOT_BUILT",
violations,
passed: violations.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
if (violations.length > 0) {
process.stderr.write(
`Optional recipe contract failed:\n${violations
.map((violation) => `${violation.ruleId}: ${violation.path}`)
.join("\n")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Optional recipes: PASS (${report.recipeCount} recipe-only capabilities, bundle=${report.bundleStatus})\n`,
);
+340 -41
View File
@@ -1,13 +1,129 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import {
access,
mkdir,
readFile,
readdir,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
const governance = JSON.parse(
await readFile("config/contracts/registry-governance.json", "utf8"),
import {
canonicalizeRegistryValue,
diffRegistrySnapshots,
registrySnapshotDigest,
validateBreakingEvidence,
verifyRegistryBaselineApproval,
} from "./lib/registry-compatibility.mjs";
/** @param {string} name @param {string | undefined} fallback */
function argumentValue(name, fallback) {
const index = process.argv.indexOf(name);
return index >= 0 && process.argv[index + 1]
? process.argv[index + 1]
: fallback;
}
const defaultGovernancePath = "config/contracts/registry-governance.json";
const governancePath =
/** @type {string} */ (
argumentValue("--governance", defaultGovernancePath)
);
const artifactPath =
/** @type {string} */ (
argumentValue("--artifact", "artifacts/quality/registries.json")
);
const usesRepositoryBaseline =
governancePath === defaultGovernancePath &&
!process.argv.includes("--no-baseline");
const baselinePath = argumentValue(
"--baseline",
usesRepositoryBaseline
? "config/contracts/registry-baseline.json"
: undefined,
);
const approvalPath = argumentValue(
"--approval",
usesRepositoryBaseline
? "config/contracts/registry-baseline.approval.json"
: undefined,
);
const evidencePath = argumentValue(
"--compatibility-evidence",
usesRepositoryBaseline
? "config/contracts/registry-change-evidence.json"
: undefined,
);
const governance = JSON.parse(await readFile(governancePath, "utf8"));
const failures = [];
const owners = new Map();
const snapshots = [];
const rowsByRegistry = new Map();
const sourcesByRegistry = 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 TypeScript migration may replace the declared extension.
}
}
if (candidates.length > 1) {
failures.push(
`ambiguous registry source ${declaredPath}: ${candidates.join(", ")}`,
);
return null;
}
return candidates[0] ?? null;
}
/** @param {unknown} value */
function runtimeType(value) {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
if (Number.isInteger(value)) return "integer";
return typeof value;
}
/** @param {unknown} value @param {string} declaration */
function matchesDeclaredType(value, declaration) {
const actual = runtimeType(value);
return declaration
.split("|")
.some(
(candidate) =>
candidate === actual ||
(candidate === "number" && actual === "integer"),
);
}
/** @param {string} directory @returns {Promise<string[]>} */
async function filesBelow(directory) {
try {
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().filter((file) =>
/\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(file),
);
} catch {
return [];
}
}
for (const specification of governance.registries) {
if (owners.has(specification.registryId)) {
@@ -16,10 +132,11 @@ for (const specification of governance.registries) {
owners.set(specification.registryId, specification.owner);
let rows = specification.declaredRows;
const sourcePath = await resolveRegistrySource(specification.path);
try {
await access(specification.path);
if (!sourcePath) throw new Error("missing registry source");
const module = await import(
`${pathToFileURL(path.resolve(specification.path)).href}?registry-check=${Date.now()}`
`${pathToFileURL(path.resolve(sourcePath)).href}?registry-check=${Date.now()}`
);
rows = module[specification.exportName];
} catch {
@@ -31,6 +148,12 @@ for (const specification of governance.registries) {
continue;
}
rowsByRegistry.set(specification.registryId, rows);
sourcesByRegistry.set(
specification.registryId,
sourcePath ?? specification.path,
);
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`);
@@ -41,72 +164,248 @@ for (const specification of governance.registries) {
failures.push(`${specification.registryId}.${rowName} missing ${field}`);
}
}
for (const [field, declaredType] of Object.entries(
specification.fieldTypes ?? {},
)) {
if (
field in row &&
!matchesDeclaredType(row[field], String(declaredType))
) {
failures.push(
`${specification.registryId}.${rowName}.${field} expected ${declaredType}, received ${runtimeType(row[field])}`,
);
}
}
if (
specification.keyField &&
row[specification.keyField] !== rowName
) {
failures.push(
`${specification.registryId}.${rowName}.${specification.keyField} must match its registry key`,
);
}
}
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;
const identity = JSON.stringify(canonicalizeRegistryValue(value));
if (values.has(identity)) {
failures.push(
`${specification.registryId}.${rowName} duplicates ${field}=${String(value)} from ${values.get(identity)}`,
);
} else {
values.set(identity, 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])}`,
);
}
}
}
const contract = Object.freeze({
requiredFields: specification.requiredFields,
fieldTypes: specification.fieldTypes ?? {},
uniqueFields: specification.uniqueFields ?? [],
allowedValues: specification.allowedValues ?? {},
references: specification.references ?? [],
keyField: specification.keyField ?? null,
breakingFields: specification.breakingFields ?? [],
});
snapshots.push({
registryId: specification.registryId,
owner: specification.owner,
source: specification.path,
source: sourcePath ?? specification.path,
rowCount: Object.keys(rows).length,
rows,
contract,
rows: canonicalizeRegistryValue(rows),
});
}
const sourceFiles = [
for (const specification of governance.registries) {
const rows = rowsByRegistry.get(specification.registryId);
if (!rows) continue;
for (const reference of specification.references ?? []) {
const targetRows = rowsByRegistry.get(reference.registryId);
if (!targetRows) {
failures.push(
`${specification.registryId} references unknown registry ${reference.registryId}`,
);
continue;
}
const targetValues = new Set(
Object.values(targetRows)
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
.map((row) => row[reference.targetField])
.filter((value) => value !== undefined && value !== null),
);
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 &&
value !== null &&
!targetValues.has(value)
) {
failures.push(
`${specification.registryId}.${rowName}.${reference.field} references unknown ${reference.registryId}.${reference.targetField}=${String(value)}`,
);
}
}
}
for (const consumer of specification.consumers ?? []) {
try {
const source = await readFile(consumer.path, "utf8");
if (!source.includes(consumer.token)) {
failures.push(
`${specification.registryId} consumer ${consumer.path} is missing ${consumer.token}`,
);
}
} catch {
failures.push(
`${specification.registryId} consumer source is missing: ${consumer.path}`,
);
}
}
if (specification.consumerIdentityField) {
const consumerFiles = (
await Promise.all(
(specification.consumerDirectories ?? []).map(filesBelow),
)
).flat();
const sourcePath = sourcesByRegistry.get(specification.registryId);
const consumerText = (
await Promise.all(
consumerFiles
.filter((file) => file !== sourcePath)
.map((file) => readFile(file, "utf8")),
)
).join("\n");
const exemptions = new Set(specification.orphanExemptRows ?? []);
for (const [rowName, row] of Object.entries(rows)) {
if (
!row ||
typeof row !== "object" ||
Array.isArray(row) ||
exemptions.has(rowName)
) {
continue;
}
const identity = row[specification.consumerIdentityField];
if (
(typeof identity !== "string" &&
typeof identity !== "number") ||
!consumerText.includes(String(identity))
) {
failures.push(
`${specification.registryId}.${rowName} has no executable consumer for ${specification.consumerIdentityField}=${String(identity)}`,
);
}
}
}
}
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 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) {
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)$/.test(entry.name)) continue;
const content = await readFile(target, "utf8");
for (const sourceDirectory of sourceFiles) {
for (const file of await filesBelow(sourceDirectory)) {
const content = await readFile(file, "utf8");
for (const pattern of adHocPatterns) {
if (pattern.expression.test(content)) {
failures.push(`ad-hoc ${pattern.name} in ${target}`);
failures.push(`ad-hoc ${pattern.name} in ${file}`);
}
}
}
}
for (const sourceDirectory of sourceFiles) {
await scanDirectory(sourceDirectory);
const currentSnapshot =
/** @type {Readonly<Record<string, unknown>>} */ (
canonicalizeRegistryValue({
schemaVersion: 2,
registries: snapshots,
})
);
let baselineDigest = null;
let currentDigest = registrySnapshotDigest(currentSnapshot);
let compatibility =
/** @type {{impact: string, changes: readonly Record<string, unknown>[]}} */ ({
impact: "not-evaluated",
changes: [],
});
if (baselinePath && approvalPath && evidencePath) {
try {
const baseline = JSON.parse(await readFile(baselinePath, "utf8"));
const approval = JSON.parse(await readFile(approvalPath, "utf8"));
const approvalResult = verifyRegistryBaselineApproval(baseline, approval);
baselineDigest = approvalResult.actualDigest;
if (!approvalResult.passed) {
failures.push(
`registry baseline approval digest mismatch: approved=${approvalResult.approvedDigest} actual=${approvalResult.actualDigest}`,
);
}
compatibility = diffRegistrySnapshots(baseline, currentSnapshot);
const evidence = JSON.parse(await readFile(evidencePath, "utf8"));
const evidenceResult = validateBreakingEvidence(compatibility, evidence);
failures.push(...evidenceResult.failures);
} catch (error) {
failures.push(
`registry compatibility evidence unavailable: ${
error instanceof Error ? error.name : "unknown"
}`,
);
}
}
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/registries.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
compatibilityImpact: governance.compatibilityImpact.current,
failures,
registries: snapshots,
},
null,
2,
)}\n`,
);
const report = {
schemaVersion: 2,
generatedAt: new Date().toISOString(),
baselineDigest,
currentDigest,
compatibility,
failures,
registries: snapshots,
};
await mkdir(path.dirname(artifactPath), { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(report, 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`);
process.stdout.write(
`Registry governance: ${snapshots.length} registries PASS; compatibility=${compatibility.impact}\n`,
);
@@ -0,0 +1,63 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
diffRegistrySnapshots,
validateBreakingEvidence,
verifyRegistryBaselineApproval,
} from "./lib/registry-compatibility.mjs";
const fixtures = JSON.parse(
await readFile(
"tests/fixtures/registry/compatibility/semantic-diff.json",
"utf8",
),
);
const results = [];
for (const fixture of fixtures.cases) {
const actual = diffRegistrySnapshots(fixture.before, fixture.after);
results.push({
id: fixture.id,
expected: fixture.expected,
actual: actual.impact,
passed: actual.impact === fixture.expected,
});
}
const breaking = diffRegistrySnapshots(
fixtures.breakingEvidence.before,
fixtures.breakingEvidence.after,
);
const missingEvidence = validateBreakingEvidence(breaking, {
schemaVersion: 1,
changes: [],
});
results.push({
id: "breaking-evidence-required",
expected: false,
actual: missingEvidence.passed,
passed: !missingEvidence.passed,
});
const tamperedApproval = verifyRegistryBaselineApproval(
fixtures.tamperedApproval.snapshot,
fixtures.tamperedApproval.approval,
);
results.push({
id: "tampered-baseline-digest",
expected: false,
actual: tamperedApproval.passed,
passed: !tamperedApproval.passed,
});
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/registry-compatibility-fixtures.json",
`${JSON.stringify({ schemaVersion: 1, results }, null, 2)}\n`,
);
if (results.some((result) => !result.passed)) {
process.stderr.write("Registry compatibility fixture failed.\n");
process.exit(1);
}
process.stdout.write(
`Registry compatibility fixtures: ${results.length} PASS\n`,
);
+88
View File
@@ -0,0 +1,88 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
/** @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 policyPath = argumentValue(
"--policy",
"config/testing/risk-coverage.json",
);
const summaryPath = argumentValue(
"--summary",
"artifacts/tests/coverage/coverage-summary.json",
);
const artifactPath = argumentValue(
"--artifact",
"artifacts/quality/risk-coverage.json",
);
const policy = JSON.parse(await readFile(policyPath, "utf8"));
const summary = JSON.parse(await readFile(summaryPath, "utf8"));
const failures = [];
/** @type {Array<{
* scope: string,
* metric: string,
* threshold: number,
* received: number | undefined,
* passed: boolean
* }>} */
const results = [];
/**
* @param {string} scope
* @param {Record<string, {pct: number}>} actual
* @param {Record<string, number>} minimum
*/
function evaluate(scope, actual, minimum) {
for (const [metric, threshold] of Object.entries(minimum)) {
const received = actual?.[metric]?.pct;
const passed =
typeof received === "number" &&
Number.isFinite(received) &&
received >= threshold;
results.push({ scope, metric, threshold, received, passed });
if (!passed) {
failures.push(
`${scope}.${metric} expected >= ${threshold}, received ${String(received)}`,
);
}
}
}
evaluate("total", summary.total, policy.summary);
for (const modulePolicy of policy.criticalModules) {
const key = Object.keys(summary).find(
(candidate) =>
candidate !== "total" &&
candidate.replaceAll("\\", "/").endsWith(`/${modulePolicy.path}`),
);
if (!key) {
failures.push(`critical module missing from coverage: ${modulePolicy.path}`);
continue;
}
evaluate(modulePolicy.path, summary[key], modulePolicy.minimum);
}
const artifact = {
schemaVersion: 1,
policy: policyPath,
summary: summaryPath,
status: failures.length === 0 ? "PASS" : "FAIL",
results,
failures,
};
await mkdir(path.dirname(artifactPath), { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`);
if (failures.length > 0) {
process.stderr.write(`Risk coverage failed:\n- ${failures.join("\n- ")}\n`);
process.exit(1);
}
process.stdout.write(
`Risk coverage: PASS (${results.length} scoped thresholds)\n`,
);
+148
View File
@@ -0,0 +1,148 @@
import { mkdir, writeFile } from "node:fs/promises";
import {
diffDependencyInventories,
isValidSha512Integrity,
supplyChainDigest,
validateDependencyReview,
validateLicensePolicy,
validateVulnerabilityReport,
verifySupplyChainCoherence,
} from "./lib/supply-chain.mjs";
const integrity = `sha512-${Buffer.alloc(64, 1).toString("base64")}`;
const baseDependency = {
name: "base",
version: "1.0.0",
direct: false,
scope: "production",
optional: false,
license: "MIT",
integrity,
dependencies: [],
};
const directDependency = {
...baseDependency,
name: "new-direct",
direct: true,
};
const before = { dependencies: [baseDependency] };
const after = { dependencies: [baseDependency, directDependency] };
const diff = diffDependencyInventories(before, after);
const selfReview = validateDependencyReview(diff, after, {
changes: [
{
changeId: "add:new-direct@1.0.0",
owner: "same-person",
reviewer: "same-person",
reason: "fixture",
rollback: "remove",
},
],
});
const deniedLicense = validateLicensePolicy(
{
dependencies: [{ ...baseDependency, license: "AGPL-3.0" }],
},
{
allowedLicenses: ["MIT"],
deniedLicensePatterns: ["AGPL"],
},
);
const vulnerable = validateVulnerabilityReport(
{
provider: "fixture",
scannedLockfileSha256: "lock",
findings: [
{
id: "CVE-FIXTURE",
packageName: "base",
version: "1.0.0",
severity: "critical",
},
],
},
{ blockAtSeverity: "high" },
{
exceptions: [
{
vulnerabilityId: "CVE-FIXTURE",
packageName: "base",
owner: "owner",
reviewer: "reviewer",
reason: "expired fixture",
expiresAt: "2000-01-01T00:00:00.000Z",
},
],
},
"lock",
new Date("2026-07-26T00:00:00.000Z"),
);
const mismatchedCoherence = verifySupplyChainCoherence(
{
components: [],
metadata: {
properties: [{ name: "ca:lockfileSha256", value: "wrong" }],
},
},
{ dependencies: [baseDependency], lockfileSha256: "lock" },
{
subject: [{ digest: { sha256: "wrong" } }],
predicate: { materials: { lockfileSha256: "wrong" } },
},
"dist",
);
const orderingStable =
supplyChainDigest({ dependencies: [baseDependency, directDependency] }) ===
supplyChainDigest({ dependencies: [directDependency, baseDependency] });
const approvedDigest = supplyChainDigest(before);
const tamperedBaselineRejected =
approvedDigest !==
supplyChainDigest({
dependencies: [{ ...baseDependency, version: "9.9.9-tampered" }],
});
const providerFailure = validateVulnerabilityReport(
{
provider: "",
scannedLockfileSha256: "wrong",
findings: [],
},
{ blockAtSeverity: "high" },
{ exceptions: [] },
"lock",
);
const results = [
{
id: "transitive-removal-is-real-diff",
passed:
diffDependencyInventories(after, before).removed[0] ===
"new-direct@1.0.0",
},
{
id: "tampered-integrity-rejected",
passed: !isValidSha512Integrity("sha512-dGFtcGVyZWQ="),
},
{ id: "high-risk-self-approval-rejected", passed: !selfReview.passed },
{ id: "denied-license-rejected", passed: !deniedLicense.passed },
{
id: "critical-vulnerability-expired-exception-rejected",
passed: !vulnerable.passed,
},
{ id: "sbom-provenance-mismatch-rejected", passed: !mismatchedCoherence.passed },
{ id: "dependency-ordering-deterministic", passed: orderingStable },
{ id: "baseline-digest-tamper-rejected", passed: tamperedBaselineRejected },
{
id: "vulnerability-provider-evidence-invalid",
passed: !providerFailure.passed,
},
];
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/supply-chain-fixtures.json",
`${JSON.stringify({ schemaVersion: 1, results }, null, 2)}\n`,
);
if (results.some((result) => !result.passed)) {
process.stderr.write("Supply-chain negative fixture failed.\n");
process.exit(1);
}
process.stdout.write(`Supply-chain fixtures: ${results.length} PASS\n`);
@@ -0,0 +1,105 @@
import { spawnSync } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
const fixtureDirectory = path.resolve(".tmp/supply-chain-provider-fixture");
await rm(fixtureDirectory, { recursive: true, force: true });
await mkdir(fixtureDirectory, { recursive: true });
const inventory = JSON.parse(
await readFile("artifacts/release/dependency-inventory.json", "utf8"),
);
const verification = JSON.parse(
await readFile(
"artifacts/security/supply-chain-verification.json",
"utf8",
),
);
const vulnerabilityPath = path.join(
fixtureDirectory,
"vulnerability-report.json",
);
const attestationPath = path.join(fixtureDirectory, "attestation.json");
await writeFile(
vulnerabilityPath,
`${JSON.stringify(
{
schemaVersion: 1,
provider: "fixture-scanner",
scannedLockfileSha256: inventory.lockfileSha256,
generatedAt: "2026-07-26T00:00:00.000Z",
findings: [],
},
null,
2,
)}\n`,
);
await writeFile(
attestationPath,
`${JSON.stringify(
{
schemaVersion: 1,
provider: "fixture-attestor",
signer: "fixture-workload-identity",
subject: {
name: "dist",
digest: { sha256: verification.distSha256 },
},
},
null,
2,
)}\n`,
);
const providerRun = spawnSync(
"node",
["scripts/generate-supply-chain.mjs"],
{
env: {
...process.env,
VULNERABILITY_REPORT_PATH: vulnerabilityPath,
PROVENANCE_ATTESTATION_PATH: attestationPath,
},
encoding: "utf8",
},
);
let promotionStatus = "MISSING";
if (providerRun.status === 0) {
promotionStatus = JSON.parse(
await readFile(
"artifacts/security/supply-chain-verification.json",
"utf8",
),
).promotionStatus;
}
const restore = spawnSync(
"node",
["scripts/generate-supply-chain.mjs"],
{ encoding: "utf8" },
);
await rm(fixtureDirectory, { recursive: true, force: true });
const passed =
providerRun.status === 0 &&
promotionStatus === "PASS" &&
restore.status === 0;
await writeFile(
"artifacts/security/supply-chain-provider-fixtures.json",
`${JSON.stringify(
{
schemaVersion: 1,
providerAccepted: providerRun.status === 0,
promotionStatus,
unverifiedDefaultRestored: restore.status === 0,
status: passed ? "PASS" : "FAIL",
},
null,
2,
)}\n`,
);
if (!passed) {
process.stderr.write(
`Supply-chain provider fixture failed: ${providerRun.stderr || restore.stderr}\n`,
);
process.exit(1);
}
process.stdout.write(
"Supply-chain provider fixture: verified PASS and unconfigured default restored\n",
);
+167
View File
@@ -0,0 +1,167 @@
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
/** @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 sourceRoot = argumentValue("--source-root", "tests");
const artifactPath = argumentValue(
"--artifact",
"artifacts/quality/test-evidence.json",
);
const fixtureMode = sourceRoot !== "tests";
const failures = [];
const facts = {
scannedFiles: 0,
visualBaselines: 0,
sharedScenarios: 0,
};
/** @param {string} target @returns {Promise<string[]>} */
async function filesBelow(target) {
try {
const metadata = await stat(target);
if (metadata.isFile()) return [target];
const entries = await readdir(target, { withFileTypes: true });
const groups = await Promise.all(
entries.map((entry) => filesBelow(path.join(target, entry.name))),
);
return groups.flat();
} catch {
return [];
}
}
const sourceFiles = (await filesBelow(sourceRoot)).filter(
(file) => fixtureMode || !file.split(path.sep).includes("fixtures"),
);
for (const file of sourceFiles) {
if (!/\.(?:js|jsx|mjs|ts|tsx|fixture|txt)$/.test(file)) continue;
const source = await readFile(file, "utf8");
facts.scannedFiles += 1;
const skipPattern =
/\b(?:test|it|describe)(?:\.describe)?\.(?:skip|fixme)\s*\(/g;
if (skipPattern.test(source)) {
const quarantine =
/quarantine\(owner=[^)]+,\s*defect=[^)]+,\s*expires=\d{4}-\d{2}-\d{2}\)/;
if (!quarantine.test(source)) {
failures.push(`${file}: skip/fixme lacks owned expiring quarantine`);
}
}
const wholeUiMask =
/\bmask\s*:\s*\[[\s\S]{0,240}(?:locator|getByRole)\s*\(\s*["'](?:html|body|main|application|document)["']/i;
if (wholeUiMask.test(source)) {
failures.push(`${file}: screenshot mask may not cover the whole UI`);
}
}
if (!fixtureMode) {
const e2eConfig = await readFile("playwright.config.js", "utf8");
for (const token of [
"pnpm build",
"pnpm preview",
"reuseExistingServer: false",
'"junit"',
'trace: "retain-on-failure"',
'"chromium-compact"',
'"firefox"',
'"webkit"',
]) {
if (!e2eConfig.includes(token)) {
failures.push(`playwright.config.js missing release evidence token ${token}`);
}
}
const e2eFiles = (await filesBelow("tests/e2e")).filter((file) =>
/\.spec\.(?:js|ts)$/.test(file),
);
for (const file of e2eFiles) {
const source = await readFile(file, "utf8");
if (!source.includes("support/browser/strict-browser-test")) {
failures.push(`${file}: bypasses strict browser fixture`);
}
}
const scenarioCatalog = await readFile(
"tests/mocks/scenarios/catalog.ts",
"utf8",
);
const scenarioIdBlock =
scenarioCatalog.match(
/HTTP_SCENARIO_IDS\s*=\s*Object\.freeze\(\[([\s\S]*?)\]\s*as const\)/,
)?.[1] ?? "";
facts.sharedScenarios = (scenarioIdBlock.match(/"[^"]+"/g) ?? []).length;
if (facts.sharedScenarios < 19) {
failures.push("shared MSW catalog must retain all 19 failure scenarios");
}
const handler = await readFile(
"tests/mocks/handlers/reference-resources.ts",
"utf8",
);
if (
!handler.includes("assertOperationScenario") ||
!handler.includes("../scenarios/catalog.js")
) {
failures.push("MSW handler bypasses shared scenario catalog");
}
const baselineFiles = (await filesBelow("tests/visual/__snapshots__")).filter(
(file) => file.endsWith(".png"),
);
facts.visualBaselines = baselineFiles.length;
if (facts.visualBaselines < 4) {
failures.push("visual baseline requires at least four risk surfaces");
}
for (const required of [
"playwright.storybook.config.js",
"playwright.visual.config.js",
"tests/storybook/workshop.spec.ts",
"artifacts/tests/storybook/results.xml",
"artifacts/tests/visual/results.xml",
]) {
if ((await filesBelow(required)).length === 0) {
failures.push(`test evidence missing ${required}`);
}
}
const requiredBuiltFiles = [
"dist/index.html",
"dist/config.json",
"dist/release-manifest.json",
"dist/runtime-config.schema.json",
"dist/.vite/manifest.json",
];
for (const required of requiredBuiltFiles) {
if ((await filesBelow(required)).length === 0) {
failures.push(`built-dist contract missing ${required}`);
}
}
const sourceMaps = (await filesBelow("dist")).filter((file) =>
file.endsWith(".map"),
);
if (sourceMaps.length > 0) {
failures.push(`production dist contains source maps: ${sourceMaps.join(", ")}`);
}
}
const report = {
schemaVersion: 1,
sourceRoot,
status: failures.length === 0 ? "PASS" : "FAIL",
facts,
failures,
};
await mkdir(path.dirname(artifactPath), { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
if (failures.length > 0) {
process.stderr.write(`Test evidence failed:\n- ${failures.join("\n- ")}\n`);
process.exit(1);
}
process.stdout.write(
`Test evidence: PASS (${facts.scannedFiles} files, ${facts.visualBaselines} baselines, ${facts.sharedScenarios} scenarios)\n`,
);
+2
View File
@@ -123,6 +123,8 @@ async function drillChunkMismatch() {
failureKind: "DEPLOY_MISMATCH",
manifestLoaded: true,
currentBuildId: "build-a",
currentReleaseId: "release-a",
activeBuildId: "build-b",
activeReleaseId: "release-b",
storage,
};
+46 -2
View File
@@ -1,6 +1,13 @@
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);
@@ -8,12 +15,38 @@ 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");
const buildTime = process.env.SOURCE_DATE_EPOCH
? new Date(Number(process.env.SOURCE_DATE_EPOCH) * 1_000)
: new Date();
if (!Number.isFinite(buildTime.getTime())) {
throw new Error("SOURCE_DATE_EPOCH must be epoch seconds");
}
const builtAt = buildTime.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;
@@ -31,6 +64,8 @@ const manifest = {
outputs: {
directory: "dist",
viteManifest: "dist/.vite/manifest.json",
routeChunks,
runtimeConfigSchema: "dist/runtime-config.schema.json",
},
};
@@ -44,6 +79,7 @@ const releaseManifest = {
assetManifestHash,
releaseId,
builtAt,
routeChunks,
};
await mkdir("artifacts/release", { recursive: true });
@@ -52,6 +88,14 @@ 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`,
+425 -36
View File
@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { gzipSync } from "node:zlib";
import {
@@ -9,47 +10,404 @@ import {
} from "node:fs/promises";
import path from "node:path";
import {
diffDependencyInventories,
flattenPnpmDependencyTree,
isValidSha512Integrity,
parsePnpmLockfilePackages,
supplyChainDigest,
validateDependencyReview,
validateLicensePolicy,
validateVulnerabilityReport,
verifySupplyChainCoherence,
} from "./lib/supply-chain.mjs";
/** @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();
try {
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();
} catch {
return [];
}
}
/** @param {string} file */
async function sha256File(file) {
return createHash("sha256").update(await readFile(file)).digest("hex");
}
/** @param {string[]} files */
async function digestFileSet(files) {
const rows = await Promise.all(
files.sort().map(async (file) => ({
path: file.replaceAll("\\", "/"),
sha256: await sha256File(file),
})),
);
return supplyChainDigest(rows);
}
/** @param {string} file @returns {Promise<Record<string, unknown> | null>} */
async function optionalJson(file) {
try {
return JSON.parse(await readFile(file, "utf8"));
} catch {
return null;
}
}
export async function buildDependencyInventory() {
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
const lockfileSha256 = createHash("sha256")
.update(lockfileText)
.digest("hex");
const listed = spawnSync(
"corepack",
["pnpm", "list", "--json", "--depth", "Infinity"],
{
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
},
);
if (listed.status !== 0) {
throw new Error(`pnpm dependency graph failed: ${listed.stderr}`);
}
const roots = JSON.parse(listed.stdout);
const root = roots[0];
const flattened = await flattenPnpmDependencyTree(
root,
packageJson.dependencies ?? {},
packageJson.devDependencies ?? {},
);
const lockRows = parsePnpmLockfilePackages(lockfileText);
const lockByIdentity = new Map(
lockRows.map((row) => [`${row.name}@${row.version}`, row]),
);
const failures = [];
const dependencies = flattened.map((dependency) => {
const identity = `${dependency.name}@${dependency.version}`;
const lockRow = lockByIdentity.get(identity);
if (!lockRow) failures.push(`dependency missing from lockfile: ${identity}`);
if (lockRow && !isValidSha512Integrity(lockRow.integrity)) {
failures.push(`dependency has invalid sha512 integrity: ${identity}`);
}
return {
...dependency,
integrity: lockRow?.integrity ?? "missing",
};
});
const inventoryIds = new Set(
dependencies.map((dependency) => `${dependency.name}@${dependency.version}`),
);
for (const lockRow of lockRows) {
const identity = `${lockRow.name}@${lockRow.version}`;
if (!inventoryIds.has(identity)) {
failures.push(`transitive lockfile dependency omitted: ${identity}`);
}
}
if (failures.length > 0) {
throw new Error(failures.join("\n"));
}
return {
schemaVersion: 2,
packageManager: packageJson.packageManager,
lockfileSha256,
dependencyCount: dependencies.length,
directDependencyCount: dependencies.filter((entry) => entry.direct).length,
dependencies,
};
}
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const lockfile = await readFile("pnpm-lock.yaml");
const outputFiles = await filesWithin("dist");
if (outputFiles.length === 0) {
throw new Error("dist is missing; run the production build first");
}
const outputs = await Promise.all(
outputFiles.map(async (outputFile) => {
const content = await readFile(outputFile);
const metadata = await stat(outputFile);
return {
path: outputFile,
path: outputFile.replaceAll("\\", "/"),
bytes: metadata.size,
gzipBytes: gzipSync(content).byteLength,
sha256: createHash("sha256").update(content).digest("hex"),
};
}),
);
const distDigest = supplyChainDigest(
outputs.map(({ path: outputPath, bytes, sha256 }) => ({
path: outputPath,
bytes,
sha256,
})),
);
const inventory = await buildDependencyInventory();
const licensePolicy = JSON.parse(
await readFile("config/security/dependency-policy.json", "utf8"),
);
const licenseResult = validateLicensePolicy(inventory, licensePolicy);
const dependencies = {
...packageJson.dependencies,
...packageJson.devDependencies,
const baseline = await optionalJson(
"config/security/dependency-baseline.json",
);
const baselineApproval = await optionalJson(
"config/security/dependency-baseline.approval.json",
);
const dependencyEvidence = JSON.parse(
await readFile(
"config/security/dependency-change-evidence.json",
"utf8",
),
);
const skipsBaseline = process.argv.includes("--no-baseline");
const baselineFailures = [];
let dependencyDiff =
/** @type {ReturnType<typeof diffDependencyInventories>} */ ({
added: [],
removed: [],
changed: [],
upgrades: [],
});
let reviewResult =
/** @type {ReturnType<typeof validateDependencyReview>} */ ({
passed: skipsBaseline,
highRisk: [],
failures: skipsBaseline ? [] : ["dependency baseline unavailable"],
});
if (baseline && baselineApproval) {
const actualBaselineDigest = supplyChainDigest(baseline);
if (
baselineApproval.schemaVersion !== 1 ||
baselineApproval.snapshotDigest !== actualBaselineDigest ||
typeof baselineApproval.owner !== "string" ||
!baselineApproval.owner
) {
baselineFailures.push("dependency baseline approval digest mismatch");
}
dependencyDiff = diffDependencyInventories(baseline, inventory);
reviewResult = validateDependencyReview(
dependencyDiff,
inventory,
dependencyEvidence,
);
} else if (!skipsBaseline) {
baselineFailures.push("dependency baseline and approval are required");
}
const vulnerabilityPolicy = JSON.parse(
await readFile("config/security/vulnerability-policy.json", "utf8"),
);
const vulnerabilityExceptions = JSON.parse(
await readFile("config/security/vulnerability-exceptions.json", "utf8"),
);
const vulnerabilityInput = process.env.VULNERABILITY_REPORT_PATH
? await optionalJson(process.env.VULNERABILITY_REPORT_PATH)
: null;
const vulnerabilityResult = vulnerabilityInput
? validateVulnerabilityReport(
vulnerabilityInput,
vulnerabilityPolicy,
vulnerabilityExceptions,
inventory.lockfileSha256,
)
: {
passed: false,
failures: ["external vulnerability provider report is missing"],
blocking: [],
};
const vulnerabilityReport = {
schemaVersion: 1,
provider: vulnerabilityInput?.provider ?? "UNCONFIGURED",
scannedLockfileSha256:
vulnerabilityInput?.scannedLockfileSha256 ?? inventory.lockfileSha256,
status: vulnerabilityInput
? vulnerabilityResult.passed
? "PASS"
: "FAIL"
: "FAIL_UNVERIFIED",
findings: vulnerabilityInput?.findings ?? [],
exceptionsApplied:
vulnerabilityInput && vulnerabilityResult.passed
? vulnerabilityExceptions.exceptions
: [],
failures: vulnerabilityResult.failures,
blocking: vulnerabilityResult.blocking,
};
const sourceFiles = (
await Promise.all(
[
"src",
"scripts",
"config",
"public",
"schemas",
"package.json",
"pnpm-lock.yaml",
"vite.config.js",
].map(async (target) => {
try {
const metadata = await stat(target);
return metadata.isDirectory() ? filesWithin(target) : [target];
} catch {
return [];
}
}),
)
).flat();
const sourceSetSha256 = await digestFileSet(sourceFiles);
const components = inventory.dependencies.map((dependency) => ({
type: "library",
"bom-ref": `pkg:npm/${encodeURIComponent(dependency.name)}@${dependency.version}`,
name: dependency.name,
version: dependency.version,
scope: dependency.optional ? "optional" : "required",
hashes: [
{
alg: "SHA-512",
content: dependency.integrity.slice("sha512-".length),
},
],
licenses:
dependency.license === "NOASSERTION"
? [{ expression: "NOASSERTION" }]
: [{ expression: dependency.license }],
properties: [
{ name: "ca:direct", value: String(dependency.direct) },
{ name: "ca:scope", value: dependency.scope },
],
}));
const serialSeed = supplyChainDigest({
lockfileSha256: inventory.lockfileSha256,
components: components.map((component) => component["bom-ref"]),
});
const sbom = {
bomFormat: "CycloneDX",
specVersion: "1.6",
serialNumber: `urn:uuid:${serialSeed.slice(0, 8)}-${serialSeed.slice(8, 12)}-${serialSeed.slice(12, 16)}-${serialSeed.slice(16, 20)}-${serialSeed.slice(20, 32)}`,
version: 1,
metadata: {
component: {
type: "application",
name: packageJson.name,
version: packageJson.version,
},
properties: [
{
name: "ca:lockfileSha256",
value: inventory.lockfileSha256,
},
],
},
components,
dependencies: inventory.dependencies.map((dependency) => ({
ref: `pkg:npm/${encodeURIComponent(dependency.name)}@${dependency.version}`,
dependsOn: dependency.dependencies.map((identity) => {
const separator = identity.lastIndexOf("@");
return `pkg:npm/${encodeURIComponent(identity.slice(0, separator))}@${identity.slice(separator + 1)}`;
}),
})),
};
const provenance = {
_type: "https://in-toto.io/Statement/v1",
subject: [{ name: "dist", digest: { sha256: distDigest } }],
predicateType: "https://slsa.dev/provenance/v1",
predicate: {
buildDefinition: {
buildType: "https://vite.dev/build/v1",
externalParameters: {
nodeVersion: process.version,
packageManager: packageJson.packageManager,
},
internalParameters: {
sourceSetSha256,
},
resolvedDependencies: [
{
uri: "pnpm-lock.yaml",
digest: { sha256: inventory.lockfileSha256 },
},
],
},
runDetails: {
builder: { id: "local:clean-architecture-frontend-template" },
metadata: { invocationId: "LOCAL_UNSIGNED" },
},
materials: {
lockfileSha256: inventory.lockfileSha256,
sourceSetSha256,
sbomSha256: supplyChainDigest(sbom),
},
},
};
const coherence = verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
);
const attestationInput = process.env.PROVENANCE_ATTESTATION_PATH
? await optionalJson(process.env.PROVENANCE_ATTESTATION_PATH)
: null;
const attestationSubject =
/** @type {Record<string, unknown>} */ (
/** @type {Record<string, unknown>} */ (
attestationInput?.subject ?? {}
).digest ?? {}
);
const attestationPassed =
attestationSubject.sha256 === distDigest &&
typeof attestationInput?.provider === "string" &&
Boolean(attestationInput.provider) &&
typeof attestationInput?.signer === "string" &&
Boolean(attestationInput.signer);
const localFailures = [
...licenseResult.failures,
...baselineFailures,
...reviewResult.failures,
...coherence.failures,
];
if (vulnerabilityInput && !vulnerabilityResult.passed) {
localFailures.push(
...vulnerabilityResult.failures,
...vulnerabilityResult.blocking,
);
}
const localPassed = localFailures.length === 0;
const promotionPassed =
localPassed && vulnerabilityResult.passed && attestationPassed;
const verification = {
schemaVersion: 1,
localStatus: localPassed ? "PASS" : "FAIL",
promotionStatus: promotionPassed ? "PASS" : "FAIL_UNVERIFIED",
lockfileSha256: inventory.lockfileSha256,
sourceSetSha256,
distSha256: distDigest,
sbomSha256: supplyChainDigest(sbom),
dependencyDiff,
highRiskReview: reviewResult.highRisk,
vulnerabilityStatus: vulnerabilityReport.status,
provenanceAttestationStatus: attestationPassed
? "PASS"
: "FAIL_UNVERIFIED",
failures: localFailures,
};
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(
@@ -59,7 +417,8 @@ await writeFile(
context: {
nodeVersion: process.version,
packageManager: packageJson.packageManager,
runnerImage: process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
runnerImage:
process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
},
outputs,
},
@@ -67,36 +426,66 @@ await writeFile(
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`,
`${JSON.stringify(inventory, null, 2)}\n`,
);
await writeFile(
"artifacts/release/sbom.cdx.json",
`${JSON.stringify(sbom, null, 2)}\n`,
);
await writeFile(
"artifacts/release/provenance.json",
`${JSON.stringify(provenance, 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"),
schemaVersion: 2,
baselineDigest: baseline ? supplyChainDigest(baseline) : null,
currentDigest: supplyChainDigest(inventory),
...dependencyDiff,
highRisk: reviewResult.highRisk,
reviewFailures: reviewResult.failures,
},
null,
2,
)}\n`,
);
await writeFile(
"artifacts/security/license-report.json",
`${JSON.stringify(
{
schemaVersion: 1,
status: licenseResult.passed ? "PASS" : "FAIL",
dependencyCount: inventory.dependencyCount,
results: licenseResult.results,
failures: licenseResult.failures,
},
null,
2,
)}\n`,
);
await writeFile(
"artifacts/security/vulnerability-report.json",
`${JSON.stringify(vulnerabilityReport, null, 2)}\n`,
);
await writeFile(
"artifacts/security/supply-chain-verification.json",
`${JSON.stringify(verification, null, 2)}\n`,
);
if (!localPassed) {
process.stderr.write(
`Local supply-chain verification failed:\n- ${localFailures.join("\n- ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Supply chain: LOCAL PASS (${inventory.dependencyCount} dependencies); promotion=${verification.promotionStatus}\n`,
);
+5 -5
View File
@@ -1,8 +1,8 @@
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze([
"APP_HOME",
"SAMPLE_RESOURCE_LIST",
"NOT_FOUND",
]);
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",
+265
View File
@@ -0,0 +1,265 @@
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
export const REQUIRED_RECIPE_IDS = Object.freeze([
"analytics-error-sink",
"browser-permission",
"client-workflow",
"feature-flag",
"file-transfer",
"generated-api",
"large-data-ui",
"multi-tab",
"offline-indexeddb",
"realtime",
"service-worker-pwa",
"web-worker",
]);
const lifecycleRecipes = new Set([
"analytics-error-sink",
"browser-permission",
"client-workflow",
"file-transfer",
"generated-api",
"multi-tab",
"offline-indexeddb",
"realtime",
"service-worker-pwa",
"web-worker",
]);
/** @param {unknown} value */
function nonEmptyStrings(value) {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every((entry) => typeof entry === "string" && entry.trim().length > 0)
);
}
/**
* @param {unknown} input
* @param {Readonly<Record<string, unknown>>} packageDocument
* @returns {string[]}
*/
export function validateRecipeCatalog(input, packageDocument) {
const document =
/** @type {Record<string, any>} */ (
input && typeof input === "object" ? input : {}
);
/** @type {string[]} */
const violations = [];
if (document.schemaVersion !== 1) violations.push("CATALOG_SCHEMA_VERSION");
if (document.decisionId !== "VD-10") violations.push("CATALOG_DECISION");
if (document.defaultStatus !== "NOT_INSTALLED") {
violations.push("CATALOG_DEFAULT_MUST_BE_NOT_INSTALLED");
}
if (
!Array.isArray(document.productionRuntimeDependencies) ||
document.productionRuntimeDependencies.length > 0
) {
violations.push("UNSELECTED_RUNTIME_DEPENDENCY");
}
if (!nonEmptyStrings(document.vendorPackagePatterns)) {
violations.push("VENDOR_PATTERN_CATALOG");
}
if (!Array.isArray(document.recipes)) {
return [...violations, "RECIPE_CATALOG_MISSING"];
}
const actualIds = document.recipes
.map(/** @param {Record<string, unknown>} recipe */ (recipe) => recipe.id)
.sort();
if (JSON.stringify(actualIds) !== JSON.stringify(REQUIRED_RECIPE_IDS)) {
violations.push("RECIPE_ID_SET");
}
if (new Set(actualIds).size !== actualIds.length) {
violations.push("RECIPE_ID_DUPLICATE");
}
for (const recipe of document.recipes) {
const id = typeof recipe.id === "string" ? recipe.id : "unknown";
if (recipe.status !== "RECIPE_AVAILABLE") {
violations.push(`${id}:STATUS_MUST_NOT_CLAIM_INSTALLED`);
}
for (const field of [
"trigger",
"boundary",
"port",
"fake",
"owner",
"fallback",
"serverStatePolicy",
]) {
if (typeof recipe[field] !== "string" || recipe[field].trim().length === 0) {
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
}
}
for (const field of [
"forbiddenWhen",
"failureKinds",
"securityPrivacy",
"removal",
]) {
if (!nonEmptyStrings(recipe[field])) {
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
}
}
if (
!Number.isInteger(recipe.bundleBudgetGzipBytes) ||
recipe.bundleBudgetGzipBytes < 1
) {
violations.push(`${id}:INVALID_BUNDLE_BUDGET`);
}
if (recipe.owner === "frontend-platform") {
violations.push(`${id}:PROJECT_OWNER_NOT_ASSIGNED`);
}
if (
lifecycleRecipes.has(id) &&
!nonEmptyStrings(recipe.lifecycleMethods)
) {
violations.push(`${id}:CLEANUP_CONTRACT_MISSING`);
}
if (
id === "client-workflow" &&
recipe.serverStatePolicy !== "reference-only"
) {
violations.push(`${id}:SERVER_STATE_DUPLICATION_POLICY`);
}
}
const dependencies = {
.../** @type {Record<string, string>} */ (packageDocument.dependencies ?? {}),
.../** @type {Record<string, string>} */ (
packageDocument.devDependencies ?? {}
),
};
for (const pattern of document.vendorPackagePatterns ?? []) {
const wildcard = String(pattern).endsWith("*");
const prefix = String(pattern).replace(/\/?\*$/, "");
if (
Object.keys(dependencies).some(
(dependency) =>
dependency === prefix ||
dependency.startsWith(`${prefix}/`) ||
(wildcard && dependency.startsWith(prefix)),
)
) {
violations.push(`UNSELECTED_VENDOR_INSTALLED:${prefix}`);
}
}
return violations;
}
/** @param {string} directory @returns {Promise<string[]>} */
export async function sourceFiles(directory) {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
) {
return [];
}
throw error;
}
const groups = await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory()
? sourceFiles(target)
: /\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)
? [target]
: [];
}),
);
return groups.flat();
}
/**
* @param {string} root
* @param {{scanProductionBoundary?: boolean}} [options]
*/
export async function scanOptionalRecipeSources(
root,
{ scanProductionBoundary = true } = {},
) {
/** @type {Array<{ruleId: string; path: string}>} */
const violations = [];
for (const file of await sourceFiles(root)) {
const relative = path.relative(process.cwd(), file).replaceAll("\\", "/");
const relativeToRoot = path.relative(root, file).replaceAll("\\", "/");
const content = await readFile(file, "utf8");
const imports = [
...content.matchAll(
/(?:from\s*|import\s*\(\s*)["']([^"']+)["']/g,
),
].map((match) => match[1]);
if (
scanProductionBoundary &&
(relativeToRoot.startsWith("src/") ||
(path.basename(path.resolve(root)) === "src" &&
!relativeToRoot.startsWith(".."))) &&
imports.some((specifier) =>
/(?:^|\/)recipes\/frontend-capabilities(?:\/|$)/.test(specifier),
)
) {
violations.push({ ruleId: "PRODUCTION_IMPORTS_RECIPE", path: relative });
}
const localVendorAdapter =
relative.includes("recipes/") && relative.includes("/adapters/");
if (
!localVendorAdapter &&
imports.some((specifier) =>
/^(?:@launchdarkly\/|@sentry\/|@opentelemetry\/|@openapitools\/openapi-generator-cli$|@reduxjs\/toolkit$|@tanstack\/react-virtual$|@uppy\/|firebase(?:\/|$)|idb$|react-window$|redux(?:\/|$)|socket\.io-client$|tus-js-client$|workbox-window$|xstate$|zustand$)/.test(
specifier,
),
)
) {
violations.push({ ruleId: "VENDOR_IMPORT_OUTSIDE_ADAPTER", path: relative });
}
if (
/localStorage\s*\.\s*(?:setItem|getItem)\s*\([^)]*(?:credential|password|secret|token)/is.test(
content,
) ||
/searchParams\s*\.\s*set\s*\(\s*["'](?:credential|password|secret|token)/is.test(
content,
) ||
/(?:record|track|emit)\s*\(\s*\{[\s\S]{0,400}(?:credential|password|secret|token)\s*:/i.test(
content,
)
) {
violations.push({ ruleId: "CREDENTIAL_LEAK_PATH", path: relative });
}
if (
/(?:createStore|configureStore|create\s*\()\s*\([\s\S]{0,600}(?:apiResponse|queryData|serverState)\s*:/i.test(
content,
)
) {
violations.push({ ruleId: "CLIENT_STORE_DUPLICATES_SERVER_STATE", path: relative });
}
}
return violations;
}
/** @param {string} distRoot */
export async function scanProductionBundle(distRoot) {
/** @type {string[]} */
const violations = [];
for (const file of await sourceFiles(distRoot)) {
const content = await readFile(file, "utf8");
if (content.includes("frontend-optional-recipe-must-not-reach-production")) {
violations.push(path.relative(process.cwd(), file));
}
}
return violations;
}
+321
View File
@@ -0,0 +1,321 @@
import { createHash } from "node:crypto";
export const COMPATIBILITY_IMPACTS = Object.freeze([
"none",
"additive",
"behavior-change",
"breaking",
]);
const impactRank = new Map(
COMPATIBILITY_IMPACTS.map((impact, index) => [impact, index]),
);
/** @param {unknown} value @returns {unknown} */
export function canonicalizeRegistryValue(value) {
if (Array.isArray(value)) {
const projected =
/** @type {unknown[]} */ (value.map(canonicalizeRegistryValue));
return projected.every(
(item) =>
item === null ||
["string", "number", "boolean"].includes(typeof item),
)
? projected.sort((left, right) =>
JSON.stringify(left).localeCompare(JSON.stringify(right)),
)
: projected;
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalizeRegistryValue(item)]),
);
}
return value;
}
/** @param {unknown} value @returns {string} */
export function canonicalRegistryJson(value) {
return JSON.stringify(canonicalizeRegistryValue(value)) ?? "undefined";
}
/** @param {unknown} snapshot */
export function registrySnapshotDigest(snapshot) {
return createHash("sha256")
.update(canonicalRegistryJson(snapshot))
.digest("hex");
}
/** @param {string} current @param {string} candidate */
function strongestImpact(current, candidate) {
return (impactRank.get(candidate) ?? 0) > (impactRank.get(current) ?? 0)
? candidate
: current;
}
/** @param {unknown} value */
function valueType(value) {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
return typeof value;
}
/**
* @param {string} registryId
* @param {string} rowName
* @param {string} field
* @param {string} kind
*/
function changeId(registryId, rowName, field, kind) {
return `${registryId}:${rowName}:${field}:${kind}`;
}
/**
* Calculates a semantic diff. Object key and primitive-array ordering is
* canonicalized before comparison and therefore cannot create a false change.
*
* @param {Readonly<Record<string, unknown>>} before
* @param {Readonly<Record<string, unknown>>} after
*/
export function diffRegistrySnapshots(before, after) {
const changes = /** @type {Array<Record<string, unknown>>} */ ([]);
let impact = "none";
const beforeRegistries =
/** @type {Map<string, Record<string, unknown>>} */ (new Map(
/** @type {Array<Record<string, unknown>>} */ (before.registries ?? []).map(
(registry) => [String(registry.registryId), registry],
),
));
const afterRegistries =
/** @type {Map<string, Record<string, unknown>>} */ (new Map(
/** @type {Array<Record<string, unknown>>} */ (after.registries ?? []).map(
(registry) => [String(registry.registryId), registry],
),
));
const registryIds = new Set([
...beforeRegistries.keys(),
...afterRegistries.keys(),
]);
for (const registryId of [...registryIds].sort()) {
const previous = beforeRegistries.get(registryId);
const current = afterRegistries.get(registryId);
if (!previous || !current) {
const changeImpact = previous ? "breaking" : "additive";
impact = strongestImpact(impact, changeImpact);
changes.push({
changeId: changeId(registryId, "*", "*", previous ? "removed" : "added"),
registryId,
rowName: "*",
field: "*",
kind: previous ? "registry-removed" : "registry-added",
impact: changeImpact,
});
continue;
}
const previousContract =
/** @type {Record<string, unknown>} */ (previous.contract ?? {});
const currentContract =
/** @type {Record<string, unknown>} */ (current.contract ?? {});
const contractFields = new Set([
...Object.keys(previousContract),
...Object.keys(currentContract),
]);
for (const field of [...contractFields].sort()) {
const beforeHas = Object.hasOwn(previousContract, field);
const afterHas = Object.hasOwn(currentContract, field);
const beforeValue = previousContract[field];
const afterValue = currentContract[field];
if (
beforeHas &&
afterHas &&
canonicalRegistryJson(beforeValue) ===
canonicalRegistryJson(afterValue)
) {
continue;
}
const kind = !beforeHas
? "contract-field-added"
: !afterHas
? "contract-field-removed"
: "contract-field-changed";
impact = strongestImpact(impact, "breaking");
changes.push({
changeId: changeId(registryId, "$contract", field, kind),
registryId,
rowName: "$contract",
field,
kind,
impact: "breaking",
before: canonicalizeRegistryValue(beforeValue),
after: canonicalizeRegistryValue(afterValue),
});
}
const breakingFields = new Set(
/** @type {string[]} */ (
currentContract.breakingFields ?? []
),
);
const beforeRows =
/** @type {Record<string, Record<string, unknown>>} */ (
previous.rows ?? {}
);
const afterRows =
/** @type {Record<string, Record<string, unknown>>} */ (current.rows ?? {});
const rowNames = new Set([
...Object.keys(beforeRows),
...Object.keys(afterRows),
]);
for (const rowName of [...rowNames].sort()) {
const beforeRow = beforeRows[rowName];
const afterRow = afterRows[rowName];
if (!beforeRow || !afterRow) {
const changeImpact = beforeRow ? "breaking" : "additive";
impact = strongestImpact(impact, changeImpact);
changes.push({
changeId: changeId(
registryId,
rowName,
"*",
beforeRow ? "removed" : "added",
),
registryId,
rowName,
field: "*",
kind: beforeRow ? "row-removed" : "row-added",
impact: changeImpact,
});
continue;
}
const fields = new Set([
...Object.keys(beforeRow),
...Object.keys(afterRow),
]);
for (const field of [...fields].sort()) {
const beforeHas = Object.hasOwn(beforeRow, field);
const afterHas = Object.hasOwn(afterRow, field);
const beforeValue = beforeRow[field];
const afterValue = afterRow[field];
if (
beforeHas &&
afterHas &&
canonicalRegistryJson(beforeValue) ===
canonicalRegistryJson(afterValue)
) {
continue;
}
let kind;
let changeImpact;
if (!beforeHas) {
kind = "field-added";
changeImpact = "additive";
} else if (!afterHas) {
kind = "field-removed";
changeImpact = "breaking";
} else if (valueType(beforeValue) !== valueType(afterValue)) {
kind = "field-type-changed";
changeImpact = "breaking";
} else if (
Array.isArray(beforeValue) &&
Array.isArray(afterValue) &&
beforeValue.some(
(item) =>
!afterValue.some(
(candidate) =>
canonicalRegistryJson(candidate) ===
canonicalRegistryJson(item),
),
)
) {
kind = "allowed-value-removed";
changeImpact = "breaking";
} else {
kind = "field-changed";
changeImpact = breakingFields.has(field)
? "breaking"
: "behavior-change";
}
impact = strongestImpact(impact, changeImpact);
changes.push({
changeId: changeId(registryId, rowName, field, kind),
registryId,
rowName,
field,
kind,
impact: changeImpact,
before: canonicalizeRegistryValue(beforeValue),
after: canonicalizeRegistryValue(afterValue),
});
}
}
}
return Object.freeze({
impact,
changes: Object.freeze(changes),
});
}
/**
* @param {Readonly<Record<string, unknown>>} snapshot
* @param {Readonly<Record<string, unknown>>} approval
*/
export function verifyRegistryBaselineApproval(snapshot, approval) {
const actualDigest = registrySnapshotDigest(snapshot);
const approvedDigest = approval.snapshotDigest;
return Object.freeze({
passed:
approval.schemaVersion === 1 &&
typeof approval.owner === "string" &&
approval.owner.length > 0 &&
typeof approval.approvedAt === "string" &&
approvedDigest === actualDigest,
actualDigest,
approvedDigest:
typeof approvedDigest === "string" ? approvedDigest : "missing",
});
}
/**
* @param {ReturnType<typeof diffRegistrySnapshots>} diff
* @param {Readonly<Record<string, unknown>>} evidenceFile
*/
export function validateBreakingEvidence(diff, evidenceFile) {
const evidence = new Map(
/** @type {Array<Record<string, unknown>>} */ (
evidenceFile.changes ?? []
).map((entry) => [entry.changeId, entry]),
);
const failures = [];
for (const change of diff.changes.filter(
(entry) => entry.impact === "breaking",
)) {
const entry = evidence.get(change.changeId);
if (!entry) {
failures.push(`breaking change missing evidence: ${change.changeId}`);
continue;
}
for (const field of [
"versionBump",
"migration",
"compatibilityWindow",
"rollback",
"owner",
]) {
if (typeof entry[field] !== "string" || entry[field].trim().length === 0) {
failures.push(
`breaking change ${change.changeId} missing non-empty ${field}`,
);
}
}
}
return Object.freeze({
passed: failures.length === 0,
failures: Object.freeze(failures),
});
}
+547
View File
@@ -0,0 +1,547 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
/** @param {unknown} value @returns {unknown} */
export function canonicalizeSupplyChainValue(value) {
if (Array.isArray(value)) {
return value
.map(canonicalizeSupplyChainValue)
.sort((left, right) =>
JSON.stringify(left).localeCompare(JSON.stringify(right)),
);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalizeSupplyChainValue(item)]),
);
}
return value;
}
/** @param {unknown} value */
export function supplyChainDigest(value) {
return createHash("sha256")
.update(JSON.stringify(canonicalizeSupplyChainValue(value)))
.digest("hex");
}
/** @param {string} lockfile */
export function parsePnpmLockfilePackages(lockfile) {
const entries =
/** @type {Array<{name: string, version: string, integrity: string}>} */ (
[]
);
let inPackages = false;
/** @type {{name: string, version: string, integrity: string} | null} */
let current = null;
for (const line of lockfile.split(/\r?\n/)) {
if (line === "packages:") {
inPackages = true;
continue;
}
if (line === "snapshots:") {
if (current) entries.push(current);
break;
}
if (!inPackages) continue;
const packageMatch = line.match(/^ {2}(\S.*):$/);
if (packageMatch) {
if (current) entries.push(current);
const key = packageMatch[1].replace(/^['"]|['"]$/g, "");
const separator = key.lastIndexOf("@");
current = {
name: key.slice(0, separator),
version: key.slice(separator + 1),
integrity: "",
};
continue;
}
const integrityMatch = line.match(/\bintegrity:\s*([^,}\s]+)/);
if (current && integrityMatch) {
current.integrity = integrityMatch[1];
}
}
return entries.sort((left, right) =>
`${left.name}@${left.version}`.localeCompare(
`${right.name}@${right.version}`,
),
);
}
/** @param {string} integrity */
export function isValidSha512Integrity(integrity) {
if (!integrity.startsWith("sha512-")) return false;
try {
return Buffer.from(integrity.slice("sha512-".length), "base64").length === 64;
} catch {
return false;
}
}
/**
* @param {unknown} raw
* @returns {string}
*/
export function normalizeLicense(raw) {
if (typeof raw === "string" && raw.trim()) return raw.trim();
if (
raw &&
typeof raw === "object" &&
"type" in raw &&
typeof raw.type === "string"
) {
return raw.type;
}
if (Array.isArray(raw)) {
const licenses = raw.map(normalizeLicense).filter(
(license) => license !== "NOASSERTION",
);
return licenses.length > 0 ? licenses.join(" OR ") : "NOASSERTION";
}
return "NOASSERTION";
}
/**
* @param {Record<string, unknown>} root
* @param {Readonly<Record<string, string>>} directProduction
* @param {Readonly<Record<string, string>>} directDevelopment
*/
export async function flattenPnpmDependencyTree(
root,
directProduction,
directDevelopment,
) {
const records =
/** @type {Map<string, {
* name: string,
* version: string,
* direct: boolean,
* scope: "production" | "development",
* optional: boolean,
* packagePath: string,
* dependencies: Set<string>
* }>} */ (new Map());
const directIds = new Set();
for (const [name, rawDependency] of Object.entries(
/** @type {Record<string, unknown>} */ (root.dependencies ?? {}),
)) {
if (
Object.hasOwn(directProduction, name) &&
rawDependency &&
typeof rawDependency === "object" &&
!Array.isArray(rawDependency)
) {
directIds.add(
`${name}@${String(
/** @type {Record<string, unknown>} */ (rawDependency).version ?? "",
)}`,
);
}
}
for (const [name, rawDependency] of Object.entries(
/** @type {Record<string, unknown>} */ (root.devDependencies ?? {}),
)) {
if (
Object.hasOwn(directDevelopment, name) &&
rawDependency &&
typeof rawDependency === "object" &&
!Array.isArray(rawDependency)
) {
directIds.add(
`${name}@${String(
/** @type {Record<string, unknown>} */ (rawDependency).version ?? "",
)}`,
);
}
}
/**
* @param {Record<string, unknown>} node
* @param {"production" | "development"} scope
* @param {boolean} optionalPath
*/
function visit(node, scope, optionalPath) {
for (const [groupName, group] of Object.entries({
dependencies: node.dependencies,
devDependencies: node.devDependencies,
optionalDependencies: node.optionalDependencies,
})) {
if (!group || typeof group !== "object" || Array.isArray(group)) continue;
for (const [name, rawDependency] of Object.entries(group)) {
if (
!rawDependency ||
typeof rawDependency !== "object" ||
Array.isArray(rawDependency)
) {
continue;
}
const dependency =
/** @type {Record<string, unknown>} */ (rawDependency);
const version = String(dependency.version ?? "");
const packagePath = String(dependency.path ?? "");
const identity = `${name}@${version}`;
const childScope =
scope === "production" && groupName !== "devDependencies"
? "production"
: "development";
const childOptional =
optionalPath || groupName === "optionalDependencies";
const previous = records.get(identity);
const dependencies = previous?.dependencies ?? new Set();
for (const childGroup of [
dependency.dependencies,
dependency.optionalDependencies,
]) {
if (
!childGroup ||
typeof childGroup !== "object" ||
Array.isArray(childGroup)
) {
continue;
}
for (const [childName, rawChild] of Object.entries(childGroup)) {
if (
rawChild &&
typeof rawChild === "object" &&
!Array.isArray(rawChild)
) {
dependencies.add(
`${childName}@${String(rawChild.version ?? "")}`,
);
}
}
}
records.set(identity, {
name,
version,
direct: directIds.has(identity),
scope:
previous?.scope === "production" || childScope === "production"
? "production"
: "development",
optional: previous ? previous.optional && childOptional : childOptional,
packagePath: previous?.packagePath || packagePath,
dependencies,
});
visit(dependency, childScope, childOptional);
}
}
}
const productionRoot = {
dependencies: Object.fromEntries(
Object.entries(
/** @type {Record<string, unknown>} */ (root.dependencies ?? {}),
).filter(([name]) => Object.hasOwn(directProduction, name)),
),
};
const developmentRoot = {
devDependencies: Object.fromEntries(
Object.entries(
/** @type {Record<string, unknown>} */ (root.devDependencies ?? {}),
).filter(([name]) => Object.hasOwn(directDevelopment, name)),
),
};
visit(productionRoot, "production", false);
visit(developmentRoot, "development", false);
const result = [];
for (const record of records.values()) {
let license = "NOASSERTION";
let optional = record.optional;
if (record.packagePath) {
try {
const manifest = JSON.parse(
await readFile(`${record.packagePath}/package.json`, "utf8"),
);
license = normalizeLicense(manifest.license ?? manifest.licenses);
} catch {
// Platform-specific optional packages may not be materialized locally.
optional = true;
}
}
result.push({
name: record.name,
version: record.version,
direct: record.direct,
scope: record.scope,
optional,
license,
dependencies: [...record.dependencies].sort(),
});
}
return result.sort((left, right) =>
`${left.name}@${left.version}`.localeCompare(
`${right.name}@${right.version}`,
),
);
}
/**
* @param {Readonly<Record<string, unknown>>} before
* @param {Readonly<Record<string, unknown>>} after
*/
export function diffDependencyInventories(before, after) {
const beforeRows =
/** @type {Array<Record<string, unknown>>} */ (before.dependencies ?? []);
const afterRows =
/** @type {Array<Record<string, unknown>>} */ (after.dependencies ?? []);
const beforeMap = new Map(
beforeRows.map((row) => [`${row.name}@${row.version}`, row]),
);
const afterMap = new Map(
afterRows.map((row) => [`${row.name}@${row.version}`, row]),
);
const added = [...afterMap.keys()].filter((key) => !beforeMap.has(key));
const removed = [...beforeMap.keys()].filter((key) => !afterMap.has(key));
const changed = [];
for (const key of [...beforeMap.keys()].filter((item) => afterMap.has(item))) {
if (
supplyChainDigest(beforeMap.get(key)) !==
supplyChainDigest(afterMap.get(key))
) {
changed.push(key);
}
}
const upgrades = [];
for (const removedKey of removed) {
const previous = beforeMap.get(removedKey);
const replacement = added.find(
(addedKey) => afterMap.get(addedKey)?.name === previous?.name,
);
if (replacement) {
upgrades.push({
name: previous?.name,
from: previous?.version,
to: afterMap.get(replacement)?.version,
});
}
}
return Object.freeze({
added: Object.freeze(added.sort()),
removed: Object.freeze(removed.sort()),
changed: Object.freeze(changed.sort()),
upgrades: Object.freeze(
upgrades.sort((left, right) =>
String(left.name).localeCompare(String(right.name)),
),
),
});
}
/**
* @param {Readonly<Record<string, unknown>>} inventory
* @param {Readonly<Record<string, unknown>>} policy
*/
export function validateLicensePolicy(inventory, policy) {
const allowed = new Set(
/** @type {string[]} */ (policy.allowedLicenses ?? []),
);
const denied = /** @type {string[]} */ (policy.deniedLicensePatterns ?? []);
const failures = [];
const results = [];
for (const dependency of /** @type {Array<Record<string, unknown>>} */ (
inventory.dependencies ?? []
)) {
const license = String(dependency.license ?? "NOASSERTION");
const explicitlyDenied = denied.some((pattern) =>
new RegExp(pattern, "i").test(license),
);
const unknownAccepted =
license === "NOASSERTION" && dependency.optional === true;
const passed =
!explicitlyDenied && (allowed.has(license) || unknownAccepted);
results.push({
package: `${dependency.name}@${dependency.version}`,
license,
passed,
reason: unknownAccepted ? "platform-optional-not-materialized" : null,
});
if (!passed) {
failures.push(
`${dependency.name}@${dependency.version} has disallowed license ${license}`,
);
}
}
return Object.freeze({
passed: failures.length === 0,
failures: Object.freeze(failures),
results: Object.freeze(results),
});
}
/**
* @param {ReturnType<typeof diffDependencyInventories>} diff
* @param {Readonly<Record<string, unknown>>} inventory
* @param {Readonly<Record<string, unknown>>} evidenceFile
*/
export function validateDependencyReview(diff, inventory, evidenceFile) {
const rows =
/** @type {Array<Record<string, unknown>>} */ (inventory.dependencies ?? []);
const byIdentity = new Map(
rows.map((row) => [`${row.name}@${row.version}`, row]),
);
const evidence = new Map(
/** @type {Array<Record<string, unknown>>} */ (
evidenceFile.changes ?? []
).map((entry) => [entry.changeId, entry]),
);
const highRisk = diff.added.filter((identity) => {
const row = byIdentity.get(identity);
return row?.direct === true && row.scope === "production";
});
const failures = [];
for (const identity of highRisk) {
const changeId = `add:${identity}`;
const entry = evidence.get(changeId);
if (!entry) {
failures.push(`high-risk dependency missing review: ${changeId}`);
continue;
}
for (const field of ["owner", "reviewer", "reason", "rollback"]) {
if (typeof entry[field] !== "string" || !entry[field].trim()) {
failures.push(`${changeId} missing ${field}`);
}
}
if (entry.owner === entry.reviewer) {
failures.push(`${changeId} may not be self-approved`);
}
}
return Object.freeze({
passed: failures.length === 0,
highRisk: Object.freeze(highRisk),
failures: Object.freeze(failures),
});
}
const severityRank = new Map([
["unknown", 0],
["low", 1],
["moderate", 2],
["high", 3],
["critical", 4],
]);
/**
* @param {Readonly<Record<string, unknown>>} report
* @param {Readonly<Record<string, unknown>>} policy
* @param {Readonly<Record<string, unknown>>} exceptionFile
* @param {string} lockfileSha256
* @param {Date} [now]
*/
export function validateVulnerabilityReport(
report,
policy,
exceptionFile,
lockfileSha256,
now = new Date(),
) {
const failures = [];
if (report.scannedLockfileSha256 !== lockfileSha256) {
failures.push("vulnerability report lockfile digest mismatch");
}
if (typeof report.provider !== "string" || !report.provider.trim()) {
failures.push("vulnerability report provider missing");
}
const threshold = severityRank.get(String(policy.blockAtSeverity)) ?? 3;
const exceptions =
/** @type {Array<Record<string, unknown>>} */ (
exceptionFile.exceptions ?? []
);
const blocking = [];
for (const finding of /** @type {Array<Record<string, unknown>>} */ (
report.findings ?? []
)) {
const severity = String(finding.severity ?? "unknown").toLowerCase();
if ((severityRank.get(severity) ?? 0) < threshold) continue;
const exception = exceptions.find(
(entry) =>
entry.vulnerabilityId === finding.id &&
entry.packageName === finding.packageName,
);
const expiry =
typeof exception?.expiresAt === "string"
? Date.parse(exception.expiresAt)
: Number.NaN;
const validException =
exception &&
typeof exception.owner === "string" &&
exception.owner.trim() &&
typeof exception.reviewer === "string" &&
exception.reviewer.trim() &&
exception.owner !== exception.reviewer &&
typeof exception.reason === "string" &&
exception.reason.trim() &&
Number.isFinite(expiry) &&
expiry > now.getTime();
if (!validException) {
blocking.push(
`${finding.id}:${finding.packageName}@${finding.version}:${severity}`,
);
}
}
return Object.freeze({
passed: failures.length === 0 && blocking.length === 0,
failures: Object.freeze(failures),
blocking: Object.freeze(blocking),
});
}
/**
* @param {Readonly<Record<string, unknown>>} sbom
* @param {Readonly<Record<string, unknown>>} inventory
* @param {Readonly<Record<string, unknown>>} provenance
* @param {string} distDigest
*/
export function verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
) {
const failures = [];
const componentCount = Array.isArray(sbom.components)
? sbom.components.length
: -1;
const dependencyCount = Array.isArray(inventory.dependencies)
? inventory.dependencies.length
: -2;
if (componentCount !== dependencyCount) {
failures.push("SBOM component count does not match inventory");
}
const metadata =
/** @type {Record<string, unknown>} */ (sbom.metadata ?? {});
const properties =
/** @type {Array<{name?: string, value?: string}>} */ (
metadata.properties ?? []
);
if (properties.find(
/** @param {{name?: string, value?: string}} property */
(property) =>
property.name === "ca:lockfileSha256" &&
property.value === inventory.lockfileSha256,
) === undefined) {
failures.push("SBOM lockfile digest does not match inventory");
}
const subject =
/** @type {Array<Record<string, unknown>>} */ (provenance.subject ?? [])[0];
const subjectDigest =
/** @type {Record<string, unknown>} */ (subject?.digest ?? {});
if (subjectDigest.sha256 !== distDigest) {
failures.push("provenance subject does not match built dist digest");
}
const predicate =
/** @type {Record<string, unknown>} */ (provenance.predicate ?? {});
const materials =
/** @type {Record<string, unknown>} */ (predicate.materials ?? {});
if (materials.lockfileSha256 !== inventory.lockfileSha256) {
failures.push("provenance lockfile material does not match inventory");
}
return Object.freeze({
passed: failures.length === 0,
failures: Object.freeze(failures),
});
}
+149 -44
View File
@@ -1,10 +1,37 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
const scanRoots = ["src", "dist"];
const findings = /** @type {Array<{ruleId: string, file: string}>} */ ([]);
/** @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 policyPath = argumentValue(
"--policy",
"config/security/secret-scan-policy.json",
);
const artifactPath = argumentValue(
"--artifact",
"artifacts/security/scan.sarif",
);
const policy = JSON.parse(await readFile(policyPath, "utf8"));
const findings =
/** @type {Array<{
* ruleId: string,
* file: string,
* line: number,
* fingerprint: string
* }>} */ ([]);
const policyFailures = [];
const patterns = [
{ id: "private-key", expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g },
{
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 },
{
@@ -14,35 +41,101 @@ const patterns = [
},
];
/** @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();
/** @param {string} target @returns {Promise<string[]>} */
async function filesWithin(target) {
try {
const metadata = await stat(target);
if (metadata.isFile()) return [target];
const entries = await readdir(target, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => {
const child = path.join(target, entry.name);
return entry.isDirectory() ? filesWithin(child) : [child];
}),
));
return nested.flat();
} catch {
return [];
}
}
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 excluded = new Set(
/** @type {string[]} */ (policy.excludedPaths ?? []).map((entry) =>
entry.replaceAll("\\", "/"),
),
);
const allowlist =
/** @type {Array<{
* path: string,
* ruleId: string,
* owner: string,
* reason: string,
* expiresAt: string
* }>} */ (policy.allowlist ?? []);
for (const entry of allowlist) {
const expiry = Date.parse(entry.expiresAt);
if (
!entry.path.startsWith("tests/") ||
!entry.owner?.trim() ||
!entry.reason?.trim() ||
!Number.isFinite(expiry) ||
expiry <= Date.now()
) {
policyFailures.push(
`invalid or expired secret allowlist entry: ${entry.path}:${entry.ruleId}`,
);
}
}
const roots = [
...(/** @type {string[]} */ (policy.trackedRoots ?? [])),
...(/** @type {string[]} */ (policy.generatedRoots ?? [])),
];
const scanFiles = (
await Promise.all(roots.map((root) => filesWithin(root)))
).flat();
for (const scanFile of [...new Set(scanFiles)].sort()) {
const normalized = scanFile.replaceAll("\\", "/");
if (
[...excluded].some(
(entry) => normalized === entry || normalized.startsWith(`${entry}/`),
) ||
/\.(?:png|jpe?g|gif|webp|woff2?|zip|gz|sarif)$/i.test(normalized)
) {
continue;
}
let content;
try {
content = await readFile(scanFile, "utf8");
} catch {
continue;
}
for (const pattern of patterns) {
pattern.expression.lastIndex = 0;
for (const match of content.matchAll(pattern.expression)) {
const isAllowed = allowlist.some(
(entry) =>
entry.path === normalized &&
entry.ruleId === pattern.id &&
Date.parse(entry.expiresAt) > Date.now(),
);
if (isAllowed) continue;
const prefix = content.slice(0, match.index);
findings.push({
ruleId: pattern.id,
file: normalized,
line: prefix.split(/\r?\n/).length,
fingerprint: createHash("sha256")
.update(`${pattern.id}:${normalized}:${String(match.index)}`)
.digest("hex"),
});
}
}
}
const sarif = {
version: "2.1.0",
$schema:
"https://json.schemastore.org/sarif-2.1.0.json",
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
runs: [
{
tool: {
@@ -54,29 +147,41 @@ const sarif = {
})),
},
},
results: findings.map((finding) => ({
ruleId: finding.ruleId,
message: { text: "Potential secret material must be removed." },
locations: [
{
physicalLocation: {
artifactLocation: { uri: finding.file },
},
results: [
...findings.map((finding) => ({
ruleId: finding.ruleId,
message: {
text: "Potential secret material must be removed.",
},
],
})),
partialFingerprints: {
primaryLocationLineHash: finding.fingerprint,
},
locations: [
{
physicalLocation: {
artifactLocation: { uri: finding.file },
region: { startLine: finding.line },
},
},
],
})),
...policyFailures.map((failure) => ({
ruleId: "invalid-allowlist",
message: { text: failure },
})),
],
},
],
};
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`);
await mkdir(path.dirname(artifactPath), { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(sarif, null, 2)}\n`);
if (findings.length > 0 || policyFailures.length > 0) {
process.stderr.write(
`Security scan found ${findings.length + policyFailures.length} blocking result(s).\n`,
);
process.exit(1);
}
process.stdout.write("Source and built-asset secret scan: PASS\n");
process.stdout.write(
`Tracked source, config, built asset and artifact secret scan: PASS (${scanFiles.length} files)\n`,
);
+47
View File
@@ -0,0 +1,47 @@
import { createReadStream } from "node:fs";
import { access, stat } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
const root = path.resolve(process.argv[2] ?? "artifacts/storybook/static");
const port = Number(process.argv[3] ?? 6006);
const contentTypes = /** @type {Readonly<Record<string, string>>} */ ({
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
});
await access(root);
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
const decoded = decodeURIComponent(url.pathname);
const requested = path.resolve(root, `.${decoded}`);
if (requested !== root && !requested.startsWith(`${root}${path.sep}`)) {
response.writeHead(403).end();
return;
}
const details = await stat(requested).catch(() => null);
const file = details?.isDirectory()
? path.join(requested, "index.html")
: requested;
await access(file);
response.writeHead(200, {
"Content-Type":
contentTypes[path.extname(file)] ?? "application/octet-stream",
"Cache-Control": "no-store",
});
createReadStream(file).pipe(response);
} catch {
response.writeHead(404).end();
}
});
server.listen(port, "127.0.0.1", () => {
process.stdout.write(`Static evidence server: ${root} on ${port}\n`);
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => server.close(() => process.exit(0)));
}
+113
View File
@@ -0,0 +1,113 @@
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/optional-recipe-removal");
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
const copyTargets = [
"src",
"tests",
"recipes",
"scripts",
"config",
"public",
"index.html",
"package.json",
"tsconfig.base.json",
"tsconfig.json",
"tsconfig.app.json",
"tsconfig.node.json",
"tsconfig.test.json",
"tsconfig.recipes.json",
"vite.config.js",
"vitest.config.js",
"playwright.config.js",
"eslint.config.js",
".dependency-cruiser.cjs",
];
/** @param {string} script */
function runPnpm(script) {
return (
spawnSync(process.execPath, [pnpmCli, script], {
cwd: fixtureRoot,
stdio: "inherit",
}).status === 0
);
}
/** @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();
}
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");
await rm(path.join(fixtureRoot, "recipes"), { recursive: true, force: true });
await rm(path.join(fixtureRoot, "tests/recipes"), {
recursive: true,
force: true,
});
const checks = [
["typecheck", runPnpm("check:types")],
["architecture", runPnpm("check:architecture")],
["test", runPnpm("test:all")],
["build", runPnpm("build")],
];
/** @type {string[]} */
const residue = [];
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 (content.includes("frontend-optional-recipe-must-not-reach-production")) {
residue.push(path.relative(fixtureRoot, file));
}
}
checks.push(["bundle-residue", residue.length === 0]);
const passed = checks.every(([, result]) => result);
await mkdir("artifacts/tests", { recursive: true });
await writeFile(
"artifacts/tests/optional-recipe-removal.xml",
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="optional-recipe-removal" tests="${checks.length}" failures="${passed ? 0 : 1}">` +
checks
.map(
([name, result]) =>
`<testcase name="${name}">${result ? "" : `<failure>${residue.join(", ")}</failure>`}</testcase>`,
)
.join("") +
`</testsuite>\n`,
);
await rm(fixtureRoot, { recursive: true, force: true });
if (!passed) {
process.stderr.write(
`Optional recipe removal failed: ${checks
.filter(([, result]) => !result)
.map(([name]) => name)
.join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Optional recipe removal: PASS (${checks.length} base checks)\n`,
);
+8 -1
View File
@@ -6,6 +6,7 @@ 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",
@@ -66,8 +67,14 @@ try {
}).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: "샘플 리소스" }).click();
await page.getByRole("link", { name: targetLabel }).click();
await page.getByRole("heading", { name: "세션이 필요합니다." }).waitFor();
const namedInteractionMs = performance.now() - interactionStarted;
const paint = await page.evaluate(
+237 -45
View File
@@ -1,78 +1,270 @@
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
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/sample-removal");
const sampleRoot = path.resolve("src/sample/contract-fixture");
const sourceRoot = path.resolve("src");
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",
"tests/e2e/reference-route.spec.js",
"tests/mocks",
];
const copyTargets = [
"src",
"tests",
"recipes",
"scripts",
"config",
"public",
"index.html",
"package.json",
"tsconfig.base.json",
"tsconfig.json",
"tsconfig.app.json",
"tsconfig.node.json",
"tsconfig.test.json",
"tsconfig.recipes.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";
import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.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 SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY;
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({});
}
`;
const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
"ko-KR": Object.freeze({}),
"en-US": Object.freeze({}),
});
`;
/** @param {string} directory @returns {Promise<string[]>} */
async function sourceFiles(directory) {
async function filesBelow(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
const groups = await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? sourceFiles(target) : [target];
return entry.isDirectory() ? filesBelow(target) : [target];
}),
));
return nested.flat();
);
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");
const incomingImports = [];
for (const sourceFile of await sourceFiles(sourceRoot)) {
if (sourceFile.startsWith(sampleRoot)) continue;
const content = await readFile(sourceFile, "utf8");
if (/from\s+["'][^"']*sample\/contract-fixture/.test(content)) {
incomingImports.push(path.relative(".", sourceFile));
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,
);
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-messages.js"),
emptyMessages,
);
const governanceFile = path.join(
fixtureRoot,
"config/contracts/registry-governance.json",
);
const removalGovernance = JSON.parse(await readFile(governanceFile, "utf8"));
removalGovernance.registries = removalGovernance.registries.map(
/** @param {Record<string, unknown>} registry */
(registry) => ({
...registry,
...(Array.isArray(registry.consumers)
? {
consumers: registry.consumers.filter(
/** @param {{path?: string}} consumer */
(consumer) =>
!consumer.path?.includes("features/reference-feature"),
),
}
: {}),
...(Array.isArray(registry.consumerDirectories)
? {
consumerDirectories: registry.consumerDirectories.filter(
/** @param {string} directory */
(directory) =>
!directory.includes("features/reference-feature"),
),
}
: {}),
}),
);
await writeFile(
governanceFile,
`${JSON.stringify(removalGovernance, null, 2)}\n`,
);
/** @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);
}
}
}
let buildStatus = 1;
if (incomingImports.length === 0) {
await cp("src", path.join(fixtureRoot, "src"), {
recursive: true,
filter: (source) => !source.startsWith(sampleRoot),
});
await cp("public", path.join(fixtureRoot, "public"), { recursive: true });
await cp("index.html", path.join(fixtureRoot, "index.html"));
await cp("vite.config.js", path.join(fixtureRoot, "vite.config.js"));
const result = spawnSync(
process.execPath,
[
pnpmCli,
"exec",
"vite",
"build",
fixtureRoot,
"--outDir",
path.join(fixtureRoot, "dist"),
],
{ stdio: "inherit" },
);
buildStatus = result.status ?? 1;
const checks = [
["typecheck", runPnpm("check:types")],
["architecture", runPnpm("check:architecture")],
["registry-structure", runPnpm("check:registries:structure")],
["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 });
const passed = incomingImports.length === 0 && buildStatus === 0;
await writeFile(
"artifacts/tests/sample-removal.xml",
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="sample-removal" tests="2" failures="${passed ? 0 : 1}">` +
`<testcase name="no-product-import"/>` +
`<testcase name="production-build">${passed ? "" : "<failure/>"}</testcase>` +
`<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(
`Sample removal failed. Incoming imports: ${incomingImports.join(", ")}\n`,
`Reference feature removal failed: ${failures.join(", ")}; residue: ${[...residue, ...builtResidue].join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write("Sample removal smoke: PASS\n");
process.stdout.write(
`Reference feature removal: PASS (${checks.length} checks, no fixture IDs)\n`,
);
+47
View File
@@ -0,0 +1,47 @@
import { spawnSync } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { supplyChainDigest } from "./lib/supply-chain.mjs";
const owner = process.env.DEPENDENCY_BASELINE_OWNER;
const reason = process.env.DEPENDENCY_BASELINE_REASON;
if (!owner?.trim() || !reason?.trim()) {
process.stderr.write(
"DEPENDENCY_BASELINE_OWNER and DEPENDENCY_BASELINE_REASON are required.\n",
);
process.exit(2);
}
const commands = /** @type {Array<[string, string[]]>} */ ([
["corepack", ["pnpm", "build"]],
["node", ["scripts/generate-supply-chain.mjs", "--no-baseline"]],
]);
for (const [command, args] of commands) {
const result = spawnSync(command, args, { stdio: "inherit" });
if (result.status !== 0) process.exit(result.status ?? 1);
}
const inventory = JSON.parse(
await readFile("artifacts/release/dependency-inventory.json", "utf8"),
);
await writeFile(
"config/security/dependency-baseline.json",
`${JSON.stringify(inventory, null, 2)}\n`,
);
await writeFile(
"config/security/dependency-baseline.approval.json",
`${JSON.stringify(
{
schemaVersion: 1,
snapshotDigest: supplyChainDigest(inventory),
owner,
reason,
approvedAt: new Date().toISOString(),
},
null,
2,
)}\n`,
);
process.stdout.write(
`Dependency baseline approved: ${inventory.dependencyCount} packages\n`,
);
+41
View File
@@ -0,0 +1,41 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { registrySnapshotDigest } from "./lib/registry-compatibility.mjs";
const inputPath =
process.argv[2] ?? "artifacts/quality/registry-current-snapshot.json";
const outputPath =
process.argv[3] ?? "config/contracts/registry-baseline.json";
const owner = process.env.REGISTRY_BASELINE_OWNER;
const reason = process.env.REGISTRY_BASELINE_REASON;
if (!owner || !reason) {
process.stderr.write(
"REGISTRY_BASELINE_OWNER and REGISTRY_BASELINE_REASON are required.\n",
);
process.exit(1);
}
const input = JSON.parse(await readFile(inputPath, "utf8"));
const snapshot = input.registries
? { schemaVersion: 2, registries: input.registries }
: input;
const digest = registrySnapshotDigest(snapshot);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`);
await writeFile(
"config/contracts/registry-baseline.approval.json",
`${JSON.stringify(
{
schemaVersion: 1,
snapshotDigest: digest,
owner,
reason,
approvedAt: new Date().toISOString(),
},
null,
2,
)}\n`,
);
process.stdout.write(`Registry baseline updated: ${digest}\n`);
+67 -1
View File
@@ -6,6 +6,10 @@ 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 {{
@@ -34,7 +38,17 @@ const fixturesDocument =
);
const release = JSON.parse(await readFile("dist/release-manifest.json", "utf8"));
const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8"));
const viteManifest = await readFile("dist/.vite/manifest.json");
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");
@@ -52,6 +66,58 @@ if (!Number.isFinite(Date.parse(release.builtAt))) {
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({
+76
View File
@@ -0,0 +1,76 @@
import { spawnSync } from "node:child_process";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { supplyChainDigest } from "./lib/supply-chain.mjs";
/** @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();
}
async function distDigest() {
const rows = await Promise.all(
(await filesWithin("dist")).map(async (file) => ({
path: path.relative("dist", file).replaceAll("\\", "/"),
bytes: (await readFile(file)).byteLength,
content: supplyChainDigest(await readFile(file)),
})),
);
return supplyChainDigest(rows);
}
function build(environment = process.env) {
return spawnSync("corepack", ["pnpm", "build"], {
env: environment,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
});
}
const deterministicEnvironment = {
...process.env,
SOURCE_DATE_EPOCH: "946684800",
};
const firstBuild = build(deterministicEnvironment);
const firstDigest = firstBuild.status === 0 ? await distDigest() : "BUILD_FAILED";
const secondBuild = build(deterministicEnvironment);
const secondDigest =
secondBuild.status === 0 ? await distDigest() : "BUILD_FAILED";
const restoreBuild = build();
const passed =
firstBuild.status === 0 &&
secondBuild.status === 0 &&
restoreBuild.status === 0 &&
firstDigest === secondDigest;
await mkdir("artifacts/release", { recursive: true });
await writeFile(
"artifacts/release/reproducible-build.json",
`${JSON.stringify(
{
schemaVersion: 1,
sourceDateEpoch: deterministicEnvironment.SOURCE_DATE_EPOCH,
firstDigest,
secondDigest,
restored: restoreBuild.status === 0,
status: passed ? "PASS" : "FAIL",
},
null,
2,
)}\n`,
);
if (!passed) {
process.stderr.write(
`Reproducible build failed: first=${firstDigest} second=${secondDigest}\n`,
);
process.exit(1);
}
process.stdout.write(`Reproducible build: PASS (${firstDigest})\n`);
+121
View File
@@ -0,0 +1,121 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import {
isValidSha512Integrity,
parsePnpmLockfilePackages,
supplyChainDigest,
verifySupplyChainCoherence,
} from "./lib/supply-chain.mjs";
/** @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 inventory = JSON.parse(
await readFile("artifacts/release/dependency-inventory.json", "utf8"),
);
const sbom = JSON.parse(
await readFile("artifacts/release/sbom.cdx.json", "utf8"),
);
const provenance = JSON.parse(
await readFile("artifacts/release/provenance.json", "utf8"),
);
const verification = JSON.parse(
await readFile(
"artifacts/security/supply-chain-verification.json",
"utf8",
),
);
const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
const lockfileSha256 = createHash("sha256")
.update(lockfileText)
.digest("hex");
const outputs = await Promise.all(
(await filesWithin("dist")).map(async (file) => {
const content = await readFile(file);
return {
path: file.replaceAll("\\", "/"),
bytes: (await stat(file)).size,
sha256: createHash("sha256").update(content).digest("hex"),
};
}),
);
const distDigest = supplyChainDigest(outputs);
const coherence = verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
);
const failures = [...coherence.failures];
if (
inventory.lockfileSha256 !== lockfileSha256 ||
verification.lockfileSha256 !== lockfileSha256
) {
failures.push("inventory/verification lockfile digest mismatch");
}
if (
verification.distSha256 !== distDigest ||
verification.sbomSha256 !== supplyChainDigest(sbom)
) {
failures.push("verification digest set is incoherent");
}
const lockRows = parsePnpmLockfilePackages(lockfileText);
const inventoryRows =
/** @type {Array<Record<string, unknown>>} */ (
inventory.dependencies ?? []
);
const inventoryByIdentity = new Map(
inventoryRows.map((entry) => [
`${entry.name}@${entry.version}`,
entry,
]),
);
if (lockRows.length !== inventoryRows.length) {
failures.push("transitive dependency count differs from lockfile");
}
for (const lockRow of lockRows) {
const identity = `${lockRow.name}@${lockRow.version}`;
const dependency = inventoryByIdentity.get(identity);
if (
!dependency ||
dependency.integrity !== lockRow.integrity ||
!isValidSha512Integrity(lockRow.integrity)
) {
failures.push(`lockfile inventory integrity mismatch: ${identity}`);
}
}
const report = {
schemaVersion: 1,
status: failures.length === 0 ? "PASS" : "FAIL",
dependencyCount: inventoryRows.length,
lockfileSha256,
distSha256: distDigest,
sbomSha256: supplyChainDigest(sbom),
failures,
};
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/supply-chain-coherence.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(
`Supply-chain artifact coherence failed:\n- ${failures.join("\n- ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Supply-chain artifact coherence: PASS (${inventoryRows.length} dependencies)\n`,
);
+30
View File
@@ -0,0 +1,30 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
const verification = JSON.parse(
await readFile(
"artifacts/security/supply-chain-verification.json",
"utf8",
),
);
const passed = verification.promotionStatus === "PASS";
const report = {
schemaVersion: 1,
status: passed ? "PASS" : "FAIL_UNVERIFIED",
vulnerabilityStatus: verification.vulnerabilityStatus,
provenanceAttestationStatus:
verification.provenanceAttestationStatus,
lockfileSha256: verification.lockfileSha256,
distSha256: verification.distSha256,
};
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/promotion-verification.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (!passed) {
process.stderr.write(
"Supply-chain promotion is FAIL_UNVERIFIED: external vulnerability and signed provenance evidence are required.\n",
);
process.exit(1);
}
process.stdout.write("Supply-chain promotion evidence: PASS\n");
+3 -1
View File
@@ -1,5 +1,7 @@
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",
@@ -7,7 +9,7 @@ await writeFile(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
scope: ["APP_HOME", "SAMPLE_RESOURCE_LIST", "NOT_FOUND"],
scope: MANUAL_A11Y_ROUTE_IDS,
threshold: { critical: 0, serious: 0 },
automatedStatus: "passed",
manualReview: "see artifacts/tests/a11y-manual/report.json",

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