Dog-Fooding the Requirements DSL — Chapter 01: From typed-specs to typed-requirements
FEATURE-TRACE-EXPLORER-TUI— ten acceptance criteria, three@Satisfieslinks toREQ-DISCOVERABLE-TRACEABILITY,REQ-DOG-FOOD, andREQ-PARALLEL-DELIVERABLE. This chapter walks the class from its typed-specs-shaped twin to its actual@frenchexdev/requirementsshape, construct by construct.
The predecessor of @frenchexdev/requirements is the typed-specs series from March 2026. That series introduced a small, disciplined toolkit: Feature as an abstract class, acceptance criteria as abstract methods, and three decorators — @FeatureTest, @Implements, @Exclude — that bound tests to the features they verified. It worked. It covered 112 out of 112 ACs on the SSG at the time. It was the foundation that made the next move conceivable.
The next move is the one this series documents. The new package preserves what typed-specs had, renames one construct, and adds an entire layer above it. The preserved constructs give the new package its continuity — a reader of typed-specs recognises three-quarters of the vocabulary on sight. The renamed construct carries a semantic shift that the typed-specs vocabulary could not quite accommodate. The additions are what justify calling the new package a requirements DSL rather than a feature DSL.
This chapter is the migration map. It walks every construct from the typed-specs series and says, of each one: did it persist, was it renamed, was it split, is there a new thing with no predecessor, or was something dropped. The spine is a table; each row is justified in the prose below it. The running example — FEATURE-TRACE-EXPLORER-TUI — then gets rewritten line by line, with every changed line pointed at.
The migration matrix
Every construct the typed-specs series named appears in one of the rows below, together with every construct the new package adds. The change kind column classifies the transition into one of five kinds:
- persists — same name, same shape, same meaning; may have enlarged semantics in the new neighbourhood but the typed-specs reader will not be surprised.
- renames — same shape, same compile-time enforcement, different name. A renaming is rarely neutral: the new word usually carries a semantic shift that was implicit before.
- splits — one typed-specs construct becomes two or more in the new package.
- new — no predecessor in typed-specs. These rows are where the Requirement stratum lives.
- drops — the typed-specs construct is not carried forward.
| typed-specs name | verb/role | @frenchexdev/requirements name |
verb/role | change kind |
|---|---|---|---|---|
Priority |
enum — Critical/High/Medium/Low | Priority |
enum — Critical/High/Medium/Low | persists |
ACResult |
{ satisfied, reason? } marker |
ACResult |
{ satisfied, reason? } marker |
persists |
abstract class Feature (3 fields) |
WHAT | abstract class Feature (4 fields — adds enabled) |
WHAT | persists |
| (no predecessor) | abstract class Requirement<S> (11 abstract/optional fields, generic over Style) |
WHY | new | |
| (no predecessor) | RequirementStyle, StyleVocabulary, StyleValidators, RequirementReporter, FitCriterionAdapter |
project-chosen register | new | |
| (no predecessor) | Rationale, Evidence, FitCriterion |
typed reasoning / verifiables | new | |
| (no predecessor) | EarsStatement, RequirementSource, RiskStatement, HistoryEntry |
discriminated unions over the Requirement fields | new | |
| (no predecessor) | RequirementId, FeatureId, AcName, IsoDate, Sentence, Percentage |
branded primitives | new | |
@FeatureTest(Feature) |
class decorator, links test class to Feature | @FeatureTest(Feature) |
class decorator, links test class to Feature, auto-registers with vitest | persists |
@Implements<Feature>('ac') |
method decorator, links method to AC | @Verifies<Feature>('ac') |
method decorator, links method to AC | renames |
@Exclude() |
method decorator, skip from scanning | @Exclude(reason?) |
method decorator, skip from scanning, optional diagnostic | persists (enlarged) |
| (no predecessor) | @Satisfies(Req1, Req2, …) |
class decorator on Feature — bidirectional Feature → Requirement link | new | |
| (no predecessor) | @Refines(ParentReq) |
class decorator on Requirement — Requirement → Requirement decomposition | new | |
| (no predecessor) | @Expects(TestLevel) |
method decorator on AC — declares which test levels are required | new | |
static coversACs |
class-level escape hatch for parametrised tests | static coversACs |
class-level escape hatch for parametrised tests | persists |
RequirementRef[] runtime registry |
test-to-AC index | RequirementRef[] + SatisfactionLink[] + RefinementLink[] |
test-to-AC, Feature-to-Req, Req-to-Req indices | splits |
compliance (regex-based scanner) |
reads decorators from source | compliance (AST-based scanner via ts-morph) |
reads decorators from source | persists (richer implementation) |
Every row above is expanded in prose below. The table is meant to be re-read at the end of the chapter — it makes more sense when the words next to each row have accumulated meaning.
No row has drops as its change kind. That is not an accident: the new package is a superset of the old. Nothing from typed-specs disappears. This is a design property, not a coincidence. A migration that drops something forces the reader to abandon what they already knew; a migration that only adds invites the reader to keep what they had and grow into the rest. The one rename — @Implements → @Verifies — is the only point at which the new package asks the typed-specs reader to change a habit.
Construct group 1 — the types that persisted
Six constructs carry over unchanged or with strictly additive changes. Each one has the same shape in the new package that it had in typed-specs. What changes is not the shape but the neighbourhood: the types around them are richer, and that richer neighbourhood subtly enlarges what they mean.
Priority
In typed-specs, the Priority enum was defined in typed-specs/04-features.md as four values:
export enum Priority {
Critical = 'critical',
High = 'high',
Medium = 'medium',
Low = 'low',
}export enum Priority {
Critical = 'critical',
High = 'high',
Medium = 'medium',
Low = 'low',
}In @frenchexdev/requirements, the enum is identical. The four values. The four string encodings. The same order. A Feature in the new package declares its priority the same way a Feature did in typed-specs; a Requirement in the new package declares its priority the same way.
What enlarges is what Priority ranges over. In typed-specs, priority was a property of features only. In the new package, priority is a property of features and of requirements. A Critical Requirement and a Critical Feature can coexist; a High Feature can satisfy a Critical Requirement, which is an interesting compliance question the scanner reports on. The enum is the same; the questions it lets you ask are more.
ACResult
export interface ACResult {
satisfied: boolean;
reason?: string;
}export interface ACResult {
satisfied: boolean;
reason?: string;
}Identical in both packages. Two fields. One required boolean, one optional explanation string. The interface declaration is a direct copy of the typed-specs version.
Typed-specs described ACResult as a marker type reserved for a future runtime health check. That description is still accurate in the new package: abstract methods on Feature declare ACResult as their return type to give them a meaningful signature, but the tests that verify those methods still use Playwright or vitest assertions, not ACResult. The marker is load-bearing in one sense only: it ensures that AC abstract methods are callable in principle, which lets future tooling run them as post-deployment health checks without changing any feature file.
abstract class Feature
This is the first construct where persists comes with an asterisk. Typed-specs defined Feature with three abstract fields:
// typed-specs version
export abstract class Feature {
abstract readonly id: string;
abstract readonly title: string;
abstract readonly priority: Priority;
}// typed-specs version
export abstract class Feature {
abstract readonly id: string;
abstract readonly title: string;
abstract readonly priority: Priority;
}The new package defines Feature with four fields: three abstract, one optional concrete:
// @frenchexdev/requirements — src/base.ts
export abstract class Feature {
abstract readonly id: string;
abstract readonly title: string;
abstract readonly priority: Priority;
readonly enabled?: boolean;
}// @frenchexdev/requirements — src/base.ts
export abstract class Feature {
abstract readonly id: string;
abstract readonly title: string;
abstract readonly priority: Priority;
readonly enabled?: boolean;
}The three abstract fields are unchanged. The addition is enabled?: boolean. The question mark matters: the field is optional and defaults to undefined. Every feature from the typed-specs era continues to compile against the new base class without modification.
What does enabled mean? It is the field that lets a feature class declare itself as roadmap-only, not-yet-shipped, or intentionally dormant. A feature with enabled = false still counts for compliance-matrix coverage — the ACs are still enumerated, the @Satisfies link is still recorded, the test files are still required — but the compliance gate treats it differently: its uncovered ACs do not fail the critical tier. The field is how the running example — FEATURE-TRACE-EXPLORER-TUI, a tier-2 roadmap item — declares itself a roadmap item without being invisible to the scanner.
Typed-specs had no equivalent. A typed-specs feature was either in the codebase or not; there was no way for a feature to be declared-but-not-shipped. The new field formalises a distinction that teams were already making informally — "this feature is real, it is planned, its ACs are known, but we have not implemented it yet" — and lets the compliance scanner treat the declared-but-dormant case gracefully.
The three concrete scanner consequences of enabled = false are worth enumerating: the Feature appears in the REQ → FEAT graph but renders with a dotted edge; the Feature's ACs are counted toward total-AC metrics but excluded from coverage-ratio metrics; and the Feature does not contribute to the critical-tier pass/fail gate even if its priority is Critical. Each of these is a behaviour the compliance scanner implements at the boundary of "real work the team is doing today" and "roadmap work the team has committed to but not yet started". Without enabled, the three behaviours would have to be carried by three separate conventions, each forgettable.
A subtler consequence: enabled is readable by external tooling. A product-management dashboard that consumes the package's JSON reports can filter to enabled Features for capacity reporting, and to all Features for roadmap reporting, using the same field. The field is not a scanner-only affordance; it is public surface.
@FeatureTest
The class decorator has the same signature and the same primary effect in both packages. Typed-specs defined it as:
// typed-specs/05-decorators.md
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;
(target as any).__featureClass = feature;
for (const ref of registry) {
if (ref.testClass === target.name && !ref.feature) {
ref.feature = feature.name;
}
}
return target;
};
}// typed-specs/05-decorators.md
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;
(target as any).__featureClass = feature;
for (const ref of registry) {
if (ref.testClass === target.name && !ref.feature) {
ref.feature = feature.name;
}
}
return target;
};
}The @frenchexdev/requirements version, in src/decorators.ts, adds one capability: auto-registration with vitest. Where typed-specs test files needed prototype-iteration boilerplate to bridge class methods into vitest's functional describe/it API, the new decorator does that bridging inside itself:
if (typeof g.describe === 'function' && typeof g.it === 'function') {
const vDescribe = g.describe as (name: string, fn: () => void) => void;
const vIt = g.it as (name: string, fn: () => unknown, timeout?: number) => void;
const instance = new target();
vDescribe(target.name, () => {
for (const method of Object.getOwnPropertyNames(target.prototype)) {
if (method === 'constructor') continue;
if (excludedMethods.has(`${target.name}.${method}`)) continue;
vIt(method, () => (instance as unknown as Record<string, () => unknown>)[method]!(), opts?.timeout);
}
});
}if (typeof g.describe === 'function' && typeof g.it === 'function') {
const vDescribe = g.describe as (name: string, fn: () => void) => void;
const vIt = g.it as (name: string, fn: () => unknown, timeout?: number) => void;
const instance = new target();
vDescribe(target.name, () => {
for (const method of Object.getOwnPropertyNames(target.prototype)) {
if (method === 'constructor') continue;
if (excludedMethods.has(`${target.name}.${method}`)) continue;
vIt(method, () => (instance as unknown as Record<string, () => unknown>)[method]!(), opts?.timeout);
}
});
}The signature is the same. The meaning is the same. The ergonomic consequence is different: test files in the new package contain neither describe nor it. That absence is a first-class requirement — REQ-DOG-FOOD codifies it — and the auto-registration is the mechanism that lets the absence be honest rather than a cosmetic choice with hidden ceremony elsewhere.
The decorator's second job — backfilling the feature name onto registry entries already pushed by @Verifies — is identical to the typed-specs version. Method decorators run before class decorators in TypeScript's legacy decorator order; both packages deal with that ordering the same way.
@Exclude
// typed-specs
export function Exclude() { /* adds 'Class.method' to excludedMethods */ }
// @frenchexdev/requirements
export function Exclude() { /* adds 'Class.method' to excludedMethods */ }// typed-specs
export function Exclude() { /* adds 'Class.method' to excludedMethods */ }
// @frenchexdev/requirements
export function Exclude() { /* adds 'Class.method' to excludedMethods */ }Same shape, same role. The one enrichment, planned but not yet visible in every call site, is an optional reason: string parameter the scanner can surface in diagnostics: @Exclude('setup helper for page navigation'). An excluded method without a reason is indistinguishable from a missed @Verifies; an excluded method with a reason tells the reader why the scanner is being told to look away. The field is optional; every call site from the typed-specs era continues to work. The change is strictly additive.
static coversACs
The class-level escape hatch for parametrised tests — static readonly coversACs: (keyof Feature)[] — persists unchanged. Typed-specs used it for visual regression tests generated in loops; the new package uses it for scenarii replay, where a single test class drives a generated set of scenario cases. The scanner recognises both forms in both packages.
The coversACs array remains the pragmatic recommendation for exactly the case where @Verifies cannot apply: a loop that generates test instances at registration time. Per-method @Verifies is preferred everywhere else, in both packages, for the same reason it was preferred in typed-specs — granularity.
A note on continuity
All six persisted constructs share a property worth stating once, in aggregate: a typed-specs file, opened today against the new package's base types, compiles. There is no version 1 shim, no compatibility shim, no polyfill. Priority imports from a new path, but the enum is the same enum. ACResult imports from a new path, but the interface is the same interface. Feature gains one optional field; optional fields are backward-compatible. @FeatureTest gains auto-vitest-registration; the gain is invisible at the call site. @Exclude gains an optional reason argument; the argument is optional.
The only syntactic change a typed-specs codebase needs to make, to compile against the new package's base types, is the rename covered in the next section. That rename is mechanical. A sed or a multi-file search-and-replace does it in a single edit. Every other preserved construct arrives at its new home unchanged.
This property is not an accident; it was a constraint the new package held itself to. The typed-specs series established a set of concepts that worked well enough to document in a six-part blog series. Breaking those concepts would ask readers to unlearn what they had just finished learning. Preserving those concepts, and building above them, was the only honest way to argue that the new package is an evolution rather than a replacement. Chapter 01b — the historical path — returns to this point from a chronology angle: why Feature came first, why Requirement came later, and why the gap between them was a legitimate phase rather than a design omission.
Construct group 2 — the renames
One construct is renamed. The rename is the reason this chapter exists as more than a table.
@Implements becomes @Verifies
The typed-specs signature, from typed-specs/05-decorators.md:
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,
});
};
}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,
});
};
}The @frenchexdev/requirements signature, from src/decorators.ts:
export function Verifies<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,
});
};
}export function Verifies<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,
});
};
}Side by side, the two signatures are nearly identical. The generic constraint (T extends Feature), the key-of-T typing (keyof T & string), the registry write, the field names on the RequirementRef — all the same. The only source-level difference is the third parameter: _descriptor: PropertyDescriptor in typed-specs, _descriptor?: PropertyDescriptor in the new package. That difference is a TypeScript 5 compatibility concern — quoted-name method decorators are invoked with two arguments, not three, in the new compiler — and is orthogonal to the rename.
The rename, on its own, changes nothing the compiler enforces. @Implements<NavigationFeature>('tocClickLoadsPage') and @Verifies<NavigationFeature>('tocClickLoadsPage') do exactly the same thing. A find-and-replace in a typed-specs codebase would mechanically migrate every call site.
So why rename?
Because the word implements is subtly wrong in a world where Requirement exists. In a Feature-only world, the verb has nowhere to collide. A test method "implements" the AC because nothing else in the vocabulary is a candidate for that verb. The AC is an abstract method; the test is its body; "implements" is how OOP names the relation between an abstract method and its concrete realisation. The metaphor is intuitive because there is no neighbour to disturb it.
In a Feature-plus-Requirement world, the verb has three candidates, and implements is not the best fit for any of them.
- A Feature satisfies a Requirement. SysML, and the literature on requirements engineering generally, calls this relation satisfy. The Feature exists because the Requirement demands something; the Feature is the WHAT that meets the WHY. "Feature implements Requirement" is not wrong in a loose sense, but it conflates two legs: the Feature satisfies the Requirement by existing, and the Feature is itself implemented by code. Conflating those two is exactly the confusion that typed-specs was accused of — the word requirement appeared in the prose, but the model only carried feature.
- An AC is on a Feature. The Feature carries the AC as an abstract method. This is composition: Feature owns AC. No active verb is needed; the AC simply belongs to the Feature. OOP's "AC is a member of Feature" is the right description.
- A Test verifies an AC. The test does not implement the AC — the AC is not an abstract method that the test is a concrete body for, not in the new package. The AC is a specification marker with an
ACResultreturn type, and the test is a separate artefact that demonstrates, at runtime and with assertions, that the codebase satisfies the AC. The relation is verification, not implementation.
The three verbs are: Feature satisfies Requirement; AC is on Feature; Test verifies AC. They are each distinct, and each needs a distinct name at the decorator level: @Satisfies, @Feature (implicit in being an abstract method), @Verifies.
Verifies is the only word that cleanly fits the test-to-AC leg without colliding with the other two. Implements collides with Satisfies (both describe a kind of fulfilment). Meets, realises, fulfils — each has the same collision. Asserts is a candidate but emphasises the runtime behaviour of the test body rather than its relation to the AC. Verifies carries the correct semantic: the test proves the AC at the level of evidence, not the level of body-of-an-abstract-method.
One further point: the rename is prospective. The typed-specs codebase at the time of publication still has @Implements everywhere, and a migration from @Implements to @Verifies in a monorepo is mechanical. The rename is not a breaking change; it is a change of name that was held over from the typed-specs era because the gap it acknowledged — the absence of Requirement — was not yet filled. Now that Requirement is filled, the verb can be the verb it should always have been.
A single rename is worth almost nothing on its own. Its value is in what it permits the additions to mean, and the additions are the next three sections.
Why no other renames
A legitimate question, given the care taken over @Implements → @Verifies, is whether other names in the typed-specs vocabulary are equally load-bearing and equally in need of revision. The answer, after a pass through the surface, is no.
Feature is the correct name for what Feature is. A Feature is a unit of functionality that carries ACs and satisfies Requirements. There is no other word that better captures that role. SysML has System Element and Block; those are broader and less specific. DDD has Bounded Context and Aggregate; those are architectural and too coarse. Component, Module, Capability — all have their own connotations that mostly map to something larger than what a Feature carries. Feature remains the right word.
Priority is standard. ACResult is a marker with a self-describing name. @FeatureTest describes exactly what it does — declare that this class is a test bound to a Feature. @Exclude describes exactly what it does — exclude this method from scanning. None of these names has the collision problem that @Implements had.
The only borderline case is the AC itself. Should an abstract method on a Feature be called an AC (acceptance criterion) or something else? The typed-specs series adopted AC and it stuck; the new package keeps it. The alternative, criterion or requirement-slot or check, each carries its own baggage. Acceptance criterion is what the industry calls the concept; the abbreviation AC is compact and universally recognised; nothing is gained by renaming it. The verb attached to the AC — Test verifies AC — is the renaming that mattered.
Construct group 3 — the new decorators
Three decorators have no predecessor in typed-specs. Each one declares a relation that Requirement makes possible: Feature→Requirement, Requirement→Requirement, AC→TestLevel. Together they constitute the decorator surface of the Requirement stratum.
@Satisfies(Req1, Req2, …)
The class decorator on Feature. Declares which Requirements the Feature helps meet. From src/decorators.ts:
export function Satisfies<
R extends ReadonlyArray<abstract new (...args: any[]) => Requirement>,
>(...requirements: R) {
return function <C extends abstract new (...args: any[]) => Feature>(target: C): C {
const requirementClasses = requirements.map(r => r.name);
satisfactionLinks.push({ featureClass: target.name, requirementClasses });
(target as any).__satisfies = requirementClasses;
return target;
};
}export function Satisfies<
R extends ReadonlyArray<abstract new (...args: any[]) => Requirement>,
>(...requirements: R) {
return function <C extends abstract new (...args: any[]) => Feature>(target: C): C {
const requirementClasses = requirements.map(r => r.name);
satisfactionLinks.push({ featureClass: target.name, requirementClasses });
(target as any).__satisfies = requirementClasses;
return target;
};
}The generic constraint carries the weight. R extends ReadonlyArray<abstract new (...args: any[]) => Requirement> means each argument must be a subclass of the abstract Requirement base class. Passing something that is not a Requirement is a compile error. Passing a Feature class by mistake — a plausible slip — is a compile error. Passing a string that refers to a Requirement is a compile error. The decorator does not accept strings anywhere; Requirement-to-Feature links are type references, not string references.
The secondary effect is runtime registration. Each @Satisfies call pushes a SatisfactionLink onto a module-level array. The compliance scanner and the trace explorer both consume this array at report time: the scanner to answer "which Features satisfy REQ-X?" and the explorer to walk the graph interactively.
The decorator is variadic. A Feature may satisfy zero Requirements (unusual — and the scanner flags it as an orphan unless the Feature is explicitly marked with enabled = false), one, or several. The running example satisfies three:
@Satisfies(
ReqDiscoverableTraceabilityRequirement,
ReqDogFoodRequirement,
ReqParallelDeliverableRequirement,
)
export abstract class FeatureTraceExplorerTuiFeature extends Feature { … }@Satisfies(
ReqDiscoverableTraceabilityRequirement,
ReqDogFoodRequirement,
ReqParallelDeliverableRequirement,
)
export abstract class FeatureTraceExplorerTuiFeature extends Feature { … }Three Requirements. Three class references passed as decorator arguments. The scanner now knows, at compile time and at runtime, that FEATURE-TRACE-EXPLORER-TUI owes its existence to three distinct WHYs. A regression in one does not invisibly erase the Feature's purpose; the Feature still has two other Requirements to answer to. The explicit multiplicity is what makes the @Satisfies relation worth typing.
Typed-specs had no equivalent. A typed-specs Feature was named after its Requirement, and its existence was its justification. That mapping was implicit and uncheckable. @Satisfies makes the mapping explicit and checkable.
One subtlety: @Satisfies writes to a module-level satisfactionLinks array at decorator evaluation time, which means the link exists as soon as the Feature's module is imported. The compliance scanner does not rely on that runtime registration — it reads the decorator arguments statically from the AST, which sidesteps the usual problem of "modules that were not imported produce missing registry entries". But the runtime registration is still useful for other tooling: the trace explorer, for instance, loads every Feature and every Requirement module at startup and walks the resulting graph in memory without re-running the AST scanner. Registry and AST-scan are two ways of asking the same question; the scanner uses whichever is appropriate for the context.
@Refines(ParentReq)
The class decorator on Requirement. Declares that one Requirement is a refinement or decomposition of another. From src/decorators.ts:
export function Refines<R extends abstract new (...args: any[]) => Requirement>(
...parents: readonly R[]
) {
return function <C extends abstract new (...args: any[]) => Requirement>(target: C): C {
const parentClasses = parents.map(p => p.name);
refinementLinks.push({ childClass: target.name, parentClasses });
(target as any).__refines = parentClasses;
return target;
};
}export function Refines<R extends abstract new (...args: any[]) => Requirement>(
...parents: readonly R[]
) {
return function <C extends abstract new (...args: any[]) => Requirement>(target: C): C {
const parentClasses = parents.map(p => p.name);
refinementLinks.push({ childClass: target.name, parentClasses });
(target as any).__refines = parentClasses;
return target;
};
}The generic bounds are the mirror of @Satisfies: both decorator input and decorator target must be subclasses of Requirement. @Refines is the Requirement-to-Requirement relation. SysML calls this refine or derive; the literature calls it decomposition; the DSL calls it refinement.
A typical usage: a broad business Requirement ShoppingCartPersistenceRequirement has two derived child Requirements — CartRecoveryWithinThirtyDaysRequirement and CartExpiresAfterOneYearRequirement — each of which refines the parent by adding a specific bound or policy. The child Requirement writes:
@Refines(ShoppingCartPersistenceRequirement)
export abstract class CartRecoveryWithinThirtyDaysRequirement extends Requirement<DefaultStyleType> { … }@Refines(ShoppingCartPersistenceRequirement)
export abstract class CartRecoveryWithinThirtyDaysRequirement extends Requirement<DefaultStyleType> { … }The parent is a type reference. The refinement is bidirectional at the registry level: the parent can ask "which Requirements refine me?" and the child can ask "which Requirement do I refine?". The trace explorer displays the refinement tree. The compliance scanner treats orphan parents (a parent Requirement with no Features and no refinements) as a smell — the parent is either too abstract and needs refinement, or it is too specific and should be collapsed into its sole child.
The decorator is also variadic, so a single Requirement can refine multiple parents. This is less common than the single-parent case, but the DSL supports it: a cross-cutting Requirement that constrains two otherwise-independent parent Requirements can declare both with @Refines(ParentA, ParentB).
Typed-specs had no equivalent. Because typed-specs did not have Requirement as a type, it could not have a Requirement-to-Requirement relation. This decorator is one of the places where the new package does something qualitatively, not quantitatively, beyond what typed-specs could do.
A common question about @Refines: is refinement transitive? If A refines B and B refines C, does A refine C? The answer is yes for traversal, no for the registry. The refinementLinks array stores only the direct edges. The trace explorer and the compliance scanner compute the transitive closure on demand when the graph is traversed. This mirrors how SysML defines refine and derive: the relation is a direct edge; the transitive reach is a property of the graph, not a property of the individual edge. Projects that want to query "everything that eventually refines C" use the explorer's graph API; projects that want to check a specific direct edge use the registry directly. Both questions are first-class.
@Expects(TestLevel)
The method decorator on AC abstract methods. Declares which test levels the AC expects. From src/decorators.ts:
export function Expects(..._levels: TestLevel[]) {
return function (_target: any, _propertyKey: string): void {};
}export function Expects(..._levels: TestLevel[]) {
return function (_target: any, _propertyKey: string): void {};
}The runtime body is empty. @Expects is a pure AST marker; the compliance scanner reads the decorator arguments statically and builds an AC-by-TestLevel matrix from them. The test levels are the enum:
export enum TestLevel {
Unit = 'unit',
Functional = 'functional',
EndToEnd = 'e2e',
Accessibility = 'a11y',
Internationalization = 'i18n',
Visual = 'visual',
Performance = 'perf',
}export enum TestLevel {
Unit = 'unit',
Functional = 'functional',
EndToEnd = 'e2e',
Accessibility = 'a11y',
Internationalization = 'i18n',
Visual = 'visual',
Performance = 'perf',
}Seven values. Each is a plausible level of test for an AC. An AC decorated with @Expects(TestLevel.Unit, TestLevel.EndToEnd) tells the scanner: "this AC must have at least one unit test and at least one e2e test verifying it; if either is missing, the compliance report flags the AC as partially covered". The default — no @Expects on an AC — means the AC expects a unit test only. A team that wants stricter defaults changes the default in its scanner configuration; the per-AC override is always @Expects.
The decorator is what lets scaffolders — the seven test-file generators in the package — know what to emit. requirements scaffold e2e FEATURE-X reads the @Expects decorators on that Feature's ACs, computes the set of uncovered (AC, TestLevel) pairs, and emits stub test files for each pair. Without @Expects, the scaffolder cannot know whether a given AC is owed a visual regression test, a performance test, or neither; the information has nowhere to live.
Typed-specs had no equivalent. The typed-specs scanner recognised only one test level — whichever test file the AC appeared in was its test. The new package lets a single AC declare expectations across multiple levels, which matters because non-trivial ACs are almost always owed more than one kind of test.
A worked example from the running Feature. The AC traceExplorerOpensHelpOverlayOnQuestionMark can be meaningfully verified at three levels: a unit test that asserts the key-handler function returns the expected overlay state; a functional test that drives the TUI through a mocked I/O harness and observes the overlay rendering; an end-to-end test that spawns the actual binary, presses ?, and checks the terminal output. Each level catches a different class of regression. The AC's @Expects(TestLevel.Unit, TestLevel.Functional, TestLevel.EndToEnd) declaration turns the three-level expectation into a typed fact the scaffolder can generate stubs for, the compliance gate can enforce coverage of, and the report can surface as a matrix.
The seven-level enum is deliberately broad. Not every AC earns every level; most earn one. The enum exists so that the minority of ACs that earn more than one have a typed vocabulary to express the expectation. Without the enum, the expectation lives in prose in a commit message; with the enum, it lives in the AC's definition and the scanner can read it.
Construct group 4 — the new data layer
The Requirement class is generic over a Style, and the Style system is its own stratum of novelty. This section tours the new data layer at altitude. Chapters 04 and 05 enumerate every one of the twenty-two REQ classes and walk the fields in depth; chapters 08 through 11 walk the five Styles. Here the goal is only to signpost.
Discriminated unions
The new package adds six discriminated unions that typed-specs did not need, because typed-specs had no Requirement type to carry them.
EarsStatement— five EARS patterns plus a natural-language escape. Every Requirement'sstatementfield is typed against this union, and the Style's validators check the pattern-specific slot shape. A ubiquitous-pattern statement has aresponseslot; an event-driven statement has atriggerand aresponse; the type system refuses slot mixing.RequirementSource— eight provenance kinds. Stakeholder, regulation, standard, precedent-incident, customer-interview, market-research, derived-from-parent, design-decision. Thesourcefield on every Requirement is typed against this union.FitCriterion— seven verifiable kinds. Unit-test, coverage-threshold, quality-gate, metric, inspection, demonstration, narrative. The running example's RequirementREQ-DOG-FOODdeclares three fit criteria covering three different kinds — a quality gate (ripgrep returns zero matches), a unit-test binding (two named test methods), and a coverage threshold (98% lines on thecli/directory).Rationale— a structured reasoning artefact.claim,kind(project-validated string),evidence[],assumptions?. The evidence array is itself typed as a union over five evidence kinds — metric, incident, study, expert-opinion, precedent. Prose in a Jira ticket becomes prose-plus-type in a Requirement file.RiskStatement—level,ifNotMet,mitigations?. The level is narrowed to the Style's risk taxonomy, so a SIL-styled project gets SIL 1–4 while a default-styled project gets Low/Medium/High/Critical.HistoryEntry— append-only audit trail, one entry per Requirement change.date,author,change,reason. The scanner refuses to remove entries; the file schema refuses to rewrite history. A Requirement's history is the spec's memory.
Each of these unions is a place where prose becomes structure. Typed-specs had no place to put this structure because it had no Requirement class to attach the fields to; it put the prose in JSDoc and trusted the developer to keep it coherent. The new package refuses to trust: the prose becomes a typed object, the typed object is schema-checked, the schema is generated from the TypeScript types. Chapter 14 walks the AST extraction and registry mechanics.
Branded primitives
Six primitives are branded: RequirementId, FeatureId, AcName, IsoDate, Sentence, Percentage. Each is a string (or number, for Percentage) on the wire; each is a distinct type at compile time.
A branded type is a regular primitive with a phantom nominal type grafted on. RequirementId and FeatureId are both strings, but they are not interchangeable: a function that expects a FeatureId refuses a RequirementId, even though JavaScript would run either. The brand is enforced only at compile time; there is no runtime tag, no wrapper object, no allocation overhead.
The point is the smart constructor. parseRequirementId('REQ-DOG-FOOD') returns a RequirementId | ParseError union; the caller handles the error explicitly. Once the value is branded, the type system carries the invariant — "this string has been validated as a Requirement ID" — everywhere else. Parse, don't validate: the parse happens once, at the boundary, and the rest of the code is relieved of re-checking.
Typed-specs used plain strings for IDs. That worked at small scale — 20 features, 112 ACs — and produced no bugs that made it into the blog. At the scale of 22 Requirements, 25 Features, 54 test files, and five Styles, the brands pay for themselves: a Feature ID that accidentally ends up where a Requirement ID is expected is a compile error, not a lookup miss in a map.
REQ-DOG-FOOD as a worked example of the new data layer
The cornerstone Requirement of the package, REQ-DOG-FOOD, exercises most of the new data-layer types in one file. Its definition, abridged:
export abstract class ReqDogFoodRequirement extends Requirement<DefaultStyleType> {
readonly id = 'REQ-DOG-FOOD';
readonly title = 'The DSL must be tested with itself — @FeatureTest/@Verifies only, never describe/it';
readonly priority = Priority.Critical;
readonly status = 'Approved' as const;
readonly kind = 'Constraint' as const;
readonly statement = {
pattern: 'ubiquitous' as const,
response: 'use @FeatureTest and @Verifies for every test of every new command, against a Feature class under requirements/features/, and every Feature class must declare @Satisfies listing the Requirements it helps meet.',
};
readonly rationale = {
claim: 'A DSL whose authors do not dog-food it has no credibility; this is already a non-negotiable project rule recorded in user memory.',
kind: 'principle' as const,
evidence: [
{ kind: 'precedent' as const, requirement: 'feedback_no_describe_it',
rationale: 'User memory rule, absolute across all packages.' },
],
};
readonly fitCriteria = [
{ kind: 'quality-gate' as const, tool: 'rg', rule: '`rg "\\b(describe|it)\\(" packages/requirements/test` must return zero matches' },
{ kind: 'unit-test' as const, describes: '…', binds: ['satisfiesDecoratorRegistersBidirectionalLink', 'complianceStrictFailsOnFeatureWithoutSatisfies'] },
{ kind: 'coverage-threshold' as const, metric: 'line' as const, min: 98, scope: 'src/cli/**' },
];
readonly verificationMethod = 'Test' as const;
readonly source = { type: 'stakeholder' as const, role: 'project-rule', date: '2026-04-14' };
readonly risk = { level: 'Critical' as const, ifNotMet: 'Package preaches a DSL it does not use; credibility collapses; users reject.' };
}export abstract class ReqDogFoodRequirement extends Requirement<DefaultStyleType> {
readonly id = 'REQ-DOG-FOOD';
readonly title = 'The DSL must be tested with itself — @FeatureTest/@Verifies only, never describe/it';
readonly priority = Priority.Critical;
readonly status = 'Approved' as const;
readonly kind = 'Constraint' as const;
readonly statement = {
pattern: 'ubiquitous' as const,
response: 'use @FeatureTest and @Verifies for every test of every new command, against a Feature class under requirements/features/, and every Feature class must declare @Satisfies listing the Requirements it helps meet.',
};
readonly rationale = {
claim: 'A DSL whose authors do not dog-food it has no credibility; this is already a non-negotiable project rule recorded in user memory.',
kind: 'principle' as const,
evidence: [
{ kind: 'precedent' as const, requirement: 'feedback_no_describe_it',
rationale: 'User memory rule, absolute across all packages.' },
],
};
readonly fitCriteria = [
{ kind: 'quality-gate' as const, tool: 'rg', rule: '`rg "\\b(describe|it)\\(" packages/requirements/test` must return zero matches' },
{ kind: 'unit-test' as const, describes: '…', binds: ['satisfiesDecoratorRegistersBidirectionalLink', 'complianceStrictFailsOnFeatureWithoutSatisfies'] },
{ kind: 'coverage-threshold' as const, metric: 'line' as const, min: 98, scope: 'src/cli/**' },
];
readonly verificationMethod = 'Test' as const;
readonly source = { type: 'stakeholder' as const, role: 'project-rule', date: '2026-04-14' };
readonly risk = { level: 'Critical' as const, ifNotMet: 'Package preaches a DSL it does not use; credibility collapses; users reject.' };
}Eight typed fields. Each one exercises a different piece of the data layer.
id,title,prioritycarry over from Feature's base fields. ThepriorityisPriority.Critical— the same enum typed-specs used.statusis narrowed to the DefaultStyle's workflow states.'Approved' as constis the only accepted value in that slot for a Requirement that has cleared the review pipeline.kindis narrowed to the Style'srequirementKinds.'Constraint'is one of DefaultStyle's registered kinds; typos are compile errors.statementis an EARS ubiquitous-pattern clause with aresponseslot. The Style validator enforces that ubiquitous statements must carry aresponseand nothing else; event-driven statements must carry atriggerand aresponse. The discriminated union onpatterncarries the invariant.rationaleis a structured claim with akindand anevidence[]array. The single evidence item is a precedent reference pointing at a user-memory rule. Other Requirements cite metrics, incidents, studies, expert opinions — each a typed evidence kind.fitCriteriais a three-element array spanning three of the sevenFitCriterionkinds: a quality-gate (a ripgrep invocation that must return zero matches), a unit-test binding (two named test methods that prove the AC), and a coverage threshold (98% lines onsrc/cli/). Each kind has its own slot shape; the scanner refuses arrays where aquality-gateentry accidentally carries abindsfield.verificationMethodis narrowed to the Style'sverificationMethods.'Test'is one of five legal values for DefaultStyle; the others coverAnalysis,Inspection,Demonstration, andSimilarity.sourceis a provenance record.'stakeholder'is the source kind; the remaining slots (role,date) are validated against the Style's declared slots for that kind.riskis a taxonomy-narrowed level plus a consequence statement.'Critical'is DefaultStyle's top level; IndustrialStyle replaces it withSIL-4.
Typed-specs could express none of this as types. A typed-specs Feature was a class with three fields and a set of abstract methods; there was no place for the eight-field Requirement to live. The new data layer is what makes it possible for a class to be the specification rather than merely name one.
The running example, migrated line by line
The actual FEATURE-TRACE-EXPLORER-TUI file, in full, from packages/requirements/requirements/features/feature-trace-explorer-tui.ts:
import { Feature, Priority, Satisfies, type ACResult } from '../../src';
import { ReqDiscoverableTraceabilityRequirement } from '../requirements/req-discoverable-traceability';
import { ReqDogFoodRequirement } from '../requirements/req-dog-food';
import { ReqParallelDeliverableRequirement } from '../requirements/req-parallel-deliverable';
/** Tier-2 roadmap — enriched ACs for implementer agent. */
@Satisfies(
ReqDiscoverableTraceabilityRequirement,
ReqDogFoodRequirement,
ReqParallelDeliverableRequirement,
)
export abstract class FeatureTraceExplorerTuiFeature extends Feature {
readonly id = 'FEATURE-TRACE-EXPLORER-TUI';
readonly title = '`requirements explore` — interactive TTY browser over the traceability graph';
readonly priority = Priority.Low;
readonly enabled = false;
// ── Positive path ──
abstract traceExplorerBuildsGraph(): ACResult;
abstract traceExplorerHandlesArrowKeyNavigation(): ACResult;
abstract traceExplorerDrillsDownFromAnyNode(): ACResult;
abstract traceExplorerOpensHelpOverlayOnQuestionMark(): ACResult;
abstract traceExplorerJumpsBackUpWithBackspace(): ACResult;
// ── Error / validation paths ──
abstract traceExplorerRefusesToStartOnNonTty(): ACResult;
abstract traceExplorerExitsCleanlyOnCtrlC(): ACResult;
// ── Integration ──
abstract traceExplorerUsesFileSystemPortForDiscovery(): ACResult;
abstract traceExplorerUsesPromptPortForInteraction(): ACResult;
// ── End-to-end ──
abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;
}import { Feature, Priority, Satisfies, type ACResult } from '../../src';
import { ReqDiscoverableTraceabilityRequirement } from '../requirements/req-discoverable-traceability';
import { ReqDogFoodRequirement } from '../requirements/req-dog-food';
import { ReqParallelDeliverableRequirement } from '../requirements/req-parallel-deliverable';
/** Tier-2 roadmap — enriched ACs for implementer agent. */
@Satisfies(
ReqDiscoverableTraceabilityRequirement,
ReqDogFoodRequirement,
ReqParallelDeliverableRequirement,
)
export abstract class FeatureTraceExplorerTuiFeature extends Feature {
readonly id = 'FEATURE-TRACE-EXPLORER-TUI';
readonly title = '`requirements explore` — interactive TTY browser over the traceability graph';
readonly priority = Priority.Low;
readonly enabled = false;
// ── Positive path ──
abstract traceExplorerBuildsGraph(): ACResult;
abstract traceExplorerHandlesArrowKeyNavigation(): ACResult;
abstract traceExplorerDrillsDownFromAnyNode(): ACResult;
abstract traceExplorerOpensHelpOverlayOnQuestionMark(): ACResult;
abstract traceExplorerJumpsBackUpWithBackspace(): ACResult;
// ── Error / validation paths ──
abstract traceExplorerRefusesToStartOnNonTty(): ACResult;
abstract traceExplorerExitsCleanlyOnCtrlC(): ACResult;
// ── Integration ──
abstract traceExplorerUsesFileSystemPortForDiscovery(): ACResult;
abstract traceExplorerUsesPromptPortForInteraction(): ACResult;
// ── End-to-end ──
abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;
}A typed-specs-shaped twin of the same Feature would look like this:
// Hypothetical typed-specs version — pre-migration
import { Feature, Priority, type ACResult } from '../base';
export abstract class TraceExplorerTuiFeature extends Feature {
readonly id = 'TRACE-EXPLORER-TUI';
readonly title = 'Interactive TTY browser over the traceability graph';
readonly priority = Priority.Low;
abstract traceExplorerBuildsGraph(): ACResult;
abstract traceExplorerHandlesArrowKeyNavigation(): ACResult;
abstract traceExplorerDrillsDownFromAnyNode(): ACResult;
abstract traceExplorerOpensHelpOverlayOnQuestionMark(): ACResult;
abstract traceExplorerJumpsBackUpWithBackspace(): ACResult;
abstract traceExplorerRefusesToStartOnNonTty(): ACResult;
abstract traceExplorerExitsCleanlyOnCtrlC(): ACResult;
abstract traceExplorerUsesFileSystemPortForDiscovery(): ACResult;
abstract traceExplorerUsesPromptPortForInteraction(): ACResult;
abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;
}// Hypothetical typed-specs version — pre-migration
import { Feature, Priority, type ACResult } from '../base';
export abstract class TraceExplorerTuiFeature extends Feature {
readonly id = 'TRACE-EXPLORER-TUI';
readonly title = 'Interactive TTY browser over the traceability graph';
readonly priority = Priority.Low;
abstract traceExplorerBuildsGraph(): ACResult;
abstract traceExplorerHandlesArrowKeyNavigation(): ACResult;
abstract traceExplorerDrillsDownFromAnyNode(): ACResult;
abstract traceExplorerOpensHelpOverlayOnQuestionMark(): ACResult;
abstract traceExplorerJumpsBackUpWithBackspace(): ACResult;
abstract traceExplorerRefusesToStartOnNonTty(): ACResult;
abstract traceExplorerExitsCleanlyOnCtrlC(): ACResult;
abstract traceExplorerUsesFileSystemPortForDiscovery(): ACResult;
abstract traceExplorerUsesPromptPortForInteraction(): ACResult;
abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;
}The two files are mostly the same — fifteen lines of the twenty-two in the original exist unchanged in the typed-specs version. Line by line, the differences are:
Imports. The new version imports Satisfies alongside Feature, Priority, and ACResult. The typed-specs version has no Satisfies to import. The new version also imports three Requirement classes by name; the typed-specs version does not reference any Requirement class, because there was none to reference. Three lines added, one line mildly changed.
Class-level decorator. The new version carries a four-line @Satisfies(…) decorator immediately before the class declaration. The typed-specs version has no class-level decorator. The four lines are the single largest addition to the file: they declare, explicitly and type-safely, the three Requirements this Feature exists to meet. Without these four lines, the Feature is orphaned; with them, the compliance scanner can answer "which Requirements does this Feature satisfy" and, inversely, "which Features satisfy REQ-DOG-FOOD".
Feature ID convention. The typed-specs version uses a bare TRACE-EXPLORER-TUI; the new version uses FEATURE-TRACE-EXPLORER-TUI. The prefix is not ornamental. It is the convention the @frenchexdev/requirements scanner uses to distinguish Feature IDs from Requirement IDs in trace reports. Typed-specs needed no such convention because it had no Requirement IDs to collide with; the new package adopts the FEATURE- prefix everywhere, and the branded FeatureId primitive parses the prefix at construction time.
Class name suffix. The typed-specs version is TraceExplorerTuiFeature; the new version is FeatureTraceExplorerTuiFeature. The Feature prefix on the class name is a codebase convention that makes the class unambiguous when imported next to a ReqTraceExplorerTuiRequirement — the two names are clearly distinct. Typed-specs had no such concern.
enabled = false. The new version declares itself roadmap-only by setting the optional enabled field to false. The typed-specs version has no way to make this declaration; its Feature class had no such field. A typed-specs developer who wanted to mark a feature as roadmap-only would have done it by a comment, by a convention, by an absent implementation — by something the type system could not check. The new version makes the declaration type-level, which means the compliance scanner reads it directly from the AST and treats the Feature accordingly.
The title. The new title has a code span for requirements explore and an em-dash clarifying what the command is. The old title is a plain sentence. The change is cosmetic — both titles say the same thing — but it is symptomatic: the new version titles are written to be surfaced in a TUI that renders markdown, not just a plaintext report.
The ACs themselves. Unchanged. Every one of the ten abstract methods has the same name, the same signature, the same return type. The methods are the part of the class that @Implements in typed-specs and @Verifies in the new package would each bind a test to. The test file that verifies these ten ACs reads @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerBuildsGraph') in the new package and would read @Implements<TraceExplorerTuiFeature>('traceExplorerBuildsGraph') in typed-specs; the rename is the only syntactic difference at the call site.
The file would be enriched further by @Expects decorators on a subset of the ACs. The end-to-end AC, endToEndNavigatesReqToFeatToAcToTest, earns @Expects(TestLevel.EndToEnd) — the default unit-test-only expectation is insufficient. The integration-path ACs earn @Expects(TestLevel.Unit, TestLevel.Functional). The positive-path ACs keep the default. A fully-annotated version of the file adds those decorators one AC at a time and costs roughly ten lines of additions.
The test file, before and after
The Feature file is only one side of the migration. The test file that verifies it is the other side, and it also changes shape.
Typed-specs, on a similar Feature, would have produced a test file like:
// Hypothetical typed-specs test file
import { test, expect } from 'vitest';
import { FeatureTest, Implements, Exclude } from '../../requirements/decorators';
import { TraceExplorerTuiFeature } from '../../requirements/features/trace-explorer-tui';
@FeatureTest(TraceExplorerTuiFeature)
class TraceExplorerTuiTests {
@Exclude()
private async buildFixtureGraph() { /* … */ }
@Implements<TraceExplorerTuiFeature>('traceExplorerBuildsGraph')
async 'builds a graph from the fixture project'() { /* … */ }
@Implements<TraceExplorerTuiFeature>('traceExplorerHandlesArrowKeyNavigation')
async 'handles up/down/left/right arrow keys'() { /* … */ }
// … eight more methods
}
// Runtime bridge into vitest
const instance = new TraceExplorerTuiTests();
for (const method of Object.getOwnPropertyNames(TraceExplorerTuiTests.prototype)) {
if (method === 'constructor' || method === 'buildFixtureGraph') continue;
test(method, async () => { await (instance as any)[method](); });
}// Hypothetical typed-specs test file
import { test, expect } from 'vitest';
import { FeatureTest, Implements, Exclude } from '../../requirements/decorators';
import { TraceExplorerTuiFeature } from '../../requirements/features/trace-explorer-tui';
@FeatureTest(TraceExplorerTuiFeature)
class TraceExplorerTuiTests {
@Exclude()
private async buildFixtureGraph() { /* … */ }
@Implements<TraceExplorerTuiFeature>('traceExplorerBuildsGraph')
async 'builds a graph from the fixture project'() { /* … */ }
@Implements<TraceExplorerTuiFeature>('traceExplorerHandlesArrowKeyNavigation')
async 'handles up/down/left/right arrow keys'() { /* … */ }
// … eight more methods
}
// Runtime bridge into vitest
const instance = new TraceExplorerTuiTests();
for (const method of Object.getOwnPropertyNames(TraceExplorerTuiTests.prototype)) {
if (method === 'constructor' || method === 'buildFixtureGraph') continue;
test(method, async () => { await (instance as any)[method](); });
}The @frenchexdev/requirements version of the same test file drops the runtime bridge and renames one decorator:
// @frenchexdev/requirements test file
import { FeatureTest, Verifies, Exclude } from '../../src/decorators';
import { FeatureTraceExplorerTuiFeature } from '../../requirements/features/feature-trace-explorer-tui';
@FeatureTest(FeatureTraceExplorerTuiFeature)
class FeatureTraceExplorerTuiTests {
@Exclude()
private async buildFixtureGraph() { /* … */ }
@Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerBuildsGraph')
async 'builds a graph from the fixture project'() { /* … */ }
@Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerHandlesArrowKeyNavigation')
async 'handles up/down/left/right arrow keys'() { /* … */ }
// … eight more methods
}
// No runtime bridge. @FeatureTest handles it.// @frenchexdev/requirements test file
import { FeatureTest, Verifies, Exclude } from '../../src/decorators';
import { FeatureTraceExplorerTuiFeature } from '../../requirements/features/feature-trace-explorer-tui';
@FeatureTest(FeatureTraceExplorerTuiFeature)
class FeatureTraceExplorerTuiTests {
@Exclude()
private async buildFixtureGraph() { /* … */ }
@Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerBuildsGraph')
async 'builds a graph from the fixture project'() { /* … */ }
@Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerHandlesArrowKeyNavigation')
async 'handles up/down/left/right arrow keys'() { /* … */ }
// … eight more methods
}
// No runtime bridge. @FeatureTest handles it.Three changes, at the file level.
The imports no longer reference vitest — no test, no expect at the top level. Individual test methods may still use expect internally; the file-level import is not needed because the class decorator handles the vitest wiring.
@Implements becomes @Verifies. Mechanical rename.
The last eight lines — the prototype-iteration bridge into vitest.test — are gone. The @FeatureTest decorator wires each method into a describe/it block at class-decoration time, so the bridge is no longer the test file's responsibility. The absence of the bridge is the REQ-DOG-FOOD property: test files contain neither describe nor it. The constraint is enforced by a ripgrep-based fit criterion on the package itself — any reintroduction of bare describe/it fails the compliance gate immediately.
The net effect is that a test file in the new package is shorter, more uniform, and more aggressively typed at the @Verifies call sites. A reader coming from typed-specs recognises nine-tenths of the file on sight; the tenth — the missing bridge — is a relief.
The scanner, rewritten
One table row deserves its own section. The typed-specs compliance scanner was regex-driven; the new package's scanner is AST-based via ts-morph. The change is classified as persists (richer implementation) in the matrix because the scanner's role is unchanged — it walks the test files and the feature files, cross-references them, and reports on gaps. But the mechanism under that role has changed enough to reshape what the scanner can say.
The typed-specs scanner, sketched in typed-specs/06-compliance.md, was about three hundred lines of regex. It read feature files for abstract class (\w+)Feature and abstract (\w+)\(\) patterns. It read test files for @FeatureTest\((\w+)\) and @Implements<(\w+)>\('(\w+)'\) patterns. It joined the two sides in memory and emitted a report. The regexes were robust enough for the typed-specs surface — the surface was small, the patterns were consistent, and the escape hatch for parametrised tests (static coversACs) was matched by its own regex. The scanner worked.
It also could not have scaled to the new surface.
A regex-based scanner reading @Satisfies(ReqA, ReqB, ReqC) has to anticipate every whitespace pattern, every trailing comma, every cross-line argument. It has to handle the case where a Feature declares @Satisfies with three Requirements split across three lines versus the case where the same decorator puts them on one line. It has to handle comments inside the argument list. It has to handle — and this is where the regex approach falls over — the case where the argument is not a bare class name but an expression: a re-export alias, a namespace qualifier, a conditional import. The new package contains all of these patterns in production use. Regex cannot parse them; a TypeScript AST can.
The ts-morph library gives the new scanner a typed view over the decorator call sites. When the scanner sees @Satisfies(ReqA, ReqB), it receives a decorator node whose expression is a CallExpression whose arguments are two Identifier nodes, each of which can be resolved to a symbol and, through the symbol, to the declared class. The resolution is structural: an import alias is transparently followed; a namespace qualifier is transparently unwrapped; a re-export is transparently chased. The scanner answers "which Requirement classes does this Feature satisfy?" with the actual class references, not with strings.
The same mechanism extends to @Refines, @Expects, @FeatureTest, @Verifies. Every decorator in the new surface is read through the AST and resolved structurally. The scanner's output is a graph of references, not a map of strings. The difference matters when a consumer package imports a Requirement under a different name, re-exports it with an alias, and passes the alias to @Satisfies three modules away; the regex scanner would have seen an unknown identifier and flagged a phantom error, while the AST scanner resolves the chain.
One consequence of the AST move: the scanner reads a single canonical view of the decorators, not the runtime registry. The runtime registry still exists and is populated by decorator execution, but it is used for tooling that runs in-process (the explorer, development-time diagnostics). The compliance gate, which runs without executing the project, reads only the AST. This decoupling is what lets the gate run on a fresh clone without a build step: source is sufficient.
The typed-specs scanner was a regex pass; the new scanner is a compiler pass. The role is the same; the altitude is higher. That change earns persists (richer implementation) rather than persists in the matrix.
Diagram 1 — migration arrows
Diagram 2 — new decorators on the running example
What persists vs what the migration actually buys
A rename, on its own, is almost nothing. @Implements<NavigationFeature>('tocClickLoadsPage') and @Verifies<NavigationFeature>('tocClickLoadsPage') are indistinguishable to the compiler, to the test runner, to the registry, and to the human eye at the call site. A codebase that mechanically replaced one with the other at every line would behave identically. If the migration were only a rename, it would be a refactor without a reason.
The rename's value is what it permits the additions to mean.
@Verifies on its own is @Implements with a new name. A test method decorated with @Verifies<Feature>('ac') does exactly what a test method decorated with @Implements<Feature>('ac') did in typed-specs: push a registry entry, bind the method to the AC at compile time, catch typos through keyof T. Nothing is gained by the name change in isolation.
@Verifies plus @Satisfies is a different system. A test method still verifies an AC; but the AC is now part of a Feature that explicitly declares which Requirements it satisfies; but the Requirement that the Feature satisfies is a typed object, not a prose reference; but the Requirement itself might refine a parent Requirement; but the parent Requirement has its own Feature satisfiers. The test verifies not just the AC but, by transitivity, the Requirement. The verification leg is the end of a four-step chain, not an atomic two-step leg. The chain is: Requirement → Feature (via @Satisfies) → AC (via composition) → Test (via @Verifies). Each leg is a different verb. Each verb has its own decorator. Mixing the verbs would collapse the chain back to typed-specs, where the middle legs were implicit and the Requirement end was rhetorical.
@Verifies plus @Satisfies plus @Refines plus @Expects plus Requirement<S> plus the five Styles plus the six discriminated unions plus the six branded primitives is a requirements DSL. The full set constitutes the novelty. The rename is necessary; the additions are sufficient.
The migration buys, concretely:
- A typed WHY tier. Requirements are classes, not strings. The scanner refuses orphan Requirements and orphan Features with equal severity.
- Explicit many-to-many. A Feature can satisfy several Requirements. A Requirement can be satisfied by several Features. Both multiplicities are in the type system and in the registry, not in the commit message.
- Decomposition. One Requirement can refine another. The refinement tree is traversable in code and rendered as a graph by the explorer.
- Test-level expectations per AC. An AC that requires both unit and e2e evidence declares so at its own definition site; the scaffolder reads that declaration and emits the missing stubs.
- Project-chosen vocabulary. A SIL-styled project reads as SIL; a lean-styled project reads as lean; the same
Requirement<S>base class serves every style. The Style abstraction is open-closed; new Styles register without touching the base. - Parse-once-validate-everywhere data discipline. Every Requirement field is typed against a discriminated union or a branded primitive. Invalid shapes are compile errors, not runtime surprises. The history field is append-only at the schema level.
- AST-based compliance. The scanner reads decorator arguments statically through
ts-morph, not through regex heuristics. Registry-backed runtime information stays available for tools that need it, but the source of truth for compliance is the AST.
The single rename was the cost of admission. The additions are the return. A reader who has absorbed the preserved constructs from typed-specs now has to learn six or seven new ones. The rest of the series enumerates them.
A counterargument, briefly entertained
It is fair to ask whether the new package overcorrects. Typed-specs, by carrying only Feature, covered 112 of 112 ACs on a real site with a small decorator surface. The critique that typed-specs did not model Requirement was honest — the word was used, the type was not — but the critique did not say that typed-specs failed at its job. The new package's six or seven new decorators, discriminated unions, Style abstraction, and branded primitives all have to earn their weight over what typed-specs already did.
The answer, taken up in detail in chapters 19 and 19b, is that the new weight earns itself only when the project has a reason to carry it. A small codebase with a dozen Features and one stakeholder has no call for @Satisfies — every Feature satisfies the implicit Requirement "the product exists", and declaring that is ceremony. A medium codebase with a product owner, a compliance officer, and a traceability obligation cannot afford to leave the Requirement stratum implicit; the gap is precisely what the officer is paid to spot. The new package is for the second codebase. Chapter 19's "when NOT to use this DSL" section is blunt about the first. A reader who finishes this chapter and decides typed-specs is still enough for their project has read the chapter correctly.
The point of the migration is not that every typed-specs project should migrate. The point is that projects which had outgrown typed-specs without knowing it — which had accumulated requirement-like information in commit messages, tickets, and tribal memory — now have a place for that information to live. The new package is the place.
What the table omits
The migration table earlier in the chapter covers constructs. It does not cover file layout, build scripts, test configuration, CLI surface, or documentation. Each of these also changed; each change is load-bearing in its own way; none is covered here.
File layout moved from a single requirements/ directory at the SSG root to a packaged monorepo layout under packages/requirements/. That move is part of the larger extraction documented in project_frenchexdev_monorepo.md; this chapter is not the place for it.
The CLI surface grew from a single compliance command to a handful of sub-commands: compliance, trace gaps | matrix | chain, scaffold test | e2e, feature new | sync, requirement new | sync | list | show | orphan, schema register. Each sub-command has its own chapter; chapter 13b walks the developer-experience surface.
Test configuration shifted from a single vitest config to a layered configuration supporting unit, functional, and e2e levels. Each level has its own glob, its own timeout, its own setup. The @Expects decorator is the project-side declaration that binds an AC to a level; the vitest configuration is the infrastructure that runs the tests at that level. The two meet at the scaffolder.
Documentation expanded from a six-part blog series to a thirty-three-part blog series, a package README, a CHANGELOG, five style pitches, two how-to guides, and a glossary. The expansion is symptomatic of the material's reach, not of editorial enthusiasm.
Each of these omissions is covered elsewhere in the series. The migration table is intentionally narrow: it tracks constructs, not context. The reader who wants the full context reads the hub.
A concrete migration recipe
For a project already using typed-specs, the migration to @frenchexdev/requirements is mechanical in its first three steps and editorial in the next three. Laid out as a recipe, not a prescription.
Step 1 — rename @Implements to @Verifies
A multi-file search and replace. Every @Implements< becomes @Verifies<. The generic typing, the AC-name argument, the stacked-decorator patterns all carry over unchanged. The compiler catches any site missed by the replacement: @Implements ceases to exist in the new package, and any stray reference fails to resolve. Run the test suite; if it passes, step 1 is done.
Step 2 — update the base-class and decorator imports
The typed-specs imports point at requirements/base and requirements/decorators in the SSG. The new imports point at @frenchexdev/requirements or at ../../src/ inside the package itself. A codemod or a global find-and-replace handles this. Step 2 ends when the project compiles against the new package's types.
Step 3 — drop the prototype-iteration bridges from test files
Every test file in typed-specs ended with something like:
const instance = new FooTests();
for (const method of Object.getOwnPropertyNames(FooTests.prototype)) {
if (method === 'constructor') continue;
test(method, () => (instance as any)[method]());
}const instance = new FooTests();
for (const method of Object.getOwnPropertyNames(FooTests.prototype)) {
if (method === 'constructor') continue;
test(method, () => (instance as any)[method]());
}These bridges are redundant in the new package — the @FeatureTest class decorator does the bridging. Delete them. The test suite continues to pass. The files become shorter and more uniform.
Step 4 — write a Requirement class for each implicit Requirement
This is the editorial step. Open every Feature file in the project and ask: what Requirement does this Feature satisfy? The answer will often be a sentence from a README, a link to a Jira ticket, a paragraph in the project's docs/ directory, or silence. For each Feature:
- Write a Requirement class in
requirements/requirements/with an ID, a title, a priority, a status, a statement (typed as an EARS clause if possible), a rationale with at least one evidence entry, at least one fit criterion, a verification method, a source, and a risk. - Decorate the Feature class with
@Satisfies(ThatRequirement). - Run
npx requirements compliance. The scanner should now report the Feature as satisfying the Requirement.
Most projects have fewer Requirements than Features — a single Requirement typically covers two to five Features. The editorial work is less than "write a Requirement per Feature" suggests. The work is not negligible, however, and teams sometimes do this step incrementally, one bounded context at a time.
Step 5 — add @Expects decorators to ACs that earn them
For each AC that requires more than a unit test, add an @Expects(TestLevel.X, TestLevel.Y) decorator. The default — no decorator — means [TestLevel.Unit]. An AC that requires an end-to-end test earns @Expects(TestLevel.EndToEnd); one that requires an accessibility test earns @Expects(TestLevel.Accessibility). The scaffolder can then generate stubs for missing levels.
This step is also editorial, and also incremental. Teams typically do the end-to-end and accessibility levels first (those being the ones most often overlooked) and backfill the rest as they come up.
Step 6 — pick a Style
Every Requirement is generic over a Style. The default is DefaultStyle (ISO/IEC/IEEE 29148 + Volere + EARS). Projects in regulated domains may pick IndustrialStyle; lean shops may pick LeanStyle; agile teams may pick AgileStyle; kanban teams may pick KanbanStyle. The Style choice narrows the vocabulary slots — status, kind, verificationMethod, risk.level, rationale.kind — to the Style's registered values.
For most projects, the default Style is the right starting point. Chapter 08 walks the five built-ins side by side; chapters 09–11 go deep on each. Changing Style is a project-wide decision and worth its own conversation.
Step 7 — run the compliance gate strictly
Once every Feature has a @Satisfies link and every Requirement has at least one satisfier, run npx requirements compliance --strict in CI. The gate blocks merges on three conditions: critical uncovered ACs, orphan Features, and approved Requirements without satisfiers. The project now has a load-bearing constraint that typed-specs could not express: every Requirement the project declares must have at least one Feature that satisfies it.
The seven steps together take, on a project with thirty Features, roughly two to four engineer-days. The first three steps are under a day; the editorial steps are the rest. The result is a project whose requirements, features, ACs, and tests are typed and cross-referenced — a project that has closed the gap between what typed-specs modelled and what typed-specs named.
Running-example recap — what this chapter added to our understanding of FEATURE-TRACE-EXPLORER-TUI
After this chapter, the running example reads, line by line, as the new vocabulary permits.
The @Satisfies decorator above the class names three Requirements — REQ-DISCOVERABLE-TRACEABILITY, REQ-DOG-FOOD, REQ-PARALLEL-DELIVERABLE — and the reader can see, with no further context, what the Feature exists to do. The enabled = false field marks the Feature as a tier-2 roadmap item: declared, enumerated, dormant. The ten AC methods are the same ten that a typed-specs-shaped version would have carried; the difference is that each of them now sits inside a class whose three explicit Requirements give the ACs a why. The eventual test file, when it is written, will carry @FeatureTest(FeatureTraceExplorerTuiFeature) at the class level and @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerBuildsGraph') on each method; the verb @Verifies is the one clean fit for the test-to-AC leg once the Feature-to-Requirement leg has its own verb.
The file has not grown much — eight or nine added lines between imports and the @Satisfies block — but the growth is entirely in the direction of recoverable intent. Every piece of information that typed-specs carried in prose or convention is now carried by a decorator or a field, and the scanner reads it.
A reader who comes to the file fresh, without having read any of the surrounding chapters, learns from the source alone that:
- the Feature is tier-2 and roadmap-only (
enabled = false); - it is a
Lowpriority item (priority = Priority.Low); - it exists to meet three named Requirements (
@Satisfies(…)); - it has ten ACs grouped into positive path, error path, integration, and end-to-end concerns (the
// ── … ──section comments); - every AC is a candidate for verification by a test binding with
@Verifies<FeatureTraceExplorerTuiFeature>('acName').
The typed-specs-shaped twin of the same file would have surrendered the first, third, and partially the fifth pieces of information to prose in a commit message or a README. The new file carries them all at the source level. That is the concrete shift the migration achieves, rendered in a single example small enough to read in one page.
Five things the migration does not change
Symmetry with the seven-step recipe above: five things the migration deliberately preserves.
The test-first discipline
Typed-specs insisted that every AC has at least one test before it can count as covered. The new package preserves that insistence. The @Verifies decorator is the binding; the compliance scanner counts bindings; an AC without a @Verifies is uncovered; an uncovered critical-tier AC fails the gate. The flow is identical to typed-specs. Nothing about the migration relaxes the requirement that a test must exist before an AC is satisfied.
The one-file-per-Feature rule
Typed-specs asked every Feature to live in its own file under requirements/features/. The new package keeps that rule. A Feature file is small, focused, and imported by a single test file that verifies it. The rule makes the scanner's discovery job simple — glob the directory — and keeps Features from accreting concerns. Requirements get their own sister directory with the same rule: one Requirement per file under requirements/requirements/.
The JSDoc-as-specification convention
Every abstract method in a typed-specs Feature file carried a JSDoc comment explaining the AC in plain English. The new package preserves the convention. A reader scanning a Feature file reads the comments first, the method names second; the method names are machine-readable identifiers, the comments are the human-readable specifications. Both live in the same place and cannot drift. The Requirement class adds several typed fields that carry specification information directly, but the JSDoc convention continues to apply to Feature ACs.
The abstract-class preference
Typed-specs preferred abstract classes over interfaces for Feature because classes survive transpilation, carry runtime metadata, and can be passed as decorator arguments. The new package makes the same choice for both Feature and Requirement. Neither construct could have been an interface: @FeatureTest(SomeInterface) is a type-level reference TypeScript refuses to accept as a value. The abstract class is the right shape, and the choice carries over unchanged.
The keyof T typing on AC names
Typed-specs used keyof T & string to constrain the AC-name argument of @Implements. The same typing lives on @Verifies in the new package. A misspelled AC name is a compile error, not a runtime surprise. The decorator refuses to accept a string that is not a property name of the Feature class. This is the single most valuable type-level discipline in the whole DSL — nothing else the new package adds changes the fact that @Verifies<Feature>('typo') is a build failure before the code runs.
These five continuities are what make the migration feel like an evolution rather than a replacement. A developer who was productive in typed-specs is productive in the new package on day one; the learning curve is entirely in the additions, not in the relearning.
A closing reflection, before the links
This chapter has been a long table and a long defence of a short rename. It has walked every construct that persisted, every construct that was renamed, every construct that is new, and every construct that was enriched. It has rewritten one Feature file line by line and one test file side by side. It has laid out a seven-step migration recipe and named the one decorator whose verb-change justifies the whole exercise.
What it has not done, and what no single chapter in a thirty-three-part series can do, is prove that the new shape is worth its weight. That proof is cumulative: it comes from walking the twenty-two Requirements in chapters 04 and 05, the twenty-five Features in chapter 06, the fifty-four test classes in chapter 07, the five Styles in chapters 08 through 11, the traceability graphs in chapter 12, the quality gates in chapter 13, the developer experience in chapter 13b, and the day-in-the-life walkthrough in chapter 22b. Each of those chapters is a piece of the same argument. This chapter is the first piece. It says: here is how the vocabulary grew.
The next chapter — 01b — returns to the same delta from a different angle: chronology. Why did Feature come first in the codebase and Requirement come later? When is a project ready for the Requirement stratum, and when is introducing it premature? The historical path chapter answers those questions from the perspective of a team deciding when to make the move.
Readers who came for mechanics should continue to chapter 03, which enumerates every decorator in the full surface. Readers who came for context should continue to chapter 01b. Readers who came for the edge cases should continue to 13c, which walks what a real red build in this system looks like. The chapters branch; the migration table is the common ground.
Related Reading
- typed-specs/05-decorators.md#the-three-decorators — the original
@FeatureTest/@Implements/@Excludesurface. - typed-specs/04-features.md#the-base-types — the typed-specs Feature base class, three fields.
- requirement-vs-feature.md — the definitional distinction between Requirement and Feature; prerequisite for this series.
- 00-named-but-not-modelled.md — the chapter that motivated this one. Read it first if you jumped in here.
- 03-the-decorator-surface.md — the next stop. Every decorator in the full API, enumerated with examples.
Previous: 00 — Named but not modelled Next: 01b — The historical path: Feature first, Requirement later