Requirements as Code in TypeScript: A Type-Safe Chain on a Live Website
What if the compiler refused to build until every feature was defined, every acceptance criterion was linked to a test, and every test reference was valid? What if the system could verify its own compliance — and that verification was itself a tracked requirement?
The Problem: Requirements Live Everywhere Except the Code
In most projects, requirements live in Jira. Tests live in test files. The connection between them is a mental model in the developer's head — or worse, a comment that drifts the moment someone renames a function.
This website — a terminal-styled CV with SPA navigation, scroll spy, search, mermaid diagrams, 8 accent color palettes, 4 theme modes, mobile viewports, and a full static build pipeline — has 20 distinct features and 112 acceptance criteria. Before this system, the connection between "what should the website do?" and "which tests verify it?" was a markdown table in a blog post. Human-maintained. Drift-prone. Unenforceable.
The C# Requirements DSL solved this with Roslyn source generators and abstract classes. This is the same philosophy, adapted to TypeScript — proving it works outside the .NET ecosystem, on a real website, with real tests.
The Philosophy: Meta-Modeling Applied to Requirements
The approach borrows from meta-metamodeling — the same layered architecture used in the CMF framework. In the OMG's Meta-Object Facility (MOF):
| Layer | In C# CMF (DDD) | In C# Requirements DSL | In TypeScript Requirements |
|---|---|---|---|
| M3 (meta-metamodel) | C# type system + generic constraints | abstract record Feature<TParent> + Roslyn analyzers |
TypeScript abstract class + keyof T + decorators |
| M2 (metamodel) | AggregateRoot, ValueObject, DomainEvent |
Feature, Story, AcceptanceCriterionResult, [Implements] |
Feature base class, @FeatureTest, @Implements, @Exclude |
| M1 (model) | OrderAggregate, CustomerEntity |
UserRolesFeature, OrderProcessingEpic |
NavigationFeature, ScrollSpyFeature, BuildPipelineFeature |
| M0 (instances) | Runtime domain objects | Validation results at build-time + runtime | Test execution results + compliance report |
The key insight from meta-modeling: the shape of what a "requirement" IS should be defined once, at M2, and enforced by M3. In TypeScript, M3 is the type system itself — abstract methods that must exist, keyof T that catches typos, decorators that register metadata.
The Architecture
requirements/
├── base.ts ← Feature abstract class, Priority enum, ACResult type
├── decorators.ts ← @FeatureTest, @Implements, @Exclude
├── features/
│ ├── navigation.ts ← 8 abstract AC methods
│ ├── scroll-spy.ts ← 7 abstract AC methods
│ ├── search.ts ← 5 abstract AC methods
│ ├── ... (20 files)
│ └── req-track.ts ← 7 ACs (self-tracking)
└── index.ts ← Re-exports everything
test/
├── e2e/*.spec.ts ← @FeatureTest(Feature) + @Implements<Feature>('ac')
├── unit/*.test.ts ← Same pattern
├── a11y/*.spec.ts ← coversACs for dynamic tests
├── visual/*.spec.ts ← coversACs for dynamic tests
└── perf/*.spec.ts ← @Implements on each test
scripts/
└── compliance-report.ts ← Scanner + quality gaterequirements/
├── base.ts ← Feature abstract class, Priority enum, ACResult type
├── decorators.ts ← @FeatureTest, @Implements, @Exclude
├── features/
│ ├── navigation.ts ← 8 abstract AC methods
│ ├── scroll-spy.ts ← 7 abstract AC methods
│ ├── search.ts ← 5 abstract AC methods
│ ├── ... (20 files)
│ └── req-track.ts ← 7 ACs (self-tracking)
└── index.ts ← Re-exports everything
test/
├── e2e/*.spec.ts ← @FeatureTest(Feature) + @Implements<Feature>('ac')
├── unit/*.test.ts ← Same pattern
├── a11y/*.spec.ts ← coversACs for dynamic tests
├── visual/*.spec.ts ← coversACs for dynamic tests
└── perf/*.spec.ts ← @Implements on each test
scripts/
└── compliance-report.ts ← Scanner + quality gateLayer 1: Features Are Abstract Classes
A feature is not a string. Not a Jira ticket. Not a comment. A feature is an abstract class whose abstract methods are its acceptance criteria:
// requirements/base.ts
export enum Priority { Critical = 'critical', High = 'high', Medium = 'medium', Low = 'low' }
export interface ACResult {
satisfied: boolean;
reason?: string;
}
export abstract class Feature {
abstract readonly id: string;
abstract readonly title: string;
abstract readonly priority: Priority;
}// requirements/base.ts
export enum Priority { Critical = 'critical', High = 'high', Medium = 'medium', Low = 'low' }
export interface ACResult {
satisfied: boolean;
reason?: string;
}
export abstract class Feature {
abstract readonly id: string;
abstract readonly title: string;
abstract readonly priority: Priority;
}// requirements/features/navigation.ts
export abstract class NavigationFeature extends Feature {
readonly id = 'NAV';
readonly title = 'SPA Navigation + Deep Links';
readonly priority = Priority.Critical;
/** Clicking a TOC entry loads the corresponding page. */
abstract tocClickLoadsPage(): ACResult;
/** The browser back button restores the previous page state. */
abstract backButtonRestores(): ACResult;
/** The active navigation item is visually highlighted. */
abstract activeItemHighlights(): ACResult;
/** Clicking an anchor link scrolls smoothly to the target. */
abstract anchorScrollsSmoothly(): ACResult;
/** Navigating directly to a URL loads the correct page. */
abstract directUrlLoads(): ACResult;
/** Pressing F5 reloads the page and preserves the current view. */
abstract f5ReloadPreserves(): ACResult;
/** Deep links resolve to the correct section within a page. */
abstract deepLinkLoads(): ACResult;
/** Every page state produces a bookmarkable URL. */
abstract bookmarkableUrl(): ACResult;
}// requirements/features/navigation.ts
export abstract class NavigationFeature extends Feature {
readonly id = 'NAV';
readonly title = 'SPA Navigation + Deep Links';
readonly priority = Priority.Critical;
/** Clicking a TOC entry loads the corresponding page. */
abstract tocClickLoadsPage(): ACResult;
/** The browser back button restores the previous page state. */
abstract backButtonRestores(): ACResult;
/** The active navigation item is visually highlighted. */
abstract activeItemHighlights(): ACResult;
/** Clicking an anchor link scrolls smoothly to the target. */
abstract anchorScrollsSmoothly(): ACResult;
/** Navigating directly to a URL loads the correct page. */
abstract directUrlLoads(): ACResult;
/** Pressing F5 reloads the page and preserves the current view. */
abstract f5ReloadPreserves(): ACResult;
/** Deep links resolve to the correct section within a page. */
abstract deepLinkLoads(): ACResult;
/** Every page state produces a bookmarkable URL. */
abstract bookmarkableUrl(): ACResult;
}Each abstract method is a formal statement: "this must be true." The method name is the canonical identifier. The JSDoc is the human-readable description. The return type (ACResult) enforces that every criterion produces a verifiable outcome.
Layer 2: Decorators Link Tests to Features
TypeScript decorators (using experimentalDecorators) create the bridge between features and tests. Three decorators, three roles:
// requirements/decorators.ts
/** Link a test class to a Feature. */
export function FeatureTest<T extends abstract new (...args: any[]) => Feature>(feature: T) {
return function <C extends new (...args: any[]) => any>(target: C): C {
(target as any).__feature = feature.name;
return target;
};
}
/** Link a test method to an acceptance criterion.
* The AC name must be keyof T — compiler catches typos. */
export function Implements<T extends Feature>(ac: keyof T & string) {
return function (target: any, propertyKey: string, _descriptor: PropertyDescriptor): void {
registry.push({ feature: '', ac, testClass: target.constructor.name, testMethod: propertyKey });
};
}
/** Exclude a helper method from requirement scanning. */
export function Exclude() {
return function (target: any, propertyKey: string, _descriptor: PropertyDescriptor): void {
excludedMethods.add(`${target.constructor.name}.${propertyKey}`);
};
}// requirements/decorators.ts
/** Link a test class to a Feature. */
export function FeatureTest<T extends abstract new (...args: any[]) => Feature>(feature: T) {
return function <C extends new (...args: any[]) => any>(target: C): C {
(target as any).__feature = feature.name;
return target;
};
}
/** Link a test method to an acceptance criterion.
* The AC name must be keyof T — compiler catches typos. */
export function Implements<T extends Feature>(ac: keyof T & string) {
return function (target: any, propertyKey: string, _descriptor: PropertyDescriptor): void {
registry.push({ feature: '', ac, testClass: target.constructor.name, testMethod: propertyKey });
};
}
/** Exclude a helper method from requirement scanning. */
export function Exclude() {
return function (target: any, propertyKey: string, _descriptor: PropertyDescriptor): void {
excludedMethods.add(`${target.constructor.name}.${propertyKey}`);
};
}The critical line is keyof T & string. This means: the AC name must be a real method on the Feature class. A typo is a compile error:
// ✅ Compiles: 'tocClickLoadsPage' exists on NavigationFeature
@Implements<NavigationFeature>('tocClickLoadsPage')
// ❌ Compile error: 'tocClickLoadsPagee' is not assignable to keyof NavigationFeature
@Implements<NavigationFeature>('tocClickLoadsPagee')
// ❌ Compile error: 'neverThrows' does not exist on NavigationFeature
@Implements<NavigationFeature>('neverThrows')// ✅ Compiles: 'tocClickLoadsPage' exists on NavigationFeature
@Implements<NavigationFeature>('tocClickLoadsPage')
// ❌ Compile error: 'tocClickLoadsPagee' is not assignable to keyof NavigationFeature
@Implements<NavigationFeature>('tocClickLoadsPagee')
// ❌ Compile error: 'neverThrows' does not exist on NavigationFeature
@Implements<NavigationFeature>('neverThrows')No magic strings. No runtime discovery of broken references. The compiler catches it before the test ever runs.
Layer 3: Tests Are Decorated Class Methods
E2E Tests (Playwright)
// test/e2e/navigation.spec.ts
@FeatureTest(NavigationFeature)
class NavigationTests {
@Exclude()
private async waitForReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1500);
}
@Implements<NavigationFeature>('tocClickLoadsPage')
async 'clicking TOC item loads page with fade transition'({ page }: { page: Page }): Promise<void> {
await this.waitForReady(page);
await page.click('.toc-item[data-path="content/blog/binary-wrapper.md"]');
await page.waitForTimeout(500);
await expect(page.locator('#markdown-output h1')).toContainText('BinaryWrapper');
expect(page.url()).toContain('binary-wrapper');
}
@Implements<NavigationFeature>('backButtonRestores')
async 'back button returns to previous page'({ page }: { page: Page }): Promise<void> {
await this.waitForReady(page);
// navigate to page A, then page B, then back
await page.click('.toc-item[data-path="content/blog/binary-wrapper.md"]');
await page.waitForTimeout(500);
await page.click('.toc-item[data-path="content/skills.md"]');
await page.waitForTimeout(500);
await page.goBack();
await page.waitForTimeout(1000);
await expect(page.locator('#markdown-output h1')).toContainText('BinaryWrapper');
}
}
// Register class methods as Playwright tests
const instance = new NavigationTests();
for (const method of Object.getOwnPropertyNames(NavigationTests.prototype)) {
if (method === 'constructor' || method === 'waitForReady') continue;
test(method, async ({ page }) => {
await (instance as unknown as Record<string, Function>)[method]!({ page });
});
}// test/e2e/navigation.spec.ts
@FeatureTest(NavigationFeature)
class NavigationTests {
@Exclude()
private async waitForReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1500);
}
@Implements<NavigationFeature>('tocClickLoadsPage')
async 'clicking TOC item loads page with fade transition'({ page }: { page: Page }): Promise<void> {
await this.waitForReady(page);
await page.click('.toc-item[data-path="content/blog/binary-wrapper.md"]');
await page.waitForTimeout(500);
await expect(page.locator('#markdown-output h1')).toContainText('BinaryWrapper');
expect(page.url()).toContain('binary-wrapper');
}
@Implements<NavigationFeature>('backButtonRestores')
async 'back button returns to previous page'({ page }: { page: Page }): Promise<void> {
await this.waitForReady(page);
// navigate to page A, then page B, then back
await page.click('.toc-item[data-path="content/blog/binary-wrapper.md"]');
await page.waitForTimeout(500);
await page.click('.toc-item[data-path="content/skills.md"]');
await page.waitForTimeout(500);
await page.goBack();
await page.waitForTimeout(1000);
await expect(page.locator('#markdown-output h1')).toContainText('BinaryWrapper');
}
}
// Register class methods as Playwright tests
const instance = new NavigationTests();
for (const method of Object.getOwnPropertyNames(NavigationTests.prototype)) {
if (method === 'constructor' || method === 'waitForReady') continue;
test(method, async ({ page }) => {
await (instance as unknown as Record<string, Function>)[method]!({ page });
});
}Three patterns to note:
@Exclude()on helpers —waitForReadyis a shared setup. Without@Exclude(), the lint rule would flag it as "missing@Implements."- Method names are string literals —
'clicking TOC item loads page with fade transition'becomes the test name in Playwright's output. - Registration loop — converts class methods into Playwright
test()calls. Playwright doesn't natively support class-based tests.
Unit Tests (Vitest)
// test/unit/slugify.test.ts
@FeatureTest(SlugifyFeature)
class SlugifyTests {
@Implements<SlugifyFeature>('stripsHtmlMarkdownSpecialChars')
'lowercases and replaces spaces'() {
expect(slugify('Hello World')).toBe('hello-world');
}
@Implements<SlugifyFeature>('hierarchicalNesting')
'h2 nests under h1'() {
const parentSlugs: Record<number, string> = {};
const slugCount: Record<string, number> = {};
buildHierarchicalSlug('Root', 1, parentSlugs, slugCount);
expect(buildHierarchicalSlug('Child', 2, parentSlugs, slugCount)).toBe('root--child');
}
@Implements<SlugifyFeature>('deduplicatesSlugs')
'handles duplicate slugs'() {
const p: Record<number, string> = {};
const c: Record<string, number> = {};
buildHierarchicalSlug('Root', 1, p, c);
const s1 = buildHierarchicalSlug('Same', 2, p, c);
const s2 = buildHierarchicalSlug('Same', 2, p, c);
expect(s1).toBe('root--same');
expect(s2).toBe('root--same-1');
}
}// test/unit/slugify.test.ts
@FeatureTest(SlugifyFeature)
class SlugifyTests {
@Implements<SlugifyFeature>('stripsHtmlMarkdownSpecialChars')
'lowercases and replaces spaces'() {
expect(slugify('Hello World')).toBe('hello-world');
}
@Implements<SlugifyFeature>('hierarchicalNesting')
'h2 nests under h1'() {
const parentSlugs: Record<number, string> = {};
const slugCount: Record<string, number> = {};
buildHierarchicalSlug('Root', 1, parentSlugs, slugCount);
expect(buildHierarchicalSlug('Child', 2, parentSlugs, slugCount)).toBe('root--child');
}
@Implements<SlugifyFeature>('deduplicatesSlugs')
'handles duplicate slugs'() {
const p: Record<number, string> = {};
const c: Record<string, number> = {};
buildHierarchicalSlug('Root', 1, p, c);
const s1 = buildHierarchicalSlug('Same', 2, p, c);
const s2 = buildHierarchicalSlug('Same', 2, p, c);
expect(s1).toBe('root--same');
expect(s2).toBe('root--same-1');
}
}Multiple test methods can reference the same AC — stripsHtmlMarkdownSpecialChars has 11 tests covering different edge cases. The scanner counts the AC as covered if at least one test links to it.
Dynamic Tests (A11y, Visual Regression)
Some tests are generated in loops — 44 pages x 4 themes = 176 visual regression tests. Decorators can't go on loop iterations. Instead, a class-level coversACs array tells the scanner which ACs the file covers:
// test/visual/pages.spec.ts
@FeatureTest(VisualRegressionFeature)
class VisualTests {
static readonly coversACs: (keyof VisualRegressionFeature)[] = [
'desktopBaselinesMatch', 'mobileBaselinesMatch',
'componentScreenshotsMatch', 'toleranceThresholdsCorrect'
];
}
// 176 tests generated dynamically from sitemap
for (const theme of themes) {
for (const pagePath of pages) {
test(`${pageSlug} [${theme.name}]`, async ({ page }) => {
await applyTheme(page, theme);
await expect(page).toHaveScreenshot(screenshotName, { fullPage: true });
});
}
}// test/visual/pages.spec.ts
@FeatureTest(VisualRegressionFeature)
class VisualTests {
static readonly coversACs: (keyof VisualRegressionFeature)[] = [
'desktopBaselinesMatch', 'mobileBaselinesMatch',
'componentScreenshotsMatch', 'toleranceThresholdsCorrect'
];
}
// 176 tests generated dynamically from sitemap
for (const theme of themes) {
for (const pagePath of pages) {
test(`${pageSlug} [${theme.name}]`, async ({ page }) => {
await applyTheme(page, theme);
await expect(page).toHaveScreenshot(screenshotName, { fullPage: true });
});
}
}The coversACs array is also type-checked — keyof VisualRegressionFeature ensures every string is a real AC method.
Layer 4: The Compliance Scanner
The scanner reads feature definitions and test decorators, cross-references them, and produces a coverage matrix:
$ npx tsx scripts/compliance-report.ts
── Feature Compliance Report ──
✓ NAV SPA Navigation + Deep Links 8/8 ACs (100%)
✓ SPY Scroll Spy 7/7 ACs (100%)
✓ BUILD Static Build Pipeline 13/13 ACs (100%)
✓ REQ-TRACK Requirements Tracking System 7/7 ACs (100%)
...all 20 features at 100%...
Coverage: 112/112 ACs (100%)
Critical uncovered: 0
Quality gate: PASS$ npx tsx scripts/compliance-report.ts
── Feature Compliance Report ──
✓ NAV SPA Navigation + Deep Links 8/8 ACs (100%)
✓ SPY Scroll Spy 7/7 ACs (100%)
✓ BUILD Static Build Pipeline 13/13 ACs (100%)
✓ REQ-TRACK Requirements Tracking System 7/7 ACs (100%)
...all 20 features at 100%...
Coverage: 112/112 ACs (100%)
Critical uncovered: 0
Quality gate: PASSThe algorithm:
- Read features: parse
requirements/features/*.tsfor abstract method names - Scan tests: find
@Implements<Feature>('ac')decorators +coversACsarrays - Cross-reference: for each feature, count which ACs have linked tests
- Quality gate:
--strictexits non-zero if critical features are incomplete
The Self-Reference: REQ-TRACK
The most unusual feature is REQ-TRACK: it tracks the tracking system itself. 7 acceptance criteria verify that the requirements infrastructure works:
// requirements/features/req-track.ts
export abstract class RequirementsTrackingFeature extends Feature {
readonly id = 'REQ-TRACK';
readonly title = 'Requirements Tracking System';
readonly priority = Priority.Critical;
abstract featureDefinitionsCompile(): ACResult;
abstract everyFeatureHasTest(): ACResult;
abstract scannerGeneratesJson(): ACResult;
abstract scannerGeneratesConsole(): ACResult;
abstract qualityGateFailsOnGap(): ACResult;
abstract eslintFlagsMissingDecorator(): ACResult;
abstract selfAppearsInReport(): ACResult;
}// requirements/features/req-track.ts
export abstract class RequirementsTrackingFeature extends Feature {
readonly id = 'REQ-TRACK';
readonly title = 'Requirements Tracking System';
readonly priority = Priority.Critical;
abstract featureDefinitionsCompile(): ACResult;
abstract everyFeatureHasTest(): ACResult;
abstract scannerGeneratesJson(): ACResult;
abstract scannerGeneratesConsole(): ACResult;
abstract qualityGateFailsOnGap(): ACResult;
abstract eslintFlagsMissingDecorator(): ACResult;
abstract selfAppearsInReport(): ACResult;
}Each AC has a clear "what implements it" and "what tests it":
| AC | What it means | Implementation | Test |
|---|---|---|---|
featureDefinitionsCompile |
All 20 feature .ts files are valid TypeScript |
tsconfig.json + requirements/features/*.ts — they ARE the implementation; if they have a type error, tsc fails |
Runs tsc --noEmit, asserts exit code 0 |
everyFeatureHasTest |
No feature has 0 tests linked | @FeatureTest + @Implements decorators on every test |
Runs scanner, asserts every feature.coveredCount > 0 |
scannerGeneratesJson |
Scanner outputs valid JSON | scripts/compliance-report.ts with --json flag |
Parses output, checks features + summary keys |
scannerGeneratesConsole |
Scanner prints human-readable summary | scripts/compliance-report.ts console reporter |
Checks output contains "Coverage:" and "ACs" |
qualityGateFailsOnGap |
Scanner reports critical gaps | process.exit(1) in scanner when --strict |
Checks criticalUncovered array exists in JSON |
eslintFlagsMissingDecorator |
Decorators are importable and functional | requirements/decorators.ts exporting FeatureTest, Implements, Exclude |
Imports all 3, asserts typeof is 'function' |
selfAppearsInReport |
REQ-TRACK itself shows 100% | Scanner finds its own @Implements refs |
Runs scanner, finds REQ-TRACK, asserts percentage === 100 |
AC #6 — selfAppearsInReport — creates a self-reference loop:
REQ-TRACK (7 ACs)
├── AC-6: "REQ-TRACK itself appears in report at 100%"
│ ↓
│ test: 'REQ-TRACK is listed in compliance report at 100%'
│ ↓
│ @Implements<RequirementsTrackingFeature>('selfAppearsInReport')
│ ↓
│ scanner finds this test → links to REQ-TRACK AC-6
│ ↓
│ report shows REQ-TRACK: 7/7 ✓
│ ↓
│ test reads report → asserts 7/7 → passes
│ ↓
└── ✓ The system verified itselfREQ-TRACK (7 ACs)
├── AC-6: "REQ-TRACK itself appears in report at 100%"
│ ↓
│ test: 'REQ-TRACK is listed in compliance report at 100%'
│ ↓
│ @Implements<RequirementsTrackingFeature>('selfAppearsInReport')
│ ↓
│ scanner finds this test → links to REQ-TRACK AC-6
│ ↓
│ report shows REQ-TRACK: 7/7 ✓
│ ↓
│ test reads report → asserts 7/7 → passes
│ ↓
└── ✓ The system verified itselfThe scanner runs first, the test reads its output. Not circular at runtime — self-referential in design, sequential in execution.
// test/unit/compliance.test.ts
@Implements<RequirementsTrackingFeature>('selfAppearsInReport')
'REQ-TRACK is listed in compliance report at 100%'() {
const json = execSync('npx tsx scripts/compliance-report.ts --json',
{ cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
const report = JSON.parse(json);
const reqTrack = report.features.find((f: any) => f.id === 'REQ-TRACK');
expect(reqTrack).toBeDefined();
expect(reqTrack.percentage).toBe(100);
}// test/unit/compliance.test.ts
@Implements<RequirementsTrackingFeature>('selfAppearsInReport')
'REQ-TRACK is listed in compliance report at 100%'() {
const json = execSync('npx tsx scripts/compliance-report.ts --json',
{ cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
const report = JSON.parse(json);
const reqTrack = report.features.find((f: any) => f.id === 'REQ-TRACK');
expect(reqTrack).toBeDefined();
expect(reqTrack.percentage).toBe(100);
}The Complete Feature Set
| ID | Feature | Priority | ACs | Test Layer |
|---|---|---|---|---|
| NAV | SPA Navigation + Deep Links | Critical | 8 | E2E |
| SPY | Scroll Spy | High | 7 | E2E |
| SEARCH | Search | High | 5 | E2E |
| THEME | Theme Switching | High | 4 | E2E |
| ACCENT | Accent Color Palette | High | 8 | E2E |
| KB | Keyboard Shortcuts | Medium | 5 | E2E |
| HIRE | Hire Modal | Medium | 4 | E2E |
| OVERLAY | Overlays | Medium | 6 | E2E |
| COPY | Copy Buttons | Medium | 4 | E2E |
| MOBILE | Mobile Sidebar | Medium | 5 | E2E |
| A11Y | Accessibility | High | 5 | A11y |
| CONTRAST | Color Contrast Matrix | High | 3 | A11y |
| VIS | Visual Regression | Medium | 4 | Visual |
| PERF | Performance | High | 4 | Perf |
| SLUG | Slugify | Critical | 5 | Unit + property |
| FM | Frontmatter Parsing | High | 5 | Unit + property |
| SCORE | Search Scoring | High | 5 | Unit |
| MERMAID | Mermaid Config | High | 5 | Unit |
| BUILD | Static Build Pipeline | High | 13 | Unit (mock IO) |
| REQ-TRACK | Requirements Tracking | Critical | 7 | Unit (self) |
| Total | 112 |
What the Compiler Catches
| Bug class | Example | Caught by |
|---|---|---|
| Typo in AC name | @Implements<NAV>('tocClickLoadsPagee') |
tsc — compile error |
| Wrong feature | @Implements<NAV>('neverThrows') |
tsc — not a key of NAV |
| Deleted feature | import { Deleted } from '...' |
tsc — module not found |
| Renamed AC | Method renamed in feature class | tsc — all references break |
| Missing test for AC | Feature has AC with no @Implements |
Compliance scanner |
| Critical AC uncovered | Critical feature < 100% | Quality gate (--strict) |
Comparison: C# vs TypeScript
| Aspect | C# (Roslyn SG) | TypeScript |
|---|---|---|
| Feature definition | abstract record Feature |
abstract class Feature |
| AC definition | abstract AcceptanceCriterionResult Method() |
abstract method(): ACResult |
| Test linkage | [Implements] attribute |
@Implements decorator |
| Type safety | typeof() + nameof() |
keyof T & string |
| Build check | Roslyn analyzer diagnostic | tsc --noEmit |
| Coverage scan | Source generator (build-time) | CLI scanner (run-time) |
| Self-tracking | Not implemented | REQ-TRACK: 7/7 |
How to Add a New Feature
// 1. Define the feature
// requirements/features/my-feature.ts
export abstract class MyFeature extends Feature {
readonly id = 'MY';
readonly title = 'My New Feature';
readonly priority = Priority.High;
abstract userCanDoX(): ACResult;
abstract systemHandlesY(): ACResult;
}
// 2. Write decorated tests
// test/e2e/my-feature.spec.ts
@FeatureTest(MyFeature)
class MyFeatureTests {
@Implements<MyFeature>('userCanDoX')
async 'user can do X'({ page }: { page: Page }): Promise<void> { ... }
@Implements<MyFeature>('systemHandlesY')
async 'system handles Y'({ page }: { page: Page }): Promise<void> { ... }
}
// 3. Check compliance
// $ npx tsx scripts/compliance-report.ts
// ✓ MY My New Feature 2/2 ACs (100%)// 1. Define the feature
// requirements/features/my-feature.ts
export abstract class MyFeature extends Feature {
readonly id = 'MY';
readonly title = 'My New Feature';
readonly priority = Priority.High;
abstract userCanDoX(): ACResult;
abstract systemHandlesY(): ACResult;
}
// 2. Write decorated tests
// test/e2e/my-feature.spec.ts
@FeatureTest(MyFeature)
class MyFeatureTests {
@Implements<MyFeature>('userCanDoX')
async 'user can do X'({ page }: { page: Page }): Promise<void> { ... }
@Implements<MyFeature>('systemHandlesY')
async 'system handles Y'({ page }: { page: Page }): Promise<void> { ... }
}
// 3. Check compliance
// $ npx tsx scripts/compliance-report.ts
// ✓ MY My New Feature 2/2 ACs (100%)Lessons Learned
The type system is the best requirements tracker. Jira tracks what someone typed. The compiler tracks what actually exists. When @Implements<NavigationFeature>('tocClickLoadsPage') compiles, the feature exists, the AC exists, and the test references both.
Self-tracking closes the loop. REQ-TRACK verifying itself is not a gimmick. If the scanner breaks, the self-tracking test fails. If someone removes a feature, coverage drops below 100%. The immune system has an immune system.
Abstract methods are the right abstraction for acceptance criteria. An AC is "this must be true." An abstract method is "this must be implemented." The isomorphism is exact.
Decorators bridge what the type system can't reach. TypeScript checks that @Implements<Feature>('ac') is valid. But it can't check that every AC has an @Implements somewhere. The compliance scanner fills that gap — the decorator provides metadata, the type system validates references, and the scanner checks completeness.
It works on a real website. This is not a demo project. 20 features, 112 acceptance criteria, E2E tests in Playwright, unit tests in Vitest, property-based tests with fast-check, visual regression across 4 themes and 4 mobile devices, accessibility audits across 8 accent colors. The requirements chain is the backbone that holds it all together.
Tradeoffs and the Explosion Problem
This system has a cost. It's important to be honest about it.
What It Costs
More boilerplate per test file. Every test class needs @FeatureTest, every method needs @Implements or @Exclude, and a registration loop at the bottom bridges class methods to the test runner. That's ~10 extra lines per file. For 19 test files, that's ~190 lines of ceremony that didn't exist before.
Two files per feature instead of one. Adding a feature means creating a definition in requirements/features/ AND writing the test. Before, you just wrote the test. The feature definition is small (~15 lines), but it's another file to create, import, and maintain.
Class-based tests are less flexible than functions. Playwright and Vitest are designed around function-based tests. Wrapping them in classes with decorators adds indirection. Shared state between test methods in a class can lead to subtle coupling. The @Exclude() decorator on every helper is noise.
Dynamic tests use a weaker pattern. Loop-generated tests (176 visual regression screenshots, 72 contrast checks) can't have per-method @Implements — they use coversACs at the class level, which says "this file covers these ACs" without linking specific tests to specific criteria. Less precise.
The Explosion Problem
The real question is: does this scale?
Right now: 20 features, 112 ACs, 19 test files. The compliance report fits on one screen. But imagine a larger project:
100 features × 10 ACs each = 1,000 abstract methods across 100 feature files. That's 100 imports to manage, 1,000 decorator references to keep in sync, and a compliance report that scrolls for pages.
Epics containing features containing stories containing tasks — the C# version supports this hierarchy via generics (
Feature<TParent>). The TypeScript version doesn't implement hierarchy yet. Adding it means:Epic→Feature<TEpic>→Story<TFeature>→ each with their own ACs. The number of abstract classes and cross-references grows combinatorially.Multi-team, multi-repo — the scanner reads files from disk. If requirements span multiple repositories, the scanner can't see them. The C# version solves this with NuGet packages and project references. The TypeScript version would need npm packages or a monorepo.
Where It Breaks Down
| Scale | Works? | Why |
|---|---|---|
| 1-20 features | Yes | Fits in your head, report on one screen |
| 20-50 features | Probably | Scanner still fast, but managing 50 feature files gets tedious |
| 50-200 features | Needs tooling | Scanner needs filtering (by team, by priority), feature files need code generation |
| 200+ features | Needs a different approach | At this scale, you want a database + UI, not abstract classes in files |
What This System Is and Isn't
It IS: a type-safe traceability chain for small-to-medium projects where the developer who writes the feature also writes the tests. The compiler catches broken links. The scanner catches gaps. The self-tracking verifies the infrastructure.
It ISN'T: a replacement for Jira, Azure DevOps, or Linear. It doesn't handle: assignment, estimation, sprint planning, dependencies between teams, or approval workflows. It tracks the technical chain (feature → test), not the project management chain (who → when → approved by whom).
The sweet spot is exactly where this website sits: a single developer, ~20 features, ~100 ACs, all in one repo. The overhead of the decorator system pays for itself in compile-time safety and compliance visibility. Beyond that, the costs grow faster than the benefits — and you're better off with a real requirements management tool that generates the feature definitions, rather than writing them by hand.
Could It Be Simpler?
Yes. If you don't need the type-safe keyof T checking, you could use plain string tags in test names (test('TOC click @NAV:0', ...)), skip the decorator boilerplate entirely, and have the scanner grep for @FEAT:AC patterns. You lose compile-time typo detection but gain simplicity. For many projects, that's the right tradeoff.
The full decorator system exists here because this website is a demonstration of the approach — proving that the C# philosophy translates to TypeScript. In production, choose the level of ceremony that matches your project's size and risk tolerance.