From 4729eb1b8962a677bcdebf7c8c8f23959111c834 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 15 Sep 2026 16:48:50 -0600 Subject: [PATCH] fix(parser): recognize breaking changes in standard commit layouts --- .github/workflows/tests.yml | 16 +++++++ .gitignore | 2 + box.json | 12 ++++- .../plugins/ConventionalChangelogParser.cfc | 47 ++++++++++++------- server.json | 14 ++++++ tests/Application.cfc | 6 +++ tests/runner.cfm | 5 ++ tests/specs/CommitParsingSpec.cfc | 42 +++++++++++++++++ 8 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 server.json create mode 100644 tests/Application.cfc create mode 100644 tests/runner.cfm create mode 100644 tests/specs/CommitParsingSpec.cfc diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0154618 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,16 @@ +name: Tests +on: + push: + branches: [master] + pull_request: + branches: [master] +jobs: + parser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Ortus-Solutions/setup-commandbox@v2.0.1 + - run: box install + - run: | + box server start --noBrowser + box testbox run diff --git a/.gitignore b/.gitignore index f5a6d64..cd11948 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /modules/ jmimemagic.log +/testbox/ +/.engine/ diff --git a/box.json b/box.json index 8fd6b07..313bb77 100644 --- a/box.json +++ b/box.json @@ -26,7 +26,8 @@ "ignore":[ "**/.*", "test", - "tests" + "tests", + "server.json" ], "dependencies":{ "semver":"^1.1.3", @@ -34,6 +35,13 @@ }, "installPaths":{ "semver":"modules/semver/", - "hyper":"modules/hyper/" + "hyper":"modules/hyper/", + "testbox":"testbox/" + }, + "devDependencies":{ + "testbox":"^7.0.0+19" + }, + "testbox":{ + "runner":"http://127.0.0.1:60359/tests/runner.cfm" } } diff --git a/models/plugins/ConventionalChangelogParser.cfc b/models/plugins/ConventionalChangelogParser.cfc index 4bf8669..1b442c4 100644 --- a/models/plugins/ConventionalChangelogParser.cfc +++ b/models/plugins/ConventionalChangelogParser.cfc @@ -35,22 +35,7 @@ component implements="interfaces.CommitParser" { boolean dryRun = false, boolean verbose = false ) { - var ccCommit = {}; - var parts = arraySlice( replace( commit.getFullMessage(), chr( 13 ), "", "all" ).split("\n{2,}"), 1 ); - var topParts = reFindNoCase( "^(\w+)\(([^)]+)\)\:\s(.+)$", parts[ 1 ], 1, true ); - - ccCommit[ "type" ] = topParts.pos.len() >= 2 ? - mid( parts[ 1 ], topParts.pos[ 2 ], topParts.len[ 2 ] ) : - "other"; - ccCommit[ "scope" ] = topParts.pos.len() >= 3 ? - mid( parts[ 1 ], topParts.pos[ 3 ], topParts.len[ 3 ] ) : - "*"; - ccCommit[ "subject" ] = topParts.pos.len() >= 4 ? - mid( parts[ 1 ], topParts.pos[ 4 ], topParts.len[ 4 ] ) : - ""; - ccCommit[ "body" ] = topParts.pos.len() == 1 ? parts[ 1 ] : parts[ 2 ] ?: ""; - ccCommit[ "footer" ] = parts[ 3 ] ?: ""; - ccCommit[ "isBreakingChange" ] = find( "BREAKING CHANGE:", ccCommit.footer ) > 0; + var ccCommit = parseMessage( commit.getFullMessage() ); ccCommit[ "hash" ] = commit.getId().getName(); ccCommit[ "shortHash" ] = objectReader.abbreviate( commit.getId() ).name(); @@ -61,6 +46,36 @@ component implements="interfaces.CommitParser" { return ccCommit; } + /** Extract conventional header, body, and breaking footer from any standard paragraph layout. */ + public struct function parseMessage( required string message ) { + var ccCommit = {}; + var parts = listToArray( reReplace( replace( message, chr( 13 ), "", "all" ), "\n{2,}", chr( 1 ), "all" ), chr( 1 ), true ); + var header = parts[ 1 ]; + var topParts = reFindNoCase( "^(\w+)(?:\(([^)]+)\))?(!)?\:\s(.+)$", header, 1, true ); + + ccCommit.type = arrayLen( topParts.pos ) >= 2 ? lCase( mid( header, topParts.pos[ 2 ], topParts.len[ 2 ] ) ) : "other"; + ccCommit.scope = arrayLen( topParts.pos ) >= 3 && topParts.len[ 3 ] > 0 ? + mid( header, topParts.pos[ 3 ], topParts.len[ 3 ] ) : "*"; + ccCommit.subject = arrayLen( topParts.pos ) >= 5 ? mid( header, topParts.pos[ 5 ], topParts.len[ 5 ] ) : ""; + ccCommit.body = ""; + ccCommit.footer = ""; + ccCommit.isBreakingChange = arrayLen( topParts.pos ) >= 4 && topParts.len[ 4 ] > 0; + + var i = 0; + for ( var paragraph in parts ) { + i++; + if ( i == 1 ) continue; + if ( reFindNoCase( "(?m)^BREAKING(?: CHANGE|-CHANGE):\s*\S", paragraph ) ) { + ccCommit.footer = arrayToList( arraySlice( parts, i ), chr( 10 ) & chr( 10 ) ); + ccCommit.isBreakingChange = true; + break; + } + ccCommit.body &= ( len( ccCommit.body ) ? chr( 10 ) & chr( 10 ) : "" ) & paragraph; + } + + return ccCommit; + } + /** * Print a parsed commit in a nice format to the console. * diff --git a/server.json b/server.json new file mode 100644 index 0000000..004d943 --- /dev/null +++ b/server.json @@ -0,0 +1,14 @@ +{ + "name":"semantic-release-breaking-tests", + "app":{ + "cfengine":"lucee@6", + "serverHomeDirectory":".engine/breaking-tests" + }, + "web":{ + "http":{ + "port":60359 + } + }, + "openBrowser":false, + "noBrowser":true +} diff --git a/tests/Application.cfc b/tests/Application.cfc new file mode 100644 index 0000000..de36348 --- /dev/null +++ b/tests/Application.cfc @@ -0,0 +1,6 @@ +component { + this.name = "semantic-release-tests"; + this.mappings[ "/semanticRelease" ] = getDirectoryFromPath( getCurrentTemplatePath() ) & "../"; + this.mappings[ "/testbox" ] = this.mappings[ "/semanticRelease" ] & "testbox"; + this.mappings[ "/semver" ] = this.mappings[ "/semanticRelease" ] & "modules/semver"; +} diff --git a/tests/runner.cfm b/tests/runner.cfm new file mode 100644 index 0000000..af3f7a6 --- /dev/null +++ b/tests/runner.cfm @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/specs/CommitParsingSpec.cfc b/tests/specs/CommitParsingSpec.cfc new file mode 100644 index 0000000..d1e4fb8 --- /dev/null +++ b/tests/specs/CommitParsingSpec.cfc @@ -0,0 +1,42 @@ +component extends="testbox.system.BaseSpec" { + function run() { + describe( "breaking release classification", function() { + beforeEach( function() { + variables.parser = new semanticRelease.models.plugins.ConventionalChangelogParser(); + variables.analyzer = new semanticRelease.models.plugins.DefaultCommitAnalyzer(); + } ); + + it( "recognizes qb's breaking commits with a footer directly after the subject", function() { + var messages = [ + "feat(QueryBuilder): match null upsert targets (issue 324)" & chr( 10 ) & chr( 10 ) & + "BREAKING CHANGE: Custom grammars overriding compileUpsert must accept matchNulls.", + "feat(QueryBuilder): add named return formatters (issue 315)" & chr( 10 ) & chr( 10 ) & + "BREAKING CHANGE: Native queryExecute returntype options are no longer honored." + ]; + var commits = messages.map( function( message ) { return parser.parseMessage( message ); } ); + expect( commits[ 1 ].scope ).toBe( "QueryBuilder" ); + expect( commits[ 1 ].subject ).toBe( "match null upsert targets (issue 324)" ); + expect( commits[ 1 ].footer ).toInclude( "BREAKING CHANGE:" ); + expect( analyzer.run( commits ) ).toBe( "major" ); + } ); + + it( "finds a footer after multiple body paragraphs", function() { + var commit = parser.parseMessage( "fix(qb): change behavior" & chr( 10 ) & chr( 10 ) & + "First explanation." & chr( 10 ) & chr( 10 ) & "Second explanation." & chr( 10 ) & chr( 10 ) & + "BREAKING CHANGE: Consumers must adapt." ); + expect( commit.body ).toInclude( "Second explanation." ); + expect( commit.footer ).toInclude( "Consumers must adapt." ); + expect( analyzer.run( [ commit ] ) ).toBe( "major" ); + } ); + + it( "recognizes conventional exclamation headers but ignores a body mention", function() { + expect( analyzer.run( [ parser.parseMessage( "fix!: change behavior" ) ] ) ).toBe( "major" ); + expect( analyzer.run( [ parser.parseMessage( "feat(qb)!: change behavior" ) ] ) ).toBe( "major" ); + var commit = parser.parseMessage( "fix(qb): ordinary fix" & chr( 10 ) & chr( 10 ) & + "The docs mention BREAKING CHANGE: as an example." ); + expect( commit.isBreakingChange ).toBeFalse(); + expect( analyzer.run( [ commit ] ) ).toBe( "patch" ); + } ); + } ); + } +}