laymos 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +740 -0
- package/dist/cli/cli.d.ts +3 -0
- package/dist/cli/cli.d.ts.map +1 -0
- package/dist/cli/cli.js +6 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/run.d.ts +11 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +242 -0
- package/dist/config/define-config.d.ts +3 -0
- package/dist/config/define-config.d.ts.map +1 -0
- package/dist/config/define-config.js +228 -0
- package/dist/config/index.d.ts +5 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +4 -0
- package/dist/config/layer-graph.d.ts +7 -0
- package/dist/config/layer-graph.d.ts.map +1 -0
- package/dist/config/layer-graph.js +53 -0
- package/dist/config/layer.d.ts +5 -0
- package/dist/config/layer.d.ts.map +1 -0
- package/dist/config/layer.js +21 -0
- package/dist/config/module.d.ts +9 -0
- package/dist/config/module.d.ts.map +1 -0
- package/dist/config/module.js +29 -0
- package/dist/config/path.d.ts +4 -0
- package/dist/config/path.d.ts.map +1 -0
- package/dist/config/path.js +32 -0
- package/dist/config/types.d.ts +36 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +1 -0
- package/dist/engine/1-extract/extract.d.ts +12 -0
- package/dist/engine/1-extract/extract.d.ts.map +1 -0
- package/dist/engine/1-extract/extract.js +160 -0
- package/dist/engine/1-extract/index.d.ts +3 -0
- package/dist/engine/1-extract/index.d.ts.map +1 -0
- package/dist/engine/1-extract/index.js +1 -0
- package/dist/engine/2-resolve/index.d.ts +3 -0
- package/dist/engine/2-resolve/index.d.ts.map +1 -0
- package/dist/engine/2-resolve/index.js +1 -0
- package/dist/engine/2-resolve/resolve.d.ts +24 -0
- package/dist/engine/2-resolve/resolve.d.ts.map +1 -0
- package/dist/engine/2-resolve/resolve.js +74 -0
- package/dist/engine/3-evaluate/evaluate.d.ts +10 -0
- package/dist/engine/3-evaluate/evaluate.d.ts.map +1 -0
- package/dist/engine/3-evaluate/evaluate.js +80 -0
- package/dist/engine/3-evaluate/index.d.ts +3 -0
- package/dist/engine/3-evaluate/index.d.ts.map +1 -0
- package/dist/engine/3-evaluate/index.js +1 -0
- package/dist/engine/4-emit/emit.d.ts +7 -0
- package/dist/engine/4-emit/emit.d.ts.map +1 -0
- package/dist/engine/4-emit/emit.js +77 -0
- package/dist/engine/4-emit/index.d.ts +2 -0
- package/dist/engine/4-emit/index.d.ts.map +1 -0
- package/dist/engine/4-emit/index.js +1 -0
- package/dist/engine/errors.d.ts +19 -0
- package/dist/engine/errors.d.ts.map +1 -0
- package/dist/engine/errors.js +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/node.d.ts +24 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +139 -0
- package/dist/report/architecture.d.ts +104 -0
- package/dist/report/architecture.d.ts.map +1 -0
- package/dist/report/architecture.js +1 -0
- package/dist/report/index.d.ts +3 -0
- package/dist/report/index.d.ts.map +1 -0
- package/dist/report/index.js +2 -0
- package/dist/report/stories.d.ts +22 -0
- package/dist/report/stories.d.ts.map +1 -0
- package/dist/report/stories.js +1 -0
- package/dist/story/artifact/artifact.d.ts +57 -0
- package/dist/story/artifact/artifact.d.ts.map +1 -0
- package/dist/story/artifact/artifact.js +234 -0
- package/dist/story/artifact/index.d.ts +2 -0
- package/dist/story/artifact/index.d.ts.map +1 -0
- package/dist/story/artifact/index.js +1 -0
- package/dist/story/artifact/storage.d.ts +10 -0
- package/dist/story/artifact/storage.d.ts.map +1 -0
- package/dist/story/artifact/storage.js +162 -0
- package/dist/story/artifact/types.d.ts +74 -0
- package/dist/story/artifact/types.d.ts.map +1 -0
- package/dist/story/artifact/types.js +1 -0
- package/dist/story/core/blocks.d.ts +20 -0
- package/dist/story/core/blocks.d.ts.map +1 -0
- package/dist/story/core/blocks.js +104 -0
- package/dist/story/core/declare.d.ts +63 -0
- package/dist/story/core/declare.d.ts.map +1 -0
- package/dist/story/core/declare.js +62 -0
- package/dist/story/core/index.d.ts +4 -0
- package/dist/story/core/index.d.ts.map +1 -0
- package/dist/story/core/index.js +1 -0
- package/dist/story/core/recorder.d.ts +38 -0
- package/dist/story/core/recorder.d.ts.map +1 -0
- package/dist/story/core/recorder.js +28 -0
- package/dist/story/core/runtime-context.d.ts +14 -0
- package/dist/story/core/runtime-context.d.ts.map +1 -0
- package/dist/story/core/runtime-context.js +19 -0
- package/dist/story/core/types.d.ts +21 -0
- package/dist/story/core/types.d.ts.map +1 -0
- package/dist/story/core/types.js +1 -0
- package/dist/story/effect/effect.d.ts +52 -0
- package/dist/story/effect/effect.d.ts.map +1 -0
- package/dist/story/effect/effect.js +99 -0
- package/dist/story/effect/index.d.ts +3 -0
- package/dist/story/effect/index.d.ts.map +1 -0
- package/dist/story/effect/index.js +1 -0
- package/dist/story/runner/index.d.ts +3 -0
- package/dist/story/runner/index.d.ts.map +1 -0
- package/dist/story/runner/index.js +1 -0
- package/dist/story/runner/runner.d.ts +44 -0
- package/dist/story/runner/runner.d.ts.map +1 -0
- package/dist/story/runner/runner.js +476 -0
- package/dist/story/story-runtime/index.d.ts +2 -0
- package/dist/story/story-runtime/index.d.ts.map +1 -0
- package/dist/story/story-runtime/index.js +1 -0
- package/dist/story/story-runtime/story-runtime.d.ts +52 -0
- package/dist/story/story-runtime/story-runtime.d.ts.map +1 -0
- package/dist/story/story-runtime/story-runtime.js +194 -0
- package/dist/story/vanilla/index.d.ts +29 -0
- package/dist/story/vanilla/index.d.ts.map +1 -0
- package/dist/story/vanilla/index.js +78 -0
- package/package.json +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
# Laymos
|
|
2
|
+
|
|
3
|
+
**Layers. Modules. Stories.**
|
|
4
|
+
|
|
5
|
+
One config that declares your architecture. From it: enforcement rules, and the diagram — the same artifact. The picture you show in onboarding is provably true, because it's generated from the thing that's enforced.
|
|
6
|
+
|
|
7
|
+
The pitch in one line: **"Declare your architecture once; get enforcement and the diagram for free."**
|
|
8
|
+
|
|
9
|
+
Successor to `depcruise-viz`. Retired with it: stacks, features, barrels, feature closure.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## The Problem
|
|
14
|
+
|
|
15
|
+
Every team draws an architecture diagram. Every team writes lint rules. They drift apart. The diagram in the wiki lies; the rules in the repo are unreadable. Laymos makes them one thing.
|
|
16
|
+
|
|
17
|
+
There are three pillars, each answering a different question:
|
|
18
|
+
|
|
19
|
+
- **Layers** — what are the big boundaries, and who may import whom?
|
|
20
|
+
- **Modules** — within a boundary, what are the units, and how do they connect?
|
|
21
|
+
- **Stories** — when code actually _runs_, what path does it take?
|
|
22
|
+
|
|
23
|
+
Layers and modules are static. Stories are runtime. Together: intent, structure, behavior.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Part 1: Layers
|
|
28
|
+
|
|
29
|
+
### What a layer is
|
|
30
|
+
|
|
31
|
+
A layer is a **set of folders or files**.
|
|
32
|
+
|
|
33
|
+
- Paths are **plain prefixes** — a path names a file or a directory subtree. No glob patterns; scattered folders are handled by listing multiple paths.
|
|
34
|
+
- Nested paths: **longest matching prefix wins.** Disjointness follows by construction — the only invalid overlap is two layers declaring the identical path, a config error.
|
|
35
|
+
- Layers are **explicit**. No implicit layers, no "open" layers that everything can import. If a layer is importable by all, every edge is drawn.
|
|
36
|
+
|
|
37
|
+
_Why:_ explicitness is the product. Implicit permissions are exactly the ambiguity laymos exists to remove.
|
|
38
|
+
|
|
39
|
+
### The layer graph
|
|
40
|
+
|
|
41
|
+
A layer graph is a **DAG** of layers.
|
|
42
|
+
|
|
43
|
+
- An edge means **"may import."**
|
|
44
|
+
- **No edge = forbidden.** Siblings without an edge cannot import each other.
|
|
45
|
+
- Reachability is **transitive**: if A→B and B→C, then A may import C.
|
|
46
|
+
- Cycles in a graph → config error, validated at config time.
|
|
47
|
+
|
|
48
|
+
_Why transitive:_ it matches the general flow of software — a root can reach its descendants. We chose relaxed layering over strict layering to avoid noisy edge declarations.
|
|
49
|
+
|
|
50
|
+
_Why DAG, not tree:_ a "domain" layer importable by many parents means multiple incoming edges. That's a DAG. A tree can't express it.
|
|
51
|
+
|
|
52
|
+
### Multiple graphs, one truth
|
|
53
|
+
|
|
54
|
+
A project can define **multiple layer graphs** — e.g. one for frontend, one for backend.
|
|
55
|
+
|
|
56
|
+
- A layer **may appear in more than one graph**. There is no special "shared layer" kind — just layers, sometimes used in multiple graphs.
|
|
57
|
+
- The **union of all graphs must be acyclic.** A→B in one graph and B→A in another is a config error. So is any longer cycle formed across graphs.
|
|
58
|
+
- Rules are generated from the **union**. Graphs are how you organize and communicate; the union is what's enforced.
|
|
59
|
+
- Reachability follows that union, including paths whose edges are organized into different graphs. Each graph remains a focused view; together they declare the complete architecture.
|
|
60
|
+
|
|
61
|
+
### Scale
|
|
62
|
+
|
|
63
|
+
Target: **under 10 layers per graph**, slightly above 10 in the extreme. Layers are the bird's-eye view. Hundreds of layers means you're doing modules' job with layers.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Part 2: Modules
|
|
68
|
+
|
|
69
|
+
### What a module is
|
|
70
|
+
|
|
71
|
+
A module is a **file or a folder**.
|
|
72
|
+
|
|
73
|
+
- Modules are **strictly flat** — no module inside another module.
|
|
74
|
+
- A module lives in exactly one layer: the **longest layer prefix containing its path**. Inferred from the path, never declared.
|
|
75
|
+
- A module cannot straddle a layer boundary — no layer path may sit inside a module's path (validated structurally, at config time).
|
|
76
|
+
- A layer itself can be a module.
|
|
77
|
+
|
|
78
|
+
_Why flat:_ nesting modules recreates the hierarchy problem layers already solve. One level of granularity per pillar.
|
|
79
|
+
|
|
80
|
+
### Module rules are opt-in
|
|
81
|
+
|
|
82
|
+
Defining a module surfaces **no errors by itself**. Modules default-allow; layers default-deny. This asymmetry is deliberate and worth stating loudly:
|
|
83
|
+
|
|
84
|
+
- **Layers:** drawing the graph _is_ writing the rules. Absence of an edge forbids.
|
|
85
|
+
- **Modules:** defining modules is organization. Rules are opted into per module.
|
|
86
|
+
|
|
87
|
+
_Why:_ layers are few and architectural — full intent is cheap to declare. Modules are many — forcing rules on all of them would make adoption impossible.
|
|
88
|
+
|
|
89
|
+
### The two module rules
|
|
90
|
+
|
|
91
|
+
A module may declare constraints on its edges, in either direction:
|
|
92
|
+
|
|
93
|
+
- **`canImport: [...]`** — this module may only import the listed modules. Disciplines a _consumer_.
|
|
94
|
+
- **`canImportedBy: [...]`** — only the listed modules may import this one. Protects a _provider_.
|
|
95
|
+
|
|
96
|
+
We kept both because neither is expressible with only the other — dropping one means scattering inverted rules across every other module. It's one concept ("module constraints") with two fields, not two rule systems.
|
|
97
|
+
|
|
98
|
+
Resolution law: **AND semantics, deny wins.** An import A→B is legal iff A's outgoing rule (if configured) allows B _and_ B's incoming rule (if configured) allows A. An unconfigured side has no opinion. Nothing can loosen; rules only tighten.
|
|
99
|
+
|
|
100
|
+
Intra-module imports (files within the same module) always bypass module rules.
|
|
101
|
+
|
|
102
|
+
Module rules may constrain modules in the same layer or across layers. A
|
|
103
|
+
cross-layer import must satisfy both systems: the layer graph must permit it,
|
|
104
|
+
and both modules' configured constraints must permit it. Module rules can
|
|
105
|
+
tighten the layer graph but cannot grant permission that the graph denies.
|
|
106
|
+
|
|
107
|
+
### Layers and modules are decoupled
|
|
108
|
+
|
|
109
|
+
- **Layer rules bind every file in a layer**, module'd or not.
|
|
110
|
+
- **Module rules bind only module'd files.**
|
|
111
|
+
|
|
112
|
+
_Why:_ an earlier draft made layer enforcement route through modules ("only module'd files play layer rules"). That created a blind spot — layer enforcement, the core feature, would depend on adoption of the opt-in feature. Decoupling kills the blind spot and is also simpler: both rule passes run over the same file-level edge graph, aggregated to different units.
|
|
113
|
+
|
|
114
|
+
One import can violate both a layer rule and a module rule. Both are reported. No dedupe — they're different intents failing.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Part 3: Coverage & Ignore
|
|
119
|
+
|
|
120
|
+
Two independent coverage metrics, both **warnings, never errors**:
|
|
121
|
+
|
|
122
|
+
1. **Layer coverage** — of all files in the repo, how many belong to some layer? Uncovered files are flagged.
|
|
123
|
+
2. **Module coverage** — within a layer, how many files belong to some module?
|
|
124
|
+
|
|
125
|
+
_Why warnings:_ a brownfield repo cannot reach 100% before the first useful run. Coverage is the ratchet, not the gate. Coverage is shown per layer in the viz, nudging teams toward full module definition.
|
|
126
|
+
|
|
127
|
+
A layer with zero modules is valid config — just 0% module coverage.
|
|
128
|
+
|
|
129
|
+
The source inventory comes only from configured `sourceRoots`. Each root is a
|
|
130
|
+
project-relative file or directory; directories are traversed recursively.
|
|
131
|
+
Laymos analyzes JavaScript and TypeScript source files (`js`, `jsx`, `mjs`,
|
|
132
|
+
`cjs`, `ts`, `tsx`); declarations, minified files, and non-source assets are
|
|
133
|
+
outside the inventory. Git tracking and `.gitignore` never affect analysis.
|
|
134
|
+
|
|
135
|
+
**Ignore** is a single global set of folders/files, shared by layers and modules:
|
|
136
|
+
|
|
137
|
+
- Ignored means **invisible, not permitted** — no rules generated, no coverage warnings, imports to/from them unchecked.
|
|
138
|
+
- Layered code may freely import ignored files.
|
|
139
|
+
- Ignore beats layer paths on conflict. It is the escape hatch (generated code, composition roots, anything).
|
|
140
|
+
- Explicitly ignored files remain tagged as ignored in the report so the escape hatch is auditable, but their edges are removed and they do not count toward coverage.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Part 4: Enforcement & Execution
|
|
145
|
+
|
|
146
|
+
### The check contract
|
|
147
|
+
|
|
148
|
+
- **Config errors** (overlapping layers, union cycles, module straddling) → hard fail.
|
|
149
|
+
- **Rule violations** (layer or module) → fail. Lint errors.
|
|
150
|
+
- **Coverage** → warning. Never fails CI.
|
|
151
|
+
|
|
152
|
+
`import type` **counts as a violation.** Type-only imports cross boundaries at the type level; intent is enforced regardless of runtime erasure. (Reservation on record: shared contract types across frontend/backend — `import type { AppRouter }` — will pressure this rule. Escape hatch deferred; `ignore` covers it for now.)
|
|
153
|
+
|
|
154
|
+
### How checking actually works
|
|
155
|
+
|
|
156
|
+
[skott](https://github.com/antoine-coulon/skott) is used as an **extractor only** — run with type-only tracking always on, producing the full file-level import graph (it resolves ESM/CJS/`import type`/TS path aliases). Laymos's own engine owns all semantics:
|
|
157
|
+
|
|
158
|
+
**extract (skott) → resolve (files → layers/modules) → evaluate (rules) → emit (violations + viz data)**
|
|
159
|
+
|
|
160
|
+
_Why skott over dependency-cruiser_ (see ADR-0001): identical edge sets in a head-to-head POC, ~30% faster, far simpler graph API. _Why not transpile laymos config into an existing rule engine:_ our semantics (transitive reachability, sink layers, AND-gated module constraints) would compile into O(n²) regex pair-lists with foreign error messages and a transpiler to debug forever. Owning the engine means one evaluation produces both enforcement and visualization, with no drift between them.
|
|
161
|
+
|
|
162
|
+
### Violations are rich objects
|
|
163
|
+
|
|
164
|
+
Verdicts are module/layer-level; **evidence is file-level.** (Line numbers and per-edge import kind were considered and dropped — no extractor provides them; both are additive later. ADR-0001.)
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
{ kind: "layer", from: { layer, file }, to: { layer, file } }
|
|
168
|
+
{ kind: "module", rule: "canImport" | "canImportedBy",
|
|
169
|
+
from: { module, layer, file }, to: { module, layer, file } }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Config speaks modules; violations point at files. That's not an inconsistency — config is intent, evidence is fact.
|
|
173
|
+
|
|
174
|
+
### Config
|
|
175
|
+
|
|
176
|
+
One file: **`laymos.config.ts`**, typed default export built with reference builders — cross-references are values, not strings, so they autocomplete and survive renames. Cost accepted: laymos executes user code to read config.
|
|
177
|
+
|
|
178
|
+
`defineConfig` validates everything decidable from the config alone and throws with all issues at once. Source roots must be non-empty and non-overlapping;
|
|
179
|
+
every layer, module, and ignored path must fall within one. The loader
|
|
180
|
+
additionally warns — never errors — on declared paths that don't exist on disk:
|
|
181
|
+
declaring structure before its folder exists is legitimate intent-first design.
|
|
182
|
+
|
|
183
|
+
Every module used by a rule must also appear in `modules`, and each module may
|
|
184
|
+
have at most one `rules(...)` entry. Rule references must reuse those declared
|
|
185
|
+
module values rather than recreating the same path.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { defineConfig, edge, layer, layerGraph, module, rules } from 'laymos';
|
|
189
|
+
|
|
190
|
+
const ui = layer('ui', ['src/ui']);
|
|
191
|
+
const domain = layer('domain', ['src/domain']);
|
|
192
|
+
const data = layer('data', ['src/data']);
|
|
193
|
+
|
|
194
|
+
const billing = module('src/domain/billing');
|
|
195
|
+
const checkout = module('src/domain/checkout');
|
|
196
|
+
|
|
197
|
+
export default defineConfig({
|
|
198
|
+
sourceRoots: ['src'],
|
|
199
|
+
graphs: [layerGraph('app', [edge(ui, [domain, data]), edge(domain, data)])],
|
|
200
|
+
modules: [billing, checkout],
|
|
201
|
+
moduleRules: [rules(billing, { canImportedBy: [checkout] })],
|
|
202
|
+
ignore: ['src/generated'],
|
|
203
|
+
});
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Modules are declared and constrained in **two separate acts** (`module()` then `rules()`): mutually-referencing rules would otherwise hit the JS temporal dead zone between `const` bindings — and it mirrors the semantics, since declaring a module imposes nothing.
|
|
207
|
+
|
|
208
|
+
Configured source roots and paths are project-root-relative plain paths. `src/x` and `./src/x`
|
|
209
|
+
are equivalent; separators and redundant segments are normalized. Absolute
|
|
210
|
+
paths, escaping `..`, and glob syntax are rejected.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Part 5: Visualization (principles only — details deferred)
|
|
215
|
+
|
|
216
|
+
Two things are always shown, distinctly:
|
|
217
|
+
|
|
218
|
+
1. **Declared** — the rules you configured.
|
|
219
|
+
2. **Actual** — the real state of the codebase, from the extracted graph.
|
|
220
|
+
|
|
221
|
+
Per edge, three render states: **declared & used** (healthy), **declared & never used** (dead edge — candidate for tightening), **used & violating**. The allowed/used/violating triad is the product.
|
|
222
|
+
|
|
223
|
+
The viz payload is self-contained: the labeled file tree plus layer-level rollups, correlated by root-relative file paths — no joins against other outputs needed.
|
|
224
|
+
|
|
225
|
+
Graphs render side by side; a layer used across graphs spans them horizontally. Selecting a layer or module shows its actual edges — what it imports, what imports it — not just its rules. Rules are validations, never an exhaustive map; the actual state fills the rest.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Part 6: Stories
|
|
230
|
+
|
|
231
|
+
### The idea
|
|
232
|
+
|
|
233
|
+
Layers and modules are static intent. Stories capture **runtime behavior at the intent level** — a flowchart of what actually happened, where "do this" might be a thousand lines but is one node.
|
|
234
|
+
|
|
235
|
+
Not coverage tooling (istanbul knows _which lines_ ran, not _what they meant_). Not XState (we don't make the flowchart be the code — code stays code, the flowchart is derived; annotate what exists, no rewrite).
|
|
236
|
+
|
|
237
|
+
### The primitives — exactly three
|
|
238
|
+
|
|
239
|
+
- **`functionBlock(name, meta, fn)`** — a named function-boundary block that may contribute to any number of Stories. Its metadata must describe what the boundary means in the narrative.
|
|
240
|
+
- **`step(name, meta, fn)`** — a named inline block whose metadata must describe the action's narrative purpose.
|
|
241
|
+
- **`decision(name, meta, value)`** — a statement-first, expression-capable condition over a string, number, or boolean literal union. Each chained `when(literal, armMeta, fn)` declares one Arm and eagerly executes the matching branch. Decision and Arm metadata must describe the choice and consequence.
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
decision(
|
|
245
|
+
'fraud gate',
|
|
246
|
+
{ description: 'Reject high-risk orders' },
|
|
247
|
+
score > 0.7 ? 'rejected' : 'approved',
|
|
248
|
+
)
|
|
249
|
+
.when(
|
|
250
|
+
'approved',
|
|
251
|
+
{ name: 'Accept order', description: 'Continue to payment' },
|
|
252
|
+
() => processPayment(order),
|
|
253
|
+
)
|
|
254
|
+
.when(
|
|
255
|
+
'rejected',
|
|
256
|
+
{
|
|
257
|
+
name: 'Reject order',
|
|
258
|
+
description: 'Stop checkout before any payment is captured',
|
|
259
|
+
},
|
|
260
|
+
() => rejectOrder(order),
|
|
261
|
+
)
|
|
262
|
+
.exhaustive();
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Each `when` removes its literal from the remaining input union and narrows its
|
|
266
|
+
callback to that literal. `exhaustive()` is optional when the chain's result is
|
|
267
|
+
ignored; when present, it is callable only after the union is fully handled and
|
|
268
|
+
returns the selected branch's result. `otherwise(armMeta, fn)` declares one
|
|
269
|
+
fallback Arm, narrows its callback to the remaining union, and returns the
|
|
270
|
+
selected result. A non-exhaustive chain with no matching Arm does nothing, like
|
|
271
|
+
an `if` statement without an `else`. Literal Arms default their narrative name
|
|
272
|
+
to the textual form of their literal; the fallback defaults to `Otherwise`.
|
|
273
|
+
|
|
274
|
+
`laymos/story` provides `yield*`-able Effect builders. Arm callbacks return
|
|
275
|
+
Effects; the matching Effect is selected eagerly and runs when the builder is
|
|
276
|
+
yielded. `exhaustive()` remains an optional type-level proof (ADR-0002).
|
|
277
|
+
|
|
278
|
+
Active execution context propagates across Effect fibers. Sequential visits
|
|
279
|
+
append to the active execution path; visits that overlap while unfinished
|
|
280
|
+
become parallel branches. Each concurrent branch must
|
|
281
|
+
be spanned by a marked Block for laymos to reconstruct its nested sequence
|
|
282
|
+
reliably. A visit the recorder cannot unambiguously place — it starts while a
|
|
283
|
+
sibling visit is unfinished and no marked Block spans its branch — **fails the
|
|
284
|
+
Scenario's recording** with an error naming both blocks and their source
|
|
285
|
+
locations and the fix: wrap each concurrent branch in a block. Ambiguous
|
|
286
|
+
structure is never recorded best-effort; an artifact that finalizes is correct,
|
|
287
|
+
not approximate.
|
|
288
|
+
|
|
289
|
+
Every block callback also establishes a parent scope. `functionBlock`, `step`, and
|
|
290
|
+
the selected arm of `decision` may therefore contain visits recursively at any
|
|
291
|
+
depth. A `step` can be either a leaf action or a meaningful narrative grouping;
|
|
292
|
+
there is no separate grouping block.
|
|
293
|
+
|
|
294
|
+
Each block carries a required, non-empty static **description** and optional
|
|
295
|
+
per-invocation **attributes** — each scenario stamps its own attribute values
|
|
296
|
+
into its block visits. Metadata is the mandatory second argument to every
|
|
297
|
+
primitive, and every Decision Arm also requires metadata with a non-empty
|
|
298
|
+
description. Story and Scenario descriptions follow the same rule.
|
|
299
|
+
|
|
300
|
+
Dynamic attributes belong only to visits. They never participate in block
|
|
301
|
+
identity or folding. Arguments, return values, and errors are never captured
|
|
302
|
+
automatically.
|
|
303
|
+
The public type is `Readonly<Record<string, unknown>>`. The `attributes` field
|
|
304
|
+
accepts either a record or a resolver: function arguments are supplied to a
|
|
305
|
+
`functionBlock` resolver, the selected literal to a `decision` resolver, and no
|
|
306
|
+
arguments to a `step` resolver. Laymos JSON-serializes the supplied record and
|
|
307
|
+
stores the resulting JSON data rather than retaining a live object reference.
|
|
308
|
+
In recording mode, serialization failure throws a dedicated error containing
|
|
309
|
+
the Block name and source location, causing the Scenario to fail. Values
|
|
310
|
+
that serialize follow native `JSON.stringify` semantics. When
|
|
311
|
+
no Scenario recorder is active, block wrappers do not evaluate or serialize
|
|
312
|
+
attribute resolvers.
|
|
313
|
+
|
|
314
|
+
_Why declared arms:_ runtime-only tracing can't know an un-run branch exists. Declaring arms gives the tracer the decision's known outcomes. **Accepted limitation:** blocks _inside_ a never-taken arm, or in never-loaded files, are invisible. The arm is shown as unobserved without implying that the story is incomplete. No static analysis for stories — stories are runtime, period.
|
|
315
|
+
|
|
316
|
+
The Decision Block definition owns every declared Arm. A literal Arm has its
|
|
317
|
+
literal structural key; the Otherwise Arm has a reserved internal key. Both
|
|
318
|
+
have a required narrative description and may have a distinct narrative name.
|
|
319
|
+
Each Decision Visit stores
|
|
320
|
+
its `selectedArm`, including when the selected Arm later fails, and its generic
|
|
321
|
+
`children` execution path contains everything observed within that Arm. The
|
|
322
|
+
Execution Path needs no Decision-specific item type. Every Decision Visit has
|
|
323
|
+
a selected Arm; computing the input value happens before the Decision, just as
|
|
324
|
+
an `if` condition is evaluated before its body.
|
|
325
|
+
|
|
326
|
+
_Why explicit wrappers despite invasiveness:_ comment directives orphan on refactor; there's no free lunch, only choosing who pays. Bounded by guidance: **wrap functions, not if-statements.** `decision` is the only blessed intra-function construct. If a team finds that intolerable, stories aren't for them — opt-in per flow, like modules.
|
|
327
|
+
|
|
328
|
+
Block identity is generated from the project-relative file, line, and column reported by `Error().stack`. Names and descriptions are narrative metadata, not identifiers. Laymos does not resolve source maps or preserve identity across generations: moving code and rerunning the suite intentionally produces a new story artifact.
|
|
329
|
+
|
|
330
|
+
### What a story is
|
|
331
|
+
|
|
332
|
+
A Story is one executable implementation narrative for a feature or use case.
|
|
333
|
+
Every Scenario prepares different conditions, invokes that same execution
|
|
334
|
+
exactly once, and intentionally verifies its result. Their observations
|
|
335
|
+
converge into one explanation of how the implementation logic works. A Story
|
|
336
|
+
describes only explicitly marked Blocks and makes no completeness claim about
|
|
337
|
+
the surrounding code or use case.
|
|
338
|
+
|
|
339
|
+
- **One story per `<story-name>.story.ts` file** — hard convention, not suggestion. The kebab-case file is the discovery unit; the declared Story name remains independent human-facing metadata. The name deliberately does not contain `.test`, so no test runner ever picks a Story up: stories are not tests.
|
|
340
|
+
- A discovered Story file that declares zero Stories or more than one Story is
|
|
341
|
+
an invalid definition. It cannot produce a per-Story artifact and causes the
|
|
342
|
+
execution API to reject rather than returning a test-failure result.
|
|
343
|
+
- The Story ID is that file's project-relative path. A Story leaf name is
|
|
344
|
+
unique among siblings, while the same leaf name may appear in different
|
|
345
|
+
Story Groups. Moving the file intentionally creates a new execution identity
|
|
346
|
+
on the next generation.
|
|
347
|
+
- Each Scenario prepares one explicit value for the shared Story execution and
|
|
348
|
+
verifies either its success value or typed error. Preparation, verification,
|
|
349
|
+
and optional cleanup are operational phases outside the narrative.
|
|
350
|
+
- Scenarios form one flat builder chain. Narrative nesting belongs to Block
|
|
351
|
+
Visits, not to the Story/Scenario hierarchy.
|
|
352
|
+
- Scenario names are narrative metadata and need not be unique. A Scenario has
|
|
353
|
+
no generated identifier: it is identified by its declaration position within
|
|
354
|
+
the Story, which the flat synchronous list makes total and deterministic.
|
|
355
|
+
- Scenarios run sequentially in declaration order, each at most once per
|
|
356
|
+
generation. There are no retries and no concurrency between Scenarios;
|
|
357
|
+
parallelism inside a Scenario is recorded explicitly by the Execution Path.
|
|
358
|
+
- A Scenario outcome is `succeeded`, `failed`, `interrupted`, or `skipped`, as
|
|
359
|
+
determined by the Story runner. An expected typed execution error can produce
|
|
360
|
+
a successful Scenario through `verifyError`; defects and interruptions never
|
|
361
|
+
can. Phase failures remain distinguishable. Skipped Scenarios contain no
|
|
362
|
+
visits.
|
|
363
|
+
- Story, Scenario, Block, Decision, and Arm descriptions are required and must
|
|
364
|
+
not be empty. Attributes and custom Arm names remain optional.
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
// checkout.story.ts
|
|
368
|
+
import { Effect } from 'effect';
|
|
369
|
+
import { storyGroup } from 'laymos/story';
|
|
370
|
+
|
|
371
|
+
const commerce = storyGroup('Commerce', {
|
|
372
|
+
description: 'Customer purchase behavior',
|
|
373
|
+
});
|
|
374
|
+
const checkout = commerce.group('Checkout', {
|
|
375
|
+
description: 'Order placement and payment behavior',
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
checkout
|
|
379
|
+
.story('Place an order', {
|
|
380
|
+
description: 'Places an order after inventory and payment approval',
|
|
381
|
+
})
|
|
382
|
+
.provide(AppLive)
|
|
383
|
+
.execute((prepared: CheckoutWorld) => checkout(prepared.orderId))
|
|
384
|
+
.scenario(
|
|
385
|
+
'happy path',
|
|
386
|
+
{
|
|
387
|
+
description: 'Completes an eligible order',
|
|
388
|
+
},
|
|
389
|
+
(scenario) =>
|
|
390
|
+
scenario
|
|
391
|
+
.prepare(() => seedEligibleOrder())
|
|
392
|
+
.verify((result, prepared) => verifyCompleted(result, prepared))
|
|
393
|
+
.cleanup((prepared) => removeOrder(prepared.orderId)),
|
|
394
|
+
)
|
|
395
|
+
.scenario(
|
|
396
|
+
'fraud rejected',
|
|
397
|
+
{
|
|
398
|
+
description: 'Rejects a high-risk order before payment authorization',
|
|
399
|
+
timeout: '2 minutes',
|
|
400
|
+
},
|
|
401
|
+
(scenario) =>
|
|
402
|
+
scenario
|
|
403
|
+
.prepare(() => seedRejectedOrder())
|
|
404
|
+
.verifyError((error, prepared) => verifyRejection(error, prepared)),
|
|
405
|
+
);
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Story Groups are reusable declaration values and normally live in a shared
|
|
409
|
+
file. Root Groups use `storyGroup()`, nested Groups use `.group()`, and grouped
|
|
410
|
+
Stories use `.story()`. Direct `story()` creates a Standalone Story. Group and
|
|
411
|
+
Story names are non-empty path segments and cannot contain `/`; sibling Groups
|
|
412
|
+
and Stories share one namespace. Every Group has a required description.
|
|
413
|
+
|
|
414
|
+
`laymos/story` is the only authoring surface. One optional
|
|
415
|
+
Story-level Layer provides the fixed environment used by all lifecycle phases;
|
|
416
|
+
Scenarios vary explicit prepared values and service state, never the dependency
|
|
417
|
+
graph. Scenario preparation, execution, verification, cleanup, blocks, and
|
|
418
|
+
decision arms all return Effects.
|
|
419
|
+
|
|
420
|
+
### An owned Effect runner (ADR-0004)
|
|
421
|
+
|
|
422
|
+
Stories run against real integrations — real databases, real APIs — and are
|
|
423
|
+
not tests, so laymos owns their execution end-to-end. There is no test
|
|
424
|
+
framework anywhere in the story path.
|
|
425
|
+
|
|
426
|
+
- **Discovery and identity are the runner's.** `laymos stories` discovers
|
|
427
|
+
`*.story.ts` files, loads them through jiti on Node, and the Story ID is the
|
|
428
|
+
file the runner imported — no stack parsing for identity.
|
|
429
|
+
- **Sequential, single-process.** Scenarios run in declaration order in one
|
|
430
|
+
process. Module-level state is shared across Story files, exactly as it is
|
|
431
|
+
in production; per-file worker isolation is deliberately absent.
|
|
432
|
+
- **Explicit lifecycle, no retries or watch mode.** The builder separates
|
|
433
|
+
preparation, shared execution, verification, and optional cleanup. Every
|
|
434
|
+
runnable Scenario must intentionally verify either a success value or typed
|
|
435
|
+
error. Cleanup runs whenever preparation produced a value.
|
|
436
|
+
- **Timeouts protect liveness.** Every Scenario gets a generous default
|
|
437
|
+
timeout (60 seconds), overridable per Scenario (`timeout: '10 minutes'` as
|
|
438
|
+
an Effect `Duration`) and per run (`--timeout`). Timing out interrupts the
|
|
439
|
+
Scenario fiber and is recorded as an `interrupted` outcome.
|
|
440
|
+
|
|
441
|
+
### Only execution is narrative
|
|
442
|
+
|
|
443
|
+
Recording is active only while the runner invokes the shared Story execution.
|
|
444
|
+
Preparation, verification, and cleanup may call marked production code, but
|
|
445
|
+
those calls execute without producing Block Visits. Operational mechanics do
|
|
446
|
+
not compete with the explanation of the feature logic.
|
|
447
|
+
|
|
448
|
+
### The artifact
|
|
449
|
+
|
|
450
|
+
A generation run records all Scenarios of a Story into **one JSON artifact per Story**: shared Block definitions plus each Scenario's recursive Execution Path of Block Visits. Test result + artifact, from one run.
|
|
451
|
+
|
|
452
|
+
The artifact preserves blocks separately from their visits. A block is the
|
|
453
|
+
shared, source-identified narrative unit; a block visit is one occurrence in
|
|
454
|
+
one scenario. The artifact knows two block kinds: `block` and `decision`.
|
|
455
|
+
`functionBlock` and `step` both record as `block` — which primitive marked a
|
|
456
|
+
block is recording mechanics, not narrative, exactly as the plain and Effect
|
|
457
|
+
variants record identically. Only `decision` is distinct, because it owns
|
|
458
|
+
declared Arms. A visit has no identifier of its own: its identity is its
|
|
459
|
+
position in the Scenario's execution path, and its value contains facts only —
|
|
460
|
+
Block ID, outcome, selected Arm when applicable, and optional attributes. It
|
|
461
|
+
does not encode parentage, ordering, or next relationships.
|
|
462
|
+
|
|
463
|
+
The Scenario owns one recursive execution path. The path is an array, so array
|
|
464
|
+
order means sequence. A Visit item carries its facts and owns a nested child
|
|
465
|
+
path; a Parallel item owns an array of branch paths.
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
type StoryId = string;
|
|
469
|
+
type BlockId = string;
|
|
470
|
+
|
|
471
|
+
interface StoryArtifact {
|
|
472
|
+
readonly schemaVersion: 3;
|
|
473
|
+
readonly generatedAt: number;
|
|
474
|
+
readonly name: string;
|
|
475
|
+
readonly description: string;
|
|
476
|
+
readonly blocks: Readonly<Record<BlockId, Block>>;
|
|
477
|
+
readonly scenarios: readonly Scenario[];
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
type Arm =
|
|
481
|
+
| {
|
|
482
|
+
readonly kind: 'literal';
|
|
483
|
+
readonly value: string | number | boolean;
|
|
484
|
+
readonly name: string;
|
|
485
|
+
readonly description: string;
|
|
486
|
+
}
|
|
487
|
+
| {
|
|
488
|
+
readonly kind: 'otherwise';
|
|
489
|
+
readonly name: string;
|
|
490
|
+
readonly description: string;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
type SelectedArm =
|
|
494
|
+
| { readonly kind: 'literal'; readonly value: string | number | boolean }
|
|
495
|
+
| { readonly kind: 'otherwise' };
|
|
496
|
+
|
|
497
|
+
type Block =
|
|
498
|
+
| {
|
|
499
|
+
readonly kind: 'block';
|
|
500
|
+
readonly name: string;
|
|
501
|
+
readonly description: string;
|
|
502
|
+
readonly location: StorySourceLocation;
|
|
503
|
+
}
|
|
504
|
+
| {
|
|
505
|
+
readonly kind: 'decision';
|
|
506
|
+
readonly name: string;
|
|
507
|
+
readonly description: string;
|
|
508
|
+
readonly location: StorySourceLocation;
|
|
509
|
+
readonly arms: readonly Arm[];
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
interface Scenario {
|
|
513
|
+
readonly name: string;
|
|
514
|
+
readonly description: string;
|
|
515
|
+
readonly location: StorySourceLocation;
|
|
516
|
+
readonly outcome: ScenarioOutcome;
|
|
517
|
+
readonly execution: ExecutionPath;
|
|
518
|
+
readonly failures: readonly {
|
|
519
|
+
readonly phase: 'preparation' | 'execution' | 'verification' | 'cleanup';
|
|
520
|
+
readonly message: string;
|
|
521
|
+
}[];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
type ExecutionPath = readonly ExecutionItem[];
|
|
525
|
+
|
|
526
|
+
type ExecutionItem =
|
|
527
|
+
| {
|
|
528
|
+
readonly blockId: BlockId;
|
|
529
|
+
readonly outcome: BlockVisitOutcome;
|
|
530
|
+
readonly selectedArm?: SelectedArm;
|
|
531
|
+
readonly attributes?: Readonly<Record<string, unknown>>;
|
|
532
|
+
readonly children: ExecutionPath;
|
|
533
|
+
}
|
|
534
|
+
| { readonly parallel: readonly ExecutionPath[] };
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
Generated identities live in record keys and are not repeated in their values;
|
|
538
|
+
visits carry no generated identity at all. Arrays are reserved for meaningful
|
|
539
|
+
order. Records are serialized by sorted key for content hashing. The execution
|
|
540
|
+
path is the sole source of sequence, parallelism, containment, and visit facts.
|
|
541
|
+
There are no visit IDs, parent IDs, next IDs, flow-edge records, or
|
|
542
|
+
parallel-group records — a dangling or orphaned visit reference is
|
|
543
|
+
unrepresentable.
|
|
544
|
+
|
|
545
|
+
Every Visit records when it began and how long it ran. Timing uses a hybrid
|
|
546
|
+
clock: the Scenario stores its absolute start time and total duration, each
|
|
547
|
+
Visit stores a monotonic `startOffsetMillis` relative to the Scenario start
|
|
548
|
+
plus its own `durationMillis` — so trace nesting is consistent by
|
|
549
|
+
construction and devtools can render a span waterfall directly from the
|
|
550
|
+
Execution Path. Completion updates the Visit's outcome to `succeeded`,
|
|
551
|
+
`failed`, or `interrupted`; return values and errors are not captured
|
|
552
|
+
automatically.
|
|
553
|
+
|
|
554
|
+
Synchronous throws, Promise rejections, and Effect failures or defects mark a
|
|
555
|
+
Visit `failed`. An Effect interruption-only Cause marks it `interrupted`; a
|
|
556
|
+
Cause containing both failure and interruption is `failed`. A Scenario timeout
|
|
557
|
+
or run interruption marks every still-active Visit `interrupted`. A caught inner failure
|
|
558
|
+
does not taint an enclosing Block that handles it and returns normally: the
|
|
559
|
+
inner Visit is `failed` and the enclosing Visit is `succeeded`. Errors and
|
|
560
|
+
Causes are not captured automatically.
|
|
561
|
+
|
|
562
|
+
Every scenario preserves the visits observed before failure or interruption.
|
|
563
|
+
Uncaught failure marks
|
|
564
|
+
the failing visit and each enclosing visit through which it propagates as
|
|
565
|
+
failed; cancellation or timeout marks visits still active at interruption as
|
|
566
|
+
interrupted. When one visit must be referenced externally, its address is the
|
|
567
|
+
Scenario's declaration position plus the visit's structural position in the
|
|
568
|
+
execution path — derivable, never stored. The Story runner is the authority
|
|
569
|
+
on the Scenario outcome.
|
|
570
|
+
|
|
571
|
+
### Ephemeral generation
|
|
572
|
+
|
|
573
|
+
Story artifacts exist only as return values from an explicit generation
|
|
574
|
+
request. Laymos writes no index, cache, staging directory, or artifact file.
|
|
575
|
+
Callers may keep the returned values in memory for presentation, but a refresh
|
|
576
|
+
always runs the requested Stories again and atomically replaces that caller-owned
|
|
577
|
+
state.
|
|
578
|
+
|
|
579
|
+
Generation orders Scenarios by declaration position and Blocks by generated
|
|
580
|
+
identity. Sequential order and containment come from the execution path itself.
|
|
581
|
+
|
|
582
|
+
Finalization also normalizes degenerate forms so one execution has one
|
|
583
|
+
canonical representation: empty parallel branches are dropped, a Parallel item
|
|
584
|
+
left with a single branch is inlined into its parent path, and one left with
|
|
585
|
+
none is removed. A canonical artifact therefore guarantees every Parallel item
|
|
586
|
+
has at least two non-empty branches. Consumers trust these invariants and the
|
|
587
|
+
Arm rules above; consumers may trust these invariants.
|
|
588
|
+
|
|
589
|
+
### Node APIs
|
|
590
|
+
|
|
591
|
+
`laymos/node` exposes discovery and fresh execution:
|
|
592
|
+
|
|
593
|
+
```ts
|
|
594
|
+
discoverStories(baseDir): Effect<StoryCatalog, StoryDiscoveryError>
|
|
595
|
+
runStory(baseDir, storyId): Effect<StoryRunResult, StoryRunnerError>
|
|
596
|
+
runStoryGroup(baseDir, groupPath): Effect<StoriesRunResult, StoryDiscoveryError | StoryRunnerError>
|
|
597
|
+
runStories(baseDir, storyIds): Effect<StoriesRunResult, StoryRunnerError>
|
|
598
|
+
runAllStories(baseDir): Effect<AllStoriesRunResult, StoryRunnerError>
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
Execution results carry fresh evidence and diagnostics:
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
interface StoryRunResult {
|
|
605
|
+
readonly status: 'passed' | 'failed';
|
|
606
|
+
readonly artifact: StoryArtifact;
|
|
607
|
+
readonly failures: readonly StoryFailure[];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
interface StoriesRunResult {
|
|
611
|
+
readonly status: 'passed' | 'failed';
|
|
612
|
+
readonly report: LaymosStoriesReport;
|
|
613
|
+
readonly failures: readonly StoryFailure[];
|
|
614
|
+
}
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
`discoverStories` imports every Story module to collect names, descriptions,
|
|
618
|
+
and Group ancestry, but it never prepares or executes a Scenario. Story files
|
|
619
|
+
and their shared imports must therefore remain declaration-only at module
|
|
620
|
+
scope. Discovery validates the complete catalog atomically and reports every
|
|
621
|
+
invalid file, conflicting Group declaration, and sibling-name collision
|
|
622
|
+
together. `runStoryGroup` performs fresh discovery and executes every Story in
|
|
623
|
+
the selected subtree. `runStory` and `runStories` continue to use file-based
|
|
624
|
+
Story IDs.
|
|
625
|
+
|
|
626
|
+
The CLI mirrors group execution with
|
|
627
|
+
`laymos stories --group "DynamoDB / Entities"`. A Group selection cannot be
|
|
628
|
+
combined with explicit Story file arguments.
|
|
629
|
+
|
|
630
|
+
The execution APIs run the owned Story runner with `baseDir` as the target
|
|
631
|
+
project's root. The runner discovers `*.story.ts` files itself, loads them
|
|
632
|
+
through jiti, and executes Scenarios sequentially in a single Node process.
|
|
633
|
+
There is no test framework, configuration file, or worker pool in the story
|
|
634
|
+
path.
|
|
635
|
+
|
|
636
|
+
Interrupting an execution API interrupts its Effect. A Scenario timeout is
|
|
637
|
+
returned as reportable partial evidence with an `interrupted` Scenario outcome;
|
|
638
|
+
the runner then continues with the remaining Scenarios. Each artifact records
|
|
639
|
+
`generatedAt` so a caller can label the evidence it currently holds.
|
|
640
|
+
|
|
641
|
+
`runAllStories` runs every discovered Story to completion and returns
|
|
642
|
+
`status: 'failed'` when any Scenario failed or was interrupted. Skipped
|
|
643
|
+
Scenarios do not make the aggregate result fail.
|
|
644
|
+
|
|
645
|
+
Discovering no Story files is a valid complete generation and succeeds with a
|
|
646
|
+
passed empty report. `runStory` fails with `StoryRunnerError` when its Story ID
|
|
647
|
+
does not resolve to an existing Story file.
|
|
648
|
+
|
|
649
|
+
A failed or interrupted Scenario is reportable evidence, so a Scenario failure
|
|
650
|
+
succeeds with `status: 'failed'` and a finalized result containing the Scenario's
|
|
651
|
+
partial Block Visits. Module loading, invalid definitions, and recording
|
|
652
|
+
failures fail with `StoryRunnerError` instead. The two result cases are not a
|
|
653
|
+
discriminated union because they carry the same data; `status` is the aggregate
|
|
654
|
+
test result and Scenario outcomes retain the detail.
|
|
655
|
+
|
|
656
|
+
An invalid Story definition is also a rejecting failure; rejected requests do
|
|
657
|
+
not expose any earlier in-memory results from that request.
|
|
658
|
+
|
|
659
|
+
### Outside a Scenario
|
|
660
|
+
|
|
661
|
+
The production `laymos/story` entry has no recording capability. Its
|
|
662
|
+
`functionBlock` returns the original function, `step` returns the original
|
|
663
|
+
Effect, and `decision` performs only the declared branching. Attribute
|
|
664
|
+
resolvers are not evaluated, and there is no recorder lookup, source-location
|
|
665
|
+
capture, serialization, event emission, or runtime configuration.
|
|
666
|
+
|
|
667
|
+
During Story execution, the runner uses Jiti to resolve `laymos/story` to a
|
|
668
|
+
private `story-runtime` implementation throughout the loaded source graph.
|
|
669
|
+
That runtime declares Stories and records Blocks while preserving the public
|
|
670
|
+
surface's production semantics. `story-runtime` is deliberately absent from
|
|
671
|
+
the package export map, so application code and TypeScript tooling cannot
|
|
672
|
+
resolve it as a package subpath. Production logging and replay are outside v1.
|
|
673
|
+
|
|
674
|
+
---
|
|
675
|
+
|
|
676
|
+
## The Unifying Principle
|
|
677
|
+
|
|
678
|
+
Every pillar has the same shape:
|
|
679
|
+
|
|
680
|
+
> **Static truth + runtime evidence, merged. Declared intent + actual state, both visible.**
|
|
681
|
+
|
|
682
|
+
- Layers/modules: config (intent) merged with the extracted import graph (reality) → violations + viz.
|
|
683
|
+
- Stories: declared arms (shape) merged with recorded scenarios (reality) → observed flow graph.
|
|
684
|
+
- Production, later: story graph (map) merged with logs (reality) → replay.
|
|
685
|
+
|
|
686
|
+
One config. One engine. Enforcement and the diagram are the same artifact.
|
|
687
|
+
|
|
688
|
+
---
|
|
689
|
+
|
|
690
|
+
## Package Layout
|
|
691
|
+
|
|
692
|
+
One package, three subpaths — consumers only pay for what they import:
|
|
693
|
+
|
|
694
|
+
| Subpath | Contents | Runs where |
|
|
695
|
+
| -------------- | ----------------------------------------------------- | ------------------------ |
|
|
696
|
+
| `laymos` | Config DSL + types | Anywhere (browser-safe) |
|
|
697
|
+
| `laymos/node` | analysis engine + Story discovery and fresh execution | Node, dev-time |
|
|
698
|
+
| `laymos/story` | Effect Story builder and blocks | Production (Effect apps) |
|
|
699
|
+
|
|
700
|
+
```
|
|
701
|
+
src/
|
|
702
|
+
├─ config/ builders: layer, edge, layerGraph, module, rules, defineConfig
|
|
703
|
+
├─ engine/ extract (skott) → resolve → evaluate → emit, tagged errors
|
|
704
|
+
├─ cli/ effect CLI: laymos lint, laymos stories
|
|
705
|
+
├─ story/
|
|
706
|
+
│ ├─ core/ declaration model and recorder contract
|
|
707
|
+
│ ├─ effect/ → "laymos/story"
|
|
708
|
+
│ ├─ story-runtime/ private runner-only implementation
|
|
709
|
+
│ ├─ runner/ owned Story runner: discovery, loading, execution
|
|
710
|
+
│ └─ artifact/ Story recording and artifact data model
|
|
711
|
+
├─ index.ts → "laymos"
|
|
712
|
+
└─ node.ts → "laymos/node"
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
---
|
|
716
|
+
|
|
717
|
+
## Technology Choices
|
|
718
|
+
|
|
719
|
+
| Choice | Why |
|
|
720
|
+
| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
721
|
+
| **TypeScript projects** as the target | Import resolution, `import type` semantics, TS path aliases — the ecosystem laymos speaks. |
|
|
722
|
+
| **skott** as extractor only, type-tracking always on | Identical edge sets to dependency-cruiser in a head-to-head POC, ~30% faster, simpler API (ADR-0001). All rule semantics stay in laymos's own engine. |
|
|
723
|
+
| **Own rules engine** | Transitivity, sink layers, AND-gated module constraints don't survive translation to regex pair-lists. |
|
|
724
|
+
| **Effect** for the engine, CLI, and Stories | One runtime and one failure/concurrency model across authoring and execution (ADR-0002). |
|
|
725
|
+
| **`laymos.config.ts`** single typed file, reference builders | One source of truth; cross-references are values — autocomplete, rename-safe. Cost: config is executed code. |
|
|
726
|
+
| **Owned Effect Story runner** (ADR-0004) | Stories run real integrations, not tests; mocking, retries, and parallel workers are anti-features. Sequential and single-process. |
|
|
727
|
+
| **`Error().stack`** for generated block identity | Zero build-step; project-relative file, line, and column are sufficient within one generated artifact. |
|
|
728
|
+
| **Structured JSON log lines** for prod mode | One-line runtime cost; correlation is a viewer problem. |
|
|
729
|
+
|
|
730
|
+
---
|
|
731
|
+
|
|
732
|
+
## Explicitly Out of Scope / Deferred
|
|
733
|
+
|
|
734
|
+
- **Runtime coupling beyond imports** (events, pub/sub) — laymos governs static import structure; stories capture executed flows. Coupling without imports is not modeled.
|
|
735
|
+
- **Cross-pillar assertions (v2):** a story asserting "this path never touches module X" — runtime evidence checked against layer/module rules. The pillars unify into one enforcement system here. Designed later, deliberately.
|
|
736
|
+
- **Escape hatches** beyond `ignore` (per-edge exceptions, baselines) — deferred until real need.
|
|
737
|
+
- **Viz specifics** — principles fixed (declared vs actual, triad of edge states, side-by-side graphs), rendering deferred.
|
|
738
|
+
- **Line-level violation evidence** and per-edge import kind — dropped for v1 (ADR-0001); additive later.
|
|
739
|
+
- **Production replay viewer** — format designed, feature deferred.
|
|
740
|
+
- **Naming of graph-views** — "layer graph" for now; parked.
|