Compare commits

...
246 changed files with 26243 additions and 1176 deletions
+18
View File
@@ -23,6 +23,24 @@ module.exports = {
from: { path: "^src/presentation/(?!adapters/query)" }, from: { path: "^src/presentation/(?!adapters/query)" },
to: { path: "^(src/(adapters|bootstrap)|@tanstack)" }, 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", name: "adapters-do-not-know-presentation",
severity: "error", severity: "error",
+3
View File
@@ -10,4 +10,7 @@ artifacts/**/*.xml
artifacts/**/*.txt artifacts/**/*.txt
artifacts/**/*.sarif artifacts/**/*.sarif
artifacts/tests/e2e/ artifacts/tests/e2e/
artifacts/storybook/
artifacts/tests/storybook/
artifacts/tests/visual/
!artifacts/**/.gitkeep !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;
+1 -1
View File
@@ -15,4 +15,4 @@ M5 Error association: pending
M6 Color signal: pending M6 Color signal: pending
M7 Reduced motion: pending M7 Reduced motion: pending
Screen reader: pending Screen reader: pending
Notes: Review the text-field error association and modal focus containment/restoration. 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,4 +1,4 @@
# SAMPLE_RESOURCE_LIST accessibility review # REFERENCE_RESOURCE_LIST accessibility review
Status: pending-manual-review Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_LIST Route ID: REFERENCE_RESOURCE_LIST
@@ -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.
+114 -19
View File
@@ -58,7 +58,10 @@
"gates": { "gates": {
"FE-GATE-001": { "FE-GATE-001": {
"name": "manifest-lockfile", "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", "logPath": "artifacts/quality/install.txt",
"evidence": ["artifacts/quality/install.txt"], "evidence": ["artifacts/quality/install.txt"],
"retentionClass": "merge-cycle" "retentionClass": "merge-cycle"
@@ -74,13 +77,19 @@
"name": "typecheck", "name": "typecheck",
"steps": [ "steps": [
{ "script": "check:types", "expect": "pass" }, { "script": "check:types", "expect": "pass" },
{ "script": "check:types:recipes", "expect": "pass" },
{ "script": "check:types:fixture", "expect": "fail" }, { "script": "check:types:fixture", "expect": "fail" },
{ "script": "check:types:fixture:ts-port", "expect": "fail" }, { "script": "check:types:fixture:ts-port", "expect": "fail" },
{ "script": "check:types:fixture:ts-result", "expect": "fail" }, { "script": "check:types:fixture:ts-result", "expect": "fail" },
{ "script": "check:types:fixture:application-output", "expect": "fail" }, { "script": "check:types:fixture:application-output", "expect": "fail" },
{ "script": "check:types:fixture:application-input", "expect": "fail" }, { "script": "check:types:fixture:application-input", "expect": "fail" },
{ "script": "check:types:fixture:async-overlay", "expect": "fail" }, { "script": "check:types:fixture:async-overlay", "expect": "fail" },
{ "script": "check:types:fixture:route-runtime", "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", "logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"], "evidence": ["artifacts/quality/check-types.txt"],
@@ -95,9 +104,19 @@
}, },
"FE-GATE-005": { "FE-GATE-005": {
"name": "unit", "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", "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" "retentionClass": "merge-cycle"
}, },
"FE-GATE-006": { "FE-GATE-006": {
@@ -111,20 +130,37 @@
"name": "integration", "name": "integration",
"steps": [ "steps": [
{ "script": "test:integration", "expect": "pass" }, { "script": "test:integration", "expect": "pass" },
{ "script": "test:reference-feature", "expect": "pass" } { "script": "test:reference-feature", "expect": "pass" },
{ "script": "test:recipes", "expect": "pass" }
], ],
"logPath": "artifacts/quality/gates/FE-GATE-007.txt", "logPath": "artifacts/quality/gates/FE-GATE-007.txt",
"evidence": [ "evidence": [
"artifacts/tests/integration.xml", "artifacts/tests/integration.xml",
"artifacts/tests/reference-feature.xml" "artifacts/tests/reference-feature.xml",
"artifacts/tests/optional-recipes.xml"
], ],
"retentionClass": "merge-cycle" "retentionClass": "merge-cycle"
}, },
"FE-GATE-008": { "FE-GATE-008": {
"name": "e2e", "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", "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" "retentionClass": "merge-cycle"
}, },
"FE-GATE-009": { "FE-GATE-009": {
@@ -150,14 +186,37 @@
"name": "architecture", "name": "architecture",
"steps": [ "steps": [
{ "script": "check:architecture", "expect": "pass" }, { "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", "expect": "pass" },
{
"script": "check:registries:compatibility-fixtures",
"expect": "pass"
},
{ "script": "check:registries:baseline-fixture", "expect": "fail" },
{ "script": "check:registries:fixture", "expect": "fail" }, { "script": "check:registries:fixture", "expect": "fail" },
{ "script": "check:routes:fixture", "expect": "fail" } { "script": "check:routes:fixture", "expect": "fail" }
], ],
"logPath": "artifacts/quality/gates/FE-GATE-010.txt", "logPath": "artifacts/quality/gates/FE-GATE-010.txt",
"evidence": [ "evidence": [
"artifacts/quality/dependency-report.json", "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/registries.json",
"artifacts/quality/registry-compatibility-fixtures.json",
"artifacts/quality/registry-baseline-fixture.json",
"artifacts/quality/registry-fixture.json", "artifacts/quality/registry-fixture.json",
"artifacts/quality/route-registry-fixture.json" "artifacts/quality/route-registry-fixture.json"
], ],
@@ -165,11 +224,15 @@
}, },
"FE-GATE-011": { "FE-GATE-011": {
"name": "build", "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", "logPath": "artifacts/quality/gates/FE-GATE-011.txt",
"evidence": [ "evidence": [
"artifacts/release/build-manifest.json", "artifacts/release/build-manifest.json",
"artifacts/release/runtime-config.schema.json" "artifacts/release/runtime-config.schema.json",
"artifacts/storybook/static/index.html"
], ],
"retentionClass": "release-coherence" "retentionClass": "release-coherence"
}, },
@@ -177,23 +240,45 @@
"name": "bundle", "name": "bundle",
"steps": [ "steps": [
{ "script": "build", "expect": "pass" }, { "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", "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" "retentionClass": "release-coherence"
}, },
"FE-GATE-013": { "FE-GATE-013": {
"name": "security", "name": "security",
"steps": [ "steps": [
{ "script": "verify:reproducible-build", "expect": "pass" },
{ "script": "build:release", "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" } { "script": "check:browser-security", "expect": "pass" }
], ],
"logPath": "artifacts/quality/gates/FE-GATE-013.txt", "logPath": "artifacts/quality/gates/FE-GATE-013.txt",
"evidence": [ "evidence": [
"artifacts/security/scan.sarif", "artifacts/security/scan.sarif",
"artifacts/security/scan-fixture.sarif",
"artifacts/release/dependency-inventory.json", "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" "retentionClass": "release-coherence"
}, },
@@ -207,11 +292,15 @@
"FE-GATE-015": { "FE-GATE-015": {
"name": "release-coherence", "name": "release-coherence",
"steps": [ "steps": [
{ "script": "build", "expect": "pass" }, { "script": "build:release", "expect": "pass" },
{ "script": "verify:release", "expect": "pass" } { "script": "verify:release", "expect": "pass" },
{ "script": "verify:supply-chain:promotion", "expect": "pass" }
], ],
"logPath": "artifacts/quality/gates/FE-GATE-015.txt", "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" "retentionClass": "release-coherence"
}, },
"FE-GATE-016": { "FE-GATE-016": {
@@ -262,10 +351,16 @@
"retentionClass": "release-coherence" "retentionClass": "release-coherence"
}, },
"FE-GATE-020": { "FE-GATE-020": {
"name": "reference-feature-removal", "name": "removability",
"steps": [{ "script": "test:sample-removal", "expect": "pass" }], "steps": [
{ "script": "test:sample-removal", "expect": "pass" },
{ "script": "test:optional-recipe-removal", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-020.txt", "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" "retentionClass": "merge-cycle"
}, },
"FE-GATE-021": { "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": []
}
+348 -53
View File
@@ -1,42 +1,17 @@
{ {
"schemaVersion": 1, "schemaVersion": 2,
"sourceDirectories": [
"src/application",
"src/presentation",
"src/domain"
],
"registries": [ "registries": [
{ {
"registryId": "FE-REG-ROUTE", "registryId": "FE-REG-ROUTE",
"path": "src/features/installed-feature-contracts.js", "path": "src/features/installed-feature-contracts.js",
"exportName": "ROUTE_REGISTRY", "exportName": "ROUTE_REGISTRY",
"owner": "feature-routing-navigation-guard-contract", "owner": "feature-frontend-routing-release-recovery-runtime",
"uniqueFields": ["routeId", "path", "chunkId"], "keyField": "routeId",
"allowedValues": {
"paramsSchema": [null, "NotFoundSplat"],
"searchSchema": [null, "ReferenceResourceListQuery"],
"loadingSurface": [
"app-shell",
"example-page",
"reference-resource-list",
"none"
],
"errorSurface": [
"route-boundary",
"feature-boundary",
"not-found"
],
"chunkId": [
"route-home",
"route-examples-ui",
"route-examples-states",
"route-examples-auth",
"route-reference-resources",
"route-not-found"
]
},
"references": [
{
"field": "routeId",
"registryId": "FE-REG-ROUTE-RUNTIME",
"targetField": "routeId"
}
],
"requiredFields": [ "requiredFields": [
"routeId", "routeId",
"path", "path",
@@ -45,6 +20,73 @@
"access", "access",
"loadingSurface", "loadingSurface",
"errorSurface", "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" "chunkId"
] ]
}, },
@@ -53,38 +95,56 @@
"path": "src/features/installed-feature-contracts.js", "path": "src/features/installed-feature-contracts.js",
"exportName": "ROUTE_RUNTIME_CONTRACT", "exportName": "ROUTE_RUNTIME_CONTRACT",
"owner": "feature-frontend-routing-release-recovery-runtime", "owner": "feature-frontend-routing-release-recovery-runtime",
"keyField": "routeId",
"requiredFields": [ "requiredFields": [
"routeId", "routeId",
"moduleId", "moduleId",
"paramsCodec", "paramsCodec",
"searchCodec" "searchCodec"
], ],
"uniqueFields": ["routeId", "moduleId"], "fieldTypes": {
"allowedValues": { "routeId": "string",
"moduleId": [ "moduleId": "string",
"home-page", "paramsCodec": "string",
"ui-gallery-page", "searchCodec": "string"
"state-gallery-page",
"auth-example-page",
"reference-resource-page",
"not-found-page"
],
"paramsCodec": ["none", "NotFoundSplat"],
"searchCodec": ["none", "ReferenceResourceListQuery"]
}, },
"uniqueFields": ["routeId", "moduleId"],
"references": [ "references": [
{ {
"field": "routeId", "field": "routeId",
"registryId": "FE-REG-ROUTE", "registryId": "FE-REG-ROUTE",
"targetField": "routeId" "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", "registryId": "FE-REG-API",
"path": "src/features/installed-feature-contracts.js", "path": "src/features/installed-feature-contracts.js",
"exportName": "API_OPERATIONS", "exportName": "API_OPERATIONS",
"owner": "feature-api-client-response-envelope-contract", "owner": "feature-frontend-api-client-response-envelope-contract",
"keyField": "operationId",
"requiredFields": [ "requiredFields": [
"method", "method",
"path", "path",
@@ -97,20 +157,127 @@
"requestSchema", "requestSchema",
"responseSchema", "responseSchema",
"owner" "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", "registryId": "FE-REG-ENV",
"path": "src/contracts/env.js", "path": "src/contracts/env.js",
"exportName": "ENV_REGISTRY", "exportName": "ENV_REGISTRY",
"owner": "feature-frontend-env-runtime-config-contract", "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", "registryId": "FE-REG-STORAGE",
"path": "src/contracts/storage-keys.js", "path": "src/contracts/storage-keys.js",
"exportName": "STORAGE_REGISTRY", "exportName": "STORAGE_REGISTRY",
"owner": "feature-frontend-storage-registry-contract", "owner": "feature-frontend-storage-registry-contract",
"keyField": "logicalName",
"requiredFields": [ "requiredFields": [
"logicalName", "logicalName",
"physicalKey", "physicalKey",
@@ -120,6 +287,44 @@
"ttl", "ttl",
"migration", "migration",
"quotaFallback" "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"
] ]
}, },
{ {
@@ -127,6 +332,7 @@
"path": "src/contracts/errors.js", "path": "src/contracts/errors.js",
"exportName": "ERROR_REGISTRY", "exportName": "ERROR_REGISTRY",
"owner": "feature-frontend-error-classification-boundary-contract", "owner": "feature-frontend-error-classification-boundary-contract",
"keyField": "kind",
"requiredFields": [ "requiredFields": [
"kind", "kind",
"defaultRetryable", "defaultRetryable",
@@ -135,13 +341,41 @@
"action", "action",
"telemetryEvent", "telemetryEvent",
"redaction" "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", "registryId": "FE-REG-QUERY",
"path": "src/features/installed-feature-contracts.js", "path": "src/features/installed-feature-contracts.js",
"exportName": "QUERY_REGISTRY", "exportName": "QUERY_REGISTRY",
"owner": "feature-server-state-caching-contract", "owner": "feature-frontend-server-state-caching-contract",
"requiredFields": [ "requiredFields": [
"namespace", "namespace",
"serialization", "serialization",
@@ -149,13 +383,39 @@
"invalidation", "invalidation",
"version", "version",
"persistence" "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", "registryId": "FE-REG-TELEMETRY",
"path": "src/contracts/telemetry.js", "path": "src/contracts/telemetry.js",
"exportName": "TELEMETRY_REGISTRY", "exportName": "TELEMETRY_REGISTRY",
"owner": "feature-frontend-observability-logging-trace-contract", "owner": "feature-frontend-diagnostics-telemetry-runtime",
"keyField": "eventName",
"requiredFields": [ "requiredFields": [
"eventName", "eventName",
"trigger", "trigger",
@@ -164,6 +424,31 @@
"forbiddenAttributes", "forbiddenAttributes",
"sampling", "sampling",
"delivery" "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"
] ]
}, },
{ {
@@ -171,11 +456,21 @@
"path": "src/contracts/release-tokens.js", "path": "src/contracts/release-tokens.js",
"exportName": "RELEASE_TOKEN_REGISTRY", "exportName": "RELEASE_TOKEN_REGISTRY",
"owner": "feature-frontend-release-cache-rollback-contract", "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"
} }
], ],
"compatibilityImpact": { "breakingFields": ["token", "source", "compatibilityRole"]
"allowed": ["none", "additive", "behavior-change", "breaking"],
"current": "behavior-change"
} }
]
} }
@@ -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
}
}
]
}
@@ -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으로 묶지 않는다.
@@ -12,7 +12,7 @@
- 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다. - 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다.
- 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다. - 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다.
최초 검토 기준은 `develop``cb195f8`이며, RP-01~RP-04 구현 결과를 이 문서에 최초 검토 기준은 `develop``cb195f8`이며, RP-01~RP-12 구현 결과를 이 문서에
누적 반영했다. 이후 구현으로 경로나 세부 내용이 달라질 수 있으므로, 각 항목은 누적 반영했다. 이후 구현으로 경로나 세부 내용이 달라질 수 있으므로, 각 항목은
문서의 경로뿐 아니라 해당 테스트와 아키텍처 게이트로 계속 검증해야 한다. 문서의 경로뿐 아니라 해당 테스트와 아키텍처 게이트로 계속 검증해야 한다.
@@ -27,26 +27,21 @@
- Vitest, Testing Library, MSW, Playwright, axe를 이용한 테스트 계층 - Vitest, Testing Library, MSW, Playwright, axe를 이용한 테스트 계층
- CI 게이트 taxonomy와 호환성·보안·성능·릴리스 계약 문서 - CI 게이트 taxonomy와 호환성·보안·성능·릴리스 계약 문서
그러나 “도메인 기능을 바로 추가할 수 있는 프론트엔드 플랫폼” 기준으로는 아직 RP-01~RP-12에서 TypeScript 도구 안전망, application runtime 주입,
중요한 연결부가 빠져 있다. 가장 큰 문제는 공통 기능이 없다는 것보다 이미 있는
기능이 실제 기능 화면의 표준 호출 경로로 조립되지 않았다는 점이다.
특히 다음은 선행 해결이 필요하다.
RP-01~RP-05에서 TypeScript 도구 안전망, application runtime 주입,
query/mutation inbound adapter, HTTP 실행 계약과 executable route/release query/mutation inbound adapter, HTTP 실행 계약과 executable route/release
recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다. 현재 선행 해결 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 증거는 프로젝트가 선택하고 검증해야 한다.
1. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치 따라서 더 정확한 표현은 다음과 같다.
2. 국제화, diagnostics, optional adapter recipe와 심화 품질 게이트
따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다. > application API, 서버 상태, 폼, 라우팅, 페이지, 디자인 시스템, 테스트와
더 정확한 표현은 다음과 같다. > local 공급망 증적의 표준 수직 경로와 opt-in adapter recipe는 갖춰졌다.
> 실제 capability 설치와 hosting·IdP·취약점/서명/운영 provider는 프로젝트
> 운영·안전 계약과 범용 앱 셸은 갖춰졌지만, 기능 개발자가 사용하는 application > 통합 범위이며, 없는 외부 증거를 완료로 표시하지 않는다.
> API, 서버 상태, 폼, 라우팅, 페이지 패턴의 표준 수직 경로는 아직 보강이
> 필요하다.
## 3. 판정 기준 ## 3. 판정 기준
@@ -65,30 +60,30 @@ recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다.
| 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 | | 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 |
| application facade | 준비됨 | typed input/output catalog, provider, production composition test | feature input use case를 contribution으로 확장 | | 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 뒤에서 사용 | | HTTP client | 준비됨 | path/search/body projection, runtime timeout/retry, abort/cleanup test | feature gateway 뒤에서 사용 |
| retry | 준비됨 | HTTP 단일 소유, runtime max attempts, Query retry off | RP-09에서 telemetry 연결 | | retry | 준비됨 | HTTP 단일 소유, runtime max attempts, Query retry off, logical execution당 bounded diagnostics | terminal event 중복 방지 계약 유지 |
| 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper | | 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper |
| 검증 | 부분 준비 | runtime/API Zod parse 결과를 실제 request에 사용 | route/form/domain 경계를 추가 | | 검증 | 준비 | runtime/API/route/form Zod parse 결과를 실행 경계에서 사용하고 domain invariant와 분리 | feature별 schema 소유권 유지 |
| 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 | | 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 |
| 서버 상태 | 준비됨 | reference route의 query/mutation, cancellation, stale, optimistic/conflict/rollback | feature별 query contribution recipe 유지 | | 서버 상태 | 준비됨 | reference route의 query/mutation, cancellation, stale, optimistic/conflict/rollback | feature별 query contribution recipe 유지 |
| 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 | | 클라이언트 상태 | 준비됨/프로젝트 선택 | local/URL/query/context 소유권, session external store, typed workflow recipe | 실제 cross-page workflow가 생길 때 하나의 store 선택 |
| 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 | | 범용 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로 사용 범위 확장 | | 라우팅 | 준비됨 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 |
| 앱 셸·반응형 | 부분 준비 | header/sidebar/content/theme 구현 | 접근 가능한 mobile drawer와 focus 복원 | | 앱 셸·반응형 | 준비 | native modal Drawer, compact/desktop layout, Escape/link dismiss/focus restore, pseudo reflow와 RTL direction | compact browser matrix 유지 |
| 페이지 템플릿 | 미제공 | 각 페이지가 직접 레이아웃 조립 | list/detail/form/status 등 슬롯 기반 템플릿 | | 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status와 public design-system entry | feature별 slot 조합 유지 |
| 디자인 토큰 | 부분 준비 | semantic color/theme 토큰 존재 | typography, spacing, motion, layer 등 3단계 토큰 | | 디자인 토큰 | 준비 | primitive/semantic/component CSS, 48-token 자동 계약, dark/forced-colors/reduced-motion | 제품 brand token은 외부 프로젝트에서 확장 |
| 공통 UI | 부분 준비 | Button, TextField, Card, Alert, Badge, Dialog | form/navigation/overlay/data/layout primitives 확장 | | 공통 UI | 준비 | action/form/feedback/overlay/navigation primitive와 pattern, compatibility export | public story와 visual state matrix 유지 |
| 아이콘 | 미제공 | 문자 기호를 직접 사용 | Lucide를 로컬 icon facade 뒤에서 사용 | | 아이콘 | 준비됨 | Lucide static vendor facade와 semantic icon/IconButton 접근성 계약 | 의미 icon 추가 시 bundle/접근성 기준 적용 |
| 폼 | 미제공 | 수동 문자열 검증 예제만 존재 | schema 기반 form facade와 422/dirty/pending 정책 | | 폼 | 준비됨 | Zod 기반 local facade, error summary/focus, 422 allowlist, dirty/pending/conflict 정책 | 복합 form 요구가 생기면 VD-04 조건으로 vendor adapter 평가 |
| 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 | | 국제화 | 준비됨 | 137-key typed catalog, locale provider, Intl formatter, safe fallback/alias, pseudo·RTL gate | 실제 locale·번역 승인은 프로젝트에서 연결 |
| logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 | | logging/diagnostics | 준비됨 | 별도 `DiagnosticsPort`, 8-event registry, allowlist, bounded/no-op adapter와 production producer | 실제 프로젝트의 remote sink는 port 뒤에서 선택 |
| telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 | | 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 화면에서 전체 상태 전시 | | 비동기 상태 불변식 | 준비됨 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 |
| 단위·통합·E2E | 준비됨 | Vitest, RTL, MSW, Playwright 3엔진 | TS 테스트 검사, 실제 bootstrap 통합, 위험 시나리오 보강 | | 단위·통합·E2E | 준비됨 | source/test strict typecheck, shared MSW 19개 scenario, 실제 bootstrap, built-dist 3엔진·compact E2E | 제품별 critical flow를 같은 catalog/gate에 추가 |
| UI 회귀 검증 | 미제공 | axe/reflow는 있으나 visual baseline 없음 | Storybook 또는 동급 workshop과 시각 회귀 | | UI 회귀 검증 | 준비됨 | dev-only Storybook interaction/axe와 pinned Chromium visual baseline 4종 | cloud review와 다중 OS/device는 프로젝트 선택 |
| 샘플 제거 | 준비됨 | feature/catalog/test 제거 후 type/architecture/registry/test/home/build 8단계 검증 | 새 contribution도 같은 제거 gate에 포함 | | 샘플 제거 | 준비됨 | feature/catalog/test 제거 후 type/architecture/registry/test/home/build 9단계 검증 | 새 contribution도 같은 제거 gate에 포함 |
| registry·compatibility 집행 | 부분 준비 | registry와 gate는 있으나 실제 before/after 및 orphan 검사가 제한적 | type/reference/orphan/diff/migration을 자동 검증 | | registry·compatibility 집행 | 준비 | 10개 registry type/reference/consumer/orphan, 승인 digest와 actual semantic diff, breaking evidence | public 계약 변경 시 baseline review 유지 |
| 공급망 검사 | 부분 준비 | lockfile·문서·gate는 있으나 실제 transitive 취약점/license/SBOM 깊이가 부족 | pinned scanner와 policy exception/증적 연결 | | 공급망 검사 | 준비됨/프로젝트 선택 | 561개 transitive inventory/integrity/license, actual diff, CycloneDX, local provenance, secret/reproducible build gate | 실제 vulnerability scanner와 signed attestation 없이는 promotion `FAIL_UNVERIFIED` |
| realtime·offline·file 등 | 프로젝트 선택 | 현재 없음 | port/adapter recipe와 선택 기준 제공 | | realtime·offline·file 등 | 준비됨/프로젝트 선택 | 12개 opt-in TypeScript port/fake/unavailable, failure/security/bundle/removal gate | 실제 요구·owner 승인 시 해당 recipe만 설치 |
## 5. 우선순위별 발견 사항 ## 5. 우선순위별 발견 사항
@@ -216,11 +211,14 @@ guard → browser navigation adapter의 1회 reload로 연결됐다. 일반 rend
failure는 이 경로에서 제외되고, 반복 실패·offline·malformed manifest·storage failure는 이 경로에서 제외되고, 반복 실패·offline·malformed manifest·storage
실패는 지원 표면으로 fail-closed된다. 실패는 지원 표면으로 fail-closed된다.
#### telemetry, registry, 공급망 gate의 실행 깊이가 부족하다 #### RP-09에서 diagnostics/telemetry 실행 깊이 보강
telemetry registry에는 여러 사건이 있지만 실제 production producer는 제한적이다. `DiagnosticsPort``TelemetryPort`를 분리하고 boot, HTTP logical outcome,
boot, API attempt/final failure, auth recovery, storage/cache degradation, release/chunk render, cache, storage, route, release mismatch와 delivery drop을 production
recovery를 registry 사건에 연결해야 한다. 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 검사는 다음까지 확장한다. registry/compatibility 검사는 다음까지 확장한다.
@@ -236,13 +234,13 @@ known vulnerability, license policy, SBOM/provenance를 pinned tool로 검사해
### 5.2 P1: 공통 플랫폼 기본 제공 항목 ### 5.2 P1: 공통 플랫폼 기본 제공 항목
- schema 기반 form facade와 field/error/pending/dirty/422 정책 - RP-06에서 완료한 schema 기반 form facade와 field/error/pending/dirty/422 정책 유지
- standard, collection, detail, form, status page template - RP-06에서 완료한 standard, collection, detail, form, status page template의 public entry 정리
- 접근 가능한 drawer, menu, popover, select 같은 interaction primitive - 접근 가능한 drawer, menu, popover, select 같은 interaction primitive
- token → primitive → pattern → template로 이어지는 디자인 시스템 - token → primitive → pattern → template로 이어지는 디자인 시스템
- Lucide를 감싼 local icon registry와 `IconButton` - Lucide를 감싼 local icon registry와 `IconButton`
- typed message key, locale provider, formatter, pseudo-locale/RTL smoke - typed message key, locale provider, formatter, pseudo-locale/RTL smoke
- redacted structured diagnostics/logger와 telemetry wiring - RP-09에서 완료한 redacted structured diagnostics와 telemetry wiring 유지
- Storybook 또는 동급 isolated UI workshop - Storybook 또는 동급 isolated UI workshop
- Playwright visual baseline, shared MSW scenarios, built-dist E2E - Playwright visual baseline, shared MSW scenarios, built-dist E2E
- React Hooks, JSX accessibility, TanStack Query 관련 lint - React Hooks, JSX accessibility, TanStack Query 관련 lint
@@ -250,6 +248,24 @@ known vulnerability, license policy, SBOM/provenance를 pinned tool로 검사해
- registry/compatibility의 실제 diff와 orphan reference 검사 - registry/compatibility의 실제 diff와 orphan reference 검사
- transitive vulnerability, license, SBOM/provenance 공급망 gate - 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를 제공할 선택 항목 ### 5.3 P2: 경계와 recipe를 제공할 선택 항목
다음 기능을 모든 앱의 초기 번들에 설치할 필요는 없다. 대신 port 또는 local 다음 기능을 모든 앱의 초기 번들에 설치할 필요는 없다. 대신 port 또는 local
@@ -270,6 +286,13 @@ vendor facade, 선택 조건, 실패 정책, 테스트 fixture를 문서로 제
| large data UI | virtualization, data grid | owned component facade | 데이터 규모가 측정 기준을 넘을 때 | | large data UI | virtualization, data grid | owned component facade | 데이터 규모가 측정 기준을 넘을 때 |
| analytics/error sink | vendor SDK, OpenTelemetry | redaction, consent, sampling adapter | 운영 provider와 정책이 정해졌을 때 | | 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를 브라우저가 직접 연결하는 구조는 기본 서버의 Redis, MongoDB, PostgreSQL, MinIO를 브라우저가 직접 연결하는 구조는 기본
frontend adapter catalog에 넣지 않는다. 브라우저는 권한 있는 backend API/BFF를 frontend adapter catalog에 넣지 않는다. 브라우저는 권한 있는 backend API/BFF를
통해 이 자원에 접근해야 한다. 프론트에서 대응되는 변화 지점은 데이터베이스 통해 이 자원에 접근해야 한다. 프론트에서 대응되는 변화 지점은 데이터베이스
@@ -361,7 +384,8 @@ bootstrap → React TSX → tests 순서로 이동한다.
- retry: `src/adapters/http/retry-policy.js`, 부분 준비 - retry: `src/adapters/http/retry-policy.js`, 부분 준비
- API client: `src/adapters/http/client.js`, 부분 준비 - API client: `src/adapters/http/client.js`, 부분 준비
- logger: 없음. telemetry와 분리하거나 diagnostics port로 합치는 결정 필요 - logger: `DiagnosticsPort`로 telemetry와 분리해 구현. closed event/level,
allowlist와 bounded/no-op adapter 제공
- token manager: 의도적으로 없음. opaque external auth owner가 credential을 소유 - token manager: 의도적으로 없음. opaque external auth owner가 credential을 소유
- error: `src/contracts/errors.js`와 HTTP normalization, 부분 준비 - error: `src/contracts/errors.js`와 HTTP normalization, 부분 준비
- validation: runtime/API Zod는 존재, route/form/domain 분리는 미완성 - validation: runtime/API Zod는 존재, route/form/domain 분리는 미완성
@@ -508,6 +508,22 @@ RP-06은 기존 reference controls/layout으로 돌아가도 controller/applicat
경계가 유지돼야 한다. vendor adapter, form contract와 template commit을 구분해 경계가 유지돼야 한다. vendor adapter, form contract와 template commit을 구분해
부분 revert가 가능하게 한다. 부분 revert가 가능하게 한다.
**구현 증거 (2026-07-26)**
- VD-04에서 dependency 추가 없는 native controller + Zod local facade를
채택했고 vendor 도입 조건을 문서화했다.
- `src/presentation/forms`가 field registration, parse/error map,
dirty/touched/reset/pending/result, 422 allowlist, first-error focus,
duplicate submit과 dirty navigation을 제공한다.
- `src/presentation/templates`가 다섯 page 유형의 slot/landmark/responsive
계약을 제공하며 architecture negative fixture가 application/vendor import를
거절한다.
- reference list/detail/create/status route가 네 구체 template를 사용하고
production composition test가 list → form → command → HTTP → invalidation →
list 경로를 실행한다.
- component/integration/E2E test가 validation, transform, 422, conflict,
secret 비노출, navigation focus와 320px reflow를 검증한다.
### 07. `feature-frontend-design-system-platform` ### 07. `feature-frontend-design-system-platform`
**목표** **목표**
@@ -572,6 +588,22 @@ RP-07은 compatibility export로 기존 primitive import를 복구할 수 있어
token rename은 alias/migration 기간을 두고, vendor adapter와 local API commit을 token rename은 alias/migration 기간을 두고, vendor adapter와 local API commit을
분리한다. 분리한다.
**구현 증거 (2026-07-26)**
- VD-05에서 `lucide-react@1.25.0` static semantic facade와 native-first
interaction을 채택하고 headless vendor 재평가 조건을 닫았다.
- `src/presentation/design-system`이 48개 필수 token, public TypeScript barrel,
action/form/feedback/overlay/navigation primitive와 공통 pattern을 제공한다.
- 기존 `components/ui` 경로는 compatibility export로 유지하고 앱 셸, gallery와
reference feature는 public entry를 소비한다.
- 모바일 navigation은 native modal Drawer로 전환되어 배경 비활성화, Escape,
route dismiss와 trigger focus restore를 제공한다.
- source/negative fixture gate가 undefined token, raw palette, direct vendor,
deep import, tooltip-only 정보와 accessible name 누락을 거절한다.
- component/browser test가 Menu typeahead, Tabs activation, Toast queue,
form controls, compact reflow와 open-dialog axe를 실행한다. 로컬 WebKit은 host
`libevent-2.1.so.7` 부재로 환경 검증 상태를 유지한다.
### 08. `feature-frontend-i18n-message-formatting-contract` ### 08. `feature-frontend-i18n-message-formatting-contract`
**목표** **목표**
@@ -619,6 +651,26 @@ token rename은 alias/migration 기간을 두고, vendor adapter와 local API co
RP-08은 기존 기본 언어 catalog를 fallback으로 유지한다. 번역 catalog를 RP-08은 기존 기본 언어 catalog를 fallback으로 유지한다. 번역 catalog를
파괴적으로 덮어쓰지 않는다. 파괴적으로 덮어쓰지 않는다.
**구현 증거 (2026-07-26)**
- VD-06에서 dependency를 추가하지 않는 browser `Intl` + typed local catalog를
채택하고 vendor 재평가 조건, fallback과 key migration 정책을 문서화했다.
- `src/presentation/i18n`이 137개 common key, key별 interpolation,
`ko-KR` fallback, compatibility alias, date/number/relative/list/plural/select
formatter와 `LocaleProvider`를 제공한다.
- shell, route lifecycle/access/recovery, async/form error, page template와
design-system default copy가 catalog consumer로 연결됐다.
- feature route copy는 feature-owned catalog contribution으로 분리되어 reference
feature 제거 시 source와 built artifact에 전용 message key가 남지 않는다.
- reference application mapper는 locale-formatted date 대신 timestamp를
반환하고 presentation formatter가 UTC 또는 명시 timezone을 적용한다.
- `check:i18n`과 type negative fixture가 catalog/placeholder 불일치,
hardcoded common literal, backend raw message render, unsafe HTML,
unknown key와 interpolation mismatch를 거절한다.
- unit/component/browser test가 safe fallback, pseudo 320px reflow,
document `lang/dir`, RTL Drawer/Tabs/directional icon과 deterministic formatter를
검증한다.
### 09. `feature-frontend-diagnostics-telemetry-runtime` ### 09. `feature-frontend-diagnostics-telemetry-runtime`
**목표** **목표**
@@ -671,6 +723,28 @@ render와 release 사건을 실제 producer에 연결한다.
RP-09는 exporter를 제거하고 즉시 no-op adapter로 전환할 수 있어야 한다. RP-09는 exporter를 제거하고 즉시 no-op adapter로 전환할 수 있어야 한다.
diagnostics port와 redaction test는 유지한다. diagnostics port와 redaction test는 유지한다.
**구현 증거 (2026-07-26)**
- VD-07에서 level/event 기반 `DiagnosticsPort`와 semantic `TelemetryPort`
분리하고 bounded memory/no-op 또는 설정 기반 best-effort HTTP exporter를
채택했다.
- diagnostics 8종과 telemetry 5종의 registry, context/attribute allowlist,
고카디널리티 value policy, timestamp와 status/attempt/duration/queue bucket을
구현했다.
- boot, route, render, HTTP logical outcome, cache, storage, release mismatch와
telemetry drop을 production composition에 연결했다. HTTP는 success/recovery/
terminal/abort 각각 logical execution당 diagnostics 한 번, terminal
non-abort failure telemetry 한 번만 발행한다.
- bounded oldest-drop queue, drop reason 집계, sink/observer failure 격리와
nonrecursive delivery evidence를 구현했다. exporter가 없으면 network와 queue
side effect가 없는 true no-op이다.
- `check:diagnostics`, source negative fixture와 TypeScript negative fixture가
producer 누락, direct console, unknown event/context, raw URL/query/body/
credential 경계를 거절한다.
- unit/integration test가 circular/hostile error, pre-mount boot, queue/sink
failure, no-op, cache/storage/release producer, success/retry recovery/terminal/
abort 횟수와 reference route/operation/correlation context를 검증한다.
### 10. `feature-frontend-test-registry-evidence-hardening` ### 10. `feature-frontend-test-registry-evidence-hardening`
**목표** **목표**
@@ -734,6 +808,35 @@ RP-10은 직전 승인 registry snapshot과 test evidence다. flaky visual/brows
infrastructure commit은 product behavior와 분리한다. 장기 skip으로 PASS하지 않고 infrastructure commit은 product behavior와 분리한다. 장기 skip으로 PASS하지 않고
owner와 만료 시한이 있는 quarantine만 허용한다. owner와 만료 시한이 있는 quarantine만 허용한다.
**구현 증거 (2026-07-26)**
- VD-08에서 dev-only Storybook static workshop과 production build를 대상으로 한
local Playwright visual baseline을 채택하고 cloud review는 선택 사항으로
분리했다.
- 10개 registry의 required field, runtime type, enum, unique, cross-reference,
consumer와 orphan을 검사하고 승인 snapshot digest와 현재 snapshot의 actual
semantic diff를 계산한다. 행·field·type·path뿐 아니라 registry 검증 계약
변경도 breaking evidence 대상이다.
- ordering-only 변경은 `none`, row addition은 `additive`, 일반 값 변경은
`behavior-change`, 제거/type/path/contract 변경은 `breaking`으로 계산한다.
breaking에는 version, migration, compatibility window, rollback과 owner를
요구하며 digest 변조와 누락 fixture가 실제로 실패한다.
- API operation별 19개 shared MSW scenario catalog와 strict unhandled-request
server를 제공하고 reference vertical integration이 공통 envelope/handler를
사용한다.
- 기본 Playwright는 `build` + `preview`의 실제 `dist`를 Chromium, Firefox,
WebKit에서 검사하고 별도 compact project를 제공한다. 개발 피드백용 Vite
profile은 `playwright.dev.config.js`로 분리했다.
- Storybook public primitive story, interaction과 axe test, wide/compact/
pseudo/dark/state surface의 pinned Chromium visual baseline 4종을 CI evidence로
연결했다.
- V8 coverage와 9개 high-risk module을 대상으로 40개 scoped threshold를
적용하고 threshold 미달 fixture를 차단한다.
- deterministic clock/random/scheduler/storage, unexpected console/page error/
request failure 정책, 무소유 skip과 full-screen mask 금지 gate를 제공한다.
- JUnit, HTML report, trace/screenshot, coverage, registry와 fixture artifact를
기존 26개 blocking gate taxonomy에 연결했다.
### 11. `feature-frontend-supply-chain-verification` ### 11. `feature-frontend-supply-chain-verification`
**목표** **목표**
@@ -789,6 +892,31 @@ owner와 만료 시한이 있는 quarantine만 허용한다.
RP-11은 P1 최종 저장소 기준선이다. scanner outage를 무검증 승인으로 우회하지 RP-11은 P1 최종 저장소 기준선이다. scanner outage를 무검증 승인으로 우회하지
않고 promotion을 보류한다. 않고 promotion을 보류한다.
**구현 증거 (2026-07-26)**
- VD-09에서 frozen pnpm graph와 lockfile을 local SSOT로, package manifest
license policy와 CycloneDX 1.6을 local evidence로 채택했다. 외부 vulnerability
report와 signed attestation이 없으면 promotion은 `FAIL_UNVERIFIED`다.
- 현재 직접 35개, 전체 전이 561개 dependency의 name/version, direct/scope/
optional, SHA-512 integrity, license와 dependency edge를 deterministic
inventory로 생성한다. lockfile row와 inventory가 양방향 일치하지 않으면
실패한다.
- 승인 baseline digest와 actual add/remove/change/upgrade diff를 계산하고 새
direct production dependency에는 owner와 다른 reviewer, reason과 rollback을
요구한다.
- CycloneDX SBOM component/edge와 local in-toto/SLSA 형태 provenance의
source/lock/SBOM/dist digest를 coherence gate로 다시 계산한다.
- license allow/deny, vulnerability severity와 독립·만료 exception 정책,
provider lock digest와 attestation subject를 machine-readable하게 검증한다.
provider fixture는 promotion PASS를 증명한 뒤 unconfigured
`FAIL_UNVERIFIED`를 복원한다.
- secret scan을 source/scripts/tests/config/schema/public/dist/generated release
metadata로 확장하고 원문 대신 rule/path/line/fingerprint만 SARIF에 남긴다.
test-only allowlist도 owner/reason/expiry를 강제한다.
- `SOURCE_DATE_EPOCH` 기반 동일 build 2회 digest, 실제 frozen install mismatch,
transitive omission/integrity/baseline/self-review/license/vulnerability/
provider/SBOM/provenance/secret negative fixture를 blocking gate에 연결했다.
## 9. P1 exit gate ## 9. P1 exit gate
- 현실적인 form의 validation/dirty/pending/422/conflict가 작동한다. - 현실적인 form의 validation/dirty/pending/422/conflict가 작동한다.
@@ -875,6 +1003,31 @@ RP-12는 recipe별 merge commit이다. optional adapter 문제 시 해당 recipe
revert하고 RP-11을 유지한다. 여러 vendor를 되돌릴 수 없는 한 commit에 묶지 revert하고 RP-11을 유지한다. 여러 vendor를 되돌릴 수 없는 한 commit에 묶지
않는다. 않는다.
**구현 증거 (2026-07-26)**
- VD-10에서 실제 project 요구가 선택되지 않았음을 기록하고 12개 capability를
모두 `RECIPE_AVAILABLE`, production runtime dependency 0개로 유지했다.
- machine-readable catalog에 recipe별 trigger/forbidden 조건, boundary,
port/fake, failure matrix, lifecycle cleanup, project owner 요구,
security/privacy, gzip budget, fallback, server-state 정책과 제거 순서를
등록했다.
- production-excluded `recipes/frontend-capabilities`에 12개 vendor-neutral
TypeScript port와 deterministic fake, fail-closed unavailable adapter를
제공한다. 프로젝트는 선택한 최소 계약만 application/presentation 경계로
복사하고 concrete adapter를 composition에서 연결한다.
- realtime ordering/unsubscribe, offline migration/close, worker cancel,
multi-tab dedupe, permission result, workflow reset, large-data stale
generation, analytics consent/redaction/queue와 나머지 facade contract를
runnable test로 검증한다.
- cleanup 누락, 승인되지 않은 dependency, vendor direct import, credential
storage/URL/telemetry 경로, server-state store 복제와 production recipe import
negative fixture를 blocking gate에 연결했다.
- recipe와 recipe test를 통째로 제거한 임시 사본에서 base typecheck,
architecture, 전체 test와 production build를 실행하며, opt-in하지 않은
sentinel이 built `dist`에 없는지 검사한다.
- 상세 도입/배치/검증/제거 절차는
`docs/architecture/optional-adapter-recipes.md`에 기록했다.
## 11. Vendor decision gate ## 11. Vendor decision gate
| ID | 시점 | 결정 | 기본값 또는 미결정 시 처리 | 차단 범위 | | ID | 시점 | 결정 | 기본값 또는 미결정 시 처리 | 차단 범위 |
@@ -163,6 +163,30 @@ contract/presentation은 `src/features/reference-feature`가 소유하고, gener
installed catalog만 bootstrap과 router에 노출된다. 제거 gate는 feature와 test를 installed catalog만 bootstrap과 router에 노출된다. 제거 gate는 feature와 test를
삭제한 복제본에서 전체 P0 경로를 다시 실행한다. 삭제한 복제본에서 전체 P0 경로를 다시 실행한다.
RP-06에서 inbound form/page 경계도 실행됐다. `src/presentation/forms`는 Zod
presentation schema, controlled field state, error focus, 422 allowlist,
pending/deduplication과 dirty navigation을 local facade로 감싼다.
`src/presentation/templates`는 slot과 landmark만 소유하고 application, HTTP,
query vendor import는 architecture gate가 거절한다. reference feature의
list/detail/create/status route가 각각 Collection/Detail/Form/Status template의
실제 consumer다.
RP-07에서 React inbound adapter 안의 UI 공급자 경계도 닫혔다.
`src/presentation/design-system/index.ts`는 token → primitive → pattern →
template public API이며 feature와 shell은 이 entry만 소비한다. Lucide는
`icons/vendors/lucide.tsx`에 격리된 inbound vendor facade이므로 application
port가 아니다. native Dialog/Drawer/Menu/Tabs의 focus·keyboard 상태도
presentation이 소유하고 use case나 outbound adapter로 올리지 않는다.
RP-08에서 i18n은 application output port가 아니라 React inbound adapter의
local facade로 확정됐다. domain/application은 locale이나 번역 문장을 알지 않고
timestamp, number, failure kind 같은 의미 값만 반환한다.
`src/presentation/i18n`이 typed message catalog, formatter, fallback,
`<html lang/dir>`과 pseudo/RTL smoke를 소유한다. backend raw `message`
application failure registry를 우회해 렌더링할 수 없으며 `check:i18n` negative
fixture가 이 경계를 집행한다. 번역 vendor를 나중에 선택해도 이 facade 뒤의
adapter만 교체한다.
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한 이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
불일치를 제거하는 것이다. 불일치를 제거하는 것이다.
@@ -755,22 +779,31 @@ Logger와 telemetry는 같은 것이 아니다.
- Telemetry: registry에 정의된 semantic event와 metric - Telemetry: registry에 정의된 semantic event와 metric
- Error reporter: 예외 집계와 release correlation - Error reporter: 예외 집계와 release correlation
기본 `Logger` output port는 safe context만 받는다. VD-07에 따라 기본 구현은 임의 message 문자열을 받는 `Logger`가 아니라 닫힌
event ID와 safe context만 받는 `DiagnosticsPort`다.
```ts ```ts
export interface Logger { export interface DiagnosticsPort {
debug(message: string, context?: SafeLogContext): void; record(input: {
info(message: string, context?: SafeLogContext): void; level: DiagnosticLevel;
warn(message: string, context?: SafeLogContext): void; eventId: DiagnosticEventId;
error(message: string, context?: SafeLogContext): void; context?: DiagnosticContext;
}): void;
} }
``` ```
개발 환경에는 redacted console adapter, production에는 allowlist 기반 기본 runtime에는 bounded in-memory diagnostics와 설정 기반 best-effort
remote adapter, 테스트에는 recording 또는 no-op adapter를 연결한다. telemetry adapter, 테스트에는 recording 또는 no-op adapter를 연결한다.
direct `console`은 redaction 경계를 우회하므로 source gate가 거절한다.
민감정보 redaction은 각 호출자의 선의가 아니라 adapter와 contract에서 민감정보 redaction은 각 호출자의 선의가 아니라 adapter와 contract에서
강제한다. 강제한다.
현재 production producer는 boot, logical HTTP outcome, cache, storage, route,
render, release mismatch와 telemetry delivery drop을 포함한다. HTTP terminal
telemetry는 모든 retry가 끝난 뒤 한 번만 발행하며 abort에는 발행하지 않는다.
raw path/query/body/error 대신 route/operation/correlation ID와
status/attempt/duration bucket만 전달한다.
## 16. Feature 경계와 removable reference feature ## 16. Feature 경계와 removable reference feature
Reference feature는 단순 UI fixture가 아니라 다음 경로를 모두 실행해야 Reference feature는 단순 UI fixture가 아니라 다음 경로를 모두 실행해야
@@ -852,27 +885,32 @@ capability의 기본 정책, port 또는 안전한 no-op 구현과 composition
| Adapter | 도입 조건 | 기본 상태 | | Adapter | 도입 조건 | 기본 상태 |
| --- | --- | --- | | --- | --- | --- |
| WebSocket/SSE | 실시간 server event 필요 | 미설치 recipe | | WebSocket/SSE | 실시간 server event 필요 | opt-in recipe 제공, 미설치 |
| IndexedDB | 큰 offline data 또는 durable queue 필요 | 미설치 recipe | | IndexedDB | 큰 offline data 또는 durable queue 필요 | opt-in recipe 제공, 미설치 |
| Service Worker/PWA | offline shell과 installability 필요 | 미설치 recipe | | Service Worker/PWA | offline shell과 installability 필요 | opt-in recipe 제공, 미설치 |
| Offline mutation queue | 재연결 후 명령 재처리 필요 | 미설치 recipe | | Offline mutation queue | 재연결 후 명령 재처리 필요 | 미설치 recipe |
| Feature flag | remote rollout/kill switch 필요 | 미설치 recipe | | Feature flag | remote rollout/kill switch 필요 | opt-in recipe 제공, 미설치 |
| Translation catalog vendor | 원격 catalog·복수 namespace 운영 필요 | 기본 locale facade 뒤에 미설치 | | Translation catalog vendor | 원격 catalog·복수 namespace 운영 필요 | 기본 locale facade 뒤에 미설치 |
| Analytics | 사용자 동의 기반 product analytics 필요 | 미설치 recipe | | Analytics | 사용자 동의 기반 product analytics 필요 | opt-in recipe 제공, 미설치 |
| Error-reporting SDK | 운영 예외 집계 필요 | 미설치 recipe | | Error-reporting SDK | 운영 예외 집계 필요 | opt-in recipe 제공, 미설치 |
| OpenTelemetry | 조직 trace 연계 필요 | 미설치 recipe | | OpenTelemetry | 조직 trace 연계 필요 | opt-in recipe 제공, 미설치 |
| Web Worker | CPU 작업이 main thread를 막음 | 미설치 recipe | | Web Worker | CPU 작업이 main thread를 막음 | opt-in recipe 제공, 미설치 |
| Notification | 사용자 권한 기반 browser notification 필요 | 미설치 recipe | | Notification | 사용자 권한 기반 browser notification 필요 | opt-in recipe 제공, 미설치 |
| Clipboard/File/Media | 해당 browser capability 필요 | 미설치 recipe | | Clipboard/File/Media | 해당 browser capability 필요 | opt-in recipe 제공, 미설치 |
| Image CDN adapter | responsive image transform 필요 | 미설치 recipe | | Image CDN adapter | responsive image transform 필요 | 미설치 recipe |
| Virtualization | 대량 list rendering이 측정상 병목 | 미설치 recipe | | Virtualization | 대량 list rendering이 측정상 병목 | opt-in recipe 제공, 미설치 |
| OpenAPI generator | backend 계약에서 client 생성 필요 | 미설치 recipe | | OpenAPI generator | backend 계약에서 client 생성 필요 | opt-in recipe 제공, 미설치 |
| Zustand/Redux/Jotai | 복잡한 cross-page client state 확인 | 미설치 recipe | | Zustand/Redux/Jotai | 복잡한 cross-page client state 확인 | opt-in recipe 제공, 미설치 |
| XState 등 state machine | 장기 workflow 상태 전이가 복잡함 | 미설치 recipe | | XState 등 state machine | 장기 workflow 상태 전이가 복잡함 | opt-in recipe 제공, 미설치 |
| Cloud visual-review service | 외부 승인·호스팅 workflow 필요 | 로컬 Storybook/visual gate 뒤에 미설치 | | Cloud visual-review service | 외부 승인·호스팅 workflow 필요 | 로컬 Storybook/visual gate 뒤에 미설치 |
선택 adapter는 “나중에 쓸 수 있으므로” 기본 bundle에 넣지 않는다. 도입 선택 adapter는 “나중에 쓸 수 있으므로” 기본 bundle에 넣지 않는다. 도입
조건, 보안 영향, bundle 비용과 제거 방법이 확인된 경우에만 추가한다. 조건, 보안 영향, bundle 비용과 제거 방법이 확인된 경우에만 추가한다.
현재 구현된 공통 catalog, TypeScript contract/fake와 blocking gate는
`docs/architecture/optional-adapter-recipes.md`
`config/recipes/frontend-capability-recipes.json`을 따른다. 이 recipe source를
production에서 직접 import하는 것은 금지하며 선택한 contract만 application
소유 경계로 이동한다.
## 19. 새 outbound adapter 추가 recipe ## 19. 새 outbound adapter 추가 recipe
@@ -1199,9 +1237,10 @@ contract와 실패 분기를 우선한다.
- [ ] runtime timeout/retry 설정이 실제 HTTP transport에 반영된다. - [ ] runtime timeout/retry 설정이 실제 HTTP transport에 반영된다.
- [ ] QueryClient와 feature query bridge가 실제 route에서 동작한다. - [ ] QueryClient와 feature query bridge가 실제 route에서 동작한다.
- [ ] session UI API와 credential attachment가 분리되어 있다. - [ ] session UI API와 credential attachment가 분리되어 있다.
- [ ] logger와 telemetry가 HTTP/render/storage/cache failure 경로에 - [x] diagnostics와 telemetry가 HTTP/render/storage/cache failure 경로에
연결된다. 연결된다.
- [ ] page lifecycle에서 필요한 telemetry flush/cleanup이 수행된다. - [x] `pagehide`에서 bounded telemetry queue를 flush하고 adapter `dispose`
lifecycle listener를 정리한다.
### 26.3 HTTP와 validation ### 26.3 HTTP와 validation
@@ -1249,8 +1288,8 @@ contract와 실패 분기를 우선한다.
### P1: 기본 플랫폼 완성도 ### P1: 기본 플랫폼 완성도
1. error/result/validation/form kernel 1. 완료: error/result/validation/form kernel
2. 배타적인 async 상태와 page template 2. 완료: 배타적인 async 상태와 page template
3. Logger와 telemetry 실제 wiring 3. Logger와 telemetry 실제 wiring
4. design-system public API, icon wrapper와 headless interaction 4. design-system public API, icon wrapper와 headless interaction
5. locale/message/formatter와 pseudo-locale/RTL 경계 5. locale/message/formatter와 pseudo-locale/RTL 경계
@@ -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 상태로 유지한다.
@@ -12,9 +12,9 @@
- page controller와 application input use case의 연결 - page controller와 application input use case의 연결
- 프론트엔드에서 반복 사용하는 설계 패턴 - 프론트엔드에서 반복 사용하는 설계 패턴
## 2. 현재 상태와 문제 ## 2. 현재 상태와 구현 기준
RP-04 이후 현재 구현에는 다음 장점이 있다. RP-04 이후 route runtime과 RP-06 page/form platform에는 다음 장점이 있다.
- route registry가 path와 access policy를 소유한다. - route registry가 path와 access policy를 소유한다.
- contract에서 Data Router route object와 navigation을 생성한다. - contract에서 Data Router route object와 navigation을 생성한다.
@@ -22,6 +22,12 @@ RP-04 이후 현재 구현에는 다음 장점이 있다.
- 앱 셸과 보호 route, not-found surface가 있다. - 앱 셸과 보호 route, not-found surface가 있다.
- route heading focus와 비동기/render error boundary가 있다. - route heading focus와 비동기/render error boundary가 있다.
- redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다. - 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`, platform route 계약, `src/features/installed-feature-contracts.js`,
`src/features/installed-feature-runtimes.tsx`의 완전성은 TypeScript와 registry `src/features/installed-feature-runtimes.tsx`의 완전성은 TypeScript와 registry
@@ -31,9 +37,11 @@ access, title, navigation, chunk ID는
manifest의 dynamic entry는 release manifest route chunk map과 검증되며, manifest의 dynamic entry는 release manifest route chunk map과 검증되며,
`ChunkRecoveryBoundary`는 일반 render error와 chunk rejection을 분리한다. `ChunkRecoveryBoundary`는 일반 render error와 chunk rejection을 분리한다.
페이지도 공통 `PageHeader` 외에는 각자 section과 class를 직접 조립한다. 목록, 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 결정 ## 3. React Router mode 결정
@@ -439,6 +447,13 @@ controller가 소유하지 않는 것:
| Page Template | 반복 layout/state | 접근성과 반응형 구조 재사용 | data fetching을 template에 포함 | | Page Template | 반복 layout/state | 접근성과 반응형 구조 재사용 | data fetching을 template에 포함 |
| Registry + Runtime Map | route/operation/event | 선언과 실행 완전성 | 모든 설정을 하나의 거대 전역 파일에 집중 | | 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 패턴은 추상화 파일만 만든 것으로 완료되지 않는다. reference usage, negative
architecture test, 실패 상태 test가 있어야 제공된 패턴으로 본다. architecture test, 실패 상태 test가 있어야 제공된 패턴으로 본다.
@@ -179,6 +179,11 @@ export type AppFailure =
vendor를 선택하더라도 feature 외부에는 hook/facade만 export한다. 제품 코드가 vendor를 선택하더라도 feature 외부에는 hook/facade만 export한다. 제품 코드가
store instance의 `getState``setState`를 임의 호출하지 않게 한다. 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 ### 3.3 persistence
@@ -468,27 +473,65 @@ raw response body, stack, token, URL query, PII를 사용자 copy나 일반 log
### 7.3 diagnostics와 telemetry ### 7.3 diagnostics와 telemetry
현재 telemetry event contract와 별도로 개발·진단용 structured logger가 필요하다. VD-07에서 level/event 기반 `DiagnosticsPort`와 semantic
다음 두 설계 중 하나를 ADR로 결정한다. `TelemetryPort`를 분리했다. diagnostics는 8개 event ID와 safe context
allowlist를, telemetry는 event별 required/optional attribute와 value policy를
1. `DiagnosticsPort`가 log/event/span을 내부 method로 구분 사용한다.
2. `LoggerPort``TelemetryPort`를 분리
공통 요구: 공통 요구:
- log level과 event key는 닫힌 union - log level과 event key는 닫힌 union
- attribute allowlist와 중앙 redaction - attribute allowlist와 중앙 redaction
- dev adapter는 console을 사용하되 동일 redaction 적용 - 기본 adapter는 bounded memory/no-op이고 endpoint가 있을 때만 best-effort
- production adapter는 provider SDK를 감싸며 앱 코드는 SDK를 import하지 않음 HTTP queue를 사용
- production provider SDK를 추가할 때도 port 뒤에서 감싸며 앱 코드는 SDK를
import하지 않음
- 오류 객체 전체를 그대로 serialize하지 않음 - 오류 객체 전체를 그대로 serialize하지 않음
- trace ID/build ID/route ID/operation ID를 허용된 범위에서 연결 - trace ID/build ID/route ID/operation ID를 허용된 범위에서 연결
- logging failure가 제품 flow를 실패시키지 않음 - logging failure가 제품 flow를 실패시키지 않음
- consent가 필요한 analytics와 essential diagnostics를 분리 - 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. 폼 표준 ## 8. 폼 표준
form vendor는 React Hook Form 또는 TanStack Form 등을 평가하되 local facade 뒤에 VD-04에 따라 현재 기본 엔진은 React native form event와 controlled value이며
둔다. vendor 선택과 무관하게 다음 API를 제공한다. Zod를 local facade 뒤에서 사용한다. 동적 field array, 비동기 field validation,
대규모 render isolation 요구가 실제로 생기면 public API를 유지한 채 React Hook
Form 또는 TanStack Form adapter를 평가한다.
현재 public API는 다음과 같다.
- `Form` - `Form`
- `FormField` - `FormField`
@@ -500,6 +543,18 @@ form vendor는 React Hook Form 또는 TanStack Form 등을 평가하되 local fa
- `mapValidationFailureToFields` - `mapValidationFailureToFields`
- `useDirtyNavigationGuard` - `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`로 연결 1. label, description, error를 stable ID와 `aria-describedby`로 연결
@@ -568,4 +623,5 @@ form vendor는 React Hook Form 또는 TanStack Form 등을 평가하되 local fa
- 상태 종류별 소유권이 테스트와 문서에서 확인된다. - 상태 종류별 소유권이 테스트와 문서에서 확인된다.
- token은 UI와 일반 storage/store에 노출되지 않는다. - token은 UI와 일반 storage/store에 노출되지 않는다.
- failure와 validation의 각 계층이 typed mapper로 분리된다. - failure와 validation의 각 계층이 typed mapper로 분리된다.
- common UI copy, locale formatter와 direction이 typed i18n facade를 통과한다.
- query/mutation/form recipe만으로 새 기능을 만들 수 있다. - query/mutation/form recipe만으로 새 기능을 만들 수 있다.
+42 -13
View File
@@ -1,18 +1,47 @@
# Build and supply-chain gate # Build and supply-chain gate
Merge and release controls: ## Local blocking controls
- frozen `pnpm-lock.yaml` installation; drift is blocking - `pnpm install --frozen-lockfile` and a real manifest/lock mismatch fixture
- clean production build with hashed assets and build manifest - all direct and transitive lockfile rows with package SHA-512 integrity
- machine-readable bundle sizes and checksums - production/development, direct/transitive and platform-optional classification
- source plus built-asset credential-pattern scan - package-manifest license allow/deny policy
- direct dependency inventory and lockfile digest - approved inventory baseline digest and actual add/remove/change/upgrade diff
- base/head dependency diff review record - 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, The canonical commands are:
and scanner selection remain policy inputs. An approved suppression must record
reason, owner, expiry, affected package, and compensating control. Expired
suppressions are blocking.
`artifacts/security/dependency-diff.json` is a local baseline. CI replaces it ```bash
with the actual base/head direct and transitive lockfile diff before release. 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.
+32 -16
View File
@@ -34,6 +34,10 @@
- 초기 로딩, 빈 화면, terminal error, background 상태 - 초기 로딩, 빈 화면, terminal error, background 상태
- `src/presentation/components/state-surfaces.jsx` - `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` - `/examples/ui`, `/examples/states`
- 실행 가능한 primitive와 상태 예제 - 실행 가능한 primitive와 상태 예제
- component/E2E/axe 테스트 - component/E2E/axe 테스트
@@ -43,23 +47,23 @@
- Chromium, Firefox, WebKit - Chromium, Firefox, WebKit
- 등록 라우트의 자동 접근성 검사 - 등록 라우트의 자동 접근성 검사
이 기반은 유효하지만 아직 디자인 시스템 플랫폼 전체는 아니다. RP-07 구현 이후 이 기반은 저장소 내부 디자인 시스템 플랫폼 계약을 충족한다.
아래 목록은 구현 전 공백과 현재 해결 상태를 함께 보존한다.
1. 토큰이 색상 중심이며 typography, elevation, motion, z-layer, control size, 1. typography, elevation, motion, z-layer, control size와 breakpoint는
breakpoint가 계약으로 닫혀 있지 않다. `design-system/tokens`의 3계층과 자동 gate로 닫혔다.
2. 공통 입력은 `TextField` 하나뿐이어서 일반적인 폼을 공통 규칙으로 만들 수 2. RP-06 form/page foundation은 준비됐지만 TextArea, Select, Checkbox,
다. RadioGroup 같은 form primitive 확장은 RP-07에 남아 있다.
3. 앱 셸 예제 화면에서 공용 primitive 대신 raw `button`, `select`, 링크 3. 앱 셸, 예제와 reference feature는 public design-system barrel을 소비한다.
class를 다시 작성하는 곳이 있다. 4. 문자 glyph는 semantic Lucide facade로 교체됐다.
4. `☰`, `×` 같은 문자 glyph를 직접 사용하며 아이콘 공급자 경계가 없다. 5. token은 세 CSS 파일로 분리됐고 `theme.css`는 layout/component styling만
5. `theme.css` 하나가 token, layout, primitive, pattern 스타일을 모두 소유한다. 소유한다.
6. runtime gallery는 있지만 격리된 story, interaction story, 시각 회귀 기준선이 6. runtime gallery는 있지만 격리된 story, interaction story, 시각 회귀 기준선이
없다. 없다.
7. 사용자 문구가 한국어 literal로 고정되어 locale과 RTL 계약이 없다. 7. 사용자 문구가 한국어 literal로 고정되어 locale과 RTL 계약이 없다.
8. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage` 같은 공통 페이지 8. page template, form과 pattern은 public barrel에서 제공된다.
템플릿이 없다. 9. 모바일 navigation은 native modal Drawer로 focus 이동, 배경 비활성화,
9. 모바일 sidebar는 표시 전환은 되지만 modal drawer 수준의 focus 이동, focus Escape/link dismiss와 trigger focus restore를 제공한다.
복원, 배경 비활성화 계약은 없다.
따라서 기존 구성요소는 폐기하지 않고 아래 목표 계층으로 이동·확장한다. 따라서 기존 구성요소는 폐기하지 않고 아래 목표 계층으로 이동·확장한다.
@@ -530,8 +534,9 @@ Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다.
## 9. 국제화 계약 ## 9. 국제화 계약
디자인 시스템 primitive는 한국어 문구를 내부 기본값으로 숨기지 않는다. RP-08에서 디자인 시스템 primitive의 닫기, alert, toast, 글자 수 같은 기본
접근성 label이나 오류 문구가 필요하면 명시적인 prop 또는 message key를 받는다. 문구는 `useLocale()` typed catalog로 이동했다. 제품 의미를 가진 label은 여전히
명시적인 prop으로 받으며 primitive가 feature message key를 소유하지 않는다.
필수 국제화 기반: 필수 국제화 기반:
@@ -547,6 +552,17 @@ Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다.
- pseudo-locale - pseudo-locale
- RTL story와 E2E smoke - 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 문자열을 연결한다. - 번역 문장 중간에 JSX 문자열을 연결한다.
@@ -555,7 +571,7 @@ Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다.
- route title, navigation label, toast message를 JSX literal로 분산한다. - route title, navigation label, toast message를 JSX literal로 분산한다.
- 번역 누락 시 빈 문자열을 렌더링한다. - 번역 누락 시 빈 문자열을 렌더링한다.
Storybook toolbar에서 최소한 다음 조합을 전환할 수 있어야 한다. RP-10의 Storybook toolbar에서 최소한 다음 조합을 전환할 수 있어야 한다.
- `ko-KR`, light - `ko-KR`, light
- `en-US`, light - `en-US`, light
@@ -43,7 +43,12 @@
- `test:unit` - `test:unit`
- `test:component` - `test:component`
- `test:integration` - `test:integration`
- `test:coverage`
- `test:e2e` - `test:e2e`
- `test:e2e:dev`
- `build:storybook`
- `test:storybook`
- `test:visual`
- `test:a11y` - `test:a11y`
- `review:a11y-manual` - `review:a11y-manual`
- `test:sample-removal` - `test:sample-removal`
@@ -67,7 +72,7 @@ field/documentation 단계를 구성한다.
- 320px reflow와 mobile navigation을 E2E로 확인한다. - 320px reflow와 mobile navigation을 E2E로 확인한다.
- release build의 bundle과 lab performance budget이 별도 gate다. - release build의 bundle과 lab performance budget이 별도 gate다.
### 2.3 확인된 공백 ### 2.3 RP-10에서 닫힌 공백과 남은 외부 범위
#### 테스트 TypeScript typecheck 기반 #### 테스트 TypeScript typecheck 기반
@@ -76,20 +81,12 @@ field/documentation 단계를 구성한다.
검사하되 실패를 의도한 `tests/fixtures`는 별도 negative command가 소유한다. 검사하되 실패를 의도한 `tests/fixtures`는 별도 negative command가 소유한다.
Vitest의 변환 성공을 TypeScript typecheck의 대체물로 취급하지 않는다. Vitest의 변환 성공을 TypeScript typecheck의 대체물로 취급하지 않는다.
#### 실제 bootstrap integration test가 없다 #### 실제 bootstrap integration
`tests/component/bootstrap-shell.test.jsx`는 production bootstrap을 import하지 runtime config와 release manifest를 검증한 composition root에서 실제 provider
않고 테스트 내부의 `<TestShell>`만 렌더링한다. E2E는 실제 entry를 통과하지만, 순서와 application input을 연결하는 component/integration test를 제공한다.
다음 실패를 작은 통합 테스트에서 식별하기 어렵다. production Playwright profile은 source fixture가 아니라 `build` + `preview`
실제 entry와 hashed route chunk를 사용한다.
- runtime config fetch 실패
- config/manifest mismatch
- adapter composition 실패
- provider 순서 또는 누락
- external auth owner 유무
- product tree를 마운트하기 전 fail-closed
- boot error shell의 safe metadata
- StrictMode와 unmount cleanup
#### TanStack Query의 React integration test 기반 #### TanStack Query의 React integration test 기반
@@ -100,53 +97,44 @@ optimistic commit/rollback, conflict 해제와 namespace invalidation이 실제
QueryClient 위에서 실행된다. HTTP 자동 retry가 소유자이므로 이 adapter의 QueryClient 위에서 실행된다. HTTP 자동 retry가 소유자이므로 이 adapter의
query/mutation vendor retry는 꺼져 있다. query/mutation vendor retry는 꺼져 있다.
#### Form 테스트가 단일 TextField 흐름에 머문다 #### Form과 route 위험
현재 component/E2E는 label, description, error association과 빈 값 submit을 form component/reference feature test가 error summary, 첫 오류 focus, Zod
검사한다. error summary, 첫 오류 focus, async validation race, 422 field error, transform, 422 allowlist, double submit, dirty navigation, optimistic rollback과
double submit, dirty navigation, mutation conflict는 없다. conflict를 검증한다. route registry/runtime 양방향 참조, codec, lazy module,
location reset, scroll restoration, blocker와 bounded chunk recovery도
unit/component/built artifact 검증에 연결됐다.
#### Route registry와 실행 tree가 별도로 테스트된다 #### Storybook과 시각 회귀
registry snapshot과 일부 navigation/access policy test는 있으나 다음 계약을 public design-system primitive를 실제 platform provider로 렌더하는 dev-only
강제하지 않는다. Storybook, interaction/axe test와 production build를 대상으로 한
`toHaveScreenshot()` baseline 4종을 제공한다. cloud review, 다중 OS font
rasterization과 실제 device farm은 프로젝트가 요구할 때 연결한다.
- 모든 route ID에 lazy runtime module이 존재하는가 #### Shared MSW와 결정성
- params/search가 실제 codec으로 검증되는가
- deep link와 basename refresh가 동작하는가
- chunk load failure가 1회 reload/support surface로 연결되는가
- route error boundary가 location 변경 시 reset되는가
- scroll restoration과 form navigation blocker가 동작하는가
#### Storybook과 시각 회귀가 없다 operation별 success/empty/slow/network/timeout/content/envelope/schema/auth/
403/404/409/422/429/retry/terminal을 포함한 19개 scenario catalog와 strict
unhandled-request server를 공유한다. clock/random/scheduler/storage helper와
browser console/page/network failure 정책으로 비결정적 우회를 차단한다.
`/examples/ui`는 통합 gallery지만 component별 모든 state를 격리하지 않는다. #### Built-dist와 compact E2E
Storybook story, interaction story, story-level axe, `toHaveScreenshot()` baseline이
없다. `screenshot: "only-on-failure"`는 디버깅 증거이며 시각 회귀 테스트가 아니다.
#### MSW scenario가 공유되지 않는다 기본 `test:e2e`는 CI에서 기존 server를 재사용하지 않고 `build` + `preview`
Chromium, Firefox, WebKit과 compact project로 실행한다. 빠른 Vite 개발 profile은
`test:e2e:dev`로 분리한다.
integration file마다 `setupServer`, handler, response body를 다시 정의한다. #### 위험 기반 coverage
Node integration, Storybook browser, feature component test, E2E mock service가 같은
시나리오 이름과 contract fixture를 공유하지 않는다.
#### E2E가 개발 서버를 대상으로 한다 V8 text/JSON/LCOV를 생성하고 전체 기준과 retry/storage/telemetry/compatibility/
performance/promotion/chunk/diagnostics/registry compatibility 9개 high-risk
module에 40개 scoped threshold를 적용한다. critical module 누락 또는 threshold
미달 fixture는 merge gate를 실패시킨다.
현재 Playwright web server는 `pnpm dev`다. route behavior 확인에는 유효하지만 남은 범위는 실제 device/browser farm, cloud visual approval, 외부 인증·telemetry
다음 release 위험은 production build/preview에서만 확인할 수 있다. provider와 production field data다. 이 증거가 없을 때 저장소 내부 test를
`PRODUCTION_READY`의 대체물로 사용하지 않는다.
- hashed lazy chunk
- source 변환과 tree shaking
- base path
- deep-link fallback
- build-time environment
- release manifest와 runtime config 조합
- minified code의 chunk failure
#### Coverage가 실행·차단되지 않는다
`vitest.config.js`에는 reporter만 선언되어 있고 coverage provider, script,
threshold, diff policy가 없다.
## 3. 위험 기반 테스트 계층 ## 3. 위험 기반 테스트 계층
@@ -410,6 +398,8 @@ Page / AsyncSurface / Form pattern
- filter 순서가 달라도 canonical key가 같다. - filter 순서가 달라도 canonical key가 같다.
- route unmount 또는 superseded input에서 request를 abort한다. - route unmount 또는 superseded input에서 request를 abort한다.
- aborted request는 terminal error나 telemetry failure로 오분류되지 않는다. - aborted request는 terminal error나 telemetry failure로 오분류되지 않는다.
- success와 retry recovery는 terminal failure telemetry를 만들지 않고,
exhausted retry는 logical execution당 한 번만 발행한다.
- offline/paused와 loading을 구분한다. - offline/paused와 loading을 구분한다.
- 401 복구는 한 번만 수행한다. - 401 복구는 한 번만 수행한다.
- 재로그인 후 허용된 operation만 다시 실행한다. - 재로그인 후 허용된 operation만 다시 실행한다.
@@ -454,6 +444,16 @@ composition을 검증한다. 제거 모드는 feature source/tests와 installed
contract/runtime/adapter contribution을 제거한 복제본에서 P0 gate와 built asset contract/runtime/adapter contribution을 제거한 복제본에서 P0 gate와 built asset
잔여 0개를 다시 검증한다. 잔여 0개를 다시 검증한다.
RP-06 form/page matrix는 `tests/component/form-foundation.test.tsx`,
`tests/component/page-templates.test.tsx`,
`tests/features/reference-feature/reference-page.test.tsx`
`tests/e2e/reference-form.spec.js`에 있다. client validation에서 command 0회와
첫 오류 focus, Zod transform/default, pending 중 중복 제출, 승인된 422
field/unknown field mapping, conflict 입력 보존, reset/dirty, navigation
confirmation/focus restore, URL/storage 비노출과 320px reflow를 검증한다.
HTTP integration test는 backend copy를 버리고 422 `path`/`code`만 전달하는지
별도로 검사한다.
## 8. Form 테스트 ## 8. Form 테스트
Form test는 field primitive, form controller, application command mapping을 Form test는 field primitive, form controller, application command mapping을
@@ -552,6 +552,30 @@ client route guard는 UX이며 authorization이 아님을 test 이름과 문서
공용 컴포넌트는 한 번의 결함이 모든 페이지로 전파되므로 위험도가 높다. 공용 컴포넌트는 한 번의 결함이 모든 페이지로 전파되므로 위험도가 높다.
RP-07의 실행 경로는 `check:design-system`,
`check:design-system:fixture`, `check:types:fixture:icon-button`,
`tests/component/design-system-platform.test.tsx`
`tests/e2e/design-system-interactions.spec.js`다. Story interaction/visual
baseline은 VD-08/RP-10에서 추가하며 현재 runtime gallery를 isolated workshop
완료 증거로 사용하지 않는다.
RP-08의 국제화 실행 경로는 `check:i18n`, `check:i18n:fixture`,
`check:types:fixture:i18n-key`, `check:types:fixture:i18n-params`,
`tests/unit/i18n-contract.test.ts`, `tests/component/locale-platform.test.tsx`
`tests/e2e/i18n.spec.js`다.
- unit: locale catalog/placeholder parity, safe fallback/alias, pseudo expansion,
direction과 UTC date/number/relative/list/plural/select
- component: document `lang/dir`, RTL tab arrow semantics, direction-aware
pagination과 modal Drawer
- E2E: 320px pseudo reflow와 compact RTL shell/Drawer/focus restore
- negative: common UI locale literal, backend message JSX render, raw translated
HTML, unknown key와 잘못된 interpolation parameter
`ar-EG` catalog가 영어 smoke copy를 재사용하는 것은 RTL behavior 검증용이며
번역 품질 PASS가 아니다. 실제 locale catalog에는 언어 담당 승인 evidence가
추가되어야 한다.
### 10.1 Component behavior ### 10.1 Component behavior
- native role/name/value - native role/name/value
@@ -1192,8 +1216,9 @@ corepack pnpm build
corepack pnpm check:bundle corepack pnpm check:bundle
``` ```
TypeScript test, Storybook, coverage, visual, built-dist 명령이 도입되면 위 목록과 TypeScript test, Storybook, coverage, visual built-dist 명령
CI registry에 추가한다. `config/ci/gates.json`의 blocking step과 JUnit/HTML/trace/fixture evidence에
연결되어 있다.
## 18. Feature Definition of Done ## 18. Feature Definition of Done
@@ -1249,7 +1274,8 @@ CI registry에 추가한다.
- [ ] high-risk module branch 목표를 충족한다. - [ ] high-risk module branch 목표를 충족한다.
- [ ] 신규 코드의 미검증 branch에 승인 없는 예외가 없다. - [ ] 신규 코드의 미검증 branch에 승인 없는 예외가 없다.
- [ ] bundle/performance budget을 통과한다. - [ ] bundle/performance budget을 통과한다.
- [ ] telemetry/error output에 민감 정보가 없다. - [x] diagnostics/telemetry output의 allowlist, value policy와 negative fixture가
raw URL/query/body/storage value/error object를 거절한다.
- [ ] 관련 gate와 evidence registry가 갱신되었다. - [ ] 관련 gate와 evidence registry가 갱신되었다.
## 19. 금지 패턴 ## 19. 금지 패턴
@@ -1273,6 +1299,16 @@ CI registry에 추가한다.
- flaky test를 owner/만료일 없이 skip - flaky test를 owner/만료일 없이 skip
- CI gate에 `continue-on-error` - CI gate에 `continue-on-error`
### 선택형 adapter recipe gate
선택형 capability example은 `tests/recipes`에서 contract/fake/unavailable을
실행하지만 production entry에는 포함하지 않는다. `check:optional-recipes`
12개 catalog 완전성, unselected dependency, production source import와 built
bundle sentinel 부재를 검사한다. negative fixture는 lifecycle cleanup 누락,
vendor direct import, credential storage/URL/telemetry 경로와 workflow store의
server-state 복제를 거절한다. `test:optional-recipe-removal`은 recipe 전체를
제거한 사본에서 base typecheck/test/build를 다시 실행한다.
## 20. 단계별 도입 순서 ## 20. 단계별 도입 순서
1. `tsconfig.test.json`과 test typecheck gate를 추가한다. 1. `tsconfig.test.json`과 test typecheck gate를 추가한다.
+40
View File
@@ -76,7 +76,10 @@ export default [
"artifacts/**", "artifacts/**",
"tests/fixtures/typecheck/**", "tests/fixtures/typecheck/**",
"tests/fixtures/architecture/forbidden/**", "tests/fixtures/architecture/forbidden/**",
"tests/fixtures/diagnostics/forbidden/**",
"tests/fixtures/i18n/forbidden/**",
"tests/fixtures/security/forbidden/**", "tests/fixtures/security/forbidden/**",
"tests/fixtures/optional-recipes/**",
], ],
}, },
eslint.configs.recommended, eslint.configs.recommended,
@@ -115,6 +118,25 @@ export default [
"no-unused-vars": "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"], files: ["**/*.tsx"],
languageOptions: { languageOptions: {
@@ -190,6 +212,17 @@ export default [
]), ]),
}, },
}, },
{
files: [`src/presentation/templates/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports([
"**/application/**",
"**/adapters/**",
"**/bootstrap/**",
"@tanstack/**",
]),
},
},
{ {
files: [`src/adapters/**/*.${sourceExtensions}`], files: [`src/adapters/**/*.${sourceExtensions}`],
rules: { rules: {
@@ -261,6 +294,12 @@ export default [
}, },
}, },
}, },
{
files: [`tests/support/browser/**/*.${sourceExtensions}`],
rules: {
"react-hooks/rules-of-hooks": "off",
},
},
{ {
files: [ files: [
`tests/fixtures/architecture/forbidden/**/*.${sourceExtensions}`, `tests/fixtures/architecture/forbidden/**/*.${sourceExtensions}`,
@@ -272,6 +311,7 @@ export default [
"@tanstack/**", "@tanstack/**",
"react", "react",
"react-dom", "react-dom",
"**/application/**",
]), ]),
"no-restricted-globals": [ "no-restricted-globals": [
"error", "error",
+45 -2
View File
@@ -13,12 +13,19 @@
"build": "vite build && node scripts/generate-build-manifest.mjs", "build": "vite build && node scripts/generate-build-manifest.mjs",
"build:release": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security", "build:release": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security",
"preview": "vite preview", "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:architecture": "node scripts/check-architecture.mjs",
"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": "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:app": "tsc --project tsconfig.app.json",
"check:types:node": "tsc --project tsconfig.node.json", "check:types:node": "tsc --project tsconfig.node.json",
"check:types:test": "tsc --project tsconfig.test.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": "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-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:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts",
@@ -26,21 +33,52 @@
"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: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: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: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: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: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: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: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": "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", "test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs",
"review:a11y-manual": "node scripts/verify-a11y-manual.mjs", "review:a11y-manual": "node scripts/verify-a11y-manual.mjs",
"test:sample-removal": "node scripts/test-sample-removal.mjs", "test:sample-removal": "node scripts/test-sample-removal.mjs",
"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:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests",
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature", "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", "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", "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": "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: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": "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: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", "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:compatibility": "node scripts/check-compatibility.mjs",
@@ -57,6 +95,7 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "5.101.4", "@tanstack/react-query": "5.101.4",
"lucide-react": "1.25.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"react-router-dom": "7.18.1", "react-router-dom": "7.18.1",
@@ -70,6 +109,8 @@
"@babel/plugin-syntax-typescript": "8.0.3", "@babel/plugin-syntax-typescript": "8.0.3",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.0", "@playwright/test": "1.62.0",
"@storybook/addon-a11y": "10.5.4",
"@storybook/react-vite": "10.5.4",
"@tailwindcss/vite": "4.3.3", "@tailwindcss/vite": "4.3.3",
"@testing-library/jest-dom": "7.0.0", "@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2", "@testing-library/react": "16.3.2",
@@ -78,12 +119,14 @@
"@types/react": "19.2.8", "@types/react": "19.2.8",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.4", "@vitejs/plugin-react": "6.0.4",
"@vitest/coverage-v8": "4.1.10",
"dependency-cruiser": "18.1.0", "dependency-cruiser": "18.1.0",
"eslint": "10.8.0", "eslint": "10.8.0",
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "7.1.1",
"globals": "17.7.0", "globals": "17.7.0",
"jsdom": "29.1.1", "jsdom": "29.1.1",
"msw": "2.15.0", "msw": "2.15.0",
"storybook": "10.5.4",
"tailwindcss": "4.3.3", "tailwindcss": "4.3.3",
"typescript": "7.0.2", "typescript": "7.0.2",
"vite": "8.1.5", "vite": "8.1.5",
+31 -7
View File
@@ -6,20 +6,44 @@ export default defineConfig({
reporter: [ reporter: [
["list"], ["list"],
["html", { outputFolder: "./artifacts/tests/e2e/report", open: "never" }], ["html", { outputFolder: "./artifacts/tests/e2e/report", open: "never" }],
["junit", { outputFile: "./artifacts/tests/e2e/results.xml" }],
], ],
use: { use: {
baseURL: "http://127.0.0.1:5173", baseURL: "http://127.0.0.1:4173",
trace: "retain-on-failure", trace: "retain-on-failure",
screenshot: "only-on-failure", screenshot: "only-on-failure",
}, },
webServer: { webServer: {
command: "corepack pnpm dev --host 127.0.0.1", command:
url: "http://127.0.0.1:5173", "corepack pnpm build && corepack pnpm preview --host 127.0.0.1 --port 4173",
reuseExistingServer: !process.env.CI, url: "http://127.0.0.1:4173",
reuseExistingServer: false,
}, },
projects: [ projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } }, {
{ name: "firefox", use: { ...devices["Desktop Firefox"] } }, name: "chromium",
{ name: "webkit", use: { ...devices["Desktop Safari"] } }, 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" }],
});
+1482 -16
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,4 +1,5 @@
allowBuilds: allowBuilds:
esbuild: true
msw: true msw: true
minimumReleaseAgeExclude: minimumReleaseAgeExclude:
- '@playwright/test@1.62.0' - '@playwright/test@1.62.0'
+3
View File
@@ -14,6 +14,9 @@
"route-examples-states": "src/presentation/examples/state-gallery-page.jsx", "route-examples-states": "src/presentation/examples/state-gallery-page.jsx",
"route-examples-auth": "src/presentation/examples/auth-example-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-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" "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": [ "required": [
"schemaVersion", "schemaVersion",
"generatedAt", "generatedAt",
"compatibilityImpact", "baselineDigest",
"currentDigest",
"compatibility",
"failures", "failures",
"registries" "registries"
], ],
"properties": { "properties": {
"schemaVersion": { "const": 1 }, "schemaVersion": { "const": 2 },
"generatedAt": { "type": "string", "format": "date-time" }, "generatedAt": { "type": "string", "format": "date-time" },
"compatibilityImpact": { "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"] "enum": ["none", "additive", "behavior-change", "breaking"]
}, },
"changes": { "type": "array" }
},
"additionalProperties": false
},
"failures": { "type": "array", "maxItems": 0 }, "failures": { "type": "array", "maxItems": 0 },
"registries": { "registries": {
"type": "array", "type": "array",
"minItems": 8, "minItems": 10,
"maxItems": 8, "maxItems": 10,
"items": { "items": {
"type": "object", "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 }
}
}
}
+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`,
);
+240 -48
View File
@@ -1,28 +1,65 @@
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 path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
/** @param {string} name @param {string} fallback */ import {
canonicalizeRegistryValue,
diffRegistrySnapshots,
registrySnapshotDigest,
validateBreakingEvidence,
verifyRegistryBaselineApproval,
} from "./lib/registry-compatibility.mjs";
/** @param {string} name @param {string | undefined} fallback */
function argumentValue(name, fallback) { function argumentValue(name, fallback) {
const index = process.argv.indexOf(name); const index = process.argv.indexOf(name);
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback; return index >= 0 && process.argv[index + 1]
? process.argv[index + 1]
: fallback;
} }
const governancePath = argumentValue( const defaultGovernancePath = "config/contracts/registry-governance.json";
"--governance", const governancePath =
"config/contracts/registry-governance.json", /** @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 artifactPath = argumentValue( const approvalPath = argumentValue(
"--artifact", "--approval",
"artifacts/quality/registries.json", usesRepositoryBaseline
? "config/contracts/registry-baseline.approval.json"
: undefined,
); );
const governance = JSON.parse( const evidencePath = argumentValue(
await readFile(governancePath, "utf8"), "--compatibility-evidence",
usesRepositoryBaseline
? "config/contracts/registry-change-evidence.json"
: undefined,
); );
const governance = JSON.parse(await readFile(governancePath, "utf8"));
const failures = []; const failures = [];
const owners = new Map(); const owners = new Map();
const snapshots = []; const snapshots = [];
const rowsByRegistry = new Map(); const rowsByRegistry = new Map();
const sourcesByRegistry = new Map();
const registryExtensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".mts"]; const registryExtensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".mts"];
/** @param {string} declaredPath */ /** @param {string} declaredPath */
@@ -38,7 +75,7 @@ async function resolveRegistrySource(declaredPath) {
await access(candidate); await access(candidate);
candidates.push(candidate); candidates.push(candidate);
} catch { } catch {
// A migration may legitimately replace the declared extension. // A TypeScript migration may replace the declared extension.
} }
} }
if (candidates.length > 1) { if (candidates.length > 1) {
@@ -50,6 +87,44 @@ async function resolveRegistrySource(declaredPath) {
return candidates[0] ?? 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) { for (const specification of governance.registries) {
if (owners.has(specification.registryId)) { if (owners.has(specification.registryId)) {
failures.push(`duplicate owner for ${specification.registryId}`); failures.push(`duplicate owner for ${specification.registryId}`);
@@ -74,6 +149,10 @@ for (const specification of governance.registries) {
} }
rowsByRegistry.set(specification.registryId, rows); rowsByRegistry.set(specification.registryId, rows);
sourcesByRegistry.set(
specification.registryId,
sourcePath ?? specification.path,
);
for (const [rowName, row] of Object.entries(rows)) { for (const [rowName, row] of Object.entries(rows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) { if (!row || typeof row !== "object" || Array.isArray(row)) {
@@ -85,6 +164,26 @@ for (const specification of governance.registries) {
failures.push(`${specification.registryId}.${rowName} missing ${field}`); 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 ?? []) { for (const field of specification.uniqueFields ?? []) {
@@ -93,12 +192,13 @@ for (const specification of governance.registries) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue; if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const value = row[field]; const value = row[field];
if (value === undefined) continue; if (value === undefined) continue;
if (values.has(value)) { const identity = JSON.stringify(canonicalizeRegistryValue(value));
if (values.has(identity)) {
failures.push( failures.push(
`${specification.registryId}.${rowName} duplicates ${field}=${String(value)} from ${values.get(value)}`, `${specification.registryId}.${rowName} duplicates ${field}=${String(value)} from ${values.get(identity)}`,
); );
} else { } else {
values.set(value, rowName); values.set(identity, rowName);
} }
} }
} }
@@ -121,12 +221,22 @@ for (const specification of governance.registries) {
} }
} }
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({ snapshots.push({
registryId: specification.registryId, registryId: specification.registryId,
owner: specification.owner, owner: specification.owner,
source: sourcePath ?? specification.path, source: sourcePath ?? specification.path,
rowCount: Object.keys(rows).length, rowCount: Object.keys(rows).length,
rows, contract,
rows: canonicalizeRegistryValue(rows),
}); });
} }
@@ -145,18 +255,74 @@ for (const specification of governance.registries) {
Object.values(targetRows) Object.values(targetRows)
.filter((row) => row && typeof row === "object" && !Array.isArray(row)) .filter((row) => row && typeof row === "object" && !Array.isArray(row))
.map((row) => row[reference.targetField]) .map((row) => row[reference.targetField])
.filter((value) => value !== undefined), .filter((value) => value !== undefined && value !== null),
); );
for (const [rowName, row] of Object.entries(rows)) { for (const [rowName, row] of Object.entries(rows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue; if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const value = row[reference.field]; const value = row[reference.field];
if (value !== undefined && !targetValues.has(value)) { if (
value !== undefined &&
value !== null &&
!targetValues.has(value)
) {
failures.push( failures.push(
`${specification.registryId}.${rowName}.${reference.field} references unknown ${reference.registryId}.${reference.targetField}=${String(value)}`, `${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 ?? [ const sourceFiles = governance.sourceDirectories ?? [
@@ -166,54 +332,80 @@ const sourceFiles = governance.sourceDirectories ?? [
]; ];
const adHocPatterns = [ const adHocPatterns = [
{ name: "direct fetch", expression: /\bfetch\s*\(/ }, { 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: "direct import.meta.env", expression: /\bimport\.meta\.env\./ },
{ name: "raw API path", expression: /["']\/api\// }, { name: "raw API path", expression: /["']\/api\// },
]; ];
/** @param {string} directory */ for (const sourceDirectory of sourceFiles) {
async function scanDirectory(directory) { for (const file of await filesBelow(sourceDirectory)) {
const entries = await import("node:fs/promises").then(({ readdir }) => const content = await readFile(file, "utf8");
readdir(directory, { withFileTypes: true }),
);
for (const entry of entries) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) {
await scanDirectory(target);
continue;
}
if (!/\.(js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)) continue;
const content = await readFile(target, "utf8");
for (const pattern of adHocPatterns) { for (const pattern of adHocPatterns) {
if (pattern.expression.test(content)) { 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) { const currentSnapshot =
await scanDirectory(sourceDirectory); /** @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 }); const report = {
await writeFile( schemaVersion: 2,
artifactPath,
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
compatibilityImpact: governance.compatibilityImpact.current, baselineDigest,
currentDigest,
compatibility,
failures, failures,
registries: snapshots, registries: snapshots,
}, };
null, await mkdir(path.dirname(artifactPath), { recursive: true });
2, await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
)}\n`,
);
if (failures.length > 0) { if (failures.length > 0) {
process.stderr.write(`Registry governance failed:\n${failures.join("\n")}\n`); process.stderr.write(`Registry governance failed:\n${failures.join("\n")}\n`);
process.exit(1); 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`,
);
+7 -1
View File
@@ -15,7 +15,13 @@ const buildId = process.env.VITE_BUILD_ID ?? "local-build";
const commitSha = process.env.VITE_COMMIT_SHA ?? "local"; const commitSha = process.env.VITE_COMMIT_SHA ?? "local";
const releaseId = process.env.RELEASE_ID ?? "local-release"; const releaseId = process.env.RELEASE_ID ?? "local-release";
const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`; const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`;
const builtAt = new Date().toISOString(); 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 viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
const viteManifestObject = const viteManifestObject =
/** @type {Record<string, {file: string, name?: string, isDynamicEntry?: boolean}>} */ ( /** @type {Record<string, {file: string, name?: string, isDynamicEntry?: boolean}>} */ (
+417 -28
View File
@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { gzipSync } from "node:zlib"; import { gzipSync } from "node:zlib";
import { import {
@@ -9,8 +10,21 @@ import {
} from "node:fs/promises"; } from "node:fs/promises";
import path from "node:path"; 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[]>} */ /** @param {string} directory @returns {Promise<string[]>} */
async function filesWithin(directory) { async function filesWithin(directory) {
try {
const entries = await readdir(directory, { withFileTypes: true }); const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all( const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => { entries.map((entry) => {
@@ -19,37 +33,381 @@ async function filesWithin(directory) {
}), }),
)); ));
return nested.flat().sort(); 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 packageJson = JSON.parse(await readFile("package.json", "utf8"));
const lockfile = await readFile("pnpm-lock.yaml");
const outputFiles = await filesWithin("dist"); 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( const outputs = await Promise.all(
outputFiles.map(async (outputFile) => { outputFiles.map(async (outputFile) => {
const content = await readFile(outputFile); const content = await readFile(outputFile);
const metadata = await stat(outputFile); const metadata = await stat(outputFile);
return { return {
path: outputFile, path: outputFile.replaceAll("\\", "/"),
bytes: metadata.size, bytes: metadata.size,
gzipBytes: gzipSync(content).byteLength, gzipBytes: gzipSync(content).byteLength,
sha256: createHash("sha256").update(content).digest("hex"), 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 = { const baseline = await optionalJson(
...packageJson.dependencies, "config/security/dependency-baseline.json",
...packageJson.devDependencies, );
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/performance", { recursive: true });
await mkdir("artifacts/release", { recursive: true }); await mkdir("artifacts/release", { recursive: true });
await mkdir("artifacts/security", { recursive: true }); await mkdir("artifacts/security", { recursive: true });
await writeFile( await writeFile(
"artifacts/performance/bundle.json", "artifacts/performance/bundle.json",
`${JSON.stringify( `${JSON.stringify(
@@ -59,7 +417,8 @@ await writeFile(
context: { context: {
nodeVersion: process.version, nodeVersion: process.version,
packageManager: packageJson.packageManager, packageManager: packageJson.packageManager,
runnerImage: process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`, runnerImage:
process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
}, },
outputs, outputs,
}, },
@@ -67,36 +426,66 @@ await writeFile(
2, 2,
)}\n`, )}\n`,
); );
await writeFile( await writeFile(
"artifacts/release/dependency-inventory.json", "artifacts/release/dependency-inventory.json",
`${JSON.stringify( `${JSON.stringify(inventory, null, 2)}\n`,
{ );
schemaVersion: 1, await writeFile(
lockfileSha256: createHash("sha256").update(lockfile).digest("hex"), "artifacts/release/sbom.cdx.json",
dependencies: inventory, `${JSON.stringify(sbom, null, 2)}\n`,
}, );
null, await writeFile(
2, "artifacts/release/provenance.json",
)}\n`, `${JSON.stringify(provenance, null, 2)}\n`,
); );
await writeFile( await writeFile(
"artifacts/release/checksums.txt", "artifacts/release/checksums.txt",
`${outputs.map((output) => `${output.sha256} ${output.path}`).join("\n")}\n`, `${outputs.map((output) => `${output.sha256} ${output.path}`).join("\n")}\n`,
); );
await writeFile( await writeFile(
"artifacts/security/dependency-diff.json", "artifacts/security/dependency-diff.json",
`${JSON.stringify( `${JSON.stringify(
{ {
schemaVersion: 1, schemaVersion: 2,
reviewStatus: "local-baseline", baselineDigest: baseline ? supplyChainDigest(baseline) : null,
directDependencies: inventory.length, currentDigest: supplyChainDigest(inventory),
highRiskUnreviewed: [], ...dependencyDiff,
lockfileSha256: createHash("sha256").update(lockfile).digest("hex"), highRisk: reviewResult.highRisk,
reviewFailures: reviewResult.failures,
}, },
null, null,
2, 2,
)}\n`, )}\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`,
);
+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),
});
}
+134 -29
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"; import path from "node:path";
const scanRoots = ["src", "dist"]; /** @param {string} name @param {string} fallback */
const findings = /** @type {Array<{ruleId: string, file: string}>} */ ([]); 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 = [ 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: "aws-access-key", expression: /\bAKIA[0-9A-Z]{16}\b/g },
{ id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g }, { id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g },
{ {
@@ -14,35 +41,101 @@ const patterns = [
}, },
]; ];
/** @param {string} directory @returns {Promise<string[]>} */ /** @param {string} target @returns {Promise<string[]>} */
async function filesWithin(directory) { async function filesWithin(target) {
const entries = await readdir(directory, { withFileTypes: true }); 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( const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => { entries.map((entry) => {
const target = path.join(directory, entry.name); const child = path.join(target, entry.name);
return entry.isDirectory() ? filesWithin(target) : [target]; return entry.isDirectory() ? filesWithin(child) : [child];
}), }),
)); ));
return nested.flat(); return nested.flat();
} catch {
return [];
}
} }
for (const root of scanRoots) { const excluded = new Set(
for (const scanFile of await filesWithin(root)) { /** @type {string[]} */ (policy.excludedPaths ?? []).map((entry) =>
if (/\.(png|jpg|jpeg|gif|woff2?|zip)$/i.test(scanFile)) continue; entry.replaceAll("\\", "/"),
const content = await readFile(scanFile, "utf8"); ),
);
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) { for (const pattern of patterns) {
pattern.expression.lastIndex = 0; pattern.expression.lastIndex = 0;
if (pattern.expression.test(content)) { for (const match of content.matchAll(pattern.expression)) {
findings.push({ ruleId: pattern.id, file: scanFile }); 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 = { const sarif = {
version: "2.1.0", version: "2.1.0",
$schema: $schema: "https://json.schemastore.org/sarif-2.1.0.json",
"https://json.schemastore.org/sarif-2.1.0.json",
runs: [ runs: [
{ {
tool: { tool: {
@@ -54,29 +147,41 @@ const sarif = {
})), })),
}, },
}, },
results: findings.map((finding) => ({ results: [
...findings.map((finding) => ({
ruleId: finding.ruleId, ruleId: finding.ruleId,
message: { text: "Potential secret material must be removed." }, message: {
text: "Potential secret material must be removed.",
},
partialFingerprints: {
primaryLocationLineHash: finding.fingerprint,
},
locations: [ locations: [
{ {
physicalLocation: { physicalLocation: {
artifactLocation: { uri: finding.file }, artifactLocation: { uri: finding.file },
region: { startLine: finding.line },
}, },
}, },
], ],
})), })),
...policyFailures.map((failure) => ({
ruleId: "invalid-allowlist",
message: { text: failure },
})),
],
}, },
], ],
}; };
await mkdir("artifacts/security", { recursive: true }); await mkdir(path.dirname(artifactPath), { recursive: true });
await writeFile( await writeFile(artifactPath, `${JSON.stringify(sarif, null, 2)}\n`);
"artifacts/security/scan.sarif", if (findings.length > 0 || policyFailures.length > 0) {
`${JSON.stringify(sarif, null, 2)}\n`, process.stderr.write(
); `Security scan found ${findings.length + policyFailures.length} blocking result(s).\n`,
);
if (findings.length > 0) {
process.stderr.write(`Security scan found ${findings.length} blocking result(s).\n`);
process.exit(1); 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`,
);
+59 -7
View File
@@ -14,9 +14,17 @@ const fixtureRoot = path.resolve(".tmp/reference-feature-removal");
const pnpmCli = /** @type {string} */ (process.env.npm_execpath); const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
const featureSource = "src/features/reference-feature"; const featureSource = "src/features/reference-feature";
const featureTests = "tests/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 = [ const copyTargets = [
"src", "src",
"tests", "tests",
"recipes",
"scripts", "scripts",
"config", "config",
"public", "public",
@@ -27,6 +35,7 @@ const copyTargets = [
"tsconfig.app.json", "tsconfig.app.json",
"tsconfig.node.json", "tsconfig.node.json",
"tsconfig.test.json", "tsconfig.test.json",
"tsconfig.recipes.json",
"vite.config.js", "vite.config.js",
"vitest.config.js", "vitest.config.js",
"playwright.config.js", "playwright.config.js",
@@ -36,6 +45,7 @@ const copyTargets = [
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.js"; const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.js";
import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.js"; import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.js";
import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.js";
export const INSTALLED_FEATURE_CONTRACTS = export const INSTALLED_FEATURE_CONTRACTS =
/** @type {readonly unknown[]} */ (Object.freeze([])); /** @type {readonly unknown[]} */ (Object.freeze([]));
@@ -43,6 +53,7 @@ export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY;
export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT; export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
export const API_OPERATIONS = Object.freeze({}); export const API_OPERATIONS = Object.freeze({});
export const QUERY_REGISTRY = Object.freeze({}); export const QUERY_REGISTRY = Object.freeze({});
export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY;
export const NAVIGATION_ROUTES = Object.freeze( export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY) Object.values(ROUTE_REGISTRY)
.filter((definition) => definition.navigationOrder !== null) .filter((definition) => definition.navigationOrder !== null)
@@ -84,6 +95,12 @@ export function createInstalledFeatureInputs(_context: FeatureContext) {
} }
`; `;
const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
"ko-KR": Object.freeze({}),
"en-US": Object.freeze({}),
});
`;
/** @param {string} directory @returns {Promise<string[]>} */ /** @param {string} directory @returns {Promise<string[]>} */
async function filesBelow(directory) { async function filesBelow(directory) {
const entries = await readdir(directory, { withFileTypes: true }); const entries = await readdir(directory, { withFileTypes: true });
@@ -112,14 +129,12 @@ for (const target of copyTargets) {
} }
await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir"); await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
await rm(path.join(fixtureRoot, featureSource), { for (const ownedPath of featureOwnedPaths) {
await rm(path.join(fixtureRoot, ownedPath), {
recursive: true, recursive: true,
force: true, force: true,
}); });
await rm(path.join(fixtureRoot, featureTests), { }
recursive: true,
force: true,
});
await writeFile( await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-contracts.js"), path.join(fixtureRoot, "src/features/installed-feature-contracts.js"),
emptyContracts, emptyContracts,
@@ -132,6 +147,43 @@ await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-adapters.ts"), path.join(fixtureRoot, "src/features/installed-feature-adapters.ts"),
emptyAdapters, 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[]} */ /** @type {string[]} */
const residue = []; const residue = [];
@@ -152,7 +204,7 @@ for (const root of ["src", "tests"]) {
const checks = [ const checks = [
["typecheck", runPnpm("check:types")], ["typecheck", runPnpm("check:types")],
["architecture", runPnpm("check:architecture")], ["architecture", runPnpm("check:architecture")],
["registry", runPnpm("check:registries")], ["registry-structure", runPnpm("check:registries:structure")],
["unit-integration", runPnpm("test:all")], ["unit-integration", runPnpm("test:all")],
[ [
"home-smoke", "home-smoke",
+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`);
+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");
@@ -0,0 +1,102 @@
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.js";
import {
projectDiagnosticRecord,
safeErrorKind,
type DiagnosticRecord,
type DiagnosticRecordInput,
} from "../../contracts/diagnostics.js";
import { projectTelemetryEvent } from "../../contracts/telemetry.js";
export const noOpDiagnostics: DiagnosticsPort = Object.freeze({
record() {},
});
export function createDiagnosticsAdapter(
options: Readonly<{
maxEntries?: number;
now?: () => number;
sink?: (record: DiagnosticRecord) => void;
}> = {},
) {
const maxEntries = Math.max(1, options.maxEntries ?? 100);
const entries: DiagnosticRecord[] = [];
const droppedReasons = new Map<string, number>();
function drop(reason: string) {
droppedReasons.set(reason, (droppedReasons.get(reason) ?? 0) + 1);
}
function record(input: DiagnosticRecordInput) {
try {
const projected = projectDiagnosticRecord(input, options.now);
if (!projected.success) {
drop(projected.reason);
return;
}
if (entries.length >= maxEntries) {
entries.shift();
drop("queue-full");
}
entries.push(projected.record);
try {
options.sink?.(projected.record);
} catch {
drop("sink-failure");
}
} catch {
drop("serialization-failure");
}
}
return Object.freeze({
record,
entries: () => structuredClone(entries) as readonly DiagnosticRecord[],
dropped: () => Object.freeze(Object.fromEntries(droppedReasons)),
});
}
type BootSafeContext = Readonly<{
kind?: string;
buildId?: string;
configSchemaVersion?: string;
supportReference?: string;
}>;
let lastBootEvidence:
| Readonly<{
diagnostic: DiagnosticRecord | null;
telemetry: Readonly<Record<string, unknown>> | null;
}>
| undefined;
export function recordBootFailure(
error: unknown,
safe: BootSafeContext,
now: () => number = Date.now,
) {
const errorKind =
typeof safe.kind === "string" ? safe.kind : safeErrorKind(error);
const attributes = {
error_kind: errorKind,
build_id: safe.buildId ?? "unknown",
config_schema_version: safe.configSchemaVersion ?? "unknown",
};
const diagnostic = projectDiagnosticRecord(
{
level: "error",
eventId: "app.boot.failed",
context: attributes,
},
now,
);
const telemetry = projectTelemetryEvent("app.boot.failed", attributes, now);
lastBootEvidence = Object.freeze({
diagnostic: diagnostic.success ? diagnostic.record : null,
telemetry: telemetry.success ? telemetry.event : null,
});
return lastBootEvidence;
}
export function getLastBootEvidence() {
return lastBootEvidence ? structuredClone(lastBootEvidence) : undefined;
}
+98 -11
View File
@@ -4,6 +4,7 @@ import {
createFailure as failure, createFailure as failure,
kindForStatus as statusKind, kindForStatus as statusKind,
normalizeUnknownFailure, normalizeUnknownFailure,
safeValidationIssues,
} from "../../contracts/errors.js"; } from "../../contracts/errors.js";
import { mapOperationPayload } from "./resource-mapper.js"; import { mapOperationPayload } from "./resource-mapper.js";
import { retryDelay, shouldRetry } from "./retry-policy.js"; import { retryDelay, shouldRetry } from "./retry-policy.js";
@@ -13,6 +14,11 @@ import {
validateOperationRequest, validateOperationRequest,
} from "./schema-registry.js"; } from "./schema-registry.js";
import { buildRequestTarget } from "./request-builder.js"; import { buildRequestTarget } from "./request-builder.js";
import {
attemptBucket,
durationBucket,
statusGroup,
} from "../../contracts/diagnostics.js";
const noAuthSession = const noAuthSession =
/** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({ /** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({
@@ -53,7 +59,10 @@ const noAuthSession =
* timeoutMs?: number, * timeoutMs?: number,
* maxRetryAttempts?: number, * maxRetryAttempts?: number,
* scheduler?: Scheduler, * scheduler?: Scheduler,
* getOperation?: typeof getApiOperation * getOperation?: typeof getApiOperation,
* diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* correlationIdFactory?: () => string
* }} dependencies * }} dependencies
*/ */
export function createHttpClient(dependencies) { export function createHttpClient(dependencies) {
@@ -71,6 +80,11 @@ export function createHttpClient(dependencies) {
const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000; const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000;
const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2; const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2;
const selectOperation = dependencies.getOperation ?? getApiOperation; const selectOperation = dependencies.getOperation ?? getApiOperation;
const diagnostics = dependencies.diagnostics;
const telemetry = dependencies.telemetry;
const correlationIdFactory =
dependencies.correlationIdFactory ??
(() => `request-${Math.floor(random() * 1_000_000).toString(36)}`);
const scheduler = const scheduler =
dependencies.scheduler ?? dependencies.scheduler ??
/** @type {Scheduler} */ ({ /** @type {Scheduler} */ ({
@@ -90,7 +104,8 @@ export function createHttpClient(dependencies) {
* pathParams?: Record<string, string | number>, * pathParams?: Record<string, string | number>,
* searchParams?: unknown, * searchParams?: unknown,
* signal?: AbortSignal, * signal?: AbortSignal,
* idempotencyKey?: string * idempotencyKey?: string,
* correlationId?: string
* }} [legacyInput] * }} [legacyInput]
* @returns {Promise<HttpResult>} * @returns {Promise<HttpResult>}
*/ */
@@ -105,9 +120,55 @@ export function createHttpClient(dependencies) {
body: legacyInput.body, body: legacyInput.body,
signal: legacyInput.signal, signal: legacyInput.signal,
idempotencyKey: legacyInput.idempotencyKey, idempotencyKey: legacyInput.idempotencyKey,
correlationId: legacyInput.correlationId,
} }
: request; : request;
const operation = selectOperation(input.operationId); const operation = selectOperation(input.operationId);
const startedAt = clock.now();
const correlationId = input.correlationId ?? correlationIdFactory();
/**
* @param {HttpResult} outcome
* @param {"success" | "recovered" | "failed" | "aborted"} outcomeKind
*/
function finalize(outcome, outcomeKind) {
const error = outcome.ok ? undefined : outcome.error;
const context = {
route_id: input.routeId,
operation_id: input.operationId,
correlation_id: correlationId,
outcome: outcomeKind,
error_kind: error?.kind ?? "NONE",
http_status_group: statusGroup(error?.httpStatus),
attempt_count_bucket: attemptBucket(
error?.attemptCount ?? retryCount + 1,
),
duration_bucket: durationBucket(clock.now() - startedAt),
};
try {
diagnostics?.record({
level: error ? "warn" : "info",
eventId: "http.request.completed",
context,
});
} catch {
// Diagnostics cannot change the HTTP result.
}
if (error && outcomeKind !== "aborted") {
try {
telemetry?.emit("api.request.failed", {
error_kind: context.error_kind,
http_status_group: context.http_status_group,
attempt_count_bucket: context.attempt_count_bucket,
route_id: context.route_id,
operation_id: context.operation_id,
duration_bucket: context.duration_bucket,
});
} catch {
// Telemetry cannot change the HTTP result.
}
}
return outcome;
}
const logicalIdempotencyKey = const logicalIdempotencyKey =
operation.idempotency === "keyed" operation.idempotency === "keyed"
? input.idempotencyKey ?? idempotencyKeyFactory() ? input.idempotencyKey ?? idempotencyKeyFactory()
@@ -125,28 +186,40 @@ export function createHttpClient(dependencies) {
idempotencyKey: logicalIdempotencyKey, idempotencyKey: logicalIdempotencyKey,
}); });
if (outcome.ok) return outcome; if (outcome.ok) {
return finalize(
outcome,
retryCount > 0 || recoveryUsed ? "recovered" : "success",
);
}
if (outcome.error.httpStatus === 401 && !recoveryUsed) { if (outcome.error.httpStatus === 401 && !recoveryUsed) {
recoveryUsed = true; recoveryUsed = true;
const recovered = await recoverSession(authSession, operation, outcome.error); const recovered = await recoverSession(
if (!recovered.ok) return recovered; authSession,
operation,
outcome.error,
);
if (!recovered.ok) return finalize(recovered, "failed");
if (operation.idempotency === "none") { if (operation.idempotency === "none") {
return { return finalize(
{
ok: false, ok: false,
error: { error: {
...outcome.error, ...outcome.error,
retryable: false, retryable: false,
action: "retry", action: "retry",
}, },
}; },
"failed",
);
} }
continue; continue;
} }
if (outcome.error.httpStatus === 401 && recoveryUsed) { if (outcome.error.httpStatus === 401 && recoveryUsed) {
authSession.onUnauthenticated(); authSession.onUnauthenticated();
return outcome; return finalize(outcome, "failed");
} }
if ( if (
@@ -157,7 +230,10 @@ export function createHttpClient(dependencies) {
maxRetryAttempts, maxRetryAttempts,
) )
) { ) {
return outcome; return finalize(
outcome,
outcome.error.kind === "REQUEST_ABORTED" ? "aborted" : "failed",
);
} }
const delay = retryDelay(outcome.error, retryCount, random, clock.now()); const delay = retryDelay(outcome.error, retryCount, random, clock.now());
@@ -166,12 +242,15 @@ export function createHttpClient(dependencies) {
try { try {
await clock.sleep(delay, input.signal); await clock.sleep(delay, input.signal);
} catch { } catch {
return { return finalize(
{
ok: false, ok: false,
error: failure("REQUEST_ABORTED", input.operationId, retryCount, { error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
code: "REQUEST_ABORTED", code: "REQUEST_ABORTED",
}), }),
}; },
"aborted",
);
} }
} }
} }
@@ -415,6 +494,10 @@ async function parseResponse(
const kind = statusKind(response.status); const kind = statusKind(response.status);
const retryAfter = response.headers.get("retry-after"); const retryAfter = response.headers.get("retry-after");
const backendError =
envelopeRecord.error && typeof envelopeRecord.error === "object"
? /** @type {Record<string, unknown>} */ (envelopeRecord.error)
: {};
return { return {
ok: false, ok: false,
error: failure(kind, operation.operationId, attempt, { error: failure(kind, operation.operationId, attempt, {
@@ -426,6 +509,10 @@ async function parseResponse(
response.status === 429 && retryAfter response.status === 429 && retryAfter
? parseRetryAfterHeader(retryAfter) ? parseRetryAfterHeader(retryAfter)
: undefined, : undefined,
validationIssues:
response.status === 422
? safeValidationIssues(backendError.details)
: undefined,
}), }),
}; };
} }
+1
View File
@@ -8,6 +8,7 @@ export type OperationRequestInput = Readonly<{
body?: unknown; body?: unknown;
signal?: AbortSignal; signal?: AbortSignal;
idempotencyKey?: string; idempotencyKey?: string;
correlationId?: string;
}>; }>;
export type RequestTargetResult = export type RequestTargetResult =
@@ -1,6 +1,7 @@
import { QueryClient } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { createFailure } from "../../contracts/errors.js"; import { createFailure } from "../../contracts/errors.js";
import { safeErrorKind } from "../../contracts/diagnostics.js";
export const QUERY_CACHE_DEFAULTS = Object.freeze({ export const QUERY_CACHE_DEFAULTS = Object.freeze({
staleTime: 30_000, staleTime: 30_000,
@@ -11,8 +12,32 @@ export const QUERY_CACHE_DEFAULTS = Object.freeze({
persistence: false, persistence: false,
}); });
export function createQueryClient() { /**
* @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies]
*/
export function createQueryClient(dependencies = {}) {
/** @param {string} operation @param {unknown} error */
function report(operation, error) {
try {
dependencies.diagnostics?.record({
level: "warn",
eventId: "cache.operation.failed",
context: {
operation,
error_kind: safeErrorKind(error),
},
});
} catch {
// Query behavior remains independent from diagnostics.
}
}
return new QueryClient({ return new QueryClient({
queryCache: new QueryCache({
onError: (error) => report("query", error),
}),
mutationCache: new MutationCache({
onError: (error) => report("mutation", error),
}),
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: QUERY_CACHE_DEFAULTS.staleTime, staleTime: QUERY_CACHE_DEFAULTS.staleTime,
@@ -29,15 +54,16 @@ export function createQueryClient() {
/** /**
* @param {QueryClient} queryClient * @param {QueryClient} queryClient
* @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies]
* @returns {import("../../application/ports/query-cache-port.js").QueryCachePort} * @returns {import("../../application/ports/query-cache-port.js").QueryCachePort}
*/ */
export function createQueryCacheAdapter(queryClient) { export function createQueryCacheAdapter(queryClient, dependencies = {}) {
return Object.freeze({ return Object.freeze({
read(key) { read(key) {
try { try {
return { ok: true, value: queryClient.getQueryData(key) }; return { ok: true, value: queryClient.getQueryData(key) };
} catch { } catch {
return cacheFailure("read", key); return cacheFailure("read", key, dependencies.diagnostics);
} }
}, },
write(key, value) { write(key, value) {
@@ -45,7 +71,7 @@ export function createQueryCacheAdapter(queryClient) {
queryClient.setQueryData(key, structuredClone(value)); queryClient.setQueryData(key, structuredClone(value));
return { ok: true }; return { ok: true };
} catch { } catch {
return cacheFailure("write", key); return cacheFailure("write", key, dependencies.diagnostics);
} }
}, },
async invalidate(namespace) { async invalidate(namespace) {
@@ -53,15 +79,31 @@ export function createQueryCacheAdapter(queryClient) {
await queryClient.invalidateQueries({ queryKey: namespace, exact: false }); await queryClient.invalidateQueries({ queryKey: namespace, exact: false });
return { ok: true }; return { ok: true };
} catch { } catch {
return cacheFailure("invalidate", namespace); return cacheFailure("invalidate", namespace, dependencies.diagnostics);
} }
}, },
}); });
} }
/** @param {string} phase @param {readonly unknown[]} key */ /**
function cacheFailure(phase, key) { * @param {string} phase
* @param {readonly unknown[]} key
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
*/
function cacheFailure(phase, key, diagnostics) {
const namespace = typeof key[0] === "string" ? key[0] : "unknown"; const namespace = typeof key[0] === "string" ? key[0] : "unknown";
try {
diagnostics?.record({
level: "warn",
eventId: "cache.operation.failed",
context: {
operation: phase,
error_kind: "QUERY_CACHE_FAILURE",
},
});
} catch {
// Cache behavior remains independent from diagnostics.
}
return { return {
ok: /** @type {false} */ (false), ok: /** @type {false} */ (false),
error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, { error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, {
@@ -5,7 +5,8 @@ import { getStorageDefinition } from "../../contracts/storage-keys.js";
* @param {{ * @param {{
* localStorage?: Storage, * localStorage?: Storage,
* sessionStorage?: Storage, * sessionStorage?: Storage,
* now?: () => number * now?: () => number,
* diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort
* }} [dependencies] * }} [dependencies]
* @returns {import("../../application/ports/storage-port.js").StoragePort} * @returns {import("../../application/ports/storage-port.js").StoragePort}
*/ */
@@ -26,7 +27,7 @@ export function createBrowserStorageAdapter(dependencies = {}) {
try { try {
definition = getStorageDefinition(logicalName); definition = getStorageDefinition(logicalName);
} catch { } catch {
return unavailable("read", logicalName); return unavailable("read", logicalName, dependencies.diagnostics);
} }
const backend = backendFor(definition.backend); const backend = backendFor(definition.backend);
@@ -50,7 +51,7 @@ export function createBrowserStorageAdapter(dependencies = {}) {
} }
return { ok: true, value: structuredClone(envelope.value) }; return { ok: true, value: structuredClone(envelope.value) };
} catch { } catch {
return unavailable("read", logicalName); return unavailable("read", logicalName, dependencies.diagnostics);
} }
}, },
@@ -59,7 +60,7 @@ export function createBrowserStorageAdapter(dependencies = {}) {
try { try {
definition = getStorageDefinition(logicalName); definition = getStorageDefinition(logicalName);
} catch { } catch {
return unavailable("write", logicalName); return unavailable("write", logicalName, dependencies.diagnostics);
} }
const expiresAt = const expiresAt =
@@ -82,12 +83,24 @@ export function createBrowserStorageAdapter(dependencies = {}) {
if (definition.quotaFallback === "memory") { if (definition.quotaFallback === "memory") {
memory.set(definition.physicalKey, structuredClone(value)); memory.set(definition.physicalKey, structuredClone(value));
recordStorageFailure(
dependencies.diagnostics,
"write",
logicalName,
quota,
);
return { return {
ok: false, ok: false,
error: storageFailure(quota, "write", logicalName), error: storageFailure(quota, "write", logicalName),
fallback: "memory", fallback: "memory",
}; };
} }
recordStorageFailure(
dependencies.diagnostics,
"write",
logicalName,
quota,
);
return { return {
ok: false, ok: false,
error: storageFailure(quota, "write", logicalName), error: storageFailure(quota, "write", logicalName),
@@ -101,14 +114,14 @@ export function createBrowserStorageAdapter(dependencies = {}) {
try { try {
definition = getStorageDefinition(logicalName); definition = getStorageDefinition(logicalName);
} catch { } catch {
return unavailable("remove", logicalName); return unavailable("remove", logicalName, dependencies.diagnostics);
} }
try { try {
backendFor(definition.backend)?.removeItem(definition.physicalKey); backendFor(definition.backend)?.removeItem(definition.physicalKey);
memory.delete(definition.physicalKey); memory.delete(definition.physicalKey);
return { ok: true }; return { ok: true };
} catch { } catch {
return unavailable("remove", logicalName); return unavailable("remove", logicalName, dependencies.diagnostics);
} }
}, },
}); });
@@ -128,10 +141,38 @@ function storageFailure(quota, phase, logicalName) {
); );
} }
/** @param {string} phase @param {string} logicalName */ /**
function unavailable(phase, logicalName) { * @param {string} phase
* @param {string} logicalName
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
*/
function unavailable(phase, logicalName, diagnostics) {
recordStorageFailure(diagnostics, phase, logicalName, false);
return { return {
ok: /** @type {false} */ (false), ok: /** @type {false} */ (false),
error: storageFailure(false, phase, logicalName), error: storageFailure(false, phase, logicalName),
}; };
} }
/**
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
* @param {string} phase
* @param {string} logicalName
* @param {boolean} quota
*/
function recordStorageFailure(diagnostics, phase, logicalName, quota) {
try {
diagnostics?.record({
level: "warn",
eventId: "storage.operation.failed",
context: {
operation: `${phase}:${logicalName}`,
error_kind: quota
? "STORAGE_QUOTA_EXCEEDED"
: "STORAGE_UNAVAILABLE",
},
});
} catch {
// Storage behavior remains independent from diagnostics.
}
}
+76 -13
View File
@@ -1,7 +1,14 @@
import { projectTelemetryEvent } from "../../contracts/telemetry.js"; import { projectTelemetryEvent } from "../../contracts/telemetry.js";
import { queueSizeBucket } from "../../contracts/diagnostics.js";
export const noOpTelemetry = Object.freeze({ export const noOpTelemetry = Object.freeze({
emit: () => {}, emit: () => {},
flush: async () => {},
pendingCount: () => 0,
droppedCount: () => 0,
dropReasons: () => Object.freeze({}),
deliveryEvidence: () => null,
dispose: () => {},
}); });
/** /**
@@ -10,22 +17,20 @@ export const noOpTelemetry = Object.freeze({
* endpoint?: string, * endpoint?: string,
* fetcher?: typeof fetch, * fetcher?: typeof fetch,
* maxQueue?: number, * maxQueue?: number,
* schedule?: (callback: () => void) => void * schedule?: (callback: () => void) => void,
* now?: () => number,
* onDrop?: (event: Readonly<Record<string, unknown>>) => void,
* lifecycle?: Pick<EventTarget, "addEventListener" | "removeEventListener">
* }} options * }} options
*/ */
export function createTelemetryAdapter(options) { export function createTelemetryAdapter(options) {
if (!options.enabled || !options.endpoint) { if (!options.enabled || !options.endpoint) {
return Object.freeze({ return noOpTelemetry;
...noOpTelemetry,
flush: async () => {},
pendingCount: () => 0,
droppedCount: () => 0,
});
} }
const endpoint = /** @type {string} */ (options.endpoint); const endpoint = /** @type {string} */ (options.endpoint);
const fetcher = options.fetcher ?? fetch; const fetcher = options.fetcher ?? fetch;
const maxQueue = options.maxQueue ?? 100; const maxQueue = Math.max(1, options.maxQueue ?? 100);
const schedule = options.schedule ?? queueMicrotask; const schedule = options.schedule ?? queueMicrotask;
const queue = const queue =
/** @type {Array<{eventName: string, attributes: Readonly<Record<string, unknown>>}>} */ ( /** @type {Array<{eventName: string, attributes: Readonly<Record<string, unknown>>}>} */ (
@@ -34,18 +39,63 @@ export function createTelemetryAdapter(options) {
let scheduled = false; let scheduled = false;
let flushing = false; let flushing = false;
let dropped = 0; let dropped = 0;
const dropReasons = new Map();
let lastDeliveryEvidence =
/** @type {Readonly<Record<string, unknown>> | null} */ (null);
const lifecycle =
options.lifecycle ??
(typeof globalThis.addEventListener === "function" &&
typeof globalThis.removeEventListener === "function"
? globalThis
: undefined);
/** @param {string} reason @param {number} count */
function recordDrop(reason, count = 1) {
const safeReason =
{
"queue-full": "queue-full",
"sink-failure": "sink-failure",
"serialization-failure": "serialization-failure",
"unknown-attributes": "invalid-context",
"invalid-attribute-value": "invalid-context",
"missing-required-attributes": "invalid-context",
"unregistered-event": "invalid-event",
}[reason] ?? "invalid-event";
dropped += count;
dropReasons.set(safeReason, (dropReasons.get(safeReason) ?? 0) + count);
const internal = projectTelemetryEvent(
"telemetry.delivery.dropped",
{
reason: safeReason,
queue_size_bucket: queueSizeBucket(queue.length),
},
options.now,
);
if (internal.success) {
lastDeliveryEvidence = internal.event;
try {
options.onDrop?.(internal.event);
} catch {
// Drop observers are deliberately nonrecursive.
}
}
}
/** @param {string} eventName @param {Record<string, unknown>} attributes */ /** @param {string} eventName @param {Record<string, unknown>} attributes */
function emit(eventName, attributes) { function emit(eventName, attributes) {
const projected = projectTelemetryEvent(eventName, attributes); const projected = projectTelemetryEvent(
eventName,
attributes,
options.now,
);
if (!projected.success) { if (!projected.success) {
dropped += 1; recordDrop(projected.reason);
return; return;
} }
if (queue.length >= maxQueue) { if (queue.length >= maxQueue) {
queue.shift(); queue.shift();
dropped += 1; recordDrop("queue-full");
} }
queue.push(projected.event); queue.push(projected.event);
@@ -69,19 +119,32 @@ export function createTelemetryAdapter(options) {
body: JSON.stringify({ events: batch }), body: JSON.stringify({ events: batch }),
keepalive: true, keepalive: true,
}); });
if (!response.ok) dropped += batch.length; if (!response.ok) recordDrop("sink-failure", batch.length);
} catch { } catch {
dropped += batch.length; recordDrop("sink-failure", batch.length);
} finally { } finally {
flushing = false; flushing = false;
} }
} }
const flushBeforePageExit = () => {
void flush();
};
lifecycle?.addEventListener("pagehide", flushBeforePageExit);
function dispose() {
lifecycle?.removeEventListener("pagehide", flushBeforePageExit);
}
return Object.freeze({ return Object.freeze({
emit, emit,
flush, flush,
pendingCount: () => queue.length, pendingCount: () => queue.length,
droppedCount: () => dropped, droppedCount: () => dropped,
dropReasons: () => Object.freeze(Object.fromEntries(dropReasons)),
deliveryEvidence: () =>
lastDeliveryEvidence ? structuredClone(lastDeliveryEvidence) : null,
dispose,
}); });
} }
+60 -1
View File
@@ -3,6 +3,7 @@ import type {
ApplicationApi, ApplicationApi,
ColorSchemePreference, ColorSchemePreference,
RenderFailureReport, RenderFailureReport,
RouteChangedReport,
} from "./ports/in/application-api.js"; } from "./ports/in/application-api.js";
import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js"; import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js";
import { decideChunkRecovery } from "./use-cases/decide-chunk-recovery.js"; import { decideChunkRecovery } from "./use-cases/decide-chunk-recovery.js";
@@ -43,7 +44,20 @@ export function createApplication(
const diagnostics = Object.freeze({ const diagnostics = Object.freeze({
reportRenderFailure(report: RenderFailureReport) { reportRenderFailure(report: RenderFailureReport) {
try { try {
outputPorts.diagnostics.emit("ui.render.failed", { outputPorts.diagnostics.record({
level: "error",
eventId: "ui.render.failed",
context: {
route_id: report.routeId,
build_id: report.buildId,
component_boundary: report.boundaryName,
},
});
} catch {
// Diagnostics are best-effort and cannot become an application failure.
}
try {
outputPorts.telemetry.emit("ui.render.failed", {
route_id: report.routeId, route_id: report.routeId,
build_id: report.buildId, build_id: report.buildId,
component_boundary: report.boundaryName, component_boundary: report.boundaryName,
@@ -52,6 +66,20 @@ export function createApplication(
// Diagnostics are best-effort and cannot become an application failure. // Diagnostics are best-effort and cannot become an application failure.
} }
}, },
reportRouteChanged(report: RouteChangedReport) {
try {
outputPorts.diagnostics.record({
level: "info",
eventId: "route.changed",
context: {
route_id: report.routeId,
build_id: report.buildId,
},
});
} catch {
// Diagnostics are best-effort and cannot become navigation failure.
}
},
}); });
const runtime = Object.freeze({ const runtime = Object.freeze({
@@ -74,6 +102,37 @@ export function createApplication(
try { try {
const current = await outputPorts.releaseInfo.getCurrent(); const current = await outputPorts.releaseInfo.getCurrent();
const active = await outputPorts.releaseInfo.refresh(); const active = await outputPorts.releaseInfo.refresh();
if (
current.buildId !== active.buildId ||
current.releaseId !== active.releaseId
) {
const mismatchKind =
current.buildId !== active.buildId
? "BUILD_MISMATCH"
: "RELEASE_MISMATCH";
try {
outputPorts.diagnostics.record({
level: "warn",
eventId: "release.mismatch.detected",
context: {
build_id: current.buildId,
active_release_id: active.releaseId,
mismatch_kind: mismatchKind,
},
});
} catch {
// Recovery remains independent from diagnostics.
}
try {
outputPorts.telemetry.emit("release.mismatch.detected", {
build_id: current.buildId,
active_release_id: active.releaseId,
mismatch_kind: mismatchKind,
});
} catch {
// Recovery remains independent from telemetry.
}
}
if (!active.routeChunks[input.chunkId]) { if (!active.routeChunks[input.chunkId]) {
return { return {
action: "support" as const, action: "support" as const,
@@ -0,0 +1,5 @@
import type { DiagnosticRecordInput } from "../../contracts/diagnostics.js";
export type DiagnosticsPort = Readonly<{
record(input: DiagnosticRecordInput): void;
}>;
@@ -11,6 +11,11 @@ export type RenderFailureReport = Readonly<{
boundaryName: "route" | "feature"; boundaryName: "route" | "feature";
}>; }>;
export type RouteChangedReport = Readonly<{
routeId: string;
buildId: string;
}>;
export type ReleaseSummary = Readonly<{ export type ReleaseSummary = Readonly<{
buildId: string; buildId: string;
releaseId: string; releaseId: string;
@@ -34,6 +39,7 @@ export type ApplicationApi = Readonly<{
}>; }>;
diagnostics: Readonly<{ diagnostics: Readonly<{
reportRenderFailure(report: RenderFailureReport): void; reportRenderFailure(report: RenderFailureReport): void;
reportRouteChanged(report: RouteChangedReport): void;
}>; }>;
runtime: Readonly<{ runtime: Readonly<{
getReleaseSummary(): Promise<ReleaseSummary>; getReleaseSummary(): Promise<ReleaseSummary>;
@@ -2,6 +2,7 @@ import type { AuthSessionPort } from "../auth-session-port.js";
import type { ReleaseInfoPort } from "../release-info-port.js"; import type { ReleaseInfoPort } from "../release-info-port.js";
import type { StoragePort } from "../storage-port.js"; import type { StoragePort } from "../storage-port.js";
import type { TelemetryPort } from "../telemetry-port.js"; import type { TelemetryPort } from "../telemetry-port.js";
import type { DiagnosticsPort } from "../diagnostics-port.js";
/** /**
* Capabilities required by application use cases. Implementations live in * Capabilities required by application use cases. Implementations live in
@@ -13,7 +14,8 @@ export type ApplicationOutputPorts = Readonly<{
"getState" | "subscribe" | "beginSignIn" | "signOut" | "recover" "getState" | "subscribe" | "beginSignIn" | "signOut" | "recover"
>; >;
preferences: StoragePort; preferences: StoragePort;
diagnostics: TelemetryPort; diagnostics: DiagnosticsPort;
telemetry: TelemetryPort;
releaseInfo: ReleaseInfoPort; releaseInfo: ReleaseInfoPort;
navigation: Readonly<{ reload(): void }>; navigation: Readonly<{ reload(): void }>;
}>; }>;
+1
View File
@@ -9,3 +9,4 @@ export type { QueryCachePort } from "../query-cache-port.js";
export type { ReleaseInfoPort } from "../release-info-port.js"; export type { ReleaseInfoPort } from "../release-info-port.js";
export type { StoragePort } from "../storage-port.js"; export type { StoragePort } from "../storage-port.js";
export type { TelemetryPort } from "../telemetry-port.js"; export type { TelemetryPort } from "../telemetry-port.js";
export type { DiagnosticsPort } from "../diagnostics-port.js";
-5
View File
@@ -1,5 +0,0 @@
/**
* @typedef {{ emit(eventName: string, attributes: Record<string, unknown>): void }} TelemetryPort
*/
export {};
+10
View File
@@ -0,0 +1,10 @@
import type { TelemetryEventName } from "../../contracts/telemetry.js";
export type { TelemetryEventName };
export type TelemetryPort = Readonly<{
emit(
eventName: TelemetryEventName,
attributes: Record<string, unknown>,
): void;
}>;
+4
View File
@@ -1,5 +1,6 @@
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { recordBootFailure } from "../adapters/diagnostics/bounded-diagnostics.js";
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx"; import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx";
import { createRuntimeComposition } from "./create-runtime-composition.js"; import { createRuntimeComposition } from "./create-runtime-composition.js";
import { initializeColorScheme } from "./initialize-color-scheme.js"; import { initializeColorScheme } from "./initialize-color-scheme.js";
@@ -19,6 +20,8 @@ const root = createRoot(rootElement);
async function boot() { async function boot() {
try { try {
const composition = await createRuntimeComposition(); const composition = await createRuntimeComposition();
document.documentElement.dataset.buildId = composition.release.buildId;
document.documentElement.dataset.releaseId = composition.release.releaseId;
initializeColorScheme(composition.application.preferences); initializeColorScheme(composition.application.preferences);
root.render(<RuntimeApplication composition={composition} />); root.render(<RuntimeApplication composition={composition} />);
} catch (error) { } catch (error) {
@@ -27,6 +30,7 @@ async function boot() {
? error.safe ? error.safe
: { supportReference: "boot:unknown" }; : { supportReference: "boot:unknown" };
recordBootFailure(error, safe);
root.render(<BootErrorShell {...safe} />); root.render(<BootErrorShell {...safe} />);
} }
} }
+27 -6
View File
@@ -4,6 +4,7 @@ import {
createUnavailableSessionAdapter, createUnavailableSessionAdapter,
} from "../adapters/auth/external-session-adapter.js"; } from "../adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../adapters/http/client.js"; import { createHttpClient } from "../adapters/http/client.js";
import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.js";
import { import {
createQueryClient, createQueryClient,
} from "../adapters/query-cache/tanstack-query-cache.js"; } from "../adapters/query-cache/tanstack-query-cache.js";
@@ -53,6 +54,8 @@ function storageOrUndefined(value) {
* fetcher?: typeof fetch, * fetcher?: typeof fetch,
* clock?: import("../application/ports/clock-port.js").ClockPort, * clock?: import("../application/ports/clock-port.js").ClockPort,
* scheduler?: Parameters<typeof createHttpClient>[0]["scheduler"] * scheduler?: Parameters<typeof createHttpClient>[0]["scheduler"]
* diagnostics?: Parameters<typeof createHttpClient>[0]["diagnostics"],
* telemetry?: Parameters<typeof createHttpClient>[0]["telemetry"]
* }} context * }} context
*/ */
export function createRuntimeHttpClient(context, contract = {}) { export function createRuntimeHttpClient(context, contract = {}) {
@@ -64,6 +67,8 @@ export function createRuntimeHttpClient(context, contract = {}) {
fetcher: context.fetcher, fetcher: context.fetcher,
clock: context.clock, clock: context.clock,
scheduler: context.scheduler, scheduler: context.scheduler,
diagnostics: context.diagnostics,
telemetry: context.telemetry,
...contract, ...contract,
}); });
} }
@@ -86,15 +91,28 @@ export async function createRuntimeAdapters(context) {
: externalOwner : externalOwner
? createExternalAuthSessionAdapter(externalOwner) ? createExternalAuthSessionAdapter(externalOwner)
: createUnavailableSessionAdapter(); : createUnavailableSessionAdapter();
const queryClient = createQueryClient(); const diagnostics = createDiagnosticsAdapter();
const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(host.localStorage),
sessionStorage: storageOrUndefined(host.sessionStorage),
});
const telemetry = createTelemetryAdapter({ const telemetry = createTelemetryAdapter({
enabled: config.TELEMETRY_ENABLED, enabled: config.TELEMETRY_ENABLED,
endpoint: config.TELEMETRY_ENDPOINT, endpoint: config.TELEMETRY_ENDPOINT,
fetcher: context.fetcher, fetcher: context.fetcher,
onDrop(event) {
const attributes =
event.attributes && typeof event.attributes === "object"
? event.attributes
: {};
diagnostics.record({
level: "warn",
eventId: "telemetry.delivery.dropped",
context: /** @type {Record<string, unknown>} */ (attributes),
});
},
});
const queryClient = createQueryClient({ diagnostics });
const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(host.localStorage),
sessionStorage: storageOrUndefined(host.sessionStorage),
diagnostics,
}); });
const releaseInfo = Object.freeze({ const releaseInfo = Object.freeze({
async getCurrent() { async getCurrent() {
@@ -125,6 +143,8 @@ export async function createRuntimeAdapters(context) {
runtime: context.runtime, runtime: context.runtime,
authSession, authSession,
fetcher: context.fetcher, fetcher: context.fetcher,
diagnostics,
telemetry,
}, },
contract, contract,
), ),
@@ -134,7 +154,8 @@ export async function createRuntimeAdapters(context) {
outputPorts: Object.freeze({ outputPorts: Object.freeze({
session: authSession, session: authSession,
preferences: storage, preferences: storage,
diagnostics: telemetry, diagnostics,
telemetry,
releaseInfo, releaseInfo,
navigation, navigation,
}), }),
+183
View File
@@ -0,0 +1,183 @@
export const DIAGNOSTIC_LEVELS = Object.freeze([
"debug",
"info",
"warn",
"error",
] as const);
export type DiagnosticLevel = (typeof DIAGNOSTIC_LEVELS)[number];
export const DIAGNOSTIC_EVENT_REGISTRY = Object.freeze({
"app.boot.failed": Object.freeze({ level: "error" }),
"http.request.completed": Object.freeze({ level: "info" }),
"cache.operation.failed": Object.freeze({ level: "warn" }),
"storage.operation.failed": Object.freeze({ level: "warn" }),
"route.changed": Object.freeze({ level: "info" }),
"ui.render.failed": Object.freeze({ level: "error" }),
"release.mismatch.detected": Object.freeze({ level: "warn" }),
"telemetry.delivery.dropped": Object.freeze({ level: "warn" }),
});
export type DiagnosticEventId = keyof typeof DIAGNOSTIC_EVENT_REGISTRY;
export const DIAGNOSTIC_CONTEXT_ALLOWLIST = Object.freeze([
"app_version",
"build_id",
"release_id",
"active_release_id",
"config_schema_version",
"api_contract_version",
"route_id",
"operation_id",
"correlation_id",
"error_kind",
"outcome",
"http_status_group",
"attempt_count_bucket",
"duration_bucket",
"component_boundary",
"mismatch_kind",
"operation",
"reason",
"queue_size_bucket",
] as const);
export type DiagnosticContextKey =
(typeof DIAGNOSTIC_CONTEXT_ALLOWLIST)[number];
export type DiagnosticContext = Readonly<
Partial<Record<DiagnosticContextKey, string | number | boolean>>
>;
export type DiagnosticRecordInput = Readonly<{
level: DiagnosticLevel;
eventId: DiagnosticEventId;
context?: Readonly<Record<string, unknown>>;
}>;
export type DiagnosticRecord = Readonly<{
level: DiagnosticLevel;
eventId: DiagnosticEventId;
timestamp: string;
context: DiagnosticContext;
}>;
const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,63}$/;
function projectDiagnosticRecordUnsafe(
input: DiagnosticRecordInput,
now: () => number = Date.now,
):
| Readonly<{ success: true; record: DiagnosticRecord }>
| Readonly<{ success: false; reason: string }> {
if (!Object.hasOwn(DIAGNOSTIC_EVENT_REGISTRY, input.eventId)) {
return { success: false, reason: "unregistered-event" };
}
if (!DIAGNOSTIC_LEVELS.includes(input.level)) {
return { success: false, reason: "invalid-level" };
}
const contextEntries = Object.entries(input.context ?? {});
if (contextEntries.length > DIAGNOSTIC_CONTEXT_ALLOWLIST.length) {
return { success: false, reason: "invalid-context" };
}
const projected: Partial<
Record<DiagnosticContextKey, string | number | boolean>
> = {};
for (const [key, value] of contextEntries) {
if (
!DIAGNOSTIC_CONTEXT_ALLOWLIST.includes(key as DiagnosticContextKey)
) {
return { success: false, reason: "unknown-context" };
}
if (typeof value === "string") {
if (!SAFE_VALUE.test(value)) {
return { success: false, reason: "invalid-context" };
}
projected[key as DiagnosticContextKey] = value;
} else if (typeof value === "number" && Number.isFinite(value)) {
projected[key as DiagnosticContextKey] = value;
} else if (typeof value === "boolean") {
projected[key as DiagnosticContextKey] = value;
} else {
return { success: false, reason: "invalid-context" };
}
}
let timestamp: string;
try {
timestamp = new Date(now()).toISOString();
} catch {
timestamp = new Date(0).toISOString();
}
return {
success: true,
record: Object.freeze({
level: input.level,
eventId: input.eventId,
timestamp,
context: Object.freeze(projected),
}),
};
}
export function projectDiagnosticRecord(
input: DiagnosticRecordInput,
now: () => number = Date.now,
): ReturnType<typeof projectDiagnosticRecordUnsafe> {
try {
return projectDiagnosticRecordUnsafe(input, now);
} catch {
return { success: false, reason: "serialization-failure" };
}
}
export function safeErrorKind(error: unknown): string {
try {
if (error && typeof error === "object") {
const record = error as Readonly<Record<string, unknown>>;
if (
typeof record.kind === "string" &&
/^[A-Z][A-Z0-9_]{0,63}$/.test(record.kind)
) {
return record.kind;
}
if (
typeof record.name === "string" &&
/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(record.name)
) {
const normalized = record.name
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.toUpperCase();
return /^[A-Z][A-Z0-9_]{0,63}$/.test(normalized)
? normalized
: "UNKNOWN_FAILURE";
}
}
} catch {
return "UNKNOWN_FAILURE";
}
return "UNKNOWN_FAILURE";
}
export function statusGroup(status: number | undefined): string {
return typeof status === "number" && Number.isFinite(status)
? `${Math.max(0, Math.min(9, Math.floor(status / 100)))}xx`
: "none";
}
export function attemptBucket(attemptCount: number): string {
if (attemptCount <= 1) return "1";
if (attemptCount === 2) return "2";
if (attemptCount <= 4) return "3-4";
return "5+";
}
export function durationBucket(durationMs: number): string {
if (!Number.isFinite(durationMs) || durationMs < 0) return "unknown";
if (durationMs < 100) return "lt100ms";
if (durationMs < 500) return "100-499ms";
if (durationMs < 2_000) return "500-1999ms";
return "gte2000ms";
}
export function queueSizeBucket(size: number): string {
if (size <= 0) return "0";
if (size <= 10) return "1-10";
if (size <= 50) return "11-50";
return "51+";
}
+58
View File
@@ -195,6 +195,7 @@ export const ERROR_REGISTRY = Object.freeze({
* requestId?: string, * requestId?: string,
* traceId?: string, * traceId?: string,
* retryAfterMs?: number, * retryAfterMs?: number,
* validationIssues?: readonly Readonly<{path: string, code: string}>[],
* userMessageKey: string, * userMessageKey: string,
* action: ErrorAction, * action: ErrorAction,
* causeClass?: string * causeClass?: string
@@ -211,6 +212,7 @@ export const ERROR_REGISTRY = Object.freeze({
* requestId?: string, * requestId?: string,
* traceId?: string, * traceId?: string,
* retryAfterMs?: number, * retryAfterMs?: number,
* validationIssues?: readonly Readonly<{path: string, code: string}>[],
* causeClass?: string * causeClass?: string
* }} [details] * }} [details]
* @returns {ApiFailure} * @returns {ApiFailure}
@@ -236,6 +238,24 @@ export function createFailure(kind, operationId, attempt, details = {}) {
...(typeof details.retryAfterMs === "number" ...(typeof details.retryAfterMs === "number"
? { retryAfterMs: details.retryAfterMs } ? { retryAfterMs: details.retryAfterMs }
: {}), : {}),
...(Array.isArray(details.validationIssues)
? {
validationIssues: Object.freeze(
details.validationIssues
.filter(
(issue) =>
issue &&
typeof issue === "object" &&
typeof issue.path === "string" &&
typeof issue.code === "string",
)
.slice(0, 50)
.map((issue) =>
Object.freeze({ path: issue.path, code: issue.code }),
),
),
}
: {}),
...(typeof details.causeClass === "string" ...(typeof details.causeClass === "string"
? { causeClass: details.causeClass } ? { causeClass: details.causeClass }
: {}), : {}),
@@ -244,6 +264,44 @@ export function createFailure(kind, operationId, attempt, details = {}) {
}); });
} }
/**
* Projects an untrusted 422 details payload into the only validation metadata
* allowed to cross the HTTP boundary. Backend copy and additional values are
* deliberately discarded.
*
* @param {unknown} value
* @returns {readonly Readonly<{path: string, code: string}>[]}
*/
export function safeValidationIssues(value) {
if (!value || typeof value !== "object") return Object.freeze([]);
const candidate =
/** @type {{issues?: unknown, fieldErrors?: unknown}} */ (value);
const issues = Array.isArray(candidate.issues)
? candidate.issues
: Array.isArray(candidate.fieldErrors)
? candidate.fieldErrors
: [];
return Object.freeze(
issues
.filter(
(issue) =>
issue &&
typeof issue === "object" &&
typeof issue.path === "string" &&
typeof issue.code === "string" &&
issue.path.length <= 120 &&
issue.code.length <= 80,
)
.slice(0, 50)
.map((issue) =>
Object.freeze({
path: issue.path,
code: issue.code,
}),
),
);
}
/** @param {number} status */ /** @param {number} status */
export function kindForStatus(status) { export function kindForStatus(status) {
if (status === 401) return "AUTH_REQUIRED"; if (status === 401) return "AUTH_REQUIRED";
+27
View File
@@ -0,0 +1,27 @@
/**
* @typedef {{
* schemaId: string,
* boundary: "route-params" | "route-search" |
* "route-search-api-request" | "api-request" | "api-response",
* owner: string,
* runtime: "zod"
* }} SchemaDefinition
*/
export const PLATFORM_SCHEMA_REGISTRY =
/** @type {Readonly<Record<string, Readonly<SchemaDefinition>>>} */ (
Object.freeze({
none: Object.freeze({
schemaId: "none",
boundary: "route-params",
owner: "feature-frontend-routing-release-recovery-runtime",
runtime: "zod",
}),
NotFoundSplat: Object.freeze({
schemaId: "NotFoundSplat",
boundary: "route-params",
owner: "feature-frontend-routing-release-recovery-runtime",
runtime: "zod",
}),
})
);
+36
View File
@@ -0,0 +1,36 @@
export type TelemetryEventName =
| "app.boot.failed"
| "api.request.failed"
| "ui.render.failed"
| "release.mismatch.detected"
| "telemetry.delivery.dropped";
export type TelemetryDefinition = Readonly<{
eventName: TelemetryEventName;
trigger: string;
requiredAttributes: readonly string[];
optionalAttributes: readonly string[];
forbiddenAttributes: readonly string[];
sampling: string;
delivery: "best-effort";
}>;
export type TelemetryEvent = Readonly<{
eventName: TelemetryEventName;
timestamp: string;
attributes: Readonly<Record<string, unknown>>;
}>;
export const TELEMETRY_ATTRIBUTE_ALLOWLIST: readonly string[];
export const TELEMETRY_FORBIDDEN_ATTRIBUTES: readonly string[];
export const TELEMETRY_REGISTRY: Readonly<
Record<TelemetryEventName, TelemetryDefinition>
>;
export function projectTelemetryEvent(
eventName: string,
attributes: Record<string, unknown>,
now?: () => number,
):
| Readonly<{ success: true; event: TelemetryEvent }>
| Readonly<{ success: false; reason: string }>;

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