diff --git a/packages/generate-license-file/src/lib/internal/resolveLicenseContent/licenseFile.ts b/packages/generate-license-file/src/lib/internal/resolveLicenseContent/licenseFile.ts index fc817886..136469cb 100644 --- a/packages/generate-license-file/src/lib/internal/resolveLicenseContent/licenseFile.ts +++ b/packages/generate-license-file/src/lib/internal/resolveLicenseContent/licenseFile.ts @@ -23,7 +23,20 @@ export const licenseFile: Resolution = async inputs => { maxDepth: 1, }); - const filteredLicenseFiles = licenseFiles.filter(file => !extensionDenyList.includes(extname(file))); + // glob({ nocase: true }) returns matches using the pattern's case on + // case-insensitive filesystems (e.g. "license" on macOS) but the on-disk + // case elsewhere (e.g. "LICENSE" on Linux), and in filesystem order either + // way. Sort case-insensitively (locale-independently, for determinism) so + // the chosen file and the warning are stable across platforms; the base + // name (e.g. "license") then sorts ahead of decorated variants + // (e.g. "license-3rdparty.csv"), which is usually the real license. + const filteredLicenseFiles = licenseFiles + .filter(file => !extensionDenyList.includes(extname(file))) + .sort((a, b) => { + const lowerA = a.toLowerCase(); + const lowerB = b.toLowerCase(); + return lowerA < lowerB ? -1 : lowerA > lowerB ? 1 : 0; + }); if (filteredLicenseFiles.length === 0) { return null; diff --git a/packages/generate-license-file/test/internal/resolveLicenseContent/licenseFile.spec.ts b/packages/generate-license-file/test/internal/resolveLicenseContent/licenseFile.spec.ts index aedd2f6e..c4f8c86c 100644 --- a/packages/generate-license-file/test/internal/resolveLicenseContent/licenseFile.spec.ts +++ b/packages/generate-license-file/test/internal/resolveLicenseContent/licenseFile.spec.ts @@ -51,6 +51,32 @@ describe("licenseFile", () => { expect(result).toBe("license contents"); }); + it("should pick the base license file over decorated variants regardless of glob's order or casing", async () => { + // glob({ nocase: true }) returns matches using the pattern's case on + // case-insensitive filesystems (macOS yields "license") but the on-disk + // case elsewhere (Linux yields "LICENSE"), in filesystem order either way. + // The real license must win over a decorated variant like + // "LICENSE-3rdparty.csv" on both, so the output is identical cross-platform. + const upperLicense = "/some/directory/LICENSE"; + const lowerLicense = "/some/directory/license"; + const thirdParty = "/some/directory/LICENSE-3rdparty.csv"; + + when(mockedReadFile).calledWith(upperLicense, { encoding: "utf-8" }).thenResolve("license contents"); + when(mockedReadFile).calledWith(lowerLicense, { encoding: "utf-8" }).thenResolve("license contents"); + when(mockedReadFile).calledWith(thirdParty, { encoding: "utf-8" }).thenResolve("third party contents"); + + // Case-sensitive filesystem (e.g. Linux): glob yields the on-disk "LICENSE" + mockedGlob.mockResolvedValueOnce([thirdParty, upperLicense]); + const caseSensitive = await licenseFile(resolutionInputs); + + // Case-insensitive filesystem (e.g. macOS): glob yields the pattern's "license" + mockedGlob.mockResolvedValueOnce([thirdParty, lowerLicense]); + const caseInsensitive = await licenseFile(resolutionInputs); + + expect(caseSensitive).toBe("license contents"); + expect(caseInsensitive).toBe("license contents"); + }); + it.each( extensionDenyList, )("should return null if all license files are in the extension deny list", async extension => {