From c0899139d96556c6c03a247e0ebaf08ba79c4df7 Mon Sep 17 00:00:00 2001 From: Alex Vuong Date: Fri, 13 Jun 2025 15:31:22 -0700 Subject: [PATCH 01/11] fix oasdiff on multiple versions of api --- src/diff/oasDiff.test.ts | 176 +++++++++++++++++++++++++++++++-------- src/diff/oasDiff.ts | 106 ++++++++++++++++++----- 2 files changed, 227 insertions(+), 55 deletions(-) diff --git a/src/diff/oasDiff.test.ts b/src/diff/oasDiff.test.ts index 4f77a56..34a91c2 100644 --- a/src/diff/oasDiff.test.ts +++ b/src/diff/oasDiff.test.ts @@ -11,15 +11,21 @@ import sinon from "sinon"; const pq = proxyquire.noCallThru(); describe("oasDiffChangelog", () => { - it("should execute oasdiff command with correct parameters", async () => { - const stub = sinon.stub(); - stub.onCall(0).returns("version 1.0.0"); - stub.onCall(1).returns(""); + it("should execute oasdiff command with correct parameters for single file mode", async () => { + const execSyncStub = sinon.stub(); + execSyncStub.onCall(0).returns("version 1.0.0"); // version check + execSyncStub.onCall(1).returns(""); // diff result + + const fsStub = { + readdir: sinon.stub().returns(["api-v1"]), + stat: sinon.stub().returns({ isDirectory: () => true }), + }; const oasDiff = pq("./oasDiff", { child_process: { - execSync: stub, + execSync: execSyncStub, }, + "fs-extra": fsStub, }); // Arrange @@ -28,7 +34,36 @@ describe("oasDiffChangelog", () => { const flags = {}; const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); - expect(stub.called).to.be.true; + expect(execSyncStub.called).to.be.true; + expect(execSyncStub.args[1][0]).to.equal('oasdiff changelog "base.yaml" "new.yaml"'); + expect(result).to.equal(0); + }); + + it("should execute oasdiff command with correct parameters for directory mode", async () => { + const execSyncStub = sinon.stub(); + execSyncStub.onCall(0).returns("version 1.0.0"); // version check + execSyncStub.onCall(1).returns(""); // diff result + + const fsStub = { + readdir: sinon.stub().returns(["api-v1"]), + stat: sinon.stub().returns({ isDirectory: () => true }), + }; + + const oasDiff = pq("./oasDiff", { + child_process: { + execSync: execSyncStub, + }, + "fs-extra": fsStub, + }); + + // Arrange + const baseApi = "base"; + const newApi = "new"; + const flags = { dir: true }; + const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(execSyncStub.called).to.be.true; + expect(execSyncStub.args[1][0]).to.include('"base/api-v1/**/*.yaml"'); expect(result).to.equal(0); }); @@ -37,15 +72,20 @@ describe("oasDiffChangelog", () => { execSyncStub.onCall(0).returns("version 1.0.0"); execSyncStub.onCall(1).returns("mock oasdiff change"); + const fsStub = { + readdir: sinon.stub().returns(["api-v1"]), + stat: sinon.stub().returns({ isDirectory: () => true }), + }; + const oasDiff = pq("./oasDiff", { child_process: { execSync: execSyncStub, }, + "fs-extra": fsStub, }); - // Arrange - const baseApi = "base.yaml"; - const newApi = "new.yaml"; + const baseApi = "base"; + const newApi = "new"; const flags = {}; const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); @@ -58,15 +98,20 @@ describe("oasDiffChangelog", () => { execSyncStub.onCall(0).returns("version 1.0.0"); execSyncStub.onCall(1).throws(new Error("mock oasdiff error")); + const fsStub = { + readdir: sinon.stub().returns(["api-v1"]), + stat: sinon.stub().returns({ isDirectory: () => true }), + }; + const oasDiff = pq("./oasDiff", { child_process: { execSync: execSyncStub, }, + "fs-extra": fsStub, }); - // Arrange - const baseApi = "base.yaml"; - const newApi = "new.yaml"; + const baseApi = "base"; + const newApi = "new"; const flags = {}; const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); @@ -79,14 +124,20 @@ describe("oasDiffChangelog", () => { execSyncStub.onCall(0).returns("version 1.0.0"); execSyncStub.onCall(1).returns("a change"); + const fsStub = { + readdir: sinon.stub().returns(["api-v1"]), + stat: sinon.stub().returns({ isDirectory: () => true }), + }; + const oasDiff = pq("./oasDiff", { child_process: { execSync: execSyncStub, }, + "fs-extra": fsStub, }); - const baseApi = "base.yaml"; - const newApi = "new.yaml"; + const baseApi = "base"; + const newApi = "new"; const flags = { dir: true, }; @@ -94,61 +145,116 @@ describe("oasDiffChangelog", () => { expect(execSyncStub.called).to.be.true; expect(execSyncStub.args[1][0]).to.equal( - 'oasdiff changelog --composed "base.yaml/**/*.yaml" "new.yaml/**/*.yaml"' + 'oasdiff changelog --composed "base/api-v1/**/*.yaml" "new/api-v1/**/*.yaml"' ); }); - it("should save the changes to a file when the --out-file flag is provided", async () => { + it("should concatenate results from multiple directories in text format", async () => { const execSyncStub = sinon.stub(); execSyncStub.onCall(0).returns("version 1.0.0"); - execSyncStub.onCall(1).returns("a change"); - const fsStub = sinon.stub(); + execSyncStub.onCall(1).returns("changes in api-v1"); + execSyncStub.onCall(2).returns("changes in api-v2"); + + const fsStub = { + readdir: sinon.stub().returns(["api-v1", "api-v2"]), + stat: sinon.stub().returns({ isDirectory: () => true }), + writeFile: sinon.stub(), + }; const oasDiff = pq("./oasDiff", { child_process: { execSync: execSyncStub, }, - "fs-extra": { - writeFile: fsStub, - }, + "fs-extra": fsStub, }); - // Arrange - const baseApi = "base.yaml"; - const newApi = "new.yaml"; + const baseApi = "base"; + const newApi = "new"; const flags = { "out-file": "output.txt", }; await oasDiff.oasDiffChangelog(baseApi, newApi, flags); - expect(fsStub.called).to.be.true; + + expect(fsStub.writeFile.called).to.be.true; + const writtenContent = fsStub.writeFile.args[0][1]; + expect(writtenContent).to.include("=== Changes in api-v1 ==="); + expect(writtenContent).to.include("changes in api-v1"); + expect(writtenContent).to.include("=== Changes in api-v2 ==="); + expect(writtenContent).to.include("changes in api-v2"); }); - it("should save the changes to a jsonfile when the --out-file flag is provided and format is json", async () => { + it("should concatenate results from multiple directories in JSON format", async () => { const execSyncStub = sinon.stub(); execSyncStub.onCall(0).returns("version 1.0.0"); - execSyncStub.onCall(1).returns('{"change": "a change"}'); - const fsStub = sinon.stub(); + execSyncStub.onCall(1).returns('{"changes": "in api-v1"}'); + execSyncStub.onCall(2).returns('{"changes": "in api-v2"}'); + + const fsStub = { + readdir: sinon.stub().returns(["api-v1", "api-v2"]), + stat: sinon.stub().returns({ isDirectory: () => true }), + writeJson: sinon.stub(), + }; const oasDiff = pq("./oasDiff", { child_process: { execSync: execSyncStub, }, - "fs-extra": { - writeJson: fsStub, - }, + "fs-extra": fsStub, }); - // Arrange - const baseApi = "base.yaml"; - const newApi = "new.yaml"; + const baseApi = "base"; + const newApi = "new"; const flags = { "out-file": "output.json", format: "json", }; await oasDiff.oasDiffChangelog(baseApi, newApi, flags); - expect(fsStub.called).to.be.true; + + expect(fsStub.writeJson.called).to.be.true; + const writtenContent = fsStub.writeJson.args[0][1]; + expect(writtenContent).to.be.an("array").with.lengthOf(2); + expect(writtenContent[0]).to.deep.include({ + changes: "in api-v1", + directory: "api-v1", + }); + expect(writtenContent[1]).to.deep.include({ + changes: "in api-v2", + directory: "api-v2", + }); + }); + + it("should skip non-directory entries", async () => { + const execSyncStub = sinon.stub(); + execSyncStub.onCall(0).returns("version 1.0.0"); + execSyncStub.onCall(1).returns("changes in api-v1"); + + const fsStub = { + readdir: sinon.stub().returns(["api-v1", "not-a-dir.txt"]), + stat: sinon.stub(), + writeFile: sinon.stub(), + }; + // First call for api-v1 returns isDirectory true + fsStub.stat.onCall(0).returns({ isDirectory: () => true }); + // Second call for not-a-dir.txt returns isDirectory false + fsStub.stat.onCall(1).returns({ isDirectory: () => false }); + + const oasDiff = pq("./oasDiff", { + child_process: { + execSync: execSyncStub, + }, + "fs-extra": fsStub, + }); + + const baseApi = "base"; + const newApi = "new"; + const flags = {}; + + await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + // Should only call execSync twice (once for version check, once for api-v1) + expect(execSyncStub.callCount).to.equal(2); }); it("should throw an error if oasdiff is not installed", () => { diff --git a/src/diff/oasDiff.ts b/src/diff/oasDiff.ts index 8c05561..a2ffe87 100644 --- a/src/diff/oasDiff.ts +++ b/src/diff/oasDiff.ts @@ -34,42 +34,108 @@ async function _saveOrLogOas(changes: string, flags): Promise { * Wrapper for oasdiff changelog command. * * @param baseApi - The base API file or directory - * @param newApi - The new API file + * @param newApi - The new API file or directory * @param flags - Parsed CLI flags passed to the command * @returns 0 if no changes are reported, 1 if changes are reported, and 2 if an error occurs */ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { try { checkOasDiffIsInstalled(); - console.log("Starting oasdiff"); + console.log("......Starting oasdiff......"); const jsonMode = flags.format === "json" ? "-f json" : ""; const directoryMode = flags.dir ? "--composed" : ""; - // If the user is diffing directories, we need to pass the glob pattern to oasdiff - let baseApiTarget = baseApi; - let newApiTarget = newApi; + let hasChanges = false; + let hasErrors = false; + const allResults = []; + + // Handle directory mode if (flags.dir) { - baseApiTarget = '"' + baseApi + "/**/*.yaml" + '"'; - newApiTarget = '"' + newApi + "/**/*.yaml" + '"'; - } + // Get the list of directories in both base and new API paths + const baseDirectories = await fs.readdir(baseApi); + const newDirectories = await fs.readdir(newApi); + + // Loop through base directories and find matching ones in new + for (const baseDir of baseDirectories) { + const baseDirPath = `${baseApi}/${baseDir}`; + const newDirPath = `${newApi}/${baseDir}`; + + // Skip if not a directory or if matching directory doesn't exist in new + if ( + !(await fs.stat(baseDirPath)).isDirectory() || + !newDirectories.includes(baseDir) + ) { + continue; + } + + console.log(`Processing directory pair: ${baseDir}`); - // TODO: Do we want to support the other output formats? - // See https://github.com/oasdiff/oasdiff/blob/main/docs/BREAKING-CHANGES.md#output-formats + try { + const baseYamlPath = `"${baseDirPath}/**/*.yaml"`; + const newYamlPath = `"${newDirPath}/**/*.yaml"`; - // TODO: Do we want to support customizing severity levels? - // This would be akin to the raml rulesets - const oasdiffOutput = execSync( - `oasdiff changelog ${jsonMode} ${directoryMode} ${baseApiTarget} ${newApiTarget}` - ).toString(); + const oasdiffOutput = execSync( + `oasdiff changelog ${jsonMode} ${directoryMode} ${baseYamlPath} ${newYamlPath}` + ).toString(); - if (oasdiffOutput.trim().length === 0) { - console.log("No API changes reported by oasdiff"); - return 0; + if (oasdiffOutput.trim().length > 0) { + console.log(`Changes found in ${baseDir}`); + if (flags.format === "json") { + // For JSON format, parse the output and add to array + const outputJson = JSON.parse(oasdiffOutput); + outputJson.directory = baseDir; + allResults.push(outputJson); + } else { + allResults.push(oasdiffOutput); + } + hasChanges = true; + } else { + console.log(`No changes found in ${baseDir}`); + } + } catch (err) { + console.error(`Error processing ${baseDir}:`, err); + hasErrors = true; + } + } } else { - await _saveOrLogOas(oasdiffOutput, flags); - return 1; + // Handle single file mode + try { + const oasdiffOutput = execSync( + `oasdiff changelog ${jsonMode} "${baseApi}" "${newApi}"` + ).toString(); + + if (oasdiffOutput.trim().length > 0) { + console.log("Changes found"); + if (flags.format === "json") { + // For JSON format, parse the output + const outputJson = JSON.parse(oasdiffOutput); + allResults.push(outputJson); + } else { + allResults.push(oasdiffOutput); + } + hasChanges = true; + } else { + console.log("No changes found"); + } + } catch (err) { + console.error("Error processing files:", err); + hasErrors = true; + } + } + + if (hasChanges) { + if (flags.format === "json") { + await _saveOrLogOas(JSON.stringify(allResults, null, 2), flags); + } else { + await _saveOrLogOas(allResults.join("\n"), flags); + } + } + + if (hasErrors) { + return 2; } + return hasChanges ? 1 : 0; } catch (err) { console.error(err); return 2; From d1b318d7a4d955a592c436a76ba90ecfdca4c8c6 Mon Sep 17 00:00:00 2001 From: Alex Vuong Date: Fri, 13 Jun 2025 18:26:09 -0700 Subject: [PATCH 02/11] fix tests --- src/diff/oasDiff.test.ts | 8 ++++++-- src/diff/oasDiff.ts | 11 ++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/diff/oasDiff.test.ts b/src/diff/oasDiff.test.ts index 34a91c2..881825c 100644 --- a/src/diff/oasDiff.test.ts +++ b/src/diff/oasDiff.test.ts @@ -35,7 +35,9 @@ describe("oasDiffChangelog", () => { const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); expect(execSyncStub.called).to.be.true; - expect(execSyncStub.args[1][0]).to.equal('oasdiff changelog "base.yaml" "new.yaml"'); + expect(execSyncStub.args[1][0]).to.equal( + 'oasdiff changelog "base.yaml" "new.yaml"' + ); expect(result).to.equal(0); }); @@ -122,7 +124,7 @@ describe("oasDiffChangelog", () => { it("should run oasdiff in directory mode when the --dir flag is provided", async () => { const execSyncStub = sinon.stub(); execSyncStub.onCall(0).returns("version 1.0.0"); - execSyncStub.onCall(1).returns("a change"); + execSyncStub.onCall(1).returns("a minor change"); const fsStub = { readdir: sinon.stub().returns(["api-v1"]), @@ -172,6 +174,7 @@ describe("oasDiffChangelog", () => { const newApi = "new"; const flags = { "out-file": "output.txt", + dir: true, }; await oasDiff.oasDiffChangelog(baseApi, newApi, flags); @@ -208,6 +211,7 @@ describe("oasDiffChangelog", () => { const flags = { "out-file": "output.json", format: "json", + dir: true, }; await oasDiff.oasDiffChangelog(baseApi, newApi, flags); diff --git a/src/diff/oasDiff.ts b/src/diff/oasDiff.ts index a2ffe87..90816af 100644 --- a/src/diff/oasDiff.ts +++ b/src/diff/oasDiff.ts @@ -17,12 +17,12 @@ import { execSync } from "child_process"; async function _saveOrLogOas(changes: string, flags): Promise { const file = flags["out-file"]; if (file) { - console.log(`API Changes found! Saving results to file ${file}`); + console.log(`API Changes found! Saving results to file ${file}:`); if (flags.format === "json") { - console.log("Using json format"); + console.log(` using json format`); await fs.writeJson(file, JSON.parse(changes)); } else { - console.log("Using console format"); + console.log(` using console format`); await fs.writeFile(file, changes); } } else { @@ -82,12 +82,13 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { if (oasdiffOutput.trim().length > 0) { console.log(`Changes found in ${baseDir}`); if (flags.format === "json") { - // For JSON format, parse the output and add to array const outputJson = JSON.parse(oasdiffOutput); outputJson.directory = baseDir; allResults.push(outputJson); } else { - allResults.push(oasdiffOutput); + // For text format, add section headers + const formattedOutput = `=== Changes in ${baseDir} ===\n${oasdiffOutput}`; + allResults.push(formattedOutput); } hasChanges = true; } else { From 37c163255eccfb928b296865941b13959b9e9dc6 Mon Sep 17 00:00:00 2001 From: Alex Vuong Date: Mon, 16 Jun 2025 12:26:19 -0700 Subject: [PATCH 03/11] PR feedback --- src/diff/oasDiff.test.ts | 170 +++++++++++++++++++++++++++++++++++++++ src/diff/oasDiff.ts | 82 ++++++++++++++----- 2 files changed, 234 insertions(+), 18 deletions(-) diff --git a/src/diff/oasDiff.test.ts b/src/diff/oasDiff.test.ts index 881825c..81b58b2 100644 --- a/src/diff/oasDiff.test.ts +++ b/src/diff/oasDiff.test.ts @@ -261,6 +261,176 @@ describe("oasDiffChangelog", () => { expect(execSyncStub.callCount).to.equal(2); }); + it("should report deleted APIs when directories exist in base but not in new", async () => { + const execSyncStub = sinon.stub(); + execSyncStub.onCall(0).returns("version 1.0.0"); + + const fsStub = { + readdir: sinon.stub(), + stat: sinon.stub(), + writeFile: sinon.stub(), + }; + + // Base has api-v1 and api-v2, new only has api-v1 + fsStub.readdir.onCall(0).returns(["api-v1", "api-v2"]); // base directories + fsStub.readdir.onCall(1).returns(["api-v2"]); // new directories + + // All stat calls return isDirectory true + fsStub.stat.returns({ isDirectory: () => true }); + + const oasDiff = pq("./oasDiff", { + child_process: { + execSync: execSyncStub, + }, + "fs-extra": fsStub, + }); + + const baseApi = "base"; + const newApi = "new"; + const flags = { + "out-file": "output.txt", + dir: true, + }; + + await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(fsStub.writeFile.called).to.be.true; + const writtenContent = fsStub.writeFile.args[0][1]; + expect(writtenContent).to.include("======api-v1 API is deleted======"); + }); + + it("should report added APIs when directories exist in new but not in base", async () => { + const execSyncStub = sinon.stub(); + execSyncStub.onCall(0).returns("version 1.0.0"); + + const fsStub = { + readdir: sinon.stub(), + stat: sinon.stub(), + writeFile: sinon.stub(), + }; + + // Base has only api-v1, new has api-v1 and api-v2 + fsStub.readdir.onCall(0).returns(["api-v1"]); // base directories + fsStub.readdir.onCall(1).returns(["api-v1", "api-v2"]); // new directories + + // All stat calls return isDirectory true + fsStub.stat.returns({ isDirectory: () => true }); + + const oasDiff = pq("./oasDiff", { + child_process: { + execSync: execSyncStub, + }, + "fs-extra": fsStub, + }); + + const baseApi = "base"; + const newApi = "new"; + const flags = { + "out-file": "output.txt", + dir: true, + }; + + await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(fsStub.writeFile.called).to.be.true; + const writtenContent = fsStub.writeFile.args[0][1]; + expect(writtenContent).to.include("======api-v2 API is added======"); + }); + + it("should report both added and deleted APIs in the same comparison", async () => { + const execSyncStub = sinon.stub(); + execSyncStub.onCall(0).returns("version 1.0.0"); + execSyncStub.onCall(1).returns("changes in api-v1"); + + const fsStub = { + readdir: sinon.stub(), + stat: sinon.stub(), + writeFile: sinon.stub(), + }; + + // Base has api-v1 and api-v2, new has api-v1 and api-v3 + fsStub.readdir.onCall(0).returns(["api-v1", "api-v2"]); // base directories + fsStub.readdir.onCall(1).returns(["api-v2", "api-v3"]); // new directories + + // All stat calls return isDirectory true + fsStub.stat.returns({ isDirectory: () => true }); + + const oasDiff = pq("./oasDiff", { + child_process: { + execSync: execSyncStub, + }, + "fs-extra": fsStub, + }); + + const baseApi = "base"; + const newApi = "new"; + const flags = { + "out-file": "output.txt", + dir: true, + }; + + await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(fsStub.writeFile.called).to.be.true; + const writtenContent = fsStub.writeFile.args[0][1]; + expect(writtenContent).to.include("======api-v1 API is deleted======"); + expect(writtenContent).to.include("======api-v3 API is added======"); + expect(writtenContent).to.include("=== Changes in api-v2 ==="); + }); + + it("should handle mixed scenarios with changes, additions, and deletions", async () => { + const execSyncStub = sinon.stub(); + execSyncStub.onCall(0).returns("version 1.0.0"); + execSyncStub.onCall(1).returns("changes in common-api"); + execSyncStub.onCall(2).returns(""); // no changes in stable-api + + const fsStub = { + readdir: sinon.stub(), + stat: sinon.stub(), + writeFile: sinon.stub(), + }; + + // Base: common-api, stable-api, old-api + // New: common-api, stable-api, new-api + fsStub.readdir.onCall(0).returns(["common-api", "stable-api", "old-api"]); // base + fsStub.readdir.onCall(1).returns(["common-api", "stable-api", "new-api"]); // new + + // All stat calls return isDirectory true + fsStub.stat.returns({ isDirectory: () => true }); + + const oasDiff = pq("./oasDiff", { + child_process: { + execSync: execSyncStub, + }, + "fs-extra": fsStub, + }); + + const baseApi = "base"; + const newApi = "new"; + const flags = { + "out-file": "output.txt", + dir: true, + }; + + await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(fsStub.writeFile.called).to.be.true; + const writtenContent = fsStub.writeFile.args[0][1]; + + // Should show deleted API + expect(writtenContent).to.include("======old-api API is deleted======"); + + // Should show added API + expect(writtenContent).to.include("======new-api API is added======"); + + // Should show changes in common-api + expect(writtenContent).to.include("=== Changes in common-api ==="); + expect(writtenContent).to.include("changes in common-api"); + + // Should NOT show stable-api since it has no changes + expect(writtenContent).to.not.include("=== Changes in stable-api ==="); + }); + it("should throw an error if oasdiff is not installed", () => { const oasDiff = pq("./oasDiff", { child_process: { diff --git a/src/diff/oasDiff.ts b/src/diff/oasDiff.ts index 90816af..159dac8 100644 --- a/src/diff/oasDiff.ts +++ b/src/diff/oasDiff.ts @@ -5,7 +5,10 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import fs from "fs-extra"; -import { execSync } from "child_process"; +import { exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); /** * If a file is given, saves the changes to the file, as JSON by default. @@ -30,6 +33,27 @@ async function _saveOrLogOas(changes: string, flags): Promise { } } +/** + * Execute oasdiff changelog command + * + * @param baseSpec - The base spec path + * @param newSpec - The new spec path + * @param jsonMode - JSON format flag + * @param directoryMode - Directory mode flag + * @returns The stdout output from oasdiff + */ +async function executeOasDiff( + baseSpec: string, + newSpec: string, + jsonMode: string, + directoryMode = "" +): Promise { + const { stdout } = await execAsync( + `oasdiff changelog ${jsonMode} ${directoryMode} "${baseSpec}" "${newSpec}"` + ); + return stdout; +} + /** * Wrapper for oasdiff changelog command. * @@ -40,7 +64,7 @@ async function _saveOrLogOas(changes: string, flags): Promise { */ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { try { - checkOasDiffIsInstalled(); + await checkOasDiffIsInstalled(); console.log("......Starting oasdiff......"); const jsonMode = flags.format === "json" ? "-f json" : ""; @@ -61,23 +85,31 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { const baseDirPath = `${baseApi}/${baseDir}`; const newDirPath = `${newApi}/${baseDir}`; - // Skip if not a directory or if matching directory doesn't exist in new - if ( - !(await fs.stat(baseDirPath)).isDirectory() || - !newDirectories.includes(baseDir) - ) { + // Skip if not a directory + if (!(await fs.stat(baseDirPath)).isDirectory()) { + continue; + } + + // Check if matching directory doesn't exist in new + if (!newDirectories.includes(baseDir)) { + console.log(`${baseDir} API is deleted`); + allResults.push(`======${baseDir} API is deleted======`); + hasChanges = true; continue; } console.log(`Processing directory pair: ${baseDir}`); try { - const baseYamlPath = `"${baseDirPath}/**/*.yaml"`; - const newYamlPath = `"${newDirPath}/**/*.yaml"`; + const baseYamlPath = `${baseDirPath}/**/*.yaml`; + const newYamlPath = `${newDirPath}/**/*.yaml`; - const oasdiffOutput = execSync( - `oasdiff changelog ${jsonMode} ${directoryMode} ${baseYamlPath} ${newYamlPath}` - ).toString(); + const oasdiffOutput = await executeOasDiff( + baseYamlPath, + newYamlPath, + jsonMode, + directoryMode + ); if (oasdiffOutput.trim().length > 0) { console.log(`Changes found in ${baseDir}`); @@ -99,12 +131,26 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { hasErrors = true; } } + + // Check for newly added APIs (directories in new but not in base) + for (const newDir of newDirectories) { + const newDirPath = `${newApi}/${newDir}`; + + // Skip if not a directory + if (!(await fs.stat(newDirPath)).isDirectory()) { + continue; + } + + // Check if this directory doesn't exist in base (added API) + if (!baseDirectories.includes(newDir)) { + console.log(`${newDir} API is added`); + allResults.push(`======${newDir} API is added======`); + hasChanges = true; + } + } } else { - // Handle single file mode try { - const oasdiffOutput = execSync( - `oasdiff changelog ${jsonMode} "${baseApi}" "${newApi}"` - ).toString(); + const oasdiffOutput = await executeOasDiff(baseApi, newApi, jsonMode); if (oasdiffOutput.trim().length > 0) { console.log("Changes found"); @@ -143,9 +189,9 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { } } -export function checkOasDiffIsInstalled() { +export async function checkOasDiffIsInstalled() { try { - execSync(`oasdiff --version`).toString(); + await execAsync(`oasdiff --version`); } catch (err) { throw new Error( "oasdiff is not installed. Install oasdiff according to https://github.com/oasdiff/oasdiff#installation" From 1d249cfc537e3b1b343bbb0cf95f102a1ef2906d Mon Sep 17 00:00:00 2001 From: Alex Vuong Date: Mon, 16 Jun 2025 13:36:17 -0700 Subject: [PATCH 04/11] PR feedback --- src/diff/oasDiff.test.ts | 149 +++++++++++++++++++++------------------ src/diff/oasDiff.ts | 34 ++++++--- 2 files changed, 107 insertions(+), 76 deletions(-) diff --git a/src/diff/oasDiff.test.ts b/src/diff/oasDiff.test.ts index 81b58b2..109eeb7 100644 --- a/src/diff/oasDiff.test.ts +++ b/src/diff/oasDiff.test.ts @@ -12,9 +12,10 @@ const pq = proxyquire.noCallThru(); describe("oasDiffChangelog", () => { it("should execute oasdiff command with correct parameters for single file mode", async () => { - const execSyncStub = sinon.stub(); - execSyncStub.onCall(0).returns("version 1.0.0"); // version check - execSyncStub.onCall(1).returns(""); // diff result + const execStub = sinon.stub(); + // Mock the callback-style exec function + execStub.callsArgWith(1, null, "", ""); // version check + execStub.onSecondCall().callsArgWith(1, null, "", ""); // diff result const fsStub = { readdir: sinon.stub().returns(["api-v1"]), @@ -23,7 +24,7 @@ describe("oasDiffChangelog", () => { const oasDiff = pq("./oasDiff", { child_process: { - execSync: execSyncStub, + exec: execStub, }, "fs-extra": fsStub, }); @@ -34,17 +35,17 @@ describe("oasDiffChangelog", () => { const flags = {}; const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); - expect(execSyncStub.called).to.be.true; - expect(execSyncStub.args[1][0]).to.equal( - 'oasdiff changelog "base.yaml" "new.yaml"' + expect(execStub.called).to.be.true; + expect(execStub.args[1][0]).to.equal( + 'oasdiff changelog "base.yaml" "new.yaml"' ); expect(result).to.equal(0); }); it("should execute oasdiff command with correct parameters for directory mode", async () => { - const execSyncStub = sinon.stub(); - execSyncStub.onCall(0).returns("version 1.0.0"); // version check - execSyncStub.onCall(1).returns(""); // diff result + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); // version check + execStub.onSecondCall().callsArgWith(1, null, "", ""); // diff result const fsStub = { readdir: sinon.stub().returns(["api-v1"]), @@ -53,7 +54,7 @@ describe("oasDiffChangelog", () => { const oasDiff = pq("./oasDiff", { child_process: { - execSync: execSyncStub, + exec: execStub, }, "fs-extra": fsStub, }); @@ -64,15 +65,15 @@ describe("oasDiffChangelog", () => { const flags = { dir: true }; const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); - expect(execSyncStub.called).to.be.true; - expect(execSyncStub.args[1][0]).to.include('"base/api-v1/**/*.yaml"'); + expect(execStub.called).to.be.true; + expect(execStub.args[1][0]).to.include("base/api-v1/**/*.yaml"); expect(result).to.equal(0); }); it("should return 1 when oasdiff returns a non-empty string", async () => { - const execSyncStub = sinon.stub(); - execSyncStub.onCall(0).returns("version 1.0.0"); - execSyncStub.onCall(1).returns("mock oasdiff change"); + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); + execStub.onSecondCall().callsArgWith(1, null, "mock oasdiff change", ""); const fsStub = { readdir: sinon.stub().returns(["api-v1"]), @@ -81,7 +82,7 @@ describe("oasDiffChangelog", () => { const oasDiff = pq("./oasDiff", { child_process: { - execSync: execSyncStub, + exec: execStub, }, "fs-extra": fsStub, }); @@ -91,14 +92,14 @@ describe("oasDiffChangelog", () => { const flags = {}; const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); - expect(execSyncStub.called).to.be.true; + expect(execStub.called).to.be.true; expect(result).to.equal(1); }); it("should return 2 when oasdiff throws an error", async () => { - const execSyncStub = sinon.stub(); - execSyncStub.onCall(0).returns("version 1.0.0"); - execSyncStub.onCall(1).throws(new Error("mock oasdiff error")); + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); + execStub.onSecondCall().callsArgWith(1, new Error("mock oasdiff error")); const fsStub = { readdir: sinon.stub().returns(["api-v1"]), @@ -107,7 +108,7 @@ describe("oasDiffChangelog", () => { const oasDiff = pq("./oasDiff", { child_process: { - execSync: execSyncStub, + exec: execStub, }, "fs-extra": fsStub, }); @@ -117,14 +118,14 @@ describe("oasDiffChangelog", () => { const flags = {}; const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); - expect(execSyncStub.called).to.be.true; + expect(execStub.called).to.be.true; expect(result).to.equal(2); }); it("should run oasdiff in directory mode when the --dir flag is provided", async () => { - const execSyncStub = sinon.stub(); - execSyncStub.onCall(0).returns("version 1.0.0"); - execSyncStub.onCall(1).returns("a minor change"); + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); + execStub.onSecondCall().callsArgWith(1, null, "a minor change", ""); const fsStub = { readdir: sinon.stub().returns(["api-v1"]), @@ -133,7 +134,7 @@ describe("oasDiffChangelog", () => { const oasDiff = pq("./oasDiff", { child_process: { - execSync: execSyncStub, + exec: execStub, }, "fs-extra": fsStub, }); @@ -145,17 +146,17 @@ describe("oasDiffChangelog", () => { }; await oasDiff.oasDiffChangelog(baseApi, newApi, flags); - expect(execSyncStub.called).to.be.true; - expect(execSyncStub.args[1][0]).to.equal( - 'oasdiff changelog --composed "base/api-v1/**/*.yaml" "new/api-v1/**/*.yaml"' + expect(execStub.called).to.be.true; + expect(execStub.args[1][0]).to.equal( + 'oasdiff changelog --composed "base/api-v1/**/*.yaml" "new/api-v1/**/*.yaml"' ); }); it("should concatenate results from multiple directories in text format", async () => { - const execSyncStub = sinon.stub(); - execSyncStub.onCall(0).returns("version 1.0.0"); - execSyncStub.onCall(1).returns("changes in api-v1"); - execSyncStub.onCall(2).returns("changes in api-v2"); + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); + execStub.onSecondCall().callsArgWith(1, null, "changes in api-v1", ""); + execStub.onThirdCall().callsArgWith(1, null, "changes in api-v2", ""); const fsStub = { readdir: sinon.stub().returns(["api-v1", "api-v2"]), @@ -165,7 +166,7 @@ describe("oasDiffChangelog", () => { const oasDiff = pq("./oasDiff", { child_process: { - execSync: execSyncStub, + exec: execStub, }, "fs-extra": fsStub, }); @@ -188,10 +189,14 @@ describe("oasDiffChangelog", () => { }); it("should concatenate results from multiple directories in JSON format", async () => { - const execSyncStub = sinon.stub(); - execSyncStub.onCall(0).returns("version 1.0.0"); - execSyncStub.onCall(1).returns('{"changes": "in api-v1"}'); - execSyncStub.onCall(2).returns('{"changes": "in api-v2"}'); + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); + execStub + .onSecondCall() + .callsArgWith(1, null, '{"changes": "in api-v1"}', ""); + execStub + .onThirdCall() + .callsArgWith(1, null, '{"changes": "in api-v2"}', ""); const fsStub = { readdir: sinon.stub().returns(["api-v1", "api-v2"]), @@ -201,7 +206,7 @@ describe("oasDiffChangelog", () => { const oasDiff = pq("./oasDiff", { child_process: { - execSync: execSyncStub, + exec: execStub, }, "fs-extra": fsStub, }); @@ -230,9 +235,9 @@ describe("oasDiffChangelog", () => { }); it("should skip non-directory entries", async () => { - const execSyncStub = sinon.stub(); - execSyncStub.onCall(0).returns("version 1.0.0"); - execSyncStub.onCall(1).returns("changes in api-v1"); + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); + execStub.onSecondCall().callsArgWith(1, null, "changes in api-v1", ""); const fsStub = { readdir: sinon.stub().returns(["api-v1", "not-a-dir.txt"]), @@ -246,7 +251,7 @@ describe("oasDiffChangelog", () => { const oasDiff = pq("./oasDiff", { child_process: { - execSync: execSyncStub, + exec: execStub, }, "fs-extra": fsStub, }); @@ -257,13 +262,13 @@ describe("oasDiffChangelog", () => { await oasDiff.oasDiffChangelog(baseApi, newApi, flags); - // Should only call execSync twice (once for version check, once for api-v1) - expect(execSyncStub.callCount).to.equal(2); + // Should only call exec twice (once for version check, once for api-v1) + expect(execStub.callCount).to.equal(2); }); it("should report deleted APIs when directories exist in base but not in new", async () => { - const execSyncStub = sinon.stub(); - execSyncStub.onCall(0).returns("version 1.0.0"); + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); const fsStub = { readdir: sinon.stub(), @@ -271,7 +276,7 @@ describe("oasDiffChangelog", () => { writeFile: sinon.stub(), }; - // Base has api-v1 and api-v2, new only has api-v1 + // Base has api-v1 and api-v2, new only has api-v2 fsStub.readdir.onCall(0).returns(["api-v1", "api-v2"]); // base directories fsStub.readdir.onCall(1).returns(["api-v2"]); // new directories @@ -280,7 +285,7 @@ describe("oasDiffChangelog", () => { const oasDiff = pq("./oasDiff", { child_process: { - execSync: execSyncStub, + exec: execStub, }, "fs-extra": fsStub, }); @@ -300,8 +305,8 @@ describe("oasDiffChangelog", () => { }); it("should report added APIs when directories exist in new but not in base", async () => { - const execSyncStub = sinon.stub(); - execSyncStub.onCall(0).returns("version 1.0.0"); + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); const fsStub = { readdir: sinon.stub(), @@ -318,7 +323,7 @@ describe("oasDiffChangelog", () => { const oasDiff = pq("./oasDiff", { child_process: { - execSync: execSyncStub, + exec: execStub, }, "fs-extra": fsStub, }); @@ -338,9 +343,9 @@ describe("oasDiffChangelog", () => { }); it("should report both added and deleted APIs in the same comparison", async () => { - const execSyncStub = sinon.stub(); - execSyncStub.onCall(0).returns("version 1.0.0"); - execSyncStub.onCall(1).returns("changes in api-v1"); + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); + execStub.onSecondCall().callsArgWith(1, null, "changes in api-v1", ""); const fsStub = { readdir: sinon.stub(), @@ -348,7 +353,7 @@ describe("oasDiffChangelog", () => { writeFile: sinon.stub(), }; - // Base has api-v1 and api-v2, new has api-v1 and api-v3 + // Base has api-v1 and api-v2, new has api-v2 and api-v3 fsStub.readdir.onCall(0).returns(["api-v1", "api-v2"]); // base directories fsStub.readdir.onCall(1).returns(["api-v2", "api-v3"]); // new directories @@ -357,7 +362,7 @@ describe("oasDiffChangelog", () => { const oasDiff = pq("./oasDiff", { child_process: { - execSync: execSyncStub, + exec: execStub, }, "fs-extra": fsStub, }); @@ -379,10 +384,10 @@ describe("oasDiffChangelog", () => { }); it("should handle mixed scenarios with changes, additions, and deletions", async () => { - const execSyncStub = sinon.stub(); - execSyncStub.onCall(0).returns("version 1.0.0"); - execSyncStub.onCall(1).returns("changes in common-api"); - execSyncStub.onCall(2).returns(""); // no changes in stable-api + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); + execStub.onSecondCall().callsArgWith(1, null, "changes in common-api", ""); + execStub.onThirdCall().callsArgWith(1, null, "", ""); // no changes in stable-api const fsStub = { readdir: sinon.stub(), @@ -400,7 +405,7 @@ describe("oasDiffChangelog", () => { const oasDiff = pq("./oasDiff", { child_process: { - execSync: execSyncStub, + exec: execStub, }, "fs-extra": fsStub, }); @@ -431,15 +436,23 @@ describe("oasDiffChangelog", () => { expect(writtenContent).to.not.include("=== Changes in stable-api ==="); }); - it("should throw an error if oasdiff is not installed", () => { + it("should throw an error if oasdiff is not installed", async () => { + const execStub = sinon.stub(); + execStub.callsArgWith(1, new Error("oasdiff not installed")); + const oasDiff = pq("./oasDiff", { child_process: { - execSync: sinon.stub().throws(new Error("oasdiff not installed")), + exec: execStub, }, }); - expect(() => oasDiff.checkOasDiffIsInstalled()).to.throw( - "oasdiff is not installed. Install oasdiff according to https://github.com/oasdiff/oasdiff#installation" - ); + try { + await oasDiff.checkOasDiffIsInstalled(); + expect.fail("Expected function to throw an error"); + } catch (error) { + expect(error.message).to.equal( + "oasdiff is not installed. Install oasdiff according to https://github.com/oasdiff/oasdiff#installation" + ); + } }); }); diff --git a/src/diff/oasDiff.ts b/src/diff/oasDiff.ts index 159dac8..f10c0f0 100644 --- a/src/diff/oasDiff.ts +++ b/src/diff/oasDiff.ts @@ -6,9 +6,6 @@ */ import fs from "fs-extra"; import { exec } from "child_process"; -import { promisify } from "util"; - -const execAsync = promisify(exec); /** * If a file is given, saves the changes to the file, as JSON by default. @@ -48,10 +45,23 @@ async function executeOasDiff( jsonMode: string, directoryMode = "" ): Promise { - const { stdout } = await execAsync( - `oasdiff changelog ${jsonMode} ${directoryMode} "${baseSpec}" "${newSpec}"` - ); - return stdout; + return new Promise((resolve, reject) => { + const flags = [jsonMode, directoryMode] + .filter((flag) => flag.trim() !== "") + .join(" "); + const flagsString = flags ? ` ${flags}` : ""; + // intentionally not leaving space for flagsString + exec( + `oasdiff changelog${flagsString} "${baseSpec}" "${newSpec}"`, + (error, stdout) => { + if (error) { + reject(error); + } else { + resolve(stdout); + } + } + ); + }); } /** @@ -191,7 +201,15 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { export async function checkOasDiffIsInstalled() { try { - await execAsync(`oasdiff --version`); + await new Promise((resolve, reject) => { + exec(`oasdiff --version`, (error, stdout, stderr) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); } catch (err) { throw new Error( "oasdiff is not installed. Install oasdiff according to https://github.com/oasdiff/oasdiff#installation" From e0da462f10e20a50a28b5e74a2bae24802fa86d8 Mon Sep 17 00:00:00 2001 From: Alex Vuong Date: Tue, 17 Jun 2025 13:29:02 -0700 Subject: [PATCH 05/11] clean up --- .eslintrc.json | 2 +- src/diff/oasDiff.test.ts | 100 +++++++++++++++++++++++++++++++++++++-- src/diff/oasDiff.ts | 30 ++++++++++-- 3 files changed, 121 insertions(+), 11 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index a96753c..d016607 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -19,7 +19,7 @@ "max-lines": [ "error", { - "max": 500 + "max": 600 } ], "@typescript-eslint/naming-convention": [ diff --git a/src/diff/oasDiff.test.ts b/src/diff/oasDiff.test.ts index 109eeb7..17a0f03 100644 --- a/src/diff/oasDiff.test.ts +++ b/src/diff/oasDiff.test.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, salesforce.com, inc. + * Copyright (c) 2025, salesforce.com, inc. * All rights reserved. * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause @@ -224,13 +224,15 @@ describe("oasDiffChangelog", () => { expect(fsStub.writeJson.called).to.be.true; const writtenContent = fsStub.writeJson.args[0][1]; expect(writtenContent).to.be.an("array").with.lengthOf(2); - expect(writtenContent[0]).to.deep.include({ - changes: "in api-v1", + expect(writtenContent[0]).to.deep.equal({ directory: "api-v1", + status: "modified", + changes: { changes: "in api-v1" }, }); - expect(writtenContent[1]).to.deep.include({ - changes: "in api-v2", + expect(writtenContent[1]).to.deep.equal({ directory: "api-v2", + status: "modified", + changes: { changes: "in api-v2" }, }); }); @@ -436,6 +438,94 @@ describe("oasDiffChangelog", () => { expect(writtenContent).to.not.include("=== Changes in stable-api ==="); }); + it("should report deleted APIs in JSON format", async () => { + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); + + const fsStub = { + readdir: sinon.stub(), + stat: sinon.stub(), + writeJson: sinon.stub(), + }; + + // Base has api-v1 and api-v2, new only has api-v2 + fsStub.readdir.onCall(0).returns(["api-v1", "api-v2"]); // base directories + fsStub.readdir.onCall(1).returns(["api-v2"]); // new directories + + // All stat calls return isDirectory true + fsStub.stat.returns({ isDirectory: () => true }); + + const oasDiff = pq("./oasDiff", { + child_process: { + exec: execStub, + }, + "fs-extra": fsStub, + }); + + const baseApi = "base"; + const newApi = "new"; + const flags = { + "out-file": "output.json", + format: "json", + dir: true, + }; + + await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(fsStub.writeJson.called).to.be.true; + const writtenContent = fsStub.writeJson.args[0][1]; + expect(writtenContent).to.be.an("array").with.lengthOf(1); + expect(writtenContent[0]).to.deep.equal({ + directory: "api-v1", + status: "deleted", + message: "api-v1 API is deleted", + }); + }); + + it("should report added APIs in JSON format", async () => { + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); + + const fsStub = { + readdir: sinon.stub(), + stat: sinon.stub(), + writeJson: sinon.stub(), + }; + + // Base has only api-v1, new has api-v1 and api-v2 + fsStub.readdir.onCall(0).returns(["api-v1"]); // base directories + fsStub.readdir.onCall(1).returns(["api-v1", "api-v2"]); // new directories + + // All stat calls return isDirectory true + fsStub.stat.returns({ isDirectory: () => true }); + + const oasDiff = pq("./oasDiff", { + child_process: { + exec: execStub, + }, + "fs-extra": fsStub, + }); + + const baseApi = "base"; + const newApi = "new"; + const flags = { + "out-file": "output.json", + format: "json", + dir: true, + }; + + await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(fsStub.writeJson.called).to.be.true; + const writtenContent = fsStub.writeJson.args[0][1]; + expect(writtenContent).to.be.an("array").with.lengthOf(1); + expect(writtenContent[0]).to.deep.equal({ + directory: "api-v2", + status: "added", + message: "api-v2 API is added", + }); + }); + it("should throw an error if oasdiff is not installed", async () => { const execStub = sinon.stub(); execStub.callsArgWith(1, new Error("oasdiff not installed")); diff --git a/src/diff/oasDiff.ts b/src/diff/oasDiff.ts index f10c0f0..9f84fce 100644 --- a/src/diff/oasDiff.ts +++ b/src/diff/oasDiff.ts @@ -103,7 +103,15 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { // Check if matching directory doesn't exist in new if (!newDirectories.includes(baseDir)) { console.log(`${baseDir} API is deleted`); - allResults.push(`======${baseDir} API is deleted======`); + if (flags.format === "json") { + allResults.push({ + directory: baseDir, + status: "deleted", + message: `${baseDir} API is deleted`, + }); + } else { + allResults.push(`======${baseDir} API is deleted======`); + } hasChanges = true; continue; } @@ -125,8 +133,12 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { console.log(`Changes found in ${baseDir}`); if (flags.format === "json") { const outputJson = JSON.parse(oasdiffOutput); - outputJson.directory = baseDir; - allResults.push(outputJson); + if (outputJson.length) { + allResults.push({ + directory: baseDir, + changes: outputJson, + }); + } } else { // For text format, add section headers const formattedOutput = `=== Changes in ${baseDir} ===\n${oasdiffOutput}`; @@ -154,7 +166,15 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { // Check if this directory doesn't exist in base (added API) if (!baseDirectories.includes(newDir)) { console.log(`${newDir} API is added`); - allResults.push(`======${newDir} API is added======`); + if (flags.format === "json") { + allResults.push({ + directory: newDir, + status: "added", + message: `${newDir} API is added`, + }); + } else { + allResults.push(`======${newDir} API is added======`); + } hasChanges = true; } } @@ -202,7 +222,7 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { export async function checkOasDiffIsInstalled() { try { await new Promise((resolve, reject) => { - exec(`oasdiff --version`, (error, stdout, stderr) => { + exec(`oasdiff --version`, (error) => { if (error) { reject(error); } else { From f25db22534a301a259694b2c5a432a52927d5d06 Mon Sep 17 00:00:00 2001 From: Alex Vuong Date: Tue, 17 Jun 2025 13:43:12 -0700 Subject: [PATCH 06/11] clean up --- .eslintrc.json | 2 +- src/diff/diffCommand.test.ts | 4 +- src/diff/oasDiff.test.ts | 106 +++++++++++++++++++++-- src/diff/oasDiff.ts | 12 ++- src/generate-from-oas/generateCommand.ts | 1 - src/generate-from-oas/generateFromOas.ts | 1 - 6 files changed, 110 insertions(+), 16 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index d016607..1c0454f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -19,7 +19,7 @@ "max-lines": [ "error", { - "max": 600 + "max": 700 } ], "@typescript-eslint/naming-convention": [ diff --git a/src/diff/diffCommand.test.ts b/src/diff/diffCommand.test.ts index 5847347..00d4908 100644 --- a/src/diff/diffCommand.test.ts +++ b/src/diff/diffCommand.test.ts @@ -13,7 +13,7 @@ import { Rule } from "json-rules-engine"; import chai from "chai"; import chaiFs from "chai-fs"; -import { DiffCommand as cmd, DiffCommand } from "./diffCommand"; +import { DiffCommand as cmd } from "./diffCommand"; import * as diffDirectories from "./diffDirectories"; import { NodeChanges } from "./changes/nodeChanges"; import { ApiChanges } from "./changes/apiChanges"; @@ -24,10 +24,8 @@ import { RuleCategory } from "./ruleCategory"; import * as oasDiff from "./oasDiff"; import proxyquire from "proxyquire"; -import sinon from "sinon"; chai.use(chaiFs); -const pq = proxyquire.noCallThru(); const nodeChanges = new NodeChanges("test-id", ["test:type"]); nodeChanges.added = { "core:name": "oldName" }; diff --git a/src/diff/oasDiff.test.ts b/src/diff/oasDiff.test.ts index 17a0f03..746748c 100644 --- a/src/diff/oasDiff.test.ts +++ b/src/diff/oasDiff.test.ts @@ -193,10 +193,10 @@ describe("oasDiffChangelog", () => { execStub.callsArgWith(1, null, "version 1.0.0", ""); execStub .onSecondCall() - .callsArgWith(1, null, '{"changes": "in api-v1"}', ""); + .callsArgWith(1, null, '[{"changes": "in api-v1"}]', ""); execStub .onThirdCall() - .callsArgWith(1, null, '{"changes": "in api-v2"}', ""); + .callsArgWith(1, null, '[{"changes": "in api-v2"}]', ""); const fsStub = { readdir: sinon.stub().returns(["api-v1", "api-v2"]), @@ -226,13 +226,11 @@ describe("oasDiffChangelog", () => { expect(writtenContent).to.be.an("array").with.lengthOf(2); expect(writtenContent[0]).to.deep.equal({ directory: "api-v1", - status: "modified", - changes: { changes: "in api-v1" }, + changes: [{ changes: "in api-v1" }], }); expect(writtenContent[1]).to.deep.equal({ directory: "api-v2", - status: "modified", - changes: { changes: "in api-v2" }, + changes: [{ changes: "in api-v2" }], }); }); @@ -526,6 +524,102 @@ describe("oasDiffChangelog", () => { }); }); + it("should not include directories with empty changes in JSON format", async () => { + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); + execStub + .onSecondCall() + .callsArgWith(1, null, '[{"changes": "in api-v1"}]', ""); + execStub.onThirdCall().callsArgWith(1, null, "[]", ""); // empty array + + const fsStub = { + readdir: sinon.stub().returns(["api-v1", "api-v2"]), + stat: sinon.stub().returns({ isDirectory: () => true }), + writeJson: sinon.stub(), + }; + + const oasDiff = pq("./oasDiff", { + child_process: { + exec: execStub, + }, + "fs-extra": fsStub, + }); + + const baseApi = "base"; + const newApi = "new"; + const flags = { + "out-file": "output.json", + format: "json", + dir: true, + }; + + await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(fsStub.writeJson.called).to.be.true; + const writtenContent = fsStub.writeJson.args[0][1]; + expect(writtenContent).to.be.an("array").with.lengthOf(1); + expect(writtenContent[0]).to.deep.equal({ + directory: "api-v1", + changes: [{ changes: "in api-v1" }], + }); + }); + + it("should not include empty results in single file JSON mode", async () => { + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "", ""); // version check + execStub.onSecondCall().callsArgWith(1, null, "[]", ""); // empty array result + + const fsStub = { + readdir: sinon.stub().returns(["api-v1"]), + stat: sinon.stub().returns({ isDirectory: () => true }), + }; + + const oasDiff = pq("./oasDiff", { + child_process: { + exec: execStub, + }, + "fs-extra": fsStub, + }); + + // Arrange + const baseApi = "base.yaml"; + const newApi = "new.yaml"; + const flags = { format: "json" }; + const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(execStub.called).to.be.true; + expect(result).to.equal(0); // No changes should be reported + }); + + it("should include non-empty results in single file JSON mode", async () => { + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "", ""); // version check + execStub + .onSecondCall() + .callsArgWith(1, null, '[{"change": "something"}]', ""); // non-empty array result + + const fsStub = { + readdir: sinon.stub().returns(["api-v1"]), + stat: sinon.stub().returns({ isDirectory: () => true }), + }; + + const oasDiff = pq("./oasDiff", { + child_process: { + exec: execStub, + }, + "fs-extra": fsStub, + }); + + // Arrange + const baseApi = "base.yaml"; + const newApi = "new.yaml"; + const flags = { format: "json" }; + const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(execStub.called).to.be.true; + expect(result).to.equal(1); // Changes should be reported + }); + it("should throw an error if oasdiff is not installed", async () => { const execStub = sinon.stub(); execStub.callsArgWith(1, new Error("oasdiff not installed")); diff --git a/src/diff/oasDiff.ts b/src/diff/oasDiff.ts index 9f84fce..1e17d44 100644 --- a/src/diff/oasDiff.ts +++ b/src/diff/oasDiff.ts @@ -133,18 +133,19 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { console.log(`Changes found in ${baseDir}`); if (flags.format === "json") { const outputJson = JSON.parse(oasdiffOutput); - if (outputJson.length) { + if (outputJson?.length > 0) { allResults.push({ directory: baseDir, changes: outputJson, }); + hasChanges = true; } } else { // For text format, add section headers const formattedOutput = `=== Changes in ${baseDir} ===\n${oasdiffOutput}`; allResults.push(formattedOutput); + hasChanges = true; } - hasChanges = true; } else { console.log(`No changes found in ${baseDir}`); } @@ -187,11 +188,14 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { if (flags.format === "json") { // For JSON format, parse the output const outputJson = JSON.parse(oasdiffOutput); - allResults.push(outputJson); + if (outputJson?.length > 0) { + allResults.push(outputJson); + hasChanges = true; + } } else { allResults.push(oasdiffOutput); + hasChanges = true; } - hasChanges = true; } else { console.log("No changes found"); } diff --git a/src/generate-from-oas/generateCommand.ts b/src/generate-from-oas/generateCommand.ts index dd05d62..9e45f41 100644 --- a/src/generate-from-oas/generateCommand.ts +++ b/src/generate-from-oas/generateCommand.ts @@ -10,7 +10,6 @@ import { allCommonFlags } from "../common/flags"; import { generateFromOas, DEFAULT_CONFIG_PACKAGE_PATH, - DEFAULT_CONFIG_PATH, } from "./generateFromOas"; export class GenerateCommand extends Command { diff --git a/src/generate-from-oas/generateFromOas.ts b/src/generate-from-oas/generateFromOas.ts index 1d7609e..874ddb4 100644 --- a/src/generate-from-oas/generateFromOas.ts +++ b/src/generate-from-oas/generateFromOas.ts @@ -7,7 +7,6 @@ import path from "path"; import { execSync } from "child_process"; -import { string, boolean } from "@oclif/parser/lib/flags"; // Path relative to project root export const DEFAULT_CONFIG_BASE_PATH = From 58d70fcb98606dcdc23d3fda013ddbd4a5fef221 Mon Sep 17 00:00:00 2001 From: Alex Vuong Date: Tue, 17 Jun 2025 13:45:32 -0700 Subject: [PATCH 07/11] clean up --- src/diff/oasDiff.test.ts | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/diff/oasDiff.test.ts b/src/diff/oasDiff.test.ts index 746748c..6b6d0d8 100644 --- a/src/diff/oasDiff.test.ts +++ b/src/diff/oasDiff.test.ts @@ -11,6 +11,26 @@ import sinon from "sinon"; const pq = proxyquire.noCallThru(); describe("oasDiffChangelog", () => { + it("should throw an error if oasdiff is not installed", async () => { + const execStub = sinon.stub(); + execStub.callsArgWith(1, new Error("oasdiff not installed")); + + const oasDiff = pq("./oasDiff", { + child_process: { + exec: execStub, + }, + }); + + try { + await oasDiff.checkOasDiffIsInstalled(); + expect.fail("Expected function to throw an error"); + } catch (error) { + expect(error.message).to.equal( + "oasdiff is not installed. Install oasdiff according to https://github.com/oasdiff/oasdiff#installation" + ); + } + }); + it("should execute oasdiff command with correct parameters for single file mode", async () => { const execStub = sinon.stub(); // Mock the callback-style exec function @@ -619,24 +639,4 @@ describe("oasDiffChangelog", () => { expect(execStub.called).to.be.true; expect(result).to.equal(1); // Changes should be reported }); - - it("should throw an error if oasdiff is not installed", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, new Error("oasdiff not installed")); - - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - }); - - try { - await oasDiff.checkOasDiffIsInstalled(); - expect.fail("Expected function to throw an error"); - } catch (error) { - expect(error.message).to.equal( - "oasdiff is not installed. Install oasdiff according to https://github.com/oasdiff/oasdiff#installation" - ); - } - }); }); From 3e36efba224011e2a2a34e2d7b09e40c6a3a57a6 Mon Sep 17 00:00:00 2001 From: Alex Vuong Date: Tue, 17 Jun 2025 18:16:34 -0700 Subject: [PATCH 08/11] use recursive to traverse directories, with maxDep =3 --- .eslintrc.json | 2 +- src/diff/diffCommand.test.ts | 2 - src/diff/oasDiff.test.ts | 624 ++++++++++++++++++----------------- src/diff/oasDiff.ts | 223 +++++++++---- 4 files changed, 479 insertions(+), 372 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 1c0454f..a96753c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -19,7 +19,7 @@ "max-lines": [ "error", { - "max": 700 + "max": 500 } ], "@typescript-eslint/naming-convention": [ diff --git a/src/diff/diffCommand.test.ts b/src/diff/diffCommand.test.ts index 00d4908..1989237 100644 --- a/src/diff/diffCommand.test.ts +++ b/src/diff/diffCommand.test.ts @@ -23,8 +23,6 @@ import { CategorizedChange } from "./changes/categorizedChange"; import { RuleCategory } from "./ruleCategory"; import * as oasDiff from "./oasDiff"; -import proxyquire from "proxyquire"; - chai.use(chaiFs); const nodeChanges = new NodeChanges("test-id", ["test:type"]); diff --git a/src/diff/oasDiff.test.ts b/src/diff/oasDiff.test.ts index 6b6d0d8..f40604b 100644 --- a/src/diff/oasDiff.test.ts +++ b/src/diff/oasDiff.test.ts @@ -4,13 +4,191 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +/* eslint header/header: "off", max-lines:"off" */ + import { expect } from "chai"; import proxyquire from "proxyquire"; import sinon from "sinon"; const pq = proxyquire.noCallThru(); +// Helper functions for test setup +function createMockFs(): { + readdir: sinon.SinonStub; + stat: sinon.SinonStub; + writeFile: sinon.SinonStub; + writeJson: sinon.SinonStub; +} { + return { + readdir: sinon.stub(), + stat: sinon.stub(), + writeFile: sinon.stub(), + writeJson: sinon.stub(), + }; +} + +function createMockExec(): sinon.SinonStub { + const execStub = sinon.stub(); + execStub.callsArgWith(1, null, "version 1.0.0", ""); // Default version check + return execStub; +} + +/** + * Sets up mock directory structure for fs operations in tests. + * + * This helper function configures sinon stubs for fs.readdir and fs.stat to simulate + * a directory tree structure. Files are automatically detected by .json and .yaml extensions. + * + * @param fsStub - The mocked fs object with stubbed readdir and stat methods + * @param structure - Array of directory objects + * + * @example + * // Simple API directory structure + * setupDirectoryStructure(fsStub, [ + * { path: "base", contents: ["api-v1"] }, + * { path: "base/api-v1", contents: ["exchange.json", "spec.yaml"] } + * ]); + * + * @example + * // Multiple APIs + * setupDirectoryStructure(fsStub, [ + * { path: "base", contents: ["api-v1", "api-v2"] }, + * { path: "base/api-v1", contents: ["exchange.json"] }, + * { path: "base/api-v2", contents: ["exchange.json"] } + * ]); + * this function equivalant to + * // Manual readdir setup (sequential calls) +* fsStub.readdir.onCall(0).returns(["api-v1"]); // First call: base directory +* fsStub.readdir.onCall(1).returns(["exchange.json", "spec.yaml"]); // Second call: base/api-v1 directory + +* // Manual stat setup for each item +* fsStub.stat.withArgs("base/api-v1").returns({ isDirectory: () => true }); // api-v1 is a directory +* fsStub.stat.withArgs("base/api-v1/exchange.json").returns({ isDirectory: () => false }); // .json is a file +* fsStub.stat.withArgs("base/api-v1/spec.yaml").returns({ isDirectory: () => false }); // .yaml is a file +* // Default behavior - everything else is a directory +* fsStub.stat.returns({ isDirectory: () => true }); + */ +function setupDirectoryStructure( + fsStub: { + readdir: sinon.SinonStub; + stat: sinon.SinonStub; + }, + structure: Array<{ path: string; contents: string[] }> +): void { + let callIndex = 0; + + for (const dir of structure) { + fsStub.readdir.onCall(callIndex++).returns(dir.contents); + + for (const item of dir.contents) { + const itemPath = `${dir.path}/${item}`; + const isFile = item.endsWith(".json") || item.endsWith(".yaml"); + fsStub.stat.withArgs(itemPath).returns({ isDirectory: () => !isFile }); + } + } + + fsStub.stat.returns({ isDirectory: () => true }); +} + +function createOasDiffProxy( + execStub: sinon.SinonStub, + fsStub: { + readdir: sinon.SinonStub; + stat: sinon.SinonStub; + writeFile: sinon.SinonStub; + writeJson: sinon.SinonStub; + } +) { + return pq("./oasDiff", { + child_process: { + exec: execStub, + }, + "fs-extra": fsStub, + }); +} + describe("oasDiffChangelog", () => { + let consoleErrorSpy; + beforeEach(() => { + consoleErrorSpy = sinon.spy(console, "error"); + }); + afterEach(() => { + consoleErrorSpy.restore(); + }); + it("should return error code 2 when no exchange.json files are found", async () => { + const execStub = createMockExec(); + const fsStub = createMockFs(); + + setupDirectoryStructure(fsStub, [ + { path: "base", contents: ["api-v1"] }, + { path: "base/api-v1", contents: ["spec.yaml"] }, + ]); + + const oasDiff = createOasDiffProxy(execStub, fsStub); + + const baseApi = "base"; + const newApi = "new"; + const flags = { dir: true }; + + const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(result).to.equal(2); + expect(consoleErrorSpy.called).to.be.true; + expect(consoleErrorSpy.args[0][0].message).to.include( + "No exchange.json file found in leaf directory: base/api-v1" + ); + }); + + it("should return error code 2 when no exchange.json files are found in entire directory tree", async () => { + const execStub = createMockExec(); + const fsStub = createMockFs(); + + setupDirectoryStructure(fsStub, [{ path: "base", contents: [] }]); + + const oasDiff = createOasDiffProxy(execStub, fsStub); + + const baseApi = "base"; + const newApi = "new"; + const flags = { dir: true }; + + const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(result).to.equal(2); + expect(consoleErrorSpy.called).to.be.true; + expect(consoleErrorSpy.args[0][0].message).to.include( + "No exchange.json file found in leaf directory: base. Each API directory must contain an exchange.json file." + ); + }); + + it("should return error code 2 when maximum directory depth is exceeded", async () => { + const execStub = createMockExec(); + const fsStub = createMockFs(); + + // Create very deep directory structure (4 levels deep, exceeding limit of 3) + const deepStructure: Array<{ path: string; contents: string[] }> = []; + let currentPath = "base"; + for (let i = 0; i <= 4; i++) { + deepStructure.push({ path: currentPath, contents: ["nested"] }); + currentPath += "/nested"; + } + + setupDirectoryStructure(fsStub, deepStructure); + + const oasDiff = createOasDiffProxy(execStub, fsStub); + + const baseApi = "base"; + const newApi = "new"; + const flags = { dir: true }; + + const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(result).to.equal(2); + expect(consoleErrorSpy.called).to.be.true; + expect(consoleErrorSpy.args[0][0].message).to.include( + "Maximum directory depth (3) exceeded while searching for exchange.json files in: base/nested/nested/nested/nested" + ); + }); + it("should throw an error if oasdiff is not installed", async () => { const execStub = sinon.stub(); execStub.callsArgWith(1, new Error("oasdiff not installed")); @@ -31,25 +209,29 @@ describe("oasDiffChangelog", () => { } }); + it("should return 2 when oasdiff throws an error", async () => { + const execStub = createMockExec(); + execStub.onSecondCall().callsArgWith(1, new Error("mock oasdiff error")); + + const fsStub = createMockFs(); + const oasDiff = createOasDiffProxy(execStub, fsStub); + + const baseApi = "base"; + const newApi = "new"; + const flags = {}; + const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); + + expect(execStub.called).to.be.true; + expect(result).to.equal(2); + }); + it("should execute oasdiff command with correct parameters for single file mode", async () => { - const execStub = sinon.stub(); - // Mock the callback-style exec function - execStub.callsArgWith(1, null, "", ""); // version check + const execStub = createMockExec(); execStub.onSecondCall().callsArgWith(1, null, "", ""); // diff result - const fsStub = { - readdir: sinon.stub().returns(["api-v1"]), - stat: sinon.stub().returns({ isDirectory: () => true }), - }; - - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const fsStub = createMockFs(); + const oasDiff = createOasDiffProxy(execStub, fsStub); - // Arrange const baseApi = "base.yaml"; const newApi = "new.yaml"; const flags = {}; @@ -63,23 +245,19 @@ describe("oasDiffChangelog", () => { }); it("should execute oasdiff command with correct parameters for directory mode", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); // version check + const execStub = createMockExec(); execStub.onSecondCall().callsArgWith(1, null, "", ""); // diff result - const fsStub = { - readdir: sinon.stub().returns(["api-v1"]), - stat: sinon.stub().returns({ isDirectory: () => true }), - }; + const fsStub = createMockFs(); + setupDirectoryStructure(fsStub, [ + { path: "base", contents: ["api-v1"] }, + { path: "base/api-v1", contents: ["exchange.json", "spec.yaml"] }, + { path: "new", contents: ["api-v1"] }, + { path: "new/api-v1", contents: ["exchange.json", "spec.yaml"] }, + ]); - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const oasDiff = createOasDiffProxy(execStub, fsStub); - // Arrange const baseApi = "base"; const newApi = "new"; const flags = { dir: true }; @@ -91,21 +269,11 @@ describe("oasDiffChangelog", () => { }); it("should return 1 when oasdiff returns a non-empty string", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); + const execStub = createMockExec(); execStub.onSecondCall().callsArgWith(1, null, "mock oasdiff change", ""); - const fsStub = { - readdir: sinon.stub().returns(["api-v1"]), - stat: sinon.stub().returns({ isDirectory: () => true }), - }; - - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const fsStub = createMockFs(); + const oasDiff = createOasDiffProxy(execStub, fsStub); const baseApi = "base"; const newApi = "new"; @@ -116,48 +284,19 @@ describe("oasDiffChangelog", () => { expect(result).to.equal(1); }); - it("should return 2 when oasdiff throws an error", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); - execStub.onSecondCall().callsArgWith(1, new Error("mock oasdiff error")); - - const fsStub = { - readdir: sinon.stub().returns(["api-v1"]), - stat: sinon.stub().returns({ isDirectory: () => true }), - }; - - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); - - const baseApi = "base"; - const newApi = "new"; - const flags = {}; - const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); - - expect(execStub.called).to.be.true; - expect(result).to.equal(2); - }); - it("should run oasdiff in directory mode when the --dir flag is provided", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); + const execStub = createMockExec(); execStub.onSecondCall().callsArgWith(1, null, "a minor change", ""); - const fsStub = { - readdir: sinon.stub().returns(["api-v1"]), - stat: sinon.stub().returns({ isDirectory: () => true }), - }; + const fsStub = createMockFs(); + setupDirectoryStructure(fsStub, [ + { path: "base", contents: ["api-v1"] }, + { path: "base/api-v1", contents: ["exchange.json", "spec.yaml"] }, + { path: "new", contents: ["api-v1"] }, + { path: "new/api-v1", contents: ["exchange.json", "spec.yaml"] }, + ]); - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const oasDiff = createOasDiffProxy(execStub, fsStub); const baseApi = "base"; const newApi = "new"; @@ -173,23 +312,21 @@ describe("oasDiffChangelog", () => { }); it("should concatenate results from multiple directories in text format", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); + const execStub = createMockExec(); execStub.onSecondCall().callsArgWith(1, null, "changes in api-v1", ""); execStub.onThirdCall().callsArgWith(1, null, "changes in api-v2", ""); - const fsStub = { - readdir: sinon.stub().returns(["api-v1", "api-v2"]), - stat: sinon.stub().returns({ isDirectory: () => true }), - writeFile: sinon.stub(), - }; + const fsStub = createMockFs(); + setupDirectoryStructure(fsStub, [ + { path: "base", contents: ["api-v1", "api-v2"] }, + { path: "base/api-v1", contents: ["exchange.json"] }, + { path: "base/api-v2", contents: ["exchange.json"] }, + { path: "new", contents: ["api-v1", "api-v2"] }, + { path: "new/api-v1", contents: ["exchange.json"] }, + { path: "new/api-v2", contents: ["exchange.json"] }, + ]); - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const oasDiff = createOasDiffProxy(execStub, fsStub); const baseApi = "base"; const newApi = "new"; @@ -209,8 +346,7 @@ describe("oasDiffChangelog", () => { }); it("should concatenate results from multiple directories in JSON format", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); + const execStub = createMockExec(); execStub .onSecondCall() .callsArgWith(1, null, '[{"changes": "in api-v1"}]', ""); @@ -218,18 +354,17 @@ describe("oasDiffChangelog", () => { .onThirdCall() .callsArgWith(1, null, '[{"changes": "in api-v2"}]', ""); - const fsStub = { - readdir: sinon.stub().returns(["api-v1", "api-v2"]), - stat: sinon.stub().returns({ isDirectory: () => true }), - writeJson: sinon.stub(), - }; + const fsStub = createMockFs(); + setupDirectoryStructure(fsStub, [ + { path: "base", contents: ["api-v1", "api-v2"] }, + { path: "base/api-v1", contents: ["exchange.json"] }, + { path: "base/api-v2", contents: ["exchange.json"] }, + { path: "new", contents: ["api-v1", "api-v2"] }, + { path: "new/api-v1", contents: ["exchange.json"] }, + { path: "new/api-v2", contents: ["exchange.json"] }, + ]); - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const oasDiff = createOasDiffProxy(execStub, fsStub); const baseApi = "base"; const newApi = "new"; @@ -254,61 +389,19 @@ describe("oasDiffChangelog", () => { }); }); - it("should skip non-directory entries", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); - execStub.onSecondCall().callsArgWith(1, null, "changes in api-v1", ""); - - const fsStub = { - readdir: sinon.stub().returns(["api-v1", "not-a-dir.txt"]), - stat: sinon.stub(), - writeFile: sinon.stub(), - }; - // First call for api-v1 returns isDirectory true - fsStub.stat.onCall(0).returns({ isDirectory: () => true }); - // Second call for not-a-dir.txt returns isDirectory false - fsStub.stat.onCall(1).returns({ isDirectory: () => false }); - - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); - - const baseApi = "base"; - const newApi = "new"; - const flags = {}; - - await oasDiff.oasDiffChangelog(baseApi, newApi, flags); - - // Should only call exec twice (once for version check, once for api-v1) - expect(execStub.callCount).to.equal(2); - }); - it("should report deleted APIs when directories exist in base but not in new", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); + const execStub = createMockExec(); - const fsStub = { - readdir: sinon.stub(), - stat: sinon.stub(), - writeFile: sinon.stub(), - }; - - // Base has api-v1 and api-v2, new only has api-v2 - fsStub.readdir.onCall(0).returns(["api-v1", "api-v2"]); // base directories - fsStub.readdir.onCall(1).returns(["api-v2"]); // new directories + const fsStub = createMockFs(); + setupDirectoryStructure(fsStub, [ + { path: "base", contents: ["api-v1", "api-v2"] }, + { path: "base/api-v1", contents: ["exchange.json"] }, + { path: "base/api-v2", contents: ["exchange.json"] }, + { path: "new", contents: ["api-v2"] }, // only api-v2 + { path: "new/api-v2", contents: ["exchange.json"] }, + ]); - // All stat calls return isDirectory true - fsStub.stat.returns({ isDirectory: () => true }); - - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const oasDiff = createOasDiffProxy(execStub, fsStub); const baseApi = "base"; const newApi = "new"; @@ -325,28 +418,18 @@ describe("oasDiffChangelog", () => { }); it("should report added APIs when directories exist in new but not in base", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); - - const fsStub = { - readdir: sinon.stub(), - stat: sinon.stub(), - writeFile: sinon.stub(), - }; + const execStub = createMockExec(); - // Base has only api-v1, new has api-v1 and api-v2 - fsStub.readdir.onCall(0).returns(["api-v1"]); // base directories - fsStub.readdir.onCall(1).returns(["api-v1", "api-v2"]); // new directories + const fsStub = createMockFs(); + setupDirectoryStructure(fsStub, [ + { path: "base", contents: ["api-v1"] }, + { path: "base/api-v1", contents: ["exchange.json"] }, + { path: "new", contents: ["api-v1", "api-v2"] }, + { path: "new/api-v1", contents: ["exchange.json"] }, + { path: "new/api-v2", contents: ["exchange.json"] }, + ]); - // All stat calls return isDirectory true - fsStub.stat.returns({ isDirectory: () => true }); - - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const oasDiff = createOasDiffProxy(execStub, fsStub); const baseApi = "base"; const newApi = "new"; @@ -363,29 +446,20 @@ describe("oasDiffChangelog", () => { }); it("should report both added and deleted APIs in the same comparison", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); - execStub.onSecondCall().callsArgWith(1, null, "changes in api-v1", ""); - - const fsStub = { - readdir: sinon.stub(), - stat: sinon.stub(), - writeFile: sinon.stub(), - }; + const execStub = createMockExec(); + execStub.onSecondCall().callsArgWith(1, null, "changes in api-v2", ""); - // Base has api-v1 and api-v2, new has api-v2 and api-v3 - fsStub.readdir.onCall(0).returns(["api-v1", "api-v2"]); // base directories - fsStub.readdir.onCall(1).returns(["api-v2", "api-v3"]); // new directories + const fsStub = createMockFs(); + setupDirectoryStructure(fsStub, [ + { path: "base", contents: ["api-v1", "api-v2"] }, + { path: "base/api-v1", contents: ["exchange.json"] }, + { path: "base/api-v2", contents: ["exchange.json"] }, + { path: "new", contents: ["api-v2", "api-v3"] }, + { path: "new/api-v2", contents: ["exchange.json"] }, + { path: "new/api-v3", contents: ["exchange.json"] }, + ]); - // All stat calls return isDirectory true - fsStub.stat.returns({ isDirectory: () => true }); - - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const oasDiff = createOasDiffProxy(execStub, fsStub); const baseApi = "base"; const newApi = "new"; @@ -404,31 +478,23 @@ describe("oasDiffChangelog", () => { }); it("should handle mixed scenarios with changes, additions, and deletions", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); + const execStub = createMockExec(); execStub.onSecondCall().callsArgWith(1, null, "changes in common-api", ""); execStub.onThirdCall().callsArgWith(1, null, "", ""); // no changes in stable-api - const fsStub = { - readdir: sinon.stub(), - stat: sinon.stub(), - writeFile: sinon.stub(), - }; - - // Base: common-api, stable-api, old-api - // New: common-api, stable-api, new-api - fsStub.readdir.onCall(0).returns(["common-api", "stable-api", "old-api"]); // base - fsStub.readdir.onCall(1).returns(["common-api", "stable-api", "new-api"]); // new + const fsStub = createMockFs(); + setupDirectoryStructure(fsStub, [ + { path: "base", contents: ["common-api", "stable-api", "old-api"] }, + { path: "base/common-api", contents: ["exchange.json"] }, + { path: "base/stable-api", contents: ["exchange.json"] }, + { path: "base/old-api", contents: ["exchange.json"] }, + { path: "new", contents: ["common-api", "stable-api", "new-api"] }, + { path: "new/common-api", contents: ["exchange.json"] }, + { path: "new/stable-api", contents: ["exchange.json"] }, + { path: "new/new-api", contents: ["exchange.json"] }, + ]); - // All stat calls return isDirectory true - fsStub.stat.returns({ isDirectory: () => true }); - - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const oasDiff = createOasDiffProxy(execStub, fsStub); const baseApi = "base"; const newApi = "new"; @@ -457,28 +523,20 @@ describe("oasDiffChangelog", () => { }); it("should report deleted APIs in JSON format", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); - - const fsStub = { - readdir: sinon.stub(), - stat: sinon.stub(), - writeJson: sinon.stub(), - }; + const execStub = createMockExec(); + // Empty JSON array (no changes in api-v2) + execStub.onSecondCall().callsArgWith(1, null, "[]", ""); - // Base has api-v1 and api-v2, new only has api-v2 - fsStub.readdir.onCall(0).returns(["api-v1", "api-v2"]); // base directories - fsStub.readdir.onCall(1).returns(["api-v2"]); // new directories + const fsStub = createMockFs(); + setupDirectoryStructure(fsStub, [ + { path: "base", contents: ["api-v1", "api-v2"] }, + { path: "base/api-v1", contents: ["exchange.json"] }, + { path: "base/api-v2", contents: ["exchange.json"] }, + { path: "new", contents: ["api-v2"] }, // only api-v2 + { path: "new/api-v2", contents: ["exchange.json"] }, + ]); - // All stat calls return isDirectory true - fsStub.stat.returns({ isDirectory: () => true }); - - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const oasDiff = createOasDiffProxy(execStub, fsStub); const baseApi = "base"; const newApi = "new"; @@ -501,28 +559,20 @@ describe("oasDiffChangelog", () => { }); it("should report added APIs in JSON format", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); - - const fsStub = { - readdir: sinon.stub(), - stat: sinon.stub(), - writeJson: sinon.stub(), - }; - - // Base has only api-v1, new has api-v1 and api-v2 - fsStub.readdir.onCall(0).returns(["api-v1"]); // base directories - fsStub.readdir.onCall(1).returns(["api-v1", "api-v2"]); // new directories + const execStub = createMockExec(); + // Empty JSON array (no changes in api-v1) + execStub.onSecondCall().callsArgWith(1, null, "[]", ""); - // All stat calls return isDirectory true - fsStub.stat.returns({ isDirectory: () => true }); + const fsStub = createMockFs(); + setupDirectoryStructure(fsStub, [ + { path: "base", contents: ["api-v1"] }, + { path: "base/api-v1", contents: ["exchange.json"] }, + { path: "new", contents: ["api-v1", "api-v2"] }, + { path: "new/api-v1", contents: ["exchange.json"] }, + { path: "new/api-v2", contents: ["exchange.json"] }, + ]); - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const oasDiff = createOasDiffProxy(execStub, fsStub); const baseApi = "base"; const newApi = "new"; @@ -545,25 +595,23 @@ describe("oasDiffChangelog", () => { }); it("should not include directories with empty changes in JSON format", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "version 1.0.0", ""); + const execStub = createMockExec(); execStub .onSecondCall() .callsArgWith(1, null, '[{"changes": "in api-v1"}]', ""); execStub.onThirdCall().callsArgWith(1, null, "[]", ""); // empty array - const fsStub = { - readdir: sinon.stub().returns(["api-v1", "api-v2"]), - stat: sinon.stub().returns({ isDirectory: () => true }), - writeJson: sinon.stub(), - }; + const fsStub = createMockFs(); + setupDirectoryStructure(fsStub, [ + { path: "base", contents: ["api-v1", "api-v2"] }, + { path: "base/api-v1", contents: ["exchange.json"] }, + { path: "base/api-v2", contents: ["exchange.json"] }, + { path: "new", contents: ["api-v1", "api-v2"] }, + { path: "new/api-v1", contents: ["exchange.json"] }, + { path: "new/api-v2", contents: ["exchange.json"] }, + ]); - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const oasDiff = createOasDiffProxy(execStub, fsStub); const baseApi = "base"; const newApi = "new"; @@ -585,21 +633,11 @@ describe("oasDiffChangelog", () => { }); it("should not include empty results in single file JSON mode", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "", ""); // version check + const execStub = createMockExec(); execStub.onSecondCall().callsArgWith(1, null, "[]", ""); // empty array result - const fsStub = { - readdir: sinon.stub().returns(["api-v1"]), - stat: sinon.stub().returns({ isDirectory: () => true }), - }; - - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const fsStub = createMockFs(); + const oasDiff = createOasDiffProxy(execStub, fsStub); // Arrange const baseApi = "base.yaml"; @@ -612,23 +650,13 @@ describe("oasDiffChangelog", () => { }); it("should include non-empty results in single file JSON mode", async () => { - const execStub = sinon.stub(); - execStub.callsArgWith(1, null, "", ""); // version check + const execStub = createMockExec(); execStub .onSecondCall() .callsArgWith(1, null, '[{"change": "something"}]', ""); // non-empty array result - const fsStub = { - readdir: sinon.stub().returns(["api-v1"]), - stat: sinon.stub().returns({ isDirectory: () => true }), - }; - - const oasDiff = pq("./oasDiff", { - child_process: { - exec: execStub, - }, - "fs-extra": fsStub, - }); + const fsStub = createMockFs(); + const oasDiff = createOasDiffProxy(execStub, fsStub); // Arrange const baseApi = "base.yaml"; diff --git a/src/diff/oasDiff.ts b/src/diff/oasDiff.ts index 1e17d44..76c5e77 100644 --- a/src/diff/oasDiff.ts +++ b/src/diff/oasDiff.ts @@ -6,6 +6,94 @@ */ import fs from "fs-extra"; import { exec } from "child_process"; +import path from "path"; + +/** + * Recursively find directories containing exchange.json files + * + * @param rootPath - The root path to search from + * @returns Array of objects with directory name and full path + */ +async function findExchangeDirectories( + rootPath: string +): Promise> { + const result = []; + // assume the directory depth is 3, we don't want to go too deep + const maxDepth = 3; + let foundAnyExchangeJson = false; + + async function searchDirectory(currentPath: string, depth = 0) { + if (depth > maxDepth) { + throw new Error( + `Maximum directory depth (${maxDepth}) exceeded while searching for exchange.json files in: ${currentPath}` + ); + } + + try { + const entries = await fs.readdir(currentPath); + + // Check if current directory contains exchange.json + const hasExchangeJson = entries.includes("exchange.json"); + + if (hasExchangeJson) { + foundAnyExchangeJson = true; + const dirName = path.basename(currentPath); + result.push({ + name: dirName, + path: currentPath, + }); + return; + } + + // Check if this is a leaf directory (no subdirectories) + const subdirectories = []; + for (const entry of entries) { + const entryPath = path.join(currentPath, entry); + const stat = await fs.stat(entryPath); + if (stat.isDirectory()) { + subdirectories.push(entryPath); + } + } + console.log("subdirectories", subdirectories); + console.log("hasExchangeJson", hasExchangeJson); + // If this is a leaf directory and we haven't found exchange.json, that's an error + if (subdirectories.length === 0 && !hasExchangeJson) { + throw new Error( + `No exchange.json file found in leaf directory: ${currentPath}. Each API directory must contain an exchange.json file.` + ); + } + + // Search subdirectories + for (const subPath of subdirectories) { + await searchDirectory(subPath, depth + 1); + } + } catch (err) { + // Re-throw our custom errors + if ( + err.message.includes("exchange.json") || + err.message.includes("Maximum directory depth") + ) { + throw err; + } + // Ignore other errors for individual directories (permissions, etc.) + console.warn( + `Warning: Could not read directory ${currentPath}:`, + err.message + ); + } + } + + await searchDirectory(rootPath); + + // Final check: if we didn't find any exchange.json files at all + if (!foundAnyExchangeJson) { + throw new Error( + `No exchange.json files found in any directory under: ${rootPath}. Each API directory must contain an exchange.json file.` + ); + } + + return result; +} /** * If a file is given, saves the changes to the file, as JSON by default. @@ -86,97 +174,90 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { // Handle directory mode if (flags.dir) { - // Get the list of directories in both base and new API paths - const baseDirectories = await fs.readdir(baseApi); - const newDirectories = await fs.readdir(newApi); + // Find all exchange.json files and their parent directories + const baseExchangeDirs = await findExchangeDirectories(baseApi); + const newExchangeDirs = await findExchangeDirectories(newApi); - // Loop through base directories and find matching ones in new - for (const baseDir of baseDirectories) { - const baseDirPath = `${baseApi}/${baseDir}`; - const newDirPath = `${newApi}/${baseDir}`; + const allDirNames = new Set([ + ...baseExchangeDirs.map((dir) => dir.name), + ...newExchangeDirs.map((dir) => dir.name), + ]); - // Skip if not a directory - if (!(await fs.stat(baseDirPath)).isDirectory()) { - continue; - } + for (const dirName of allDirNames) { + const baseDir = baseExchangeDirs.find((dir) => dir.name === dirName); + const newDir = newExchangeDirs.find((dir) => dir.name === dirName); - // Check if matching directory doesn't exist in new - if (!newDirectories.includes(baseDir)) { - console.log(`${baseDir} API is deleted`); + // Check if directory was deleted + if (baseDir && !newDir) { + console.log(`${dirName} API is deleted`); if (flags.format === "json") { allResults.push({ - directory: baseDir, + directory: dirName, status: "deleted", - message: `${baseDir} API is deleted`, + message: `${dirName} API is deleted`, }); } else { - allResults.push(`======${baseDir} API is deleted======`); + allResults.push(`======${dirName} API is deleted======`); } hasChanges = true; continue; } - console.log(`Processing directory pair: ${baseDir}`); - - try { - const baseYamlPath = `${baseDirPath}/**/*.yaml`; - const newYamlPath = `${newDirPath}/**/*.yaml`; - - const oasdiffOutput = await executeOasDiff( - baseYamlPath, - newYamlPath, - jsonMode, - directoryMode - ); - - if (oasdiffOutput.trim().length > 0) { - console.log(`Changes found in ${baseDir}`); - if (flags.format === "json") { - const outputJson = JSON.parse(oasdiffOutput); - if (outputJson?.length > 0) { - allResults.push({ - directory: baseDir, - changes: outputJson, - }); - hasChanges = true; - } - } else { - // For text format, add section headers - const formattedOutput = `=== Changes in ${baseDir} ===\n${oasdiffOutput}`; - allResults.push(formattedOutput); - hasChanges = true; - } - } else { - console.log(`No changes found in ${baseDir}`); - } - } catch (err) { - console.error(`Error processing ${baseDir}:`, err); - hasErrors = true; - } - } - - // Check for newly added APIs (directories in new but not in base) - for (const newDir of newDirectories) { - const newDirPath = `${newApi}/${newDir}`; - - // Skip if not a directory - if (!(await fs.stat(newDirPath)).isDirectory()) { - continue; - } - - // Check if this directory doesn't exist in base (added API) - if (!baseDirectories.includes(newDir)) { - console.log(`${newDir} API is added`); + // Check if directory was added + if (!baseDir && newDir) { + console.log(`${dirName} API is added`); if (flags.format === "json") { allResults.push({ - directory: newDir, + directory: dirName, status: "added", - message: `${newDir} API is added`, + message: `${dirName} API is added`, }); } else { - allResults.push(`======${newDir} API is added======`); + allResults.push(`======${dirName} API is added======`); } hasChanges = true; + continue; + } + + // Both directories exist, compare them + if (baseDir && newDir) { + console.log(`Processing directory pair: ${dirName}`); + + try { + const baseYamlPath = `${baseDir.path}/**/*.yaml`; + const newYamlPath = `${newDir.path}/**/*.yaml`; + + const oasdiffOutput = await executeOasDiff( + baseYamlPath, + newYamlPath, + jsonMode, + directoryMode + ); + + if (oasdiffOutput.trim().length > 0) { + console.log(`Changes found in ${dirName}`); + if (flags.format === "json") { + const outputJson = JSON.parse(oasdiffOutput); + if (outputJson?.length > 0) { + allResults.push({ + directory: dirName, + changes: outputJson, + }); + hasChanges = true; + } + } else { + // For text format, add section headers + const formattedOutput = `=== Changes in ${dirName} ===\n${oasdiffOutput}`; + allResults.push(formattedOutput); + hasChanges = true; + } + } else { + console.log(`No changes found in ${dirName}`); + } + } catch (err) { + console.error(`Error processing ${dirName}:`, err); + hasErrors = true; + } } } } else { From 28d7ccf8190bd792a356877eb2e746df4af3b9f4 Mon Sep 17 00:00:00 2001 From: Alex Vuong Date: Wed, 18 Jun 2025 13:42:16 -0700 Subject: [PATCH 09/11] run diff based on yaml files --- src/diff/oasDiff.test.ts | 76 ++++++++++++---------- src/diff/oasDiff.ts | 136 ++++++++++++++++++++++++++++++++------- 2 files changed, 155 insertions(+), 57 deletions(-) diff --git a/src/diff/oasDiff.test.ts b/src/diff/oasDiff.test.ts index f40604b..01241a8 100644 --- a/src/diff/oasDiff.test.ts +++ b/src/diff/oasDiff.test.ts @@ -75,19 +75,26 @@ function setupDirectoryStructure( }, structure: Array<{ path: string; contents: string[] }> ): void { - let callIndex = 0; - for (const dir of structure) { - fsStub.readdir.onCall(callIndex++).returns(dir.contents); + fsStub.readdir.withArgs(dir.path).resolves(dir.contents); for (const item of dir.contents) { const itemPath = `${dir.path}/${item}`; - const isFile = item.endsWith(".json") || item.endsWith(".yaml"); - fsStub.stat.withArgs(itemPath).returns({ isDirectory: () => !isFile }); + const isFile = + item.endsWith(".json") || + item.endsWith(".yaml") || + item.endsWith(".yml"); + fsStub.stat.withArgs(itemPath).resolves({ + isDirectory: () => !isFile, + isFile: () => isFile, + }); } } - fsStub.stat.returns({ isDirectory: () => true }); + fsStub.stat.resolves({ + isDirectory: () => true, + isFile: () => false, + }); } function createOasDiffProxy( @@ -264,7 +271,9 @@ describe("oasDiffChangelog", () => { const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); expect(execStub.called).to.be.true; - expect(execStub.args[1][0]).to.include("base/api-v1/**/*.yaml"); + expect(execStub.args[1][0]).to.equal( + 'oasdiff changelog "base/api-v1/spec.yaml" "new/api-v1/spec.yaml"' + ); expect(result).to.equal(0); }); @@ -307,7 +316,7 @@ describe("oasDiffChangelog", () => { expect(execStub.called).to.be.true; expect(execStub.args[1][0]).to.equal( - 'oasdiff changelog --composed "base/api-v1/**/*.yaml" "new/api-v1/**/*.yaml"' + 'oasdiff changelog "base/api-v1/spec.yaml" "new/api-v1/spec.yaml"' ); }); @@ -319,11 +328,11 @@ describe("oasDiffChangelog", () => { const fsStub = createMockFs(); setupDirectoryStructure(fsStub, [ { path: "base", contents: ["api-v1", "api-v2"] }, - { path: "base/api-v1", contents: ["exchange.json"] }, - { path: "base/api-v2", contents: ["exchange.json"] }, + { path: "base/api-v1", contents: ["exchange.json", "spec.yaml"] }, + { path: "base/api-v2", contents: ["exchange.json", "spec.yaml"] }, { path: "new", contents: ["api-v1", "api-v2"] }, - { path: "new/api-v1", contents: ["exchange.json"] }, - { path: "new/api-v2", contents: ["exchange.json"] }, + { path: "new/api-v1", contents: ["exchange.json", "spec.yaml"] }, + { path: "new/api-v2", contents: ["exchange.json", "spec.yaml"] }, ]); const oasDiff = createOasDiffProxy(execStub, fsStub); @@ -357,11 +366,11 @@ describe("oasDiffChangelog", () => { const fsStub = createMockFs(); setupDirectoryStructure(fsStub, [ { path: "base", contents: ["api-v1", "api-v2"] }, - { path: "base/api-v1", contents: ["exchange.json"] }, - { path: "base/api-v2", contents: ["exchange.json"] }, + { path: "base/api-v1", contents: ["exchange.json", "spec.yaml"] }, + { path: "base/api-v2", contents: ["exchange.json", "spec.yaml"] }, { path: "new", contents: ["api-v1", "api-v2"] }, - { path: "new/api-v1", contents: ["exchange.json"] }, - { path: "new/api-v2", contents: ["exchange.json"] }, + { path: "new/api-v1", contents: ["exchange.json", "spec.yaml"] }, + { path: "new/api-v2", contents: ["exchange.json", "spec.yaml"] }, ]); const oasDiff = createOasDiffProxy(execStub, fsStub); @@ -452,11 +461,11 @@ describe("oasDiffChangelog", () => { const fsStub = createMockFs(); setupDirectoryStructure(fsStub, [ { path: "base", contents: ["api-v1", "api-v2"] }, - { path: "base/api-v1", contents: ["exchange.json"] }, - { path: "base/api-v2", contents: ["exchange.json"] }, + { path: "base/api-v1", contents: ["exchange.json", "spec.yaml"] }, + { path: "base/api-v2", contents: ["exchange.json", "spec.yaml"] }, { path: "new", contents: ["api-v2", "api-v3"] }, - { path: "new/api-v2", contents: ["exchange.json"] }, - { path: "new/api-v3", contents: ["exchange.json"] }, + { path: "new/api-v2", contents: ["exchange.json", "spec.yaml"] }, + { path: "new/api-v3", contents: ["exchange.json", "spec.yaml"] }, ]); const oasDiff = createOasDiffProxy(execStub, fsStub); @@ -485,13 +494,13 @@ describe("oasDiffChangelog", () => { const fsStub = createMockFs(); setupDirectoryStructure(fsStub, [ { path: "base", contents: ["common-api", "stable-api", "old-api"] }, - { path: "base/common-api", contents: ["exchange.json"] }, - { path: "base/stable-api", contents: ["exchange.json"] }, - { path: "base/old-api", contents: ["exchange.json"] }, + { path: "base/common-api", contents: ["exchange.json", "spec.yaml"] }, + { path: "base/stable-api", contents: ["exchange.json", "spec.yaml"] }, + { path: "base/old-api", contents: ["exchange.json", "spec.yaml"] }, { path: "new", contents: ["common-api", "stable-api", "new-api"] }, - { path: "new/common-api", contents: ["exchange.json"] }, - { path: "new/stable-api", contents: ["exchange.json"] }, - { path: "new/new-api", contents: ["exchange.json"] }, + { path: "new/common-api", contents: ["exchange.json", "spec.yaml"] }, + { path: "new/stable-api", contents: ["exchange.json", "spec.yaml"] }, + { path: "new/new-api", contents: ["exchange.json", "spec.yaml"] }, ]); const oasDiff = createOasDiffProxy(execStub, fsStub); @@ -599,16 +608,16 @@ describe("oasDiffChangelog", () => { execStub .onSecondCall() .callsArgWith(1, null, '[{"changes": "in api-v1"}]', ""); - execStub.onThirdCall().callsArgWith(1, null, "[]", ""); // empty array + execStub.onThirdCall().callsArgWith(1, null, "[]", ""); const fsStub = createMockFs(); setupDirectoryStructure(fsStub, [ { path: "base", contents: ["api-v1", "api-v2"] }, - { path: "base/api-v1", contents: ["exchange.json"] }, - { path: "base/api-v2", contents: ["exchange.json"] }, + { path: "base/api-v1", contents: ["exchange.json", "spec.yaml"] }, + { path: "base/api-v2", contents: ["exchange.json", "spec.yaml"] }, { path: "new", contents: ["api-v1", "api-v2"] }, - { path: "new/api-v1", contents: ["exchange.json"] }, - { path: "new/api-v2", contents: ["exchange.json"] }, + { path: "new/api-v1", contents: ["exchange.json", "spec.yaml"] }, + { path: "new/api-v2", contents: ["exchange.json", "spec.yaml"] }, ]); const oasDiff = createOasDiffProxy(execStub, fsStub); @@ -634,19 +643,18 @@ describe("oasDiffChangelog", () => { it("should not include empty results in single file JSON mode", async () => { const execStub = createMockExec(); - execStub.onSecondCall().callsArgWith(1, null, "[]", ""); // empty array result + execStub.onSecondCall().callsArgWith(1, null, "[]", ""); const fsStub = createMockFs(); const oasDiff = createOasDiffProxy(execStub, fsStub); - // Arrange const baseApi = "base.yaml"; const newApi = "new.yaml"; const flags = { format: "json" }; const result = await oasDiff.oasDiffChangelog(baseApi, newApi, flags); expect(execStub.called).to.be.true; - expect(result).to.equal(0); // No changes should be reported + expect(result).to.equal(0); }); it("should include non-empty results in single file JSON mode", async () => { diff --git a/src/diff/oasDiff.ts b/src/diff/oasDiff.ts index 76c5e77..aa4244d 100644 --- a/src/diff/oasDiff.ts +++ b/src/diff/oasDiff.ts @@ -54,8 +54,6 @@ async function findExchangeDirectories( subdirectories.push(entryPath); } } - console.log("subdirectories", subdirectories); - console.log("hasExchangeJson", hasExchangeJson); // If this is a leaf directory and we haven't found exchange.json, that's an error if (subdirectories.length === 0 && !hasExchangeJson) { throw new Error( @@ -95,6 +93,36 @@ async function findExchangeDirectories( return result; } +/** + * Find YAML files in a directory + * + * @param dirPath - The directory path to search + * @returns Array of YAML file paths + */ +async function findYamlFiles(dirPath: string): Promise { + try { + const entries = await fs.readdir(dirPath); + const yamlFiles = []; + + for (const entry of entries) { + const entryPath = path.join(dirPath, entry); + const stat = await fs.stat(entryPath); + + if ( + stat.isFile() && + (entry.endsWith(".yaml") || entry.endsWith(".yml")) + ) { + yamlFiles.push(entryPath); + } + } + + return yamlFiles; + } catch (err) { + console.warn(`Warning: Could not read directory ${dirPath}:`, err.message); + return []; + } +} + /** * If a file is given, saves the changes to the file, as JSON by default. * Otherwise, logs the changes to console, as text by default. @@ -166,7 +194,6 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { console.log("......Starting oasdiff......"); const jsonMode = flags.format === "json" ? "-f json" : ""; - const directoryMode = flags.dir ? "--composed" : ""; let hasChanges = false; let hasErrors = false; @@ -221,36 +248,99 @@ export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { // Both directories exist, compare them if (baseDir && newDir) { + console.log("==================================="); console.log(`Processing directory pair: ${dirName}`); try { - const baseYamlPath = `${baseDir.path}/**/*.yaml`; - const newYamlPath = `${newDir.path}/**/*.yaml`; + const baseYamlFiles = await findYamlFiles(baseDir.path); + const newYamlFiles = await findYamlFiles(newDir.path); - const oasdiffOutput = await executeOasDiff( - baseYamlPath, - newYamlPath, - jsonMode, - directoryMode - ); + const directoryChanges = []; + const directoryChangesText = []; - if (oasdiffOutput.trim().length > 0) { - console.log(`Changes found in ${dirName}`); - if (flags.format === "json") { - const outputJson = JSON.parse(oasdiffOutput); - if (outputJson?.length > 0) { - allResults.push({ - directory: dirName, - changes: outputJson, + // Process each YAML file pair + for (const baseYamlFile of baseYamlFiles) { + const baseFileName = path.basename(baseYamlFile); + const newYamlFile = newYamlFiles.find( + (f) => path.basename(f) === baseFileName + ); + + if (newYamlFile) { + console.log(`Comparing ${baseFileName} in ${dirName}`); + const oasdiffOutput = await executeOasDiff( + baseYamlFile, + newYamlFile, + jsonMode + ); + + if (oasdiffOutput.trim().length > 0) { + if (flags.format === "json") { + const outputJson = JSON.parse(oasdiffOutput); + if (outputJson?.length > 0) { + directoryChanges.push(...outputJson); + } + } else { + directoryChangesText.push( + `--- Changes in ${baseFileName} ---\n${oasdiffOutput}` + ); + } + } + } else { + console.log(`File ${baseFileName} was deleted in ${dirName}`); + if (flags.format === "json") { + directoryChanges.push({ + file: baseFileName, + status: "deleted", + message: `File ${baseFileName} was deleted`, + }); + } else { + directoryChangesText.push( + `--- File ${baseFileName} was deleted ---` + ); + } + } + } + + // Check for added files + for (const newYamlFile of newYamlFiles) { + const newFileName = path.basename(newYamlFile); + const baseYamlFile = baseYamlFiles.find( + (f) => path.basename(f) === newFileName + ); + + if (!baseYamlFile) { + console.log(`File ${newFileName} was added in ${dirName}`); + if (flags.format === "json") { + directoryChanges.push({ + file: newFileName, + status: "added", + message: `File ${newFileName} was added`, }); - hasChanges = true; + } else { + directoryChangesText.push( + `--- File ${newFileName} was added ---` + ); } + } + } + + if ( + directoryChanges.length > 0 || + directoryChangesText.length > 0 + ) { + console.log(`Changes found in ${dirName}`); + if (flags.format === "json") { + allResults.push({ + directory: dirName, + changes: directoryChanges, + }); } else { - // For text format, add section headers - const formattedOutput = `=== Changes in ${dirName} ===\n${oasdiffOutput}`; + const formattedOutput = `=== Changes in ${dirName} ===\n${directoryChangesText.join( + "\n" + )}`; allResults.push(formattedOutput); - hasChanges = true; } + hasChanges = true; } else { console.log(`No changes found in ${dirName}`); } From 5ec0f6d804292b2985be3077a52c8b2191752780 Mon Sep 17 00:00:00 2001 From: Alex Vuong Date: Wed, 18 Jun 2025 15:56:42 -0700 Subject: [PATCH 10/11] PR feedback --- src/diff/oasDiff.ts | 447 +++++++++++++++++++++++++++----------------- 1 file changed, 276 insertions(+), 171 deletions(-) diff --git a/src/diff/oasDiff.ts b/src/diff/oasDiff.ts index aa4244d..0034940 100644 --- a/src/diff/oasDiff.ts +++ b/src/diff/oasDiff.ts @@ -4,10 +4,69 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +/* eslint header/header: "off", max-lines:"off" */ + import fs from "fs-extra"; import { exec } from "child_process"; import path from "path"; +/** + * Interface for flags used in oasDiff functions + */ +interface OasDiffFlags { + format?: "json" | "console"; + dir?: boolean; + "out-file"?: string; +} + +/** + * Represents a directory-level API change (added/deleted) + */ +interface DirectoryStatusChange { + directory: string; + status: "added" | "deleted"; + message: string; +} + +/** + * Represents file-level changes within a directory + */ +interface FileChange { + file: string; + status: "added" | "deleted"; + message: string; +} + +/** + * Represents changes found in a directory with detailed change information + */ +interface DirectoryChanges { + directory: string; + changes: (FileChange | Record)[]; +} + +/** + * Union type for all possible result items + * - string: Text format output + * - DirectoryStatusChange: JSON format for added/deleted directories + * - DirectoryChanges: JSON format for directories with file changes + * - unknown[]: Parsed JSON output from oasdiff (structure depends on oasdiff output) + */ +type OasDiffResult = + | string + | DirectoryStatusChange + | DirectoryChanges + | unknown[]; + +/** + * Return type for oasDiff handler functions + */ +interface OasDiffHandlerResult { + results: OasDiffResult[]; + hasChanges: boolean; + hasErrors: boolean; +} + /** * Recursively find directories containing exchange.json files * @@ -130,7 +189,10 @@ async function findYamlFiles(dirPath: string): Promise { * @param changes - The changes to save or log * @param flags - Parsed CLI flags passed to the command */ -async function _saveOrLogOas(changes: string, flags): Promise { +async function _saveOrLogOas( + changes: string, + flags: OasDiffFlags +): Promise { const file = flags["out-file"]; if (file) { console.log(`API Changes found! Saving results to file ${file}:`); @@ -181,206 +243,249 @@ async function executeOasDiff( } /** - * Wrapper for oasdiff changelog command. + * Handle directory mode comparison logic * - * @param baseApi - The base API file or directory - * @param newApi - The new API file or directory - * @param flags - Parsed CLI flags passed to the command - * @returns 0 if no changes are reported, 1 if changes are reported, and 2 if an error occurs + * @param baseApi - The base API directory + * @param newApi - The new API directory + * @param jsonMode - JSON formatting flag + * @param flags - CLI flags + * @returns Object containing results, hasChanges flag, and hasErrors flag */ -export async function oasDiffChangelog(baseApi: string, newApi: string, flags) { - try { - await checkOasDiffIsInstalled(); - console.log("......Starting oasdiff......"); - - const jsonMode = flags.format === "json" ? "-f json" : ""; - - let hasChanges = false; - let hasErrors = false; - const allResults = []; - - // Handle directory mode - if (flags.dir) { - // Find all exchange.json files and their parent directories - const baseExchangeDirs = await findExchangeDirectories(baseApi); - const newExchangeDirs = await findExchangeDirectories(newApi); - - const allDirNames = new Set([ - ...baseExchangeDirs.map((dir) => dir.name), - ...newExchangeDirs.map((dir) => dir.name), - ]); - - for (const dirName of allDirNames) { - const baseDir = baseExchangeDirs.find((dir) => dir.name === dirName); - const newDir = newExchangeDirs.find((dir) => dir.name === dirName); - - // Check if directory was deleted - if (baseDir && !newDir) { - console.log(`${dirName} API is deleted`); - if (flags.format === "json") { - allResults.push({ - directory: dirName, - status: "deleted", - message: `${dirName} API is deleted`, - }); - } else { - allResults.push(`======${dirName} API is deleted======`); - } - hasChanges = true; - continue; - } - - // Check if directory was added - if (!baseDir && newDir) { - console.log(`${dirName} API is added`); - if (flags.format === "json") { - allResults.push({ - directory: dirName, - status: "added", - message: `${dirName} API is added`, - }); - } else { - allResults.push(`======${dirName} API is added======`); - } - hasChanges = true; - continue; - } - - // Both directories exist, compare them - if (baseDir && newDir) { - console.log("==================================="); - console.log(`Processing directory pair: ${dirName}`); - - try { - const baseYamlFiles = await findYamlFiles(baseDir.path); - const newYamlFiles = await findYamlFiles(newDir.path); - - const directoryChanges = []; - const directoryChangesText = []; +async function handleDirectoryMode( + baseApi: string, + newApi: string, + jsonMode: string, + flags: OasDiffFlags +): Promise { + const allResults: OasDiffResult[] = []; + let hasChanges = false; + let hasErrors = false; + + // Find all exchange.json files and their parent directories + const baseExchangeDirs = await findExchangeDirectories(baseApi); + const newExchangeDirs = await findExchangeDirectories(newApi); + + const allDirNames = new Set([ + ...baseExchangeDirs.map((dir) => dir.name), + ...newExchangeDirs.map((dir) => dir.name), + ]); + + for (const dirName of allDirNames) { + const baseDir = baseExchangeDirs.find((dir) => dir.name === dirName); + const newDir = newExchangeDirs.find((dir) => dir.name === dirName); + + // Check if directory was deleted + if (baseDir && !newDir) { + console.log(`${dirName} API is deleted`); + if (flags.format === "json") { + allResults.push({ + directory: dirName, + status: "deleted", + message: `${dirName} API is deleted`, + }); + } else { + allResults.push(`======${dirName} API is deleted======`); + } + hasChanges = true; + continue; + } - // Process each YAML file pair - for (const baseYamlFile of baseYamlFiles) { - const baseFileName = path.basename(baseYamlFile); - const newYamlFile = newYamlFiles.find( - (f) => path.basename(f) === baseFileName - ); + // Check if directory was added + if (!baseDir && newDir) { + console.log(`${dirName} API is added`); + if (flags.format === "json") { + allResults.push({ + directory: dirName, + status: "added", + message: `${dirName} API is added`, + }); + } else { + allResults.push(`======${dirName} API is added======`); + } + hasChanges = true; + continue; + } - if (newYamlFile) { - console.log(`Comparing ${baseFileName} in ${dirName}`); - const oasdiffOutput = await executeOasDiff( - baseYamlFile, - newYamlFile, - jsonMode - ); + // Both directories exist, compare individual yml files in each directory + if (baseDir && newDir) { + console.log("==================================="); + console.log(`Processing directory pair: ${dirName}`); - if (oasdiffOutput.trim().length > 0) { - if (flags.format === "json") { - const outputJson = JSON.parse(oasdiffOutput); - if (outputJson?.length > 0) { - directoryChanges.push(...outputJson); - } - } else { - directoryChangesText.push( - `--- Changes in ${baseFileName} ---\n${oasdiffOutput}` - ); - } + try { + const baseYamlFiles = await findYamlFiles(baseDir.path); + const newYamlFiles = await findYamlFiles(newDir.path); + + const directoryChanges: (FileChange | Record)[] = []; + const directoryChangesText: string[] = []; + + // Process each YAML file pair + for (const baseYamlFile of baseYamlFiles) { + const baseFileName = path.basename(baseYamlFile); + const newYamlFile = newYamlFiles.find( + (f) => path.basename(f) === baseFileName + ); + + if (newYamlFile) { + console.log(`Comparing ${baseFileName} in ${dirName}`); + const oasdiffOutput = await executeOasDiff( + baseYamlFile, + newYamlFile, + jsonMode + ); + + if (oasdiffOutput.trim().length > 0) { + if (flags.format === "json") { + const outputJson = JSON.parse(oasdiffOutput); + if (outputJson?.length > 0) { + directoryChanges.push(...outputJson); } } else { - console.log(`File ${baseFileName} was deleted in ${dirName}`); - if (flags.format === "json") { - directoryChanges.push({ - file: baseFileName, - status: "deleted", - message: `File ${baseFileName} was deleted`, - }); - } else { - directoryChangesText.push( - `--- File ${baseFileName} was deleted ---` - ); - } + directoryChangesText.push( + `--- Changes in ${baseFileName} ---\n${oasdiffOutput}` + ); } } - - // Check for added files - for (const newYamlFile of newYamlFiles) { - const newFileName = path.basename(newYamlFile); - const baseYamlFile = baseYamlFiles.find( - (f) => path.basename(f) === newFileName + } else { + console.log(`File ${baseFileName} was deleted in ${dirName}`); + if (flags.format === "json") { + directoryChanges.push({ + file: baseFileName, + status: "deleted", + message: `File ${baseFileName} was deleted`, + }); + } else { + directoryChangesText.push( + `--- File ${baseFileName} was deleted ---` ); - - if (!baseYamlFile) { - console.log(`File ${newFileName} was added in ${dirName}`); - if (flags.format === "json") { - directoryChanges.push({ - file: newFileName, - status: "added", - message: `File ${newFileName} was added`, - }); - } else { - directoryChangesText.push( - `--- File ${newFileName} was added ---` - ); - } - } } + } + } - if ( - directoryChanges.length > 0 || - directoryChangesText.length > 0 - ) { - console.log(`Changes found in ${dirName}`); - if (flags.format === "json") { - allResults.push({ - directory: dirName, - changes: directoryChanges, - }); - } else { - const formattedOutput = `=== Changes in ${dirName} ===\n${directoryChangesText.join( - "\n" - )}`; - allResults.push(formattedOutput); - } - hasChanges = true; + // Check for added files + for (const newYamlFile of newYamlFiles) { + const newFileName = path.basename(newYamlFile); + const baseYamlFile = baseYamlFiles.find( + (f) => path.basename(f) === newFileName + ); + + if (!baseYamlFile) { + console.log(`File ${newFileName} was added in ${dirName}`); + if (flags.format === "json") { + directoryChanges.push({ + file: newFileName, + status: "added", + message: `File ${newFileName} was added`, + }); } else { - console.log(`No changes found in ${dirName}`); + directoryChangesText.push( + `--- File ${newFileName} was added ---` + ); } - } catch (err) { - console.error(`Error processing ${dirName}:`, err); - hasErrors = true; } } - } - } else { - try { - const oasdiffOutput = await executeOasDiff(baseApi, newApi, jsonMode); - if (oasdiffOutput.trim().length > 0) { - console.log("Changes found"); + if (directoryChanges.length > 0 || directoryChangesText.length > 0) { + console.log(`Changes found in ${dirName}`); if (flags.format === "json") { - // For JSON format, parse the output - const outputJson = JSON.parse(oasdiffOutput); - if (outputJson?.length > 0) { - allResults.push(outputJson); - hasChanges = true; - } + allResults.push({ + directory: dirName, + changes: directoryChanges, + }); } else { - allResults.push(oasdiffOutput); - hasChanges = true; + const formattedOutput = `=== Changes in ${dirName} ===\n${directoryChangesText.join( + "\n" + )}`; + allResults.push(formattedOutput); } + hasChanges = true; } else { - console.log("No changes found"); + console.log(`No changes found in ${dirName}`); } } catch (err) { - console.error("Error processing files:", err); + console.error(`Error processing ${dirName}:`, err); hasErrors = true; } } + } + + return { results: allResults, hasChanges, hasErrors }; +} + +/** + * Handle single file mode comparison logic + * + * @param baseApi - The base API file + * @param newApi - The new API file + * @param jsonMode - JSON formatting flag + * @param flags - CLI flags + * @returns Object containing results, hasChanges flag, and hasErrors flag + */ +async function handleSingleFileMode( + baseApi: string, + newApi: string, + jsonMode: string, + flags: OasDiffFlags +): Promise { + const allResults: OasDiffResult[] = []; + let hasChanges = false; + let hasErrors = false; + + try { + const oasdiffOutput = await executeOasDiff(baseApi, newApi, jsonMode); + + if (oasdiffOutput.trim().length > 0) { + console.log("Changes found"); + if (flags.format === "json") { + // For JSON format, parse the output + const outputJson = JSON.parse(oasdiffOutput); + if (outputJson?.length > 0) { + allResults.push(outputJson); + hasChanges = true; + } + } else { + allResults.push(oasdiffOutput); + hasChanges = true; + } + } else { + console.log("No changes found"); + } + } catch (err) { + console.error("Error processing files:", err); + hasErrors = true; + } + + return { results: allResults, hasChanges, hasErrors }; +} + +/** + * Wrapper for oasdiff changelog command. + * + * @param baseApi - The base API file or directory + * @param newApi - The new API file or directory + * @param flags - Parsed CLI flags passed to the command + * @returns 0 if no changes are reported, 1 if changes are reported, and 2 if an error occurs + */ +export async function oasDiffChangelog( + baseApi: string, + newApi: string, + flags: OasDiffFlags +) { + try { + await checkOasDiffIsInstalled(); + console.log("......Starting oasdiff......"); + + const jsonMode = flags.format === "json" ? "-f json" : ""; + + // Handle directory mode or single file mode + const { results, hasChanges, hasErrors } = flags.dir + ? await handleDirectoryMode(baseApi, newApi, jsonMode, flags) + : await handleSingleFileMode(baseApi, newApi, jsonMode, flags); if (hasChanges) { if (flags.format === "json") { - await _saveOrLogOas(JSON.stringify(allResults, null, 2), flags); + await _saveOrLogOas(JSON.stringify(results, null, 2), flags); } else { - await _saveOrLogOas(allResults.join("\n"), flags); + await _saveOrLogOas(results.join("\n"), flags); } } From 3f50f8144661f555fc0f6f1d6d222adeec458872 Mon Sep 17 00:00:00 2001 From: Alex Vuong Date: Thu, 19 Jun 2025 11:39:03 -0700 Subject: [PATCH 11/11] PR feedback --- src/diff/oasDiff.test.ts | 7 +++++-- src/diff/oasDiff.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/diff/oasDiff.test.ts b/src/diff/oasDiff.test.ts index 01241a8..23902a2 100644 --- a/src/diff/oasDiff.test.ts +++ b/src/diff/oasDiff.test.ts @@ -123,6 +123,7 @@ describe("oasDiffChangelog", () => { consoleErrorSpy.restore(); }); it("should return error code 2 when no exchange.json files are found", async () => { + const consoleWarnSpy = sinon.spy(console, "warn"); const execStub = createMockExec(); const fsStub = createMockFs(); @@ -142,8 +143,10 @@ describe("oasDiffChangelog", () => { expect(result).to.equal(2); expect(consoleErrorSpy.called).to.be.true; expect(consoleErrorSpy.args[0][0].message).to.include( - "No exchange.json file found in leaf directory: base/api-v1" + "No exchange.json files found in any directory under: base" ); + + consoleWarnSpy.restore(); }); it("should return error code 2 when no exchange.json files are found in entire directory tree", async () => { @@ -163,7 +166,7 @@ describe("oasDiffChangelog", () => { expect(result).to.equal(2); expect(consoleErrorSpy.called).to.be.true; expect(consoleErrorSpy.args[0][0].message).to.include( - "No exchange.json file found in leaf directory: base. Each API directory must contain an exchange.json file." + "No exchange.json files found in any directory under: base. Each API directory must contain an exchange.json file." ); }); diff --git a/src/diff/oasDiff.ts b/src/diff/oasDiff.ts index 0034940..19c0976 100644 --- a/src/diff/oasDiff.ts +++ b/src/diff/oasDiff.ts @@ -115,7 +115,7 @@ async function findExchangeDirectories( } // If this is a leaf directory and we haven't found exchange.json, that's an error if (subdirectories.length === 0 && !hasExchangeJson) { - throw new Error( + console.warn( `No exchange.json file found in leaf directory: ${currentPath}. Each API directory must contain an exchange.json file.` ); }