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

Requirement vs Feature: a method

Most backlogs pretend requirements and features are the same thing written at different zoom levels. They aren't. They answer different questions, they have different sources, they have different lifetimes, and collapsing them is the most common reason a team's traceability matrix can be green and still describe nothing anyone would want to build.

This post is a method — four tests you can run on any candidate statement to classify it — followed by the four-tier chain Requirement → Feature → AC → Test as it's implemented and enforced by the @frenchexdev/requirements package that ships with this site. The point isn't to hand you yet another template. The point is to give you a discipline that survives contact with a real backlog.

The confusion is structural

Walk into any backlog and you'll find items that look like:

  • "The site must work without JavaScript"
  • "Server-render every page at build time"
  • "Adding a new article must not require a deploy"
  • "Use a worker pool to regenerate Mermaid diagrams"

Jira files all four as "stories". PRDs file them under "features". Scrum-adjacent vocabulary calls them "user needs". None of the common templates distinguishes the first (a stakeholder-facing obligation) from the second (an engineering decomposition choice) from the third (another stakeholder obligation, disguised as a workflow note) from the fourth (a specific mechanism serving the first).

The cost is not theoretical:

  • AC lists that mix invariants with UI choices — "the page loads" (invariant) sits next to "the hamburger menu opens on tap" (mechanism).
  • Tests that die when the implementation changes — because the test targets the feature, not the requirement, so swapping the feature invalidates the test even though the requirement still holds.
  • Traceability matrices that compile but mean nothing — every row has a link, but the links cross the same level repeatedly: feature → feature, AC → AC, test → feature-that-is-really-a-requirement.

The fix is not a better template. The fix is to tell the layers apart, which requires a test for each layer. Let's build the test for the most confused boundary first: requirement vs feature.

Test 1 — must vs does

The must-vs-does test. If you can phrase the item as "the system must X" without committing to a mechanism, it's a requirement. If you have to say "the system does X by Y", it's a feature.

The presence of a mechanism is the tell. A requirement is a statement about what the world should look like. A feature is a statement about how the code arranges the world to look that way.

Requirement : The site must serve its full textual content without JavaScript.
Feature     : The site pre-renders every page at build time via the SSG worker pool.
Requirement : Adding a new article must not require a redeploy of the runtime.
Feature     : The static asset tree is served directly from a CDN edge.
Requirement : A reader must be able to reach any page in ≤ 3 clicks from the landing page.
Feature     : The site ships a persistent left-hand TOC with collapsible sections.

Write the candidate item down. Try to delete the "by Y" half and see if it still says something the stakeholder cares about. If yes, the "by Y" half was a feature choice — the remaining half is the requirement. If the sentence collapses when you delete the "by Y" half, the sentence was a feature all along.

Test 2 — the negation test

The negation test. Remove the item. If the stakeholder is harmed, it was a requirement. If the item can be swapped for a different mechanism that still satisfies the same underlying obligation, it was a feature.

Requirements are load-bearing. Features are replaceable.

Consider the first row in the table above. If the site stops serving text without JavaScript, a reader on a locked-down corporate proxy, on a poor mobile connection, or using a screen reader with a flaky JS engine cannot read the content. The stakeholder is harmed. It's a requirement.

Now consider the second row. If the site stops pre-rendering at build time and starts pre-rendering on the first request via an edge function instead, the requirement — "the site must serve its full textual content without JavaScript" — is still satisfied. The reader is not harmed. The mechanism is a feature, replaceable.

The negation test is particularly good at catching the disguised feature: a candidate item that looks stakeholder-facing ("the page must load in under 200ms") but is actually a decomposition choice for a deeper requirement ("the site must feel responsive on a 3G connection"). Negate the 200ms budget; if a 300ms page that streams content would satisfy the deeper obligation, the 200ms was a feature (or an AC under a feature), not a requirement.

Test 3 — the cardinality test

The cardinality test. One requirement is typically satisfied by N ≥ 1 candidate features. One feature typically satisfies one or more requirements. If several alternative implementations all equally satisfy the statement, the statement is the requirement, not any of the implementations.

Take "the site must work without JavaScript" and list mechanisms that would satisfy it:

  • Feature A — static site generation at build time
  • Feature B — server-side rendering on the first request, with client cache
  • Feature C — a text-only fallback served from /text/ for every page
  • Feature D — progressive enhancement with a functional HTML baseline

All four satisfy the requirement. None of them IS the requirement. If you pick any single mechanism and call it the requirement, you've frozen a decomposition choice into stakeholder-facing language, which is exactly the trap.

Cardinality is also the test that tells you when a feature spans several requirements. A "persistent left-hand TOC" feature might satisfy both "readers must reach any page in ≤ 3 clicks" and "the navigation must be visible to screen readers without a separate mode". Many-to-many through an explicit bridge — which is what @Satisfies encodes, below.

Test 4 — the source test

The source test. A requirement has a source outside engineering: stakeholder, regulation, accessibility standard, SLA, user persona, incident. A feature has a source inside engineering: an architect's decomposition choice.

This is the cleanest test when the previous three are ambiguous. The source field on the repo's Requirement<S> shape at packages/requirements/src/base.ts:86 is not decorative — it's a typed discriminated union over eight source kinds (stakeholder, regulation, standard, SLA, user-persona, incident, precedent, study). If you can't fill it in with a value that names something outside the engineering team, the item is not a requirement.

The "site must work without JavaScript" item passes this test: its source is a combination of WCAG 2.1 (accessibility standard), the editor's own editorial commitment to readable-everywhere content (stakeholder position), and a small set of named user personas (screen-reader users, users on constrained networks). All three are citable, all three are outside the engineering team.

"Use a worker pool to regenerate Mermaid diagrams" fails this test instantly: the source is an engineering decision taken to keep build times under a minute. That doesn't demote it to trivial — it's still a feature worth having — but it disqualifies it from requirement status.

The four tiers

Once you can tell a requirement from a feature, the rest of the chain falls out. The @frenchexdev/requirements package implements it as four tiers:

Diagram

Figure 1 — The four-tier chain. Each tier answers a different question; each edge is cardinality-many. Collapsing any two tiers is the most common cause of matrices that compile but don't mean anything.

Each tier carries its own vocabulary, its own field shape, its own lifecycle.

Requirement — the WHY

Requirement<S> at packages/requirements/src/base.ts:62-99 is an abstract class generic over a project-defined RequirementStyle. Its fields encode what a requirement IS, not what it does:

export abstract class Requirement<S extends RequirementStyle = RequirementStyle> {
  abstract readonly id: string;
  abstract readonly title: string;
  abstract readonly priority: Priority;

  abstract readonly status: StatusesOf<S>;
  abstract readonly kind: KindsOf<S>;
  abstract readonly statement: RequirementStatement;
  abstract readonly rationale: RequirementRationale;
  abstract readonly fitCriteria: readonly RequirementFitCriterion[];
  abstract readonly verificationMethod: VerificationMethodsOf<S>;
  abstract readonly source: RequirementProvenance;
  abstract readonly risk: RequirementRisk<RiskLevelsOf<S>>;

  readonly refines?: readonly string[];
  readonly tracedTo?: readonly TracedToLink[];
  readonly history?: readonly RequirementHistoryEntry[];
}

The shape is deliberately heavy. statement is not a free-text string — it's an EARS-patterned discriminated union (five patterns plus natural). rationale is a typed { claim, kind, evidence[], assumptions? }. fitCriteria is a list of verifiables each with its own kind and adapter, so "page loads in ≤ 200ms" can be bound to a Datadog metric without leaving the type system. source is the one the source test exercises, above. risk ties to the style's risk taxonomy — which in an IndustrialStyle project means SIL 1–4, and in a DefaultStyle project means Low/Medium/High/Critical.

The reason each field exists is that each one answers a question a requirement is supposed to answer and that a feature is not: why does this exist, who asked for it, how will we know it's satisfied, what happens if it isn't. A feature class with these same fields would be a category error — most of them would be empty or lying.

Feature — the WHAT

Feature at packages/requirements/src/base.ts:38-43 is much smaller:

export abstract class Feature {
  abstract readonly id: string;
  abstract readonly title: string;
  abstract readonly priority: Priority;
  readonly enabled?: boolean;
}

A real feature in this repo looks like the navigation feature at requirements/features/navigation.ts:

import { Feature, Priority, type ACResult } from '@frenchexdev/requirements';

export abstract class NavigationFeature extends Feature {
  readonly id = 'NAV';
  readonly title = 'SPA Navigation + Deep Links';
  readonly priority = Priority.Critical;

  /** Clicking a TOC entry loads the corresponding page. */
  abstract tocClickLoadsPage(): ACResult;

  /** The browser back button restores the previous page state. */
  abstract backButtonRestores(): ACResult;

  /** The active navigation item is visually highlighted. */
  abstract activeItemHighlights(): ACResult;

  /** Pressing F5 reloads the page and preserves the current view. */
  abstract f5ReloadPreserves(): ACResult;

  /** Every page state produces a bookmarkable URL. */
  abstract bookmarkableUrl(): ACResult;
  /* … */
}

Notice what the feature does not carry: no source, no rationale, no fitCriteria, no risk. Those belong on the requirements the feature satisfies, not on the feature itself. Notice also what it does carry: a list of acceptance criteria, declared as abstract methods. That's the next tier.

AC — the HOW-MEASURED

An acceptance criterion is one boolean claim about feature behaviour. In this package it's a method name on the Feature class, and its body type is ACResult:

export interface ACResult {
  satisfied: boolean;
  reason?: string;
}

The decision to model ACs as abstract methods — rather than as a separate AcceptanceCriterion type with a string name — buys one thing you cannot get any other way: the AC name is a keyof the Feature. When a test binds to an AC via @Verifies<NavigationFeature>('tocClickLoadsPage'), the TypeScript compiler narrows the string argument to the set of method names on NavigationFeature. A typo in the AC name is a compile error. A rename of the AC propagates via the IDE's refactor-rename. An orphan AC (one with no @Verifies binding anywhere) is caught by the compliance scanner.

This is the bit that collapses if you fuse ACs with features. If your AC list lives in a PRD as bullet points, every rename is an opportunity for silent drift. If your AC list lives on the feature class as abstract methods, the rename is mechanical and the orphan check is free.

Test — the PROOF

The fourth tier is the test, bound to the AC by two decorators. The convention on this repo — enforced by test/unit/CLAUDE.md and by a compliance scanner that fails the build on violation — is zero describe, zero it. Tests are classes:

import { expect } from 'vitest';
import { FeatureTest, Verifies } from '@frenchexdev/requirements';
import { NavigationFeature } from '../../requirements/features/navigation';
import { computeActiveItem } from '../../src/lib/url-routing';

@FeatureTest(NavigationFeature)
class NavigationActiveItemTests {
  @Verifies<NavigationFeature>('activeItemHighlights')
  'highlights the current page in the TOC'() {
    const active = computeActiveItem(TOC, loc('/blog/first-post'));
    expect(active).toBe('/blog/first-post');
  }

  @Verifies<NavigationFeature>('bookmarkableUrl')
  'produces a stable URL for every page state'() {
    const url = buildBookmarkableUrl(loc('/about'));
    expect(url).toMatch(/^\/about/);
  }
}

The decorators are defined at packages/requirements/src/decorators.ts. @FeatureTest is a class decorator (line 41) that auto-registers the class with vitest's globalThis.describe/it so the test file doesn't have to. @Verifies is a method decorator (line 79) that pushes a record into a runtime registry the compliance scanner walks. The class IS the test spec; the test file has zero boilerplate at the bottom.

What this gives you that a bare it('...', () => …) does not: a typed link, enforced by both the compiler (the keyof on @Verifies) and the scanner (the walk from @FeatureTest → Feature class → AC methods → @Verifies records). No broken link can hide.

The @Satisfies bridge

So far we have four tiers — but we still need to glue the Requirement tier to the Feature tier. In this package, that glue is a separate decorator: @Satisfies, defined at packages/requirements/src/decorators.ts:157-166:

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

A feature declares its satisfactions by decorating the class:

@Satisfies(NoJavaScriptRequirement, AccessibilityWcagAaRequirement)
export abstract class ProgressiveEnhancementBaselineFeature extends Feature { /* … */ }

The decorator pushes a bidirectional link into a runtime registry. From the feature side you can ask "which requirements does this feature claim to satisfy?". From the requirement side, the registry exposes the inverse: "which features claim to satisfy this requirement?". The compliance --strict CLI uses the inverse view to detect orphan requirements — approved requirements with no feature claiming to satisfy them — and orphan features — features with @Satisfies(nothing) or that never participated in the registry.

Two things make this bridge work as a method:

  1. @Satisfies is a decorator, not a field. A field would put the requirement list inside the feature class — a direction of coupling that fights you the day you rename a requirement. A decorator lives at the top of the file, is recognised by the compliance scanner via AST, and doesn't pollute the feature's type surface.
  2. The decorator takes class references, not string IDs. @Satisfies(NoJavaScriptRequirement) fails at compile time if the requirement class is renamed or deleted. String IDs would rot silently.

The method side asks you to classify a candidate statement as a requirement or a feature. The code side, with @Satisfies, forces the classification to be explicit and machine-checked. That's the discipline.

The eight-state lifecycle: method at project level

The method isn't just enforced per-item — it's enforced at the lifecycle of a whole project. The @frenchexdev/requirements package ships a finite state machine at packages/requirements/src/cli/scenario/project-lifecycle-fsm.ts:10-19 with eight states:

Empty
  → Seeded
  → RequirementsDrafted
  → FeaturesDerived
  → ScaffoldingReady
  → Implementing
  → Verifying
  → Published

(Plus a Failed branch for the obvious.)

Read the ordering carefully: RequirementsDrafted precedes FeaturesDerived. The FSM does not permit the transition in the other direction. You cannot derive a feature from a requirement you haven't drafted. You cannot have a feature without the requirement it satisfies sitting upstream in the graph.

This is the single biggest lever the tooling offers. In a backlog where requirements and features are the same "items" with different tags, it is trivially possible — and common — to start with the feature and retrofit the requirement. The result is exactly the disguised-feature problem from earlier: a "requirement" that is really an engineering decomposition choice dressed in stakeholder-facing language. The FSM refuses that transition. You start with the requirement or you don't start.

Worked example: the static-baseline requirement

Let's run the candidate "the site must serve its full textual content and internal navigation from a static HTML baseline, independently of JavaScript" through the whole pipeline. This is not an anti-JS position — this site is Hugo-style SSG AND a progressive-enhanced SPA. The requirement says the baseline has to stand on its own; the SPA is the enhancement layered on top.

Test 1 (must-vs-does). Phrased as "must": "The site must serve its content from a static baseline that works without JS." No mechanism — progressive enhancement, pre-rendering, and edge fallbacks all remain open design choices. Requirement.

Test 2 (negation). Remove it. Crawlers index nothing. Readers on screen readers with flaky JS get blanks. Low-bandwidth clients see spinners forever. Stakeholder harmed. Requirement.

Test 3 (cardinality). At least four candidate mechanisms: Hugo-style SSG at build time, SSR-on-first-request with hydration, a /text/ fallback tree, full progressive-enhancement layering on top of static HTML. Many-to-one. Requirement.

Test 4 (source). WCAG 2.1, editorial commitment to readable-everywhere content, crawlers (SEO), named personas (screen-reader users, users on constrained networks). All outside engineering. Requirement.

Now draft it as a Requirement<S> in code. The actual file shipped with this site lives at requirements/requirements/no-javascript.ts:

import { Requirement, Priority } from '@frenchexdev/requirements';

export abstract class NoJavaScriptRequirement extends Requirement {
  readonly id = 'REQ-NO-JS';
  readonly title = 'Full textual content must render without JavaScript';
  readonly priority = Priority.Critical;

  readonly kind = 'NonFunctional';
  readonly status = 'Approved';

  readonly statement = {
    pattern: 'ubiquitous',
    response: 'serve every page\'s full textual content and internal navigation from a static HTML baseline, independently of JavaScript execution',
  };

  readonly rationale = {
    claim: 'The site is Hugo-style SSG + progressive-enhanced SPA. JavaScript is the enhancement layer, not a precondition. The static baseline must stand on its own for crawlers, screen readers on flaky JS engines, and JS-hostile environments.',
    kind: 'regulatory-compliance',
    evidence: [{ kind: 'study' as const, title: 'WCAG 2.1 SC 4.1.1 / 1.3.1', authors: 'W3C', year: 2018 }],
  };

  readonly fitCriteria = [
    { kind: 'demonstration' as const, scenario: 'Open every page in toc.json with JavaScript disabled; the DOM contains the article body and links are plain hrefs.' },
  ];

  readonly verificationMethod = 'Test';
  readonly source = { type: 'standard', org: 'W3C', id: 'WCAG 2.1 AA', section: '4.1.1 / 1.3.1' };
  readonly risk = {
    level: 'High',
    ifNotMet: 'Accessibility non-compliance, reader exclusion, regulatory exposure.',
    mitigations: ['Build-time static rendering', 'Plain-href navigation', 'Pre-rendered SVG fallbacks'],
  };
}

Now enumerate the candidate features. The one we declare here is the progressive-enhancement baseline — the contract that the static HTML produced at build time is self-sufficient, so the SPA layer (SpaNavFeature in this repo) can activate on top without the baseline depending on it. The real file is at requirements/features/progressive-enhancement-baseline.ts:

import { Feature, Priority, Satisfies, type ACResult } from '@frenchexdev/requirements';
import { NoJavaScriptRequirement } from '../requirements/no-javascript';

@Satisfies(NoJavaScriptRequirement)
export abstract class ProgressiveEnhancementBaselineFeature extends Feature {
  readonly id = 'PE-BASELINE';
  readonly title = 'Progressive-enhancement baseline (static HTML stands alone)';
  readonly priority = Priority.Critical;

  /** escapeHtml neutralises script-tag injection so the static baseline is safe to serve standalone. */
  abstract escapeHtmlNeutralisesScriptInjection(): ACResult;

  /** escapeHtml is a no-op on plain ASCII text — baseline HTML does not corrupt author content. */
  abstract escapeHtmlPreservesPlainText(): ACResult;

  /** Internal `.md` hrefs are rewritten to plain static `.html` URLs — navigation works before the SPA activates. */
  abstract linksRewrittenToStaticHrefs(): ACResult;

  /** Relative image `src` attributes resolve to absolute paths — images load without a fetcher script. */
  abstract relativeImageSrcsRewrittenToAbsolute(): ACResult;
}

Notice the shape of the ACs. They are boolean claims about the feature's outcome, not mechanisms. Each one names a concrete, testable property of the baseline contract: an injection neutralised, plain text preserved, a link rewritten to a navigable static URL before any SPA script runs, an image src resolved without a fetcher. Each property survives a swap of the underlying mechanism — a different SSG or a different SPA library would still owe these four guarantees. And crucially, none of them forbids JavaScript: they describe what the baseline provides before JS, which is the foundation on which progressive enhancement layers its SPA navigation on top.

Finally, bind a test to each AC. The real file is test/unit/progressive-enhancement-baseline.test.ts:

import { expect } from 'vitest';
import { FeatureTest, Verifies } from '@frenchexdev/requirements';
import { ProgressiveEnhancementBaselineFeature } from '../../requirements/features/progressive-enhancement-baseline';
import { escapeHtml, rewriteLinks } from '../../scripts/lib/page-renderer';

@FeatureTest(ProgressiveEnhancementBaselineFeature)
class EscapeHtmlBaselineTests {
  @Verifies<ProgressiveEnhancementBaselineFeature>('escapeHtmlNeutralisesScriptInjection')
  'turns a literal <script> into inert HTML entities'() {
    const escaped = escapeHtml('<script>alert(1)</script>');
    expect(escaped).toBe('&lt;script&gt;alert(1)&lt;/script&gt;');
    expect(escaped).not.toContain('<script>');
  }

  @Verifies<ProgressiveEnhancementBaselineFeature>('escapeHtmlPreservesPlainText')
  'leaves plain ASCII text without entity-bearing chars unchanged'() {
    const plain = 'Hello world. The quick brown fox.';
    expect(escapeHtml(plain)).toBe(plain);
  }
}

@FeatureTest(ProgressiveEnhancementBaselineFeature)
class RewriteLinksBaselineTests {
  @Verifies<ProgressiveEnhancementBaselineFeature>('linksRewrittenToStaticHrefs')
  'converts an internal #content/... .md href into a static .html URL'() {
    const html = 'See <a href="#content/blog/post.md">the post</a>.';
    const rewritten = rewriteLinks(html, 'content/blog/index.md');
    expect(rewritten).toContain('href="/content/blog/post.html"');
  }

  @Verifies<ProgressiveEnhancementBaselineFeature>('relativeImageSrcsRewrittenToAbsolute')
  'resolves a relative image src to an absolute path'() {
    const html = '<img src="./images/diagram.png" alt="diagram">';
    const rewritten = rewriteLinks(html, 'content/blog/post.md');
    expect(rewritten).toContain('src="/content/blog/images/diagram.png"');
  }
}

Run npx requirements compliance --strict and the tool walks the graph:

  • Does NoJavaScriptRequirement have a feature with @Satisfies(NoJavaScriptRequirement)? Yes, three of them: ProgressiveEnhancementBaselineFeature, BuildPipelineFeature (the SSG side — the Hugo-style pipeline that emits the static HTML baseline), and LinkValidationFeature (link integrity is part of the baseline contract). The SPA enhancement layer (SpaNavFeature) does NOT satisfy this requirement — it sits on top of the baseline, it isn't the baseline.
  • Does every AC on ProgressiveEnhancementBaselineFeature have a @Verifies binding? Yes, all four — escape × 2, rewrite × 2.
  • Does every @Verifies reference an AC that exists? Yes (enforced by the TypeScript compiler via keyof).
  • Is every test class annotated with @FeatureTest? Yes (enforced by the scanner).

Every edge in the chain is checked. The chain is compiled, not narrated. Two of the three satisfying features were not invented for this article — they were pre-existing features whose connection to the no-JS requirement was implicit until the requirement was drafted. That is exactly the pattern the eight-state lifecycle is meant to surface: when you finally draft RequirementsDrafted, you discover a ring of features that were always satisfying it, just without a name to attach to.

The practical worksheet

Here is the method, distilled to something you can run on a backlog item tomorrow morning:

  1. Phrase the item in EARS. "When X, the system shall Y." Or "While X, the system shall Y." Or the unconditional "The system shall Y." The EARS form strips narrative and leaves the claim.

  2. Run the four tests.

    • Must-vs-does: can you phrase it without a mechanism?
    • Negation: if you remove it, is the stakeholder harmed?
    • Cardinality: can several alternative implementations all satisfy it equally?
    • Source: can you name a source outside engineering?

    Four yeses = requirement. Four nos = feature. A mixed result is a signal that the item is hiding two items — split it.

  3. If it's a requirement, draft 2–3 candidate features that would satisfy it. Don't commit to one immediately. The act of enumerating mechanisms is what confirms the requirement is a requirement (cardinality test passes again) and surfaces the design space. Pick the feature you'll actually build; keep the others in a design-notes trail because the next time this requirement's constraints shift, those candidates are where you restart.

  4. For the chosen feature, enumerate its ACs. Each AC is one boolean claim about the feature's outcome — not its mechanism. A good test: can you imagine the AC being true under a different implementation of the feature? If yes, the AC belongs to the feature. If no, the AC is over-specified and has leaked mechanism; rewrite it.

  5. Bind one @Verifies test per AC. The test class is @FeatureTest(TheFeature). The test method is @Verifies<TheFeature>('theAcName'). The AC name is a keyof the feature class — the compiler catches typos, renames propagate, orphans are a compile error at the walk step.

That's the whole method. Five steps, one of which (step 2) is the work; the other four are the bookkeeping that makes step 2 cheap to redo when the requirement changes.

Where this sits

This post is the method. Three neighbouring pieces go deeper in directions that assume you've already accepted the method:

  • Requirements ARE Types — the full argument for why a requirement's shape belongs in the type system rather than in a PRD template.
  • Requirements — Templates vs Types — what PRD templates get right, what they cost, and where a typed-requirements approach inverts the trade-off.
  • Where Requirements Meet DDD — the mapping from Feature → Bounded Context and AC → Use Case, for readers coming from a Domain-Driven Design background.

And one sibling piece from the same day walks through what happens when you extend the same discipline down into the DSL itself: A Style is a tiny compiler. Test it like one. — the Style that parametrises Requirement<S> is itself a thing that deserves its own @FeatureTest/@Verifies chain, and the recursion is load-bearing.

The method is cheap. The tooling is open. The discipline is the product.

⬇ Download