@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 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.1.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, plus the bidirectional, tri-state GAP report.
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 are resolved RELATIVE to the scenario's WHEN-subject (the operation the scenario is about),
7
- * not via a global phrase→handle map otherwise a generic "Then it succeeds" (shared by ~every op) mis-binds to
8
- * an arbitrary one. This subject-relative rule is the spike-witnessed correction on the toolfactory contract.
9
- * - Otherwise the step is UNBOUND, then DETERMINISTICALLY classified into a tri-state for a human:
10
- * PARAPHRASE (an author-owned alias resolves it no dev), NEEDS-DEV-GLUE (an operation exists but no step wires
11
- * it), NEEDS-CONTRACT (nothing backs the intent — a dev extends the contract).
12
- * - A WHEN phrase shared by >1 operation is AMBIGUOUS (a disambiguation the projector must resolve).
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 resolved subject operation handle (the scenario's When-op), if any. */
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
- /** an author-owned synonym map: normalized authored skeleton a canonical generated skeleton. Resolves PARAPHRASE without a dev. */
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
- const res = (step: FeatureStep, state: BindState, handle = "", via = "", suggest = ""): StepResult => ({ step, state, handle, via, suggest });
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, "", `try "${best.s.phrase}" — or add it to your alias map (no dev)`);
101
- // does any operation relate? (a real op exists, but no generated phrase matches the author's wording)
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.op.handle, "", `relates to '${opBest.op.name}' (${opBest.op.method.toUpperCase()} ${opBest.op.path}) — wire or alias a step`);
108
- return res(step, "NEEDS-CONTRACT", "", "", "no operation backs this intent — a developer adds it to the 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 bindStep(step: FeatureStep, subject: string, vocab: Vocabulary, idx: Indexes, aliases: Record<string, string>): StepResult {
112
- let skel = norm(`${step.kind} ${step.text}`); // resolved keyword (And/But already folded by the parser)
113
- if (aliases[skel]) skel = aliases[skel]; // author-owned alias layer (presentational; never widens the matcher)
114
-
115
- if (step.kind === "given") {
116
- if (idx.givenSkeletons.has(skel)) return res(step, "BOUND", "@access:authenticated", "x-suluk-access");
117
- return classifyUnbound(step, vocab, idx);
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 (step.kind === "when") {
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", "", "", `${hit.length} operations render this phrase — the projector must disambiguate`);
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 scenario subject.
126
- const thens = idx.thenByHandle.get(subject);
127
- if (thens?.has(skel)) return res(step, "BOUND", subject, "outcome of the scenario's When-subject");
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 bidirectional gap report. */
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 aliases = opts.aliases ?? {};
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
- // subject = the first When-step that binds to exactly one operation.
142
- let subject = "";
143
- for (const step of sc.steps) {
144
- if (step.kind !== "when") continue;
145
- const hit = idx.whenBySkeleton.get(norm(`when ${step.text}`));
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" && step.kind === "when") coveredWhen.add(r.handle);
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
- out.push(` ${r.step.kind.toUpperCase().padEnd(5)} ${r.step.text}`);
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
- if (!sc.subject) continue; // only emit scenarios whose When-action bound to an operation
62
- const op = opByHandle.get(sc.subject);
63
- if (!op) {
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
- body.push(` // ${op.method.toUpperCase()} ${op.uri}${op.requires !== "anyone" ? ` (requires ${op.requires}: set ${tokenEnv})` : ""}`);
71
- body.push(` const result = await client.${acc}(/* provide input */);`);
72
- if (expectsSuccess) body.push(` expect(result).toBeDefined();`);
73
- // C037 store assertions: a mutation should refresh the stores it invalidates.
74
- for (const key of op.store?.invalidates ?? []) body.push(` // store: expect $${key} to have refreshed after this mutation`);
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
+ });
@@ -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
+ });