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

Part III: TypeScript Hierarchy Redesign

The versus series said: "TypeScript's typed specs have a flat hierarchy. All features extend Feature. There's no Epic > Feature > Story > Task enforcement." Time to fix that.

The Current State: Flat

Here is what V1 looks like. Every requirement file extends the same base class. There is no structural distinction between a strategic initiative and a UI tweak.

// requirements/base.ts — V1 (flat)
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;
}

Every feature extends Feature directly. No grouping. No hierarchy. NavigationFeature and SearchFeature and BuildPipelineFeature are all siblings. When the scanner walks the directory, it produces a flat list. When the compliance report renders, every row sits at the same depth. You cannot tell which features belong to a broader initiative and which ones are independent. You cannot tell which stories feed into which features.

Diagram

Five features, zero structure. The compiler cannot enforce that a story belongs to a feature, or that a feature belongs to an epic. That enforcement only exists in documentation and discipline, which means it does not exist at all.

The Redesign: Generated from Flavor

When a developer runs tspec init --flavor=agile --lang=ts, tspec reads the flavor configuration (Part II) and generates hierarchical base types. These are not hand-written. They come from the meta-metamodel definition for the agile flavor, which declares the hierarchy levels and their allowed parent types.

// Generated by: tspec init --flavor=agile --lang=ts
// DO NOT EDIT — regenerate with: tspec init --flavor=agile --lang=ts

export enum Priority { Critical = 'critical', High = 'high', Medium = 'medium', Low = 'low' }
export interface ACResult { satisfied: boolean; reason?: string; }

export abstract class Requirement {
  abstract readonly id: string;
  abstract readonly title: string;
  abstract readonly priority: Priority;
}

export abstract class Epic extends Requirement { }

export abstract class Feature<TParent extends Epic = Epic> extends Requirement {
  abstract readonly parent: new () => TParent;
}

export abstract class Story<TParent extends Feature<any> = Feature<any>> extends Requirement {
  abstract readonly parent: new () => TParent;
}

export abstract class Task<TParent extends Story<any> = Story<any>> extends Requirement {
  abstract readonly parent: new () => TParent;
}

export abstract class Bug<TTarget extends Requirement = Requirement> extends Requirement {
  abstract readonly target: new () => TTarget;
  abstract readonly severity: BugSeverity;
}

export enum BugSeverity { Critical = 'critical', Major = 'major', Minor = 'minor', Cosmetic = 'cosmetic' }

The key mechanism is the generic constraint. Feature<TParent extends Epic> means you can only parameterize a feature with a type that extends Epic. Try to pass a Story and the compiler rejects it. The hierarchy is not a convention. It is a type-level invariant.

Compile-Time Enforcement

Three examples show how the constraint works in practice.

Valid hierarchy (compiles)

// Epic
export abstract class IdentityEpic extends Epic {
  readonly id = 'IDENTITY';
  readonly title = 'Identity & Access Management';
  readonly priority = Priority.Critical;
}

// Feature under Epic — compiles
export abstract class UserRolesFeature extends Feature<IdentityEpic> {
  readonly id = 'ROLES';
  readonly title = 'User Roles and Permissions';
  readonly priority = Priority.Critical;
  readonly parent = IdentityEpic;

  abstract adminCanAssignRoles(): ACResult;
  abstract nonAdminCannotAssign(): ACResult;
  abstract roleChangeImmediate(): ACResult;
}

// Story under Feature — compiles
export abstract class JwtRefreshStory extends Story<UserRolesFeature> {
  readonly id = 'JWT';
  readonly title = 'JWT Refresh Token Flow';
  readonly priority = Priority.High;
  readonly parent = UserRolesFeature;

  abstract tokenRefreshesBeforeExpiry(): ACResult;
  abstract expiredTokenReturns401(): ACResult;
}

Each level links to its parent through the generic parameter. The compiler verifies that IdentityEpic is an Epic, that UserRolesFeature is a Feature, and that the parent references are constructor functions of the correct type. If someone rearranges the hierarchy, the compiler catches it before the code reaches a branch.

Invalid hierarchy (compile error)

// Feature under Story — DOES NOT COMPILE
export abstract class BadFeature extends Feature<JwtRefreshStory> {
  //                                             ~~~~~~~~~~~~~~~
  // Type 'JwtRefreshStory' does not satisfy the constraint 'Epic'.
  // Type 'Story<UserRolesFeature>' is not assignable to type 'Epic'.
}

A feature must live under an epic. Putting it under a story violates the constraint. The error message is clear: Story is not Epic. No amount of casting or any abuse will fix this without breaking the generated base types themselves.

Bug targeting a feature (compiles)

export abstract class NegativeTotalBug extends Bug<UserRolesFeature> {
  readonly id = 'BUG-42';
  readonly title = 'Role assignment crashes on empty role list';
  readonly priority = Priority.Critical;
  readonly target = UserRolesFeature;
  readonly severity = BugSeverity.Critical;

  abstract emptyRoleListHandled(): ACResult;
}

Bugs use TTarget extends Requirement, which means they can target any level: an epic, a feature, a story, or even another bug. The generic parameter carries the reference so the scanner can trace which requirement the bug affects.

C# Side-by-Side

The same hierarchy already exists in the CMF C# implementation. The syntax differs, but the constraint mechanism is identical.

// C# — the same hierarchy, already supported
public abstract record IdentityEpic : Epic
{
    public override string Title => "Identity & Access Management";
    public override RequirementPriority Priority => RequirementPriority.Critical;
}

public abstract record UserRolesFeature : Feature<IdentityEpic>
{
    public override string Title => "User Roles and Permissions";
    public abstract AcceptanceCriterionResult AdminCanAssignRoles(UserId admin, UserId target, RoleId role);
}

// Invalid — same compile error
// public abstract record BadFeature : Feature<JwtRefreshStory> { }
// Error CS0311: 'JwtRefreshStory' cannot be used as type parameter 'TParent'

Both languages enforce the same invariant. The TypeScript version uses extends constraints; the C# version uses generic type parameter constraints with where TParent : Epic. The error messages differ in wording but not in meaning. A project that uses both languages gets the same structural guarantees on both sides.

Backward Compatibility

The default type parameter = Epic means existing V1 code still compiles without modification.

// V1 code (no generic parameter) — STILL COMPILES
export abstract class NavigationFeature extends Feature {
  // Feature defaults to Feature<Epic>
  // No parent required
}

When you omit the generic parameter, TypeScript fills in the default. Feature becomes Feature<Epic>, which satisfies the constraint. The parent property is still abstract, but existing code that does not declare a parent will not break until you try to instantiate it, and since these classes are abstract, that never happens directly.

Migration path: add generics incrementally. Start by declaring your epics, then add <TParent> to features one by one. The scanner handles both parameterized and unparameterized forms. Features without a parent appear at the root of the compliance tree. Features with a parent nest under it. You migrate at your own pace.

Scanner Reads Hierarchy

The scanner extracts the generic parameter with a straightforward regex pass over the class declarations.

// Scanner detects hierarchy
const hierarchyRegex = /extends\s+(Feature|Story|Task|Bug)<(\w+)>/;
// Captures: ["Feature", "IdentityEpic"] or ["Bug", "UserRolesFeature"]

Once the scanner knows which type each requirement extends and what its generic parameter is, it builds an adjacency list. The enriched compliance report then renders as a tree instead of a flat table.

── Feature Compliance Report (Hierarchical) ──

▼ IDENTITY    Identity & Access Management
  ✓ ROLES       User Roles and Permissions       3/3 ACs (100%)
    ✓ JWT          JWT Refresh Token Flow         2/2 ACs (100%)
    ✗ BUG-42       Role assignment crash          0/1 ACs (0%)
  ✓ AUTH        Authentication                    5/5 ACs (100%)
▼ PAYMENT     Payment Processing
  ✗ CHECKOUT    Checkout Flow                     4/6 ACs (67%)
    ✗ Missing: paymentTimeout, refundFlow
────────────────────────────────────────────────────────────
Coverage: 14/17 ACs (82%)
Critical uncovered: BUG-42, paymentTimeout
Quality gate: FAIL (critical gaps)

The flat list from V1 is gone. Every requirement has a position in the tree. Bugs appear under the feature they target. Stories nest under their parent feature. The quality gate now evaluates per-epic and per-feature, not just globally. A single uncovered bug on a critical feature fails the gate even if overall coverage looks healthy.

Diagram

Structure makes compliance actionable. When the report says "PAYMENT epic is failing", the team knows where to look. When a bug has zero ACs covered, the tree shows exactly which feature it threatens. The hierarchy is not decoration. It is the organizing principle that turns a list of requirements into a navigable map.


Previous: Part II: The Meta-Metamodel Next: Part IV: Custom Workflows