@suluk/journeys 0.1.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 +80 -0
- package/package.json +32 -0
- package/src/bind.ts +189 -0
- package/src/emit.ts +80 -0
- package/src/gherkin.ts +79 -0
- package/src/index.ts +36 -0
- package/src/normalize.ts +47 -0
- package/src/vocabulary.ts +136 -0
- package/test/journeys.test.ts +172 -0
- package/tsconfig.json +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @suluk/journeys
|
|
2
|
+
|
|
3
|
+
**Intuitive, runnable BDD over a v4 "Suluk" contract** — a non-technical author (PM / BA / QA) writes Gherkin
|
|
4
|
+
user-stories and journeys against a step vocabulary *generated from the contract*, and a bidirectional gap report
|
|
5
|
+
tells everyone exactly what the contract can and cannot yet back.
|
|
6
|
+
|
|
7
|
+
> **CANDIDATE tooling — not official OpenAPI.** Suluk is a single-contributor candidate for OpenAPI v4.0 ("Moonwalk"),
|
|
8
|
+
> unaffiliated with the OpenAPI Initiative. See [ADR C038](../../../../doc/architecture/decisions/C038-suluk-journeys-bdd.md).
|
|
9
|
+
|
|
10
|
+
## The loop
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
contract ──generateVocabulary──▶ step palette ──▶ humans author .feature stories
|
|
14
|
+
▲ │
|
|
15
|
+
└── dev fills the gap ◀── bidirectional gap report ◀── bindFeatures
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
1. **`generateVocabulary(doc)`** projects the contract into a deterministic Gherkin step palette:
|
|
19
|
+
- **Given** ← `x-suluk-access` (`authenticated` → *"Given I am a signed-in user"*)
|
|
20
|
+
- **When** ← each operation (`checkout` → *"When I checkout"*, `getCredits` → *"When I view credits"*)
|
|
21
|
+
- **Then** ← declared statuses (*"Then it succeeds"*), `x-suluk-store` (*"Then my credits refreshes"*), per-unit
|
|
22
|
+
`x-suluk-cost` (*"Then I am charged credits"*)
|
|
23
|
+
2. A non-technical author writes plain `.feature` files against that palette (the prose lives in a **sidecar**, never
|
|
24
|
+
in the contract — the [D1 wall](../../../../doc/architecture/decisions/C038-suluk-journeys-bdd.md)).
|
|
25
|
+
3. **`bindFeatures(vocab, features)`** binds each step **exact-or-UNBOUND**, with outcome (`Then`) steps resolved
|
|
26
|
+
relative to the scenario's `When`-subject, and reports the gaps **both ways**.
|
|
27
|
+
|
|
28
|
+
## What a "gap" is — bidirectional, tri-state
|
|
29
|
+
|
|
30
|
+
- **authored → contract.** An unbound step is classified deterministically:
|
|
31
|
+
- **PARAPHRASE** — you wrote it differently; an author-owned alias resolves it, *no developer needed*.
|
|
32
|
+
- **NEEDS-DEV-GLUE** — the operation exists, but no step wires it; a developer adds one.
|
|
33
|
+
- **NEEDS-CONTRACT** — nothing backs the intent; a developer extends the contract.
|
|
34
|
+
- **contract → authored.** A pure set-difference over the stable handle space surfaces every operation/store with **no
|
|
35
|
+
covering scenario** — the *"complete"* guarantee — and emits a drop-in stub for each.
|
|
36
|
+
|
|
37
|
+
Binding never uses scoring, lemmatization, or embeddings — those would make the decision non-deterministic. String
|
|
38
|
+
similarity appears only in the *presentational* "did you mean?" suggestion on an already-unbound step.
|
|
39
|
+
|
|
40
|
+
## Runnable — and it partly tests your frontend
|
|
41
|
+
|
|
42
|
+
`emitRunnableSuite(vocab, features)` lowers bound scenarios to a self-contained `bun:test` suite driven through
|
|
43
|
+
**`@suluk/sdk`'s generated client** — the same client your frontend ships on. A green scenario exercises the real
|
|
44
|
+
frontend **data-path**: typed dispatch, input validation, the auth interceptor, response decode, and the C037 store
|
|
45
|
+
invalidation/refetch. **Honest boundary:** it tests client + contract + wire + the store data layer — **not** rendered
|
|
46
|
+
UI, layout, or visual behavior (there is no DOM in a `bun:test`). That last mile is `@suluk/visual` + a browser.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { generateVocabulary, parseFeature, bindFeatures, renderGapReport, renderPhrasebook } from "@suluk/journeys";
|
|
52
|
+
import { apiDocument } from "./contract"; // your v4 contract
|
|
53
|
+
|
|
54
|
+
const vocab = generateVocabulary(apiDocument());
|
|
55
|
+
console.log(renderPhrasebook(vocab)); // the palette an author picks from
|
|
56
|
+
|
|
57
|
+
const feature = parseFeature(await Bun.file("./billing.feature").text());
|
|
58
|
+
const report = bindFeatures(vocab, [feature], {
|
|
59
|
+
aliases: { "given i am a logged in user": "given i am a signed-in user" }, // author-owned, no dev
|
|
60
|
+
});
|
|
61
|
+
console.log(renderGapReport(report));
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Stable identity
|
|
65
|
+
|
|
66
|
+
Step identity is `op.name@path-uri` (the by-name handle), **not** the `@suluk/sdk` client accessor — `resolveOps`
|
|
67
|
+
mutates the accessor in place during collision resolution, so accessor-keyed identity would churn when a sibling
|
|
68
|
+
operation is added. (Witnessed on toolfactory's `api/billing/subscription`, which holds both `getSubscription` and
|
|
69
|
+
`cancelSubscription`.)
|
|
70
|
+
|
|
71
|
+
## Discovery (designed, gated)
|
|
72
|
+
|
|
73
|
+
A semantic *reuse* search — "find an existing flow to reuse / modify / rebuild" — is designed (a deterministic
|
|
74
|
+
faceted handle-index inside this package; the reuse verdict is set algebra over contract-handle overlap; an embedding
|
|
75
|
+
overlay is a walled-off, gated sibling). It is **deferred until a real corpus exists**. See ADR C038.
|
|
76
|
+
|
|
77
|
+
## Status
|
|
78
|
+
|
|
79
|
+
`0.1.0`, ceiling **0.45** — originated, projection-model-native, spike-witnessed on the real toolfactory contract; the
|
|
80
|
+
open question is whether the constrained-vocabulary-plus-alias UX feels intuitive to a real non-technical author.
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@suluk/journeys",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MahmoodKhalil57/suluk.git",
|
|
12
|
+
"directory": "tooling/ts/packages/journeys"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
|
|
15
|
+
"bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@suluk/core": "^0.1.13",
|
|
23
|
+
"@suluk/sdk": "^0.2.2"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/bun": "latest"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"typecheck": "tsc --noEmit -p ."
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/bind.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The BINDER (C038): authored .feature steps → contract handles, plus the bidirectional, tri-state GAP report.
|
|
3
|
+
*
|
|
4
|
+
* The decision rule is EXACT-or-UNBOUND and statically decidable:
|
|
5
|
+
* - 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).
|
|
13
|
+
*
|
|
14
|
+
* No scoring/lemmatization/embedding EVER decides a bind. Token similarity appears ONLY to rank the presentational
|
|
15
|
+
* "did you mean?" suggestion on an already-UNBOUND step.
|
|
16
|
+
*/
|
|
17
|
+
import { camel, jaccard, norm, tok } from "./normalize";
|
|
18
|
+
import type { FeatureStep, Feature } from "./gherkin";
|
|
19
|
+
import type { JourneyStep, Vocabulary } from "./vocabulary";
|
|
20
|
+
|
|
21
|
+
export type BindState = "BOUND" | "PARAPHRASE" | "NEEDS-DEV-GLUE" | "NEEDS-CONTRACT" | "AMBIGUOUS";
|
|
22
|
+
|
|
23
|
+
export interface StepResult {
|
|
24
|
+
step: FeatureStep;
|
|
25
|
+
state: BindState;
|
|
26
|
+
/** the bound (or suggested) handle, when there is one. */
|
|
27
|
+
handle: string;
|
|
28
|
+
/** provenance of a BOUND step. */
|
|
29
|
+
via: string;
|
|
30
|
+
/** a human next-action for a non-BOUND step. */
|
|
31
|
+
suggest: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ScenarioResult {
|
|
35
|
+
scenario: string;
|
|
36
|
+
rule?: string;
|
|
37
|
+
/** the resolved subject operation handle (the scenario's When-op), if any. */
|
|
38
|
+
subject: string;
|
|
39
|
+
results: StepResult[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CoverageHole {
|
|
43
|
+
handle: string;
|
|
44
|
+
name: string;
|
|
45
|
+
/** a one-line drop-in stub scenario to cover this operation. */
|
|
46
|
+
stub: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface GapReport {
|
|
50
|
+
scenarios: ScenarioResult[];
|
|
51
|
+
counts: Record<BindState, number>;
|
|
52
|
+
coverage: {
|
|
53
|
+
total: number;
|
|
54
|
+
covered: number;
|
|
55
|
+
holes: CoverageHole[];
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface BindOptions {
|
|
60
|
+
/** an author-owned synonym map: normalized authored skeleton → a canonical generated skeleton. Resolves PARAPHRASE without a dev. */
|
|
61
|
+
aliases?: Record<string, string>;
|
|
62
|
+
/** how many coverage-hole stubs to emit (default: all). */
|
|
63
|
+
maxHoles?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Indexes {
|
|
67
|
+
whenBySkeleton: Map<string, JourneyStep[]>;
|
|
68
|
+
thenByHandle: Map<string, Set<string>>;
|
|
69
|
+
givenSkeletons: Set<string>;
|
|
70
|
+
whenSteps: JourneyStep[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildIndexes(vocab: Vocabulary): Indexes {
|
|
74
|
+
const whenBySkeleton = new Map<string, JourneyStep[]>();
|
|
75
|
+
const thenByHandle = new Map<string, Set<string>>();
|
|
76
|
+
const givenSkeletons = new Set<string>();
|
|
77
|
+
const whenSteps: JourneyStep[] = [];
|
|
78
|
+
for (const s of vocab.steps) {
|
|
79
|
+
if (s.kind === "given") givenSkeletons.add(s.skeleton);
|
|
80
|
+
else if (s.kind === "when") {
|
|
81
|
+
(whenBySkeleton.get(s.skeleton) ?? whenBySkeleton.set(s.skeleton, []).get(s.skeleton)!).push(s);
|
|
82
|
+
whenSteps.push(s);
|
|
83
|
+
} else {
|
|
84
|
+
(thenByHandle.get(s.handle) ?? thenByHandle.set(s.handle, new Set()).get(s.handle)!).add(s.skeleton);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { whenBySkeleton, thenByHandle, givenSkeletons, whenSteps };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const res = (step: FeatureStep, state: BindState, handle = "", via = "", suggest = ""): StepResult => ({ step, state, handle, via, suggest });
|
|
91
|
+
|
|
92
|
+
function classifyUnbound(step: FeatureStep, vocab: Vocabulary, idx: Indexes): StepResult {
|
|
93
|
+
const st = tok(step.text);
|
|
94
|
+
// best WHEN phrase (paraphrase suggestion) — presentational only.
|
|
95
|
+
let best: { s: JourneyStep; score: number } | null = null;
|
|
96
|
+
for (const w of idx.whenSteps) {
|
|
97
|
+
const score = jaccard(st, tok(w.phrase));
|
|
98
|
+
if (!best || score > best.score) best = { s: w, score };
|
|
99
|
+
}
|
|
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;
|
|
103
|
+
for (const op of vocab.operations) {
|
|
104
|
+
const score = jaccard(st, tok(camel(op.name)));
|
|
105
|
+
if (!opBest || score > opBest.score) opBest = { op, score };
|
|
106
|
+
}
|
|
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");
|
|
109
|
+
}
|
|
110
|
+
|
|
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);
|
|
118
|
+
}
|
|
119
|
+
if (step.kind === "when") {
|
|
120
|
+
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);
|
|
124
|
+
}
|
|
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);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Bind a parsed feature set against the vocabulary and produce the bidirectional gap report. */
|
|
132
|
+
export function bindFeatures(vocab: Vocabulary, features: Feature[], opts: BindOptions = {}): GapReport {
|
|
133
|
+
const idx = buildIndexes(vocab);
|
|
134
|
+
const aliases = opts.aliases ?? {};
|
|
135
|
+
const scenarios: ScenarioResult[] = [];
|
|
136
|
+
const counts: Record<BindState, number> = { BOUND: 0, PARAPHRASE: 0, "NEEDS-DEV-GLUE": 0, "NEEDS-CONTRACT": 0, AMBIGUOUS: 0 };
|
|
137
|
+
const coveredWhen = new Set<string>();
|
|
138
|
+
|
|
139
|
+
for (const feat of features) {
|
|
140
|
+
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);
|
|
153
|
+
counts[r.state]++;
|
|
154
|
+
if (r.state === "BOUND" && step.kind === "when") coveredWhen.add(r.handle);
|
|
155
|
+
return r;
|
|
156
|
+
});
|
|
157
|
+
scenarios.push({ scenario: sc.name, rule: sc.rule, subject, results });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// direction (ii): contract → authored coverage holes.
|
|
162
|
+
const whenByHandle = new Map(idx.whenSteps.map((s) => [s.handle, s]));
|
|
163
|
+
const holesAll = vocab.operations.filter((op) => !coveredWhen.has(op.handle));
|
|
164
|
+
const holes: CoverageHole[] = holesAll.slice(0, opts.maxHoles ?? holesAll.length).map((op) => {
|
|
165
|
+
const when = whenByHandle.get(op.handle);
|
|
166
|
+
const given = op.access === "authenticated" ? " Given I am a signed-in user\n" : "";
|
|
167
|
+
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
|
+
});
|
|
169
|
+
|
|
170
|
+
return { scenarios, counts, coverage: { total: vocab.operations.length, covered: vocab.operations.length - holesAll.length, holes } };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Render the gap report as readable text (for a CLI / a download endpoint). */
|
|
174
|
+
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" };
|
|
176
|
+
const out: string[] = [];
|
|
177
|
+
for (const sc of report.scenarios) {
|
|
178
|
+
out.push(`\n Scenario: ${sc.scenario}${sc.rule ? ` (Rule: ${sc.rule})` : ""}`);
|
|
179
|
+
for (const r of sc.results) {
|
|
180
|
+
out.push(` ${r.step.kind.toUpperCase().padEnd(5)} ${r.step.text}`);
|
|
181
|
+
out.push(` → ${TAG[r.state]}${r.handle ? ` [${r.handle}]` : ""}`);
|
|
182
|
+
if (r.suggest) out.push(` ↳ ${r.suggest}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
out.push(`\n GAP SUMMARY ${Object.entries(report.counts).filter(([, v]) => v).map(([k, v]) => `${k}: ${v}`).join(" ")}`);
|
|
186
|
+
out.push(` COVERAGE ${report.coverage.covered}/${report.coverage.total} operations covered; ${report.coverage.holes.length} stub(s) below.`);
|
|
187
|
+
for (const h of report.coverage.holes) out.push(h.stub);
|
|
188
|
+
return out.join("\n");
|
|
189
|
+
}
|
package/src/emit.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RUNNABLE emitter (C038): a bound feature set → a self-contained `bun:test` suite that drives each scenario
|
|
3
|
+
* through the consumer's GENERATED @suluk/sdk client (+ its reactive stores), against a LIVE deployment.
|
|
4
|
+
*
|
|
5
|
+
* Why the SDK client and not raw HTTP (the distinction from @suluk/testgen, which calls a raw `fetch(BASE+path)`
|
|
6
|
+
* harness): the SDK client is the SAME one a Suluk frontend ships on, so a green scenario exercises the real frontend
|
|
7
|
+
* DATA-PATH — typed dispatch, input validation, the auth interceptor, response decode, and the C037 store
|
|
8
|
+
* invalidation/refetch. HONEST BOUNDARY (emitted as a literal header in the suite): it tests
|
|
9
|
+
* client + contract + wire + the store data layer, NOT rendered UI / layout / visual — there is no DOM in a bun:test.
|
|
10
|
+
*
|
|
11
|
+
* The call site is lowered with @suluk/sdk's OWN `resolveOps` + `clientAccessor` — the single source of accessor
|
|
12
|
+
* identity — so the emitted `client.<ns>.<member>(…)` can NEVER drift from the method `generateSdk` actually emits.
|
|
13
|
+
* Journeys keeps `op.name@path` as the stable handle for IDENTITY; the SDK accessor (which `resolveOps` mutates) is
|
|
14
|
+
* used ONLY here, at the late call-site lowering. Where an operation needs a request value the emitter writes a
|
|
15
|
+
* "provide input" placeholder (a real NEEDS-DEV-GLUE), never an invented value (the never-launder-thin discipline).
|
|
16
|
+
*/
|
|
17
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
18
|
+
import { resolveOps, clientAccessor, type OpInfo } from "@suluk/sdk";
|
|
19
|
+
import type { Feature } from "./gherkin";
|
|
20
|
+
import type { Vocabulary } from "./vocabulary";
|
|
21
|
+
import { bindFeatures, type BindOptions } from "./bind";
|
|
22
|
+
|
|
23
|
+
export interface EmitOptions extends BindOptions {
|
|
24
|
+
/** import specifier for the consumer's generated SDK (default: the consumer's local "./sdk"). */
|
|
25
|
+
clientModule?: string;
|
|
26
|
+
/** named export that creates a client (default: "createClient"). */
|
|
27
|
+
clientFactory?: string;
|
|
28
|
+
/** env var holding the live base URL (default: "SULUK_BASE_URL"). */
|
|
29
|
+
baseUrlEnv?: string;
|
|
30
|
+
/** env var holding a bearer token for authenticated scenarios (default: "SULUK_USER_TOKEN"). */
|
|
31
|
+
tokenEnv?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Emit a runnable bun:test suite (a string) from a parsed, bound feature set, lowered to the real SDK client. */
|
|
35
|
+
export function emitRunnableSuite(doc: OpenAPIv4Document, vocab: Vocabulary, features: Feature[], opts: EmitOptions = {}): string {
|
|
36
|
+
const clientModule = opts.clientModule ?? "./sdk";
|
|
37
|
+
const clientFactory = opts.clientFactory ?? "createClient";
|
|
38
|
+
const baseUrlEnv = opts.baseUrlEnv ?? "SULUK_BASE_URL";
|
|
39
|
+
const tokenEnv = opts.tokenEnv ?? "SULUK_USER_TOKEN";
|
|
40
|
+
|
|
41
|
+
const report = bindFeatures(vocab, features, opts);
|
|
42
|
+
// accessor identity comes from @suluk/sdk itself, keyed by the STABLE handle (name@uri — neither is mutated by resolveOps).
|
|
43
|
+
const { ops } = resolveOps(doc);
|
|
44
|
+
const opByHandle = new Map<string, OpInfo>(ops.map((op) => [`${op.name}@${op.uri}`, op]));
|
|
45
|
+
|
|
46
|
+
const head = [
|
|
47
|
+
"// GENERATED by @suluk/journeys — do not edit by hand; regenerate from the contract + .feature sidecars.",
|
|
48
|
+
"// Coverage: this exercises the frontend DATA-PATH (client + contract + wire + store), NOT rendered UI / visual.",
|
|
49
|
+
`import { test, expect } from "bun:test";`,
|
|
50
|
+
`import { ${clientFactory} } from "${clientModule}";`,
|
|
51
|
+
``,
|
|
52
|
+
`const client = ${clientFactory}({`,
|
|
53
|
+
` baseURL: process.env.${baseUrlEnv},`,
|
|
54
|
+
` token: () => process.env.${tokenEnv} ?? null, // bearer; cookie auth also works via credentials:"include"`,
|
|
55
|
+
`});`,
|
|
56
|
+
``,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const body: string[] = [];
|
|
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));
|
|
69
|
+
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`);
|
|
75
|
+
body.push(`});`, ``);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!body.length) body.push("// No scenario bound a When-operation yet — author steps from the generated phrasebook.");
|
|
79
|
+
return head.join("\n") + body.join("\n");
|
|
80
|
+
}
|
package/src/gherkin.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal, dependency-free Gherkin parser (no cucumber dep; Bun-native like @suluk/sdk / @suluk/testgen).
|
|
3
|
+
*
|
|
4
|
+
* Supports Feature / Rule / Scenario / Given / When / Then / And / But and `#` comments. `And`/`But` inherit the
|
|
5
|
+
* previous step keyword (Given/When/Then) — the resolved keyword is what the binder matches on, never the raw `And`.
|
|
6
|
+
* This is an authoring surface, not a runtime; it parses the SIDECAR `.feature` text (free human prose) that the
|
|
7
|
+
* D1 wall keeps out of the contract.
|
|
8
|
+
*/
|
|
9
|
+
export type StepKind = "given" | "when" | "then";
|
|
10
|
+
|
|
11
|
+
export interface FeatureStep {
|
|
12
|
+
/** the RESOLVED keyword (And/But fold into the preceding Given/When/Then). */
|
|
13
|
+
kind: StepKind;
|
|
14
|
+
/** the step text after the keyword. */
|
|
15
|
+
text: string;
|
|
16
|
+
/** the raw line as written (for reporting). */
|
|
17
|
+
raw: string;
|
|
18
|
+
/** 1-based source line number (for file:line hand-offs). */
|
|
19
|
+
line: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Scenario {
|
|
23
|
+
name: string;
|
|
24
|
+
/** the `Rule:` this scenario sits under, if any. */
|
|
25
|
+
rule?: string;
|
|
26
|
+
steps: FeatureStep[];
|
|
27
|
+
line: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Feature {
|
|
31
|
+
feature: string;
|
|
32
|
+
scenarios: Scenario[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const KW = /^(Feature|Background|Rule|Scenario|Scenario Outline|Given|When|Then|And|But|Examples)\b:?\s*(.*)$/i;
|
|
36
|
+
|
|
37
|
+
export function parseFeature(src: string): Feature {
|
|
38
|
+
let feature = "";
|
|
39
|
+
let rule: string | undefined;
|
|
40
|
+
let cur: Scenario | null = null;
|
|
41
|
+
let last: StepKind = "given";
|
|
42
|
+
const scenarios: Scenario[] = [];
|
|
43
|
+
const lines = src.split("\n");
|
|
44
|
+
|
|
45
|
+
lines.forEach((raw, i) => {
|
|
46
|
+
const t = raw.trim();
|
|
47
|
+
if (!t || t.startsWith("#")) return;
|
|
48
|
+
const m = KW.exec(t);
|
|
49
|
+
if (!m) return;
|
|
50
|
+
const kw = m[1].toLowerCase();
|
|
51
|
+
const rest = m[2];
|
|
52
|
+
switch (kw) {
|
|
53
|
+
case "feature":
|
|
54
|
+
feature = rest;
|
|
55
|
+
return;
|
|
56
|
+
case "rule":
|
|
57
|
+
rule = rest;
|
|
58
|
+
return;
|
|
59
|
+
case "background":
|
|
60
|
+
case "examples":
|
|
61
|
+
return;
|
|
62
|
+
case "scenario":
|
|
63
|
+
case "scenario outline":
|
|
64
|
+
cur = { name: rest, rule, steps: [], line: i + 1 };
|
|
65
|
+
scenarios.push(cur);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// a step keyword
|
|
69
|
+
if (!cur) {
|
|
70
|
+
cur = { name: "(unnamed)", rule, steps: [], line: i + 1 };
|
|
71
|
+
scenarios.push(cur);
|
|
72
|
+
}
|
|
73
|
+
const k: StepKind = kw === "given" || kw === "when" || kw === "then" ? (kw as StepKind) : last;
|
|
74
|
+
last = k;
|
|
75
|
+
cur.steps.push({ kind: k, text: rest, raw: t, line: i + 1 });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return { feature, scenarios };
|
|
79
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @suluk/journeys — intuitive, runnable BDD over a v4 "Suluk" contract.
|
|
3
|
+
*
|
|
4
|
+
* A non-technical author (PM / BA / QA) writes Gherkin user-stories/journeys against a step VOCABULARY projected
|
|
5
|
+
* deterministically from the contract; the BINDER resolves each step EXACT-or-UNBOUND (outcomes relative to the
|
|
6
|
+
* scenario's When-subject) and emits a bidirectional TRI-STATE gap report; the EMITTER lowers bound scenarios to a
|
|
7
|
+
* runnable bun:test suite driven through @suluk/sdk's generated client. A pure function of the document. CANDIDATE tooling.
|
|
8
|
+
*
|
|
9
|
+
* The vocabulary names only contract facts (operations, params, statuses, store keys, access roles) — never request
|
|
10
|
+
* VALUES — so it stays on the safe side of the D1 wall; the @suluk/core matcher never imports this package.
|
|
11
|
+
*/
|
|
12
|
+
export {
|
|
13
|
+
generateVocabulary,
|
|
14
|
+
renderPhrasebook,
|
|
15
|
+
vocabularyHash,
|
|
16
|
+
opHandle,
|
|
17
|
+
type Vocabulary,
|
|
18
|
+
type JourneyStep,
|
|
19
|
+
type VocabOperation,
|
|
20
|
+
type StepKind,
|
|
21
|
+
} from "./vocabulary";
|
|
22
|
+
|
|
23
|
+
export { parseFeature, type Feature, type Scenario, type FeatureStep } from "./gherkin";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
bindFeatures,
|
|
27
|
+
renderGapReport,
|
|
28
|
+
type GapReport,
|
|
29
|
+
type ScenarioResult,
|
|
30
|
+
type StepResult,
|
|
31
|
+
type BindState,
|
|
32
|
+
type BindOptions,
|
|
33
|
+
type CoverageHole,
|
|
34
|
+
} from "./bind";
|
|
35
|
+
|
|
36
|
+
export { emitRunnableSuite, type EmitOptions } from "./emit";
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic text normalization shared by the vocabulary projector and the binder.
|
|
3
|
+
*
|
|
4
|
+
* The wall (C038 / D1): `norm` produces the matching SKELETON used to decide a BIND — it strips slot VALUES
|
|
5
|
+
* (numbers, $amounts, quoted strings, `<placeholders>`) to a `*` token so a step's literal data never enters the
|
|
6
|
+
* decision. `tok`/`jaccard` are used ONLY in the presentational tri-state classifier ("did you mean?"), never to
|
|
7
|
+
* decide a bind. No lemmatization, stemming, or embedding anywhere — pure, local, statically decidable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** camelCase / snake_case / kebab → lower-case space-separated words. `getAutoTopup` → "get auto topup". */
|
|
11
|
+
export function camel(s: string): string {
|
|
12
|
+
return s
|
|
13
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
14
|
+
.replace(/[_-]+/g, " ")
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The matching skeleton: lower-cased, punctuation-flattened, slot-VALUES collapsed to `*`, whitespace-collapsed. */
|
|
20
|
+
export function norm(s: string): string {
|
|
21
|
+
return s
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/["'`.]/g, " ")
|
|
24
|
+
.replace(/<[^>]+>/g, " * ") // <slot>
|
|
25
|
+
.replace(/\$?\b\d[\d,.]*\b/g, " * ") // numbers / $amounts → slot
|
|
26
|
+
.replace(/\s+/g, " ")
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STOP = new Set("i a an the my me of to is are for and it as on with you your".split(" "));
|
|
31
|
+
|
|
32
|
+
/** Content tokens for the presentational similarity classifier (stopwords + slots removed). */
|
|
33
|
+
export function tok(s: string): string[] {
|
|
34
|
+
return norm(s)
|
|
35
|
+
.split(" ")
|
|
36
|
+
.filter((w) => w && w !== "*" && !STOP.has(w));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Jaccard overlap of two token bags — presentational ranking only. */
|
|
40
|
+
export function jaccard(a: string[], b: string[]): number {
|
|
41
|
+
const A = new Set(a);
|
|
42
|
+
const B = new Set(b);
|
|
43
|
+
if (!A.size || !B.size) return 0;
|
|
44
|
+
let inter = 0;
|
|
45
|
+
for (const x of A) if (B.has(x)) inter++;
|
|
46
|
+
return inter / (A.size + B.size - inter);
|
|
47
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The VOCABULARY projection (C038): a v4 document → a deterministic Gherkin step palette.
|
|
3
|
+
*
|
|
4
|
+
* Pure function of the document — same contract in, same vocabulary out, no network. Every step phrase is derived
|
|
5
|
+
* from a name the contract already holds, so the vocabulary carries ZERO information the contract lacks:
|
|
6
|
+
* - GIVEN ← `x-suluk-access.requires` (the WHO axis): authenticated → "Given I am a signed-in user".
|
|
7
|
+
* - WHEN ← the operation (method + name): "When I checkout", "When I view credits".
|
|
8
|
+
* - THEN ← declared statuses ("Then it succeeds"), `x-suluk-store` (query key → "Then I see my <key>";
|
|
9
|
+
* mutation invalidates → "Then my <key> refreshes"), and per-unit `x-suluk-cost` ("Then I am charged credits").
|
|
10
|
+
*
|
|
11
|
+
* Step IDENTITY is `op.name @ path-uri` (the stable C009 by-name handle), NEVER the @suluk/sdk client accessor
|
|
12
|
+
* (which `resolveOps` mutates in place). GIVEN steps key on a synthetic `@access:<role>` handle.
|
|
13
|
+
*/
|
|
14
|
+
import type { OpenAPIv4Document, Request } from "@suluk/core";
|
|
15
|
+
import { camel, norm } from "./normalize";
|
|
16
|
+
|
|
17
|
+
export type StepKind = "given" | "when" | "then";
|
|
18
|
+
|
|
19
|
+
export interface JourneyStep {
|
|
20
|
+
/** Given / When / Then. */
|
|
21
|
+
kind: StepKind;
|
|
22
|
+
/** the human-readable phrase an author writes, e.g. "When I checkout". */
|
|
23
|
+
phrase: string;
|
|
24
|
+
/** the normalized matching skeleton (slot values stripped). */
|
|
25
|
+
skeleton: string;
|
|
26
|
+
/** stable identity: `op.name@path-uri`, or `@access:<role>` for a Given. */
|
|
27
|
+
handle: string;
|
|
28
|
+
/** provenance of this phrase (which contract fact produced it). */
|
|
29
|
+
via: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface VocabOperation {
|
|
33
|
+
handle: string;
|
|
34
|
+
name: string;
|
|
35
|
+
path: string;
|
|
36
|
+
method: string;
|
|
37
|
+
access: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Vocabulary {
|
|
41
|
+
/** every generated step, sorted deterministically. */
|
|
42
|
+
steps: JourneyStep[];
|
|
43
|
+
/** the operation table (for coverage + the phrasebook). */
|
|
44
|
+
operations: VocabOperation[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Stable by-name handle. */
|
|
48
|
+
export const opHandle = (name: string, path: string): string => `${name}@${path}`;
|
|
49
|
+
|
|
50
|
+
interface AccessFacet {
|
|
51
|
+
requires?: string;
|
|
52
|
+
}
|
|
53
|
+
interface CostComponent {
|
|
54
|
+
basis?: string;
|
|
55
|
+
}
|
|
56
|
+
interface CostFacet {
|
|
57
|
+
components?: CostComponent[];
|
|
58
|
+
estimateMicroUsd?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// x-suluk-access / x-suluk-cost are advisory vendor facets NOT modeled on the typed `Request` (a consumer stamps them,
|
|
62
|
+
// e.g. toolfactory via Object.assign) — read them through an untyped view rather than the typed surface.
|
|
63
|
+
const ext = (op: Request): Record<string, unknown> => op as unknown as Record<string, unknown>;
|
|
64
|
+
const accessOf = (op: Request): string => {
|
|
65
|
+
const a = ext(op)["x-suluk-access"] as AccessFacet | undefined;
|
|
66
|
+
return typeof a?.requires === "string" ? a.requires : "anyone";
|
|
67
|
+
};
|
|
68
|
+
const isMetered = (op: Request): boolean => {
|
|
69
|
+
const c = ext(op)["x-suluk-cost"] as CostFacet | undefined;
|
|
70
|
+
return Array.isArray(c?.components) && c.components.some((x) => x?.basis === "per-unit");
|
|
71
|
+
};
|
|
72
|
+
const statusesOf = (op: Request): string[] =>
|
|
73
|
+
op.responses ? Object.keys(op.responses) : [];
|
|
74
|
+
|
|
75
|
+
/** Derive the canonical WHEN phrase for an operation from its method + name. */
|
|
76
|
+
function whenPhrase(name: string, method: string): string {
|
|
77
|
+
const words = camel(name).split(" ");
|
|
78
|
+
const verb = method === "get" && (words[0] === "get" || words[0] === "list") ? "view " + words.slice(1).join(" ") : words.join(" ");
|
|
79
|
+
return `When I ${verb}`.replace(/\s+/g, " ").trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Project a v4 document into the deterministic step vocabulary. */
|
|
83
|
+
export function generateVocabulary(doc: OpenAPIv4Document): Vocabulary {
|
|
84
|
+
const steps: JourneyStep[] = [];
|
|
85
|
+
const operations: VocabOperation[] = [];
|
|
86
|
+
const push = (kind: StepKind, phrase: string, handle: string, via: string) => steps.push({ kind, phrase, skeleton: norm(phrase), handle, via });
|
|
87
|
+
|
|
88
|
+
for (const [path, item] of Object.entries(doc.paths)) {
|
|
89
|
+
for (const [name, op] of Object.entries(item.requests ?? {})) {
|
|
90
|
+
const handle = opHandle(name, path);
|
|
91
|
+
const access = accessOf(op);
|
|
92
|
+
operations.push({ handle, name, path, method: op.method, access });
|
|
93
|
+
|
|
94
|
+
if (access === "authenticated") push("given", "Given I am a signed-in user", "@access:authenticated", "x-suluk-access");
|
|
95
|
+
push("when", whenPhrase(name, op.method), handle, `op ${op.method.toUpperCase()} ${path}`);
|
|
96
|
+
|
|
97
|
+
const statuses = statusesOf(op);
|
|
98
|
+
if (statuses.includes("200")) push("then", "Then it succeeds", handle, "status 200");
|
|
99
|
+
const store = op["x-suluk-store"];
|
|
100
|
+
if (store?.key) push("then", `Then I see my ${camel(store.key)}`, handle, `x-suluk-store key:${store.key}`);
|
|
101
|
+
for (const inv of store?.invalidates ?? []) push("then", `Then my ${camel(inv)} refreshes`, handle, `x-suluk-store invalidates:${inv}`);
|
|
102
|
+
if (isMetered(op)) push("then", "Then I am charged credits", handle, "x-suluk-cost per-unit");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// deterministic order: kind, then phrase, then handle.
|
|
107
|
+
const ORDER: Record<StepKind, number> = { given: 0, when: 1, then: 2 };
|
|
108
|
+
steps.sort((a, b) => ORDER[a.kind] - ORDER[b.kind] || a.phrase.localeCompare(b.phrase) || a.handle.localeCompare(b.handle));
|
|
109
|
+
operations.sort((a, b) => a.handle.localeCompare(b.handle));
|
|
110
|
+
return { steps, operations };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A foldable, entity-grouped phrasebook (Markdown) — the human surface an author picks step phrases from. */
|
|
114
|
+
export function renderPhrasebook(vocab: Vocabulary): string {
|
|
115
|
+
const byHandle = new Map<string, JourneyStep[]>();
|
|
116
|
+
for (const s of vocab.steps) {
|
|
117
|
+
if (s.handle.startsWith("@access:")) continue;
|
|
118
|
+
(byHandle.get(s.handle) ?? byHandle.set(s.handle, []).get(s.handle)!).push(s);
|
|
119
|
+
}
|
|
120
|
+
const lines: string[] = ["# Available steps (generated from the contract)\n", "_Given I am a signed-in user_ — available on every authenticated operation.\n"];
|
|
121
|
+
for (const op of vocab.operations) {
|
|
122
|
+
const steps = byHandle.get(op.handle);
|
|
123
|
+
if (!steps?.length) continue;
|
|
124
|
+
lines.push(`\n### ${op.name} \`${op.method.toUpperCase()} ${op.path}\``);
|
|
125
|
+
for (const s of steps) lines.push(`- ${s.phrase}`);
|
|
126
|
+
}
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A deterministic content hash of the vocabulary (djb2 hex) — for drift detection / the build artifact. */
|
|
131
|
+
export function vocabularyHash(vocab: Vocabulary): string {
|
|
132
|
+
const payload = JSON.stringify(vocab.steps.map((s) => [s.kind, s.skeleton, s.handle]));
|
|
133
|
+
let h = 5381;
|
|
134
|
+
for (let i = 0; i < payload.length; i++) h = (((h << 5) + h) ^ payload.charCodeAt(i)) >>> 0;
|
|
135
|
+
return h.toString(16).padStart(8, "0");
|
|
136
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
3
|
+
import { generateVocabulary, vocabularyHash, opHandle } from "../src/vocabulary";
|
|
4
|
+
import { parseFeature } from "../src/gherkin";
|
|
5
|
+
import { bindFeatures } from "../src/bind";
|
|
6
|
+
import { emitRunnableSuite } from "../src/emit";
|
|
7
|
+
|
|
8
|
+
/** A minimal v4 fixture echoing real toolfactory shapes (incl. the shared `api/billing/subscription` path). */
|
|
9
|
+
const DOC: OpenAPIv4Document = {
|
|
10
|
+
openapi: "4.0.0-candidate",
|
|
11
|
+
info: { title: "fixture", version: "0.0.0" },
|
|
12
|
+
paths: {
|
|
13
|
+
"api/credits": {
|
|
14
|
+
requests: { getCredits: { method: "get", responses: { "200": { status: 200 } }, "x-suluk-access": { requires: "authenticated" }, "x-suluk-store": { key: "credits" } } },
|
|
15
|
+
},
|
|
16
|
+
"api/billing/checkout": {
|
|
17
|
+
requests: { checkout: { method: "post", responses: { "200": { status: 200 }, "400": { status: 400 } }, "x-suluk-access": { requires: "authenticated" } } },
|
|
18
|
+
},
|
|
19
|
+
"api/billing/subscription": {
|
|
20
|
+
requests: {
|
|
21
|
+
getSubscription: { method: "get", responses: { "200": { status: 200 } }, "x-suluk-access": { requires: "authenticated" }, "x-suluk-store": { key: "subscription" } },
|
|
22
|
+
cancelSubscription: { method: "post", responses: { "200": { status: 200 } }, "x-suluk-access": { requires: "authenticated" }, "x-suluk-store": { invalidates: ["subscription"] } },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
"api/transcribe": {
|
|
26
|
+
requests: { transcribe: { method: "post", responses: { "200": { status: 200 } }, "x-suluk-access": { requires: "authenticated" }, "x-suluk-cost": { components: [{ basis: "per-unit" }] } } },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
} as unknown as OpenAPIv4Document;
|
|
30
|
+
|
|
31
|
+
describe("generateVocabulary", () => {
|
|
32
|
+
const vocab = generateVocabulary(DOC);
|
|
33
|
+
const phrases = vocab.steps.map((s) => s.phrase);
|
|
34
|
+
|
|
35
|
+
test("projects When phrases from method + operation name", () => {
|
|
36
|
+
expect(phrases).toContain("When I checkout");
|
|
37
|
+
expect(phrases).toContain("When I view credits"); // get + 'get' prefix → 'view'
|
|
38
|
+
expect(phrases).toContain("When I transcribe");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("projects a Given from x-suluk-access, a charge-Then from per-unit x-suluk-cost, and a store-Then", () => {
|
|
42
|
+
expect(phrases).toContain("Given I am a signed-in user");
|
|
43
|
+
expect(phrases).toContain("Then I am charged credits"); // transcribe is per-unit metered
|
|
44
|
+
expect(phrases).toContain("Then my subscription refreshes"); // cancelSubscription invalidates subscription
|
|
45
|
+
expect(phrases).toContain("Then I see my credits"); // getCredits query store
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("is deterministic — same doc → same hash", () => {
|
|
49
|
+
expect(vocabularyHash(generateVocabulary(DOC))).toBe(vocabularyHash(vocab));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("stable identity (name@path-uri)", () => {
|
|
54
|
+
test("two operations sharing a path stay DISTINCT by name+path", () => {
|
|
55
|
+
const vocab = generateVocabulary(DOC);
|
|
56
|
+
const handles = vocab.operations.map((o) => o.handle);
|
|
57
|
+
expect(handles).toContain(opHandle("getSubscription", "api/billing/subscription"));
|
|
58
|
+
expect(handles).toContain(opHandle("cancelSubscription", "api/billing/subscription"));
|
|
59
|
+
expect(new Set(handles).size).toBe(handles.length); // no collisions
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("bindFeatures — exact-or-UNBOUND, subject-relative outcomes, tri-state", () => {
|
|
64
|
+
const vocab = generateVocabulary(DOC);
|
|
65
|
+
|
|
66
|
+
test("binds exact steps; the generic 'Then it succeeds' binds to the scenario's When-subject (not an arbitrary op)", () => {
|
|
67
|
+
const feature = parseFeature(`
|
|
68
|
+
Feature: f
|
|
69
|
+
Scenario: top up
|
|
70
|
+
Given I am a signed-in user
|
|
71
|
+
When I checkout
|
|
72
|
+
Then it succeeds
|
|
73
|
+
`);
|
|
74
|
+
const report = bindFeatures(vocab, [feature]);
|
|
75
|
+
const r = report.scenarios[0].results;
|
|
76
|
+
expect(r[0].state).toBe("BOUND"); // Given
|
|
77
|
+
expect(r[1].state).toBe("BOUND"); // When → checkout
|
|
78
|
+
expect(r[1].handle).toBe(opHandle("checkout", "api/billing/checkout"));
|
|
79
|
+
expect(r[2].state).toBe("BOUND"); // Then it succeeds → SUBJECT (checkout), the regression guard
|
|
80
|
+
expect(r[2].handle).toBe(opHandle("checkout", "api/billing/checkout"));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("an 'And' outcome inherits Then and binds to a mutation's invalidated store", () => {
|
|
84
|
+
const feature = parseFeature(`
|
|
85
|
+
Feature: f
|
|
86
|
+
Scenario: cancel
|
|
87
|
+
Given I am a signed-in user
|
|
88
|
+
When I cancel subscription
|
|
89
|
+
Then my subscription refreshes
|
|
90
|
+
`);
|
|
91
|
+
const r = bindFeatures(vocab, [feature]).scenarios[0].results;
|
|
92
|
+
expect(r[2].state).toBe("BOUND");
|
|
93
|
+
expect(r[2].handle).toBe(opHandle("cancelSubscription", "api/billing/subscription"));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("an unbacked intent is NEEDS-CONTRACT; a paraphrase resolves via the alias map", () => {
|
|
97
|
+
const feature = parseFeature(`
|
|
98
|
+
Feature: f
|
|
99
|
+
Scenario: word doc
|
|
100
|
+
Given I am a logged in user
|
|
101
|
+
When I transcribe
|
|
102
|
+
Then I download it as a Word document
|
|
103
|
+
`);
|
|
104
|
+
const report = bindFeatures(vocab, [feature], { aliases: { "given i am a logged in user": "given i am a signed-in user" } });
|
|
105
|
+
const r = report.scenarios[0].results;
|
|
106
|
+
expect(r[0].state).toBe("BOUND"); // alias resolved the paraphrase → no dev
|
|
107
|
+
expect(r[1].state).toBe("BOUND"); // transcribe
|
|
108
|
+
expect(r[2].state).toBe("NEEDS-CONTRACT"); // no operation backs a Word-doc download
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a paraphrased When does NOT silently bind (exact-or-UNBOUND)", () => {
|
|
112
|
+
const feature = parseFeature(`
|
|
113
|
+
Feature: f
|
|
114
|
+
Scenario: para
|
|
115
|
+
When I start a checkout
|
|
116
|
+
`);
|
|
117
|
+
const r = bindFeatures(vocab, [feature]).scenarios[0].results;
|
|
118
|
+
expect(r[0].state).not.toBe("BOUND");
|
|
119
|
+
expect(["PARAPHRASE", "NEEDS-DEV-GLUE"]).toContain(r[0].state);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("coverage (contract → authored): uncovered operations are surfaced with stubs", () => {
|
|
123
|
+
const feature = parseFeature(`
|
|
124
|
+
Feature: f
|
|
125
|
+
Scenario: only checkout
|
|
126
|
+
When I checkout
|
|
127
|
+
`);
|
|
128
|
+
const report = bindFeatures(vocab, [feature]);
|
|
129
|
+
expect(report.coverage.total).toBe(5);
|
|
130
|
+
expect(report.coverage.covered).toBe(1);
|
|
131
|
+
expect(report.coverage.holes.some((h) => h.name === "transcribe")).toBe(true);
|
|
132
|
+
expect(report.coverage.holes.find((h) => h.name === "getCredits")!.stub).toContain("When I view credits");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("emitRunnableSuite — lowers to the REAL @suluk/sdk entity-grouped accessor", () => {
|
|
137
|
+
const vocab = generateVocabulary(DOC);
|
|
138
|
+
const suite = emitRunnableSuite(
|
|
139
|
+
DOC,
|
|
140
|
+
vocab,
|
|
141
|
+
[
|
|
142
|
+
parseFeature(`
|
|
143
|
+
Feature: f
|
|
144
|
+
Scenario: top up
|
|
145
|
+
Given I am a signed-in user
|
|
146
|
+
When I checkout
|
|
147
|
+
Then it succeeds
|
|
148
|
+
Scenario: balance
|
|
149
|
+
Given I am a signed-in user
|
|
150
|
+
When I view credits
|
|
151
|
+
Then I see my credits
|
|
152
|
+
`),
|
|
153
|
+
],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
test("imports the consumer's createClient and wires baseURL + token from env", () => {
|
|
157
|
+
expect(suite).toContain('import { createClient } from "./sdk"');
|
|
158
|
+
expect(suite).toContain("process.env.SULUK_BASE_URL");
|
|
159
|
+
expect(suite).toContain("process.env.SULUK_USER_TOKEN");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("calls the SDK's entity-grouped accessor (resolveOps/clientAccessor), NOT the flat op name", () => {
|
|
163
|
+
expect(suite).toContain("client.billing.checkout("); // checkout (custom op) → billing.checkout
|
|
164
|
+
expect(suite).toContain("client.credits.get("); // getCredits (CRUD) → credits.get
|
|
165
|
+
expect(suite).not.toContain("client.checkout("); // never the flat by-name accessor
|
|
166
|
+
expect(suite).not.toContain("client.getCredits(");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("annotates the auth requirement for authenticated operations", () => {
|
|
170
|
+
expect(suite).toContain("requires authenticated");
|
|
171
|
+
});
|
|
172
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }
|