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
V1's flat shape: five features, zero structure, no compiler-enforced notion of which belong to which initiative.

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
Once the scanner reads the generic parameters, compliance renders as a tree with coverage on every node — per-epic and per-feature gates included.

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

⬇ Download