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

Chapter 03 — The Decorator Surface

Six decorators. Four registries. Three verbs — satisfies, refines, verifies — that together close a chain the single verb of typed-specs could not close on its own.

Running example, still the same

Chapter 00 introduced FEATURE-TRACE-EXPLORER-TUI as the running example carried across this series. This chapter is the one in which every decorator lands on that class, or on something connected to it. By the end, you will have seen the Feature with its full @Satisfies(...) list, a test class with @FeatureTest and @Verifies on every method, a helper carrying @Exclude, a hypothetical AC carrying stacked @Expects, and a Requirement elsewhere in the repo carrying @Refines. Nothing will be invented that is not actually shipped in packages/requirements/src/decorators.ts.

Here is the class, quoted in full from 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;
}

Three things to notice before we continue. First, the only decorator on the Feature itself is @Satisfies, listing three class references (not three strings, not three IDs). Second, the abstract-method shape for ACs is the same shape as the typed-specs NavigationFeature — the AC layer is structurally unchanged; the innovation sits above it. Third, there is no @Feature class decorator here, and there will not be one. The abstract class declaration is the entry point. Decorators attach relations to it, not identity.

This is worth stating plainly, because the chapter outline in the planning note hinted at eight decorators. The source ships six, and the reason is not an oversight: a class declaration already carries an identity in TypeScript. Wrapping it in @Feature would add no information the compiler does not already have. The same holds for @Requirement. The DSL's move is to put the effort into relations — satisfies, refines, verifies — and to leave identity where it already lives, in the class declaration. The inventory below will show this asymmetry honestly.

The inventory

The whole surface fits in one table. Each row is a decorator that actually exists today in src/decorators.ts; each column is a property you will want to know before using it.

Decorator Target Arity Type-safety mechanism Runtime effect
@FeatureTest(F, opts?) test class 1 (+ optional FeatureTestOptions) F extends abstract new (...args: any[]) => Feature Stamps __feature / __featureClass; backfills verifiesRegistry entries; auto-registers methods with vitest via describe/it.
@Verifies<F>('acName') test method 1 keyof F & string Pushes a RequirementRef to the registry (feature is backfilled by the enclosing @FeatureTest).
@Satisfies(Req1, Req2, …) Feature class variadic ReadonlyArray<abstract new (...args: any[]) => Requirement> Pushes a SatisfactionLink; stamps __satisfies with the Requirement class names.
@Refines(ParentReq1, ParentReq2, …) Requirement class variadic abstract new (...args: any[]) => Requirement Pushes a RefinementLink; stamps __refines with the parent class names.
@Expects(TestLevel1, TestLevel2, …) abstract AC method variadic TestLevel enum values No-op at runtime. Read statically by the AST scanner (chapter 14).
@Exclude() test helper method 0 (no generic) Adds "ClassName.methodName" to excludedMethods.

Two columns repay a second look.

The type-safety mechanism column is the column that makes this a DSL rather than a label set. Every decorator that carries a relation carries a compile-time guarantee about the shape of the thing at the other end of the relation. @FeatureTest refuses anything that is not a Feature subclass. @Verifies<F> refuses an AC name that is not a property of F. @Satisfies refuses anything that is not a Requirement subclass. @Refines refuses a parent that is not a Requirement. The compile errors are not decorative: they are where the "ladder" of typed-specs grew into a roof.

The runtime effect column is the column the next six sections are built around. Five of the six decorators populate a registry at module-load time. The sixth, @Expects, is a no-op at runtime and exists only to be read statically — the first hint that this package treats the AST as a first-class consumer, not just a side-effect. That hint will open into a full discussion in the chapter on AST extraction.

A word on what is absent from the inventory and why.

  • No @Requirement / @Feature class decorators. As noted above, the abstract class is the entry point. You declare class FooFeature extends Feature { … } and TypeScript carries the identity. A decorator would add nothing.
  • No @Priority, @Id, @Status field decorators. These are abstract fields on the base class. The compiler demands them; a decorator would be redundant.
  • No @AC method decorator. An abstract method on a Feature subclass already is an AC — the compiler forces concrete subclasses to implement every abstract method, which means the analysis layer can enumerate ACs by reading the class declaration. A decorator would be noise.
  • No lifecycle or workflow decorators. Lifecycle (Draft → Approved → Retired) lives in the status field, narrowed by the project's Style. Chapter 09 will cover how Styles carry this without a decorator.

The six decorators that do exist are, therefore, the irreducible relational apparatus. Each deserves a section of its own.

A note on decorator syntax and the experimentalDecorators flag

The package uses the legacy decorator form — the one enabled by "experimentalDecorators": true in tsconfig.json and the one that shipped in TypeScript 1.5. TypeScript 5 introduced a new, standards-track decorator form with a different signature (no PropertyDescriptor, a context object instead of three positional arguments, class-evaluation order changes). The package does not use the new form.

The reason is pragmatic: the typed-specs ancestor predated the new form, the Playwright and vitest ecosystems the package integrates with still primarily document the legacy form, and the legacy form has the PropertyDescriptor escape hatch that @Verifies uses for accessor-kind methods. The migration to new-form decorators is a candidate for a future minor; it is not urgent. The chapter on roadmap (chapter 22) lists it as a P2 item.

Readers coming from a 2026 TypeScript tutorial that shows only the new form will notice the signature mismatch. The decorators above read (target: any, propertyKey: string, _descriptor?: PropertyDescriptor) rather than the new form's (value, context) pair. Both forms express the same semantic ideas; this package happens to ship the older form, and will continue to do so until a compelling integration reason forces the rewrite.

Decorator 1 — @FeatureTest

Signature, quoted directly:

export interface FeatureTestOptions {
  timeout?: number;
}

export function FeatureTest<T extends abstract new (...args: any[]) => Feature>(
  feature: T,
  opts?: FeatureTestOptions,
) {
  return function <C extends new (...args: any[]) => any>(target: C): C {
    (target as any).__feature = feature.name;
    (target as any).__featureClass = feature;

    // Backfill feature name on registry entries that were registered before the class decorator ran
    for (const ref of registry) {
      if (ref.testClass === target.name && !ref.feature) {
        ref.feature = feature.name;
      }
    }

    // Auto-register with vitest — test files only need classes, no boilerplate.
    const g = globalThis as Record<string, unknown>;
    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);
        }
      });
    }

    return target;
  };
}

Three phases to read in order.

Phase 1 — stamp. Two properties are attached to the test class: __feature (the string name of the Feature, used by the AST scanner as a robust fallback) and __featureClass (the class reference itself, used by the runtime registry for type-preserving iteration). The dual-stamp pattern is deliberate: downstream consumers have a structural choice. Prefer the class reference when you can; fall back to the name when you must cross a serialisation boundary (for instance, when writing a JSON report).

Phase 2 — backfill. This is the piece of plumbing that lets @Verifies be written in either order relative to @FeatureTest without breaking. TypeScript decorators execute in the order method decorators first, then class decorator. So when @FeatureTest(FeatureX) runs, there may already be entries in the registry for methods of the same test class, with feature: '' because @Verifies ran before it knew which Feature the class belonged to. The backfill loop patches those empty slots. The alternative — forbidding @Verifies from running before @FeatureTest — would have been a worse API.

Phase 3 — auto-register with vitest. This is the piece that matters for REQ-DOG-FOOD. The whole reason the package bans describe and it at the test-file surface is so that every test is reachable through a Feature class. If a test file could drop a raw describe('something', …), the @Satisfies link would be optional; orphan tests would be invisible to the compliance scanner. The @FeatureTest class decorator closes this loop by generating describe/it blocks itself, iterating the class prototype, skipping excluded helpers, and respecting opts.timeout for slow features. The consequence: a test file with an @FeatureTest on one class needs no vitest boilerplate. It needs only the class.

Applied to the running example:

import { expect } from 'vitest';
import { FeatureTest, Verifies } from '@frenchexdev/requirements';
import { FeatureTraceExplorerTuiFeature } from '../../requirements/features/feature-trace-explorer-tui';

@FeatureTest(FeatureTraceExplorerTuiFeature)
class FeatureTraceExplorerTuiTests {
  // methods follow — each carries @Verifies
}

That is the complete class-level ceremony. No describe, no it, no beforeAll, no manual new FeatureTraceExplorerTuiTests(). The decorator does it.

The persist case from chapter 01. Chapter 01 — The Migration Matrix distinguished four cases for each typed-specs construct: persist (shipped unchanged), rename (same shape, different verb), extend (new behaviour on the old surface), and create (brand new decorator). @FeatureTest is the canonical persist case. Its signature is structurally identical to the typed-specs version; the only additions are the optional FeatureTestOptions (absent in typed-specs, added here for slow TUI features) and the in-decorator vitest registration (made possible by the package's zero-describe/it invariant). If you already use typed-specs, you already know how to write @FeatureTest; nothing is unfamiliar.

One subtlety worth naming. The generic bound is T extends abstract new (...args: any[]) => Feature. The abstract new is important: a Feature subclass is itself abstract (its ACs are abstract methods the test does not implement — tests only verify them). A non-abstract constructor bound would have forced test authors to create a concrete subclass just to satisfy the decorator, which would have been perverse. The abstract new bound lets us name a still-abstract class as the target of a test.

Decorator 2 — @Verifies<F>('ac')

Signature:

export function Verifies<T extends Feature>(ac: keyof T & string) {
  return function (target: any, propertyKey: string, _descriptor?: PropertyDescriptor): void {
    registry.push({
      feature: '', // filled by @FeatureTest class decorator
      ac,
      testClass: target.constructor.name,
      testMethod: propertyKey,
    });
  };
}

The single line of type-level work is ac: keyof T & string. Unpack that.

  • keyof T is the set of property names of T — for FeatureTraceExplorerTuiFeature, that is 'id' | 'title' | 'priority' | 'enabled' | 'traceExplorerBuildsGraph' | 'traceExplorerHandlesArrowKeyNavigation' | … — in principle, ten ACs plus four non-AC fields. The AST scanner filters the non-AC fields; the type system does not need to.
  • The & string intersection narrows to string keys only (excluding numeric and symbol keys, which Feature subclasses do not use but TypeScript nominally allows).
  • The result is that ac must be a string key of T. Any typo is a compile error.

Demonstrated:

@FeatureTest(FeatureTraceExplorerTuiFeature)
class FeatureTraceExplorerTuiTests {
  // ✓ compiles — 'traceExplorerBuildsGraph' is a key of the Feature
  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerBuildsGraph')
  buildsGraphFromSpecsInMemory(): void {
    const graph = buildTraceGraph(specsFixture());
    expect(graph.requirements).toHaveLength(3);
  }

  // ✗ TypeScript error — 'traceExplorerBuildGraph' (missing 's') is not a key of the Feature
  //   Argument of type '"traceExplorerBuildGraph"' is not assignable to parameter of
  //   type '"id" | "title" | "priority" | "enabled" | "traceExplorerBuildsGraph" | …'
  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerBuildGraph')
  typoVersion(): void {
    expect(true).toBe(true);
  }
}

The compiler catches this before the test runs. The same compile error fires if you change the Feature (rename an AC) and forget to update the test — a class of refactor regression that, in string-keyed frameworks, silently produces orphan tests that pass but verify nothing.

The rename case from chapter 01. In typed-specs the equivalent decorator was called @Implements. The rename to @Verifies is not cosmetic; it is forced by the arrival of @Satisfies one decorator up. Three verbs for three links:

  • A Feature satisfies a Requirement (it is the deliverable that meets the rule).
  • A test verifies an AC (it proves the implementation meets the acceptance criterion).
  • An AC, in broader usage, implements a Requirement — but in the DSL this relation is derived (via the Feature), not direct, so there is no @Implements decorator for it.

Keeping the typed-specs name would have produced a puzzle: if a Feature satisfies a Requirement, then what does a test do? The answer "a test implements the AC" was the typed-specs usage; it worked in a single-verb world. Once @Satisfies exists, the verb implements is ambiguous: does the test implement the AC, or does the Feature implement the Requirement? The rename resolves the ambiguity at the cost of one word. It is a cost worth paying.

Three small usage notes.

The _descriptor parameter is optional. Read the actual signature: (target: any, propertyKey: string, _descriptor?: PropertyDescriptor). TypeScript 5 may invoke a method decorator with only two arguments on certain method shapes (quoted-name methods, accessors). The optional third parameter lets @Verifies accept both the 2-arg and 3-arg forms. This matters for Playwright-style test names like 'clicking TOC item loads page' that force a string key.

Stacking is supported. One test method can verify multiple ACs:

@Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerDrillsDownFromAnyNode')
@Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerJumpsBackUpWithBackspace')
drillDownAndBackAreInverse(): void {
  // one round-trip test proves two ACs
}

Each decorator pushes a separate registry entry; both ACs appear as covered in the compliance report.

feature: '' is not a bug. The empty string in the pushed entry is the sentinel for "waiting on the @FeatureTest backfill". If a test method carries @Verifies but the test class has no @FeatureTest, the backfill never runs and the entry remains with feature: '' — which the compliance scanner reports as an orphan test. The sentinel makes the orphan visible instead of silently associating it with the last @FeatureTest that happened to fire.

Decorator 3 — @Satisfies(Req1, Req2, …)

Signature:

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;
  };
}

This is the create case from chapter 01 — the decorator that typed-specs did not have and could not have, because typed-specs did not model Requirement at all. Every other decorator either persists from typed-specs unchanged (@FeatureTest, @Exclude) or renames/extends an existing one (@Verifies, @Expects). @Satisfies is net new.

Variadic over Requirement subclasses. The type parameter R extends ReadonlyArray<abstract new (...args: any[]) => Requirement> says: however many arguments you pass, each one must be a Requirement subclass constructor. Plain class references, not strings, not IDs. If you pass a Feature or a bare string, the compiler rejects it.

Applied in full on the running example — this is how it actually ships:

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';

@Satisfies(
  ReqDiscoverableTraceabilityRequirement,
  ReqDogFoodRequirement,
  ReqParallelDeliverableRequirement,
)
export abstract class FeatureTraceExplorerTuiFeature extends Feature {
  // … id / title / priority / enabled, then ten abstract ACs
}

Three Requirements, one Feature. The three Requirements each have their own file in packages/requirements/requirements/requirements/, their own abstract-field specifications (EARS statement, rationale, fit criteria, verification method, source, risk), and they are, for the compiler, distinct types. The @Satisfies list is a typed reference to those three entities.

The bidirectional registry

When @Satisfies(ReqA, ReqB) is applied to FeatureX, two queries become resolvable:

  • Forward — what does FeatureX satisfy? Read (FeatureX as any).__satisfies['ReqA', 'ReqB']. Or walk satisfactionLinks filtering on featureClass === 'FeatureX'.
  • Backward — what satisfies ReqA? Walk satisfactionLinks filtering on requirementClasses.includes('ReqA'); collect the featureClass values.

Both directions are served from a single write — the satisfactionLinks.push({ featureClass, requirementClasses }). There is no separate "reverse index". The analysis layer rebuilds the reverse edge when asked; the registry stores facts, not indices.

This design has one practical consequence worth highlighting. Because the two directions are both queries over the same table, they cannot drift. In a hand-maintained two-map system (a featureToReqs map and a reqToFeatures map) it is always possible for the maps to disagree. Here they cannot: there is only one map.

How the compliance scanner uses it

npx requirements compliance --strict performs three gate checks; @Satisfies feeds the first two directly.

  1. Orphan Feature gate. Every concrete Feature subclass must appear as featureClass in at least one satisfactionLinks entry. A Feature without @Satisfies is treated as "a deliverable we are building without a stated rule it meets" — which, per REQ-DOG-FOOD, is a requirements gap.
  2. Unsatisfied-approved-Requirement gate. Every Requirement whose status === 'Approved' must appear as an element of some requirementClasses array — i.e. at least one Feature must satisfy it. An approved Requirement with no satisfier either needs a Feature written or needs its status dropped back to Draft.
  3. Critical-AC-coverage gate. Every AC of a priority: Critical Feature must have at least one @Verifies entry. This gate reads from the verifiesRegistry, not from satisfactionLinks, but it is structurally next door and failing here usually correlates with failing gates 1 or 2.

The scanner does not read any of these from a config file. It reads them from the decorator invocations themselves. That is the AST pickup property discussed later in this chapter and more fully in the chapter on AST extraction.

How the explorer TUI uses it

The traceability graph that npx requirements trace explore renders is, almost literally, satisfactionLinks ∪ refinementLinks ∪ verifiesRegistry — with nodes for Requirements, Features, ACs, and tests; and with edges labelled by which decorator produced them. Chapter 12 will walk through the graph construction step-by-step; chapter 14 will show how the explorer prefers the AST reads over the runtime registry for determinism. Here, the point is only that the three-Requirement @Satisfies on the running example is what makes the explorer have anything to show for this Feature. Remove the decorator and the Feature becomes a leaf with no upstream context.

One more note about the type parameter. The constraint is ReadonlyArray<abstract new …> rather than Array<…> because the variadic tuple is, from TypeScript's perspective, an immutable positional record — readonly signals that the decorator promises not to mutate the argument list. In practice this means you can pass requirements.map(r => r.name) as we do internally; you cannot accidentally requirements.push(…) and get away with it.

Decorator 4 — @Refines(ParentReq1, ParentReq2, …)

Signature:

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;
  };
}

Structurally identical to @Satisfies: variadic, typed against a class constructor bound, pushes a link record to a registry, stamps a metadata property on the class. The differences are exactly two:

  1. The target is a Requirement subclass, not a Feature subclass.
  2. The relation it expresses is SysML «deriveReqt» / «refine» — a child Requirement narrowing or decomposing a parent — not «satisfy».

The separation matters because it is the separation between two different SysML relation types. If @Satisfies and @Refines were collapsed into one decorator with a polymorphic target, the compliance scanner would have no way to tell whether a link means "this concrete deliverable meets the rule" or "this child rule is a specialisation of the parent rule". The two have different audit semantics; they warrant different decorators.

A plausible use on an accessibility family

Suppose the project has a top-level accessibility Requirement:

export abstract class ReqAccessibilityRequirement extends Requirement<DefaultStyleType> {
  readonly id = 'REQ-ACCESSIBILITY';
  readonly title = 'All interactive surfaces must meet WCAG 2.2 AA';
  readonly priority = Priority.High;
  // … full DefaultStyle fields …
}

And a child Requirement that narrows one axis of it — colour contrast:

import { Refines } from '@frenchexdev/requirements';
import { ReqAccessibilityRequirement } from './req-accessibility';

@Refines(ReqAccessibilityRequirement)
export abstract class ReqContrastRequirement extends Requirement<DefaultStyleType> {
  readonly id = 'REQ-CONTRAST';
  readonly title = 'Non-decorative text must meet 4.5:1 contrast in every ships-with theme';
  readonly priority = Priority.High;
  // … full DefaultStyle fields …
}

The @Refines(ReqAccessibilityRequirement) says: contrast is a specialisation of accessibility. Concrete Features — a theme switcher, a design-token pipeline — will @Satisfies(ReqContrastRequirement). The compliance scanner, when asked "what satisfies ReqAccessibilityRequirement?", will walk both direct satisfiers and satisfiers of any refinement, producing a two-level answer: "directly, these three Features; via ReqContrastRequirement, these two more".

DAG, not tree

A Requirement can refine multiple parents. The variadic ...parents: readonly R[] allows it:

@Refines(ReqAccessibilityRequirement, ReqInternationalizationRequirement)
export abstract class ReqLocalisedScreenReaderLabelsRequirement extends Requirement<DefaultStyleType> {
  readonly id = 'REQ-LOCALISED-SCREEN-READER-LABELS';
  readonly title = 'Screen-reader labels must be localised per active locale';
  readonly priority = Priority.Medium;
  // …
}

The refinement graph is therefore a directed acyclic graph, not a tree. The compliance scanner's walker must be cycle-aware — which is exactly the class of invariant fast-check is good at generating. Chapter 20 of the monorepo handoff walks through the property test that guarantees no cycle can be introduced; here, it is enough to note that the decorator allows DAGs and the scanner rejects cycles.

Orphan Requirement detection

Requirements whose status === 'Approved' must participate in the graph. The compliance scanner applies two equivalent checks:

  • The Requirement appears in some satisfactionLinks[i].requirementClasses (directly satisfied by a Feature), or
  • The Requirement appears as refinementLinks[i].childClass for some row whose parentClasses eventually reach a satisfied ancestor (indirectly satisfied through a refinement chain).

A Requirement that satisfies neither condition is a requirements gap — either no Feature has been written, or no child has been written, or the status should drop to Draft. The @Refines relation does not by itself satisfy a parent Requirement; only @Satisfies at the Feature level counts as evidence the work has been done. @Refines only organises the "what is the rule, more specifically" space.

Decorator 5 — @Expects(TestLevel)

Signature:

export function Expects(..._levels: TestLevel[]) {
  // no-op at runtime — the AST scanner reads the decorator arguments statically
  return function (_target: any, _propertyKey: string): void {};
}

And the enum it references, from src/base.ts:

export enum TestLevel {
  Unit                 = 'unit',
  Functional           = 'functional',
  EndToEnd             = 'e2e',
  Accessibility        = 'a11y',
  Internationalization = 'i18n',
  Visual               = 'visual',
  Performance          = 'perf',
}

Seven values. Each value corresponds to one of the seven built-in test scaffolders the package ships. One AC can declare that it expects more than one test level to exist.

The runtime is deliberately empty

@Expects does nothing at runtime. The function signature takes ..._levels with a leading underscore to mark the parameter as unused; the returned decorator function has an underscore-prefixed body too. This is the package's only AST-only decorator.

The reason is concrete. @Expects annotates an abstract method (an AC) on a Feature class — and an abstract method never executes. There is no "instance" on which a property can be stamped with the expected levels. There is no invocation that could push to a registry. All that exists is the method declaration in source — which the AST can see, but the runtime cannot.

The chapter on AST extraction explains the mechanism in full. Here, the one-line summary: the scanner walks the abstract-method declarations of every Feature class, collects the @Expects(…) argument list for each, and hands the pair (featureId, acName) → TestLevel[] to the scaffold planner. That is the entire consumer side of @Expects.

Stacked @Expects on a single AC

@Expects is variadic within one decorator call, but it is also stackable across multiple decorator calls — the scanner treats repetition as set union. Both of these produce the same scaffold plan:

abstract class FeatureTraceExplorerTuiFeature extends Feature {
  // Variadic form
  @Expects(TestLevel.Unit, TestLevel.EndToEnd)
  abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;
}
abstract class FeatureTraceExplorerTuiFeature extends Feature {
  // Stacked form — equivalent
  @Expects(TestLevel.Unit)
  @Expects(TestLevel.EndToEnd)
  abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;
}

The stacked form reads better when the two levels arrived at different times (a unit test first, an e2e added later) and when the commit history is valuable signal. The variadic form reads better when both levels were decided at once. The scanner does not care.

A realistic annotation of the running example

FEATURE-TRACE-EXPLORER-TUI does not carry @Expects in the source today — it is a Tier-2 roadmap Feature with enabled = false, and the scaffolding decisions for its tests have not yet been frozen. If and when they are, a reasonable initial annotation would look like this (hypothetical — not in source yet, clearly marked):

// Hypothetical — not yet in source as of 2026-04-14
abstract class FeatureTraceExplorerTuiFeature extends Feature {
  @Expects(TestLevel.Unit)
  abstract traceExplorerBuildsGraph(): ACResult;

  @Expects(TestLevel.Unit, TestLevel.Functional)
  abstract traceExplorerHandlesArrowKeyNavigation(): ACResult;

  @Expects(TestLevel.Unit)
  abstract traceExplorerDrillsDownFromAnyNode(): ACResult;

  @Expects(TestLevel.Functional)
  abstract traceExplorerOpensHelpOverlayOnQuestionMark(): ACResult;

  @Expects(TestLevel.Functional)
  abstract traceExplorerJumpsBackUpWithBackspace(): ACResult;

  @Expects(TestLevel.Unit)
  abstract traceExplorerRefusesToStartOnNonTty(): ACResult;

  @Expects(TestLevel.Functional)
  abstract traceExplorerExitsCleanlyOnCtrlC(): ACResult;

  @Expects(TestLevel.Unit)
  abstract traceExplorerUsesFileSystemPortForDiscovery(): ACResult;

  @Expects(TestLevel.Functional)
  abstract traceExplorerUsesPromptPortForInteraction(): ACResult;

  @Expects(TestLevel.EndToEnd)
  abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;
}

The reasoning mirrors the AC's natural testability: ACs expressible against a pure in-memory state machine or graph builder get Unit; ACs requiring a live Prompt port get Functional; the end-to-end AC that walks the actual graph gets EndToEnd. The scaffolder will then produce three test files — one per level — each containing @FeatureTest(FeatureTraceExplorerTuiFeature) plus @Verifies stubs only for the ACs declared at that level.

The default when @Expects is absent

An AC with no @Expects decorator defaults to [TestLevel.Unit]. This default is not a runtime fallback; it is an AST-read convention. The scanner, on finding an abstract method with no @Expects, treats its expected-levels set as the singleton [Unit] and the scaffolder generates a unit test for it.

The default matters because it lets small Features skip the annotation entirely. A two-AC utility Feature whose ACs are all unit-testable does not need any @Expects; the scaffolder will do the right thing. @Expects is only needed once a Feature has ACs that require more than a unit harness, which in the monorepo is the minority case.

Decorator 6 — @Exclude()

Signature:

export function Exclude() {
  return function (target: any, propertyKey: string, _descriptor?: PropertyDescriptor): void {
    excludedMethods.add(`${target.constructor.name}.${propertyKey}`);
  };
}

Ten lines. Adds a single string to a Set<string>. Read by the @FeatureTest auto-registration loop, which skips any prototype method whose Class.method identifier is in the set.

Usage

@FeatureTest(FeatureTraceExplorerTuiFeature)
class FeatureTraceExplorerTuiTests {

  @Exclude()
  private async waitForReady(): Promise<void> {
    await new Promise(resolve => setTimeout(resolve, 50));
  }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerBuildsGraph')
  async buildsGraphFromSpecsInMemory(): Promise<void> {
    await this.waitForReady();
    const graph = buildTraceGraph(specsFixture());
    expect(graph.requirements.length).toBeGreaterThan(0);
  }
}

The @Exclude() on waitForReady keeps the helper out of the vitest registration loop. Without it, waitForReady would be registered as a test, fire on every test run, and produce a zero-expectation pass that cluttered the output. Compliance scanning also respects the exclusion — the scanner will not flag waitForReady as an orphan test method because it is not a test method.

No reason parameter today

The outline for this series contemplated @Exclude(reason?) with an optional reason string that would flow into the scanner's diagnostic output. The current source ships @Exclude() with zero parameters — the parameterless form typed-specs had. The reason parameter is a candidate extension for the next decorator-surface iteration; it is not yet in the code. This chapter documents what exists; the roadmap chapter will flag the reason parameter as a planned extension.

Why name the absence explicitly? Because a chapter that documents six decorators with precise signatures should not invent a seventh parameter. The voice of chapter 00 — the word was there; the type was not — applies as much to this chapter's documentation discipline as to the subject matter.

The runtime registry

Five of the six decorators populate a registry at module-load time. There are four registries, all declared at the top of src/decorators.ts as module-scoped const arrays or Sets:

// 1. Test-to-AC registry — populated by @Verifies, backfilled by @FeatureTest
const registry: RequirementRef[] = [];

// 2. Excluded-method set — populated by @Exclude, read by @FeatureTest auto-registration
const excludedMethods = new Set<string>();

// 3. Feature-to-Requirement registry — populated by @Satisfies
const satisfactionLinks: SatisfactionLink[] = [];

// 4. Requirement-to-Requirement registry — populated by @Refines
const refinementLinks: RefinementLink[] = [];

And four exported read-only accessors:

export function getRequirementRegistry(): RequirementRef[];
export function getExcludedMethods(): Set<string>;
export function getSatisfactionLinks(): readonly SatisfactionLink[];
export function getRefinementLinks(): readonly RefinementLink[];

Three things to say about this shape.

Module-scoped state is intentional. The registries are not tied to an injected container or a service locator. They are plain module-level const arrays. This choice makes decoration cheap — no container to look up, no DI ceremony — and it pins the lifetime to the process. When the process exits, the registry is gone. When tests are run in parallel in separate workers, each worker has its own registry, independently populated by the modules it imports. This isolation is exactly what the chapter on AST extraction will contrast against: the registry is a per-process snapshot, the AST is a per-source-tree invariant.

Read-only to consumers. The four exported accessors return the registry itself (in the case of getRequirementRegistry and getExcludedMethods, for backwards compatibility with typed-specs) or a readonly view (in the case of the two newer link registries). Even where the returned value is mutable at the TypeScript level, mutating it is a documented anti-pattern: the decorators own the writes, consumers own the reads. Downstream code that needs to transform the registry should copy it first.

Population order is deterministic within a module, not across modules. Within one source file, decorators run in the order TypeScript specifies (methods before the enclosing class, parameters before methods, etc.). Across modules, the order is the order of import. This means: if you ask the registry "what satisfies ReqA?" at the top of a module before you have imported the Feature that satisfies it, you get an empty answer. Anyone consuming the runtime registry at startup should ensure all Feature and Requirement modules are imported first — which is exactly what the CLI entry points do, and what the compliance scanner does not, because the scanner reads the AST, not the registry.

That last point is the one this chapter cannot leave without expanding.

AST pickup vs runtime registry — when each matters

Every decorator in this chapter produces two artefacts from a single write: a runtime registry entry (via the decorator function's side effect) and an AST node (the call expression in source). Tooling has a choice of which to read. The choice is not arbitrary; each reader has a correct side and a wrong side.

The runtime registry is for consumers at runtime.

The canonical consumer is an application that, at startup, wants to enumerate its Features for a UI or a health check. Example: the requirements trace explore TUI needs the list of loaded Features and their Requirement links to build its graph. It imports the Feature and Requirement modules, lets the decorators populate the registries, then reads getSatisfactionLinks() to build its graph. This is correct usage. The runtime registry sees exactly what the running process has loaded; it is accurate by construction for that process.

The AST is for tooling that runs over the source.

The canonical consumer here is the compliance scanner. Invoked as npx requirements compliance --strict, it must report on every Feature and Requirement in the repository, regardless of whether any particular process has loaded them. It must also be deterministic — the same source tree must produce the same report on every machine, in every CI environment, in every IDE integration. And it must be safe under refactor — renaming a class must not cause the scanner to miss the class, even transiently, while the repository is in a mid-refactor state where some imports are broken.

Reading the runtime registry would fail every one of these requirements. It would require importing every Feature and Requirement module — which might fail mid-refactor. It would order results by import order — non-deterministic. It would miss any module that was not imported by the entry point. Reading the AST avoids all three failure modes: every .ts file on disk is visible, the walk order is deterministic, and an import that would fail at runtime is still a readable syntactic node.

The decorators produce both simultaneously.

This is the crucial invariant. A single @Satisfies(ReqA, ReqB) call is both:

  • a side-effecting function invocation that pushes to satisfactionLinks at module-load time, and
  • a CallExpression node in the TypeScript AST whose arguments are two Identifier references.

No separate annotation. No separate JSON file. No separate registry maintenance. One write, two readers.

The rule of thumb, then, is:

Consumer kind Reads
Application at runtime Runtime registry
Compliance scanner AST
IDE integration / code actions AST
requirements trace queries invoked in-process Runtime registry
requirements trace queries invoked as CLI AST

Chapter 14 — AST Extraction and the Registry — walks through the scanner implementation end-to-end. This chapter's job was only to establish that both readers exist and that the decorators deliberately serve both. The rest is internals.

A concrete example of the two readers disagreeing — and why the AST wins

Consider a mid-refactor state. A contributor is renaming ReqDogFoodRequirement to ReqSelfHostedTestingRequirement. They have changed the class declaration in req-dog-food.ts, they have renamed the file to req-self-hosted-testing.ts, and they have updated feature-trace-explorer-tui.ts to @Satisfies(..., ReqSelfHostedTestingRequirement, ...) — but they have not yet updated a second Feature elsewhere that still imports the old name from a module path that no longer exists. The repo does not compile.

Read the runtime registry in this state: you cannot. The import fails; the module never loads; satisfactionLinks is populated only by the Feature modules that do load; the second Feature is absent. A consumer querying "what does FeatureBarFeature satisfy?" gets [], which is indistinguishable from "it does not satisfy anything".

Read the AST in this state: you can. The scanner walks .ts files on disk. It reads the @Satisfies(ReqDogFoodRequirement) call expression in the unconverted Feature. It reports "this identifier does not resolve to any declared Requirement class — possibly a rename leftover". The contributor now knows exactly which file is stale.

The AST wins because the AST is a syntactic artefact, not a semantic one. An unresolved identifier is still a readable node. A failed import is a readable error to surface. A registry populated by import order is a fog.

This property — diagnostic usefulness in broken states — is the single biggest reason the scanner reads the AST. Chapter 14 goes into the mechanism (ts-morph walks, decorator-argument extraction, cycle detection); the motivation is always this.

When the runtime registry is the right choice

The runtime registry is not wrong to read — only wrong to read from a scanner. Cases where it is right:

  • An in-process CLI that has already loaded every Feature module. The explore TUI imports Features eagerly at startup; by the time the graph renderer runs, satisfactionLinks is complete and correct for the process. Reading it is a single array access. Reading the AST would require spawning a scanner, which is gratuitous overhead.
  • A web application enumerating its own Features for a health check. Same reasoning — the Feature modules are imported by the app's entry point; the registry is accurate; no need to reach for ts-morph at request time.
  • A unit test of the decorator itself. The decorator's correctness is "it pushes to the registry". Testing that means reading the registry, not the AST.

The rule is about coverage scope. If the consumer needs to see only the Features the current process has loaded, the registry is correct. If the consumer needs to see every Feature in the source tree, the AST is correct. The two answers agree in the fully-coherent-repo case; they diverge in any broken, partial, or parallel-process state.

Tracing a single @Satisfies invocation through both readers

To make the one write, two readers slogan concrete, follow a single decorator call through the system end to end.

The source, verbatim:

@Satisfies(
  ReqDiscoverableTraceabilityRequirement,
  ReqDogFoodRequirement,
  ReqParallelDeliverableRequirement,
)
export abstract class FeatureTraceExplorerTuiFeature extends Feature { … }

Reader one — runtime. At module-load time, the three identifiers ReqDiscoverableTraceabilityRequirement / ReqDogFoodRequirement / ReqParallelDeliverableRequirement are resolved by the ES module loader to the three class constructors they import. The decorator function Satisfies is called with those three constructors as its rest parameter. Inside the decorator, requirements.map(r => r.name) extracts the three strings 'ReqDiscoverableTraceabilityRequirement', 'ReqDogFoodRequirement', 'ReqParallelDeliverableRequirement'. A SatisfactionLink object is pushed onto satisfactionLinks. The property __satisfies is stamped onto the class constructor itself, carrying the same three strings. The decorator returns the class unchanged — Satisfies is a pure-metadata decorator; it does not wrap, decorate, or mutate the class behaviour.

Reader two — AST. The source file is never executed. A TypeScript compiler (ts-morph in the scanner's case) parses the file into a syntax tree. The decorator on the class appears as a Decorator node whose expression is a CallExpression. The callee of that call expression is the Identifier Satisfies. The arguments are three Identifier nodes referring to the three Requirement classes. The scanner walks the file, finds the decorator, reads the three argument identifiers, and records the link (featureClass: 'FeatureTraceExplorerTuiFeature', requirementClasses: ['ReqDiscoverableTraceabilityRequirement', 'ReqDogFoodRequirement', 'ReqParallelDeliverableRequirement']). This is the same fact as what the runtime reader stored — arrived at by a completely different path.

Divergence points. The two paths can differ in exactly three situations:

  1. The import is broken but the file is parseable. Runtime: the module fails to load, the decorator never fires, the registry entry is absent. AST: the decorator is still a syntactic node, the identifiers are still readable, the entry is recorded. The scanner can then flag "unresolved identifier" separately.
  2. The module exists but has not been imported by the current process. Runtime: no registry entry. AST: entry present. The discrepancy is not a bug; it is the definition of the two scopes.
  3. A decorator invocation is syntactically present but semantically nonsense — for example, @Satisfies(null as any). Runtime: the decorator function fires, null.name throws a TypeError, the module crashes at load time. AST: the argument is a non-Identifier node; the scanner flags it as a malformed decorator.

Three divergence modes, each of which the scanner detects and reports. The runtime registry simply dies (or, in case 2, silently under-reports). The asymmetry is not an accident; it is the reason the scanner exists.

Why the facts agree in the healthy case. The decorator function and the AST walker both read the same three argument identifiers. The function reads them as resolved class constructors and keeps their .name; the walker reads them as syntactic Identifier nodes and keeps their text. These two strings are, by TypeScript's identifier rules, the same string. As long as the repo compiles, the two readers report the same link. The one write, two readers discipline guarantees this convergence by construction — there is no separate annotation to drift out of sync.

Putting it all together — the fully decorated running example

Everything above, converging on a single concrete example. The code is split across three files — the Feature, a hypothetical test class, and a hypothetical refined Requirement elsewhere in the repo — and assembled into the chain the series has been building toward.

The Feature — real, in source

// 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';

@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;

  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;
}

One @Satisfies, three Requirement class references, ten abstract ACs. This is what is in the repo today.

A hypothetical @Refines relation elsewhere in the repo

The repository does ship @Refines — it is a declared decorator, exercised in requirement-core.test.ts and referenced in the GLOSSARY. For the running example, a plausible concrete use is a refinement of ReqDiscoverableTraceabilityRequirement — a child Requirement that narrows discoverability to the cold-start case:

// packages/requirements/requirements/requirements/req-discoverable-traceability-cold-start.ts
import { Requirement, Priority, Refines } from '@frenchexdev/requirements';
import type { DefaultStyleType } from '@frenchexdev/requirements/styles/default';
import { ReqDiscoverableTraceabilityRequirement } from './req-discoverable-traceability';

@Refines(ReqDiscoverableTraceabilityRequirement)
export abstract class ReqDiscoverableTraceabilityColdStartRequirement extends Requirement<DefaultStyleType> {
  readonly id = 'REQ-DISCOVERABLE-TRACEABILITY-COLD-START';
  readonly title = 'A new contributor must find the Requirement → Feature → AC → Test chain within five minutes of their first clone';
  readonly priority = Priority.High;
  readonly status = 'Approved' as const;
  readonly kind = 'Functional' as const;

  readonly statement = {
    pattern: 'ubiquitous' as const,
    response: 'The CLI must, on first invocation, print a single pointer into the traceability graph that requires no prior vocabulary.',
  };

  readonly rationale = {
    claim: 'Discoverability without a prior loop through the README kills adoption; the first five minutes are the adoption gate.',
    kind: 'principle' as const,
    evidence: [
      { kind: 'expert-opinion' as const, expert: 'Onboarding retrospective notes', recordedAt: '2026-04-14' },
    ],
  };

  readonly fitCriteria = [
    { kind: 'demonstration' as const, scenario: 'Fresh clone → npx requirements explore → user navigates to at least one Test node inside five minutes.' },
  ];

  readonly verificationMethod = 'Demonstration' as const;
  readonly source = { type: 'stakeholder' as const, role: 'project-rule', date: '2026-04-14' };
  readonly risk = { level: 'Medium' as const, ifNotMet: 'Contributors bounce before reaching the first positive feedback loop.' };
}

The @Refines(ReqDiscoverableTraceabilityRequirement) pushes a RefinementLink with childClass: 'ReqDiscoverableTraceabilityColdStartRequirement' and parentClasses: ['ReqDiscoverableTraceabilityRequirement']. A Feature could then @Satisfies(ReqDiscoverableTraceabilityColdStartRequirement) and, through the refinement edge, also count as evidence toward ReqDiscoverableTraceabilityRequirement.

The test class — hypothetical but grounded

The test/unit/explore-core.test.ts file in the repo does carry @FeatureTest(FeatureTraceExplorerTuiFeature) plus @Verifies per method — the sketch below shows the pattern in miniature, stripped of fixture noise:

// packages/requirements/test/unit/explore-core.test.ts — abridged
import { expect } from 'vitest';
import { FeatureTest, Verifies, Exclude } from '@frenchexdev/requirements';
import { FeatureTraceExplorerTuiFeature } from '../../requirements/features/feature-trace-explorer-tui';
import { buildTraceGraph, initialState, step, keyToEvent } from '../../src/cli/explore-core';

@FeatureTest(FeatureTraceExplorerTuiFeature)
class ExploreCoreTests {

  @Exclude()
  private specsFixture() {
    return { requirements: [/* … */], features: [/* … */], tests: [/* … */] };
  }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerBuildsGraph')
  buildsTheGraphFromLoadedSpecs(): void {
    const graph = buildTraceGraph(this.specsFixture());
    expect(graph.nodes.length).toBeGreaterThan(0);
    expect(graph.edges.some(e => e.kind === 'satisfies')).toBe(true);
  }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerHandlesArrowKeyNavigation')
  arrowKeysMoveTheCursorWithinTheCurrentList(): void {
    const s0 = initialState(buildTraceGraph(this.specsFixture()));
    const s1 = step(s0, keyToEvent({ name: 'down' }));
    expect(s1.cursor).toBe(s0.cursor + 1);
  }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerDrillsDownFromAnyNode')
  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerJumpsBackUpWithBackspace')
  drillDownAndBackAreInverse(): void {
    const s0 = initialState(buildTraceGraph(this.specsFixture()));
    const s1 = step(s0, keyToEvent({ name: 'return' }));
    const s2 = step(s1, keyToEvent({ name: 'backspace' }));
    expect(s2.cursor).toBe(s0.cursor);
    expect(s2.view).toBe(s0.view);
  }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerOpensHelpOverlayOnQuestionMark')
  questionMarkOpensHelpOverlay(): void {
    const s0 = initialState(buildTraceGraph(this.specsFixture()));
    const s1 = step(s0, keyToEvent({ sequence: '?' }));
    expect(s1.overlay).toBe('help');
  }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerRefusesToStartOnNonTty')
  refusesOnNonTty(): void {
    const originalTty = process.stdout.isTTY;
    try {
      (process.stdout as any).isTTY = false;
      const canStart = initialState(buildTraceGraph(this.specsFixture())).canStart;
      expect(canStart).toBe(false);
    } finally {
      (process.stdout as any).isTTY = originalTty;
    }
  }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerUsesFileSystemPortForDiscovery')
  discoversSpecsViaTheFileSystemPort(): void {
    const fakeFs = { /* … port stub … */ };
    // invoke loadSpecsViaFileSystem(fakeFs) and assert the call pattern
    expect(true).toBe(true);
  }
}

Counted: one @FeatureTest, one @Exclude, six distinct @Verifies invocations across six test methods, one of which carries two stacked @Verifies for a single round-trip assertion that proves two ACs at once. No describe. No it. No manual new ExploreCoreTests(). The DSL carries all of it.

Not every AC of the running Feature is verified by this test class — traceExplorerUsesPromptPortForInteraction, traceExplorerExitsCleanlyOnCtrlC, and endToEndNavigatesReqToFeatToAcToTest are verified in functional and end-to-end test files respectively. A compliance scanner walking this example would report "seven of ten ACs covered" — and, crucially, would know which three are missing by reading the AST, not by trusting a manually maintained table.

This is the complete decorator surface as it applies to the explorer TUI: @Satisfies on the Feature, @FeatureTest on each test class, @Verifies on each test method, @Exclude on helpers, @Refines on child Requirements elsewhere in the Requirements folder, @Expects on whichever ACs need a level other than the default. Every decorator the package ships appears. Nothing extra.

Diagram 1 — Decorator call graph

Diagram
The six decorators, their targets, their registries, and the scanner's read paths. Five decorators produce a runtime-registry entry; @Expects is AST-only. The compliance scanner reads the AST for stability; the runtime graph is served from the registries.

Diagram 2 — The fully decorated running example

Diagram
FEATURE-TRACE-EXPLORER-TUI with its full decorator chain. @Satisfies reaches up to three Requirements; @FeatureTest binds the test class; @Verifies binds each test method to one AC (or two, when stacked); @Exclude keeps the fixture helper out of the vitest loop; @Refines on a child Requirement elsewhere in the repo narrows one of the three satisfied Requirements.

Running-example recap

This chapter's contribution to the running example: every decorator the package ships has now been seen in use on FEATURE-TRACE-EXPLORER-TUI or on something one hop away from it. @Satisfies on the Feature itself with three Requirement references (in source, today). @FeatureTest and @Verifies on a test class of six methods, one with stacked @Verifies (in source, today, in the real explore-core.test.ts). @Exclude on the fixture helper (same file). @Expects — hypothetical per-AC annotations reasoned from the natural testability of each AC, not yet in source. @Refines — illustrated with a plausible cold-start child of ReqDiscoverableTraceabilityRequirement.

The Feature is unchanged from chapter 00. What has changed is that the decorator surface is now visible around it: six decorators, four registries, two readers (AST and runtime), and one invariant — one write, two readers — that chapter 14 will open in full.

  • typed-specs/05-decorators.md — the predecessor's three decorators, with @Implements where @Verifies now sits. Most of the compile-time safety reasoning in this chapter originated there; the three-verb rename and the @Satisfies / @Refines additions are the delta.
  • 00-named-but-not-modelled.md — chapter 00, the voice reference. Establishes the running example and the five-axis framing under which this chapter's decorator tour sits.
  • 01-the-migration-matrix.md — chapter 01, the persist/rename/extend/create matrix. Every decorator above fits exactly one cell; this chapter was the concrete inspection of the cells.
  • 02-why-dogfood-a-requirements-dsl.md — chapter 02, the justification for REQ-DOG-FOOD. The @FeatureTest auto-registration and the ban on bare describe/it are that requirement's direct operational consequence.
  • 14-ast-extraction-and-registry.md — forward link. The full walk through the compliance scanner and why it prefers the AST over the runtime registry.

Previous: 02 — Why dog-food a requirements DSL Next: 03b — Newcomer Primer: Abstract Classes, Decorators, Registries

⬇ Download