Requirements-as-Code
Stop tracking what you promised. Type it.
What This Is
Most teams treat requirements as paragraphs in a document, IDs in a tracker, and hopeful labels on a PR.
The link between “what we promised” and “what we shipped” lives in habit, not in the type system. When habits drift, the spec and the code quietly stop telling the same story. Requirements-as-Code changes that. I turned requirements into executable artifacts living directly in the codebase:
- A Requirement is a typed class with provenance
- A Feature declares what it satisfies
- Acceptance Criteria are abstract methods
- Tests are mechanically bound to them
Break the link → the build screams.This is not another BDD tool or contract testing framework. It is a contention system where the compiler and the IDE become active participants in keeping the promise.
This page covers both — one TypeScript, one C# — and the asymmetry that makes them complementary rather than redundant.
The Chain — Requirement → Feature → AC → Test
The model is the same on both sides:
A Requirement is the WHY: an EARS-style sentence ("When X, the system shall Y"), with rationale, risk, and a RequirementProvenance field — where it came from (regulation, customer, internal decision), with a date and a signatory. A Feature is the WHAT — the user-facing unit. Acceptance Criteria are abstract methods returning a result type (ACResult in TS, AcceptanceCriterionResult in C#), and a Test binds to an AC by name. The model is older than either implementation — it's the shape SysML, EARS, and ATDD have been pushing for years. What's new is making it executable — the compiler refuses bad bindings, the build refuses unverified critical ACs, and the IDE surfaces compliance gaps where you write code, not where you read dashboards.
TypeScript: @frenchexdev/requirements
This is the implementation that's mature today. It lives in packages/requirements-lib/ (and requirements-core, requirements-styles) inside the @frenchexdev TypeScript monorepo, and it dog-foods itself: this very CV site uses it, and npx requirements compliance --strict runs green here.
The Full Chain — Requirement → Feature → Tests
The whole chain lives in three files. The Requirement is the WHY — an EARS-style sentence, a rationale, a risk profile, a provenance. The Feature is the WHAT — abstract methods returning ACResult, one per acceptance criterion, @Satisfies linking back to the Requirement. The tests are the proof — a class decorated with @FeatureTest, methods decorated with @Verifies<F>('acName') (the generic type parameter gives keyof-checked AC names — wrong name, compile error), no describe and no it. The compliance scanner walks all three, joins them by class identity and AC name, and emits a BindingsManifest.
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'],
};
}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;
}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');
}
}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 ──
coverage from test-results/2026-05-08T06-17-07/
| | ID | Title | Total | Covered | TU | E2E | % | src | REQs |
+---+---------------------------------+-------------------------------------------------------------------------------------------------------+-------+---------+-----+-----+------+--------------------+---------------------------------------------------------------------------------+
| ✓ | TSCP-ANALYZER-BUILTINS | Four built-in analyzers dog-fooded on the package itself | 8 | 8 | 8 | 0 | 100% | src 100% (6 files) | REQ-TSCP-ANALYZER-BUILTINS |
| ✓ | TSCP-ANALYZER-CLI | CLI subcommands and rich diagnostic formatter | 13 | 13 | 6 | 7 | 100% | src 0% (0 files) | REQ-TSCP-ANALYZER-CLI |
| ✓ | TSCP-ANALYZER-EXTENSIBILITY | Analyzers from any provenance compose uniformly via `analyzers: [...]` | 5 | 5 | 4 | 1 | 100% | src 100% (1 file) | REQ-TSCP-ANALYZER-EXTENSIBILITY |
| ✓ | TSCP-ANALYZER-DIAGNOSTIC | Diagnostic carries start/end and related locations; diagFromNode populates positions | 7 | 7 | 6 | 1 | 100% | src 100% (1 file) | REQ-TSCP-ANALYZER-DIAGNOSTIC |
| ✓ | TSCP-ANALYZER-LIFECYCLE | Analyzer is read-only and runs post-fixpoint pre-commit; errors abort commit | 6 | 6 | 6 | 0 | 100% | src 100% (2 files) | REQ-TSCP-ANALYZER-LIFECYCLE |
| ✓ | TSCP-ANALYZER-PERF | Per-analyzer execution timings observable; threshold warning | 5 | 5 | 4 | 1 | 100% | src 100% (1 file) | REQ-TSCP-ANALYZER-PERF |
| ✓ | TSCP-ANALYZER-SUPPRESSION | In-source `@sourcegen-disable-next-line` directives suppress analyzer diagnostics per-node | 6 | 6 | 6 | 0 | 100% | src 100% (2 files) | REQ-TSCP-ANALYZER-SUPPRESSION |
| ✓ | TSCP-ANALYZER-TESTKIT | Sub-export `./testing` provides scaffolding helpers and assertAnalyzerContract | 9 | 9 | 9 | 0 | 100% | src 100% (4 files) | REQ-TSCP-ANALYZER-TESTKIT |
| ✓ | TSCP-ANALYZER-WATCH | Watch mode includes analyzers in every cycle by default | 3 | 3 | 0 | 3 | 100% | src 0% (0 files) | REQ-TSCP-ANALYZER-WATCH |
| ✓ | TSCP-ANALYZER-WRAPPERS | Decorator-pattern wrappers adjust analyzers at composition root | 6 | 6 | 6 | 0 | 100% | src 100% (2 files) | REQ-TSCP-ANALYZER-WRAPPERS |
| ✓ | TSCP-ATOMIC-COMMIT | At fixpoint, commit virtFS to disk atomically per file with stale-output purge | 8 | 8 | 8 | 0 | 100% | src 100% (3 files) | REQ-TSCP-ATOMIC-COMMIT, REQ-TSCP-STRICTLY-ADDITIVE-EMISSION |
| ✓ | TSCP-DRIFT-DETECTION | `sourcegen verify` detects hand-edits and regeneration drift via banner contentHash | 7 | 7 | 7 | 0 | 100% | src 100% (3 files) | REQ-TSCP-WRITE-ONCE |
| ✓ | TSCP-FIXPOINT-TERMINATION | Runner converges to a fixpoint or fails at MAX_ITERATIONS, with deterministic ordering | 8 | 8 | 8 | 0 | 100% | src 100% (5 files) | REQ-TSCP-FIXPOINT-LOOP, REQ-TSCP-ITERATION-CAP, REQ-TSCP-DETERMINISTIC-ORDERING |
| ✓ | TSCP-DECORATOR-DISCOVERY | @Generate is found by identifier name; multi-stacking and AST-target filtering are first-class | 12 | 12 | 12 | 0 | 100% | src 100% (6 files) | REQ-TSCP-DECORATOR-BY-NAME |
| ✓ | TSCP-GENERATED-FILE-BANNER | Every emitted file carries a banner with producer / iteration / SHA-256 contentHash | 8 | 8 | 8 | 0 | 100% | src 100% (2 files) | REQ-TSCP-WRITE-ONCE, REQ-TSCP-DETERMINISTIC-ORDERING |
| ✓ | TSCP-INCREMENTAL-DELTA | GenerationContext.delta exposes a read-only view of recent virtFS activity for incremental processing | 5 | 5 | 5 | 0 | 100% | src 100% (2 files) | REQ-TSCP-INCREMENTAL-DELTA-VISIBILITY |
| ✓ | TSCP-STRICTLY-ADDITIVE-EMISSION | Generators may only write inside outDir; addSource rejects path traversal with SG0040 | 9 | 9 | 9 | 0 | 100% | src 100% (2 files) | REQ-TSCP-STRICTLY-ADDITIVE-EMISSION, REQ-TSCP-EMISSION-PATH-VALIDATION |
| ✓ | TSCP-VIRTUAL-FS-ISOLATION | VirtFs isolates the disk during iterations and enforces idempotent / divergence-aware addSource | 8 | 8 | 8 | 0 | 100% | src 100% (2 files) | REQ-TSCP-VIRTFS-ISOLATION, REQ-TSCP-DIVERGENCE-DIAGNOSTIC |
| ✓ | TSCP-WATCH-MODE | CLI `watch` re-runs sourcegen on src/spec changes, ignores generated outputs, serializes runs | 10 | 10 | 10 | 0 | 100% | src 100% (2 files) | REQ-TSCP-WATCH-MODE |
Features: 19 active
Acceptance criteria: 143/143 ACs covered (100%)
Total tests linked to ACs: 261 (legacy .js tests not yet migrated to .ts are unlinked)
Runtime coverage warnings: 0
Unbound features: 0
Critical uncovered: 0
Orphan source files: 0
Quality gate: PASS ── Feature Compliance Report ──
coverage from test-results/2026-05-08T06-17-07/
| | ID | Title | Total | Covered | TU | E2E | % | src | REQs |
+---+---------------------------------+-------------------------------------------------------------------------------------------------------+-------+---------+-----+-----+------+--------------------+---------------------------------------------------------------------------------+
| ✓ | TSCP-ANALYZER-BUILTINS | Four built-in analyzers dog-fooded on the package itself | 8 | 8 | 8 | 0 | 100% | src 100% (6 files) | REQ-TSCP-ANALYZER-BUILTINS |
| ✓ | TSCP-ANALYZER-CLI | CLI subcommands and rich diagnostic formatter | 13 | 13 | 6 | 7 | 100% | src 0% (0 files) | REQ-TSCP-ANALYZER-CLI |
| ✓ | TSCP-ANALYZER-EXTENSIBILITY | Analyzers from any provenance compose uniformly via `analyzers: [...]` | 5 | 5 | 4 | 1 | 100% | src 100% (1 file) | REQ-TSCP-ANALYZER-EXTENSIBILITY |
| ✓ | TSCP-ANALYZER-DIAGNOSTIC | Diagnostic carries start/end and related locations; diagFromNode populates positions | 7 | 7 | 6 | 1 | 100% | src 100% (1 file) | REQ-TSCP-ANALYZER-DIAGNOSTIC |
| ✓ | TSCP-ANALYZER-LIFECYCLE | Analyzer is read-only and runs post-fixpoint pre-commit; errors abort commit | 6 | 6 | 6 | 0 | 100% | src 100% (2 files) | REQ-TSCP-ANALYZER-LIFECYCLE |
| ✓ | TSCP-ANALYZER-PERF | Per-analyzer execution timings observable; threshold warning | 5 | 5 | 4 | 1 | 100% | src 100% (1 file) | REQ-TSCP-ANALYZER-PERF |
| ✓ | TSCP-ANALYZER-SUPPRESSION | In-source `@sourcegen-disable-next-line` directives suppress analyzer diagnostics per-node | 6 | 6 | 6 | 0 | 100% | src 100% (2 files) | REQ-TSCP-ANALYZER-SUPPRESSION |
| ✓ | TSCP-ANALYZER-TESTKIT | Sub-export `./testing` provides scaffolding helpers and assertAnalyzerContract | 9 | 9 | 9 | 0 | 100% | src 100% (4 files) | REQ-TSCP-ANALYZER-TESTKIT |
| ✓ | TSCP-ANALYZER-WATCH | Watch mode includes analyzers in every cycle by default | 3 | 3 | 0 | 3 | 100% | src 0% (0 files) | REQ-TSCP-ANALYZER-WATCH |
| ✓ | TSCP-ANALYZER-WRAPPERS | Decorator-pattern wrappers adjust analyzers at composition root | 6 | 6 | 6 | 0 | 100% | src 100% (2 files) | REQ-TSCP-ANALYZER-WRAPPERS |
| ✓ | TSCP-ATOMIC-COMMIT | At fixpoint, commit virtFS to disk atomically per file with stale-output purge | 8 | 8 | 8 | 0 | 100% | src 100% (3 files) | REQ-TSCP-ATOMIC-COMMIT, REQ-TSCP-STRICTLY-ADDITIVE-EMISSION |
| ✓ | TSCP-DRIFT-DETECTION | `sourcegen verify` detects hand-edits and regeneration drift via banner contentHash | 7 | 7 | 7 | 0 | 100% | src 100% (3 files) | REQ-TSCP-WRITE-ONCE |
| ✓ | TSCP-FIXPOINT-TERMINATION | Runner converges to a fixpoint or fails at MAX_ITERATIONS, with deterministic ordering | 8 | 8 | 8 | 0 | 100% | src 100% (5 files) | REQ-TSCP-FIXPOINT-LOOP, REQ-TSCP-ITERATION-CAP, REQ-TSCP-DETERMINISTIC-ORDERING |
| ✓ | TSCP-DECORATOR-DISCOVERY | @Generate is found by identifier name; multi-stacking and AST-target filtering are first-class | 12 | 12 | 12 | 0 | 100% | src 100% (6 files) | REQ-TSCP-DECORATOR-BY-NAME |
| ✓ | TSCP-GENERATED-FILE-BANNER | Every emitted file carries a banner with producer / iteration / SHA-256 contentHash | 8 | 8 | 8 | 0 | 100% | src 100% (2 files) | REQ-TSCP-WRITE-ONCE, REQ-TSCP-DETERMINISTIC-ORDERING |
| ✓ | TSCP-INCREMENTAL-DELTA | GenerationContext.delta exposes a read-only view of recent virtFS activity for incremental processing | 5 | 5 | 5 | 0 | 100% | src 100% (2 files) | REQ-TSCP-INCREMENTAL-DELTA-VISIBILITY |
| ✓ | TSCP-STRICTLY-ADDITIVE-EMISSION | Generators may only write inside outDir; addSource rejects path traversal with SG0040 | 9 | 9 | 9 | 0 | 100% | src 100% (2 files) | REQ-TSCP-STRICTLY-ADDITIVE-EMISSION, REQ-TSCP-EMISSION-PATH-VALIDATION |
| ✓ | TSCP-VIRTUAL-FS-ISOLATION | VirtFs isolates the disk during iterations and enforces idempotent / divergence-aware addSource | 8 | 8 | 8 | 0 | 100% | src 100% (2 files) | REQ-TSCP-VIRTFS-ISOLATION, REQ-TSCP-DIVERGENCE-DIAGNOSTIC |
| ✓ | TSCP-WATCH-MODE | CLI `watch` re-runs sourcegen on src/spec changes, ignores generated outputs, serializes runs | 10 | 10 | 10 | 0 | 100% | src 100% (2 files) | REQ-TSCP-WATCH-MODE |
Features: 19 active
Acceptance criteria: 143/143 ACs covered (100%)
Total tests linked to ACs: 261 (legacy .js tests not yet migrated to .ts are unlinked)
Runtime coverage warnings: 0
Unbound features: 0
Critical uncovered: 0
Orphan source files: 0
Quality gate: PASSLifecycle, 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 at C:\code\FrenchExDev\FrenchExDev_i2\Net\FrenchExDev\Requirements 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";
}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();
}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);
}
}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>()
),
// …
};
}// 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 — a Roslyn diagnostic analyzer reserving the diagnostic-ID range REQ100–REQ302: orphan Features, uncovered critical ACs, lifecycle violations, missing provenance, mismatched test levels. REQ100 is implemented today; REQ101–REQ302 are scaffolded for the rest of the rules. The point of doing it this way — analyzer, not external CLI — is that a developer sees the squiggle in their editor before they push. The compliance gate stops being a CI step you fight and becomes part of how you write the code.
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, IDE-surfaced REQ100–REQ302 analyzers, integrates with the rest of the .NET DSL framework |
| Compliance gate | npx requirements compliance --strict (CLI today; LSP-ready) |
Build-time analyzers + generated registry consumers |
| Output | requirements-bindings.json + traceability matrix + report renderers |
RequirementRegistry.g.cs + Roslyn diagnostics |
| Maturity | Production — dog-fooded; this CV site is its largest live consumer | Foundation shipped; analyzer suite REQ101–302 in progress |
Each side leans into its strength. TypeScript goes broad: a port-driven core that's a CLI today, an LSP server tomorrow, a VS Code extension after that, with refactoring (rename), authoring round-trips (bidi-sync), drift detection (versioning), and a scaffolder registry that knows how to wire vitest, Playwright, axe, pa11y, and Lighthouse — all without re-discovering each ecosystem's conventions. C# goes deep: the type system itself enforces structural correctness (no Feature<Story> ever ships), the generator emits a registry that downstream code consumes type-safely (RequirementRegistry.All), and Roslyn analyzers put compliance in the IDE, not in a separate dashboard.
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 TS library is in production. It's the requirements layer for this CV site (every Feature on the roadmap is wired to a test; the compliance gate runs on pre-push), and it's the prototype for what the C# version's tooling surface will eventually look like — the IDE-friendly bits (watch mode, rename, drift detection) are easier to iterate in TypeScript first.
The C# library has shipped the foundation: the generic-constrained hierarchy compiles, RequirementRegistryGenerator emits, the three attributes integrate with the wider FrenchExDev DSL framework via MetaConcept, and REQ100 lights up. The next pieces are the rest of the analyzer suite (REQ101–REQ302), the trace-graph emission (so the same traceability matrix the TS CLI produces can be generated from RequirementRegistry), and a Visual Studio / Rider extension that surfaces the analyzer diagnostics with quick-fixes — scaffold a missing test, add an EARS sentence, link to a requirement.
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.