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

Requirements-as-Code

Make intent and verification relationships explicit. Type the model, inspect the links, run the checks.

Please view Requirement-as-Code - Governance for Governance

Please view Requirements-as-Code with AI: A Controlled Delivery Workflow for Workflow

What This Is

Requirements, implementation and tests often live in different tools. Requirements-as-Code models their relationships inside the codebase so that type checking, source analysis and test execution can each answer a specific question.

The check depends on the relationship. A typed reference can be checked during compilation. A missing test binding needs source analysis. A test result needs execution. CI enforcement needs an explicit pipeline or merge rule. These stages are separate.

This page covers both — one TypeScript, one C# — and the asymmetry that makes them complementary rather than redundant.

Anything-as-Code applied to software intent. The forward path starts with a need, then develops requirements, features, acceptance criteria, implementation and verification. I am also experimenting with specification recovery: use AI-assisted analysis of existing code and tests to propose that model, review it with a human, then use the approved baseline to guide changes. The aim is to recover useful knowledge and expose gaps before refactoring or redesign. Explore the experimental reverse path.

The existing TypeScript declaration ↔ JSON spec synchronization round-trips an already expressed model. Recovering candidate meaning from an implementation is a separate experiment; its observations do not become requirements until reviewed and approved.

The Chain — Requirement → Feature → AC → Test

Question Mechanism What it establishes
Does a referenced member or constrained type exist? TypeScript type checking or C# compilation The particular typed relationship is valid.
Is an acceptance criterion linked to a test? TypeScript source/compliance analysis A binding exists in the analyzed sources.
Does the selected test pass? Test runner The observed outcome for that run.
Does failure block integration? Configured hook or CI/merge rule Enforcement only where that rule is enabled.

The C# registry generator exposes metadata; its unfinished coverage analyzer does not currently supply the second check.

The model is the same on both sides:

Diagram
One conceptual chain, four layers. The binding mechanism between layers is what each ecosystem implements differently.

A Requirement records the WHY; a Feature identifies the behavior; an Acceptance Criterion states what must be assessed; a Test exercises it. TypeScript models include provenance and EARS-style statements. C# currently has a narrower metadata model. The shared vocabulary does not imply feature parity or automatic validation of business meaning.

TypeScript: @frenchexdev/requirements

The TypeScript tooling is used by this site. Its packages include packages/requirements-lib/, requirements-core and requirements-styles, in the TypeScript codebase supporting this CV site. The C#/PowerShell ecosystem is a separate repository. The requirements compliance --strict command inspects source relationships; its result must be reported for a specific run.

The Full Chain — Requirement → Feature → Tests

The example uses three files. The Requirement records the intent, rationale and provenance. The Feature declares acceptance criteria as methods returning ACResult, with @Satisfies linking it to the requirement. The test class uses @FeatureTest and @Verifies<F>('acName') to declare which criteria its methods exercise. The generic parameter checks criterion names against the feature type. The compliance scanner joins those declarations into a BindingsManifest; executing the tests supplies the separate result evidence.

1. The Requirement — requirements/requirements/content-loading.ts

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

export abstract class ContentMustLoadFromMarkdown extends Requirement {
  readonly id = 'REQ-MD-CONTENT';
  readonly title = 'Page content authored in Markdown, rendered at build time';
  readonly priority = Priority.Critical;

  readonly kind = 'Functional';
  readonly status = 'Approved';

  readonly statement = {
    pattern: 'event-driven',
    trigger: 'When the build pipeline visits a content/**/*.md file',
    response: 'parse YAML frontmatter, render Markdown to HTML, replace mermaid placeholders with inline SVG, and emit a static page reachable by its canonical URL',
  };

  readonly rationale = {
    claim: 'Authoring stays human-readable; HTML is a build artifact, not the source of truth. The site must work without JavaScript and remain crawlable.',
    kind: 'design-decision',
  };

  readonly fitCriteria = [
    {
      kind: 'demonstration' as const,
      scenario: 'Run `npm run build:static` and open every page in toc.json with JS disabled — body, navigation, and diagrams render.',
    },
    {
      kind: 'narrative' as const,
      text: 'Build is hermetic: the content/ tree fully determines the published site.',
    },
  ];

  readonly verificationMethod = 'Test';

  readonly source = {
    type: 'internal',
    decision: 'CV site authoring discipline',
    date: '2026-04-01',
  };

  readonly risk = {
    level: 'High',
    ifNotMet: 'Authoring drifts to ad-hoc HTML; accessibility and SEO regress.',
    mitigations: ['Strict Markdown linters', 'No raw HTML pages', 'Single render pipeline'],
  };
}

2. The Feature — requirements/features/markdown-rendering.ts

import { Feature, Priority, Satisfies, Expects, TestLevel, type ACResult } from '@frenchexdev/requirements';
import { ContentMustLoadFromMarkdown } from '../requirements/content-loading';

@Satisfies(ContentMustLoadFromMarkdown)
export abstract class MarkdownPageRendering extends Feature {
  readonly id = 'FEAT-MD-RENDER';
  readonly title = 'Markdown Page Rendering';
  readonly priority = Priority.Critical;

  /** Frontmatter is parsed into typed page metadata before render. */
  @Expects(TestLevel.Unit, TestLevel.EndToEnd)
  abstract frontmatterIsParsed(): ACResult;

  /** `<!--mermaid ... -->` placeholders are replaced by inline SVG. */
  @Expects(TestLevel.Unit)
  abstract mermaidPlaceholdersAreReplaced(): ACResult;

  /** Internal `[...](slug.md)` links resolve to canonical /<section>/<slug>/ URLs. */
  @Expects(TestLevel.EndToEnd, TestLevel.Visual)
  abstract internalLinksResolveToCanonicalPaths(): ACResult;
}

3. The Tests — test/unit/markdown-rendering.test.ts

import { expect } from 'vitest';
import { FeatureTest, Verifies } from '@frenchexdev/requirements';
import { MarkdownPageRendering } from '../../requirements/features/markdown-rendering';
import { renderMarkdownPage } from '../../src/lib/markdown-renderer';

@FeatureTest(MarkdownPageRendering)
class MarkdownPageRenderingTests {

  @Verifies<MarkdownPageRendering>('frontmatterIsParsed')
  'extracts title and section from YAML frontmatter'() {
    const page = renderMarkdownPage('---\ntitle: About\nsection: about\n---\n# Hello');
    expect(page.meta.title).toBe('About');
    expect(page.meta.section).toBe('about');
    expect(page.body).toContain('<h1>Hello</h1>');
  }

  @Verifies<MarkdownPageRendering>('mermaidPlaceholdersAreReplaced')
  'swaps mermaid placeholders for inline SVG'() {
    const page = renderMarkdownPage('# Page\n<!--mermaid id=foo-->\n```mermaid\ngraph TD;A-->B\n```');
    expect(page.body).toContain('<svg');
    expect(page.body).not.toContain('<!--mermaid');
  }

  @Verifies<MarkdownPageRendering>('internalLinksResolveToCanonicalPaths')
  'rewrites markdown links to canonical site URLs'() {
    const page = renderMarkdownPage('See [the index](index.md) for more.');
    expect(page.body).toContain('href="/index/"');
    expect(page.body).not.toContain('.md');
  }
}

Three things to notice. The @Verifies<MarkdownPageRendering>('frontmatterIsParsed') generic argument is not decoration — it's a keyof MarkdownPageRendering constraint, so renaming frontmatterIsParsed on the Feature surfaces compile errors at every test that referenced the old name. The methods use string-quoted names like 'extracts title and section from YAML frontmatter'() — vitest's auto-registration uses the method name verbatim as the it() description, so spaces and prose read naturally in the test reporter. And there is no describe, no it — the test/unit/CLAUDE.md convention forbids them, and compliance.test.ts fails the build if any test file slips bare describe/it through.

4. The CLI -

This output is taken from ts-codegen-pipeline

Feature Compliance Report

Lifecycle, Styles, and Branded Primitives

Branded primitives — RequirementId, FeatureId, AcName, IsoDate, Sentence — keep magic-string drift out by construction. Lifecycle isn't hard-coded: a Style is plug-in. The Default style ships a 5-state workflow (Draft → Approved → Implemented → Verified → Deprecated); the Industrial style adds Safety/Security/Customer context with FAT/SAT gates; Lean / Agile / Kanban styles tune the vocabulary and validators. Projects can define their own RequirementStyle.

More Than a Compliance Gate

The package isn't really a CLI library. Reading package.json, it has 35+ sub-path exports, ports/* describing FileSystem, Logger, Prompt, Vcs, FitCriterionAdapter, ToggleProvider, and a self-description that reads:

"Analysis + specs + schemas + command cores — port-driven, no Commander/clack. Consumable by CLIs, LSPs, and IDE extensions."

That phrasing is intentional. The compliance-core, test-bindings-scanner, bidi-sync-core, watch-core, rename-core, toggling, versioning, trace-graph, ac-suggester, audit-hooks, feature-new-core, requirement-core, scaffold-core, spec-from-source and spec-to-source modules all stop short of doing I/O — they accept a FileSystem port and an OutputSink and let the consumer (today: a CLI; tomorrow: an LSP server, a VS Code extension, an in-IDE refactoring command) decide where the bytes come from. The CLI you call as npx requirements compliance --strict is just one face of that surface. So is npx requirements scaffold e2e FEAT-MD-RENDER, which calls into the scaffolders/e2e port to emit a Playwright spec wired to @FeatureTest / @Verifies from the start. So is npx requirements trace gaps, which walks the trace-graph to surface uncovered ACs.

A few features deserve their own line. Content-hash versioning (versioning.ts) computes a stable contentHashOf(spec) ignoring NON_STRUCTURAL_FIELDS, then detectVersionDrift() flags spec changes that didn't bump a version — useful for catching silent edits. Bidirectional sync (bidi-sync-core) round-trips between TypeScript class declarations and JSON spec files via spec-from-source / spec-to-source, so authoring can happen in either form. Audit hooks make the requirement history append-only with optional signature hooks. Rename-core does the boring, dangerous job of renaming a Requirement / Feature / AC across specs, source, tests, and bindings — typed and mechanical, not find-and-replace. None of these are nice-to-haves; on a 30-Feature project, the difference between "I have rename-core" and "I have grep" is whether you're allowed to refactor.

C#: FrenchExDev.Net.Requirements

The C# implementation lives under Net/FrenchExDev/Requirements in the FrenchExDev repository and is one of the four DSL frameworks of the FrenchExDev .NET ecosystem, siblings of Dsl (the M3 meta-metamodel), Ddd, and Diem (the CMF). It uses the same Roslyn IIncrementalGenerator toolkit that powers Builder, FiniteStateMachine, and the Métacratie cross-compiler — the patterns transfer.

The Full Chain — Epic → Feature → Tests

The C# chain mirrors the TypeScript one but exploits two C# features TypeScript can't match: attributes that take typeof() and nameof() arguments, and generic constraints on class hierarchies. The first means a binding is a compiler-resolved reference, not a string — refactor the AC method, the test annotation follows. The second means wrong nesting is a compile error.

1. The Epic (the WHY) — Site.Requirements/ContentLoading.cs

The Epic is the requirements-tier. Today it carries Title and Priority; the EARS sentence, rationale, fit criteria, provenance, and risk fields land with the next analyzer wave (REQ101–REQ302) and align with the TypeScript Requirement shape on the other side.

using FrenchExDev.Net.Requirements;
using FrenchExDev.Net.Requirements.Attributes;

[MetaConcept]
public abstract class ContentLoading : Epic
{
    public override string Title => "Page content authored in Markdown, rendered at build time";
    public override RequirementPriority Priority => RequirementPriority.Critical;
    public override string Owner => "stephane.erard@gmail.com";
}

2. The Feature — Site.Requirements/MarkdownRendering.cs

A Feature<TParent> where TParent : Epic only accepts an Epic as parent. A Story<TParent> where TParent : RequirementMetadata only accepts an aggregate-root-shaped type. Try Feature<Story> and the build fails with a generic-constraint error, at the right line, in the right project. Wrong shapes don't reach the registry because they don't reach the binary.

using FrenchExDev.Net.Requirements;
using FrenchExDev.Net.Requirements.Attributes;

[MetaConcept]
[ForRequirement(typeof(ContentLoading))]
public abstract class MarkdownRendering : Feature<ContentLoading>
{
    public override string Title => "Markdown Page Rendering";
    public override RequirementPriority Priority => RequirementPriority.Critical;
    public override string Owner => "stephane.erard@gmail.com";

    /// <summary>Frontmatter is parsed into typed page metadata before render.</summary>
    public abstract AcceptanceCriterionResult FrontmatterIsParsed();

    /// <summary><![CDATA[<!--mermaid ... --> placeholders are replaced by inline SVG.]]></summary>
    public abstract AcceptanceCriterionResult MermaidPlaceholdersAreReplaced();

    /// <summary>Internal [text](slug.md) links resolve to canonical /section/slug/ URLs.</summary>
    public abstract AcceptanceCriterionResult InternalLinksResolveToCanonicalPaths();
}

3. The Tests — Site.Requirements.Tests/MarkdownRenderingTests.cs

using Xunit;
using FrenchExDev.Net.Requirements.Attributes;
using Site.Rendering;

[TestsFor(typeof(MarkdownRendering))]
public class MarkdownRenderingTests
{
    [Fact]
    [Verifies(typeof(MarkdownRendering),
              nameof(MarkdownRendering.FrontmatterIsParsed))]
    public void Extracts_title_and_section_from_yaml_frontmatter()
    {
        var page = MarkdownRenderer.Render("---\ntitle: About\nsection: about\n---\n# Hello");

        Assert.Equal("About", page.Meta.Title);
        Assert.Equal("about", page.Meta.Section);
        Assert.Contains("<h1>Hello</h1>", page.Body);
    }

    [Fact]
    [Verifies(typeof(MarkdownRendering),
              nameof(MarkdownRendering.MermaidPlaceholdersAreReplaced))]
    public void Swaps_mermaid_placeholders_for_inline_svg()
    {
        var page = MarkdownRenderer.Render("# Page\n<!--mermaid id=foo-->\n```mermaid\ngraph TD;A-->B\n```");

        Assert.Contains("<svg", page.Body);
        Assert.DoesNotContain("<!--mermaid", page.Body);
    }

    [Fact]
    [Verifies(typeof(MarkdownRendering),
              nameof(MarkdownRendering.InternalLinksResolveToCanonicalPaths))]
    public void Rewrites_markdown_links_to_canonical_site_urls()
    {
        var page = MarkdownRenderer.Render("See [the index](index.md) for more.");

        Assert.Contains("href=\"/index/\"", page.Body);
        Assert.DoesNotContain(".md", page.Body);
    }
}

typeof(MarkdownRendering) and nameof(MarkdownRendering.FrontmatterIsParsed) are not strings — they're compiler-resolved references. Rename the AC, the test annotation follows automatically through the IDE rename refactor. Misspell the AC, the build breaks with an unambiguous CS0103: The name 'FrntmaterIsParsed' does not exist at the test site, not a silent string mismatch a quarter later.

The Roslyn Generator + Analyzer Pair

The Roslyn side is where the magic shows up. RequirementRegistryGenerator is an IIncrementalGenerator — incremental matters, because in a monorepo with thousands of files you can't afford a full re-walk on every keystroke. It matches abstract classes deriving from the requirement hierarchy, classifies each by walking the base-type chain (Epic / Feature / Story / RequirementTask / Bug), pulls Title from expression-bodied property overrides, collects abstract methods returning AcceptanceCriterionResult as ACs, and emits a single RequirementRegistry.g.cs:

// AUTO-GENERATED by RequirementRegistryGenerator
namespace FrenchExDev.Net.Requirements.Generated;

public static class RequirementRegistry
{
    public static readonly RequirementInfo[] All = new[]
    {
        new RequirementInfo(
            kind: RequirementKind.Epic,
            type: typeof(global::Site.Requirements.ContentLoading),
            title: "Site Content Loading",
            priority: RequirementPriority.Critical,
            acceptanceCriteria: Array.Empty<string>(),
            children: new[] { typeof(global::Site.Requirements.ContentLoading.MarkdownRendering) }
        ),
        new RequirementInfo(
            kind: RequirementKind.Feature,
            type: typeof(global::Site.Requirements.ContentLoading.MarkdownRendering),
            title: "Markdown Page Rendering",
            priority: RequirementPriority.Critical,
            acceptanceCriteria: new[] {
                nameof(global::Site.Requirements.ContentLoading.MarkdownRendering.FrontmatterIsParsed),
                nameof(global::Site.Requirements.ContentLoading.MarkdownRendering.MermaidPlaceholdersAreReplaced),
                nameof(global::Site.Requirements.ContentLoading.MarkdownRendering.InternalLinksResolveToCanonicalPaths),
            },
            children: Array.Empty<Type>()
        ),
        // …
    };
}

Alongside the generator sits RequirementCoverageAnalyzer. In the inspected source it declares the REQ100 descriptor but registers no coverage analysis action. The generated registry therefore does not establish that every acceptance criterion has a test or that tests have passed. The wider REQ rule set is planned. Current C# source observations · Verification stages.

Six projects make up the library: FrenchExDev.Net.Requirements (core types), .Attributes (the three attributes plus their MetaConcept subclasses for DSL framework integration), .SourceGenerator and .SourceGenerator.Lib (incremental generator + emitter), .Analyzers (the REQ-coded diagnostics), and .Testing (sample requirements that exercise the round-trip). It dog-foods Result and Builder like every other library in the ecosystem, so there's no second-class error handling — AcceptanceCriterionResult is a struct with Satisfied/Failed factories and an implicit bool conversion that's deliberately limited so you don't accidentally silently swallow failure.

Same Idea, Two Asymmetries

The table is short on purpose:

Concern TypeScript C#
Identity Branded RequirementId / FeatureId strings + class references typeof(T) + nameof(T.AC) — compiler-resolved
Bindings layer Decorators + auto-vitest registration (no describe/it) Attributes + xUnit [Fact]
Static analysis TypeScript compiler API (ts-morph) walking sources Roslyn IIncrementalGenerator + DiagnosticAnalyzer
Lifecycle Pluggable RequirementStyle (Default 5-state, Industrial 8-state with FAT/SAT, Lean/Agile/Kanban) Generic-constrained class hierarchy (Epic → Feature<Epic> → Story<…> → RequirementTask / Bug)
Tooling surface Bidi-sync, watch, rename, toggling, versioning, trace-graph, AC suggester, audit hooks, 7 scaffolders, port-driven Generated registry and typed hierarchy; coverage analysis remains unfinished
Compliance gate npx requirements compliance --strict (CLI today; LSP-ready) Registry available to consumers; acceptance-criterion coverage enforcement unfinished
Output requirements-bindings.json + traceability matrix + report renderers RequirementRegistry.g.cs; REQ100 descriptor declared without an active coverage check
Maturity Used by this site; check results depend on the run and selected scope Types and registry implemented; coverage analyzer and further rules unfinished

TypeScript provides the broader tooling surface: source scanning, authoring synchronization, refactoring and reports. IDE/LSP consumers are an extension target. C# provides typed declarations and registry generation. Compiler-resolved references and generic constraints check their respective C# relationships; planned analyzers would add further domain checks.

The symmetry is the point. EARS sentences with rationale and provenance, a Requirement → Feature → AC → Test chain, lifecycle as a first-class concept, orphan / coverage / drift gates, audit history — all of that is the same vocabulary on both sides. What's portable is the model. The reason there are two implementations isn't that I couldn't pick one; it's that the model has to live where the work happens. The TypeScript code I write needs typed traceability. The C# code I write needs typed traceability. Either both ecosystems get it or neither does, and "neither" is what most teams settle for.

Where Each One Sits

The TypeScript library is used by this CV site for requirement declarations, test bindings and compliance inspection. A source binding identifies a relationship; a completed test run supplies execution evidence. Whether a failing check blocks a push or merge depends on the configured hook or CI rule.

The C# foundation includes the constrained type hierarchy, attributes and generated registry. Activating coverage analysis, adding further diagnostic rules and exposing trace graphs are subsequent work. IDE quick-fixes and richer metadata remain development targets.

Both feed into work I care about. The Métacratie cross-compiler — a Roslyn project that compiles legal frameworks indexed by META(Espace × Temps × Auteur) — needs the same Requirement → AC → Test discipline applied to law: a code article must be linked to its constitutional source, its case-law verifiers, and its compliance fitness criteria. And the planned Ide.Dsl-TS work (the TypeScript variant of the C# Ide.Dsl corpus) will consume the TS package's port-driven cores from inside the IDE, not over a CLI shell.

Two implementations, one model, three downstream consumers. That's the shape.

⬇ Download