diff --git a/CHANGELOG.md b/CHANGELOG.md index f26b75a..7638b9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project uses [Semantic Versioning](https://semver.org). +## [0.2.27] - 2026-06-25 + +### Added + +- **Fuzz testing** — `FuzzParseBytes` and `FuzzSanitizeYAMLEscapes` in `internal/model/fuzz_test.go` verify that neither the YAML parser nor the escape sanitizer panics on arbitrary input. Successful parses are also checked for structural integrity (non-nil pipeline, no empty job names). Run with `task fuzz` (default 30 s per target; set `FUZZ_TIME=60s` to extend). Found failures are saved to `testdata/fuzz/` for regression. + +- **Changelog automation** — `cliff.toml` configures [git-cliff](https://git-cliff.org) to generate Keep-a-Changelog–compatible release notes from Conventional Commits. `task changelog` regenerates `CHANGELOG.md` from the full git history; `task changelog-next` previews only unreleased commits without writing. Install git-cliff with `brew install git-cliff` or `cargo install git-cliff`. + ## [0.2.26] - 2026-06-25 ### Changed diff --git a/README.md b/README.md index 7147c9f..ceb0710 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

License - Release + Release

> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome. @@ -69,11 +69,18 @@ task test # run Go unit tests task lint-go # run go vet task validate # run the binary against all testdata fixtures task ci # full check: vet → test → build → validate +task fuzz # run fuzz tests for the YAML parser (Ctrl-C to stop; FUZZ_TIME=60s to set duration) +task changelog # regenerate CHANGELOG.md from git history via git-cliff +task changelog-next # preview unreleased section (dry-run, no file written) task build-windows # cross-compile for Windows x64 (requires a tagged commit → glint-.exe) task build-linux # cross-compile for Linux x64 (requires a tagged commit → glint--linux-amd64) task clean # remove build artifacts ``` +**Optional tools:** + +- [git-cliff](https://git-cliff.org) — changelog generator used by `task changelog`. Install with `brew install git-cliff` or `cargo install git-cliff`. + ## Project structure ``` diff --git a/ROADMAP.md b/ROADMAP.md index e0a88b6..ad9a49f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -119,5 +119,5 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it - ~~**`glint explain `**~~ — ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules - ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07) - ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help` -- **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar -- **Fuzz testing** — add a `go test -fuzz` target for the YAML parser to harden it against malformed input +- ~~**Changelog automation**~~ — ✓ shipped v0.2.27; `cliff.toml` configures git-cliff to produce Keep-a-Changelog–compatible release notes from Conventional Commits; `task changelog` regenerates `CHANGELOG.md`, `task changelog-next` previews unreleased entries +- ~~**Fuzz testing**~~ — ✓ shipped v0.2.27; `FuzzParseBytes` and `FuzzSanitizeYAMLEscapes` in `internal/model/fuzz_test.go`; seeds run as regular tests in CI; `task fuzz` runs them continuously (default 30 s) diff --git a/Taskfile.yml b/Taskfile.yml index deba163..f023692 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -175,6 +175,20 @@ tasks: generates: - "{{.BINARY}}-{{.TAG}}-linux-amd64" + fuzz: + desc: "Run fuzz tests for the YAML parser and sanitizer (set FUZZ_TIME=60s to control duration, default 30s)" + cmds: + - "{{.GO}} test -fuzz=FuzzParseBytes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/" + - "{{.GO}} test -fuzz=FuzzSanitizeYAMLEscapes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/" + + changelog: + desc: "Regenerate CHANGELOG.md from git history (requires git-cliff — see README)" + cmd: git cliff --config cliff.toml --output CHANGELOG.md + + changelog-next: + desc: "Preview unreleased changelog entries without writing (requires git-cliff)" + cmd: git cliff --config cliff.toml --unreleased + clean: desc: Remove build artifacts cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64 diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..7dd14cd --- /dev/null +++ b/cliff.toml @@ -0,0 +1,65 @@ +# git-cliff configuration for glint +# Install: brew install git-cliff OR cargo install git-cliff +# Usage: task changelog -- regenerate full CHANGELOG.md +# task changelog-next -- preview unreleased section (dry-run) + +[changelog] +header = """ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +This project uses [Semantic Versioning](https://semver.org). +""" +body = """ +{% if version %}\ +## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} + +{% else %}\ +## [Unreleased] + +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %}\ +### {{ group | upper_first }} + +{% for commit in commits %}\ +- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\ +{{ commit.message | upper_first }}\ +{% if commit.breaking %} **[BREAKING]**{% endif %} + +{% endfor %}\ +{% endfor %}\ +""" +trim = true +footer = "" +postprocessors = [] + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +commit_preprocessors = [ + # Drop Co-Authored-By trailers (should not appear in subjects, but guard anyway). + { pattern = "Co-Authored-By:.*", replace = "" }, +] +commit_parsers = [ + # Breaking changes (type! or scope!) — promote above everything else. + { message = "^[a-z]+\\([a-z-]+\\)!:|^[a-z]+!:", group = "Breaking Changes" }, + { message = "^feat", group = "Added" }, + { message = "^fix", group = "Fixed" }, + { message = "^perf", group = "Changed" }, + { message = "^refactor", group = "Changed" }, + # Maintenance commits — omit from the changelog body. + { message = "^docs", skip = true }, + { message = "^style", skip = true }, + { message = "^test", skip = true }, + { message = "^chore", skip = true }, + { message = "^build", skip = true }, + { message = "^claude", skip = true }, +] +protect_breaking_commits = false +filter_commits = true +tag_pattern = "v[0-9].*" +topo_order = false +sort_commits = "oldest" diff --git a/internal/model/fuzz_test.go b/internal/model/fuzz_test.go new file mode 100644 index 0000000..d60ad93 --- /dev/null +++ b/internal/model/fuzz_test.go @@ -0,0 +1,77 @@ +package model + +import "testing" + +// FuzzParseBytes ensures the YAML parser never panics on arbitrary input and +// that successful parses return a structurally sound Pipeline. +// Run with: go test -fuzz=FuzzParseBytes ./internal/model/ +// Found failures are saved to testdata/fuzz/FuzzParseBytes/. +func FuzzParseBytes(f *testing.F) { + // Seed corpus: representative inputs covering the main code paths in + // ParseBytes, including the sanitizeYAMLEscapes pre-processing step. + seeds := [][]byte{ + {}, + []byte("null"), + []byte("stages: [build]\nbuild-job:\n stage: build\n script: echo ok\n"), + []byte(".base:\n script: [make]\nchild:\n extends: .base\n stage: test\n"), + []byte("stages: [a, b]\njob-a:\n stage: a\n script: run\njob-b:\n stage: b\n needs: [job-a]\n script: run\n"), + []byte("*undefined_anchor"), + []byte("- item1\n- item2\n"), + []byte("my-job: \"just a string\"\n"), + []byte("my-job:\n stage: [build, test]\n"), + []byte("# glint: ignore GL007\nlegacy:\n only: [main]\n script: ok\n"), + []byte("workflow:\n rules:\n - if: '$CI_COMMIT_BRANCH == \"main\"'\n when: always\njob:\n script: echo ok\n"), + []byte("include:\n - local: other.yml\njob:\n script: echo ok\n"), + []byte("job:\n script: echo ok\n when: on_failure\n rules:\n - if: '$VAR =~ /^us\\//'\n"), + []byte("job:\n stage: test\n image:\n name: golang:1.21\n entrypoint: ['']\n parallel:\n matrix:\n - PLATFORM: [linux, darwin]\n script: go build\n"), + []byte("default:\n retry: 2\n timeout: 1h30m\nvariables:\n ENV: production\nstages: [build, test, deploy]\n"), + []byte("&anchor\n script: [echo ok]\njob:\n <<: *anchor\n stage: build\n"), + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, data []byte) { + p, err := ParseBytes(data) + if err != nil { + return // errors are acceptable; panics are not + } + if p == nil { + t.Fatal("ParseBytes returned nil pipeline with nil error") + } + for name := range p.Jobs { + if name == "" { + t.Fatal("ParseBytes produced a job with an empty name") + } + } + }) +} + +// FuzzSanitizeYAMLEscapes ensures the escape sanitizer never panics and never +// produces output shorter than its input (it can only expand \/ to \\/). +func FuzzSanitizeYAMLEscapes(f *testing.F) { + seeds := [][]byte{ + {}, + []byte("stage: build"), + []byte(`if: "$CI_BRANCH =~ /^us\//"`), + []byte(`"pattern: /^us\//"`), + []byte(`'single quoted \/ unchanged'`), + []byte(`"\n\t\r"`), + []byte(`"nested \"quote\" inside"`), + []byte(`'it''s fine'`), + []byte(`"unclosed`), + {'"', '\\'}, // double-quoted string ending with a lone backslash + {'"', '\\', '/'}, // the exact sequence being rewritten + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, data []byte) { + out := sanitizeYAMLEscapes(data) + if len(out) < len(data) { + t.Fatalf("sanitizeYAMLEscapes shrank output: input len=%d output len=%d\ninput: %q", + len(data), len(out), data) + } + }) +}