Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Scaling Requirements as Code: From Hand-Written Features to Generated Adapters

At 20 features, you write abstract classes by hand. At 200, you generate them from Jira. At 2,000, you generate them from a database. The compiler enforcement never changes — only the authoring tool does.

The Scaling Question

The TypeScript requirements tracking system works beautifully at the scale of this website: 20 features, 112 acceptance criteria, one developer. But what happens when the project grows?

  • 100 features × 10 ACs each = 1,000 abstract methods across 100 files. That's 100 imports to manage, 1,000 decorator references to keep in sync.
  • Multiple teams each owning different features, updating requirements in Jira or Linear, not in TypeScript files.
  • PMs and stakeholders who define acceptance criteria in tools they know — not in code.

The hand-written approach breaks down. But the type-safe chain doesn't have to.

The Insight: Generate the Types, Keep the Enforcement

The compiler doesn't care whether a .ts file was written by a human or a script. abstract class Jira456Feature { abstract adminCanAssignRoles(): ACResult; } enforces keyof T the same way regardless of origin.

The solution: adapters that read requirements from external sources and generate the TypeScript feature classes. The generated files are committed to git. The compiler validates all @Implements references. The scanner checks coverage. Nothing in the enforcement layer changes.

┌──────────────────┐     ┌───────────────────┐     ┌─────────────────────┐
│ External Sources │     │ Adapter / Codegen │     │ Type-Safe Artifacts │
│                  │────>│                   │────>│                     │
│ Jira API         │     │ sync-requirements │     │ requirements/       │
│ Linear API       │     │   --source=jira   │     │   features/*.ts     │
│ Azure DevOps     │     │   --source=linear │     │   (abstract classes │
│ PostgreSQL       │     │   --source=db     │     │    with ACs)        │
│ YAML files       │     │   --source=file   │     │                     │
│ Git log          │     │                   │     │ DO NOT EDIT header  │
└──────────────────┘     └───────────────────┘     └─────────────────────┘
                                                          │
                                                          ▼
                                                  tsc --noEmit
                                                  (@Implements validated)
                                                          │
                                                          ▼
                                                  Compliance Scanner
                                                  (same as now)

How It Stays Type-Safe with a Database

This is the key question: how do you get compiler enforcement from a database?

The answer: you don't query the database at compile time. You query it at sync time — a script reads the database and writes .ts files. The .ts files are the bridge between the dynamic world (database rows change anytime) and the static world (TypeScript's type system checks at compile time).

PM updates Jira: Feature JIRA-456 has 3 ACs
  ↓
Developer runs: npm run sync --source=jira
  ↓
Codegen writes:
  abstract class Jira456Feature {
    abstract adminCanAssignRoles(): ACResult;
    abstract userSeesOnlyPermitted(): ACResult;
    abstract roleChangesImmediate(): ACResult;
  }
  ↓
Developer writes test:
  @Implements<Jira456Feature>('adminCanAssignRoles')
  ↓
PM removes AC-2 from Jira, developer re-syncsCodegen regenerates (AC-2 gone):
  abstract class Jira456Feature {
    abstract adminCanAssignRoles(): ACResult;
    abstract roleChangesImmediate(): ACResult;
  }
  ↓
Developer's @Implements<Jira456Feature>('userSeesOnlyPermitted')
  → COMPILE ERROR: 'userSeesOnlyPermitted' not assignable to keyof Jira456FeatureDeveloper removes the orphan testChain stays consistent

The database is the authoring tool. The generated .ts files are the enforcement tool. The compiler is the gatekeeper. None of these roles change with scale.

The Adapter Pattern

Every external source implements the same interface:

// scripts/adapters/base.ts
export interface ExternalRequirement {
  id: string;
  title: string;
  priority: 'critical' | 'high' | 'medium' | 'low';
  acceptanceCriteria: { name: string; description: string }[];
  sourceUrl?: string;
  parent?: string;        // Epic / parent feature ID
  labels?: string[];
  lastUpdated: string;    // ISO date
}

export interface RequirementAdapter {
  name: string;
  fetch(): Promise<ExternalRequirement[]>;
}

YAML File Adapter (simplest — no API needed)

For small-to-medium projects that don't use Jira:

# requirements.yaml
- id: AUTH
  title: User Authentication
  priority: critical
  acceptanceCriteria:
    - name: loginWithEmail
      description: User can log in with email and password
    - name: sessionPersists
      description: Session persists across browser refresh
    - name: logoutClearsSession
      description: Logout clears all session data

The adapter reads this file and produces the same ExternalRequirement[] as any other source.

Jira Adapter

// scripts/adapters/jira.ts
export class JiraAdapter implements RequirementAdapter {
  name = 'jira';

  constructor(
    private baseUrl: string,   // https://myorg.atlassian.net
    private project: string,   // PROJ
    private auth: string,      // API token
  ) {}

  async fetch(): Promise<ExternalRequirement[]> {
    // JQL: project = PROJ AND type in (Story, Feature) AND status != Done
    // Parse: summary → title
    // Parse: acceptance criteria custom field → AC list
    // Map: Jira priority → our enum
    // Map: labels → tags
  }
}

Linear Adapter

// scripts/adapters/linear.ts
export class LinearAdapter implements RequirementAdapter {
  name = 'linear';

  async fetch(): Promise<ExternalRequirement[]> {
    // GraphQL API
    // Issues with labels → features
    // Sub-issues or checklist items → ACs
    // Team assignment → labels
  }
}

Database Adapter

// scripts/adapters/database.ts
export class DatabaseAdapter implements RequirementAdapter {
  name = 'database';

  async fetch(): Promise<ExternalRequirement[]> {
    // SQL: SELECT f.id, f.title, f.priority, ac.name, ac.description
    //      FROM features f
    //      JOIN acceptance_criteria ac ON ac.feature_id = f.id
    //      WHERE f.status != 'archived'
  }
}

Git Log Adapter (supplementary)

This one doesn't produce features — it enriches them with commit tracing:

// scripts/adapters/git.ts
export class GitLogAdapter {
  // Scans git log for commit messages referencing feature IDs
  // Output: { featureId: 'NAV', commits: ['abc123'], authors: ['serard'] }
  // Used by the compliance report to show: "NAV: last touched 2 days ago by serard"
}

The Code Generator

The generator reads any adapter's output and writes .ts files:

// scripts/sync-requirements.ts
function generateFeatureFile(req: ExternalRequirement, source: string): string {
  const className = toClassName(req.id);
  const acMethods = req.acceptanceCriteria.map(ac =>
    `  /** ${ac.description} */\n  abstract ${toCamelCase(ac.name)}(): ACResult;`
  ).join('\n\n');

  return `// Generated by: npx tsx scripts/sync-requirements.ts --source=${source}
// Source: ${req.id} "${req.title}" (synced: ${new Date().toISOString()})
// DO NOT EDIT — regenerate with: npm run sync:requirements

import { Feature, Priority, type ACResult } from '../base';

export abstract class ${className} extends Feature {
  readonly id = '${req.id}';
  readonly title = '${req.title}';
  readonly priority = Priority.${capitalize(req.priority)};
  readonly source = '${source}';
  readonly sourceUrl = '${req.sourceUrl ?? ''}';

${acMethods}
}
`;
}

Every generated file has:

  • A DO NOT EDIT header with the regeneration command
  • The source system name and URL (clickable in the IDE)
  • The sync timestamp
  • Standard abstract class structure — identical to hand-written features

What Gets Committed to Git

Generated files must be committed. This is non-negotiable for three reasons:

  1. tsc works without API credentials. A new developer clones the repo, runs tsc --noEmit — it works. No Jira token needed.
  2. CI type-checks without network access. The build server doesn't need to reach Jira to validate @Implements references.
  3. Git diff shows exactly what changed. When a PM removes an AC from Jira and you re-sync, the diff shows which abstract method was removed — and the compiler shows which tests broke.
requirements/features/
├── navigation.ts          ← hand-written (local features)
├── scroll-spy.ts          ← hand-written (local features)
├── jira-456.ts            ← GENERATED (synced from Jira)
├── jira-789.ts            ← GENERATED (synced from Jira)
├── linear-auth-42.ts      ← GENERATED (synced from Linear)
└── .sync-state.json       ← last sync timestamp per source

Hand-written and generated features coexist. The compiler treats them identically.

Developer Workflow

# Pull latest requirements from Jira
npm run work -- --sync --source=jira

# See what would change without writing
npm run work -- --sync --source=jira --dry-run

# Pull from a local YAML file (no API needed)
npm run work -- --sync --source=file

# Check compliance after sync
npm run work -- --compliance

# Full cycle: sync → type-check → compliance
npm run work -- --sync --source=jira && npx tsc --noEmit && npm run work -- --compliance

Interactive workflow menu:

--- workflow ---
  ...
  f  Feature compliance report
  y  Sync requirements (from external source)
  ...

Scaling Roadmap

Scale Authoring Enforcement Scanner
1-20 features Hand-written .ts tsc + keyof T Full report on one screen
20-100 YAML file → codegen Same Filter by label/team
100-500 Jira/Linear → codegen Same Filter + team dashboards
500+ Database → codegen Same Dashboard UI + trend charts

At every scale:

  • The generated .ts files have the same structure
  • @Implements<Feature>('ac') is validated by tsc
  • The compliance scanner cross-references features × tests
  • Self-tracking (REQ-TRACK) verifies the infrastructure

The only thing that changes is where the feature definitions come from — and that's the adapter's job.

What This Enables

Bidirectional Tracing

With the Git log adapter, the compliance report can show:

JIRA-456  User Roles           3/3 ACs (100%)
    Source: https://myorg.atlassian.net/browse/JIRA-456
    Last synced: 2026-03-20
    Commits: abc123 (serard, 2026-03-18), def456 (alice, 2026-03-19)
    Tests: test/e2e/user-roles.spec.ts (3 @Implements)

From Jira → to generated class → to test decorator → to git commits → back to Jira. Full traceability, compiler-checked at every link.

Drift Detection

The sync script can detect drift:

⚠ JIRA-456: Jira has 4 ACs, generated file has 3
  New AC: "adminCanRevokeRoles" (added in Jira on 2026-03-22)
  Action: re-sync to update generated file, then write test

Stale Feature Detection

JIRA-123: Last synced 45 days ago. Jira shows status "Done".
  Action: archive or remove generated file?

The Philosophy, Restated

The C# Requirements DSL established the principle: requirements should be types, not metadata. The TypeScript implementation proved it works outside .NET.

This scaling plan extends it: the authoring tool doesn't matter — the type system is the enforcement layer. Whether a PM writes an AC in Jira, a developer writes it in YAML, or a script reads it from a database — it becomes an abstract method on a TypeScript class, and the compiler refuses to build until every @Implements reference is valid.

The database doesn't make it less type-safe. The code generation makes it more scalable. The chain stays unbroken.