Fix: five hands-on findings from 1.5.0 release testing#418
Conversation
- work commit: -m messages already carrying a conventional-commit prefix (type:, type(scope):, breaking ! variants; case-insensitive) are committed verbatim instead of double-prefixing (feat: feat: ...) - changelog/release: prefix classification is now case-insensitive and maps the CorvidLabs commit style (Add: -> Features, Update: -> Changes, Remove: -> Removals, Fix:/Refactor:/... -> their lowercase categories) instead of dumping those commits in "Other"; breaking ! markers classify by base type, matching the documented invariant - run --init: generic starter template no longer emits an unclosed quote in the commented `# lint = "echo 'add your linter'"` example - lanes init: follow-up hint points at `fledge lanes list` instead of `fledge lane`, which exits with a usage error - run --help / README / spec: pass-through example uses `-- --release`, which is valid when appended to `cargo test` (`--nocapture` is only accepted after cargo's own `--` separator) Specs bumped (work v15, changelog v5, release v6, run v6, lanes v25, main v11) and CHANGELOG.md updated under Unreleased. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements case-insensitive conventional commit prefix matching, supports CorvidLabs-style prefixes, and handles breaking change markers across fledge work commit, fledge changelog, and fledge release. It also fixes an unclosed quote in the run --init template, corrects the CLI hint in lanes init, and updates cargo pass-through examples. Feedback focuses on improving the robustness of conventional commit parsing, specifically by aligning the parsing logic between classify_for_changelog and classify_commit, ensuring has_conventional_prefix does not match malformed headers with multiple parentheses, and verifying that scopes are properly closed in strip_conventional_prefix.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| for (prefix, label) in &prefixes { | ||
| if msg.starts_with(prefix) && msg[prefix.len()..].starts_with([':', '(']) { | ||
| return label; | ||
| let Some(head) = msg.get(..prefix.len()) else { | ||
| continue; | ||
| }; | ||
| if head.eq_ignore_ascii_case(prefix) { | ||
| // Optional breaking-change marker: `type!:`. | ||
| let rest = &msg[prefix.len()..]; | ||
| let rest = rest.strip_prefix('!').unwrap_or(rest); | ||
| if rest.starts_with([':', '(']) { | ||
| return label; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
There is a significant discrepancy between classify_for_changelog in src/release/changelog.rs and classify_commit in src/changelog.rs. The current implementation of classify_for_changelog uses a loose check rest.starts_with([':', '(']) which incorrectly classifies invalid scoped formats (e.g., feat(scope)invalid: message) as valid conventional commits. In contrast, classify_commit robustly parses the scope and ensures that the closing parenthesis is followed by : or !:. We should align classify_for_changelog with the robust parsing logic of classify_commit to prevent inconsistent changelog generation between fledge changelog and fledge release.
for (prefix, label) in &prefixes {
let Some(head) = msg.get(..prefix.len()) else {
continue;
};
if head.eq_ignore_ascii_case(prefix) {
let rest = &msg[prefix.len()...];
// Optional breaking-change marker: `type!:`.
let bare = rest.strip_prefix('!').unwrap_or(rest);
if bare.starts_with(':') {
return label;
}
// Scoped form: `type(scope):` or `type(scope)!:`.
if let Some(scoped) = rest.strip_prefix('(') {
if let Some(close) = scoped.find(')') {
let tail = &scoped[close + 1..];
let tail = tail.strip_prefix('!').unwrap_or(tail);
if tail.starts_with(':') {
return label;
}
}
}
}
}| fn has_conventional_prefix(message: &str) -> bool { | ||
| let Some(colon) = message.find(':') else { | ||
| return false; | ||
| }; | ||
| let head = &message[..colon]; | ||
| let head = head.strip_suffix('!').unwrap_or(head); | ||
| let base = match head.find('(') { | ||
| Some(open) if head.ends_with(')') => &head[..open], | ||
| Some(_) => return false, | ||
| None => head, | ||
| }; | ||
| CONVENTIONAL_COMMIT_TYPES | ||
| .iter() | ||
| .any(|known| base.eq_ignore_ascii_case(known)) | ||
| } |
There was a problem hiding this comment.
The has_conventional_prefix function has a potential false positive where it can match malformed or complex headers like feat(scope1) and fix(scope2): message or feat(scope)extra): message as valid conventional prefixes. This happens because it only checks if head ends with ) and extracts the substring before the first (. We can make this check more robust by ensuring that there is exactly one ) in head and it is located at the very end of the string.
| fn has_conventional_prefix(message: &str) -> bool { | |
| let Some(colon) = message.find(':') else { | |
| return false; | |
| }; | |
| let head = &message[..colon]; | |
| let head = head.strip_suffix('!').unwrap_or(head); | |
| let base = match head.find('(') { | |
| Some(open) if head.ends_with(')') => &head[..open], | |
| Some(_) => return false, | |
| None => head, | |
| }; | |
| CONVENTIONAL_COMMIT_TYPES | |
| .iter() | |
| .any(|known| base.eq_ignore_ascii_case(known)) | |
| } | |
| fn has_conventional_prefix(message: &str) -> bool { | |
| let Some(colon) = message.find(':') else { | |
| return false; | |
| }; | |
| let head = &message[..colon]; | |
| let head = head.strip_suffix('!').unwrap_or(head); | |
| let base = match head.find('(') { | |
| Some(open) if head.ends_with(')') && head.find(')') == Some(head.len() - 1) => &head[..open], | |
| Some(_) => return false, | |
| None => head, | |
| }; | |
| CONVENTIONAL_COMMIT_TYPES | |
| .iter() | |
| .any(|known| base.eq_ignore_ascii_case(known)) | |
| } |
| if let Some(colon_pos) = msg.find(':') { | ||
| let prefix = &msg[..colon_pos]; | ||
| let after = msg[colon_pos + 1..].trim_start(); | ||
| let prefix = prefix.strip_suffix('!').unwrap_or(prefix); | ||
| let base = if let Some(paren) = prefix.find('(') { | ||
| &prefix[..paren] | ||
| } else { | ||
| prefix | ||
| }; | ||
| let known = [ | ||
| "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", | ||
| "add", "update", "remove", | ||
| ]; | ||
| if known.contains(&base) { | ||
| if known.iter().any(|k| base.eq_ignore_ascii_case(k)) { | ||
| return after; | ||
| } | ||
| } |
There was a problem hiding this comment.
In strip_conventional_prefix, when extracting the base type from a scoped prefix, the code does not verify if the scope is properly closed (i.e., if the prefix ends with )). This can lead to stripping prefixes from malformed or non-conventional commit messages (e.g., feat(scope)invalid: message would be stripped to message). We should ensure that if a ( is found, the prefix must end with ) for it to be considered a valid conventional prefix.
if let Some(colon_pos) = msg.find(':') {
let prefix = &msg[..colon_pos];
let after = msg[colon_pos + 1..].trim_start();
let prefix = prefix.strip_suffix('!').unwrap_or(prefix);
let base = if let Some(paren) = prefix.find('(') {
if prefix.ends_with(')') {
&prefix[..paren]
} else {
return msg;
}
} else {
prefix
};
let known = [
"feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore",
"add", "update", "remove",
];
if known.iter().any(|k| base.eq_ignore_ascii_case(k)) {
return after;
}
}There was a problem hiding this comment.
β Corvin says...
_
<(^\ .oO(Caw! ^v^)
|/(\
\(\\
" "\\
"Caw! Your code sparkles like a dropped french fry."
CI Summary
| Check | Status |
|---|---|
| Dependency Audit | β Passed |
| Integration (3 OS) | β Passed |
| Lint (fmt + clippy) | β Passed |
| Spec Validation | β Passed |
| Tests (3 OS) | β Passed |
Powered by corvid-pet
fledge release generated the v1.5.1 section from commit subjects but left the pre-written [Unreleased] Fixes block stranded below it. Merge those detail bullets under the #418 entry and drop the stale section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
fledge work commitno longer double-prefixes the conventional type:-mmessages that already start withtype:/type(scope):(case-insensitive, including breaking!variants and CorvidLabs-styleAdd:/Update:/Remove:) are committed verbatim instead of becomingfeat: feat: ...fledge changelogand the release changelog writer) is now case-insensitive and understands the CorvidLabs commit style:Add:β Features,Update:β Changes,Remove:β Removals,Fix:/Refactor:/etc. map to their lowercase categories instead of landing in "Other"; breaking!markers (feat!:,fix(core)!:) classify by base type, matching changelog spec invariant 10fledge run --initgeneric starter template no longer emits an unclosed quote in the commented# lint = "echo 'add your linter'"example β uncommenting it previously made fledge.toml unparseablefledge lanes inithint now points atfledge lanes list(previouslyfledge lane, which is the alias without a subcommand and exits with a usage error)fledge run --help(plus README and run spec) pass-through example now usesfledge run test -- --release, which is valid when appended tocargo test;--nocaptureis only accepted after cargo's own--separator, so the documented example failed against fledge's own rust-cli template. The template keeps plaincargo testsince a$@placement would break the Windows append pathTest Plan
cargo fmt --checkcargo clippy --all-targets -- -D warningscargo testβ 867 unit + 177 integration tests pass (new tests cover verbatim/case-insensitive commit prefixes, CorvidLabs-style and breaking-marker classification in both classifiers, uncommented starter-template TOML validity, and the lanes init hint)cargo run -- spec checkβ 30 specs, 0 errors, 0 warningsπ€ Generated with Claude Code