Chapter 16 — Cross-Package Adoption — typed-fsm
Two packages, each depending on the other in prose. One arrow in the pnpm graph. The loop resolves because import-time precedes import-time.
Chapter 15 showed the seam from the requirements side: @frenchexdev/requirements turns each Feature's acceptance criteria into a typed FSM with @FiniteStateMachine from @frenchexdev/typed-fsm. Scenarii become states; setup / given / when / then / verify are the transitions; the result is a graph every acceptance criterion enters and the compliance scanner reads back.
This chapter walks the same seam from the other side. @frenchexdev/typed-fsm is itself a package with Requirements and Features. It declares them using @frenchexdev/requirements — the same @Satisfies, the same @Refines, the same Feature class, the same Requirement<DefaultStyleType>. Two packages, each naming the other.
The first reflex, seeing that shape, is to call it a circular dependency and look for the ladder out. There is no ladder out because there is no cycle. The arrow in the pnpm dependency graph goes one way — typed-fsm depends on requirements, requirements does not depend on typed-fsm. What loops is the authoring relation: requirements uses typed-fsm to model its scenarii, and typed-fsm uses requirements to declare its REQs and Features. Usage is not dependency. A painter can own a brush the brush-maker later borrows to sketch the painter's portrait; the brush-maker's workshop does not import the painter's studio.
This chapter draws the one arrow, counts the four REQs and seven Features on typed-fsm's side, names the eighth Feature that sits outside the REQ-Feature graph for good reason, and closes with what the cross-adoption proves about the DSL's Open-Closed properties. Chapter 17 widens the pattern to the other thirteen packages in the monorepo; this chapter is the first adoption in detail.
The mutual dependency, made literal
Open packages/typed-fsm/package.json. The block that matters:
{
"name": "@frenchexdev/typed-fsm",
"dependencies": {
"@frenchexdev/requirements": "workspace:*",
"commander": "catalog:"
},
"peerDependencies": {
"typescript": ">=5.4"
},
"devDependencies": {
"typescript": "catalog:",
"vitest": "catalog:",
"@vitest/coverage-v8": "catalog:"
}
}{
"name": "@frenchexdev/typed-fsm",
"dependencies": {
"@frenchexdev/requirements": "workspace:*",
"commander": "catalog:"
},
"peerDependencies": {
"typescript": ">=5.4"
},
"devDependencies": {
"typescript": "catalog:",
"vitest": "catalog:",
"@vitest/coverage-v8": "catalog:"
}
}Three facts to read off this file.
First, @frenchexdev/requirements sits under dependencies, not devDependencies. It is a runtime dependency. When a consumer installs @frenchexdev/typed-fsm, they also install @frenchexdev/requirements, and the installed bundle resolves symbols like Feature, Satisfies, and Requirement against the requirements package at import time. The typed-fsm shipped bundle does not fork or inline the DSL types; it re-imports them.
Second, the specifier is workspace:*. pnpm resolves this to a symlink into packages/requirements/ during development, and to the real published version when the package is built for the registry. The two packages live in the same monorepo, on the same branch, advancing in lockstep.
Third, there is no @frenchexdev/typed-fsm entry in packages/requirements/package.json's dependencies or devDependencies block. Requirements does not depend on typed-fsm — not at runtime, not at dev-time. The scenario-to-FSM seam from chapter 15 is documentary: the requirements package describes how scenarii map to @FiniteStateMachine shapes, but it does not import the typed-fsm runtime to do so. The mapping is a specification that typed-fsm's extractors later consume; the requirements package ships without any typed-fsm code inside it.
These three facts together make the graph a DAG:
@frenchexdev/requirements (leaf — no @frenchexdev/* dependencies)
▲
│ dependencies.@frenchexdev/requirements = "workspace:*"
│
@frenchexdev/typed-fsm (consumer — imports Feature, Satisfies, Requirement, …)@frenchexdev/requirements (leaf — no @frenchexdev/* dependencies)
▲
│ dependencies.@frenchexdev/requirements = "workspace:*"
│
@frenchexdev/typed-fsm (consumer — imports Feature, Satisfies, Requirement, …)One arrow. A DAG of depth two. pnpm's topological install order is unambiguous: requirements first, typed-fsm second. TypeScript's project-references resolution is unambiguous: requirements's dist/ must exist before typed-fsm's compiler emits. Vercel's cold build is unambiguous: build requirements, then build typed-fsm.
The "circular" appearance comes only from prose. If you say requirements uses typed-fsm (for scenarii) and typed-fsm uses requirements (for REQs and Features) in the same breath, the sentence sounds like it names two arrows. It names one arrow and one usage pattern. The arrow is at the package boundary; the usage is inside requirements's own spec files, in chapter 15's scenario-to-FSM mapping, which is a specification that a later build artefact consumes, not a dependency that the installer resolves.
A small test that makes the difference visible: pnpm -w why @frenchexdev/typed-fsm from the requirements package returns nothing — requirements does not depend on typed-fsm. pnpm -w why @frenchexdev/requirements from the typed-fsm package returns the workspace symlink. One direction. The installer sees a tree.
This is not a fancy trick. It is the default shape a well-scoped monorepo produces when the generic piece (the DSL) precedes the specific piece (the FSM framework) in build order, and the specific piece also happens to model its own specs in the generic piece. The self-application does not create a cycle; it creates a second use of an already-installed package from within the package that depends on it.
The rest of the chapter looks at exactly what typed-fsm authors with that second use.
typed-fsm's 4 REQs
typed-fsm ships with four Requirement classes under packages/typed-fsm/requirements/requirements/. They are named with the prefix REQ-TFSM- to avoid collision with the requirements package's own REQ-* ids, and every one of them extends Requirement<DefaultStyleType> — the ISO/IEC/IEEE 29148 + Volere + EARS register introduced in chapter 08. No Industrial, no Agile, no Kanban style for this package; the vocabulary is engineering-workmanlike, and DefaultStyle is the honest match.
The four are:
REQ-TFSM-DECORATOR-FIRST-FSM— root, Constraint kind.REQ-TFSM-AST-EXTRACTION-DETERMINISTIC— Functional, @Refines the root.REQ-TFSM-CROSS-FSM-CONSISTENCY— Functional, @Refines the root.REQ-TFSM-REVERSE-TRACEABILITY— Functional, @Refines the root.
Three refinements of one root Constraint. That shape — one parent, three children, no siblings at the top — is the smallest possible tree that shows the refinement graph doing work. It is also the natural shape for a small, focused package: one policy that defines the package's reason to exist, three consequences that make that policy operational.
REQ-TFSM-DECORATOR-FIRST-FSM — the root Constraint
The root is short and absolute:
Every state machine in the workspace shall be declared by annotating a TypeScript class with
@FiniteStateMachine({ features: [...] }), typed against state/event type parameters, and no alternative serialisation format (YAML, XML, JSON, string enums) shall be accepted as the source of truth.
Kind: Constraint. Priority: High. Status: Approved. Pattern: ubiquitous — the EARS pattern that applies without trigger or condition.
Read the rationale carefully. It is not a prejudice against YAML. It is a claim about three properties (refactoring safety, determinism of extraction, co-location with domain types) that parallel formats "systematically break". Every counter-example cited in the rationale is of the form: if you add a YAML FSM definition, you open a drift channel between the YAML and the code; the TS compiler cannot catch the drift; the extractor becomes non-deterministic because it must reconcile two sources. The Constraint kind is correct — this is not a Functional goal (produce X on input Y), it is a prohibition on the shape of the package itself.
The assumption is stated: "the host workspace uses TypeScript with decorators enabled (tsconfig experimentalDecorators or ES decorators)". That is the only concession to pluralism — the Constraint is absolute within workspaces that satisfy the assumption, mute outside them.
Fit criteria are two: (a) every @FiniteStateMachine-decorated class is extractable via extractStateMachines() into a canonical StateMachineGraph without ambiguity; (b) no YAML/XML/JSON FSM definition files are committed to the repo outside generated artefacts. Criterion (a) is enforced by the extractor tests (chapter 15's scenarii; this package's STATE-MACHINE-EXTRACTOR Feature, 136 ACs). Criterion (b) is a repo-scan gate — grep for FSM-shaped JSON/YAML siblings of decorated classes, fail if any appear. The two together triangulate the Constraint: the language forbids alternatives, and the tooling measures compliance.
Risk High: "parallel FSM formats diverge from the code over time; refactoring breaks silently; traceability from FSM → Feature → AC → Test rots; the whole @frenchexdev/* stack loses its ground truth." The risk names the exact failure mode the Constraint exists to prevent, and it names it at the level of the whole stack, not just this package. That is the signal the root has its weight right — it guards something that matters beyond its immediate authorship.
REQ-TFSM-AST-EXTRACTION-DETERMINISTIC — the determinism Functional
The first refinement reads:
Given identical source files, the chain
@FiniteStateMachinedescriptor attachment →extractStateMachinesAST scan →inferTransitions(JSDoc / canTransition / AST) shall produce byte-identicalStateMachineGraphoutput, with no reliance on runtime reflection, filesystem walk order, or environment state.
Kind: Functional. Priority: High. @Refines(ReqTfsmDecoratorFirstFsmRequirement) — the parent link, drawn in code with the @Refines decorator imported from @frenchexdev/requirements.
The rationale:
Determinism is the pre-condition for caching, diffing, and trustworthy traceability reports. A non-deterministic extractor makes every downstream artefact (composition graph, audit, blast radius, compliance report) unstable and un-reviewable.
Read this as a reviewer's claim, not an implementer's claim. The thing determinism buys is reviewability. If the graph produced by extractStateMachines differs between two runs on the same source tree, a reviewer looking at a diff of the graph sees noise and cannot tell which lines are real change and which are ordering artefacts. The Requirement does not just ask for "correct output"; it asks for byte-identical output, because anything short of byte-identical pollutes the review surface.
Fit criteria:
- property tests on
extractStateMachines+inferTransitionsassert idempotence and order-independence across 100+ fast-check runs - two runs on the same input produce identical
StateMachineGraphJSON (deep-equal)
The first criterion is the strong one — fast-check generates random permutations of input order and asserts invariance. The second is the integration-level sanity check — run twice, compare JSON. Both are quality gates the compliance scanner can verify; both land in tests on the STATE-MACHINE-EXTRACTOR and FSM-TRANSITION-INFERRER Features.
Verification method: Test. Source: { type: 'stakeholder', role: 'project-rule', date: '2026-04-14' }. Mitigations named: AST-only extractor (no runtime reflection), sort before emit, property-based tests. The last three are implementation disciplines; they appear in the rationale because the Requirement is a promise about the shape of the implementation, not just about its output.
The @Refines relationship to the root is semantic, not syntactic inheritance. ReqTfsmAstExtractionDeterministicRequirement does not inherit the root's abstract fields; it declares its own (id, title, etc.). The @Refines decorator attaches a metadata edge that the compliance scanner walks. The root's absolute prohibition on parallel formats is the reason determinism is even achievable — if there were two sources of truth, determinism would require solving a merge problem first. The refinement says: given the root's constraint (one source of truth), this Requirement commits to a specific observable consequence (byte-identical output across runs).
REQ-TFSM-CROSS-FSM-CONSISTENCY — the global-audit Functional
The second refinement widens scope from within-one-FSM to across-all-FSMs:
The union of all
@FiniteStateMachinedescriptors shall form a consistent graph: everyfeatures: [...]reference resolves to an existing Feature + AC, every@Emitsevent that is listened to by another FSM is observable in the composition graph, and every@Listensevent has at least one emitter in the workspace.
Three clauses, one conjunction. Each clause names an invariant that cannot be checked by looking at one FSM in isolation — each requires reading the whole workspace to decide.
The rationale states this explicitly:
Individual FSMs can be locally correct and globally broken: a dangling Feature reference, a phantom event, or a one-sided emits/listens pair corrupts the traceability graph without any single FSM looking suspect in isolation. Cross-FSM invariants must be audited centrally.
This is the Requirement that drives three of typed-fsm's seven public Features — FSM-AUDIT, FSM-COMPOSITION, and EVENT-TOPOLOGY. Those three features share one @Satisfies(ReqTfsmCrossFsmConsistencyRequirement) edge in their source — the convergence shown on the bipartite diagram later in the chapter. The same Requirement explains why the package ships a central audit binary (npx typed-fsm trace diff and friends) rather than scattering the checks into per-FSM tests: the invariants are workspace-level; the audit must be too.
Fit criteria:
auditFsmLinksreports zerounknownFeat/unknownAcacross the workspacedetectDriftreports zero orphan emits and zero orphan listens
The two criteria map to two of the three public analysis entry points on the ./analysis surface: auditFsmLinks (from FSM-AUDIT) and detectDrift (from EVENT-TOPOLOGY). The third clause — @Emits ↔ composition-graph observability — is discharged by FSM-COMPOSITION, which materialises the edge set the drift detector later reads.
Risk level High: "cross-FSM drift accumulates invisibly; event-driven features silently break; the composition graph shown to reviewers misrepresents the actual runtime coupling." Again the risk is framed at the level of what the graph shows, not just what the code does. Misrepresentation in review is the failure mode named first; silent runtime breakage is the second. This is a package built by someone who reads diffs for a living.
REQ-TFSM-REVERSE-TRACEABILITY — the developer-tool Functional
The third refinement is the only one with an event-driven EARS pattern — the first two are ubiquitous, this one has a trigger:
When a developer invokes
typed-fsm trace line <file:line>or passes a git diff totrace diff, the tool shall resolve the enclosing symbol from the AST, walk upstream to the FSM(s) it participates in, the Feature(s) they link to, and the AC(s) covered — and downstream to the tests that verify those ACs — producing a deterministic, reviewable report without relying on runtime heuristics.
The trigger is a human command. The response is a traversal. The constraint at the end ("without relying on runtime heuristics") echoes the root: no runtime reflection, no probabilistic matching, no fuzzy resolution. Everything must come from the AST.
The rationale:
Reverse traceability turns the four-tier chain from a specification artefact into a daily reviewer tool: "touching this line breaks these ACs, covered by these tests". Without it, the chain is write-only — built during specification, never consulted during change.
This is the Requirement that justifies the CLI surface. Without it, typed-fsm would be a library that ships extractStateMachines and nothing else; the work of building a reviewer tool on top would live downstream. Because the Requirement is in the package, the CLI is too, and the REVERSE-CORE Feature (48 ACs, Priority.Critical) is the concrete delivery.
Fit criteria:
trace line <file:line>returns a symbol + FSM + AC list for every line inside a decorated class methodtrace diffaggregates the blast radius across a multi-file git diff without duplicate entries
Two commands, two criteria, one Feature. The Requirement and its satisfying Feature have near-parallel surface areas — rare, and a good sign: when a Requirement has exactly one satisfier and the satisfier's ACs map to the Requirement's fit criteria, the traceability is densest and the review surface smallest.
Risk: "reviewers lack a mechanical way to assess change impact; the traceability chain becomes shelfware; 'does this PR break an AC?' reverts to tribal knowledge." The shelfware risk is the one this Requirement exists to prevent. "Shelfware" is the word the CLAUDE.md of adjacent packages uses to describe specifications that are written once and never read. The Requirement is a commitment against that fate.
The four, as a tree
REQ-TFSM-DECORATOR-FIRST-FSM (Constraint, root)
├─ REQ-TFSM-AST-EXTRACTION-DETERMINISTIC (Functional, @Refines)
├─ REQ-TFSM-CROSS-FSM-CONSISTENCY (Functional, @Refines)
└─ REQ-TFSM-REVERSE-TRACEABILITY (Functional, @Refines)REQ-TFSM-DECORATOR-FIRST-FSM (Constraint, root)
├─ REQ-TFSM-AST-EXTRACTION-DETERMINISTIC (Functional, @Refines)
├─ REQ-TFSM-CROSS-FSM-CONSISTENCY (Functional, @Refines)
└─ REQ-TFSM-REVERSE-TRACEABILITY (Functional, @Refines)Depth two, branching three. Every child is Functional; the root is Constraint. This is the shape the refinement graph encourages: one high-level rule, multiple concrete commitments. If a fifth Requirement is added, it should either refine the root (expanding the package's Functional surface) or refine one of the children (specialising an existing commitment). Adding a sibling of the root — a second unrefined Requirement — would be a signal that the package is growing a second core concern, and should probably be split.
The fact that the four-REQ tree mirrors the three analysis axes of typed-fsm (determinism, cross-FSM consistency, reverse traceability) is intentional. These are the three things the package promises; each is a Functional commitment; all three are consequences of the root Constraint. The REQ tree is not a historical accident — it is the shape that falls out of writing the package's reason-to-exist down in the DSL.
typed-fsm's 7 FEATs (and the eighth)
The packages/typed-fsm/requirements/features/ folder contains eight files — seven Features with @Satisfies edges to at least one REQ-TFSM-*, and one Feature (smoke.ts) that has no @Satisfies by design. The seven REQ-bound Features are the typed-fsm DSL surface; the eighth is the CLI smoke gate and lives outside the requirement graph on purpose.
The seven (in the order they appear in requirements/features/):
EVENT-TOPOLOGY—@Satisfies(ReqTfsmCrossFsmConsistencyRequirement)— Priority.High — 16 ACs.FINITE-STATE-MACHINE—@Satisfies(ReqTfsmAstExtractionDeterministicRequirement)— Priority.Critical — 10 ACs.FSM-AUDIT—@Satisfies(ReqTfsmCrossFsmConsistencyRequirement)— Priority.High — 11 ACs.FSM-COMPOSITION—@Satisfies(ReqTfsmCrossFsmConsistencyRequirement)— Priority.High — 22 ACs.FSM-TRANSITION-INFERRER—@Satisfies(ReqTfsmAstExtractionDeterministicRequirement)— Priority.Critical — 54 ACs.REVERSE-CORE—@Satisfies(ReqTfsmReverseTraceabilityRequirement)— Priority.Critical — 48 ACs.STATE-MACHINE-EXTRACTOR—@Satisfies(ReqTfsmAstExtractionDeterministicRequirement)— Priority.Critical — 136 ACs.
And the eighth:
TYPED-FSM-SMOKE— no@Satisfies— Priority.High — 3 ACs.
Seven Features, one @Satisfies edge each, three distinct Requirement targets, 297 ACs. Plus the unanchored smoke triad. Let me walk through them in the order that makes the dependency shape legible rather than alphabetical.
FINITE-STATE-MACHINE — the decorator infrastructure
@Satisfies(ReqTfsmAstExtractionDeterministicRequirement)
export abstract class FiniteStateMachineFeature extends Feature {
readonly id = 'FINITE-STATE-MACHINE';
readonly title = '@FiniteStateMachine decorator + descriptor reflection (FSM_SYMBOL)';
readonly priority = Priority.Critical;
// ── FiniteStateMachine decorator ──
abstract attachesDescriptorOnClassConstructor(): ACResult;
abstract returnsTheTargetClassUnchanged(): ACResult;
abstract storesDescriptorUnderFsmSymbol(): ACResult;
abstract descriptorIsNonEnumerable(): ACResult;
abstract descriptorIsNonWritable(): ACResult;
abstract descriptorIsNonConfigurable(): ACResult;
abstract preservesDescriptorShape(): ACResult;
// ── getFsmDescriptor ──
abstract returnsAttachedDescriptor(): ACResult;
abstract returnsUndefinedForUndecoratedClass(): ACResult;
// ── FSM_SYMBOL ──
abstract fsmSymbolIsAUniqueSymbol(): ACResult;
}@Satisfies(ReqTfsmAstExtractionDeterministicRequirement)
export abstract class FiniteStateMachineFeature extends Feature {
readonly id = 'FINITE-STATE-MACHINE';
readonly title = '@FiniteStateMachine decorator + descriptor reflection (FSM_SYMBOL)';
readonly priority = Priority.Critical;
// ── FiniteStateMachine decorator ──
abstract attachesDescriptorOnClassConstructor(): ACResult;
abstract returnsTheTargetClassUnchanged(): ACResult;
abstract storesDescriptorUnderFsmSymbol(): ACResult;
abstract descriptorIsNonEnumerable(): ACResult;
abstract descriptorIsNonWritable(): ACResult;
abstract descriptorIsNonConfigurable(): ACResult;
abstract preservesDescriptorShape(): ACResult;
// ── getFsmDescriptor ──
abstract returnsAttachedDescriptor(): ACResult;
abstract returnsUndefinedForUndecoratedClass(): ACResult;
// ── FSM_SYMBOL ──
abstract fsmSymbolIsAUniqueSymbol(): ACResult;
}Ten ACs. The decorator itself (@FiniteStateMachine), the reader helper (getFsmDescriptor), and the symbol constant (FSM_SYMBOL). This is the smallest Feature in line-count terms, and every downstream Feature in the package reads or depends on what this Feature produces.
The @Satisfies(ReqTfsmAstExtractionDeterministicRequirement) edge is interesting — the decorator itself does not perform extraction, but the determinism property of extraction depends on the decorator storing its data once, non-enumerably, and non-writably. Mutable descriptors would break determinism. So the link is to the Determinism Requirement, not to the root Constraint directly. This is an idiom: leaf Features satisfy specific Functional Requirements; the root Constraint is reached transitively via @Refines from the children.
Three of the ten ACs (descriptorIsNonEnumerable, descriptorIsNonWritable, descriptorIsNonConfigurable) are triple-axis redundancy about the same property — the descriptor must be immutable and invisible. Triple-specification is not over-engineering here; the three JavaScript property-descriptor flags measure three slightly different guarantees, and the Determinism Requirement depends on all three. The compliance scanner sees three separate AC methods that must be covered by three separate test methods; the redundancy is spec-level, not test-level.
STATE-MACHINE-EXTRACTOR — the dense Feature
136 ACs. The densest Feature in the package, and by some margin the densest in the monorepo.
@Satisfies(ReqTfsmAstExtractionDeterministicRequirement)
export abstract class StateMachineExtractorFeature extends Feature {
readonly id = 'STATE-MACHINE-EXTRACTOR';
readonly title = 'State machine extractor — pure library that turns TS source into a StateMachineGraph';
readonly priority = Priority.Critical;
// ── parseSource / hasFiniteStateMachineDecorator ──
abstract parseSourceReturnsSourceFile(): ACResult;
abstract detectsFiniteStateMachineCallDecorator(): ACResult;
abstract detectsFiniteStateMachineBareIdentifierDecorator(): ACResult;
// … 133 more
}@Satisfies(ReqTfsmAstExtractionDeterministicRequirement)
export abstract class StateMachineExtractorFeature extends Feature {
readonly id = 'STATE-MACHINE-EXTRACTOR';
readonly title = 'State machine extractor — pure library that turns TS source into a StateMachineGraph';
readonly priority = Priority.Critical;
// ── parseSource / hasFiniteStateMachineDecorator ──
abstract parseSourceReturnsSourceFile(): ACResult;
abstract detectsFiniteStateMachineCallDecorator(): ACResult;
abstract detectsFiniteStateMachineBareIdentifierDecorator(): ACResult;
// … 133 more
}The file header comment explains the authoring rule:
One AC = one observable behavior that contributes to 100% line+branch coverage.
That rule is the reason for 136. An AST extractor has a very wide input surface — decorators with and without arguments, union-type states with as const and satisfies unwrapping, variable declarations with non-identifier names, exports of types and interfaces and functions, bare identifier decorators, decorator-like strings in non-decorator positions, and so on — and each distinct input branch is a separate AC if it is to be directly testable. The density is high because the code is a compiler-shaped thing; compilers have many small branches.
The @Satisfies(ReqTfsmAstExtractionDeterministicRequirement) edge is the only one. This Feature is the heart of the Determinism Requirement — if the extractor is deterministic, the Requirement is (largely) satisfied; if it is not, the Requirement fails regardless of anything else in the package. The density is proportional to the stakes: the root Requirement's first fit criterion is extractable via extractStateMachines() without ambiguity, and unambiguity at this surface requires naming every branch.
The ACs cluster into ten sections, visible in the section comments:
parseSource/hasFiniteStateMachineDecorator(7 ACs)isMachineSource(4 ACs)filterMachineSources(2 ACs)loadLibSources(3 ACs)extractMachineFromSource: exports + functions + union states(12 ACs)extractMachineFromSource: decorator data(16 ACs)extractMachineFromSource: transitions via decorator(15 ACs)unwrap as const / satisfies(2 ACs)extractTransitions(inference) (18 ACs)helpers detection edge cases(2 ACs)buildMachineExportIndex(2 ACs)extractMachineCompositions(6 ACs)extractEmitsFromSource(9 ACs)extractListensFromSource(10 ACs)extractAdaptersFromSource(17 ACs)buildGraph(6 ACs)
Sixteen sections, 136 methods. Each section names a small function, each method names one branch. The compliance scanner sees a flat list; the reader sees a well-organised index. The section comments are not syntactic, but they are what makes 136 ACs approachable.
FSM-TRANSITION-INFERRER — the three-strategy composer
54 ACs. The second-densest Feature.
@Satisfies(ReqTfsmAstExtractionDeterministicRequirement)
export abstract class FsmTransitionInferrerFeature extends Feature {
readonly id = 'FSM-TRANSITION-INFERRER';
readonly title = 'FSM transitions inferrer — JSDoc diagram parser, canTransition switch, AST combiner';
readonly priority = Priority.Critical;
// ── parseJsDocDiagram ── (11 ACs)
// ── parseCanTransition ── (12 ACs)
// ── combineCanTransitionWithAst ── (4 ACs)
// ── selectBestTransitions ── (7 ACs)
// ── renderTransitionsArray ── (3 ACs)
// ── patchDecorator ── (6 ACs)
// (methods elided — 54 total)
}@Satisfies(ReqTfsmAstExtractionDeterministicRequirement)
export abstract class FsmTransitionInferrerFeature extends Feature {
readonly id = 'FSM-TRANSITION-INFERRER';
readonly title = 'FSM transitions inferrer — JSDoc diagram parser, canTransition switch, AST combiner';
readonly priority = Priority.Critical;
// ── parseJsDocDiagram ── (11 ACs)
// ── parseCanTransition ── (12 ACs)
// ── combineCanTransitionWithAst ── (4 ACs)
// ── selectBestTransitions ── (7 ACs)
// ── renderTransitionsArray ── (3 ACs)
// ── patchDecorator ── (6 ACs)
// (methods elided — 54 total)
}Three strategies for inferring FSM transitions from source:
- Parse the JSDoc mermaid-style diagram (
A --> B : event). - Parse a
canTransition(from, to)switch statement. - Walk AST assignments (
this.state = 'X').
A composer (selectBestTransitions) picks the best per method, preferring the canTransition switch when present, falling back to JSDoc known arrows, resolving JSDoc bare arrows with AST methods, and finally falling back to AST. A renderer (renderTransitionsArray) emits the selected transitions as a TypeScript array literal. A patcher (patchDecorator) edits the source file to inject the rendered block into the @FiniteStateMachine decorator's argument.
The Feature is @Satisfies(ReqTfsmAstExtractionDeterministicRequirement) — same parent as the extractor. The Determinism Requirement spans both, because both participate in the extraction chain: extractor produces the graph skeleton, inferrer fills in the transition edges, both must be byte-identical on identical input.
The three-strategy shape is itself a response to a reality the Requirement implies: FSMs in the wild are authored in three styles (JSDoc-first, switch-first, assignment-first), and a deterministic extractor must handle all three without producing different outputs for stylistically different but semantically identical inputs. The composer (selectBestTransitions) is the disambiguator that makes determinism hold across the three authoring styles.
FSM-AUDIT — the first convergent Feature
@Satisfies(ReqTfsmCrossFsmConsistencyRequirement)
export abstract class FsmAuditFeature extends Feature {
readonly id = 'FSM-AUDIT';
readonly title = 'FSM audit — verify FSM links are well-formed';
readonly priority = Priority.High;
// ── auditFsmLinks ──
abstract emptyMachinesReturnsEmptyReport(): ACResult;
abstract machineWithNoFeaturesArrayGoesToUnlinked(): ACResult;
abstract machineWithEmptyFeaturesArrayGoesToUnlinked(): ACResult;
abstract machineWithUnknownFeatureIdGoesToUnknownFeat(): ACResult;
abstract machineWithKnownFeatureButUnknownAcGoesToUnknownAc(): ACResult;
abstract machineWithFullyValidLinkGoesNowhere(): ACResult;
abstract machineCanHaveMultipleFeatureLinks(): ACResult;
// ── isFullyLinked ──
abstract isFullyLinkedTrueOnAllEmpty(): ACResult;
abstract isFullyLinkedFalseWhenAnyUnlinked(): ACResult;
abstract isFullyLinkedFalseWhenAnyUnknownFeat(): ACResult;
abstract isFullyLinkedFalseWhenAnyUnknownAc(): ACResult;
}@Satisfies(ReqTfsmCrossFsmConsistencyRequirement)
export abstract class FsmAuditFeature extends Feature {
readonly id = 'FSM-AUDIT';
readonly title = 'FSM audit — verify FSM links are well-formed';
readonly priority = Priority.High;
// ── auditFsmLinks ──
abstract emptyMachinesReturnsEmptyReport(): ACResult;
abstract machineWithNoFeaturesArrayGoesToUnlinked(): ACResult;
abstract machineWithEmptyFeaturesArrayGoesToUnlinked(): ACResult;
abstract machineWithUnknownFeatureIdGoesToUnknownFeat(): ACResult;
abstract machineWithKnownFeatureButUnknownAcGoesToUnknownAc(): ACResult;
abstract machineWithFullyValidLinkGoesNowhere(): ACResult;
abstract machineCanHaveMultipleFeatureLinks(): ACResult;
// ── isFullyLinked ──
abstract isFullyLinkedTrueOnAllEmpty(): ACResult;
abstract isFullyLinkedFalseWhenAnyUnlinked(): ACResult;
abstract isFullyLinkedFalseWhenAnyUnknownFeat(): ACResult;
abstract isFullyLinkedFalseWhenAnyUnknownAc(): ACResult;
}11 ACs. The first of three Features that all satisfy REQ-TFSM-CROSS-FSM-CONSISTENCY. The audit's job is to classify each FSM's link state into one of four buckets: unlinked, unknownFeat, unknownAc, or fully linked. The Requirement's first fit criterion — auditFsmLinks reports zero unknownFeat / unknownAc across the workspace — is verified by the seven auditFsmLinks ACs directly.
The four classification buckets come from four distinct failure modes, each with a specific repair:
- unlinked — the FSM has no
features: [...]at all. Repair: add a link. - unknownFeat — the FSM references a Feature id that does not exist. Repair: fix the id or add the Feature.
- unknownAc — the FSM references a Feature that exists, but an AC name that the Feature does not declare. Repair: fix the AC name or add the abstract method.
- fully linked — nothing to repair.
This is the smallest enumeration that covers the repair space. No fifth bucket.
FSM-COMPOSITION — the second convergent Feature
22 ACs.
@Satisfies(ReqTfsmCrossFsmConsistencyRequirement)
export abstract class FsmCompositionFeature extends Feature {
readonly id = 'FSM-COMPOSITION';
readonly title = 'FSM composition graph — D3-ready event coupling between machines';
readonly priority = Priority.High;
// … 22 methods across buildCompositionGraph (machines / adapters / edges) + summarize
}@Satisfies(ReqTfsmCrossFsmConsistencyRequirement)
export abstract class FsmCompositionFeature extends Feature {
readonly id = 'FSM-COMPOSITION';
readonly title = 'FSM composition graph — D3-ready event coupling between machines';
readonly priority = Priority.High;
// … 22 methods across buildCompositionGraph (machines / adapters / edges) + summarize
}Turns state-machines.json (the output of the extractor) into a D3-ready composition graph. Machines, adapters, edges. Edges carry labels from event names when present, falling back to an edge kind otherwise. The summarize helper counts nodes and edges, groups by kind, counts event-participating nodes and feature-linked nodes, and handles the empty-graph case.
The Feature's job inside the Cross-FSM Consistency Requirement is materialisation: it produces the graph that the drift detector then reads. Without it, detectDrift would have nothing to check. The @Satisfies edge points to the same Requirement as FSM-AUDIT and EVENT-TOPOLOGY; the three together discharge the Requirement's three clauses (feature-reference resolution, emits-listens symmetry, composition-graph observability).
EVENT-TOPOLOGY — the third convergent Feature
16 ACs.
@Satisfies(ReqTfsmCrossFsmConsistencyRequirement)
export abstract class EventTopologyFeature extends Feature {
readonly id = 'EVENT-TOPOLOGY';
readonly title = 'Custom event topology — emits/listens drift detection';
readonly priority = Priority.High;
// ── extractEmits, extractListens, detectDrift,
// renderEventMap, regenerateEventMap
}@Satisfies(ReqTfsmCrossFsmConsistencyRequirement)
export abstract class EventTopologyFeature extends Feature {
readonly id = 'EVENT-TOPOLOGY';
readonly title = 'Custom event topology — emits/listens drift detection';
readonly priority = Priority.High;
// ── extractEmits, extractListens, detectDrift,
// renderEventMap, regenerateEventMap
}The drift detector proper. detectDrift flags events that are listened to but not emitted, and events that are emitted but not listened to, in both directions. renderEventMap renders a table (headers + rows, columns for emitted and listened participation) that lands in data/event-map.md. regenerateEventMap is the filesystem orchestrator that aggregates all sources, skips missing paths, and writes the output.
The Feature's presence means typed-fsm does not merely detect drift — it renders a report that a reviewer can read. The output is not a boolean; it is a table. That is the reviewer-oriented shape the CROSS-FSM-CONSISTENCY Requirement's risk statement calls for ("the composition graph shown to reviewers misrepresents the actual runtime coupling" — the graph and the drift table together are what prevent the misrepresentation).
REVERSE-CORE — the reverse-traceability Feature
48 ACs. The largest single @Satisfies edge to REQ-TFSM-REVERSE-TRACEABILITY.
@Satisfies(ReqTfsmReverseTraceabilityRequirement)
export abstract class ReverseCoreFeature extends Feature {
readonly id = 'REVERSE-CORE';
readonly title = 'Reverse trace — line→symbol→FSM→ACs→tests, blast radius from git diff';
readonly priority = Priority.Critical;
// ── symbolAtLine (9 ACs)
// ── extractTestLinkAtLine (10 ACs)
// ── reverseTrace (17 ACs)
// ── computeBlastRadius (5 ACs)
// ── renderReverse (6 ACs)
// ── renderBlastRadius (4 ACs)
}@Satisfies(ReqTfsmReverseTraceabilityRequirement)
export abstract class ReverseCoreFeature extends Feature {
readonly id = 'REVERSE-CORE';
readonly title = 'Reverse trace — line→symbol→FSM→ACs→tests, blast radius from git diff';
readonly priority = Priority.Critical;
// ── symbolAtLine (9 ACs)
// ── extractTestLinkAtLine (10 ACs)
// ── reverseTrace (17 ACs)
// ── computeBlastRadius (5 ACs)
// ── renderReverse (6 ACs)
// ── renderBlastRadius (4 ACs)
}Six functions, 48 ACs. The Feature is the full implementation of the Reverse Traceability Requirement's two commands:
typed-fsm trace line <file:line>— usessymbolAtLine→reverseTrace→renderReverse.typed-fsm trace diff— uses the chainparseDiff→computeBlastRadius→renderBlastRadius.
reverseTrace is the middle layer — given a symbol, it finds the FSMs that participate, reads ACs from the FSM-Feature links, walks the bindings manifest for extra ACs, reads test decorators (@FeatureTest + @Verifies), expands test-class bodies to sibling ACs, collects matching tests, and flags ACs without tests as risks. Seventeen ACs for one function, because the function has seventeen distinct observable behaviours.
The @Satisfies(ReqTfsmReverseTraceabilityRequirement) edge is one-to-one — this Feature is the sole satisfier of that Requirement. A reader tracing REQ-TFSM-REVERSE-TRACEABILITY in the compliance report lands here and only here. The one-to-one relation is the tightest fit the @Satisfies edge can express, and it is appropriate: the Requirement names a single command surface; one Feature implements that surface.
The three Critical Features
A quick side-observation on Priority. Three of the seven REQ-bound Features are Priority.Critical:
FINITE-STATE-MACHINE(the decorator infrastructure)STATE-MACHINE-EXTRACTOR(the extraction engine)FSM-TRANSITION-INFERRER(the transition inferrer)REVERSE-CORE(the reverse-trace tool)
Four Critical, actually. All four are on the primary data path of the package: either they produce the graph, or they read it from a user-facing CLI. The other three Features (FSM-AUDIT, FSM-COMPOSITION, EVENT-TOPOLOGY) are Priority.High — they consume the graph for audit and rendering, but the package could still ship without them (with reduced utility). The Critical/High split is the minimal Priority partition this package needs.
The eighth — TYPED-FSM-SMOKE
export abstract class SmokeFeature extends Feature {
readonly id = 'TYPED-FSM-SMOKE';
readonly title = 'CLI bin smoke tests — sub-command happy paths';
readonly priority = Priority.High;
abstract binPrintsHelpOnHelpFlag(): ACResult;
abstract traceHelpListsLineAndDiff(): ACResult;
abstract traceLineWithInvalidArgFailsGracefully(): ACResult;
}export abstract class SmokeFeature extends Feature {
readonly id = 'TYPED-FSM-SMOKE';
readonly title = 'CLI bin smoke tests — sub-command happy paths';
readonly priority = Priority.High;
abstract binPrintsHelpOnHelpFlag(): ACResult;
abstract traceHelpListsLineAndDiff(): ACResult;
abstract traceLineWithInvalidArgFailsGracefully(): ACResult;
}Three ACs. No @Satisfies decorator. No import of any REQ-TFSM-* class. The comment above the class: "Smoke tests for the typed-fsm CLI bin."
The absence of @Satisfies is deliberate. Smoke tests do not exist to satisfy a Requirement; they exist to fail loudly when the bin is fundamentally broken (the entry point does not start, --help does not print, an invalid argument does not produce a non-zero exit code). These are pre-conditions for any Requirement to even be meaningfully testable. Putting them under a REQ-TFSM-* edge would misrepresent what they check.
The compliance scanner, encountering a Feature with no @Satisfies, does not flag it as an orphan — it is understood to be a deliberate non-satisfier. The convention is that a Feature with no @Satisfies must have the word smoke or a similar marker in its id; conversely, a Feature with a non-smoke id and no @Satisfies is flagged as an orphan (a Feature that exists without a Requirement is a requirements gap, the way chapter 00 named it).
Seven plus one. Seven carry policy loyalty; one is scaffolding.
The Satisfies edge map
Consolidating the seven @Satisfies edges:
REQ-TFSM-AST-EXTRACTION-DETERMINISTIC
├─ FINITE-STATE-MACHINE (Critical, 10 ACs)
├─ STATE-MACHINE-EXTRACTOR (Critical, 136 ACs)
└─ FSM-TRANSITION-INFERRER (Critical, 54 ACs)
REQ-TFSM-CROSS-FSM-CONSISTENCY
├─ FSM-AUDIT (High, 11 ACs)
├─ FSM-COMPOSITION (High, 22 ACs)
└─ EVENT-TOPOLOGY (High, 16 ACs)
REQ-TFSM-REVERSE-TRACEABILITY
└─ REVERSE-CORE (Critical, 48 ACs)
REQ-TFSM-DECORATOR-FIRST-FSM (root, reached via @Refines from the three above)
(no direct @Satisfies edges)
(no parent)
└─ TYPED-FSM-SMOKE (High, 3 ACs)REQ-TFSM-AST-EXTRACTION-DETERMINISTIC
├─ FINITE-STATE-MACHINE (Critical, 10 ACs)
├─ STATE-MACHINE-EXTRACTOR (Critical, 136 ACs)
└─ FSM-TRANSITION-INFERRER (Critical, 54 ACs)
REQ-TFSM-CROSS-FSM-CONSISTENCY
├─ FSM-AUDIT (High, 11 ACs)
├─ FSM-COMPOSITION (High, 22 ACs)
└─ EVENT-TOPOLOGY (High, 16 ACs)
REQ-TFSM-REVERSE-TRACEABILITY
└─ REVERSE-CORE (Critical, 48 ACs)
REQ-TFSM-DECORATOR-FIRST-FSM (root, reached via @Refines from the three above)
(no direct @Satisfies edges)
(no parent)
└─ TYPED-FSM-SMOKE (High, 3 ACs)Observations visible from the consolidation:
- Three of the four REQs have satisfiers. The fourth (the root Constraint) has none — it is reached only transitively via
@Refines, which is the correct shape for a policy that is enacted by refinements rather than delivered by Features. - The three REQs that have satisfiers have 3, 3, and 1 Features respectively. The 3-3-1 partition is asymmetric — the Reverse Traceability Requirement is discharged by a single dense Feature; the Determinism and Cross-FSM Consistency Requirements are each discharged by three Features that share the load.
- Every Critical Feature targets either the Determinism Requirement (3 Critical Features) or the Reverse Traceability Requirement (1 Critical Feature). Every High Feature targets the Cross-FSM Consistency Requirement. Priority clusters with Requirement — another spec-level regularity.
- Total AC count on REQ-bound Features: 10 + 136 + 54 + 11 + 22 + 16 + 48 = 297. Plus 3 on TYPED-FSM-SMOKE = 300. The package's CLAUDE.md lists 317 ACs; the 17-AC difference belongs to internal helper Features not covered here (or to counting conventions that include inherited ACs).
How the dev-time cross-adoption works
The adoption is, in one sentence: packages/typed-fsm/ imports from @frenchexdev/requirements the same way any consumer would, and uses those imports to author .ts files under packages/typed-fsm/requirements/.
Three imports cover the entire authoring surface. For a Feature file:
import { Feature, Priority, Satisfies, type ACResult } from '@frenchexdev/requirements';
import { ReqTfsmAstExtractionDeterministicRequirement } from '../requirements/req-tfsm-ast-extraction-deterministic.js';
@Satisfies(ReqTfsmAstExtractionDeterministicRequirement)
export abstract class FiniteStateMachineFeature extends Feature {
readonly id = 'FINITE-STATE-MACHINE';
readonly title = '@FiniteStateMachine decorator + descriptor reflection (FSM_SYMBOL)';
readonly priority = Priority.Critical;
abstract attachesDescriptorOnClassConstructor(): ACResult;
// …
}import { Feature, Priority, Satisfies, type ACResult } from '@frenchexdev/requirements';
import { ReqTfsmAstExtractionDeterministicRequirement } from '../requirements/req-tfsm-ast-extraction-deterministic.js';
@Satisfies(ReqTfsmAstExtractionDeterministicRequirement)
export abstract class FiniteStateMachineFeature extends Feature {
readonly id = 'FINITE-STATE-MACHINE';
readonly title = '@FiniteStateMachine decorator + descriptor reflection (FSM_SYMBOL)';
readonly priority = Priority.Critical;
abstract attachesDescriptorOnClassConstructor(): ACResult;
// …
}For a Requirement file:
import { Requirement, Priority, Refines } from '@frenchexdev/requirements';
import type { DefaultStyleType } from '@frenchexdev/requirements';
import { ReqTfsmDecoratorFirstFsmRequirement } from './req-tfsm-decorator-first-fsm.js';
@Refines(ReqTfsmDecoratorFirstFsmRequirement)
export abstract class ReqTfsmAstExtractionDeterministicRequirement extends Requirement<DefaultStyleType> {
readonly id = 'REQ-TFSM-AST-EXTRACTION-DETERMINISTIC';
// …
}import { Requirement, Priority, Refines } from '@frenchexdev/requirements';
import type { DefaultStyleType } from '@frenchexdev/requirements';
import { ReqTfsmDecoratorFirstFsmRequirement } from './req-tfsm-decorator-first-fsm.js';
@Refines(ReqTfsmDecoratorFirstFsmRequirement)
export abstract class ReqTfsmAstExtractionDeterministicRequirement extends Requirement<DefaultStyleType> {
readonly id = 'REQ-TFSM-AST-EXTRACTION-DETERMINISTIC';
// …
}Two imports for Features, two imports for Requirements, one intra-package import for the parent class. No typed-fsm-specific Feature or Requirement subtype. No local re-export wrapper. The DSL ships generic; typed-fsm uses it generic.
The compliance gate is the second half of the adoption. From packages/typed-fsm/, the command
npx requirements compliance --strictnpx requirements compliance --strictwalks packages/typed-fsm/requirements/ (the spec directory), reads every Feature and Requirement class, resolves every @Satisfies and @Refines edge, checks that every FSM referenced in a Feature's AC maps to an existing Feature + AC, checks that every test decorated with @FeatureTest and @Verifies targets a real AC on a real Feature, generates a compliance report, and exits non-zero if any gate fails.
The binary is @frenchexdev/requirements's own requirements CLI — the same binary the requirements package runs on itself. Typed-fsm does not ship a variant or fork; it invokes the sibling package's bin over its own spec files. The command does not know it is running against a sibling package. It reads requirements.config.json (typed-fsm ships one), walks the declared spec directory, and reports.
This is the test that the DSL is truly generic. If the compliance binary needed package-specific knowledge — if the requirements package had to know about typed-fsm to validate it — the adoption would be a fork, not a reuse. The fact that one binary validates both packages, with no package-specific branches in the binary, is the positive signal.
The gate runs as part of typed-fsm's normal test cycle. packages/typed-fsm/scripts/test.sh (or the equivalent root-level pnpm -F @frenchexdev/typed-fsm test) invokes vitest with coverage, then invokes npx requirements compliance --strict, and only reports success if both pass. The gate is cheap (sub-second on this spec size) and strict (zero orphans, zero unlinked tests, zero missing ACs).
What the adoption does not require
Three absences worth naming, because they are features of the DSL:
No new decorator. typed-fsm does not add a
@TypedFsmSatisfiesor@TypedFsmRefines. The generic@Satisfiesand@Refinescarry the FSM-domain information without any customisation.No new base class. typed-fsm's Features do not extend a
TypedFsmFeatureparent; they extendFeaturedirectly. Its Requirements extendRequirement<DefaultStyleType>directly. No intermediary.No new compliance binary. typed-fsm does not ship its own
typed-fsm-compliance— it usesrequirements compliance. (The typed-fsmbindoes shiptrace lineandtrace diff, but those are FSM-domain tools, not compliance tools; they belong toREQ-TFSM-REVERSE-TRACEABILITY, not to the general compliance surface.)
All three absences would be present if the DSL were domain-coupled. The fact that none of them are present is the measurable content of the claim the DSL is pluggable.
What the cross-adoption proves
Three claims are discharged by the typed-fsm adoption.
First: the DSL is domain-generic. Nothing in Feature, Requirement, @Satisfies, @Refines, Priority, or ACResult assumes a specific domain. The requirements package's own Features are about traceability CLI commands; typed-fsm's Features are about AST extractors and CLI trace tools; both extend the same base classes with zero modification. A third domain (CMS rendering, state-machine composition, build pipeline orchestration — each of these is a separate package in the monorepo) can adopt the same base classes identically.
This is the Open-Closed Principle applied at the DSL level. Open for extension: new domains adopt the DSL freely. Closed for modification: the requirements package does not change when typed-fsm adopts it. The requirements package has not changed for typed-fsm's sake. The only change was in typed-fsm: a devDependencies that became dependencies, two imports, a requirements/ folder.
Second: the compliance binary is domain-blind. npx requirements compliance --strict, run from any package that has a requirements.config.json and a requirements/ spec directory with features/ and requirements/ subfolders, produces a valid compliance report. The binary does not know which package it is running against. It reads package.json for the package name, but the name is only used for report titling — no branch of the binary's logic depends on which package is being compliance-checked.
This is the measurable shape of dog-food, but not self-reflection. The binary validates itself and it validates typed-fsm, using the same code paths for both. If the binary had a self-branch — "if we are validating ourselves, behave specially" — the property would be weaker. There is no such branch.
Third: the monorepo's authoring discipline is uniform. Every package in the @frenchexdev/* set (thirteen more beyond requirements and typed-fsm, as of this writing) is expected to follow the same pattern:
requirements/requirements/holds Requirement classes.requirements/features/holds Feature classes.requirements.config.jsondeclares paths and options.package.jsonincludes@frenchexdev/requirementsindependencies(if Features/Requirements are runtime-referenced from shipped code) ordevDependencies(if they are spec-only).- Every test uses
@FeatureTest+@Verifies, neverdescribe+it.
The pattern's cost is low (two imports, one folder, one config file). The pattern's value is high (uniform compliance, uniform traceability, one binary across all packages). Chapter 17 walks the other thirteen adoptions and shows the pattern recurring — with minor variations in Style choice, REQ count, and Feature granularity, but the same skeleton.
The typed-fsm adoption is the first concrete demonstration that this is a pattern, not a property of one package. One adoption could be coincidence; two adoptions already describe a method. The remaining adoptions (chapter 17) make the method a monorepo-wide convention.
Diagram 1 — typed-fsm package graph
Read the diagram as three solid arrows and one dashed arrow.
The solid arrows go from typed-fsm to requirements. Three of them: (a) TFSM_SRC depends on REQ_SRC via the dependencies: workspace:* entry in package.json; (b) TFSM_SPEC imports Requirement classes from REQ_SRC to pass as arguments to @Satisfies / @Refines; (c) TFSM_SPEC is validated by REQ_BIN — the compliance binary from the requirements package reads typed-fsm's spec folder and produces a report.
The dashed arrow goes from requirements to typed-fsm. One of them: REQ_SRC describes how scenarii map to @FiniteStateMachine shapes — but this is a specification relation, not a dependency. The requirements package's package.json contains no entry for typed-fsm. At install time, the dashed arrow does not exist.
pnpm's topological install order is: requirements first (no dependencies), typed-fsm second (one dependency: requirements). A reader who takes the dashed arrow at face value and calls the graph cyclic has confused the prose description of two packages referring to each other with the machine-verified dependency graph. The graph is a DAG. The loop is social.
Diagram 2 — typed-fsm's REQ/FEAT bipartite
The diagram makes three shapes visible.
First, the @Refines spine on the left. One root, three children, all @Refines edges drawn as dashed lines to signal they are refinements rather than direct satisfactions. The root has no @Satisfies edges from any Feature — it is reached only transitively. That is correct for a Constraint-kind root.
Second, the @Satisfies convergences in the middle. The Determinism Requirement receives three edges; the Cross-FSM Consistency Requirement receives three edges; the Reverse Traceability Requirement receives one edge. The 3-3-1 partition is the package's delivery shape: two Requirements share their load across three Features each, one Requirement is fully carried by a single dense Feature.
Third, the unanchored TYPED-FSM-SMOKE at the bottom right. No edge connects it to any Requirement. The dashed border signals that its unanchored state is deliberate; the compliance scanner's rule for smoke-prefixed Features is "ignore if the id contains SMOKE". This is the only Feature in the package without a Requirement edge, and its exception is documented at both the code level (comment in smoke.ts) and the tooling level (scanner rule).
A reader who counts the edges gets: 3 @Refines edges (Requirement → root), 7 @Satisfies edges (Feature → Requirement), 0 edges on the smoke Feature. The total — 10 edges across 12 nodes — is small enough to read at a glance and large enough to carry the package's full spec.
Running-example recap
FEATURE-TRACE-EXPLORER-TUI — the running example of this series — has a typed-fsm-side neighbour in the global traceability graph. That neighbour is REVERSE-CORE.
The TUI's end-to-end AC is:
abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;This AC is tested by driving the TUI through a scenario that navigates from a Requirement node to a Feature node to an AC node to a Test node. The navigation logic — given a source location, what is its enclosing symbol, its FSM, its Features, its ACs, its tests? — is provided by REVERSE-CORE's reverseTrace function. The TUI is a consumer of REVERSE-CORE's output.
The chain, end to end:
- The TUI user presses a key on a Requirement node.
- The TUI calls a typed-fsm API that eventually invokes
reverseTrace(symbol)under the hood. reverseTracewalks FSM-Feature links, reads the bindings manifest, and returns{ symbol, fsms, acs, tests, risks }.- The TUI renders the result in the right-hand pane.
- The user presses Enter on an AC, and the TUI re-invokes
reverseTraceon the symbol that declared that AC. - The chain continues to tests.
Chapter 15 showed the TUI's Feature side. This chapter shows the typed-fsm side of the same cross-package collaboration. The chain that the TUI's end-to-end AC exercises spans four @frenchexdev/* packages, and two of them — requirements and typed-fsm — are the two whose relation this chapter describes.
The end-to-end AC verifies, among other things, that the trace produced by reverseTrace is consistent with what the compliance scanner would produce for the same symbol. That consistency is the running thread: the graph is one graph; two tools read the same edges; both produce reports that match. If they ever diverge, the REVERSE-CORE Feature's tests fail, REQ-TFSM-REVERSE-TRACEABILITY goes unsatisfied, and the compliance gate blocks the merge.
What chapter 17 will do
Chapter 17 widens the frame. Instead of one cross-adoption, it walks the other thirteen packages in the monorepo and shows the recurring shape:
- Each package has a
requirements/folder. - Each folder contains
REQ-*and Feature classes that importFeature,Requirement,Satisfies,Refines,Priority, andACResultfrom@frenchexdev/requirements. - Each package ships a
requirements.config.json. - Each package's test script runs
npx requirements compliance --strict. - Style choice varies (some use
DefaultStyleType, someAgileStyleType, one usesIndustrialStyleTypefor a safety-adjacent subsystem). - REQ / Feature counts vary from 1 REQ + 2 Features (the smallest adopters) to 6 REQs + 14 Features (the largest).
The pattern is the same. The variations are parametric. Chapter 17 tabulates them.
Related Reading
- Chapter 15 — Scenarii Become FSMs — the requirements side of the same seam: acceptance scenarii expressed as
@FiniteStateMachineshapes typed-fsm can extract. - Chapter 17 — Cross-Package Adoption — Monorepo Sweep — the thirteen-package widening of the pattern demonstrated here on one.
- frenchexdev-patterns/ — the monorepo-wide design patterns index, where the DSL-adoption shape joins port-driven analysis, source-generator ontologies, and workspace catalogue imports.
- finite-state-machine.md — the standalone
@FiniteStateMachinedecorator reference, for the decorator's own surface without the adoption framing. - Chapter 00 — Named but Not Modelled — the series opener; the claim typed-specs named but did not model a Requirement type is the claim this chapter's REQ-TFSM-* tree is one particular answer to.