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) } }