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;
}// 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.
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' }// 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;
}// 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'.
}// 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;
}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'// 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
}// 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"]// 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)── 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.
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