@contractkit/plugin-bruno 1.3.0 → 1.4.1

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.
@@ -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 files: OpenCollectionFile[] = [];
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.push({ relativePath: 'opencollection.yml', content: generateCollectionRoot(options.collectionName, defaultScheme) });
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
- files.push(envFile);
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
- files.push({ relativePath: `${requestDir}/folder.yml`, content: generateFolderFile(subareaDisplayName, 1) });
182
+ globalFiles.push({ relativePath: `${requestDir}/folder.yml`, content: generateFolderFile(subareaDisplayName, 1) });
115
183
  }
116
184
 
117
- // Flatten and alphabetize within this folder before assigning `seq:`.
118
- const requests: Array<{ route: typeof root.routes[number]; op: typeof root.routes[number]['operations'][number]; requestName: string }> = [];
119
- for (const route of root.routes) {
120
- for (const op of route.operations) {
121
- if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
122
- requests.push({ route, op, requestName: op.name ?? route.path });
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
- requests.sort((a, b) => a.requestName.localeCompare(b.requestName));
126
-
127
- let seq = 1;
128
- for (const { route, op, requestName } of requests) {
129
- const fileName = op.name ? `${slugifyName(op.name)}.yml` : `${op.method}-${sanitizePath(route.path)}.yml`;
130
- let content = generateRequestFile(route, op, requestName, seq, modelMap, root, defaultScheme, randomExamples);
131
- const brunoExt = op.pluginExtensions?.['bruno'];
132
- const pluginOverride = brunoExt && typeof brunoExt === 'object' && !Array.isArray(brunoExt)
133
- ? brunoExt['template']
134
- : undefined;
135
- if (typeof pluginOverride === 'string') {
136
- content = mergePluginFile(content, pluginOverride);
137
- }
138
- files.push({ relativePath: `${requestDir}/${fileName}`, content });
139
- seq++;
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
- const trackedPaths = [...files.map(f => f.relativePath), MANIFEST_FILENAME].sort();
144
- files.push({
145
- relativePath: MANIFEST_FILENAME,
146
- content: JSON.stringify({ files: trackedPaths }, null, 2) + '\n',
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
- return files;
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
- /** Parse a previously-written manifest. Returns the list of relative paths to clean up. Returns [] if missing or unreadable so a stale/garbled manifest never blocks regeneration. */
153
- export function parseManifest(content: string): string[] {
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 parsed = JSON.parse(content);
156
- if (Array.isArray(parsed?.files) && parsed.files.every((f: unknown) => typeof f === 'string')) {
157
- return parsed.files as string[];
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 { generateOpenCollection, MANIFEST_FILENAME, parseManifest } from './codegen-bruno.js';
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
- const base = config.baseDir ? resolve(ctx.rootDir, config.baseDir) : ctx.rootDir;
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
- const base = config.baseDir ? resolve(rootDir, config.baseDir) : rootDir;
119
- const outDir = resolve(base, config.output ?? 'bruno-collection');
120
- const collectionName = config.collectionName ?? basename(rootDir);
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
- * Delete files this plugin generated on the previous run, leaving anything
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
- * On first run or after manual deletion of the manifest — nothing is
144
- * removed; stale files from prior versions linger until manually cleaned.
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 cleanupTrackedFiles(outDir: string): void {
147
- const manifestPath = resolve(outDir, MANIFEST_FILENAME);
148
- if (!existsSync(manifestPath)) return;
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
- let tracked: string[];
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
- tracked = parseManifest(readFileSync(manifestPath, 'utf-8'));
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 tracked) {
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 { generateOpenCollection, sanitizePath, MANIFEST_FILENAME, parseManifest, mergePluginFile } from '../src/codegen-bruno.js';
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 [] for malformed input', () => {
1000
- expect(parseManifest('not json')).toEqual([]);
1001
- expect(parseManifest('{}')).toEqual([]);
1002
- expect(parseManifest('{"files": "nope"}')).toEqual([]);
1003
- expect(parseManifest('{"files": [1, 2, 3]}')).toEqual([]);
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
+