@suluk/journeys 0.1.0 → 0.3.0
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/README.md +27 -0
- package/package.json +4 -2
- package/src/bind.ts +222 -57
- package/src/emit.ts +21 -13
- package/src/hatch/auth.ts +81 -0
- package/src/hatch/backends.ts +56 -0
- package/src/hatch/index.ts +19 -0
- package/src/hatch/state.ts +80 -0
- package/src/hatch/types.ts +87 -0
- package/src/index.ts +4 -0
- package/test/hatch-wall.test.ts +38 -0
- package/test/hatch.test.ts +93 -0
- package/test/journeys.test.ts +110 -1
package/README.md
CHANGED
|
@@ -68,6 +68,33 @@ mutates the accessor in place during collision resolution, so accessor-keyed ide
|
|
|
68
68
|
operation is added. (Witnessed on toolfactory's `api/billing/subscription`, which holds both `getSubscription` and
|
|
69
69
|
`cancelSubscription`.)
|
|
70
70
|
|
|
71
|
+
## Two-role authoring + composition (no developer)
|
|
72
|
+
|
|
73
|
+
A non-technical author can write stories in their **own words** and hand them to a **scaffolder** (a more technical
|
|
74
|
+
author — *not* a developer) who maps that free prose onto the runnable vocabulary. Neither role writes code.
|
|
75
|
+
|
|
76
|
+
- **`detectUndefined(vocab, features, defs)`** is the scaffolder's worklist (Cucumber-style undefined-step detection,
|
|
77
|
+
resolved by *mapping* not coding). It splits each not-yet-runnable step into **"the scaffolder can define this"**
|
|
78
|
+
(an operation/step exists → alias or decompose) vs **"escalate to a developer"** (`NEEDS-CONTRACT` — no operation
|
|
79
|
+
backs it). `renderScaffold(...)` prints it with paste-ready stubs.
|
|
80
|
+
- A **`Definitions`** artifact (author/scaffolder-owned data) turns prose into runnable steps three ways:
|
|
81
|
+
- **alias** — `"prose": "When I checkout"` (one canonical step)
|
|
82
|
+
- **decomposition** — `"I sign up and buy credits": ["Given I am a signed-in user", "When I checkout", "Then it succeeds"]`
|
|
83
|
+
- **journey** — a named, reusable sequence referenced from a story with `When I complete the "top up" journey`
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const defs = {
|
|
87
|
+
steps: { "when i sign up and buy credits": ["Given I am a signed-in user", "When I checkout", "Then it succeeds"] },
|
|
88
|
+
journeys: { "top up": ["Given I am a signed-in user", "When I checkout", "Then it succeeds"] },
|
|
89
|
+
};
|
|
90
|
+
const report = bindFeatures(vocab, [story], { definitions: defs }); // composed + decomposed, all bound
|
|
91
|
+
const todo = detectUndefined(vocab, [story], { definitions: defs }); // what still needs defining / escalation
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Composition** lets authors build bigger journeys out of existing ones (`complete the "…" journey`), and outcome
|
|
95
|
+
steps bind to the **most-recent** action, so multi-step journeys bind each `Then` to its own `When`. The only thing
|
|
96
|
+
that ever needs a developer is genuinely new backend capability (`NEEDS-CONTRACT`) — composition and mapping do not.
|
|
97
|
+
|
|
71
98
|
## Discovery (designed, gated)
|
|
72
99
|
|
|
73
100
|
A semantic *reuse* search — "find an existing flow to reuse / modify / rebuild" — is designed (a deterministic
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/journeys",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Intuitive, runnable BDD over a v4 'Suluk' contract. Projects a deterministic Gherkin step VOCABULARY from the contract (Given from x-suluk-access, When from each operation, Then from declared statuses + x-suluk-store + x-suluk-cost), binds authored .feature stories EXACT-or-UNBOUND with outcome steps resolved relative to the scenario's When-subject, and emits a BIDIRECTIONAL tri-state gap report (PARAPHRASE / NEEDS-DEV-GLUE / NEEDS-CONTRACT) + contract->authored coverage holes. A pure function of the document. CANDIDATE tooling.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
"type": "module",
|
|
17
17
|
"main": "src/index.ts",
|
|
18
18
|
"exports": {
|
|
19
|
-
".": "./src/index.ts"
|
|
19
|
+
".": "./src/index.ts",
|
|
20
|
+
"./hatch": "./src/hatch/index.ts"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
23
|
+
"@suluk/cloudflare": "^0.2.0",
|
|
22
24
|
"@suluk/core": "^0.1.13",
|
|
23
25
|
"@suluk/sdk": "^0.2.2"
|
|
24
26
|
},
|
package/src/bind.ts
CHANGED
|
@@ -1,24 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The BINDER (C038): authored .feature steps → contract handles,
|
|
2
|
+
* The BINDER (C038): authored .feature steps → contract handles, the bidirectional tri-state GAP report, plus the
|
|
3
|
+
* TWO-ROLE authoring layer (a definitions/decomposition map + undefined detection) and named-journey COMPOSITION.
|
|
3
4
|
*
|
|
4
5
|
* The decision rule is EXACT-or-UNBOUND and statically decidable:
|
|
5
6
|
* - A step's skeleton equals a generated step skeleton → BOUND to that single stable handle.
|
|
6
|
-
* - Outcome (THEN) steps
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* - Outcome (THEN) steps resolve relative to the MOST-RECENT bound WHEN (the action currently in play) — so a generic
|
|
8
|
+
* "Then it succeeds" never mis-binds, AND a multi-step / composed journey binds each outcome to its own action.
|
|
9
|
+
* - Otherwise the step is UNBOUND, then DETERMINISTICALLY classified for a human: PARAPHRASE (an alias resolves it,
|
|
10
|
+
* no dev), NEEDS-DEV-GLUE (an operation exists but no step wires it), NEEDS-CONTRACT (nothing backs it — a dev
|
|
11
|
+
* extends the contract). A WHEN phrase shared by >1 operation is AMBIGUOUS; a ref to an undefined journey is UNDEFINED.
|
|
12
|
+
*
|
|
13
|
+
* TWO ROLES (no developer for either): a NON-TECHNICAL author writes stories in their own words; a SCAFFOLDER maps that
|
|
14
|
+
* free prose onto the runnable vocabulary via a `Definitions` artifact — an ALIAS (prose → one canonical step), a
|
|
15
|
+
* DECOMPOSITION (prose → a sequence of canonical steps), or a named JOURNEY (composed by reference). `detectUndefined`
|
|
16
|
+
* tells the scaffolder exactly what is not yet runnable, and whether they can define it or must escalate to a developer.
|
|
13
17
|
*
|
|
14
18
|
* No scoring/lemmatization/embedding EVER decides a bind. Token similarity appears ONLY to rank the presentational
|
|
15
19
|
* "did you mean?" suggestion on an already-UNBOUND step.
|
|
16
20
|
*/
|
|
17
21
|
import { camel, jaccard, norm, tok } from "./normalize";
|
|
18
|
-
import type { FeatureStep, Feature } from "./gherkin";
|
|
22
|
+
import type { FeatureStep, Feature, StepKind } from "./gherkin";
|
|
19
23
|
import type { JourneyStep, Vocabulary } from "./vocabulary";
|
|
20
24
|
|
|
21
|
-
export type BindState = "BOUND" | "PARAPHRASE" | "NEEDS-DEV-GLUE" | "NEEDS-CONTRACT" | "AMBIGUOUS";
|
|
25
|
+
export type BindState = "BOUND" | "PARAPHRASE" | "NEEDS-DEV-GLUE" | "NEEDS-CONTRACT" | "AMBIGUOUS" | "UNDEFINED";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The SCAFFOLDER's mapping layer (author-owned data, no developer). Turns a non-technical author's free prose into
|
|
29
|
+
* runnable Gherkin without touching code.
|
|
30
|
+
*/
|
|
31
|
+
export interface Definitions {
|
|
32
|
+
/** a free-prose step (normalized) → a canonical generated phrase (ALIAS) OR an ordered list of canonical phrases
|
|
33
|
+
* (manual DECOMPOSITION). Each canonical phrase carries its keyword, e.g. "When I checkout" / "Then it succeeds". */
|
|
34
|
+
steps?: Record<string, string | string[]>;
|
|
35
|
+
/** named JOURNEYS for composition: a journey name → an ordered list of step phrases (each itself bound or defined).
|
|
36
|
+
* Referenced from a story with `When I complete the "<name>" journey`. */
|
|
37
|
+
journeys?: Record<string, string[]>;
|
|
38
|
+
}
|
|
22
39
|
|
|
23
40
|
export interface StepResult {
|
|
24
41
|
step: FeatureStep;
|
|
@@ -29,12 +46,16 @@ export interface StepResult {
|
|
|
29
46
|
via: string;
|
|
30
47
|
/** a human next-action for a non-BOUND step. */
|
|
31
48
|
suggest: string;
|
|
49
|
+
/** when this resolved step came from an alias/decomposition/journey expansion: the original authored prose it expanded from. */
|
|
50
|
+
expandedFrom?: { text: string; line: number };
|
|
51
|
+
/** the canonical step phrase this UNBOUND step most likely maps to (drives the scaffolder's alias stub). */
|
|
52
|
+
canonical?: string;
|
|
32
53
|
}
|
|
33
54
|
|
|
34
55
|
export interface ScenarioResult {
|
|
35
56
|
scenario: string;
|
|
36
57
|
rule?: string;
|
|
37
|
-
/** the
|
|
58
|
+
/** the FIRST bound When-op handle (a label/back-compat handle; outcomes bind to the most-recent When, see results). */
|
|
38
59
|
subject: string;
|
|
39
60
|
results: StepResult[];
|
|
40
61
|
}
|
|
@@ -57,17 +78,32 @@ export interface GapReport {
|
|
|
57
78
|
}
|
|
58
79
|
|
|
59
80
|
export interface BindOptions {
|
|
60
|
-
/**
|
|
81
|
+
/** shorthand for `definitions.steps` with 1:1 string values — an author-owned synonym map. Merged into definitions. */
|
|
61
82
|
aliases?: Record<string, string>;
|
|
83
|
+
/** the scaffolder's full mapping layer (aliases + decompositions + named journeys). */
|
|
84
|
+
definitions?: Definitions;
|
|
62
85
|
/** how many coverage-hole stubs to emit (default: all). */
|
|
63
86
|
maxHoles?: number;
|
|
64
87
|
}
|
|
65
88
|
|
|
89
|
+
// ---- a step after alias/decomposition/journey expansion ----
|
|
90
|
+
interface ResolvedStep {
|
|
91
|
+
kind: StepKind;
|
|
92
|
+
text: string;
|
|
93
|
+
raw: string;
|
|
94
|
+
line: number;
|
|
95
|
+
/** set when produced by expanding an alias / decomposition / journey. */
|
|
96
|
+
origin?: { text: string; line: number };
|
|
97
|
+
/** set when a journey-ref names a journey that is not defined. */
|
|
98
|
+
undefinedJourney?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
66
101
|
interface Indexes {
|
|
67
102
|
whenBySkeleton: Map<string, JourneyStep[]>;
|
|
68
103
|
thenByHandle: Map<string, Set<string>>;
|
|
69
104
|
givenSkeletons: Set<string>;
|
|
70
105
|
whenSteps: JourneyStep[];
|
|
106
|
+
whenByHandle: Map<string, JourneyStep>;
|
|
71
107
|
}
|
|
72
108
|
|
|
73
109
|
function buildIndexes(vocab: Vocabulary): Indexes {
|
|
@@ -75,94 +111,143 @@ function buildIndexes(vocab: Vocabulary): Indexes {
|
|
|
75
111
|
const thenByHandle = new Map<string, Set<string>>();
|
|
76
112
|
const givenSkeletons = new Set<string>();
|
|
77
113
|
const whenSteps: JourneyStep[] = [];
|
|
114
|
+
const whenByHandle = new Map<string, JourneyStep>();
|
|
78
115
|
for (const s of vocab.steps) {
|
|
79
116
|
if (s.kind === "given") givenSkeletons.add(s.skeleton);
|
|
80
117
|
else if (s.kind === "when") {
|
|
81
118
|
(whenBySkeleton.get(s.skeleton) ?? whenBySkeleton.set(s.skeleton, []).get(s.skeleton)!).push(s);
|
|
82
119
|
whenSteps.push(s);
|
|
120
|
+
whenByHandle.set(s.handle, s);
|
|
83
121
|
} else {
|
|
84
122
|
(thenByHandle.get(s.handle) ?? thenByHandle.set(s.handle, new Set()).get(s.handle)!).add(s.skeleton);
|
|
85
123
|
}
|
|
86
124
|
}
|
|
87
|
-
return { whenBySkeleton, thenByHandle, givenSkeletons, whenSteps };
|
|
125
|
+
return { whenBySkeleton, thenByHandle, givenSkeletons, whenSteps, whenByHandle };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function mergeDefinitions(opts: BindOptions): Definitions {
|
|
129
|
+
const steps: Record<string, string | string[]> = { ...opts.aliases, ...opts.definitions?.steps };
|
|
130
|
+
return { steps, journeys: opts.definitions?.journeys ?? {} };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Parse 'complete the "<name>" journey' (and a couple of natural variants) → the journey name, else null. */
|
|
134
|
+
function journeyRefName(text: string): string | null {
|
|
135
|
+
const m = /(?:complete[sd]?|run|do|perform|go through)\s+the\s+["“](.+?)["”]\s+journey/i.exec(text) || /the\s+["“](.+?)["”]\s+journey\s+(?:is|has|was)\s+(?:done|completed|complete)/i.exec(text);
|
|
136
|
+
return m ? m[1].trim() : null;
|
|
88
137
|
}
|
|
89
138
|
|
|
90
|
-
|
|
139
|
+
/** Parse a canonical phrase ("When I checkout" / "And it succeeds") into a step, inheriting `last` for And/But. */
|
|
140
|
+
function phraseToStep(phrase: string, last: StepKind, origin: { text: string; line: number }): { step: ResolvedStep; kind: StepKind } {
|
|
141
|
+
const m = /^(given|when|then|and|but)\b\s*(.*)$/i.exec(phrase.trim());
|
|
142
|
+
const kw = m ? m[1].toLowerCase() : last;
|
|
143
|
+
const text = m ? m[2] : phrase.trim();
|
|
144
|
+
const kind: StepKind = kw === "given" || kw === "when" || kw === "then" ? (kw as StepKind) : last;
|
|
145
|
+
return { step: { kind, text, raw: phrase.trim(), line: origin.line, origin }, kind };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function phrasesToSteps(phrases: string[], origin: { text: string; line: number }): ResolvedStep[] {
|
|
149
|
+
const out: ResolvedStep[] = [];
|
|
150
|
+
let last: StepKind = "given";
|
|
151
|
+
for (const p of phrases) {
|
|
152
|
+
const { step, kind } = phraseToStep(p, last, origin);
|
|
153
|
+
last = kind;
|
|
154
|
+
out.push(step);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Expand a scenario's authored steps by applying aliases / decompositions / named-journey references. */
|
|
160
|
+
function expandScenario(steps: FeatureStep[], defs: Definitions): ResolvedStep[] {
|
|
161
|
+
const out: ResolvedStep[] = [];
|
|
162
|
+
for (const s of steps) {
|
|
163
|
+
const origin = { text: s.text, line: s.line };
|
|
164
|
+
const jname = journeyRefName(s.text);
|
|
165
|
+
if (jname) {
|
|
166
|
+
const journey = defs.journeys?.[jname];
|
|
167
|
+
if (journey) out.push(...phrasesToSteps(journey, origin));
|
|
168
|
+
else out.push({ kind: s.kind, text: s.text, raw: s.raw, line: s.line, undefinedJourney: jname });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const def = defs.steps?.[norm(`${s.kind} ${s.text}`)];
|
|
172
|
+
if (Array.isArray(def)) out.push(...phrasesToSteps(def, origin));
|
|
173
|
+
else if (typeof def === "string") out.push(phraseToStep(def, s.kind, origin).step);
|
|
174
|
+
else out.push({ kind: s.kind, text: s.text, raw: s.raw, line: s.line });
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const res = (step: FeatureStep, state: BindState, extra: Partial<StepResult> = {}): StepResult => ({ step, state, handle: "", via: "", suggest: "", ...extra });
|
|
180
|
+
|
|
181
|
+
const whenPhraseOf = (handle: string, idx: Indexes): string => idx.whenByHandle.get(handle)?.phrase ?? "";
|
|
91
182
|
|
|
92
183
|
function classifyUnbound(step: FeatureStep, vocab: Vocabulary, idx: Indexes): StepResult {
|
|
93
184
|
const st = tok(step.text);
|
|
94
|
-
// best WHEN phrase (paraphrase suggestion) — presentational only.
|
|
95
185
|
let best: { s: JourneyStep; score: number } | null = null;
|
|
96
186
|
for (const w of idx.whenSteps) {
|
|
97
187
|
const score = jaccard(st, tok(w.phrase));
|
|
98
188
|
if (!best || score > best.score) best = { s: w, score };
|
|
99
189
|
}
|
|
100
|
-
if (best && best.score >= 0.6) return res(step, "PARAPHRASE", best.s.handle,
|
|
101
|
-
|
|
102
|
-
let opBest: { op: { handle: string; name: string; method: string; path: string }; score: number } | null = null;
|
|
190
|
+
if (best && best.score >= 0.6) return res(step, "PARAPHRASE", { handle: best.s.handle, canonical: best.s.phrase, suggest: `alias to "${best.s.phrase}" (no dev)` });
|
|
191
|
+
let opBest: { handle: string; name: string; method: string; path: string; score: number } | null = null;
|
|
103
192
|
for (const op of vocab.operations) {
|
|
104
193
|
const score = jaccard(st, tok(camel(op.name)));
|
|
105
|
-
if (!opBest || score > opBest.score) opBest = { op, score };
|
|
194
|
+
if (!opBest || score > opBest.score) opBest = { ...op, score };
|
|
106
195
|
}
|
|
107
|
-
if (opBest && opBest.score >= 0.34) return res(step, "NEEDS-DEV-GLUE", opBest.
|
|
108
|
-
return res(step, "NEEDS-CONTRACT",
|
|
196
|
+
if (opBest && opBest.score >= 0.34) return res(step, "NEEDS-DEV-GLUE", { handle: opBest.handle, canonical: whenPhraseOf(opBest.handle, idx), suggest: `relates to '${opBest.name}' (${opBest.method.toUpperCase()} ${opBest.path}) — define a step that maps here` });
|
|
197
|
+
return res(step, "NEEDS-CONTRACT", { suggest: "no operation backs this intent — escalate to a developer to add it to the contract" });
|
|
109
198
|
}
|
|
110
199
|
|
|
111
|
-
function
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return
|
|
200
|
+
function bindResolved(rs: ResolvedStep, subject: string, vocab: Vocabulary, idx: Indexes): StepResult {
|
|
201
|
+
const step: FeatureStep = { kind: rs.kind, text: rs.text, raw: rs.raw, line: rs.line };
|
|
202
|
+
const tag = (r: StepResult): StepResult => (rs.origin ? { ...r, expandedFrom: rs.origin } : r);
|
|
203
|
+
if (rs.undefinedJourney) return tag(res(step, "UNDEFINED", { suggest: `journey "${rs.undefinedJourney}" is not defined — add it to definitions.journeys (no dev)` }));
|
|
204
|
+
const skel = norm(`${rs.kind} ${rs.text}`);
|
|
205
|
+
if (rs.kind === "given") {
|
|
206
|
+
if (idx.givenSkeletons.has(skel)) return tag(res(step, "BOUND", { handle: "@access:authenticated", via: "x-suluk-access" }));
|
|
207
|
+
return tag(classifyUnbound(step, vocab, idx));
|
|
118
208
|
}
|
|
119
|
-
if (
|
|
209
|
+
if (rs.kind === "when") {
|
|
120
210
|
const hit = idx.whenBySkeleton.get(skel);
|
|
121
|
-
if (hit && hit.length === 1) return res(step, "BOUND", hit[0].handle, hit[0].via);
|
|
122
|
-
if (hit && hit.length > 1) return res(step, "AMBIGUOUS",
|
|
123
|
-
return classifyUnbound(step, vocab, idx);
|
|
211
|
+
if (hit && hit.length === 1) return tag(res(step, "BOUND", { handle: hit[0].handle, via: hit[0].via }));
|
|
212
|
+
if (hit && hit.length > 1) return tag(res(step, "AMBIGUOUS", { suggest: `${hit.length} operations render this phrase — the projector must disambiguate` }));
|
|
213
|
+
return tag(classifyUnbound(step, vocab, idx));
|
|
124
214
|
}
|
|
125
|
-
// THEN — bind relative to the
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return classifyUnbound(step, vocab, idx);
|
|
215
|
+
// THEN — bind relative to the most-recent bound When subject.
|
|
216
|
+
if (subject && idx.thenByHandle.get(subject)?.has(skel)) return tag(res(step, "BOUND", { handle: subject, via: "outcome of the current When-subject" }));
|
|
217
|
+
return tag(classifyUnbound(step, vocab, idx));
|
|
129
218
|
}
|
|
130
219
|
|
|
131
|
-
/** Bind a parsed feature set against the vocabulary and produce the
|
|
220
|
+
/** Bind a parsed feature set against the vocabulary (applying the scaffolder's definitions) and produce the gap report. */
|
|
132
221
|
export function bindFeatures(vocab: Vocabulary, features: Feature[], opts: BindOptions = {}): GapReport {
|
|
133
222
|
const idx = buildIndexes(vocab);
|
|
134
|
-
const
|
|
223
|
+
const defs = mergeDefinitions(opts);
|
|
135
224
|
const scenarios: ScenarioResult[] = [];
|
|
136
|
-
const counts: Record<BindState, number> = { BOUND: 0, PARAPHRASE: 0, "NEEDS-DEV-GLUE": 0, "NEEDS-CONTRACT": 0, AMBIGUOUS: 0 };
|
|
225
|
+
const counts: Record<BindState, number> = { BOUND: 0, PARAPHRASE: 0, "NEEDS-DEV-GLUE": 0, "NEEDS-CONTRACT": 0, AMBIGUOUS: 0, UNDEFINED: 0 };
|
|
137
226
|
const coveredWhen = new Set<string>();
|
|
138
227
|
|
|
139
228
|
for (const feat of features) {
|
|
140
229
|
for (const sc of feat.scenarios) {
|
|
141
|
-
|
|
142
|
-
let subject = "";
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
if (hit && hit.length === 1) {
|
|
147
|
-
subject = hit[0].handle;
|
|
148
|
-
break;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
const results = sc.steps.map((step) => {
|
|
152
|
-
const r = bindStep(step, subject, vocab, idx, aliases);
|
|
230
|
+
const resolved = expandScenario(sc.steps, defs);
|
|
231
|
+
let subject = ""; // most-recent bound When
|
|
232
|
+
let firstSubject = "";
|
|
233
|
+
const results = resolved.map((rs) => {
|
|
234
|
+
const r = bindResolved(rs, subject, vocab, idx);
|
|
153
235
|
counts[r.state]++;
|
|
154
|
-
if (r.state === "BOUND" &&
|
|
236
|
+
if (r.state === "BOUND" && rs.kind === "when") {
|
|
237
|
+
subject = r.handle;
|
|
238
|
+
if (!firstSubject) firstSubject = r.handle;
|
|
239
|
+
coveredWhen.add(r.handle);
|
|
240
|
+
}
|
|
155
241
|
return r;
|
|
156
242
|
});
|
|
157
|
-
scenarios.push({ scenario: sc.name, rule: sc.rule, subject, results });
|
|
243
|
+
scenarios.push({ scenario: sc.name, rule: sc.rule, subject: firstSubject, results });
|
|
158
244
|
}
|
|
159
245
|
}
|
|
160
246
|
|
|
161
247
|
// direction (ii): contract → authored coverage holes.
|
|
162
|
-
const whenByHandle = new Map(idx.whenSteps.map((s) => [s.handle, s]));
|
|
163
248
|
const holesAll = vocab.operations.filter((op) => !coveredWhen.has(op.handle));
|
|
164
249
|
const holes: CoverageHole[] = holesAll.slice(0, opts.maxHoles ?? holesAll.length).map((op) => {
|
|
165
|
-
const when = whenByHandle.get(op.handle);
|
|
250
|
+
const when = idx.whenByHandle.get(op.handle);
|
|
166
251
|
const given = op.access === "authenticated" ? " Given I am a signed-in user\n" : "";
|
|
167
252
|
return { handle: op.handle, name: op.name, stub: ` Scenario: cover ${op.name}\n${given} ${when?.phrase ?? "When I " + camel(op.name)}\n Then it succeeds` };
|
|
168
253
|
});
|
|
@@ -170,14 +255,94 @@ export function bindFeatures(vocab: Vocabulary, features: Feature[], opts: BindO
|
|
|
170
255
|
return { scenarios, counts, coverage: { total: vocab.operations.length, covered: vocab.operations.length - holesAll.length, holes } };
|
|
171
256
|
}
|
|
172
257
|
|
|
258
|
+
// ---- the SCAFFOLDER's tooling: detect what is not yet runnable ----
|
|
259
|
+
export interface UndefinedStep {
|
|
260
|
+
scenario: string;
|
|
261
|
+
/** the original authored prose (the non-technical author's words). */
|
|
262
|
+
text: string;
|
|
263
|
+
line: number;
|
|
264
|
+
/**
|
|
265
|
+
* How to make it run. NONE of these requires a developer EXCEPT where the scaffolder, on review, finds no operation
|
|
266
|
+
* provides the capability — then they escalate. The tool only ever SUGGESTS; it never asserts "a developer is required",
|
|
267
|
+
* because absence of a lexical match is not evidence the capability is missing.
|
|
268
|
+
* - `alias` — a confident 1:1 target was found (a paraphrase of a generated step).
|
|
269
|
+
* - `map` — a related operation was found; map it (alias or decompose) to that op's steps.
|
|
270
|
+
* - `review` — no automatic match; the scaffolder maps it from the phrasebook, or escalates only if nothing backs it.
|
|
271
|
+
* - `define-journey` — a reference to a journey that is not defined yet.
|
|
272
|
+
*/
|
|
273
|
+
resolution: "alias" | "map" | "review" | "define-journey";
|
|
274
|
+
/** a paste-ready definitions stub (or, for `review`, the honest "decide" note). */
|
|
275
|
+
suggestion: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Detect every authored step that is not yet runnable — the scaffolder's worklist (Cucumber-style "undefined steps",
|
|
280
|
+
* here resolved by MAPPING, not by writing code). It SUGGESTS a target when there is a lexical signal and otherwise
|
|
281
|
+
* defers to the scaffolder; it never falsely claims a developer is required (absence of a word-match ≠ missing
|
|
282
|
+
* capability). Reports against the ORIGINAL prose, deduped.
|
|
283
|
+
*/
|
|
284
|
+
export function detectUndefined(vocab: Vocabulary, features: Feature[], opts: BindOptions = {}): UndefinedStep[] {
|
|
285
|
+
const report = bindFeatures(vocab, features, opts);
|
|
286
|
+
const out: UndefinedStep[] = [];
|
|
287
|
+
const seen = new Set<string>();
|
|
288
|
+
for (const sc of report.scenarios) {
|
|
289
|
+
for (const r of sc.results) {
|
|
290
|
+
if (r.state === "BOUND") continue;
|
|
291
|
+
const prose = r.expandedFrom?.text ?? r.step.text;
|
|
292
|
+
const line = r.expandedFrom?.line ?? r.step.line;
|
|
293
|
+
const key = `${sc.scenario}::${norm(prose)}`;
|
|
294
|
+
if (seen.has(key)) continue;
|
|
295
|
+
seen.add(key);
|
|
296
|
+
const skel = norm(`${r.step.kind} ${prose}`);
|
|
297
|
+
const stub = (target: string) => `definitions.steps: { ${JSON.stringify(skel)}: ${JSON.stringify(target)} } // or an array to decompose`;
|
|
298
|
+
let resolution: UndefinedStep["resolution"];
|
|
299
|
+
let suggestion: string;
|
|
300
|
+
if (r.state === "UNDEFINED") {
|
|
301
|
+
resolution = "define-journey";
|
|
302
|
+
suggestion = r.suggest;
|
|
303
|
+
} else if (r.state === "PARAPHRASE" && r.canonical) {
|
|
304
|
+
resolution = "alias";
|
|
305
|
+
suggestion = stub(r.canonical);
|
|
306
|
+
} else if (r.state === "NEEDS-DEV-GLUE" && r.canonical) {
|
|
307
|
+
resolution = "map";
|
|
308
|
+
suggestion = `likely maps to "${r.canonical}" — ${stub(r.canonical)}`;
|
|
309
|
+
} else {
|
|
310
|
+
resolution = "review"; // NEEDS-CONTRACT / AMBIGUOUS: no automatic match
|
|
311
|
+
suggestion = `no automatic match — map it to a step from the phrasebook (renderPhrasebook), or escalate to a developer only if no operation provides this capability`;
|
|
312
|
+
}
|
|
313
|
+
out.push({ scenario: sc.scenario, text: prose, line, resolution, suggestion });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Render the scaffolder worklist: what a non-technical author wrote that is not yet runnable, and how to resolve it. */
|
|
320
|
+
export function renderScaffold(undefinedSteps: UndefinedStep[]): string {
|
|
321
|
+
if (!undefinedSteps.length) return "All authored steps are runnable — nothing to define. ✓";
|
|
322
|
+
const suggested = undefinedSteps.filter((u) => u.resolution !== "review");
|
|
323
|
+
const review = undefinedSteps.filter((u) => u.resolution === "review");
|
|
324
|
+
const out: string[] = ["# Undefined steps — make the stories run (the scaffolder maps prose → bound steps; no code)", ""];
|
|
325
|
+
if (suggested.length) {
|
|
326
|
+
out.push("## Suggested mappings (a likely target was found — no developer):");
|
|
327
|
+
for (const u of suggested) out.push(` • [${u.resolution}] "${u.text}" (${u.scenario}:${u.line})\n ${u.suggestion}`);
|
|
328
|
+
out.push("");
|
|
329
|
+
}
|
|
330
|
+
if (review.length) {
|
|
331
|
+
out.push("## Needs your decision (no automatic match — map from the phrasebook, or escalate to a developer if nothing backs it):");
|
|
332
|
+
for (const u of review) out.push(` • "${u.text}" (${u.scenario}:${u.line})\n ${u.suggestion}`);
|
|
333
|
+
}
|
|
334
|
+
return out.join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
173
337
|
/** Render the gap report as readable text (for a CLI / a download endpoint). */
|
|
174
338
|
export function renderGapReport(report: GapReport): string {
|
|
175
|
-
const TAG: Record<BindState, string> = { BOUND: "✓ BOUND", PARAPHRASE: "≈ PARAPHRASE", "NEEDS-DEV-GLUE": "⚙ NEEDS-DEV-GLUE", "NEEDS-CONTRACT": "✗ NEEDS-CONTRACT", AMBIGUOUS: "⚠ AMBIGUOUS" };
|
|
339
|
+
const TAG: Record<BindState, string> = { BOUND: "✓ BOUND", PARAPHRASE: "≈ PARAPHRASE", "NEEDS-DEV-GLUE": "⚙ NEEDS-DEV-GLUE", "NEEDS-CONTRACT": "✗ NEEDS-CONTRACT", AMBIGUOUS: "⚠ AMBIGUOUS", UNDEFINED: "? UNDEFINED" };
|
|
176
340
|
const out: string[] = [];
|
|
177
341
|
for (const sc of report.scenarios) {
|
|
178
342
|
out.push(`\n Scenario: ${sc.scenario}${sc.rule ? ` (Rule: ${sc.rule})` : ""}`);
|
|
179
343
|
for (const r of sc.results) {
|
|
180
|
-
|
|
344
|
+
const from = r.expandedFrom ? ` «${r.expandedFrom.text}»` : "";
|
|
345
|
+
out.push(` ${r.step.kind.toUpperCase().padEnd(5)} ${r.step.text}${from}`);
|
|
181
346
|
out.push(` → ${TAG[r.state]}${r.handle ? ` [${r.handle}]` : ""}`);
|
|
182
347
|
if (r.suggest) out.push(` ↳ ${r.suggest}`);
|
|
183
348
|
}
|
package/src/emit.ts
CHANGED
|
@@ -58,20 +58,28 @@ export function emitRunnableSuite(doc: OpenAPIv4Document, vocab: Vocabulary, fea
|
|
|
58
58
|
|
|
59
59
|
const body: string[] = [];
|
|
60
60
|
for (const sc of report.scenarios) {
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
64
|
-
body.push(`// skipped "${sc.scenario}" — ${sc.subject} is not an SDK-surfaced operation (e.g. a webhook); bind via raw HTTP.`, ``);
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
const acc = clientAccessor(op); // e.g. "billing.checkout", "credits.get"
|
|
68
|
-
const expectsSuccess = sc.results.some((r) => r.state === "BOUND" && r.step.kind === "then" && /succeed/i.test(r.step.text));
|
|
61
|
+
// emit a call for EACH bound When (a composed / multi-step journey drives several actions in order).
|
|
62
|
+
const bound = sc.results.filter((r) => r.state === "BOUND" && r.step.kind === "when");
|
|
63
|
+
if (!bound.length) continue;
|
|
69
64
|
body.push(`test(${JSON.stringify(sc.scenario)}, async () => {`);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
65
|
+
let n = 0;
|
|
66
|
+
let current: OpInfo | undefined;
|
|
67
|
+
for (const r of sc.results) {
|
|
68
|
+
if (r.state !== "BOUND") continue;
|
|
69
|
+
if (r.step.kind === "when") {
|
|
70
|
+
current = opByHandle.get(r.handle);
|
|
71
|
+
if (!current) {
|
|
72
|
+
body.push(` // skipped ${r.handle} — not an SDK-surfaced operation (e.g. a webhook); bind via raw HTTP.`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const acc = clientAccessor(current); // e.g. "billing.checkout", "credits.get"
|
|
76
|
+
body.push(` // ${current.method.toUpperCase()} ${current.uri}${current.requires !== "anyone" ? ` (requires ${current.requires}: set ${tokenEnv})` : ""}`);
|
|
77
|
+
body.push(` const result${++n} = await client.${acc}(/* provide input */);`);
|
|
78
|
+
} else if (r.step.kind === "then" && current) {
|
|
79
|
+
if (/succeed/i.test(r.step.text) && n) body.push(` expect(result${n}).toBeDefined();`);
|
|
80
|
+
if (/refresh/i.test(r.step.text)) for (const key of current.store?.invalidates ?? []) body.push(` // store: expect $${key} to have refreshed after this mutation`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
75
83
|
body.push(`});`, ``);
|
|
76
84
|
}
|
|
77
85
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AUTH HATCH (C039) — "be a signed-in user" WITHOUT the OAuth dance, for auth that cannot be scripted as a user
|
|
3
|
+
* (toolfactory is Google-OAuth-only). Works identically on a local or a real-deployment backend.
|
|
4
|
+
*
|
|
5
|
+
* Two safety properties make it fail-LOUD rather than manufacture a false green:
|
|
6
|
+
* 1. It NEVER hand-forges a session row. The consumer supplies `mintSession` — Better Auth's OWN server-side session
|
|
7
|
+
* create (token hashing/signing, expiry, the OAuth account-link) — because only the app knows its session shape.
|
|
8
|
+
* 2. It SELF-VERIFIES: after minting, it round-trips the cookie against ONE real authenticated endpoint and THROWS if
|
|
9
|
+
* the app does not accept it. A forged-but-invalid session fails the test loudly; it can never make a green.
|
|
10
|
+
*
|
|
11
|
+
* It writes only through a TEST-USER-SCOPED write hatch, so the seeded user + session belong to the test user alone —
|
|
12
|
+
* safe even against the production database (the BDD-as-living-documentation model: the seeded entity IS a test user).
|
|
13
|
+
*/
|
|
14
|
+
import type { StateHatchWrite, TestUser } from "./types";
|
|
15
|
+
|
|
16
|
+
/** The tables/columns the teardown deletes the test user's rows from (app-specific; defaults to Better Auth's shape). */
|
|
17
|
+
export interface CleanupTarget {
|
|
18
|
+
table: string;
|
|
19
|
+
column: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SignInAsOptions {
|
|
23
|
+
/** a WRITE state hatch whose scope is THIS test user (provides the user row + scoped teardown). */
|
|
24
|
+
state: StateHatchWrite;
|
|
25
|
+
/** the test user to be signed in as. */
|
|
26
|
+
user: TestUser;
|
|
27
|
+
/**
|
|
28
|
+
* INSERT-OR-GET the Better Auth `user` (and any required `account` link) row, returning the user id. The consumer
|
|
29
|
+
* provides this because the exact Better Auth table/column shape is theirs (use state.d1.seed with the scope).
|
|
30
|
+
*/
|
|
31
|
+
ensureUser: (state: StateHatchWrite, user: TestUser) => Promise<string>;
|
|
32
|
+
/**
|
|
33
|
+
* Mint a session via Better Auth's OWN API (e.g. `betterAuth({ database: drizzleAdapter(<D1>) })` + its session
|
|
34
|
+
* create) and return the session cookie. NEVER hand-build the token. Required.
|
|
35
|
+
*/
|
|
36
|
+
mintSession: (userId: string, user: TestUser) => Promise<{ cookie: string }>;
|
|
37
|
+
/**
|
|
38
|
+
* Probe ONE x-suluk-access:authenticated endpoint WITH the cookie through the real API and resolve true iff the app
|
|
39
|
+
* ACCEPTS it (e.g. getSession returns a user / a 200, not 401). The hatch throws if false — fail-closed.
|
|
40
|
+
*/
|
|
41
|
+
verify: (cookie: string) => Promise<boolean>;
|
|
42
|
+
/** scoped teardown targets (default: Better Auth's `session.userId` + `user.id`). */
|
|
43
|
+
cleanupTargets?: CleanupTarget[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SignedInSession {
|
|
47
|
+
/** the session cookie to hand the @suluk/sdk client (the `token`/cookie seam in the generated runnable suite). */
|
|
48
|
+
cookie: string;
|
|
49
|
+
userId: string;
|
|
50
|
+
/** delete the seeded session/user rows for THIS test user (scoped). Best-effort; pair with an external reaper. */
|
|
51
|
+
teardown: () => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sign in as a test user via the auth hatch, verifying the minted session against the live API before returning.
|
|
56
|
+
* Throws (never returns a trusted-but-unverified credential) if the app rejects the minted session.
|
|
57
|
+
*/
|
|
58
|
+
export async function signInAs(opts: SignInAsOptions): Promise<SignedInSession> {
|
|
59
|
+
const userId = await opts.ensureUser(opts.state, opts.user);
|
|
60
|
+
const { cookie } = await opts.mintSession(userId, opts.user);
|
|
61
|
+
|
|
62
|
+
const accepted = await opts.verify(cookie);
|
|
63
|
+
if (!accepted) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
"@suluk/journeys/hatch: the minted session was REJECTED by the live API (the auth hatch fails closed rather than manufacture a false green). Check the Better Auth session shape (cookie name/__Secure- prefix, token hashing, expiry, account-link).",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const targets = opts.cleanupTargets ?? [{ table: "session", column: "userId" }, { table: "user", column: "id" }];
|
|
70
|
+
return {
|
|
71
|
+
cookie,
|
|
72
|
+
userId,
|
|
73
|
+
async teardown() {
|
|
74
|
+
try {
|
|
75
|
+
await opts.state.d1.cleanupScope(targets);
|
|
76
|
+
} catch {
|
|
77
|
+
/* best-effort; the external reaper sweep is the backstop */
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two D1 BACKENDS (C039) — no separate test infrastructure is ever provisioned.
|
|
3
|
+
*
|
|
4
|
+
* - LOCAL : bun:sqlite directly over the miniflare local D1 file (the `wrangler dev` state). Completely local,
|
|
5
|
+
* zero-cost, zero prod risk, param-bound, full capability (a throwaway sqlite file).
|
|
6
|
+
* - REMOTE : the REAL deployment over @suluk/cloudflare's CF REST `queryD1`. Highest fidelity; the seeded entities
|
|
7
|
+
* ARE test users (BDD-as-living-documentation). The write surface is SCOPED (see state.ts) so it cannot
|
|
8
|
+
* touch real users; reaching it requires an explicit `acknowledgeRealDeployment` — a conscious choice, not
|
|
9
|
+
* an accident.
|
|
10
|
+
*/
|
|
11
|
+
import { queryD1, d1Rows, type CloudflareClient } from "@suluk/cloudflare";
|
|
12
|
+
import type { D1Exec } from "./types";
|
|
13
|
+
|
|
14
|
+
/** A D1 backend over the miniflare LOCAL sqlite file (bun:sqlite). Pass ":memory:" for tests, or the `.wrangler` path. */
|
|
15
|
+
export async function localD1(d1Path: string): Promise<D1Exec> {
|
|
16
|
+
const { Database } = await import("bun:sqlite"); // lazy: only loaded when running locally
|
|
17
|
+
const db = new Database(d1Path);
|
|
18
|
+
return {
|
|
19
|
+
kind: "local",
|
|
20
|
+
async run(sql, params) {
|
|
21
|
+
return db.query(sql).all(...((params ?? []) as never[])) as Record<string, unknown>[];
|
|
22
|
+
},
|
|
23
|
+
close() {
|
|
24
|
+
db.close();
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A D1 backend over the REAL deployment's database via the CF REST /query endpoint (params bound). */
|
|
30
|
+
export function remoteD1(cf: CloudflareClient, databaseId: string): D1Exec {
|
|
31
|
+
return {
|
|
32
|
+
kind: "remote",
|
|
33
|
+
async run(sql, params) {
|
|
34
|
+
return d1Rows(await queryD1(cf, databaseId, sql, params));
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type BackendSpec =
|
|
40
|
+
| { mode: "local"; d1Path: string }
|
|
41
|
+
| { mode: "remote"; cf: CloudflareClient; d1DatabaseId: string; acknowledgeRealDeployment: true };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a D1 backend. `local` needs nothing remote. `remote` runs against the REAL deployment and therefore requires
|
|
45
|
+
* an explicit `acknowledgeRealDeployment: true` — the operator consciously accepting that seeded rows are test users on
|
|
46
|
+
* the live database (the write surface is still test-user-scoped; see stateHatch). Fail-closed without it.
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveBackend(spec: BackendSpec): Promise<D1Exec> {
|
|
49
|
+
if (spec.mode === "local") return localD1(spec.d1Path);
|
|
50
|
+
if (!spec.acknowledgeRealDeployment) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"@suluk/journeys/hatch: a remote hatch runs against the REAL deployment — pass acknowledgeRealDeployment:true to confirm the seeded rows are test users on live data. (Prefer mode:'local' for CI; the remote write surface is test-user-scoped regardless.)",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return remoteD1(spec.cf, spec.d1DatabaseId);
|
|
56
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @suluk/journeys/hatch — the ESCAPE HATCHES (C039), a deliberately-secondary subpath. Importing it is the explicit,
|
|
3
|
+
* visible act of stepping OUT of the user-path; a scenario that composes BDD as a real user imports NONE of this.
|
|
4
|
+
*
|
|
5
|
+
* No separate test infrastructure is provisioned. A hatch runs against one of two backends:
|
|
6
|
+
* - resolveBackend({ mode: "local", d1Path }) — bun:sqlite over the miniflare local D1 (completely local).
|
|
7
|
+
* - resolveBackend({ mode: "remote", cf, d1DatabaseId, — the REAL deployment; seeded rows are TEST USERS, and the
|
|
8
|
+
* acknowledgeRealDeployment: true }) write surface is test-user-scoped so it can't touch real ones.
|
|
9
|
+
*
|
|
10
|
+
* - stateHatch — typed D1 read (default) + scoped write (seed/cleanupScope; raw exec local-only).
|
|
11
|
+
* - signInAs — the auth/OAuth hatch: mint via the app's OWN Better Auth, verified against the live API (fails
|
|
12
|
+
* closed, never a false green).
|
|
13
|
+
*
|
|
14
|
+
* Runtime IO only — provably outside the deterministic core (bind.ts / vocabulary.ts), enforced by hatch-wall.test.ts.
|
|
15
|
+
*/
|
|
16
|
+
export { resolveBackend, localD1, remoteD1, type BackendSpec } from "./backends";
|
|
17
|
+
export { stateHatch, type StateHatchOptions } from "./state";
|
|
18
|
+
export { signInAs, type SignInAsOptions, type SignedInSession, type CleanupTarget } from "./auth";
|
|
19
|
+
export type { HatchBackendKind, TestUserScope, HatchUse, TestUser, D1Exec, D1Read, D1Write, StateHatchRead, StateHatchWrite } from "./types";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The STATE HATCH (C039) over a D1 backend (local bun:sqlite OR the real deployment's CF REST). Capability-by-type:
|
|
3
|
+
* the read hatch is the default; WRITE methods exist on the returned object ONLY when `{ write: true }` is requested.
|
|
4
|
+
*
|
|
5
|
+
* TEST-USER SCOPING is the write-safety guarantee (works identically on local and prod):
|
|
6
|
+
* - `seed(table, ownerColumn, rows)` FORCES `row[ownerColumn] = scope.value` — you cannot seed a row owned by anyone
|
|
7
|
+
* but the test user.
|
|
8
|
+
* - `cleanupScope(targets)` deletes ONLY rows whose owner column equals the test user id.
|
|
9
|
+
* - `exec(rawSql)` (full, unscoped capability) is available ONLY on the LOCAL backend (a throwaway sqlite file); on
|
|
10
|
+
* the real deployment it THROWS — unscoped writes to live data are exactly the wipe-real-users risk.
|
|
11
|
+
* All values are bound params; table/column identifiers are validated.
|
|
12
|
+
*/
|
|
13
|
+
import type { D1Exec, D1Read, HatchUse, StateHatchRead, StateHatchWrite, TestUserScope } from "./types";
|
|
14
|
+
|
|
15
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
const ident = (name: string, kind: string): string => {
|
|
17
|
+
if (!IDENT.test(name)) throw new Error(`@suluk/journeys/hatch: unsafe ${kind} identifier ${JSON.stringify(name)} — only [A-Za-z0-9_] allowed (values go through bound params, identifiers cannot).`);
|
|
18
|
+
return name;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function d1Read(d1: D1Exec): D1Read {
|
|
22
|
+
return {
|
|
23
|
+
select: (sql, params) => d1.run(sql, params),
|
|
24
|
+
async get(sql, params) {
|
|
25
|
+
return (await d1.run(sql, params))[0] ?? null;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StateHatchOptions {
|
|
31
|
+
/** grant write capability (seed/cleanup, + raw exec on local). Default: read-only. */
|
|
32
|
+
write?: boolean;
|
|
33
|
+
/** the test-user scope — REQUIRED for seed/cleanup. The auth hatch supplies it. */
|
|
34
|
+
scope?: TestUserScope;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function stateHatch(d1: D1Exec): StateHatchRead;
|
|
38
|
+
export function stateHatch(d1: D1Exec, opts: StateHatchOptions & { write: true }): StateHatchWrite;
|
|
39
|
+
export function stateHatch(d1: D1Exec, opts?: StateHatchOptions): StateHatchRead | StateHatchWrite {
|
|
40
|
+
const read: StateHatchRead = { kind: d1.kind, d1: d1Read(d1) };
|
|
41
|
+
if (!opts?.write) return read;
|
|
42
|
+
|
|
43
|
+
const scope = opts.scope;
|
|
44
|
+
const needScope = (): TestUserScope => {
|
|
45
|
+
if (!scope) throw new Error("@suluk/journeys/hatch: a scoped write (seed/cleanupScope) requires a test-user scope (the seeded test user id) — so it can only ever touch that user's rows.");
|
|
46
|
+
return scope;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
kind: d1.kind,
|
|
51
|
+
scope,
|
|
52
|
+
d1: {
|
|
53
|
+
...read.d1,
|
|
54
|
+
async seed(table, ownerColumn, rows, why: HatchUse) {
|
|
55
|
+
if (!why?.because) throw new Error("@suluk/journeys/hatch: seed requires a `because` (why no user-path can do this) — recorded for the audit trail.");
|
|
56
|
+
const s = needScope();
|
|
57
|
+
const t = ident(table, "table");
|
|
58
|
+
const oc = ident(ownerColumn, "owner column");
|
|
59
|
+
for (const row of rows) {
|
|
60
|
+
const stamped = { ...row, [oc]: s.value }; // FORCE ownership to the test user — cannot seed another user's row
|
|
61
|
+
const cols = Object.keys(stamped).map((c) => ident(c, "column"));
|
|
62
|
+
const ph = cols.map(() => "?").join(", ");
|
|
63
|
+
await d1.run(`INSERT INTO ${t} (${cols.join(", ")}) VALUES (${ph})`, cols.map((c) => stamped[c]));
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
async cleanupScope(targets) {
|
|
67
|
+
const s = needScope();
|
|
68
|
+
for (const { table, column } of targets) {
|
|
69
|
+
await d1.run(`DELETE FROM ${ident(table, "table")} WHERE ${ident(column, "column")} = ?`, [s.value]);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
async exec(sql, params) {
|
|
73
|
+
if (d1.kind === "remote") {
|
|
74
|
+
throw new Error("@suluk/journeys/hatch: raw exec is refused on the REAL deployment (an unscoped write to live data) — use seed/cleanupScope (test-user-scoped), or run mode:'local'.");
|
|
75
|
+
}
|
|
76
|
+
await d1.run(sql, params);
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @suluk/journeys/hatch — types for the ESCAPE HATCHES (C039).
|
|
3
|
+
*
|
|
4
|
+
* Composing BDD AS A REAL USER (through @suluk/sdk) is the default. A hatch is the deliberately-secondary, clearly-
|
|
5
|
+
* marked escape for the cases a user-path cannot reach: AUTH bootstrap (OAuth — mint a session), an irreducible
|
|
6
|
+
* precondition with no API, internal-state inspection a Then can't observe, and teardown.
|
|
7
|
+
*
|
|
8
|
+
* NO separate test infrastructure is provisioned. A hatch runs against one of two backends:
|
|
9
|
+
* - `local` — bun:sqlite over the miniflare LOCAL D1 file (`wrangler dev` state): completely local, zero-cost,
|
|
10
|
+
* zero prod risk, full capability (it is a throwaway file).
|
|
11
|
+
* - `remote` — the REAL deployment over the CF REST API: highest fidelity, "use prod, the seeded entities ARE test
|
|
12
|
+
* users" (BDD-as-living-documentation). The write surface is SCOPED to a test user so it cannot touch
|
|
13
|
+
* real users — raw unscoped exec/delete is refused on remote.
|
|
14
|
+
*
|
|
15
|
+
* These types live OUTSIDE the deterministic core (bind.ts / vocabulary.ts): a hatch is runtime IO over live state,
|
|
16
|
+
* never an input to the contract or the request→operation matcher (the C038 wall; enforced by hatch-wall.test.ts).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The two backends a hatch can run against. */
|
|
20
|
+
export type HatchBackendKind = "local" | "remote";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A TEST-USER SCOPE — the structural write-safety guarantee, just the seeded test user's id. Every scoped write FORCES
|
|
24
|
+
* the row's owner column to this value (you cannot seed another user's row); every scoped delete is bounded to it. So a
|
|
25
|
+
* hatch can never touch a real user's data — even on the production database. The auth hatch sets this.
|
|
26
|
+
*/
|
|
27
|
+
export interface TestUserScope {
|
|
28
|
+
/** the seeded test user's id — the ONLY owner value any scoped write/delete may touch. */
|
|
29
|
+
value: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A typed marker that a scenario stepped OUT of the user-path — surfaced in the gap report so hatch use is visible. */
|
|
33
|
+
export interface HatchUse {
|
|
34
|
+
kind: "auth" | "state";
|
|
35
|
+
/** the author's justification (why a user-path can't do this) — recorded; an empty `because` is a lint smell. */
|
|
36
|
+
because: string;
|
|
37
|
+
/** did the author confirm no user-path exists? false → the linter ▲-nudges back to the front door. */
|
|
38
|
+
userPathChecked: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A seeded test user for the auth hatch. */
|
|
42
|
+
export interface TestUser {
|
|
43
|
+
email: string;
|
|
44
|
+
name?: string;
|
|
45
|
+
/** provider id link for the OAuth account row (defaults to a synthetic test id). */
|
|
46
|
+
providerAccountId?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The low-level D1 access a backend provides (params ALWAYS bound — never string-interpolated). */
|
|
50
|
+
export interface D1Exec {
|
|
51
|
+
readonly kind: HatchBackendKind;
|
|
52
|
+
run(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
|
|
53
|
+
close?(): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- capability-by-type: read methods always present; WRITE methods exist only on a write-granted hatch ----
|
|
57
|
+
|
|
58
|
+
export interface D1Read {
|
|
59
|
+
/** SELECT — values bound via params. Returns the rows of the last statement. */
|
|
60
|
+
select(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
|
|
61
|
+
/** the first row, or null. */
|
|
62
|
+
get(sql: string, params?: unknown[]): Promise<Record<string, unknown> | null>;
|
|
63
|
+
}
|
|
64
|
+
export interface D1Write extends D1Read {
|
|
65
|
+
/**
|
|
66
|
+
* Seed rows the user-path genuinely cannot create. Each row's `ownerColumn` is FORCED to the test-user scope value,
|
|
67
|
+
* so a seeded row can only ever belong to the test user. `why` is recorded (anti-rot audit trail).
|
|
68
|
+
*/
|
|
69
|
+
seed(table: string, ownerColumn: string, rows: Record<string, unknown>[], why: HatchUse): Promise<void>;
|
|
70
|
+
/** delete the test user's rows across the given `{ table, column }` targets (the scoped teardown). Requires a scope. */
|
|
71
|
+
cleanupScope(targets: { table: string; column: string }[]): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Raw SQL (full capability) — available ONLY on the LOCAL backend (a throwaway sqlite file). On the remote/real
|
|
74
|
+
* deployment this THROWS: unscoped writes against live data are the wipe-real-users risk; use `seed`/`cleanupScope`.
|
|
75
|
+
*/
|
|
76
|
+
exec(sql: string, params?: unknown[]): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface StateHatchRead {
|
|
80
|
+
kind: HatchBackendKind;
|
|
81
|
+
d1: D1Read;
|
|
82
|
+
}
|
|
83
|
+
export interface StateHatchWrite {
|
|
84
|
+
kind: HatchBackendKind;
|
|
85
|
+
scope?: TestUserScope;
|
|
86
|
+
d1: D1Write;
|
|
87
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -24,13 +24,17 @@ export { parseFeature, type Feature, type Scenario, type FeatureStep } from "./g
|
|
|
24
24
|
|
|
25
25
|
export {
|
|
26
26
|
bindFeatures,
|
|
27
|
+
detectUndefined,
|
|
27
28
|
renderGapReport,
|
|
29
|
+
renderScaffold,
|
|
28
30
|
type GapReport,
|
|
29
31
|
type ScenarioResult,
|
|
30
32
|
type StepResult,
|
|
31
33
|
type BindState,
|
|
32
34
|
type BindOptions,
|
|
33
35
|
type CoverageHole,
|
|
36
|
+
type Definitions,
|
|
37
|
+
type UndefinedStep,
|
|
34
38
|
} from "./bind";
|
|
35
39
|
|
|
36
40
|
export { emitRunnableSuite, type EmitOptions } from "./emit";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* THE CONTRACT WALL (C039) — the security council's strongest, verified property: the deterministic core of
|
|
7
|
+
* @suluk/journeys (the vocabulary projection + the binder) must NEVER transitively reach the escape hatches, the CF
|
|
8
|
+
* write-client, raw fetch, or env. A hatch is runtime IO over live state; it is invisible to the contract and the
|
|
9
|
+
* request→operation matcher. Subpath naming alone is not compiler-enforced, so this asserts it over the source.
|
|
10
|
+
*/
|
|
11
|
+
const src = (rel: string) => readFileSync(join(import.meta.dir, "..", "src", rel), "utf8");
|
|
12
|
+
|
|
13
|
+
// The deterministic projector/binder core: produces the contract-derived artifacts and the bind decision. It must do
|
|
14
|
+
// NO runtime IO and reach NOTHING in the hatch/CF/env/fetch world — not even reference it.
|
|
15
|
+
const PROJECTOR_CORE = ["vocabulary.ts", "bind.ts", "gherkin.ts", "normalize.ts"];
|
|
16
|
+
const NO_IO = [/from\s+["']\.{1,2}\/hatch/, /@suluk\/cloudflare/, /@suluk\/env/, /\bprocess\.env\b/, /\bfetch\s*\(/];
|
|
17
|
+
|
|
18
|
+
// The emitter + the package barrel: the emitter is the user-path bridge (it may import @suluk/sdk and EMIT a
|
|
19
|
+
// `process.env.SULUK_BASE_URL` string into the generated suite), but neither may import the hatch or the CF write-client.
|
|
20
|
+
const BRIDGE = ["emit.ts", "index.ts"];
|
|
21
|
+
const NO_HATCH = [/from\s+["']\.{1,2}\/hatch/, /@suluk\/cloudflare/];
|
|
22
|
+
|
|
23
|
+
describe("C039 contract wall — the deterministic core never reaches the hatch / CF write-client / fetch / env", () => {
|
|
24
|
+
for (const file of PROJECTOR_CORE) {
|
|
25
|
+
test(`src/${file}: no hatch, no @suluk/cloudflare, no env, no fetch (pure projection)`, () => {
|
|
26
|
+
for (const pattern of NO_IO) expect(pattern.test(src(file))).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
for (const file of BRIDGE) {
|
|
30
|
+
test(`src/${file}: imports neither the hatch nor the CF write-client`, () => {
|
|
31
|
+
for (const pattern of NO_HATCH) expect(pattern.test(src(file))).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
test("the hatch DOES live in its own subtree (the CF client is imported only under hatch/, never in the core)", () => {
|
|
35
|
+
expect(/@suluk\/cloudflare/.test(src("hatch/backends.ts"))).toBe(true);
|
|
36
|
+
expect(/@suluk\/cloudflare/.test(src("index.ts"))).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { localD1, remoteD1, resolveBackend } from "../src/hatch/backends";
|
|
4
|
+
import { stateHatch } from "../src/hatch/state";
|
|
5
|
+
import { CloudflareClient } from "@suluk/cloudflare";
|
|
6
|
+
import type { D1Exec, HatchUse } from "../src/hatch/types";
|
|
7
|
+
|
|
8
|
+
const WHY: HatchUse = { kind: "state", because: "OAuth-only signup; no API seeds a verified user", userPathChecked: true };
|
|
9
|
+
|
|
10
|
+
/** A CloudflareClient whose injected fetch records D1 /query bodies and returns canned envelopes (no network). */
|
|
11
|
+
function mockRemote() {
|
|
12
|
+
const bodies: { sql: string; params?: unknown[] }[] = [];
|
|
13
|
+
const fetchImpl = (async (_url: unknown, init: { body?: string }) => {
|
|
14
|
+
if (init.body) bodies.push(JSON.parse(init.body));
|
|
15
|
+
return { ok: true, status: 200, statusText: "OK", text: async () => JSON.stringify({ success: true, result: [{ results: [], success: true }] }) } as unknown as Response;
|
|
16
|
+
}) as unknown as typeof fetch;
|
|
17
|
+
const cf = new CloudflareClient({ apiToken: "t", accountId: "acct", fetch: fetchImpl });
|
|
18
|
+
return { d1: remoteD1(cf, "db"), bodies };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("backends — local (bun:sqlite) is completely local; remote needs an explicit acknowledgement", () => {
|
|
22
|
+
test("resolveBackend(local) opens the sqlite file; no remote needed", async () => {
|
|
23
|
+
const b = await resolveBackend({ mode: "local", d1Path: ":memory:" });
|
|
24
|
+
expect(b.kind).toBe("local");
|
|
25
|
+
b.close?.();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("resolveBackend(remote) THROWS without acknowledgeRealDeployment (it is the real deployment)", async () => {
|
|
29
|
+
await expect(resolveBackend({ mode: "remote", cf: {} as never, d1DatabaseId: "db" } as never)).rejects.toThrow(/acknowledgeRealDeployment/);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("stateHatch over LOCAL (the completely-local path, fully exercised)", () => {
|
|
34
|
+
let d1: D1Exec;
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
d1 = await localD1(":memory:");
|
|
37
|
+
await d1.run("CREATE TABLE credits (id TEXT, userId TEXT, balance INTEGER)");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("read-only by default — write methods are absent", () => {
|
|
41
|
+
const h = stateHatch(d1);
|
|
42
|
+
expect(typeof h.d1.select).toBe("function");
|
|
43
|
+
expect((h.d1 as unknown as Record<string, unknown>).seed).toBeUndefined();
|
|
44
|
+
expect((h.d1 as unknown as Record<string, unknown>).exec).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("seed FORCES the owner column to the test-user id, then select reads it back (real round-trip)", async () => {
|
|
48
|
+
const h = stateHatch(d1, { write: true, scope: { value: "testuser_1" } });
|
|
49
|
+
// even if the caller passes a different userId, seed overwrites it to the scope value:
|
|
50
|
+
await h.d1.seed("credits", "userId", [{ id: "c1", userId: "SOMEONE_ELSE", balance: 5 }], WHY);
|
|
51
|
+
const rows = await h.d1.select("SELECT id, userId, balance FROM credits");
|
|
52
|
+
expect(rows).toEqual([{ id: "c1", userId: "testuser_1", balance: 5 }]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("cleanupScope deletes ONLY the test user's rows", async () => {
|
|
56
|
+
const h = stateHatch(d1, { write: true, scope: { value: "testuser_1" } });
|
|
57
|
+
await h.d1.seed("credits", "userId", [{ id: "c1", balance: 5 }], WHY);
|
|
58
|
+
await d1.run("INSERT INTO credits (id, userId, balance) VALUES (?,?,?)", ["c2", "REAL_USER", 99]); // a 'real' row
|
|
59
|
+
await h.d1.cleanupScope([{ table: "credits", column: "userId" }]);
|
|
60
|
+
const left = await h.d1.select("SELECT userId FROM credits");
|
|
61
|
+
expect(left).toEqual([{ userId: "REAL_USER" }]); // the real user's row is untouched
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("raw exec IS available on local (throwaway sqlite, full capability)", async () => {
|
|
65
|
+
const h = stateHatch(d1, { write: true, scope: { value: "t" } });
|
|
66
|
+
await h.d1.exec("DELETE FROM credits"); // allowed locally
|
|
67
|
+
expect(await h.d1.select("SELECT * FROM credits")).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("seed requires a `because`, validates identifiers, and a scoped write needs a scope", async () => {
|
|
71
|
+
const h = stateHatch(d1, { write: true, scope: { value: "t" } });
|
|
72
|
+
await expect(h.d1.seed("credits", "userId", [{ id: "x" }], { kind: "state", because: "", userPathChecked: true })).rejects.toThrow(/because/);
|
|
73
|
+
await expect(h.d1.seed("credits; DROP TABLE credits", "userId", [{ id: "x" }], WHY)).rejects.toThrow(/identifier/);
|
|
74
|
+
const noScope = stateHatch(d1, { write: true });
|
|
75
|
+
await expect(noScope.d1.cleanupScope([{ table: "credits", column: "userId" }])).rejects.toThrow(/scope/);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("stateHatch over REMOTE (the real deployment) — writes are bound to params and refuse unscoped exec", () => {
|
|
80
|
+
test("seed sends a parameterized INSERT with the owner forced to the test user", async () => {
|
|
81
|
+
const { d1, bodies } = mockRemote();
|
|
82
|
+
const h = stateHatch(d1, { write: true, scope: { value: "testuser_1" } });
|
|
83
|
+
await h.d1.seed("user", "id", [{ id: "ignored", email: "bdd@example.test" }], WHY);
|
|
84
|
+
expect(bodies[0].sql).toContain("INSERT INTO user");
|
|
85
|
+
expect(bodies[0].params).toEqual(["testuser_1", "bdd@example.test"]); // id forced to the scope value; values bound
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("raw exec is REFUSED on the real deployment (unscoped write to live data)", async () => {
|
|
89
|
+
const { d1 } = mockRemote();
|
|
90
|
+
const h = stateHatch(d1, { write: true, scope: { value: "t" } });
|
|
91
|
+
await expect(h.d1.exec("DELETE FROM user")).rejects.toThrow(/refused on the REAL deployment/);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/test/journeys.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { test, expect, describe } from "bun:test";
|
|
|
2
2
|
import type { OpenAPIv4Document } from "@suluk/core";
|
|
3
3
|
import { generateVocabulary, vocabularyHash, opHandle } from "../src/vocabulary";
|
|
4
4
|
import { parseFeature } from "../src/gherkin";
|
|
5
|
-
import { bindFeatures } from "../src/bind";
|
|
5
|
+
import { bindFeatures, detectUndefined, renderScaffold } from "../src/bind";
|
|
6
6
|
import { emitRunnableSuite } from "../src/emit";
|
|
7
7
|
|
|
8
8
|
/** A minimal v4 fixture echoing real toolfactory shapes (incl. the shared `api/billing/subscription` path). */
|
|
@@ -170,3 +170,112 @@ Feature: f
|
|
|
170
170
|
expect(suite).toContain("requires authenticated");
|
|
171
171
|
});
|
|
172
172
|
});
|
|
173
|
+
|
|
174
|
+
describe("most-recent-When subject — multi-step journeys bind each outcome to its own action", () => {
|
|
175
|
+
const vocab = generateVocabulary(DOC);
|
|
176
|
+
test("a second When re-targets the following Then (not the first action)", () => {
|
|
177
|
+
const feature = parseFeature(`
|
|
178
|
+
Feature: f
|
|
179
|
+
Scenario: top up then check balance
|
|
180
|
+
Given I am a signed-in user
|
|
181
|
+
When I checkout
|
|
182
|
+
Then it succeeds
|
|
183
|
+
When I view credits
|
|
184
|
+
Then I see my credits
|
|
185
|
+
`);
|
|
186
|
+
const r = bindFeatures(vocab, [feature]).scenarios[0].results;
|
|
187
|
+
expect(r[1].handle).toBe(opHandle("checkout", "api/billing/checkout")); // When I checkout
|
|
188
|
+
expect(r[2].handle).toBe(opHandle("checkout", "api/billing/checkout")); // Then it succeeds → checkout
|
|
189
|
+
expect(r[3].handle).toBe(opHandle("getCredits", "api/credits")); // When I view credits (subject moves)
|
|
190
|
+
expect(r[4].state).toBe("BOUND"); // Then I see my credits → getCredits, NOT checkout
|
|
191
|
+
expect(r[4].handle).toBe(opHandle("getCredits", "api/credits"));
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("composition — compose a story out of a named journey (no developer)", () => {
|
|
196
|
+
const vocab = generateVocabulary(DOC);
|
|
197
|
+
const definitions = { journeys: { "top up": ["Given I am a signed-in user", "When I checkout", "Then it succeeds"] } };
|
|
198
|
+
|
|
199
|
+
test("a journey reference expands into its (already-bound) steps", () => {
|
|
200
|
+
const feature = parseFeature(`
|
|
201
|
+
Feature: f
|
|
202
|
+
Scenario: onboarding
|
|
203
|
+
When I complete the "top up" journey
|
|
204
|
+
When I view credits
|
|
205
|
+
Then I see my credits
|
|
206
|
+
`);
|
|
207
|
+
const r = bindFeatures(vocab, [feature], { definitions }).scenarios[0].results;
|
|
208
|
+
// the journey ref expanded to 3 steps, all bound, each tagged with the original prose
|
|
209
|
+
expect(r.length).toBe(5);
|
|
210
|
+
expect(r.slice(0, 3).every((x) => x.state === "BOUND")).toBe(true);
|
|
211
|
+
expect(r[0].expandedFrom?.text).toContain('complete the "top up" journey');
|
|
212
|
+
expect(r[1].handle).toBe(opHandle("checkout", "api/billing/checkout"));
|
|
213
|
+
expect(r[4].handle).toBe(opHandle("getCredits", "api/credits"));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a reference to an UNDEFINED journey is flagged UNDEFINED (scaffolder defines it, no dev)", () => {
|
|
217
|
+
const feature = parseFeature(`
|
|
218
|
+
Feature: f
|
|
219
|
+
Scenario: missing
|
|
220
|
+
When I complete the "checkout flow" journey
|
|
221
|
+
`);
|
|
222
|
+
const r = bindFeatures(vocab, [feature]).scenarios[0].results;
|
|
223
|
+
expect(r[0].state).toBe("UNDEFINED");
|
|
224
|
+
expect(r[0].suggest).toContain("checkout flow");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("two-role authoring — free prose, a scaffolder maps it, undefined is detected", () => {
|
|
229
|
+
const vocab = generateVocabulary(DOC);
|
|
230
|
+
|
|
231
|
+
test("a free-prose decomposition makes a non-technical author's step run (no dev)", () => {
|
|
232
|
+
const feature = parseFeature(`
|
|
233
|
+
Feature: f
|
|
234
|
+
Scenario: combined
|
|
235
|
+
When I sign up and buy credits
|
|
236
|
+
`);
|
|
237
|
+
const definitions = { steps: { "when i sign up and buy credits": ["Given I am a signed-in user", "When I checkout", "Then it succeeds"] } };
|
|
238
|
+
const r = bindFeatures(vocab, [feature], { definitions }).scenarios[0].results;
|
|
239
|
+
expect(r.length).toBe(3);
|
|
240
|
+
expect(r.every((x) => x.state === "BOUND")).toBe(true);
|
|
241
|
+
expect(r[1].expandedFrom?.text).toBe("I sign up and buy credits");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("detectUndefined splits 'scaffolder can define' (alias) from 'escalate to a developer'", () => {
|
|
245
|
+
const feature = parseFeature(`
|
|
246
|
+
Feature: f
|
|
247
|
+
Scenario: free prose
|
|
248
|
+
Given I am a signed-in user
|
|
249
|
+
When I cancel my subscription
|
|
250
|
+
Then I get a refund to my card
|
|
251
|
+
`);
|
|
252
|
+
const undefinedSteps = detectUndefined(vocab, [feature]);
|
|
253
|
+
const cancel = undefinedSteps.find((u) => /cancel my subscription/i.test(u.text))!;
|
|
254
|
+
expect(cancel.resolution).toBe("alias");
|
|
255
|
+
expect(cancel.suggestion).toContain("When I cancel subscription"); // the canonical step to map to
|
|
256
|
+
const refund = undefinedSteps.find((u) => /refund/i.test(u.text))!;
|
|
257
|
+
expect(refund.resolution).toBe("review"); // no lexical match — the scaffolder decides (NOT falsely 'needs a dev')
|
|
258
|
+
|
|
259
|
+
const scaffold = renderScaffold(undefinedSteps);
|
|
260
|
+
expect(scaffold).toContain("Suggested mappings");
|
|
261
|
+
expect(scaffold).toContain("Needs your decision");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("emit — a composed/multi-step journey lowers to a sequence of SDK calls", () => {
|
|
266
|
+
const vocab = generateVocabulary(DOC);
|
|
267
|
+
test("two bound Whens emit two client calls in order", () => {
|
|
268
|
+
const feature = parseFeature(`
|
|
269
|
+
Feature: f
|
|
270
|
+
Scenario: top up then check
|
|
271
|
+
Given I am a signed-in user
|
|
272
|
+
When I checkout
|
|
273
|
+
Then it succeeds
|
|
274
|
+
When I view credits
|
|
275
|
+
Then I see my credits
|
|
276
|
+
`);
|
|
277
|
+
const suite = emitRunnableSuite(DOC, vocab, [feature]);
|
|
278
|
+
expect(suite).toContain("const result1 = await client.billing.checkout(");
|
|
279
|
+
expect(suite).toContain("const result2 = await client.credits.get(");
|
|
280
|
+
});
|
|
281
|
+
});
|