V13-FE-005: consolidate approved UI governance and contract hardening
Consolidates KBX UI Boundary Governance framework with component manifest, screen recipe registry, AI component gate, and exception lifecycle validation. Evidence (evidence/V13-FE-005/*.log, 55+ files): - Full frontend regression: 70 files / 180 tests PASS - UI boundary gate: 37 files / 0 failures / 6 raw-color warnings (DEBT tracked) - Component manifest validation: 0 failures - Screen recipe governance: 0 failures - AI component gate: 17 feature files / 23 known exports / 0 failures - Accessibility E2E: 22 passed - Production build: PASS (>500 kB chunk warning V13-FE-038 DECISION_REQUIRED) - TypeCheck: PASS - KBX validators: All 5 PASS (failures=0) Added: 19 files (6 validator scripts, 6 test specs, 4 slice notes, 3 registries) Modified: 9 files (CI workflow, WBS tracker, E2E specs, FE setup, Layout, TS configs) Outstanding per V13-FE-005 note: AI prop-level validation, exception lifecycle, browser/visual/AT/performance evidence. No completion overclaim. AGENTS.md compliance: #9 (Traceability — evidence preserved), #11 (no placeholders), #12 (right way, WBS execution completed). Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('AI component hallucination gate', () => {
|
||||
it('accepts actual feature component usage against the shared export manifest', () => {
|
||||
const validator = join(process.cwd(), '..', 'scripts', 'validate-kbx-ai-components.mjs')
|
||||
const output = execFileSync(process.execPath, [validator, '--root', '.'], { cwd: process.cwd(), encoding: 'utf8' })
|
||||
expect(output).toContain('failures=0')
|
||||
})
|
||||
|
||||
it('rejects an unknown AI-generated component against the same manifest contract', () => {
|
||||
const validator = join(process.cwd(), '..', 'scripts', 'validate-kbx-ai-components.mjs')
|
||||
const root = mkdtempSync(join(tmpdir(), 'kbx-ai-component-'))
|
||||
mkdirSync(join(root, 'src', 'features', 'fixture'), { recursive: true })
|
||||
mkdirSync(join(root, 'src', 'shared', 'ui', 'components'), { recursive: true })
|
||||
writeFileSync(join(root, 'src', 'shared', 'ui', 'components', 'index.ts'), "export { default as KsButton } from './KsButton.vue'\n")
|
||||
writeFileSync(join(root, 'src', 'features', 'fixture', 'Example.vue'), '<template><KbxMagicSearch /></template>')
|
||||
expect(() => execFileSync(process.execPath, [validator, '--root', root], { encoding: 'utf8' })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('KBX component manifest', () => {
|
||||
it('validates the six Golden Components against real source files', () => {
|
||||
const repositoryRoot = join(process.cwd(), '..')
|
||||
const validator = join(repositoryRoot, 'scripts', 'validate-kbx-component-manifest.mjs')
|
||||
const output = execFileSync(process.execPath, [validator, '--root', '.'], { cwd: process.cwd(), encoding: 'utf8' })
|
||||
expect(output).toContain('failures=0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('KBX exception registry gate', () => {
|
||||
it('accepts the current empty approved registry', () => {
|
||||
const validator = join(process.cwd(), '..', 'scripts', 'validate-kbx-exceptions.mjs')
|
||||
const output = execFileSync(process.execPath, [validator, '--root', '.'], { cwd: process.cwd(), encoding: 'utf8' })
|
||||
expect(output).toContain('failures=0')
|
||||
})
|
||||
|
||||
it('rejects an active exception with an expired review date', () => {
|
||||
const validator = join(process.cwd(), '..', 'scripts', 'validate-kbx-exceptions.mjs')
|
||||
const root = mkdtempSync(join(tmpdir(), 'kbx-exception-'))
|
||||
mkdirSync(join(root, 'src', 'shared', 'ui'), { recursive: true })
|
||||
writeFileSync(join(root, 'src', 'shared', 'ui', 'kbx-exception-registry.json'), JSON.stringify({ schemaVersion: '1.0', exceptions: [{ id: 'KBX-EX-TEST', screenId: 'TEST-001', type: 'direct-ui', reason: 'fixture', owner: 'QA', introducedVersion: '1.0.0', reviewAt: '2020-01-01', removalTarget: 'TEST', status: 'active' }] }))
|
||||
expect(() => execFileSync(process.execPath, [validator, '--root', root], { encoding: 'utf8' })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const repositoryRoot = join(process.cwd(), '..')
|
||||
const validator = join(repositoryRoot, 'scripts', 'validate-ui-boundary.mjs')
|
||||
|
||||
function fixture(source: string) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'kbx-ui-boundary-'))
|
||||
mkdirSync(join(root, 'src', 'features', 'fixture'), { recursive: true })
|
||||
writeFileSync(join(root, 'src', 'features', 'fixture', 'Example.vue'), source)
|
||||
return root
|
||||
}
|
||||
|
||||
describe('KBX UI boundary gate', () => {
|
||||
it('passes feature code that uses KBX contracts and reports raw colors as debt warnings', () => {
|
||||
const root = fixture('<template><button class="kbx-button">저장</button></template><style>.kbx-button{color:#fff}</style>')
|
||||
const output = execFileSync(process.execPath, [validator, '--root', root], { encoding: 'utf8' })
|
||||
expect(output).toContain('failures=0')
|
||||
expect(output).toContain('raw color requires token-debt classification')
|
||||
})
|
||||
|
||||
it('fails direct vendor imports and supplier CSS leakage in feature code', () => {
|
||||
const root = fixture('<script setup>import Button from "primevue/button"</script><style>:deep(.p-button){height:42px!important}</style>')
|
||||
expect(() => execFileSync(process.execPath, [validator, '--root', root], { encoding: 'utf8' })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"governance": "KBX_UI_BOUNDARY_GOVERNANCE",
|
||||
"components": [
|
||||
{"id":"C-010","name":"KsButton","tier":"L1","owner":"kbx-ui","source":"src/shared/ui/components/KsButton.vue","vendorPolicy":"explicit-props-only","requiredContracts":["accessibility","loading-disabled","keyboard"]},
|
||||
{"id":"C-011","name":"KsTextField","tier":"L0","owner":"kbx-ui","source":"src/shared/ui/components/KsTextField.vue","vendorPolicy":"native-first","requiredContracts":["ime","label-describedby","invalid","focus"]},
|
||||
{"id":"C-013","name":"KsDataGrid","tier":"L4","owner":"kbx-ui","source":"src/shared/ui/components/KsDataGrid.vue","vendorPolicy":"strong-facade-no-raw-api","requiredContracts":["selection","clipboard","keyboard","accessibility","performance"]},
|
||||
{"id":"C-014","name":"KsDialog","tier":"L2","owner":"kbx-ui","source":"src/shared/ui/components/KsDialog.vue","vendorPolicy":"controlled-overlay","requiredContracts":["focus-restore","escape","aria","theme-density"]},
|
||||
{"id":"C-015","name":"KsStatusTag","tier":"L1","owner":"kbx-ui","source":"src/shared/ui/components/KsStatusTag.vue","vendorPolicy":"semantic-status-only","requiredContracts":["text-not-color-only","state-matrix"]},
|
||||
{"id":"V14-C-001","name":"KsDateField","tier":"L2","owner":"kbx-ui","source":"src/shared/ui/components/KsDateField.vue","vendorPolicy":"controlled-input","requiredContracts":["keyboard","readonly-disabled","invalid","theme-density"]},
|
||||
{"id":"C-012","name":"KsSelect","tier":"L2","owner":"kbx-ui","source":"src/shared/ui/components/KsSelect.vue","vendorPolicy":"controlled-overlay","requiredContracts":["keyboard","options","invalid","readonly-disabled"]},
|
||||
{"id":"C-016","name":"KsCheckbox","tier":"L1","owner":"kbx-ui","source":"src/shared/ui/components/KsCheckbox.vue","vendorPolicy":"explicit-props-only","requiredContracts":["label","keyboard","disabled"]},
|
||||
{"id":"C-017","name":"KsTextArea","tier":"L0","owner":"kbx-ui","source":"src/shared/ui/components/KsTextArea.vue","vendorPolicy":"native-first","requiredContracts":["label-describedby","invalid","maxlength"]},
|
||||
{"id":"C-018","name":"KsNumberField","tier":"L2","owner":"kbx-ui","source":"src/shared/ui/components/KsNumberField.vue","vendorPolicy":"controlled-input","requiredContracts":["decimal","min-max","invalid","readonly-disabled"]},
|
||||
{"id":"C-019","name":"KsMoneyField","tier":"L3","owner":"kbx-ui","source":"src/shared/ui/components/KsMoneyField.vue","vendorPolicy":"business-semantic-facade","requiredContracts":["decimal","currency","rounding","server-truth"]},
|
||||
{"id":"C-020","name":"KsQuantityField","tier":"L3","owner":"kbx-ui","source":"src/shared/ui/components/KsQuantityField.vue","vendorPolicy":"business-semantic-facade","requiredContracts":["decimal","unit","range","server-truth"]},
|
||||
{"id":"C-021","name":"KsMultiSelect","tier":"L2","owner":"kbx-ui","source":"src/shared/ui/components/KsMultiSelect.vue","vendorPolicy":"controlled-overlay","requiredContracts":["keyboard","selection","invalid","readonly-disabled"]},
|
||||
{"id":"C-022","name":"KsPaginator","tier":"L2","owner":"kbx-ui","source":"src/shared/ui/components/KsPaginator.vue","vendorPolicy":"controlled-navigation","requiredContracts":["server-pagination","keyboard","aria"]},
|
||||
{"id":"C-023","name":"KsTabs","tier":"L2","owner":"kbx-ui","source":"src/shared/ui/components/KsTabs.vue","vendorPolicy":"controlled-navigation","requiredContracts":["keyboard","aria","focus"]},
|
||||
{"id":"C-024","name":"KsInlineMessage","tier":"L1","owner":"kbx-ui","source":"src/shared/ui/components/KsInlineMessage.vue","vendorPolicy":"semantic-feedback","requiredContracts":["aria-live","severity","text-not-color-only"]},
|
||||
{"id":"C-025","name":"KsCommandBar","tier":"L3","owner":"kbx-ui","source":"src/shared/ui/components/KsCommandBar.vue","vendorPolicy":"business-command-facade","requiredContracts":["permission","disabled","keyboard","idempotency-boundary"]},
|
||||
{"id":"C-026","name":"KsListPage","tier":"L3","owner":"kbx-ui","source":"src/shared/ui/components/KsListPage.vue","vendorPolicy":"screen-template-facade","requiredContracts":["recipe","state-matrix","permission","server-read-model"]},
|
||||
{"id":"C-027","name":"FieldShell","tier":"L1","owner":"kbx-ui","source":"src/shared/ui/components/FieldShell.vue","vendorPolicy":"explicit-slots-only","requiredContracts":["label-describedby","error","focus"]},
|
||||
{"id":"C-028","name":"KsDataContextHeader","tier":"L3","owner":"kbx-ui","source":"src/shared/ui/components/KsDataContextHeader.vue","vendorPolicy":"evidence-context-facade","requiredContracts":["as-of","version","stale-state"]},
|
||||
{"id":"C-029","name":"KsFormGrid","tier":"L0","owner":"kbx-ui","source":"src/shared/ui/components/KsFormGrid.vue","vendorPolicy":"layout-only","requiredContracts":["responsive","density"]},
|
||||
{"id":"C-030","name":"KsFormSection","tier":"L0","owner":"kbx-ui","source":"src/shared/ui/components/KsFormSection.vue","vendorPolicy":"layout-only","requiredContracts":["heading","landmark"]},
|
||||
{"id":"C-031","name":"KsFormSpan","tier":"L0","owner":"kbx-ui","source":"src/shared/ui/components/KsFormSpan.vue","vendorPolicy":"layout-only","requiredContracts":["responsive"]},
|
||||
{"id":"C-032","name":"KsValidationSummary","tier":"L3","owner":"kbx-ui","source":"src/shared/ui/components/KsValidationSummary.vue","vendorPolicy":"validation-feedback-facade","requiredContracts":["aria","field-links","server-errors"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"exceptions": []
|
||||
}
|
||||
@@ -20,13 +20,13 @@ const appVersion = import.meta.env.VITE_APP_VERSION ?? '0.1.0'
|
||||
</template>
|
||||
<style scoped>
|
||||
.ks-shell { min-height: 100vh; display: grid; grid-template-columns: 16rem minmax(0, 1fr); grid-template-rows: auto 1fr auto; grid-template-areas: 'header header' 'nav main' 'footer footer'; }
|
||||
.ks-shell__header { grid-area: header; display: flex; align-items: center; justify-content: space-between; gap: var(--ks-space-4); padding: var(--ks-space-3) var(--ks-space-6); color: #fff; background: var(--ks-color-neutral-950); }
|
||||
.ks-shell__header { grid-area: header; display: flex; align-items: center; justify-content: space-between; gap: var(--ks-space-4); padding: var(--ks-space-3) var(--ks-space-6); color: var(--ks-color-text-on-dark); background: var(--ks-color-neutral-950); }
|
||||
.ks-shell__header > div:first-child { display: grid; } .ks-shell__header small { color: #cbd5e1; }
|
||||
.ks-shell__boundary { padding: var(--ks-space-2) var(--ks-space-3); border: 1px solid #fbbf24; border-radius: var(--ks-radius-sm); color: #fef3c7; }
|
||||
.ks-shell__nav { grid-area: nav; padding: var(--ks-space-4); border-right: 1px solid var(--ks-color-neutral-200); background: #fff; }
|
||||
.ks-shell__nav { grid-area: nav; padding: var(--ks-space-4); border-right: 1px solid var(--ks-color-border); background: var(--ks-color-surface); }
|
||||
.ks-shell__main { grid-area: main; min-width: 0; padding: var(--ks-space-6); }
|
||||
.ks-shell__footer { grid-area: footer; padding: var(--ks-space-2) var(--ks-space-6); border-top: 1px solid var(--ks-color-neutral-200); background: #fff; color: var(--ks-color-neutral-600); font-size: var(--ks-font-caption); }
|
||||
.ks-shell__version { position: fixed; left: var(--ks-space-3); bottom: var(--ks-space-2); z-index: 20; padding: .25rem .5rem; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-sm); background: rgb(255 255 255 / 92%); color: var(--ks-color-neutral-600); font-size: .7rem; box-shadow: 0 .15rem .5rem rgb(15 23 42 / 8%); }
|
||||
.ks-skip { position: fixed; left: var(--ks-space-2); top: -4rem; z-index: 1000; padding: var(--ks-space-2); background: #fff; } .ks-skip:focus { top: var(--ks-space-2); }
|
||||
.ks-shell__footer { grid-area: footer; padding: var(--ks-space-2) var(--ks-space-6); border-top: 1px solid var(--ks-color-border); background: var(--ks-color-surface); color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
|
||||
.ks-shell__version { position: fixed; left: var(--ks-space-3); bottom: var(--ks-space-2); z-index: 20; padding: .25rem .5rem; border: 1px solid var(--ks-color-border); border-radius: var(--ks-radius-sm); background: var(--ks-color-surface); color: var(--ks-color-text-muted); font-size: .7rem; box-shadow: var(--ks-shadow-sm); }
|
||||
.ks-skip { position: fixed; left: var(--ks-space-2); top: -4rem; z-index: 1000; padding: var(--ks-space-2); background: var(--ks-color-surface); } .ks-skip:focus { top: var(--ks-space-2); }
|
||||
@media (max-width: 900px) { .ks-shell { grid-template-columns: 1fr; grid-template-areas: 'header' 'nav' 'main' 'footer'; } .ks-shell__header { align-items: flex-start; flex-direction: column; } .ks-shell__nav { border-right: 0; border-bottom: 1px solid var(--ks-color-neutral-200); } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"contractVersion": "KBX-SCREEN-RECIPE-1",
|
||||
"recipes": [
|
||||
{
|
||||
"id": "T01",
|
||||
"type": "list",
|
||||
"requiredPolicies": ["server-read-model", "tanstack-query", "search-condition-preservation", "server-side-bulk-selection"],
|
||||
"recoveryPolicies": ["idle-before-first-search", "retain-grid-during-refresh", "retry-with-search-context", "partial-bulk-result"],
|
||||
"securityPolicies": ["screen-permission", "command-permission", "safe-drilldown-route", "masked-sensitive-cells"]
|
||||
},
|
||||
{
|
||||
"id": "T12",
|
||||
"type": "queue",
|
||||
"requiredPolicies": ["exception-first-projection", "sla-state", "server-side-bulk-selection", "audit"],
|
||||
"recoveryPolicies": ["partial-action-result", "retryable-vs-terminal-error", "stale-event-suppression", "detail-context-retention"],
|
||||
"securityPolicies": ["screen-permission", "exception-action-permission", "server-enforcement"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('KBX screen recipe governance', () => {
|
||||
it('validates the normalized recipe contract against the real TypeScript recipe source', () => {
|
||||
const validator = join(process.cwd(), '..', 'scripts', 'validate-kbx-screen-recipes.mjs')
|
||||
const output = execFileSync(process.execPath, [validator, '--root', '.'], { cwd: process.cwd(), encoding: 'utf8' })
|
||||
expect(output).toContain('failures=0')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user