Go 509 lines
package workflow
import (
"strings"
"testing"
)
// stubTagSource replaces the versionSyncTagSource resolver for the
// duration of a test. The returned cleanup restores the previous
// resolver; defer the call. An empty value simulates "no semver tag
// reachable" (a fresh repo or one carrying only foreign tags).
func stubTagSource(tag string) func() {
prev := versionSyncTagSource
versionSyncTagSource = func(string) (string, error) { return tag, nil }
return func() { versionSyncTagSource = prev }
}
func TestVersionSync_NoLocationsDeclaredIsClean(t *testing.T) {
cfg := newCfg(t)
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("no version_locations -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
}
func TestVersionSync_SingleLocationAgreesWithItself(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0] - 2026-05-22\n")
cfg.VersionLocations = []string{`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("single location -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
}
func TestVersionSync_MultipleLocationsAgreeIsClean(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0] - 2026-05-22\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.11.0\n")
writeRepoFile(t, cfg.RepoRoot, "README.md", "badge: v1.11.0 here\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
`README.md:badge: v(\d+\.\d+\.\d+)`,
}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("agreeing locations -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "1.11.0") {
t.Errorf("summary missing anchor version: %s", res.Summary)
}
}
func TestVersionSync_DriftReportsFinding(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0] - 2026-05-22\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.10.0\n")
writeRepoFile(t, cfg.RepoRoot, "README.md", "badge: v1.11.0 here\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
`README.md:badge: v(\d+\.\d+\.\d+)`,
}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("drift -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if len(res.Findings) != 1 {
t.Fatalf("findings = %d, want 1: %+v", len(res.Findings), res.Findings)
}
if res.Findings[0].Path != "VERSION" {
t.Errorf("finding path = %q, want VERSION", res.Findings[0].Path)
}
}
func TestVersionSync_MultipleDriftsReported(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.10.0\n")
writeRepoFile(t, cfg.RepoRoot, "README.md", "badge: v1.9.5 here\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
`README.md:badge: v(\d+\.\d+\.\d+)`,
}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("multi-drift -> %d (%s)", res.Code, res.Summary)
}
if len(res.Findings) != 2 {
t.Fatalf("findings = %d, want 2: %+v", len(res.Findings), res.Findings)
}
if res.Findings[0].Path != "README.md" || res.Findings[1].Path != "VERSION" {
t.Errorf("findings unsorted or wrong paths: %+v", res.Findings)
}
}
func TestVersionSync_MissingLocationBlocks(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`MISSING.md:v(\d+\.\d+\.\d+)`,
}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeBlocked {
t.Fatalf("missing location -> %d (%s)", res.Code, res.Summary)
}
if !strings.Contains(res.Summary, "MISSING.md") {
t.Errorf("summary missing the absent path: %s", res.Summary)
}
}
func TestVersionSync_RegexMatchesNothingReportsFinding(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "no version here\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("no match -> %d (%s)", res.Code, res.Summary)
}
if len(res.Findings) != 1 || res.Findings[0].Path != "VERSION" {
t.Errorf("unexpected findings: %+v", res.Findings)
}
}
func TestVersionSync_InvalidEntryErrors(t *testing.T) {
cfg := newCfg(t)
cfg.VersionLocations = []string{"no-colon-here"}
_, err := versionSync{}.Run(Env{Config: cfg})
if err == nil {
t.Fatal("expected error for malformed entry")
}
}
func TestVersionSync_RegexWithoutCaptureGroupErrors(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "v1.11.0\n")
cfg.VersionLocations = []string{`CHANGELOG.md:v\d+\.\d+\.\d+`}
_, err := versionSync{}.Run(Env{Config: cfg})
if err == nil {
t.Fatal("expected error for regex without capture group")
}
}
func TestVersionSync_BadRegexErrors(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "v1.11.0\n")
cfg.VersionLocations = []string{"CHANGELOG.md:[unclosed"}
_, err := versionSync{}.Run(Env{Config: cfg})
if err == nil {
t.Fatal("expected error for malformed regex")
}
}
func TestVersionSync_TagAnchorCleanWhenLocationsAgreeWithTag(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.12.0\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
cfg.VersionAnchor = "tag"
defer stubTagSource("v1.12.0")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("tag-anchor clean -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "tag-anchor v1.12.0") {
t.Errorf("summary missing tag-anchor mention: %s", res.Summary)
}
}
func TestVersionSync_TagAnchorAllowsForwardDrift(t *testing.T) {
// Release-commit case: CHANGELOG bumped to v1.13.0 ahead of the
// not-yet-pushed tag. The gate must NOT block this.
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.13.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.13.0\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
cfg.VersionAnchor = "tag"
defer stubTagSource("v1.12.0")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("tag-anchor forward-drift -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "ahead of tag-anchor v1.12.0") {
t.Errorf("summary missing forward-drift note: %s", res.Summary)
}
}
func TestVersionSync_TagAnchorBackwardDriftFails(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.11.0\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
cfg.VersionAnchor = "tag"
defer stubTagSource("v1.12.0")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("backward-drift -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if len(res.Findings) != 2 {
t.Fatalf("findings = %d, want 2: %+v", len(res.Findings), res.Findings)
}
if !strings.Contains(res.Findings[0].Msg, "behind tag-anchor v1.12.0") {
t.Errorf("finding msg = %q, want backward-drift mention", res.Findings[0].Msg)
}
}
func TestVersionSync_TagAnchorMutualDisagreementCaught(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.13.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.13.1\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
cfg.VersionAnchor = "tag"
defer stubTagSource("v1.12.0")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("mutual disagreement under tag-anchor -> %d (%s)", res.Code, res.Summary)
}
// Both ahead of the tag (≥), so the tag pre-check passes; the
// consistency check then catches the mutual disagreement.
if !strings.Contains(res.Summary, "tag-anchor v1.12.0") {
t.Errorf("summary missing tag-anchor mention: %s", res.Summary)
}
}
func TestVersionSync_TagAnchorNoTagsFallsBackToConsistency(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.12.0\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
cfg.VersionAnchor = "tag"
defer stubTagSource("")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("tag-anchor no-tags -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "no semver tag reachable yet") {
t.Errorf("summary missing fallback note: %s", res.Summary)
}
}
func TestVersionSync_TagAnchorNonSemverFails(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
writeRepoFile(t, cfg.RepoRoot, "PROJECT", "v2024-05-22\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`PROJECT:^v(.+)`,
}
cfg.VersionAnchor = "tag"
defer stubTagSource("v1.12.0")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("non-semver under tag-anchor -> %d (%s)", res.Code, res.Summary)
}
if len(res.Findings) != 1 || res.Findings[0].Path != "PROJECT" {
t.Errorf("findings unexpected: %+v", res.Findings)
}
if !strings.Contains(res.Findings[0].Msg, "not semver-shaped") {
t.Errorf("finding msg = %q, want non-semver mention", res.Findings[0].Msg)
}
}
func TestVersionSync_FileAnchorClean(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.13.0\n")
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.13.0]\n")
writeRepoFile(t, cfg.RepoRoot, "README.md", "badge: v1.13.0 here\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`README.md:badge: v(\d+\.\d+\.\d+)`,
}
cfg.VersionAnchor = `VERSION:^v(\d+\.\d+\.\d+)`
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("file-anchor clean -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "version_anchor VERSION") {
t.Errorf("summary missing version_anchor mention: %s", res.Summary)
}
}
func TestVersionSync_FileAnchorDriftFails(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.13.0\n")
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
}
cfg.VersionAnchor = `VERSION:^v(\d+\.\d+\.\d+)`
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("file-anchor drift -> %d (%s)", res.Code, res.Summary)
}
if len(res.Findings) != 1 || res.Findings[0].Path != "CHANGELOG.md" {
t.Errorf("findings = %+v", res.Findings)
}
}
func TestVersionSync_FileAnchorMissingPathBlocks(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
}
cfg.VersionAnchor = `MISSING:^v(\d+\.\d+\.\d+)`
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeBlocked {
t.Fatalf("file-anchor missing -> %d (%s)", res.Code, res.Summary)
}
if !strings.Contains(res.Summary, "MISSING") {
t.Errorf("summary missing the absent path: %s", res.Summary)
}
}
func TestVersionSync_FindingLineNumberMatchesContent(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
// Drift sits on line 3; the finding must point there. `(?m)` flips
// `^` from start-of-string to start-of-line — the documented spelling
// for matching a version string further down a file.
writeRepoFile(t, cfg.RepoRoot, "VERSION", "header\n\nv1.10.0\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:(?m)^v(\d+\.\d+\.\d+)`,
}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding || len(res.Findings) != 1 {
t.Fatalf("unexpected result: %+v", res)
}
if res.Findings[0].Line != 3 {
t.Errorf("finding line = %d, want 3", res.Findings[0].Line)
}
}
func TestVersionSync_AutoDetectNoVersionFilesIsClean(t *testing.T) {
cfg := newCfg(t)
cfg.VersionLocations = []string{"auto"}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("auto, no files -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "no version locations found") {
t.Errorf("summary missing the no-locations note: %s", res.Summary)
}
}
func TestVersionSync_AutoDetectSingleFileIsClean(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.14.0] - 2026-05-22\n")
cfg.VersionLocations = []string{"auto"}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("auto, single file -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.HasPrefix(res.Summary, "auto-detect: ") {
t.Errorf("summary missing the auto-detect prefix: %s", res.Summary)
}
}
func TestVersionSync_AutoDetectAgreeingFilesIsClean(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "VERSION", "1.14.0\n")
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.14.0]\n")
writeRepoFile(t, cfg.RepoRoot, "package.json", "{\n \"name\": \"demo\",\n \"version\": \"1.14.0\"\n}\n")
cfg.VersionLocations = []string{"auto"}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("auto, agreeing files -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "1.14.0") {
t.Errorf("summary missing the detected version: %s", res.Summary)
}
}
func TestVersionSync_AutoDetectDriftReportsFinding(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.14.0]\n")
writeRepoFile(t, cfg.RepoRoot, "package.json", "{\n \"version\": \"1.13.0\"\n}\n")
cfg.VersionLocations = []string{"auto"}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("auto, drift -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if len(res.Findings) != 1 || res.Findings[0].Path != "package.json" {
t.Fatalf("findings = %+v, want one on package.json", res.Findings)
}
}
func TestVersionSync_AutoDetectSkipsFileWithoutVersion(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.14.0]\n")
// A package.json with no `version` field carries no version-shaped
// string — auto-detect must skip it, not flag it as a drift.
writeRepoFile(t, cfg.RepoRoot, "package.json", "{\n \"name\": \"demo\"\n}\n")
cfg.VersionLocations = []string{"auto"}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("auto, version-less file -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
}
func TestVersionSync_AutoDetectComposesWithTagAnchor(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "VERSION", "1.13.0\n")
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.13.0]\n")
cfg.VersionLocations = []string{"auto"}
cfg.VersionAnchor = "tag"
defer stubTagSource("v1.12.0")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("auto + tag-anchor -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.HasPrefix(res.Summary, "auto-detect: ") || !strings.Contains(res.Summary, "tag-anchor v1.12.0") {
t.Errorf("summary missing auto-detect prefix or tag-anchor note: %s", res.Summary)
}
}