@contractkit/plugin-bruno 1.3.0 → 1.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/.turbo/turbo-build$colon$ci.log +6 -6
- package/.turbo/turbo-test$colon$ci.log +15 -15
- package/CHANGELOG.md +11 -0
- package/coverage/clover.xml +369 -333
- package/coverage/coverage-final.json +3 -3
- package/coverage/index.html +21 -21
- package/coverage/src/codegen-bruno.ts.html +760 -181
- package/coverage/src/index.html +30 -30
- package/coverage/src/index.ts.html +103 -76
- package/coverage/tests/helpers.ts.html +16 -16
- package/coverage/tests/index.html +1 -1
- package/dist/codegen-bruno.d.ts +41 -3
- package/dist/codegen-bruno.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +185 -85
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/codegen-bruno.ts +236 -43
- package/src/index.ts +60 -51
- package/tests/codegen-bruno.test.ts +114 -7
package/src/codegen-bruno.ts
CHANGED
|
@@ -8,8 +8,21 @@ import type {
|
|
|
8
8
|
ContractRootNode,
|
|
9
9
|
ModelNode,
|
|
10
10
|
FieldNode,
|
|
11
|
+
IncrementalManifest,
|
|
12
|
+
IncrementalUnit,
|
|
13
|
+
IncrementalResult as IncrementalResultBase,
|
|
14
|
+
} from '@contractkit/core';
|
|
15
|
+
import {
|
|
16
|
+
resolveSecurity,
|
|
17
|
+
resolveModifiers,
|
|
18
|
+
SECURITY_NONE,
|
|
19
|
+
collectTransitiveModelRefs,
|
|
20
|
+
runIncrementalCodegen,
|
|
21
|
+
parseIncrementalManifest,
|
|
22
|
+
emptyIncrementalManifest,
|
|
23
|
+
hashFingerprint,
|
|
24
|
+
INCREMENTAL_MANIFEST_VERSION,
|
|
11
25
|
} from '@contractkit/core';
|
|
12
|
-
import { resolveSecurity, resolveModifiers, SECURITY_NONE } from '@contractkit/core';
|
|
13
26
|
import { basename } from 'path';
|
|
14
27
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
15
28
|
|
|
@@ -22,6 +35,13 @@ export interface OpenCollectionFile {
|
|
|
22
35
|
/** Manifest filename — tracks which files this plugin previously generated so subsequent runs can clean up only those, leaving any user-added files alone. */
|
|
23
36
|
export const MANIFEST_FILENAME = '.contractkit-bruno-manifest.json';
|
|
24
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Bumped whenever the codegen output shape changes in a way that should bust
|
|
40
|
+
* every per-op fingerprint. Mixed into the per-op fingerprint so a plugin
|
|
41
|
+
* upgrade forces full regeneration even when source `.ck` files are unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export const BRUNO_CODEGEN_VERSION = '1';
|
|
44
|
+
|
|
25
45
|
/** Subset of a security scheme sufficient for Bruno auth generation (non-HMAC). */
|
|
26
46
|
export interface BrunoSecurityScheme {
|
|
27
47
|
type: string; // "http" | "apiKey" | "oauth2" | "openIdConnect"
|
|
@@ -68,28 +88,76 @@ export interface OpenCollectionOptions {
|
|
|
68
88
|
environments?: Record<string, Record<string, unknown>>;
|
|
69
89
|
}
|
|
70
90
|
|
|
91
|
+
/** Per-operation bookkeeping computed up front: where the file lives, what to call it, and the YAML inputs needed to render or fingerprint it. */
|
|
92
|
+
interface OpEntry {
|
|
93
|
+
/** Stable identifier across runs — `<file>::<METHOD> <path>`. */
|
|
94
|
+
opKey: string;
|
|
95
|
+
/** Output path relative to the collection root (e.g. `users/get-user.yml`). */
|
|
96
|
+
relativePath: string;
|
|
97
|
+
/** Display name used inside the YAML `info:` block (alphabetized within its folder). */
|
|
98
|
+
requestName: string;
|
|
99
|
+
/** 1-based sequence within the request's containing folder, drives Bruno's UI ordering. */
|
|
100
|
+
seq: number;
|
|
101
|
+
route: OpRouteNode;
|
|
102
|
+
op: OpOperationNode;
|
|
103
|
+
root: OpRootNode;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @deprecated Use {@link IncrementalManifest} from `@contractkit/core`. Re-exported here for backwards compatibility.
|
|
108
|
+
*/
|
|
109
|
+
export type BrunoManifest = IncrementalManifest;
|
|
110
|
+
|
|
111
|
+
/** Result of {@link generateOpenCollectionIncremental}. Renamed `skippedOpCount` for Bruno-specific clarity but otherwise the shared {@link IncrementalResultBase}. */
|
|
112
|
+
export interface IncrementalResult extends Omit<IncrementalResultBase, 'skippedUnitCount'> {
|
|
113
|
+
/** Number of ops whose codegen was skipped because their fingerprint matched. */
|
|
114
|
+
skippedOpCount: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
71
117
|
/**
|
|
72
118
|
* Generates an OpenCollection (https://spec.opencollection.com/) API collection
|
|
73
119
|
* from a set of operation roots. Produces opencollection.yml, an environment
|
|
74
120
|
* file, and one .yml request file per operation.
|
|
121
|
+
*
|
|
122
|
+
* This is the full-regeneration entry point — every file is rebuilt from scratch.
|
|
123
|
+
* For cache-aware incremental builds, use {@link generateOpenCollectionIncremental}.
|
|
75
124
|
*/
|
|
76
125
|
export function generateOpenCollection(roots: OpRootNode[], options: OpenCollectionOptions): OpenCollectionFile[] {
|
|
77
|
-
const
|
|
126
|
+
const result = generateOpenCollectionIncremental(roots, options, emptyManifest());
|
|
127
|
+
return result.filesToWrite;
|
|
128
|
+
}
|
|
78
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Cache-aware variant of {@link generateOpenCollection}. Skips re-rendering YAML
|
|
132
|
+
* for any op whose fingerprint matches the entry in `prevManifest`. The caller is
|
|
133
|
+
* responsible for emitting `filesToWrite`, deleting `deletedPaths`, and persisting
|
|
134
|
+
* `manifest` so the next run can match against it.
|
|
135
|
+
*
|
|
136
|
+
* Global files (collection root, env files, folder.yml) are always regenerated —
|
|
137
|
+
* they're cheap and depend on options the manifest doesn't fingerprint.
|
|
138
|
+
*/
|
|
139
|
+
export function generateOpenCollectionIncremental(
|
|
140
|
+
roots: OpRootNode[],
|
|
141
|
+
options: OpenCollectionOptions,
|
|
142
|
+
prevManifest: IncrementalManifest,
|
|
143
|
+
fileExists: (relativePath: string) => boolean = () => true,
|
|
144
|
+
): IncrementalResult {
|
|
79
145
|
const modelMap = buildModelMap(options.contractRoots ?? []);
|
|
80
146
|
const authOpts = options.auth;
|
|
81
147
|
const defaultScheme = authOpts?.defaultScheme ? authOpts.schemes?.[authOpts.defaultScheme] : undefined;
|
|
82
148
|
const randomExamples = options.randomExamples ?? false;
|
|
83
149
|
const includeInternal = options.includeInternal ?? true;
|
|
84
150
|
|
|
85
|
-
files
|
|
151
|
+
// ── Global files (collection root + env files + folder.yml per area/subarea) ──
|
|
152
|
+
const globalFiles: OpenCollectionFile[] = [];
|
|
153
|
+
globalFiles.push({
|
|
154
|
+
relativePath: 'opencollection.yml',
|
|
155
|
+
content: generateCollectionRoot(options.collectionName, defaultScheme),
|
|
156
|
+
});
|
|
86
157
|
for (const envFile of generateEnvFiles(options.environments, defaultScheme)) {
|
|
87
|
-
|
|
158
|
+
globalFiles.push(envFile);
|
|
88
159
|
}
|
|
89
|
-
// Manifest is appended at the end so it lists every generated path including itself.
|
|
90
160
|
|
|
91
|
-
// Roots are sorted by their top-level folder display name (then by subarea) so the
|
|
92
|
-
// emitted `seq:` numbers — which drive Bruno's UI ordering — line up alphabetically.
|
|
93
161
|
const sortedRoots = [...roots].sort((a, b) => {
|
|
94
162
|
const aArea = a.meta['area'] ?? deriveFolderName(a.file);
|
|
95
163
|
const bArea = b.meta['area'] ?? deriveFolderName(b.file);
|
|
@@ -98,12 +166,12 @@ export function generateOpenCollection(roots: OpRootNode[], options: OpenCollect
|
|
|
98
166
|
return (a.meta['subarea'] ?? '').localeCompare(b.meta['subarea'] ?? '');
|
|
99
167
|
});
|
|
100
168
|
|
|
169
|
+
const units: IncrementalUnit[] = [];
|
|
101
170
|
for (let rootIdx = 0; rootIdx < sortedRoots.length; rootIdx++) {
|
|
102
171
|
const root = sortedRoots[rootIdx]!;
|
|
103
172
|
const folder = root.meta['area'] ? slugifyName(root.meta['area']) : deriveFolderName(root.file);
|
|
104
173
|
const displayName = (root.meta['area'] ?? folder).charAt(0).toUpperCase() + (root.meta['area'] ?? folder).slice(1);
|
|
105
|
-
|
|
106
|
-
files.push({ relativePath: `${folder}/folder.yml`, content: generateFolderFile(displayName, rootIdx + 1) });
|
|
174
|
+
globalFiles.push({ relativePath: `${folder}/folder.yml`, content: generateFolderFile(displayName, rootIdx + 1) });
|
|
107
175
|
|
|
108
176
|
const subarea = root.meta['subarea'];
|
|
109
177
|
const subareaSlug = subarea ? slugifyName(subarea) : undefined;
|
|
@@ -111,55 +179,180 @@ export function generateOpenCollection(roots: OpRootNode[], options: OpenCollect
|
|
|
111
179
|
|
|
112
180
|
if (subareaSlug) {
|
|
113
181
|
const subareaDisplayName = subarea!.charAt(0).toUpperCase() + subarea!.slice(1);
|
|
114
|
-
|
|
182
|
+
globalFiles.push({ relativePath: `${requestDir}/folder.yml`, content: generateFolderFile(subareaDisplayName, 1) });
|
|
115
183
|
}
|
|
116
184
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
185
|
+
for (const entry of buildOpEntries(root, requestDir, includeInternal)) {
|
|
186
|
+
units.push({
|
|
187
|
+
key: entry.opKey,
|
|
188
|
+
fingerprint: computeOpFingerprint(entry, modelMap, defaultScheme, randomExamples),
|
|
189
|
+
render: () => [renderOpFile(entry, modelMap, defaultScheme, randomExamples)],
|
|
190
|
+
});
|
|
124
191
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const result = runIncrementalCodegen({
|
|
195
|
+
codegenVersion: BRUNO_CODEGEN_VERSION,
|
|
196
|
+
manifestFilename: MANIFEST_FILENAME,
|
|
197
|
+
prevManifest,
|
|
198
|
+
globalFiles,
|
|
199
|
+
units,
|
|
200
|
+
fileExists,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
filesToWrite: result.filesToWrite,
|
|
205
|
+
manifest: result.manifest,
|
|
206
|
+
deletedPaths: result.deletedPaths,
|
|
207
|
+
skippedOpCount: result.skippedUnitCount,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Build the ordered, alphabetized op entries for a single root. seq numbers are assigned in alphabetical order so the Bruno UI shows requests sorted by display name. */
|
|
212
|
+
function buildOpEntries(root: OpRootNode, requestDir: string, includeInternal: boolean): OpEntry[] {
|
|
213
|
+
const requests: Array<{ route: OpRouteNode; op: OpOperationNode; requestName: string }> = [];
|
|
214
|
+
for (const route of root.routes) {
|
|
215
|
+
for (const op of route.operations) {
|
|
216
|
+
if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
|
|
217
|
+
requests.push({ route, op, requestName: op.name ?? route.path });
|
|
140
218
|
}
|
|
141
219
|
}
|
|
220
|
+
requests.sort((a, b) => a.requestName.localeCompare(b.requestName));
|
|
221
|
+
|
|
222
|
+
const entries: OpEntry[] = [];
|
|
223
|
+
let seq = 1;
|
|
224
|
+
for (const { route, op, requestName } of requests) {
|
|
225
|
+
const fileName = op.name ? `${slugifyName(op.name)}.yml` : `${op.method}-${sanitizePath(route.path)}.yml`;
|
|
226
|
+
entries.push({
|
|
227
|
+
opKey: `${root.file}::${op.method.toUpperCase()} ${route.path}`,
|
|
228
|
+
relativePath: `${requestDir}/${fileName}`,
|
|
229
|
+
requestName,
|
|
230
|
+
seq,
|
|
231
|
+
route,
|
|
232
|
+
op,
|
|
233
|
+
root,
|
|
234
|
+
});
|
|
235
|
+
seq++;
|
|
236
|
+
}
|
|
237
|
+
return entries;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Render a single op's request file (including any plugin-extension YAML override). */
|
|
241
|
+
function renderOpFile(
|
|
242
|
+
entry: OpEntry,
|
|
243
|
+
modelMap: Map<string, ModelNode>,
|
|
244
|
+
defaultScheme: BrunoSecurityScheme | undefined,
|
|
245
|
+
randomExamples: boolean,
|
|
246
|
+
): OpenCollectionFile {
|
|
247
|
+
let content = generateRequestFile(entry.route, entry.op, entry.requestName, entry.seq, modelMap, entry.root, defaultScheme, randomExamples);
|
|
248
|
+
const brunoExt = entry.op.pluginExtensions?.['bruno'];
|
|
249
|
+
const pluginOverride = brunoExt && typeof brunoExt === 'object' && !Array.isArray(brunoExt) ? brunoExt['template'] : undefined;
|
|
250
|
+
if (typeof pluginOverride === 'string') {
|
|
251
|
+
content = mergePluginFile(content, pluginOverride);
|
|
252
|
+
}
|
|
253
|
+
return { relativePath: entry.relativePath, content };
|
|
254
|
+
}
|
|
142
255
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
256
|
+
/** Compute a fingerprint covering every input that affects this op's rendered file. Stable across runs given identical inputs. */
|
|
257
|
+
function computeOpFingerprint(
|
|
258
|
+
entry: OpEntry,
|
|
259
|
+
modelMap: Map<string, ModelNode>,
|
|
260
|
+
defaultScheme: BrunoSecurityScheme | undefined,
|
|
261
|
+
randomExamples: boolean,
|
|
262
|
+
): string {
|
|
263
|
+
const referencedModels = collectTransitiveModelRefs(collectOpTypeNodes(entry.route, entry.op), modelMap);
|
|
264
|
+
const modelSnapshot: Record<string, unknown> = {};
|
|
265
|
+
for (const name of [...referencedModels].sort()) {
|
|
266
|
+
const m = modelMap.get(name);
|
|
267
|
+
if (m) modelSnapshot[name] = m;
|
|
268
|
+
}
|
|
269
|
+
// Only include the route fields this op actually depends on. We deliberately exclude
|
|
270
|
+
// `route.operations` so a change to a sibling op doesn't invalidate this op's cache.
|
|
271
|
+
const routeShape = {
|
|
272
|
+
path: entry.route.path,
|
|
273
|
+
params: entry.route.params ?? null,
|
|
274
|
+
modifiers: entry.route.modifiers ?? null,
|
|
275
|
+
security: entry.route.security ?? null,
|
|
276
|
+
description: entry.route.description ?? null,
|
|
277
|
+
};
|
|
278
|
+
return hashFingerprint({
|
|
279
|
+
v: BRUNO_CODEGEN_VERSION,
|
|
280
|
+
opKey: entry.opKey,
|
|
281
|
+
relativePath: entry.relativePath,
|
|
282
|
+
requestName: entry.requestName,
|
|
283
|
+
seq: entry.seq,
|
|
284
|
+
route: routeShape,
|
|
285
|
+
op: entry.op,
|
|
286
|
+
rootMeta: entry.root.meta,
|
|
287
|
+
rootFile: entry.root.file,
|
|
288
|
+
defaultScheme: defaultScheme ?? null,
|
|
289
|
+
randomExamples,
|
|
290
|
+
models: modelSnapshot,
|
|
147
291
|
});
|
|
292
|
+
}
|
|
148
293
|
|
|
149
|
-
|
|
294
|
+
/** Collect every ContractTypeNode that contributes to this op's rendered output. Used as the seed set for transitive model collection in {@link computeOpFingerprint}. */
|
|
295
|
+
function collectOpTypeNodes(route: OpRouteNode, op: OpOperationNode): ContractTypeNode[] {
|
|
296
|
+
const out: ContractTypeNode[] = [];
|
|
297
|
+
pushFromParamSource(route.params, out);
|
|
298
|
+
pushFromParamSource(op.query, out);
|
|
299
|
+
pushFromParamSource(op.headers, out);
|
|
300
|
+
if (op.request) {
|
|
301
|
+
for (const body of op.request.bodies) out.push(body.bodyType);
|
|
302
|
+
}
|
|
303
|
+
for (const resp of op.responses) {
|
|
304
|
+
if (resp.bodyType) out.push(resp.bodyType);
|
|
305
|
+
if (resp.headers) {
|
|
306
|
+
for (const h of resp.headers) out.push(h.type);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function pushFromParamSource(src: ParamSource | undefined, out: ContractTypeNode[]): void {
|
|
313
|
+
if (!src) return;
|
|
314
|
+
if (src.kind === 'params') {
|
|
315
|
+
for (const n of src.nodes) out.push(n.type);
|
|
316
|
+
} else if (src.kind === 'ref') {
|
|
317
|
+
out.push({ kind: 'ref', name: src.name } as ContractTypeNode);
|
|
318
|
+
} else if (src.kind === 'type') {
|
|
319
|
+
out.push(src.node);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Empty-state manifest, used when none has been written yet (or when the cache is being bypassed). */
|
|
324
|
+
export function emptyManifest(): IncrementalManifest {
|
|
325
|
+
return emptyIncrementalManifest(BRUNO_CODEGEN_VERSION);
|
|
150
326
|
}
|
|
151
327
|
|
|
152
|
-
/**
|
|
153
|
-
|
|
328
|
+
/**
|
|
329
|
+
* Parse a previously-written manifest. Accepts both v1 (`{ files: string[] }`) and v2
|
|
330
|
+
* (full {@link IncrementalManifest}) shapes. v1 manifests are returned with an empty
|
|
331
|
+
* `units` map so the next run treats every op as a cache miss while still cleaning up
|
|
332
|
+
* the tracked file list.
|
|
333
|
+
*
|
|
334
|
+
* Returns an empty manifest on any parse error so a stale/garbled file never blocks
|
|
335
|
+
* regeneration.
|
|
336
|
+
*/
|
|
337
|
+
export function parseManifest(content: string): IncrementalManifest {
|
|
338
|
+
const parsed = parseIncrementalManifest(content);
|
|
339
|
+
// The shared parser handles v2. If v2 parsing produced an empty manifest, fall
|
|
340
|
+
// through to a v1 shape ({ files: [...] }) and migrate it for cleanup purposes.
|
|
341
|
+
if (parsed.files.length > 0 || Object.keys(parsed.units).length > 0) return parsed;
|
|
154
342
|
try {
|
|
155
|
-
const
|
|
156
|
-
if (Array.isArray(
|
|
157
|
-
return
|
|
343
|
+
const raw = JSON.parse(content);
|
|
344
|
+
if (Array.isArray(raw?.files) && raw.files.every((f: unknown) => typeof f === 'string')) {
|
|
345
|
+
return {
|
|
346
|
+
version: INCREMENTAL_MANIFEST_VERSION,
|
|
347
|
+
codegenVersion: '',
|
|
348
|
+
files: raw.files as string[],
|
|
349
|
+
units: {},
|
|
350
|
+
};
|
|
158
351
|
}
|
|
159
352
|
} catch {
|
|
160
353
|
// fall through
|
|
161
354
|
}
|
|
162
|
-
return
|
|
355
|
+
return emptyManifest();
|
|
163
356
|
}
|
|
164
357
|
|
|
165
358
|
// ─── Plugin file merge ─────────────────────────────────────────────────────
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { resolve, basename, dirname } from 'node:path';
|
|
2
2
|
import { existsSync, readFileSync, rmSync, readdirSync, rmdirSync } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { generateOpenCollectionIncremental, MANIFEST_FILENAME, parseManifest, emptyManifest } from './codegen-bruno.js';
|
|
4
4
|
import type { BrunoSecurityScheme } from './codegen-bruno.js';
|
|
5
|
-
import type { ContractKitPlugin, PluginValue } from '@contractkit/core';
|
|
5
|
+
import type { ContractKitPlugin, PluginContext, PluginValue, OpRootNode, ContractRootNode, IncrementalManifest } from '@contractkit/core';
|
|
6
6
|
|
|
7
7
|
/** Configuration accepted by the Bruno plugin, both via `contractkit.config.json` and `createBrunoPlugin`. */
|
|
8
8
|
export interface BrunoPluginConfig {
|
|
@@ -67,27 +67,10 @@ function describe(value: PluginValue): string {
|
|
|
67
67
|
|
|
68
68
|
const plugin: ContractKitPlugin = {
|
|
69
69
|
name: 'bruno',
|
|
70
|
-
cacheKey: 'bruno',
|
|
71
70
|
validateExtension: validateBrunoExtension,
|
|
72
71
|
async generateTargets({ opRoots, contractRoots }, ctx) {
|
|
73
72
|
const { auth, ...config } = ctx.options as BrunoPluginOptions;
|
|
74
|
-
|
|
75
|
-
const outDir = resolve(base, config.output ?? 'bruno-collection');
|
|
76
|
-
const collectionName = config.collectionName ?? basename(ctx.rootDir);
|
|
77
|
-
|
|
78
|
-
cleanupTrackedFiles(outDir);
|
|
79
|
-
|
|
80
|
-
const files = generateOpenCollection(opRoots, {
|
|
81
|
-
collectionName,
|
|
82
|
-
contractRoots,
|
|
83
|
-
auth,
|
|
84
|
-
randomExamples: config.randomExamples ?? true,
|
|
85
|
-
includeInternal: config.includeInternal,
|
|
86
|
-
environments: config.environments,
|
|
87
|
-
});
|
|
88
|
-
for (const { relativePath, content } of files) {
|
|
89
|
-
ctx.emitFile(resolve(outDir, relativePath), content);
|
|
90
|
-
}
|
|
73
|
+
await runBrunoCodegen(opRoots, contractRoots, ctx, config, auth);
|
|
91
74
|
},
|
|
92
75
|
};
|
|
93
76
|
|
|
@@ -112,58 +95,83 @@ export function createBrunoPlugin(
|
|
|
112
95
|
): ContractKitPlugin {
|
|
113
96
|
return {
|
|
114
97
|
name: 'bruno',
|
|
115
|
-
cacheKey: `bruno:${JSON.stringify(config)}`,
|
|
116
98
|
validateExtension: validateBrunoExtension,
|
|
117
99
|
async generateTargets({ opRoots, contractRoots }, ctx) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
cleanupTrackedFiles(outDir);
|
|
123
|
-
|
|
124
|
-
const files = generateOpenCollection(opRoots, {
|
|
125
|
-
collectionName,
|
|
126
|
-
contractRoots,
|
|
127
|
-
auth,
|
|
128
|
-
randomExamples: config.randomExamples ?? true,
|
|
129
|
-
includeInternal: config.includeInternal,
|
|
130
|
-
environments: config.environments,
|
|
131
|
-
});
|
|
132
|
-
for (const { relativePath, content } of files) {
|
|
133
|
-
ctx.emitFile(resolve(outDir, relativePath), content);
|
|
134
|
-
}
|
|
100
|
+
// The factory captures rootDir at creation time; ctx.rootDir may differ when the
|
|
101
|
+
// plugin is loaded via a config file, so respect the explicit one passed in here.
|
|
102
|
+
await runBrunoCodegen(opRoots, contractRoots, { ...ctx, rootDir }, config, auth);
|
|
135
103
|
},
|
|
136
104
|
};
|
|
137
105
|
}
|
|
138
106
|
|
|
139
107
|
/**
|
|
140
|
-
*
|
|
141
|
-
* the user added (custom .bru files, scripts, secrets, etc.) untouched.
|
|
108
|
+
* Shared orchestration used by both the default export and {@link createBrunoPlugin}.
|
|
142
109
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
110
|
+
* Reads the prior manifest, runs the cache-aware codegen, deletes any files that
|
|
111
|
+
* are no longer produced, and emits the changed files plus the new manifest. When
|
|
112
|
+
* `ctx.cacheEnabled` is `false` (e.g. `--force`) the prior manifest is ignored so
|
|
113
|
+
* every op regenerates.
|
|
145
114
|
*/
|
|
146
|
-
function
|
|
147
|
-
|
|
148
|
-
|
|
115
|
+
async function runBrunoCodegen(
|
|
116
|
+
opRoots: OpRootNode[],
|
|
117
|
+
contractRoots: ContractRootNode[],
|
|
118
|
+
ctx: PluginContext,
|
|
119
|
+
config: BrunoPluginConfig,
|
|
120
|
+
auth: BrunoPluginOptions['auth'],
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const base = config.baseDir ? resolve(ctx.rootDir, config.baseDir) : ctx.rootDir;
|
|
123
|
+
const outDir = resolve(base, config.output ?? 'bruno-collection');
|
|
124
|
+
const collectionName = config.collectionName ?? basename(ctx.rootDir);
|
|
149
125
|
|
|
150
|
-
|
|
126
|
+
const prevManifest: IncrementalManifest = ctx.cacheEnabled ? readManifest(outDir) : emptyManifest();
|
|
127
|
+
|
|
128
|
+
const result = generateOpenCollectionIncremental(
|
|
129
|
+
opRoots,
|
|
130
|
+
{
|
|
131
|
+
collectionName,
|
|
132
|
+
contractRoots,
|
|
133
|
+
auth,
|
|
134
|
+
randomExamples: config.randomExamples ?? true,
|
|
135
|
+
includeInternal: config.includeInternal,
|
|
136
|
+
environments: config.environments,
|
|
137
|
+
},
|
|
138
|
+
prevManifest,
|
|
139
|
+
relPath => existsSync(resolve(outDir, relPath)),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
deleteStalePaths(outDir, result.deletedPaths);
|
|
143
|
+
|
|
144
|
+
for (const { relativePath, content } of result.filesToWrite) {
|
|
145
|
+
ctx.emitFile(resolve(outDir, relativePath), content);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Read the previous run's manifest. Returns an empty manifest when the file is missing or unreadable so the next run safely starts from scratch. */
|
|
150
|
+
function readManifest(outDir: string): IncrementalManifest {
|
|
151
|
+
const manifestPath = resolve(outDir, MANIFEST_FILENAME);
|
|
152
|
+
if (!existsSync(manifestPath)) return emptyManifest();
|
|
151
153
|
try {
|
|
152
|
-
|
|
154
|
+
return parseManifest(readFileSync(manifestPath, 'utf-8'));
|
|
153
155
|
} catch {
|
|
154
|
-
return;
|
|
156
|
+
return emptyManifest();
|
|
155
157
|
}
|
|
158
|
+
}
|
|
156
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Delete files that the previous run tracked but the current run doesn't produce, then
|
|
162
|
+
* walk back up each affected directory and remove it if it's now empty (stopping at outDir).
|
|
163
|
+
* Anything user-added survives because the manifest only ever lists plugin-generated paths.
|
|
164
|
+
*/
|
|
165
|
+
function deleteStalePaths(outDir: string, relPaths: string[]): void {
|
|
166
|
+
if (relPaths.length === 0) return;
|
|
157
167
|
const removedDirs = new Set<string>();
|
|
158
|
-
for (const rel of
|
|
168
|
+
for (const rel of relPaths) {
|
|
159
169
|
const abs = resolve(outDir, rel);
|
|
160
170
|
if (existsSync(abs)) {
|
|
161
171
|
rmSync(abs, { force: true });
|
|
162
172
|
removedDirs.add(dirname(abs));
|
|
163
173
|
}
|
|
164
174
|
}
|
|
165
|
-
|
|
166
|
-
// Walk up from each affected directory and remove it if empty, stopping at outDir.
|
|
167
175
|
for (const dir of removedDirs) {
|
|
168
176
|
let current = dir;
|
|
169
177
|
while (current.startsWith(outDir) && current !== outDir) {
|
|
@@ -180,3 +188,4 @@ function cleanupTrackedFiles(outDir: string): void {
|
|
|
180
188
|
}
|
|
181
189
|
}
|
|
182
190
|
}
|
|
191
|
+
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
generateOpenCollection,
|
|
4
|
+
generateOpenCollectionIncremental,
|
|
5
|
+
emptyManifest,
|
|
6
|
+
sanitizePath,
|
|
7
|
+
MANIFEST_FILENAME,
|
|
8
|
+
parseManifest,
|
|
9
|
+
mergePluginFile,
|
|
10
|
+
} from '../src/codegen-bruno.js';
|
|
3
11
|
import { validateBrunoExtension } from '../src/index.js';
|
|
4
12
|
import {
|
|
5
13
|
opRoot,
|
|
@@ -987,7 +995,7 @@ describe('generateOpenCollection', () => {
|
|
|
987
995
|
const files = generateOpenCollection([root], { collectionName: 'API' });
|
|
988
996
|
const manifest = files.find(f => f.relativePath === MANIFEST_FILENAME);
|
|
989
997
|
expect(manifest).toBeDefined();
|
|
990
|
-
const tracked = parseManifest(manifest!.content);
|
|
998
|
+
const tracked = parseManifest(manifest!.content).files;
|
|
991
999
|
expect(tracked).toContain('opencollection.yml');
|
|
992
1000
|
expect(tracked).toContain('environments/local.yml');
|
|
993
1001
|
expect(tracked).toContain('users/folder.yml');
|
|
@@ -996,11 +1004,19 @@ describe('generateOpenCollection', () => {
|
|
|
996
1004
|
expect(tracked).toContain(MANIFEST_FILENAME);
|
|
997
1005
|
});
|
|
998
1006
|
|
|
999
|
-
it('parseManifest returns
|
|
1000
|
-
expect(parseManifest('not json')).toEqual([]);
|
|
1001
|
-
expect(parseManifest('
|
|
1002
|
-
expect(parseManifest('{
|
|
1003
|
-
expect(parseManifest('{"files":
|
|
1007
|
+
it('parseManifest returns an empty manifest for malformed input', () => {
|
|
1008
|
+
expect(parseManifest('not json').files).toEqual([]);
|
|
1009
|
+
expect(parseManifest('not json').units).toEqual({});
|
|
1010
|
+
expect(parseManifest('{}').files).toEqual([]);
|
|
1011
|
+
expect(parseManifest('{"files": "nope"}').files).toEqual([]);
|
|
1012
|
+
expect(parseManifest('{"files": [1, 2, 3]}').files).toEqual([]);
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it('parseManifest accepts v1 manifest shape and returns no per-op entries', () => {
|
|
1016
|
+
const v1 = JSON.stringify({ files: ['opencollection.yml', 'users/get-users.yml'] });
|
|
1017
|
+
const parsed = parseManifest(v1);
|
|
1018
|
+
expect(parsed.files).toEqual(['opencollection.yml', 'users/get-users.yml']);
|
|
1019
|
+
expect(parsed.units).toEqual({});
|
|
1004
1020
|
});
|
|
1005
1021
|
|
|
1006
1022
|
// ─── randomExamples ───────────────────────────────────────────────────
|
|
@@ -1286,3 +1302,94 @@ describe('validateBrunoExtension', () => {
|
|
|
1286
1302
|
});
|
|
1287
1303
|
});
|
|
1288
1304
|
|
|
1305
|
+
describe('generateOpenCollectionIncremental', () => {
|
|
1306
|
+
function rootWithTwoOps() {
|
|
1307
|
+
return opRoot([opRoute('/users', [opOperation('get'), opOperation('post')])], 'users.op');
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
it('produces the same files as a full regen on the first run (empty manifest)', () => {
|
|
1311
|
+
const root = rootWithTwoOps();
|
|
1312
|
+
const full = generateOpenCollection([root], { collectionName: 'API' });
|
|
1313
|
+
const incremental = generateOpenCollectionIncremental([root], { collectionName: 'API' }, emptyManifest());
|
|
1314
|
+
expect(incremental.skippedOpCount).toBe(0);
|
|
1315
|
+
expect(incremental.deletedPaths).toEqual([]);
|
|
1316
|
+
// Both runs should write the same set of paths.
|
|
1317
|
+
expect(new Set(incremental.filesToWrite.map(f => f.relativePath))).toEqual(new Set(full.map(f => f.relativePath)));
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
it('skips re-rendering ops whose fingerprint matches the prior manifest', () => {
|
|
1321
|
+
const root = rootWithTwoOps();
|
|
1322
|
+
const first = generateOpenCollectionIncremental([root], { collectionName: 'API' }, emptyManifest());
|
|
1323
|
+
const second = generateOpenCollectionIncremental([root], { collectionName: 'API' }, first.manifest);
|
|
1324
|
+
|
|
1325
|
+
expect(second.skippedOpCount).toBe(2);
|
|
1326
|
+
// Only global files (collection root + env file + folder.yml) and the manifest are re-emitted.
|
|
1327
|
+
const opFiles = second.filesToWrite.filter(f => f.relativePath.endsWith('.yml') && f.relativePath.startsWith('users/') && f.relativePath !== 'users/folder.yml');
|
|
1328
|
+
expect(opFiles).toEqual([]);
|
|
1329
|
+
expect(second.deletedPaths).toEqual([]);
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
it('re-renders only the op whose request shape changed', () => {
|
|
1333
|
+
const rootA = opRoot(
|
|
1334
|
+
[opRoute('/users', [opOperation('get'), opOperation('post', { request: opRequest(scalarType('string')) })])],
|
|
1335
|
+
'users.op',
|
|
1336
|
+
);
|
|
1337
|
+
const first = generateOpenCollectionIncremental([rootA], { collectionName: 'API' }, emptyManifest());
|
|
1338
|
+
|
|
1339
|
+
// Change only the POST body type — GET op should remain cached.
|
|
1340
|
+
const rootB = opRoot(
|
|
1341
|
+
[opRoute('/users', [opOperation('get'), opOperation('post', { request: opRequest(scalarType('int')) })])],
|
|
1342
|
+
'users.op',
|
|
1343
|
+
);
|
|
1344
|
+
const second = generateOpenCollectionIncremental([rootB], { collectionName: 'API' }, first.manifest);
|
|
1345
|
+
|
|
1346
|
+
expect(second.skippedOpCount).toBe(1);
|
|
1347
|
+
const writtenOpPaths = second.filesToWrite.map(f => f.relativePath).filter(p => p.startsWith('users/') && p !== 'users/folder.yml');
|
|
1348
|
+
expect(writtenOpPaths).toEqual(['users/post-users.yml']);
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
it('invalidates ops that reference a model whose definition changed', () => {
|
|
1352
|
+
const v1 = model('User', [field('name', scalarType('string'))]);
|
|
1353
|
+
const v2 = model('User', [field('name', scalarType('string')), field('email', scalarType('string'))]);
|
|
1354
|
+
const route = () => opRoute('/users', [opOperation('post', { request: opRequest('User') })]);
|
|
1355
|
+
const root = opRoot([route()], 'users.op');
|
|
1356
|
+
|
|
1357
|
+
const first = generateOpenCollectionIncremental([root], { collectionName: 'API', contractRoots: [contractRoot([v1])] }, emptyManifest());
|
|
1358
|
+
const second = generateOpenCollectionIncremental([root], { collectionName: 'API', contractRoots: [contractRoot([v2])] }, first.manifest);
|
|
1359
|
+
|
|
1360
|
+
expect(second.skippedOpCount).toBe(0);
|
|
1361
|
+
expect(second.filesToWrite.map(f => f.relativePath)).toContain('users/post-users.yml');
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it('regenerates an op whose previously-emitted file is missing on disk', () => {
|
|
1365
|
+
const root = rootWithTwoOps();
|
|
1366
|
+
const first = generateOpenCollectionIncremental([root], { collectionName: 'API' }, emptyManifest());
|
|
1367
|
+
// Simulate that users/get-users.yml was deleted.
|
|
1368
|
+
const second = generateOpenCollectionIncremental(
|
|
1369
|
+
[root],
|
|
1370
|
+
{ collectionName: 'API' },
|
|
1371
|
+
first.manifest,
|
|
1372
|
+
relPath => relPath !== 'users/get-users.yml',
|
|
1373
|
+
);
|
|
1374
|
+
expect(second.skippedOpCount).toBe(1);
|
|
1375
|
+
expect(second.filesToWrite.map(f => f.relativePath)).toContain('users/get-users.yml');
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it('lists removed ops in deletedPaths', () => {
|
|
1379
|
+
const before = opRoot([opRoute('/users', [opOperation('get'), opOperation('post')])], 'users.op');
|
|
1380
|
+
const after = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
|
|
1381
|
+
const first = generateOpenCollectionIncremental([before], { collectionName: 'API' }, emptyManifest());
|
|
1382
|
+
const second = generateOpenCollectionIncremental([after], { collectionName: 'API' }, first.manifest);
|
|
1383
|
+
expect(second.deletedPaths).toContain('users/post-users.yml');
|
|
1384
|
+
expect(second.skippedOpCount).toBe(1); // get-users still cached
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
it('treats a v1 manifest as a full cache miss but still cleans up its tracked files', () => {
|
|
1388
|
+
const root = rootWithTwoOps();
|
|
1389
|
+
const v1Manifest = parseManifest(JSON.stringify({ files: ['legacy/old-file.yml', 'opencollection.yml'] }));
|
|
1390
|
+
const result = generateOpenCollectionIncremental([root], { collectionName: 'API' }, v1Manifest);
|
|
1391
|
+
expect(result.skippedOpCount).toBe(0);
|
|
1392
|
+
expect(result.deletedPaths).toContain('legacy/old-file.yml');
|
|
1393
|
+
});
|
|
1394
|
+
});
|
|
1395
|
+
|