@suluk/journeys 0.3.1 → 0.4.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/bin/journeys.ts +197 -0
- package/package.json +7 -2
- package/src/cli.ts +186 -0
- package/src/coverage.ts +24 -0
- package/src/demos.ts +239 -0
- package/src/emit.ts +83 -15
- package/src/examples.ts +6 -0
- package/src/gherkin.ts +43 -2
- package/src/index.ts +71 -0
- package/src/outline.ts +102 -0
- package/src/promote.ts +226 -0
- package/test/audit.test.ts +90 -0
- package/test/cli.test.ts +78 -0
- package/test/demos.test.ts +126 -0
- package/test/emit-outline.test.ts +95 -0
- package/test/examples-wall.test.ts +31 -0
- package/test/outline.test.ts +105 -0
- package/test/promote-cli.test.ts +85 -0
- package/test/promote.test.ts +129 -0
package/bin/journeys.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* @suluk/journeys CLI.
|
|
4
|
+
* demos — compile `.feature` files into a Bruno/Postman demo collection (live-prod / dev-local).
|
|
5
|
+
* promote — lift a tester's `@public` Examples row into the Zod source as `.meta({ examples })` (C040-P4).
|
|
6
|
+
* audit — the unified contract grade: harden security + readiness + BDD coverage (C043), with a CI gate.
|
|
7
|
+
*
|
|
8
|
+
* `--features` may be repeated and may name a directory (all `*.feature` under it) or a single file.
|
|
9
|
+
*/
|
|
10
|
+
import { statSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { buildDemoFiles, planPromotions, parseTargetSpec, miniDiff, buildAudit, type DemoFormat, type PromoteTargetSpec } from "../src/cli";
|
|
13
|
+
|
|
14
|
+
const GRADES = ["F", "D", "C", "B", "A"];
|
|
15
|
+
|
|
16
|
+
const DEMOS_USAGE = `journeys demos --doc <openapi.json> --features <dir-or-file>... --out <dir>
|
|
17
|
+
[--format bruno|postman|both] [--name <name>] [--base-url <prodURL>] [--local-base-url <localURL>]`;
|
|
18
|
+
const PROMOTE_USAGE = `journeys promote --features <dir-or-file>... --target "<scenario>=<file>#<schemaVar>"... [--write] [--because <reason>]
|
|
19
|
+
(dry-run by default — prints a diff; pass --write to apply. Review the diff; a substrate operator
|
|
20
|
+
runs mizan_check_action_safety before --write.)`;
|
|
21
|
+
const AUDIT_USAGE = `journeys audit --doc <openapi.json> [--features <dir-or-file>...] [--min A|B|C|D|F]
|
|
22
|
+
(security + readiness from @suluk/harden; BDD coverage when --features is given; --min gates on the worst.)`;
|
|
23
|
+
const USAGE = `${DEMOS_USAGE}\n\n ${PROMOTE_USAGE}\n\n ${AUDIT_USAGE}`;
|
|
24
|
+
|
|
25
|
+
function parseFlags(args: string[]): { cmd?: string; flags: Record<string, string[]> } {
|
|
26
|
+
const flags: Record<string, string[]> = {};
|
|
27
|
+
let cmd: string | undefined;
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
const a = args[i];
|
|
30
|
+
if (a.startsWith("--")) {
|
|
31
|
+
const eq = a.indexOf("=");
|
|
32
|
+
const key = eq >= 0 ? a.slice(2, eq) : a.slice(2);
|
|
33
|
+
const val = eq >= 0 ? a.slice(eq + 1) : args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : "true";
|
|
34
|
+
(flags[key] ??= []).push(val);
|
|
35
|
+
} else if (!cmd) {
|
|
36
|
+
cmd = a;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { cmd, flags };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const one = (flags: Record<string, string[]>, key: string): string | undefined => flags[key]?.[0];
|
|
43
|
+
|
|
44
|
+
async function loadFeatureTexts(paths: string[]): Promise<string[]> {
|
|
45
|
+
const texts: string[] = [];
|
|
46
|
+
for (const p of paths) {
|
|
47
|
+
let st;
|
|
48
|
+
try {
|
|
49
|
+
st = statSync(p);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(`--features path not found: ${p}`);
|
|
52
|
+
}
|
|
53
|
+
if (st.isDirectory()) {
|
|
54
|
+
const glob = new Bun.Glob("**/*.feature");
|
|
55
|
+
let found = 0;
|
|
56
|
+
for await (const f of glob.scan({ cwd: p, absolute: true })) {
|
|
57
|
+
texts.push(await Bun.file(f).text());
|
|
58
|
+
found++;
|
|
59
|
+
}
|
|
60
|
+
if (!found) throw new Error(`no *.feature files under ${p}`);
|
|
61
|
+
} else {
|
|
62
|
+
texts.push(await Bun.file(p).text());
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return texts;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function demosCommand(flags: Record<string, string[]>): Promise<number> {
|
|
69
|
+
const docPath = one(flags, "doc");
|
|
70
|
+
const featurePaths = flags.features ?? [];
|
|
71
|
+
const out = one(flags, "out");
|
|
72
|
+
const format = (one(flags, "format") ?? "both") as DemoFormat;
|
|
73
|
+
if (!docPath || !featurePaths.length || !out) {
|
|
74
|
+
console.error("error: --doc, --features and --out are required.\n\n" + DEMOS_USAGE);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
if (!["bruno", "postman", "both"].includes(format)) {
|
|
78
|
+
console.error(`error: --format must be bruno|postman|both (got ${format}).`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
const docText = await Bun.file(docPath).text();
|
|
82
|
+
const featureTexts = await loadFeatureTexts(featurePaths);
|
|
83
|
+
const result = buildDemoFiles(docText, featureTexts, {
|
|
84
|
+
format,
|
|
85
|
+
name: one(flags, "name"),
|
|
86
|
+
baseUrl: one(flags, "base-url"),
|
|
87
|
+
localBaseUrl: one(flags, "local-base-url"),
|
|
88
|
+
});
|
|
89
|
+
if (!result.scenarios) {
|
|
90
|
+
console.error("warning: no scenarios bound a When-operation — nothing to emit. Check the .feature steps against the contract's vocabulary.");
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
for (const [rel, content] of Object.entries(result.files)) await Bun.write(join(out, rel), content);
|
|
94
|
+
console.log(`✓ ${result.scenarios} scenario(s), ${result.requests} request(s) → ${Object.keys(result.files).length} file(s) in ${out}/`);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function promoteCommand(flags: Record<string, string[]>): Promise<number> {
|
|
99
|
+
const featurePaths = flags.features ?? [];
|
|
100
|
+
const targetSpecs = flags.target ?? [];
|
|
101
|
+
if (!featurePaths.length || !targetSpecs.length) {
|
|
102
|
+
console.error("error: --features and at least one --target are required.\n\n" + PROMOTE_USAGE);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
const targets = new Map<string, PromoteTargetSpec>();
|
|
106
|
+
for (const spec of targetSpecs) {
|
|
107
|
+
const t = parseTargetSpec(spec);
|
|
108
|
+
if (!t) {
|
|
109
|
+
console.error(`error: bad --target ${JSON.stringify(spec)} — expected "<scenario>=<file>#<schemaVar>".`);
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
targets.set(t.scenario, { file: t.file, schemaVar: t.schemaVar });
|
|
113
|
+
}
|
|
114
|
+
const featureTexts = await loadFeatureTexts(featurePaths);
|
|
115
|
+
const sources: Record<string, string> = {};
|
|
116
|
+
for (const t of targets.values()) if (!(t.file in sources)) sources[t.file] = await Bun.file(t.file).text();
|
|
117
|
+
|
|
118
|
+
const plan = planPromotions(featureTexts, targets, sources, { because: one(flags, "because") });
|
|
119
|
+
for (const row of plan.rows) {
|
|
120
|
+
const where = row.schemaVar ? ` → ${row.schemaVar} (${row.file})` : "";
|
|
121
|
+
console.log(`${row.status === "applied" ? "✓" : "–"} ${row.scenario}${where}: ${row.reason}`);
|
|
122
|
+
}
|
|
123
|
+
const changed = plan.files.filter((f) => f.changed);
|
|
124
|
+
for (const f of changed) {
|
|
125
|
+
console.log(`\n--- ${f.file} ---`);
|
|
126
|
+
console.log(miniDiff(f.original, f.updated));
|
|
127
|
+
}
|
|
128
|
+
if (!changed.length) {
|
|
129
|
+
console.log("\nNothing to promote.");
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
if ("write" in flags) {
|
|
133
|
+
for (const f of changed) await Bun.write(f.file, f.updated);
|
|
134
|
+
console.log(`\n✓ wrote ${changed.length} file(s).`);
|
|
135
|
+
} else {
|
|
136
|
+
console.log(`\n(dry run — pass --write to apply. Review the diff above; a substrate operator runs mizan_check_action_safety before --write.)`);
|
|
137
|
+
}
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function auditCommand(flags: Record<string, string[]>): Promise<number> {
|
|
142
|
+
const docPath = one(flags, "doc");
|
|
143
|
+
const featurePaths = flags.features ?? [];
|
|
144
|
+
const min = one(flags, "min");
|
|
145
|
+
if (!docPath) {
|
|
146
|
+
console.error("error: --doc is required.\n\n" + AUDIT_USAGE);
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
if (min && !GRADES.includes(min)) {
|
|
150
|
+
console.error(`error: --min must be A|B|C|D|F (got ${min}).`);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
const docText = await Bun.file(docPath).text();
|
|
154
|
+
const featureTexts = featurePaths.length ? await loadFeatureTexts(featurePaths) : [];
|
|
155
|
+
const a = buildAudit(docText, featureTexts);
|
|
156
|
+
|
|
157
|
+
const dim = (label: string, d: { grade: string; score: number; findings: { severity: string; rule: string; path: string; message: string }[] }) => {
|
|
158
|
+
console.log(`\n${label}: ${d.grade} (${d.score}/100)`);
|
|
159
|
+
for (const f of d.findings.slice(0, 8)) console.log(` [${f.severity}] ${f.rule} ${f.path}: ${f.message}`);
|
|
160
|
+
if (d.findings.length > 8) console.log(` …and ${d.findings.length - 8} more`);
|
|
161
|
+
};
|
|
162
|
+
dim("security ", a.security);
|
|
163
|
+
dim("readiness", a.readiness);
|
|
164
|
+
if (a.coverage) {
|
|
165
|
+
console.log(`\ncoverage : ${a.coverage.grade} (${a.coverage.covered}/${a.coverage.total} ops covered)`);
|
|
166
|
+
if (a.coverage.uncovered.length) console.log(` uncovered (generate outlines): ${a.coverage.uncovered.join(", ")}`);
|
|
167
|
+
} else {
|
|
168
|
+
console.log(`\ncoverage : (skipped — pass --features to grade BDD coverage)`);
|
|
169
|
+
}
|
|
170
|
+
console.log(`\n= combined: worst ${a.combined.worst}, average ${a.combined.average} [${a.combined.grades.join(" · ")}]`);
|
|
171
|
+
|
|
172
|
+
if (min) {
|
|
173
|
+
if (GRADES.indexOf(a.combined.worst) < GRADES.indexOf(min)) {
|
|
174
|
+
console.error(`\n✗ combined grade ${a.combined.worst} is below the required ${min}.`);
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
console.log(`✓ meets the required ${min}.`);
|
|
178
|
+
}
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const { cmd, flags } = parseFlags(process.argv.slice(2));
|
|
183
|
+
try {
|
|
184
|
+
if (cmd === "demos") {
|
|
185
|
+
process.exit(await demosCommand(flags));
|
|
186
|
+
} else if (cmd === "promote") {
|
|
187
|
+
process.exit(await promoteCommand(flags));
|
|
188
|
+
} else if (cmd === "audit") {
|
|
189
|
+
process.exit(await auditCommand(flags));
|
|
190
|
+
} else {
|
|
191
|
+
console.log(`@suluk/journeys CLI\n\nUsage:\n ${USAGE}`);
|
|
192
|
+
process.exit(cmd ? 1 : 0);
|
|
193
|
+
}
|
|
194
|
+
} catch (e) {
|
|
195
|
+
console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/journeys",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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"
|
|
@@ -19,10 +19,15 @@
|
|
|
19
19
|
".": "./src/index.ts",
|
|
20
20
|
"./hatch": "./src/hatch/index.ts"
|
|
21
21
|
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"journeys": "bin/journeys.ts"
|
|
24
|
+
},
|
|
22
25
|
"dependencies": {
|
|
23
26
|
"@suluk/cloudflare": "^0.2.0",
|
|
24
27
|
"@suluk/core": "^0.1.13",
|
|
25
|
-
"@suluk/
|
|
28
|
+
"@suluk/examples": "^0.1.0",
|
|
29
|
+
"@suluk/harden": "^0.2.0",
|
|
30
|
+
"@suluk/sdk": "^0.3.0"
|
|
26
31
|
},
|
|
27
32
|
"devDependencies": {
|
|
28
33
|
"@types/bun": "latest"
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI core (C042) — the PURE, filesystem-free heart of `journeys demos`: a v4 contract + `.feature` texts → the demo
|
|
3
|
+
* collection file map (Bruno and/or Postman). The bin (`bin/journeys.ts`) does only argv parsing + file IO around this,
|
|
4
|
+
* so the interesting logic stays unit-testable without touching disk.
|
|
5
|
+
*/
|
|
6
|
+
import { parseDocument, type OpenAPIv4Document } from "@suluk/core";
|
|
7
|
+
import { auditDocument, auditReadiness, combineGrades, type Grade, type Finding } from "@suluk/harden";
|
|
8
|
+
import { generateVocabulary } from "./vocabulary";
|
|
9
|
+
import { parseFeature } from "./gherkin";
|
|
10
|
+
import { bindFeatures } from "./bind";
|
|
11
|
+
import { compileDemos, renderBruno, renderPostman } from "./demos";
|
|
12
|
+
import { extractPublicRows, buildExampleObject, promoteExampleIntoZod } from "./promote";
|
|
13
|
+
import { coverageGrade, type CoverageGrade } from "./coverage";
|
|
14
|
+
|
|
15
|
+
export type DemoFormat = "bruno" | "postman" | "both";
|
|
16
|
+
|
|
17
|
+
export interface BuildDemoFilesOptions {
|
|
18
|
+
/** which collection(s) to emit (default "both"). */
|
|
19
|
+
format?: DemoFormat;
|
|
20
|
+
/** collection name (default the contract's info.title). */
|
|
21
|
+
name?: string;
|
|
22
|
+
/** the PROD base URL (the live-call target). */
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
/** the LOCAL base URL a developer rehearses against first. */
|
|
25
|
+
localBaseUrl?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DemoFilesResult {
|
|
29
|
+
/** relative path → file content. When format is "both", Bruno files are under `bruno/`, Postman under `postman/`. */
|
|
30
|
+
files: Record<string, string>;
|
|
31
|
+
scenarios: number;
|
|
32
|
+
requests: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const slug = (s: string) => s.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "demo";
|
|
36
|
+
|
|
37
|
+
/** Pure: a v4 document text + `.feature` texts → the demo collection file map. No filesystem. */
|
|
38
|
+
export function buildDemoFiles(docText: string, featureTexts: string[], opts: BuildDemoFilesOptions = {}): DemoFilesResult {
|
|
39
|
+
const doc = parseDocument(docText) as OpenAPIv4Document;
|
|
40
|
+
const vocab = generateVocabulary(doc);
|
|
41
|
+
const features = featureTexts.map((t) => parseFeature(t));
|
|
42
|
+
const demos = compileDemos(doc, vocab, features);
|
|
43
|
+
|
|
44
|
+
const name = opts.name ?? doc.info?.title ?? "Demo";
|
|
45
|
+
const render = { name, baseUrl: opts.baseUrl, localBaseUrl: opts.localBaseUrl };
|
|
46
|
+
const format = opts.format ?? "both";
|
|
47
|
+
|
|
48
|
+
const files: Record<string, string> = {};
|
|
49
|
+
if (format === "bruno" || format === "both") {
|
|
50
|
+
const prefix = format === "both" ? "bruno/" : "";
|
|
51
|
+
for (const [p, c] of Object.entries(renderBruno(demos, render))) files[prefix + p] = c;
|
|
52
|
+
}
|
|
53
|
+
if (format === "postman" || format === "both") {
|
|
54
|
+
const prefix = format === "both" ? "postman/" : "";
|
|
55
|
+
files[`${prefix}${slug(name)}.postman_collection.json`] = renderPostman(demos, render);
|
|
56
|
+
}
|
|
57
|
+
return { files, scenarios: demos.length, requests: demos.reduce((n, d) => n + d.requests.length, 0) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
61
|
+
// `journeys promote` core — plan @public → Zod source edits (filesystem-free; the bin reads/writes around it).
|
|
62
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** A `--target "<scenario>=<file>#<schemaVar>"` mapping. */
|
|
65
|
+
export interface PromoteTargetSpec {
|
|
66
|
+
file: string;
|
|
67
|
+
schemaVar: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Parse `"<scenario>=<file>#<schemaVar>"`. The scenario may contain spaces/`=` only before the FIRST `=`. */
|
|
71
|
+
export function parseTargetSpec(spec: string): { scenario: string; file: string; schemaVar: string } | null {
|
|
72
|
+
const eq = spec.indexOf("=");
|
|
73
|
+
const hash = spec.lastIndexOf("#");
|
|
74
|
+
if (eq < 0 || hash < eq) return null;
|
|
75
|
+
return { scenario: spec.slice(0, eq).trim(), file: spec.slice(eq + 1, hash).trim(), schemaVar: spec.slice(hash + 1).trim() };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface PromotionRow {
|
|
79
|
+
scenario: string;
|
|
80
|
+
file?: string;
|
|
81
|
+
schemaVar?: string;
|
|
82
|
+
status: "applied" | "skipped";
|
|
83
|
+
reason: string;
|
|
84
|
+
}
|
|
85
|
+
export interface PromotionFileResult {
|
|
86
|
+
file: string;
|
|
87
|
+
original: string;
|
|
88
|
+
updated: string;
|
|
89
|
+
changed: boolean;
|
|
90
|
+
}
|
|
91
|
+
export interface PromotionPlan {
|
|
92
|
+
files: PromotionFileResult[];
|
|
93
|
+
rows: PromotionRow[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Plan the promotions for every `@public` Examples row: build the public example (content-typed) and apply
|
|
98
|
+
* `promoteExampleIntoZod` to the target's (pre-read) source — accumulating multiple rows per file. Pure: returns the
|
|
99
|
+
* before/after source per file (the bin diffs + writes). The never-clobber refusals surface as skipped rows.
|
|
100
|
+
*/
|
|
101
|
+
export function planPromotions(featureTexts: string[], targets: Map<string, PromoteTargetSpec>, sources: Record<string, string>, opts: { because?: string } = {}): PromotionPlan {
|
|
102
|
+
const features = featureTexts.map((t) => parseFeature(t));
|
|
103
|
+
const working: Record<string, string> = { ...sources };
|
|
104
|
+
const rows: PromotionRow[] = [];
|
|
105
|
+
for (const pub of extractPublicRows(features)) {
|
|
106
|
+
const target = targets.get(pub.scenario);
|
|
107
|
+
if (!target) {
|
|
108
|
+
rows.push({ scenario: pub.scenario, status: "skipped", reason: "no --target maps this scenario" });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!(target.file in working)) {
|
|
112
|
+
rows.push({ scenario: pub.scenario, file: target.file, schemaVar: target.schemaVar, status: "skipped", reason: `source file not loaded: ${target.file}` });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const example = buildExampleObject(pub.headers, pub.row);
|
|
116
|
+
const provenance = opts.because ? `${opts.because} (${pub.scenario})` : `promoted from ${pub.scenario}`;
|
|
117
|
+
const r = promoteExampleIntoZod(working[target.file], target.schemaVar, example, provenance);
|
|
118
|
+
if (r.changed) working[target.file] = r.source;
|
|
119
|
+
rows.push({ scenario: pub.scenario, file: target.file, schemaVar: target.schemaVar, status: r.changed ? "applied" : "skipped", reason: r.reason });
|
|
120
|
+
}
|
|
121
|
+
const files = Object.keys(sources).map((f) => ({ file: f, original: sources[f], updated: working[f], changed: working[f] !== sources[f] }));
|
|
122
|
+
return { files, rows };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
126
|
+
// `journeys audit` core — the UNIFIED contract grade (C043): harden security + harden readiness + journeys coverage,
|
|
127
|
+
// folded by letter via harden's combineGrades (the established harden+agents seam; harden never deps journeys).
|
|
128
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export interface DimensionAudit {
|
|
131
|
+
grade: Grade;
|
|
132
|
+
score: number;
|
|
133
|
+
findings: Finding[];
|
|
134
|
+
}
|
|
135
|
+
export interface AuditResult {
|
|
136
|
+
/** schema input-hardening (security) — `@suluk/harden` auditDocument. */
|
|
137
|
+
security: DimensionAudit;
|
|
138
|
+
/** schema-fact readiness (computed-required / missing-example) — `@suluk/harden` auditReadiness. */
|
|
139
|
+
readiness: DimensionAudit;
|
|
140
|
+
/** BDD contract coverage — present only when `.feature` files were given. */
|
|
141
|
+
coverage?: CoverageGrade;
|
|
142
|
+
/** the combined grade (worst is the safe value to gate on). */
|
|
143
|
+
combined: { worst: Grade; average: Grade; grades: Grade[] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Run all readiness dimensions over a contract (+ optional `.feature` texts) and fold them into one grade. Pure. */
|
|
147
|
+
export function buildAudit(docText: string, featureTexts: string[] = []): AuditResult {
|
|
148
|
+
const doc = parseDocument(docText) as OpenAPIv4Document;
|
|
149
|
+
const sec = auditDocument(doc);
|
|
150
|
+
const rd = auditReadiness(doc);
|
|
151
|
+
const grades: Grade[] = [sec.grade, rd.grade];
|
|
152
|
+
|
|
153
|
+
let coverage: CoverageGrade | undefined;
|
|
154
|
+
if (featureTexts.length) {
|
|
155
|
+
const report = bindFeatures(generateVocabulary(doc), featureTexts.map((t) => parseFeature(t)));
|
|
156
|
+
coverage = coverageGrade(report);
|
|
157
|
+
grades.push(coverage.grade);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
security: { grade: sec.grade, score: sec.score, findings: sec.findings },
|
|
162
|
+
readiness: { grade: rd.grade, score: rd.score, findings: rd.findings },
|
|
163
|
+
coverage,
|
|
164
|
+
combined: combineGrades(grades),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A minimal context diff (the edit is localized to one schema statement). Lines: ` ` ctx, `- ` removed, `+ ` added. */
|
|
169
|
+
export function miniDiff(oldText: string, newText: string, ctx = 2): string {
|
|
170
|
+
const a = oldText.split("\n");
|
|
171
|
+
const b = newText.split("\n");
|
|
172
|
+
let s = 0;
|
|
173
|
+
while (s < a.length && s < b.length && a[s] === b[s]) s++;
|
|
174
|
+
let ea = a.length;
|
|
175
|
+
let eb = b.length;
|
|
176
|
+
while (ea > s && eb > s && a[ea - 1] === b[eb - 1]) {
|
|
177
|
+
ea--;
|
|
178
|
+
eb--;
|
|
179
|
+
}
|
|
180
|
+
const out: string[] = [];
|
|
181
|
+
for (let i = Math.max(0, s - ctx); i < s; i++) out.push(` ${a[i]}`);
|
|
182
|
+
for (let i = s; i < ea; i++) out.push(`- ${a[i]}`);
|
|
183
|
+
for (let i = s; i < eb; i++) out.push(`+ ${b[i]}`);
|
|
184
|
+
for (let i = ea; i < Math.min(a.length, ea + ctx); i++) out.push(` ${a[i]}`);
|
|
185
|
+
return out.join("\n");
|
|
186
|
+
}
|
package/src/coverage.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BDD coverage as a graded dimension (C043). `bindFeatures` already computes which contract operations a `.feature`
|
|
3
|
+
* suite covers; this turns that into a letter (using @suluk/harden's `grade`) so it folds into harden's `combineGrades`
|
|
4
|
+
* alongside the security + readiness grades — the journeys-owned dimension of the unified contract grade. It lives in
|
|
5
|
+
* journeys (not harden) because coverage needs the `.feature` files, and harden must not depend on journeys (cycle).
|
|
6
|
+
*/
|
|
7
|
+
import { grade, type Grade } from "@suluk/harden";
|
|
8
|
+
import type { GapReport } from "./bind";
|
|
9
|
+
|
|
10
|
+
export interface CoverageGrade {
|
|
11
|
+
grade: Grade;
|
|
12
|
+
score: number;
|
|
13
|
+
covered: number;
|
|
14
|
+
total: number;
|
|
15
|
+
/** uncovered operation names — the "gaps"; generate a Scenario Outline for each (renderScenarioOutlines). */
|
|
16
|
+
uncovered: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Grade a gap report's contract→authored coverage (covered/total) and surface the uncovered ops. */
|
|
20
|
+
export function coverageGrade(report: GapReport): CoverageGrade {
|
|
21
|
+
const { covered, total, holes } = report.coverage;
|
|
22
|
+
const score = total === 0 ? 100 : Math.round((covered / total) * 100);
|
|
23
|
+
return { grade: grade(score), score, covered, total, uncovered: holes.map((h) => h.name) };
|
|
24
|
+
}
|
package/src/demos.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo collections (C042): a bound feature set → a Bruno OR Postman collection a tester clicks through to showcase a
|
|
3
|
+
* feature START-TO-END on a LIVE (production) environment during a call.
|
|
4
|
+
*
|
|
5
|
+
* Same binding as the runnable emitter (C038/C040), a DIFFERENT lowering: each bound `When` becomes a raw HTTP request
|
|
6
|
+
* (method + `{{baseUrl}}`-prefixed path + auth header + JSON body) rather than a typed `client.<acc>()` call. The body is
|
|
7
|
+
* built from the scenario's first Examples row when present, else SYNTHESIZED from the schema (origin-aware) — so a demo
|
|
8
|
+
* is concrete without a table. A `sourced` field becomes request CHAINING: the SOURCE request captures the field into a
|
|
9
|
+
* collection variable (Postman `pm.collectionVariables.set` / Bruno `bru.setVar`) and the consumer references it via
|
|
10
|
+
* `{{var}}` — the live-call equivalent of `resolveSourced`. `baseUrl` + `token` are collection variables the presenter
|
|
11
|
+
* sets to point at prod. Pure (collection text out); a downstream consumer of the contract, never read by the matcher.
|
|
12
|
+
*/
|
|
13
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
14
|
+
import { resolveOps, type OpInfo } from "@suluk/sdk";
|
|
15
|
+
import { describeInputs, synthesize, type JsonSchema } from "@suluk/examples";
|
|
16
|
+
import { bindFeatures, type BindOptions } from "./bind";
|
|
17
|
+
import type { Feature } from "./gherkin";
|
|
18
|
+
import type { Vocabulary } from "./vocabulary";
|
|
19
|
+
|
|
20
|
+
/** A request value: a concrete literal, or a `{{var}}` reference to a captured upstream response field. */
|
|
21
|
+
export type DemoValue = { kind: "literal"; value: unknown } | { kind: "var"; name: string };
|
|
22
|
+
/** Capture `res.<from>` of this request into the collection variable `var` (for downstream chaining). */
|
|
23
|
+
export interface DemoCapture {
|
|
24
|
+
var: string;
|
|
25
|
+
from: string;
|
|
26
|
+
}
|
|
27
|
+
export interface DemoRequest {
|
|
28
|
+
/** the human label (the op name). */
|
|
29
|
+
label: string;
|
|
30
|
+
/** the op's by-name handle name (for chaining resolution). */
|
|
31
|
+
name: string;
|
|
32
|
+
method: string;
|
|
33
|
+
/** path with `{param}` substituted to a row value or a `{{param}}` variable; prefixed with `{{baseUrl}}` at render. */
|
|
34
|
+
path: string;
|
|
35
|
+
needsAuth: boolean;
|
|
36
|
+
body?: Record<string, DemoValue>;
|
|
37
|
+
captures: DemoCapture[];
|
|
38
|
+
}
|
|
39
|
+
export interface DemoScenario {
|
|
40
|
+
name: string;
|
|
41
|
+
requests: DemoRequest[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SOURCED_CELL = /^<([A-Za-z_][\w]*)\.(.+)>$/;
|
|
45
|
+
const varName = (op: string, select: string) => `${op}_${select.replace(/[^A-Za-z0-9]+/g, "_")}`.replace(/_+$/, "");
|
|
46
|
+
|
|
47
|
+
function coerce(cell: string, fieldSchema?: JsonSchema): unknown {
|
|
48
|
+
const t = fieldSchema && typeof fieldSchema === "object" ? fieldSchema.type : undefined;
|
|
49
|
+
const type = Array.isArray(t) ? t[0] : t;
|
|
50
|
+
if ((type === "integer" || type === "number") && cell.trim() !== "" && Number.isFinite(Number(cell))) return Number(cell);
|
|
51
|
+
if (type === "boolean" && (cell === "true" || cell === "false")) return cell === "true";
|
|
52
|
+
return cell;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Ref {
|
|
56
|
+
op: string;
|
|
57
|
+
select: string;
|
|
58
|
+
var: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build the JSON body for a request from the Examples row (when present) else from the schema (synthesized). */
|
|
62
|
+
function buildBody(op: OpInfo, cells: Record<string, string> | null): { body?: Record<string, DemoValue>; refs: Ref[] } {
|
|
63
|
+
const bodyRaw = op.bodyRaw as JsonSchema | undefined;
|
|
64
|
+
if (!bodyRaw || typeof bodyRaw !== "object") return { refs: [] };
|
|
65
|
+
const props = (bodyRaw.properties ?? {}) as Record<string, JsonSchema>;
|
|
66
|
+
const descs = describeInputs(bodyRaw);
|
|
67
|
+
if (!descs.length) return { refs: [] };
|
|
68
|
+
const body: Record<string, DemoValue> = {};
|
|
69
|
+
const refs: Ref[] = [];
|
|
70
|
+
for (const d of descs) {
|
|
71
|
+
if (d.origin === "computed") continue; // a client never sends a server-computed field
|
|
72
|
+
const cell = cells?.[d.name];
|
|
73
|
+
const token = cell ? SOURCED_CELL.exec(cell.trim()) : null;
|
|
74
|
+
if (token) {
|
|
75
|
+
const v = varName(token[1], token[2]);
|
|
76
|
+
body[d.name] = { kind: "var", name: v };
|
|
77
|
+
refs.push({ op: token[1], select: token[2], var: v });
|
|
78
|
+
} else if (cell !== undefined && cell !== "") {
|
|
79
|
+
body[d.name] = { kind: "literal", value: coerce(cell, props[d.name]) };
|
|
80
|
+
} else if (d.origin === "sourced" && d.source) {
|
|
81
|
+
const v = varName(d.source.op, d.source.select ?? "id");
|
|
82
|
+
body[d.name] = { kind: "var", name: v };
|
|
83
|
+
refs.push({ op: d.source.op, select: d.source.select ?? "id", var: v });
|
|
84
|
+
} else {
|
|
85
|
+
body[d.name] = { kind: "literal", value: synthesize(props[d.name], d.name, { direction: "request" }) };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { body, refs };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function substitutePath(uri: string, cells: Record<string, string> | null): string {
|
|
92
|
+
const path = uri.replace(/^\/?/, "/");
|
|
93
|
+
return path.replace(/\{\+?([^}?&]+)\}/g, (_, p) => {
|
|
94
|
+
const cell = cells?.[p];
|
|
95
|
+
return cell && cell !== "" && !SOURCED_CELL.test(cell) ? cell : `{{${p}}}`;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface CompileDemoOptions extends BindOptions {}
|
|
100
|
+
|
|
101
|
+
/** Compile a bound feature set into the demo IR: ordered requests per scenario, with sourced fields wired to captures. */
|
|
102
|
+
export function compileDemos(doc: OpenAPIv4Document, vocab: Vocabulary, features: Feature[], opts: CompileDemoOptions = {}): DemoScenario[] {
|
|
103
|
+
const report = bindFeatures(vocab, features, opts);
|
|
104
|
+
const sourceScenarios = features.flatMap((f) => f.scenarios);
|
|
105
|
+
const { ops } = resolveOps(doc);
|
|
106
|
+
const opByHandle = new Map<string, OpInfo>(ops.map((op) => [`${op.name}@${op.uri}`, op]));
|
|
107
|
+
|
|
108
|
+
const demos: DemoScenario[] = [];
|
|
109
|
+
for (let i = 0; i < report.scenarios.length; i++) {
|
|
110
|
+
const sc = report.scenarios[i];
|
|
111
|
+
const ex = sourceScenarios[i]?.examples;
|
|
112
|
+
const cells = ex && ex.headers.length && ex.rows.length ? Object.fromEntries(ex.headers.map((h, ci) => [h, ex.rows[0][ci] ?? ""])) : null;
|
|
113
|
+
|
|
114
|
+
const requests: DemoRequest[] = [];
|
|
115
|
+
const refsPerReq: Ref[][] = [];
|
|
116
|
+
for (const r of sc.results) {
|
|
117
|
+
if (r.state !== "BOUND" || r.step.kind !== "when") continue;
|
|
118
|
+
const op = opByHandle.get(r.handle);
|
|
119
|
+
if (!op) continue;
|
|
120
|
+
const { body, refs } = buildBody(op, cells);
|
|
121
|
+
requests.push({
|
|
122
|
+
label: op.name,
|
|
123
|
+
name: op.name,
|
|
124
|
+
method: op.method.toUpperCase(),
|
|
125
|
+
path: substitutePath(op.uri, cells),
|
|
126
|
+
needsAuth: op.requires !== "anyone",
|
|
127
|
+
...(body && Object.keys(body).length ? { body } : {}),
|
|
128
|
+
captures: [],
|
|
129
|
+
});
|
|
130
|
+
refsPerReq.push(refs);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// resolve chaining: each ref consumed by request `c` is captured by the latest prior request whose op produced it.
|
|
134
|
+
for (let c = 0; c < requests.length; c++) {
|
|
135
|
+
for (const ref of refsPerReq[c]) {
|
|
136
|
+
for (let s = c - 1; s >= 0; s--) {
|
|
137
|
+
if (requests[s].name === ref.op) {
|
|
138
|
+
if (!requests[s].captures.some((cap) => cap.var === ref.var)) requests[s].captures.push({ var: ref.var, from: ref.select });
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (requests.length) demos.push({ name: sc.scenario, requests });
|
|
146
|
+
}
|
|
147
|
+
return demos;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
151
|
+
// Renderers
|
|
152
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
export interface RenderOptions {
|
|
155
|
+
/** collection name (default the doc/feature title or "Demo"). */
|
|
156
|
+
name?: string;
|
|
157
|
+
/** the PROD base URL — the live-call target the tester switches to. */
|
|
158
|
+
baseUrl?: string;
|
|
159
|
+
/** the LOCAL base URL a developer tests against FIRST (the same collection, just a different `baseUrl`). Default a
|
|
160
|
+
* Cloudflare Workers `wrangler dev` port. */
|
|
161
|
+
localBaseUrl?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const DEFAULT_LOCAL = "http://localhost:8787";
|
|
165
|
+
|
|
166
|
+
const slug = (s: string) => s.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "step";
|
|
167
|
+
|
|
168
|
+
/** Render a JSON body where `{{var}}` refs are quoted strings (live-call chaining) and literals are JSON. */
|
|
169
|
+
function renderBodyJson(body: Record<string, DemoValue>): string {
|
|
170
|
+
const lines = Object.entries(body).map(([k, v]) => ` ${JSON.stringify(k)}: ${v.kind === "var" ? JSON.stringify(`{{${v.name}}}`) : JSON.stringify(v.value)}`);
|
|
171
|
+
return `{\n${lines.join(",\n")}\n}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Render the demos as a Postman Collection v2.1.0 (a single JSON string). */
|
|
175
|
+
export function renderPostman(demos: DemoScenario[], opts: RenderOptions = {}): string {
|
|
176
|
+
const local = opts.localBaseUrl ?? DEFAULT_LOCAL;
|
|
177
|
+
const collection = {
|
|
178
|
+
// `baseUrl` defaults to LOCAL so a fresh import runs against a dev server first; switch it to `{{prodBaseUrl}}` (or
|
|
179
|
+
// a Postman environment) for the live production demo. Same collection, both environments.
|
|
180
|
+
info: { name: opts.name ?? "Demo", schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" },
|
|
181
|
+
variable: [
|
|
182
|
+
{ key: "baseUrl", value: local },
|
|
183
|
+
{ key: "localBaseUrl", value: local },
|
|
184
|
+
{ key: "prodBaseUrl", value: opts.baseUrl ?? "" },
|
|
185
|
+
{ key: "token", value: "" },
|
|
186
|
+
],
|
|
187
|
+
item: demos.map((sc) => ({
|
|
188
|
+
name: sc.name,
|
|
189
|
+
item: sc.requests.map((req) => {
|
|
190
|
+
const header = [
|
|
191
|
+
...(req.body ? [{ key: "Content-Type", value: "application/json" }] : []),
|
|
192
|
+
...(req.needsAuth ? [{ key: "Authorization", value: "Bearer {{token}}" }] : []),
|
|
193
|
+
];
|
|
194
|
+
const scripts: string[] = [`pm.test("2xx", () => pm.expect(pm.response.code).to.be.below(300));`];
|
|
195
|
+
for (const cap of req.captures) scripts.push(`pm.collectionVariables.set(${JSON.stringify(cap.var)}, pm.response.json().${cap.from});`);
|
|
196
|
+
return {
|
|
197
|
+
name: req.label,
|
|
198
|
+
event: scripts.length ? [{ listen: "test", script: { type: "text/javascript", exec: scripts } }] : [],
|
|
199
|
+
request: {
|
|
200
|
+
method: req.method,
|
|
201
|
+
header,
|
|
202
|
+
url: { raw: `{{baseUrl}}${req.path}`, host: ["{{baseUrl}}"], path: req.path.split("/").filter(Boolean) },
|
|
203
|
+
...(req.body ? { body: { mode: "raw", raw: renderBodyJson(req.body), options: { raw: { language: "json" } } } } : {}),
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}),
|
|
207
|
+
})),
|
|
208
|
+
};
|
|
209
|
+
return JSON.stringify(collection, null, 2);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Render the demos as a Bruno collection — a map of relative file path → `.bru`/json content the consumer writes. */
|
|
213
|
+
export function renderBruno(demos: DemoScenario[], opts: RenderOptions = {}): Record<string, string> {
|
|
214
|
+
const name = opts.name ?? "Demo";
|
|
215
|
+
const local = opts.localBaseUrl ?? DEFAULT_LOCAL;
|
|
216
|
+
// TWO environments, ONE collection: a developer runs `local` first, the presenter switches to `prod` for the live call.
|
|
217
|
+
const files: Record<string, string> = {
|
|
218
|
+
"bruno.json": JSON.stringify({ version: "1", name, type: "collection", ignore: ["node_modules", ".git"] }, null, 2),
|
|
219
|
+
"environments/local.bru": `vars {\n baseUrl: ${local}\n token: \n}\n`,
|
|
220
|
+
"environments/prod.bru": `vars {\n baseUrl: ${opts.baseUrl ?? ""}\n token: \n}\n`,
|
|
221
|
+
};
|
|
222
|
+
for (const sc of demos) {
|
|
223
|
+
const folder = slug(sc.name);
|
|
224
|
+
sc.requests.forEach((req, i) => {
|
|
225
|
+
const seq = i + 1;
|
|
226
|
+
const blocks: string[] = [
|
|
227
|
+
`meta {\n name: ${req.label}\n type: http\n seq: ${seq}\n}`,
|
|
228
|
+
`${req.method.toLowerCase()} {\n url: {{baseUrl}}${req.path}\n${req.body ? " body: json\n" : ""}${req.needsAuth ? " auth: bearer\n" : ""}}`,
|
|
229
|
+
];
|
|
230
|
+
if (req.body) blocks.push(`headers {\n Content-Type: application/json\n}`);
|
|
231
|
+
if (req.needsAuth) blocks.push(`auth:bearer {\n token: {{token}}\n}`);
|
|
232
|
+
if (req.body) blocks.push(`body:json {\n${renderBodyJson(req.body).split("\n").map((l) => " " + l).join("\n")}\n}`);
|
|
233
|
+
blocks.push(`assert {\n res.status: lt 300\n}`);
|
|
234
|
+
if (req.captures.length) blocks.push(`script:post-response {\n${req.captures.map((c) => ` bru.setVar(${JSON.stringify(c.var)}, res.body.${c.from});`).join("\n")}\n}`);
|
|
235
|
+
files[`${folder}/${seq}-${slug(req.label)}.bru`] = blocks.join("\n\n") + "\n";
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return files;
|
|
239
|
+
}
|