← back to Cli Printing Press
feat(ci): automated releases, linting, and commit conventions (#34)
c779648a6781e28628fbab3d48edffca2406792d · 2026-03-28 21:01:21 -0700 · Trevin Chow
* feat(ci): automated releases, linting, and commit conventions
Add release-please + goreleaser for fully automated releases:
- release-please watches conventional commits, opens release PRs,
bumps version in plugin.json, marketplace.json, and root.go
- goreleaser builds cross-platform binaries on release (6 targets)
- Version injected via ldflags; debug.ReadBuildInfo fallback for
go install builds
Add golangci-lint (govet, errcheck, staticcheck, unused, gofmt):
- CI action on every PR via golangci-lint-action@v9
- Claude Code hook runs gofmt on every Go file write
- Pre-existing debt excluded with tracked rules in .golangci.yml
- Fixed: 5 gofmt violations, 1 self-assignment bug in state.go
Add PR title enforcement:
- amannn/action-semantic-pull-request@v6 validates PR titles
- Required scopes: cli, skills, ci
- Documented in AGENTS.md with version bump semantics
Bump CI actions: checkout v4→v6, setup-go v5→v6
Tests added:
- Version consistency across 3 files (catches manual version drift)
- Goreleaser ldflags path matches Go variable (catches renames)
- release-please annotation exists in root.go
- Shared build cache behavior (validates CI perf fix)
- Conventional commit pattern validation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): resolve all golangci-lint issues and remove exclusion workarounds
Fix 24 lint issues, delete 248 lines of dead code, slim .golangci.yml
from 68 lines of exclusions to 30 lines with only 2 justified rules:
- errcheck excluded in test files (setup/cleanup noise)
- defer os.Remove/RemoveAll excluded (cleanup failure is harmless)
Fixes applied:
- errcheck: wrap defer Close() with explicit discard, check json.Encode
- SA1019: replace deprecated strings.Title with cases.Title (x/text)
- S1039/QF1012: WriteString(fmt.Sprintf(...)) → fmt.Fprintf
- SA4009: rename overwritten Walk callback param to _
- ineffassign: use _ for unused map lookups, remove dead assignment
Dead code deleted:
- internal/pipeline/spec_summary.go (entire file, 7 unused symbols)
- removeDeadFunctions in remediate.go
- resourceDescription, resourceNameFromPath, firstNonEmpty in parser.go
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): handle defer cleanup errors, remove os.Remove exclusions
Wrap all defer os.Remove/os.RemoveAll calls with explicit error
discard so errcheck is satisfied without exclusion rules. Removes
the last non-fmt errcheck exclusions from .golangci.yml.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(ci): require scope on all commit types and sync test with workflow
Addresses code review findings on PR #34:
1. pr-title.yml: requireScope false → true. Scope is now required
on all types (feat, fix, docs, chore, etc.), not just documented.
2. conventions_test.go: regex now only accepts cli|skills|ci scopes,
matching the workflow's scope list. Comment said "keep in sync" —
now they actually are.
3. AGENTS.md: updated to reflect scope is always required.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
M .claude/settings.jsonA .github/workflows/lint.ymlA .github/workflows/pr-title.ymlA .github/workflows/release.ymlM .gitignoreA .golangci.ymlA .goreleaser.yamlA .release-please-manifest.jsonM AGENTS.mdM go.modM go.sumA internal/cli/release_test.goM internal/cli/root.goM internal/cli/scorecard.goM internal/cli/vision.goM internal/docspec/docspec.goM internal/generator/entity_mapper.goM internal/generator/generator.goM internal/generator/readme_augment.goA internal/generator/validate_test.goM internal/graphql/parser.goM internal/llm/llm.goM internal/llmpolish/prompts.goM internal/openapi/parser.goM internal/pipeline/comparative.goA internal/pipeline/conventions_test.goM internal/pipeline/dogfood.goM internal/pipeline/fullrun.goM internal/pipeline/learnings.goM internal/pipeline/planner.goM internal/pipeline/publish.goM internal/pipeline/remediate.goM internal/pipeline/research.goM internal/pipeline/runtime.goM internal/pipeline/scorecard.goM internal/pipeline/selfimprove.goD internal/pipeline/spec_summary.goM internal/pipeline/state.goM internal/pipeline/verify_report.goM internal/profiler/apitype.goM internal/profiler/profiler.goM internal/vision/report.goA release-please-config.json
Diff
commit c779648a6781e28628fbab3d48edffca2406792d
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat Mar 28 21:01:21 2026 -0700
feat(ci): automated releases, linting, and commit conventions (#34)
* feat(ci): automated releases, linting, and commit conventions
Add release-please + goreleaser for fully automated releases:
- release-please watches conventional commits, opens release PRs,
bumps version in plugin.json, marketplace.json, and root.go
- goreleaser builds cross-platform binaries on release (6 targets)
- Version injected via ldflags; debug.ReadBuildInfo fallback for
go install builds
Add golangci-lint (govet, errcheck, staticcheck, unused, gofmt):
- CI action on every PR via golangci-lint-action@v9
- Claude Code hook runs gofmt on every Go file write
- Pre-existing debt excluded with tracked rules in .golangci.yml
- Fixed: 5 gofmt violations, 1 self-assignment bug in state.go
Add PR title enforcement:
- amannn/action-semantic-pull-request@v6 validates PR titles
- Required scopes: cli, skills, ci
- Documented in AGENTS.md with version bump semantics
Bump CI actions: checkout v4→v6, setup-go v5→v6
Tests added:
- Version consistency across 3 files (catches manual version drift)
- Goreleaser ldflags path matches Go variable (catches renames)
- release-please annotation exists in root.go
- Shared build cache behavior (validates CI perf fix)
- Conventional commit pattern validation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): resolve all golangci-lint issues and remove exclusion workarounds
Fix 24 lint issues, delete 248 lines of dead code, slim .golangci.yml
from 68 lines of exclusions to 30 lines with only 2 justified rules:
- errcheck excluded in test files (setup/cleanup noise)
- defer os.Remove/RemoveAll excluded (cleanup failure is harmless)
Fixes applied:
- errcheck: wrap defer Close() with explicit discard, check json.Encode
- SA1019: replace deprecated strings.Title with cases.Title (x/text)
- S1039/QF1012: WriteString(fmt.Sprintf(...)) → fmt.Fprintf
- SA4009: rename overwritten Walk callback param to _
- ineffassign: use _ for unused map lookups, remove dead assignment
Dead code deleted:
- internal/pipeline/spec_summary.go (entire file, 7 unused symbols)
- removeDeadFunctions in remediate.go
- resourceDescription, resourceNameFromPath, firstNonEmpty in parser.go
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): handle defer cleanup errors, remove os.Remove exclusions
Wrap all defer os.Remove/os.RemoveAll calls with explicit error
discard so errcheck is satisfied without exclusion rules. Removes
the last non-fmt errcheck exclusions from .golangci.yml.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(ci): require scope on all commit types and sync test with workflow
Addresses code review findings on PR #34:
1. pr-title.yml: requireScope false → true. Scope is now required
on all types (feat, fix, docs, chore, etc.), not just documented.
2. conventions_test.go: regex now only accepts cli|skills|ci scopes,
matching the workflow's scope list. Comment said "keep in sync" —
now they actually are.
3. AGENTS.md: updated to reflect scope is always required.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
.claude/settings.json | 13 +++
.github/workflows/lint.yml | 22 +++++
.github/workflows/pr-title.yml | 36 ++++++++
.github/workflows/release.yml | 46 ++++++++++
.gitignore | 1 +
.golangci.yml | 33 +++++++
.goreleaser.yaml | 37 ++++++++
.release-please-manifest.json | 3 +
AGENTS.md | 44 ++++++++-
go.mod | 3 +-
go.sum | 15 +++-
internal/cli/release_test.go | 83 +++++++++++++++++
internal/cli/root.go | 22 +++--
internal/cli/scorecard.go | 2 +-
internal/cli/vision.go | 6 +-
internal/docspec/docspec.go | 2 +-
internal/generator/entity_mapper.go | 24 ++---
internal/generator/generator.go | 6 +-
internal/generator/readme_augment.go | 4 +-
internal/generator/validate_test.go | 34 +++++++
internal/graphql/parser.go | 4 +-
internal/llm/llm.go | 2 +-
internal/llmpolish/prompts.go | 14 +--
internal/openapi/parser.go | 39 ++------
internal/pipeline/comparative.go | 16 ++--
internal/pipeline/conventions_test.go | 62 +++++++++++++
internal/pipeline/dogfood.go | 2 +-
internal/pipeline/fullrun.go | 42 ++++-----
internal/pipeline/learnings.go | 6 +-
internal/pipeline/planner.go | 58 ++++++------
internal/pipeline/publish.go | 8 +-
internal/pipeline/remediate.go | 49 ----------
internal/pipeline/research.go | 28 +++---
internal/pipeline/runtime.go | 2 +-
internal/pipeline/scorecard.go | 18 ++--
internal/pipeline/selfimprove.go | 14 +--
internal/pipeline/spec_summary.go | 164 ----------------------------------
internal/pipeline/state.go | 1 -
internal/pipeline/verify_report.go | 28 +++---
internal/profiler/apitype.go | 2 +-
internal/profiler/profiler.go | 6 +-
internal/vision/report.go | 38 ++++----
release-please-config.json | 25 ++++++
43 files changed, 641 insertions(+), 423 deletions(-)
diff --git a/.claude/settings.json b/.claude/settings.json
index eca411a1..4dc3775a 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,5 +1,18 @@
{
"enabledPlugins": {
"compound-engineering@every-marketplace": true
+ },
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Write|Edit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "FILE=$(echo \"$CLAUDE_TOOL_INPUT\" | grep -o '\"[^\"]*\\.go\"' | head -1 | tr -d '\"'); if [ -n \"$FILE\" ] && [ -f \"$FILE\" ]; then gofmt -l \"$FILE\" 2>/dev/null | while read f; do echo \"gofmt: $f needs formatting. Run: gofmt -w $f\"; done; fi"
+ }
+ ]
+ }
+ ]
}
}
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 00000000..cc85a205
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,22 @@
+name: Lint
+
+on:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - uses: golangci/golangci-lint-action@v9
+ with:
+ version: latest
diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml
new file mode 100644
index 00000000..bbbc300d
--- /dev/null
+++ b/.github/workflows/pr-title.yml
@@ -0,0 +1,36 @@
+name: PR Title
+
+on:
+ pull_request:
+ types: [opened, edited, synchronize, reopened]
+
+permissions:
+ pull-requests: read
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: amannn/action-semantic-pull-request@v6
+ with:
+ types: |
+ feat
+ fix
+ docs
+ chore
+ refactor
+ test
+ ci
+ perf
+ build
+ style
+ revert
+ scopes: |
+ cli
+ skills
+ ci
+ requireScope: true
+ subjectPattern: ^.+$
+ subjectPatternError: "PR title must have a description after the colon"
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..e54a6a4d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,46 @@
+name: Release
+
+on:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+
+jobs:
+ release-please:
+ runs-on: ubuntu-latest
+ outputs:
+ release_created: ${{ steps.release.outputs.release_created }}
+ tag_name: ${{ steps.release.outputs.tag_name }}
+ steps:
+ - uses: googleapis/release-please-action@v4
+ id: release
+ with:
+ release-type: go
+
+ goreleaser:
+ needs: release-please
+ if: ${{ needs.release-please.outputs.release_created }}
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - uses: goreleaser/goreleaser-action@v7
+ with:
+ version: latest
+ args: release --clean
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 0fd8a354..188490ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
.cache/
/printing-press
library/
+dist/
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 00000000..19cf079b
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,33 @@
+version: "2"
+
+formatters:
+ enable:
+ - gofmt
+ exclusions:
+ paths:
+ - library
+ - dist
+
+linters:
+ enable:
+ - govet
+ - errcheck
+ - staticcheck
+ - unused
+ settings:
+ errcheck:
+ exclude-functions:
+ # CLI output — failure means stdout is broken, nothing to recover.
+ - fmt.Fprintf
+ - fmt.Fprintln
+ - fmt.Fprint
+ - (io.Writer).Write
+ exclusions:
+ paths:
+ - library
+ - dist
+ rules:
+ # Test files: unchecked errors in setup/cleanup helpers are noise.
+ - path: '_test\.go'
+ linters:
+ - errcheck
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
new file mode 100644
index 00000000..f558fed0
--- /dev/null
+++ b/.goreleaser.yaml
@@ -0,0 +1,37 @@
+version: 2
+
+gomod:
+ proxy: true
+
+builds:
+ - main: ./cmd/printing-press
+ binary: printing-press
+ env:
+ - CGO_ENABLED=0
+ goos:
+ - linux
+ - darwin
+ - windows
+ goarch:
+ - amd64
+ - arm64
+ flags:
+ - -trimpath
+ ldflags:
+ - -s -w -X github.com/mvanhorn/cli-printing-press/internal/cli.version={{.Version}}
+ mod_timestamp: "{{ .CommitTimestamp }}"
+
+archives:
+ - formats:
+ - tar.gz
+ name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
+ format_overrides:
+ - goos: windows
+ formats:
+ - zip
+
+checksum:
+ name_template: checksums.txt
+
+changelog:
+ use: github-native
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
new file mode 100644
index 00000000..2537c1f1
--- /dev/null
+++ b/.release-please-manifest.json
@@ -0,0 +1,3 @@
+{
+ ".": "0.4.0"
+}
diff --git a/AGENTS.md b/AGENTS.md
index e3756830..2bf49f13 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,13 +16,53 @@ go test ./...
- `internal/openapi/` - OpenAPI 3.0+ parser
- `internal/generator/` - Template engine + quality gates
- `internal/catalog/` - Catalog schema validator
-- `catalog/` - API catalog entries (YAML)
+- `catalog/` - API catalog entries (YAML) + Go embed package (`catalog.FS`). Adding a YAML file here requires rebuilding the binary
- `skills/` - Claude Code skill definitions
- `testdata/` - Test fixtures (internal + OpenAPI specs)
## Commit Style
-Conventional commits: `feat(scope):`, `fix(scope):`, `docs:`, `refactor:`
+**Format:** `type(scope): description` — scope is always required.
+
+**Scopes** (these appear in changelogs and release notes):
+
+| Scope | Covers | Example |
+|-------|--------|---------|
+| `cli` | Go binary, commands, flags, embedded catalog, docs | `feat(cli): add catalog subcommands` |
+| `skills` | Skill definitions (SKILL.md), references, setup contract | `fix(skills): remove repo checkout requirement` |
+| `ci` | Workflows, release config, goreleaser | `feat(ci): add release-please` |
+
+Every commit and PR title must include one of these scopes. The `PR Title` action enforces this.
+
+**Breaking changes** use `!` after the scope: `feat(cli)!: rename catalog command to registry`. This triggers a major version bump.
+
+**Version bump rules** (release-please reads these from commit prefixes):
+- `fix(scope):` → patch (0.4.0 → 0.4.1)
+- `feat(scope):` → minor (0.4.0 → 0.5.0)
+- `feat(scope)!:` or `BREAKING CHANGE:` footer → major (0.4.0 → 1.0.0)
+- `docs:`, `chore:`, `refactor:`, `test:` → included in next release but don't trigger a bump alone
+
+**PR titles must follow the same format.** GitHub's "Squash and merge" uses the PR title as the squash commit message, so release-please reads PR titles on main. The `PR Title` GitHub Action (`.github/workflows/pr-title.yml`) enforces this — PRs with invalid titles cannot merge.
+
+## Versioning
+
+**Never manually edit version numbers.** Three files carry the version and release-please keeps them in sync:
+- `.claude-plugin/plugin.json` → `version`
+- `.claude-plugin/marketplace.json` → `plugins[0].version`
+- `internal/cli/root.go` → `var version` (annotated with `x-release-please-version`)
+
+`TestVersionConsistencyAcrossFiles` in `internal/cli/release_test.go` will fail if versions drift.
+
+## Release Process
+
+Releases are fully automated. No manual steps required.
+
+1. **Merge PRs to main** with conventional commit messages / PR titles
+2. **release-please opens a release PR** accumulating all changes since the last release, with a generated changelog
+3. **Merge the release PR** when ready to cut a release
+4. **Automated:** release-please bumps all three version files, creates a git tag, and creates a GitHub release
+5. **Automated:** goreleaser builds cross-platform binaries (linux/darwin/windows × amd64/arm64) and attaches them to the release
+6. **Users update** via `go install ...@latest` (picks up the new tag) or download binaries from the release
## Adding Catalog Entries
diff --git a/go.mod b/go.mod
index cb8d3133..a0ddccf9 100644
--- a/go.mod
+++ b/go.mod
@@ -3,14 +3,15 @@ module github.com/mvanhorn/cli-printing-press
go 1.26.1
require (
+ github.com/getkin/kin-openapi v0.133.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
+ golang.org/x/text v0.35.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/getkin/kin-openapi v0.133.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
diff --git a/go.sum b/go.sum
index 8e2e2b25..01ecb2f2 100644
--- a/go.sum
+++ b/go.sum
@@ -7,10 +7,16 @@ github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
+github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
@@ -23,6 +29,8 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -30,10 +38,15 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
+github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/cli/release_test.go b/internal/cli/release_test.go
new file mode 100644
index 00000000..b360c107
--- /dev/null
+++ b/internal/cli/release_test.go
@@ -0,0 +1,83 @@
+package cli
+
+import (
+ "encoding/json"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+func TestGoreleaserLdflagsTargetMatchesVersionVar(t *testing.T) {
+ // The goreleaser config injects the version via ldflags into
+ // internal/cli.version. If the variable is renamed or moved,
+ // goreleaser silently injects into nothing and the binary
+ // reports the hardcoded fallback. This test catches that drift.
+
+ // 1. Verify the version variable exists and is settable in this package.
+ // (If this test compiles, the variable exists. We just confirm it's a string.)
+ assert.IsType(t, "", version)
+
+ // 2. Verify the goreleaser config references the correct ldflags path.
+ data, err := os.ReadFile("../../.goreleaser.yaml")
+ require.NoError(t, err)
+
+ var config struct {
+ Builds []struct {
+ Ldflags []string `yaml:"ldflags"`
+ } `yaml:"builds"`
+ }
+ require.NoError(t, yaml.Unmarshal(data, &config))
+ require.NotEmpty(t, config.Builds)
+
+ ldflags := strings.Join(config.Builds[0].Ldflags, " ")
+ assert.Contains(t, ldflags,
+ "github.com/mvanhorn/cli-printing-press/internal/cli.version",
+ "goreleaser ldflags must target internal/cli.version")
+}
+
+func TestReleasePleaseAnnotationExists(t *testing.T) {
+ // release-please uses the x-release-please-version annotation
+ // to find and bump the hardcoded version in root.go. If the
+ // annotation is removed, release-please silently stops updating it.
+ data, err := os.ReadFile("root.go")
+ require.NoError(t, err)
+
+ assert.Contains(t, string(data), "x-release-please-version",
+ "root.go must have x-release-please-version annotation for automated version bumps")
+}
+
+func TestVersionConsistencyAcrossFiles(t *testing.T) {
+ // All version surfaces should match. release-please keeps them
+ // in sync, but this catches manual edits that drift.
+
+ // Read plugin.json version
+ pluginData, err := os.ReadFile("../../.claude-plugin/plugin.json")
+ require.NoError(t, err)
+
+ var plugin struct {
+ Version string `json:"version"`
+ }
+ require.NoError(t, json.Unmarshal(pluginData, &plugin))
+
+ // Read marketplace.json version
+ marketData, err := os.ReadFile("../../.claude-plugin/marketplace.json")
+ require.NoError(t, err)
+
+ var market struct {
+ Plugins []struct {
+ Version string `json:"version"`
+ } `json:"plugins"`
+ }
+ require.NoError(t, json.Unmarshal(marketData, &market))
+ require.NotEmpty(t, market.Plugins)
+
+ // All three should match
+ assert.Equal(t, plugin.Version, market.Plugins[0].Version,
+ "plugin.json and marketplace.json versions must match")
+ assert.Equal(t, plugin.Version, version,
+ "plugin.json and root.go hardcoded version must match")
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 347ed9da..04ad2200 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -25,7 +25,7 @@ import (
"gopkg.in/yaml.v3"
)
-var version = "0.1.0"
+var version = "0.4.0" // x-release-please-version
func Execute() error {
rootCmd := &cobra.Command{
@@ -155,13 +155,15 @@ func newGenerateCmd() *cobra.Command {
fmt.Fprintf(os.Stderr, "Generated %s at %s (from docs)\n", naming.CLI(parsed.Name), absOut)
if asJSON {
- json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
+ if err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
"name": parsed.Name,
"output_dir": absOut,
"spec_files": specFiles,
"validated": validate,
"polished": polished,
- })
+ }); err != nil {
+ return fmt.Errorf("encoding JSON: %w", err)
+ }
}
return nil
}
@@ -268,13 +270,15 @@ func newGenerateCmd() *cobra.Command {
fmt.Fprintf(os.Stderr, "Generated %s at %s\n", naming.CLI(apiSpec.Name), absOut)
if asJSON {
- json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
+ if err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
"name": apiSpec.Name,
"output_dir": absOut,
"spec_files": specFiles,
"validated": validate,
"polished": polished,
- })
+ }); err != nil {
+ return fmt.Errorf("encoding JSON: %w", err)
+ }
}
return nil
},
@@ -413,7 +417,7 @@ func fetchOrCacheSpec(specURL string, refresh bool, skipCache bool) ([]byte, err
if err != nil {
return nil, err
}
- defer resp.Body.Close()
+ defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("unexpected response status: %s", resp.Status)
@@ -496,14 +500,16 @@ func newPrintCmd() *cobra.Command {
fmt.Fprintf(os.Stderr, "\nStart with: /ce:work %s\n", state.PlanPath(pipeline.PhasePreflight))
if asJSON {
- json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
+ if err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
"api_name": apiName,
"pipeline_dir": state.PipelineDir(),
"phases_completed": countCompletedPhases(state),
"state_file": state.StatePath(),
"working_dir": state.EffectiveWorkingDir(),
"run_id": state.RunID,
- })
+ }); err != nil {
+ return fmt.Errorf("encoding JSON: %w", err)
+ }
}
return nil
},
diff --git a/internal/cli/scorecard.go b/internal/cli/scorecard.go
index 8e983ff1..ab64a3f0 100644
--- a/internal/cli/scorecard.go
+++ b/internal/cli/scorecard.go
@@ -33,7 +33,7 @@ func newScorecardCmd() *cobra.Command {
if err != nil {
return fmt.Errorf("creating temp dir: %w", err)
}
- defer os.RemoveAll(pipelineDir)
+ defer func() { _ = os.RemoveAll(pipelineDir) }()
sc, err := pipeline.RunScorecard(dir, pipelineDir, specPath, nil)
if err != nil {
diff --git a/internal/cli/vision.go b/internal/cli/vision.go
index 517b0882..b30b4113 100644
--- a/internal/cli/vision.go
+++ b/internal/cli/vision.go
@@ -85,10 +85,12 @@ The vision command produces the structure; Phase 0 fills it with intelligence.`,
fmt.Fprintf(os.Stderr, "Visionary research template written to %s/visionary-research.md\n", absOut)
fmt.Fprintf(os.Stderr, "Run Phase 0 (SKILL.md) to fill it with real research.\n")
if asJSON {
- json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
+ if err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
"api_name": apiName,
"output_file": filepath.Join(absOut, "visionary-research.md"),
- })
+ }); err != nil {
+ return fmt.Errorf("encoding JSON: %w", err)
+ }
}
return nil
},
diff --git a/internal/docspec/docspec.go b/internal/docspec/docspec.go
index d9328cfc..2001bbc4 100644
--- a/internal/docspec/docspec.go
+++ b/internal/docspec/docspec.go
@@ -78,7 +78,7 @@ func fetchHTML(url string) (string, error) {
if err != nil {
return "", err
}
- defer resp.Body.Close()
+ defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
diff --git a/internal/generator/entity_mapper.go b/internal/generator/entity_mapper.go
index 3b8840ab..67cc7428 100644
--- a/internal/generator/entity_mapper.go
+++ b/internal/generator/entity_mapper.go
@@ -24,19 +24,19 @@ type WorkflowTemplateContext struct {
}
type EntityMapping struct {
- TableName string
- HumanSingular string
- HumanPlural string
+ TableName string
+ HumanSingular string
+ HumanPlural string
IdentifierField string
- TitleField string
- UpdatedAtField string
- AssigneeField string
- PriorityField string
- DueDateField string
- TeamField string
- LabelField string
- EstimateField string
- StateField string
+ TitleField string
+ UpdatedAtField string
+ AssigneeField string
+ PriorityField string
+ DueDateField string
+ TeamField string
+ LabelField string
+ EstimateField string
+ StateField string
}
func MapEntities(s *spec.APISpec, domain profiler.DomainSignals) WorkflowTemplateContext {
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 2c7e6b15..6649d26e 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -15,6 +15,8 @@ import (
"github.com/mvanhorn/cli-printing-press/internal/naming"
"github.com/mvanhorn/cli-printing-press/internal/profiler"
"github.com/mvanhorn/cli-printing-press/internal/spec"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
)
//go:embed templates
@@ -54,7 +56,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
templates: make(map[string]*template.Template),
}
g.funcs = template.FuncMap{
- "title": strings.Title,
+ "title": cases.Title(language.English).String,
"lower": strings.ToLower,
"upper": strings.ToUpper,
"join": strings.Join,
@@ -428,7 +430,7 @@ func (g *Generator) renderTemplate(tmplName, outPath string, data any) error {
if err != nil {
return fmt.Errorf("creating %s: %w", fullPath, err)
}
- defer f.Close()
+ defer func() { _ = f.Close() }()
if err := tmpl.Execute(f, data); err != nil {
return fmt.Errorf("executing template %s: %w", tmplName, err)
diff --git a/internal/generator/readme_augment.go b/internal/generator/readme_augment.go
index 0515d004..7d7fbe7c 100644
--- a/internal/generator/readme_augment.go
+++ b/internal/generator/readme_augment.go
@@ -72,7 +72,7 @@ func AugmentREADME(readmePath, evidenceDir string) error {
resName := strings.TrimSuffix(e.Name(), "-help.txt")
output, err := os.ReadFile(filepath.Join(resDir, e.Name()))
if err == nil && len(output) > 0 {
- section.WriteString(fmt.Sprintf("### %s\n\n```\n", resName))
+ fmt.Fprintf(§ion, "### %s\n\n```\n", resName)
section.Write(output)
section.WriteString("```\n\n")
}
@@ -90,7 +90,7 @@ func AugmentREADME(readmePath, evidenceDir string) error {
output, err := os.ReadFile(filepath.Join(tier2Dir, e.Name()))
if err == nil && len(output) > 0 {
cmdName := strings.TrimSuffix(e.Name(), ".txt")
- section.WriteString(fmt.Sprintf("#### %s\n\n```json\n", cmdName))
+ fmt.Fprintf(§ion, "#### %s\n\n```json\n", cmdName)
// Truncate very long output
s := string(output)
if len(s) > 2000 {
diff --git a/internal/generator/validate_test.go b/internal/generator/validate_test.go
new file mode 100644
index 00000000..5f9498fb
--- /dev/null
+++ b/internal/generator/validate_test.go
@@ -0,0 +1,34 @@
+package generator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGoBuildCacheDirIsShared(t *testing.T) {
+ // Two different project directories should get the same cache dir.
+ // This is critical for CI performance — shared cache avoids each
+ // parallel test recompiling the Go standard library from scratch.
+ dir1, err := goBuildCacheDir("/tmp/project-a")
+ require.NoError(t, err)
+
+ dir2, err := goBuildCacheDir("/tmp/project-b")
+ require.NoError(t, err)
+
+ assert.Equal(t, dir1, dir2, "different projects should share the same build cache")
+}
+
+func TestGoBuildCacheDirPath(t *testing.T) {
+ dir, err := goBuildCacheDir("/tmp/any-project")
+ require.NoError(t, err)
+
+ home, err := os.UserHomeDir()
+ require.NoError(t, err)
+
+ expected := filepath.Join(home, ".cache", "printing-press", "go-build")
+ assert.Equal(t, expected, dir)
+}
diff --git a/internal/graphql/parser.go b/internal/graphql/parser.go
index 74780269..64859559 100644
--- a/internal/graphql/parser.go
+++ b/internal/graphql/parser.go
@@ -10,6 +10,8 @@ import (
"unicode"
"github.com/mvanhorn/cli-printing-press/internal/spec"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
)
var (
@@ -395,7 +397,7 @@ func mutationDescription(action, entityName string) string {
case "delete":
return "Delete a " + strings.ToLower(entityName)
default:
- return strings.Title(action) + " " + strings.ToLower(entityName)
+ return cases.Title(language.English).String(action) + " " + strings.ToLower(entityName)
}
}
diff --git a/internal/llm/llm.go b/internal/llm/llm.go
index 1ee46610..8ee50201 100644
--- a/internal/llm/llm.go
+++ b/internal/llm/llm.go
@@ -25,7 +25,7 @@ func Run(prompt string) (string, error) {
if err := os.WriteFile(tmpFile, []byte(prompt), 0644); err != nil {
return "", fmt.Errorf("writing prompt: %w", err)
}
- defer os.Remove(tmpFile)
+ defer func() { _ = os.Remove(tmpFile) }()
// Try claude first (-p / --print mode, prompt as positional arg)
if path, err := exec.LookPath("claude"); err == nil {
diff --git a/internal/llmpolish/prompts.go b/internal/llmpolish/prompts.go
index bb839266..3be7915c 100644
--- a/internal/llmpolish/prompts.go
+++ b/internal/llmpolish/prompts.go
@@ -30,7 +30,7 @@ func buildHelpPrompt(outputDir string) string {
b.WriteString("Rules: under 80 chars, starts with a verb, no API jargon.\n\n")
b.WriteString("Current:\n")
for _, cmd := range commands {
- b.WriteString(fmt.Sprintf("- %s: %q\n", cmd.Name, cmd.Description))
+ fmt.Fprintf(&b, "- %s: %q\n", cmd.Name, cmd.Description)
}
b.WriteString("\nReturn ONLY a JSON array, no other text:\n")
b.WriteString(`[{"command": "example", "description": "Improved description here"}]`)
@@ -50,7 +50,7 @@ func buildExamplePrompt(outputDir string) string {
cliName := filepath.Base(outputDir)
var b strings.Builder
- b.WriteString(fmt.Sprintf("Write 2-3 realistic examples for each command in %s.\n", cliName))
+ fmt.Fprintf(&b, "Write 2-3 realistic examples for each command in %s.\n", cliName)
b.WriteString("Each example should show a real developer workflow with a comment.\n\n")
b.WriteString("Commands:\n")
for _, cmd := range commands {
@@ -66,10 +66,10 @@ func buildExamplePrompt(outputDir string) string {
if path == "" {
path = "/" + strings.ReplaceAll(cmd.Name, " ", "/")
}
- b.WriteString(fmt.Sprintf("- %s (%s %s, flags: %s)\n", cmd.Name, method, path, flags))
+ fmt.Fprintf(&b, "- %s (%s %s, flags: %s)\n", cmd.Name, method, path, flags)
}
b.WriteString("\nReturn ONLY a JSON array, no other text:\n")
- b.WriteString(fmt.Sprintf(`[{"command": "example list", "examples": ["# List all examples\n%s example list --limit 10"]}]`, cliName))
+ fmt.Fprintf(&b, `[{"command": "example list", "examples": ["# List all examples\n%s example list --limit 10"]}]`, cliName)
b.WriteString("\n")
return b.String()
@@ -88,9 +88,9 @@ func buildREADMEPrompt(outputDir, apiName string) string {
}
var b strings.Builder
- b.WriteString(fmt.Sprintf("Rewrite this CLI README to sell the tool to developers.\n\n"))
- b.WriteString(fmt.Sprintf("API: %s\n", apiName))
- b.WriteString(fmt.Sprintf("Current README:\n%s\n\n", string(content)))
+ b.WriteString("Rewrite this CLI README to sell the tool to developers.\n\n")
+ fmt.Fprintf(&b, "API: %s\n", apiName)
+ fmt.Fprintf(&b, "Current README:\n%s\n\n", string(content))
b.WriteString("Write a README with:\n")
b.WriteString("1. One-line hook that makes developers want to install it\n")
b.WriteString("2. Why this exists (what gap it fills)\n")
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 64b3f106..74ab49d7 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -13,6 +13,8 @@ import (
"github.com/getkin/kin-openapi/openapi3"
"github.com/mvanhorn/cli-printing-press/internal/spec"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
)
const (
@@ -423,13 +425,11 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
var targetEndpoints map[string]spec.Endpoint
targetResourceName := primaryName
if subName != "" {
- sub, ok := resource.SubResources[subName]
- if !ok {
- sub = spec.Resource{
+ if _, ok := resource.SubResources[subName]; !ok {
+ resource.SubResources[subName] = spec.Resource{
Description: tagDescriptions[subName],
Endpoints: map[string]spec.Endpoint{},
}
- resource.SubResources[subName] = sub
}
targetEndpoints = resource.SubResources[subName].Endpoints
targetResourceName = subName
@@ -721,20 +721,6 @@ func mapTagDescriptions(tags openapi3.Tags) map[string]string {
return out
}
-func resourceDescription(op *openapi3.Operation, tagDescriptions map[string]string) string {
- if op == nil {
- return ""
- }
- for _, tag := range op.Tags {
- for _, key := range tagDescriptionKeys(tag) {
- if desc := tagDescriptions[key]; desc != "" {
- return desc
- }
- }
- }
- return ""
-}
-
func tagDescriptionKeys(name string) []string {
name = strings.TrimSpace(name)
if name == "" {
@@ -1358,11 +1344,6 @@ func firstJSONMediaType(content openapi3.Content) *openapi3.MediaType {
return nil
}
-func resourceNameFromPath(path, basePath string) string {
- primary, _ := resourceAndSubFromPath(path, basePath, nil)
- return primary
-}
-
func resourceAndSubFromPath(path, basePath string, commonPrefix []string) (string, string) {
segments := pathSegmentsAfterBase(path, basePath)
if len(commonPrefix) > 0 && hasSegmentPrefix(segments, commonPrefix) {
@@ -1747,7 +1728,6 @@ func toSnakeCase(input string) string {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
if unicode.IsUpper(r) && i > 0 && (unicode.IsLower(prev) || unicode.IsDigit(prev)) && !lastUnderscore {
b.WriteByte('_')
- lastUnderscore = true
}
b.WriteRune(unicode.ToLower(r))
lastUnderscore = false
@@ -2050,15 +2030,6 @@ func isRequired(required map[string]struct{}, name string) bool {
return ok
}
-func firstNonEmpty(values ...string) string {
- for _, value := range values {
- if value != "" {
- return value
- }
- }
- return ""
-}
-
func selectDescription(summary, description string) string {
summaryHasSpaces := summary != "" && strings.ContainsAny(summary, " \t")
@@ -2195,7 +2166,7 @@ func humanizeConcatenated(s string) string {
rest := lower[len(prefix):]
words := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(toSnakeCase(rest), "_", " "), "-", " "))
if len(words) > 0 {
- sentence := strings.Title(prefix) + " " + strings.Join(words, " ")
+ sentence := cases.Title(language.English).String(prefix) + " " + strings.Join(words, " ")
return sentence
}
}
diff --git a/internal/pipeline/comparative.go b/internal/pipeline/comparative.go
index 5cae53e6..1fc58c8a 100644
--- a/internal/pipeline/comparative.go
+++ b/internal/pipeline/comparative.go
@@ -184,18 +184,18 @@ func writeComparativeReport(result *ComparativeResult, research *ResearchResult,
var b strings.Builder
b.WriteString("# Comparative Analysis\n\n")
- b.WriteString(fmt.Sprintf("**Recommendation: %s**\n\n", strings.ToUpper(result.Recommendation)))
- b.WriteString(fmt.Sprintf("Our score: %d/100\n\n", result.OurScore))
+ fmt.Fprintf(&b, "**Recommendation: %s**\n\n", strings.ToUpper(result.Recommendation))
+ fmt.Fprintf(&b, "Our score: %d/100\n\n", result.OurScore)
// Score table
if len(result.Alternatives) > 0 {
b.WriteString("## Score Table\n\n")
b.WriteString("| Tool | Breadth | Install | Auth | Output | Agent | Fresh | Total |\n")
b.WriteString("|------|---------|---------|------|--------|-------|-------|-------|\n")
- b.WriteString(fmt.Sprintf("| **Ours** | 20 | 15 | 15 | 15 | 15 | 15 | **%d** |\n", result.OurScore))
+ fmt.Fprintf(&b, "| **Ours** | 20 | 15 | 15 | 15 | 15 | 15 | **%d** |\n", result.OurScore)
for _, a := range result.Alternatives {
- b.WriteString(fmt.Sprintf("| %s | %d | %d | %d | %d | %d | %d | **%d** |\n",
- a.Name, a.Breadth, a.InstallFriction, a.AuthUX, a.OutputFormats, a.AgentFriendly, a.Freshness, a.Total))
+ fmt.Fprintf(&b, "| %s | %d | %d | %d | %d | %d | %d | **%d** |\n",
+ a.Name, a.Breadth, a.InstallFriction, a.AuthUX, a.OutputFormats, a.AgentFriendly, a.Freshness, a.Total)
}
b.WriteString("\n")
} else {
@@ -205,20 +205,20 @@ func writeComparativeReport(result *ComparativeResult, research *ResearchResult,
// Advantages
b.WriteString("## Our Advantages\n\n")
for _, a := range result.Advantages {
- b.WriteString(fmt.Sprintf("- %s\n", a))
+ fmt.Fprintf(&b, "- %s\n", a)
}
b.WriteString("\n")
// Gaps
b.WriteString("## Gaps to Address\n\n")
for _, g := range result.Gaps {
- b.WriteString(fmt.Sprintf("- %s\n", g))
+ fmt.Fprintf(&b, "- %s\n", g)
}
b.WriteString("\n")
// Novelty context from research
if research != nil && research.NoveltyScore > 0 {
- b.WriteString(fmt.Sprintf("## Research Context\n\nNovelty score: %d/10 (%s)\n", research.NoveltyScore, research.Recommendation))
+ fmt.Fprintf(&b, "## Research Context\n\nNovelty score: %d/10 (%s)\n", research.NoveltyScore, research.Recommendation)
}
return os.WriteFile(filepath.Join(pipelineDir, "comparative-analysis.md"), []byte(b.String()), 0o644)
diff --git a/internal/pipeline/conventions_test.go b/internal/pipeline/conventions_test.go
new file mode 100644
index 00000000..69e5052a
--- /dev/null
+++ b/internal/pipeline/conventions_test.go
@@ -0,0 +1,62 @@
+package pipeline
+
+import (
+ "regexp"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// conventionalCommitPattern validates the format used in PR titles and
+// commit messages. This pattern is mirrored by the PR title GitHub Action
+// (.github/workflows/pr-title.yml) — keep them in sync.
+//
+// Scopes: cli, skills, ci (required on all types).
+// Breaking changes: ! after scope.
+var conventionalCommitPattern = regexp.MustCompile(
+ `^(feat|fix|docs|chore|refactor|test|ci|perf|build|style|revert)` +
+ `\((cli|skills|ci)\)` +
+ `!?` +
+ `: .+`)
+
+func TestConventionalCommitPatternAcceptsValid(t *testing.T) {
+ valid := []string{
+ "feat(cli): add catalog subcommands",
+ "fix(skills): remove repo checkout requirement",
+ "feat(ci): add release-please",
+ "feat(cli)!: rename catalog command to registry",
+ "docs(cli): update version flag examples",
+ "chore(ci): bump dependencies",
+ "refactor(cli): extract helper function",
+ "test(cli): add coverage for edge case",
+ "fix(cli)!: breaking change with bang",
+ "ci(ci): update workflow",
+ }
+
+ for _, msg := range valid {
+ t.Run(msg, func(t *testing.T) {
+ assert.Regexp(t, conventionalCommitPattern, msg)
+ })
+ }
+}
+
+func TestConventionalCommitPatternRejectsInvalid(t *testing.T) {
+ invalid := []string{
+ "Add new feature",
+ "updated the readme",
+ "WIP stuff",
+ "FEAT(cli): wrong case",
+ "feat:missing space",
+ "feat(): empty scope",
+ "feat: missing scope",
+ "docs: missing scope",
+ "feat(random): invalid scope",
+ "fix(anything): invalid scope",
+ }
+
+ for _, msg := range invalid {
+ t.Run(msg, func(t *testing.T) {
+ assert.NotRegexp(t, conventionalCommitPattern, msg)
+ })
+ }
+}
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index ca28e653..d971bedb 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -579,7 +579,7 @@ func checkExamples(dir string) ExampleCheckResult {
result.Detail = fmt.Sprintf("could not build CLI binary: %v", err)
return result
}
- defer os.Remove(binaryPath)
+ defer func() { _ = os.Remove(binaryPath) }()
// Get global flags from root --help
globalOut, err := runDogfoodCmd(binaryPath, 15*time.Second, "--help")
diff --git a/internal/pipeline/fullrun.go b/internal/pipeline/fullrun.go
index 80edab38..15b01f19 100644
--- a/internal/pipeline/fullrun.go
+++ b/internal/pipeline/fullrun.go
@@ -186,7 +186,7 @@ func MakeBestCLI(apiName, level, specFlag, specURL, outputDir, pressBinary strin
result.DogfoodError = fmt.Sprintf("build failed: %v", buildErr)
result.Errors = append(result.Errors, fmt.Sprintf("dogfood build: %v", buildErr))
} else {
- defer os.Remove(cliBinaryPath)
+ defer func() { _ = os.Remove(cliBinaryPath) }()
dogfood, dogErr := RunDogfood(workingDir, qualitySpecPath)
if dogErr != nil {
result.DogfoodError = dogErr.Error()
@@ -289,7 +289,7 @@ func readSpecBytes(specURL string) ([]byte, error) {
if err != nil {
return nil, err
}
- defer resp.Body.Close()
+ defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, specURL)
}
@@ -374,9 +374,9 @@ func PrintComparisonTable(results []*FullRunResult) string {
b.WriteString("=== CLI Printing Press - Full Run Comparison ===\n\n")
// Header
- b.WriteString(fmt.Sprintf("%-25s", "Metric"))
+ fmt.Fprintf(&b, "%-25s", "Metric")
for _, r := range results {
- b.WriteString(fmt.Sprintf("| %-18s", r.APIName+" ("+r.Level+")"))
+ fmt.Fprintf(&b, "| %-18s", r.APIName+" ("+r.Level+")")
}
b.WriteString("|\n")
@@ -532,9 +532,9 @@ func PrintComparisonTable(results []*FullRunResult) string {
}
func writeRow(b *strings.Builder, label string, results []*FullRunResult, fn func(*FullRunResult) string) {
- b.WriteString(fmt.Sprintf("%-25s", label))
+ fmt.Fprintf(b, "%-25s", label)
for _, r := range results {
- b.WriteString(fmt.Sprintf("| %-18s", fn(r)))
+ fmt.Fprintf(b, "| %-18s", fn(r))
}
b.WriteString("|\n")
}
@@ -548,7 +548,7 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
var b strings.Builder
b.WriteString("# Learnings Plan - CLI Printing Press Full Run\n\n")
- b.WriteString(fmt.Sprintf("Generated: %s\n\n", time.Now().Format("2006-01-02 15:04:05")))
+ fmt.Fprintf(&b, "Generated: %s\n\n", time.Now().Format("2006-01-02 15:04:05"))
// Summarize runs
b.WriteString("## Runs\n\n")
@@ -557,8 +557,8 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
if len(r.Errors) > 0 {
status = fmt.Sprintf("%d errors", len(r.Errors))
}
- b.WriteString(fmt.Sprintf("- **%s** (%s) - Gates %d/7, Duration %s, Status: %s\n",
- r.APIName, r.Level, r.GatesPassed, r.Duration.Round(time.Second), status))
+ fmt.Fprintf(&b, "- **%s** (%s) - Gates %d/7, Duration %s, Status: %s\n",
+ r.APIName, r.Level, r.GatesPassed, r.Duration.Round(time.Second), status)
}
b.WriteString("\n")
@@ -606,7 +606,7 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
avg := t.sum / t.total
if t.lowCount > 0 {
hasGaps = true
- b.WriteString(fmt.Sprintf("- **%s** - low in %d/%d runs (avg %d/10)\n", name, t.lowCount, t.total, avg))
+ fmt.Fprintf(&b, "- **%s** - low in %d/%d runs (avg %d/10)\n", name, t.lowCount, t.total, avg)
}
}
if !hasGaps {
@@ -623,12 +623,12 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
if t.total == 0 || t.lowCount == 0 {
continue
}
- b.WriteString(fmt.Sprintf("%d. **Improve %s templates** - affects %d/%d APIs tested\n",
- priority, name, t.lowCount, t.total))
+ fmt.Fprintf(&b, "%d. **Improve %s templates** - affects %d/%d APIs tested\n",
+ priority, name, t.lowCount, t.total)
if advice, ok := dimensionAdvice[name]; ok {
// Include first line of advice
lines := strings.SplitN(advice, "\n", 2)
- b.WriteString(fmt.Sprintf(" - %s\n", strings.TrimSpace(lines[0])))
+ fmt.Fprintf(&b, " - %s\n", strings.TrimSpace(lines[0]))
}
priority++
}
@@ -641,7 +641,7 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
b.WriteString("## Gate Failures\n\n")
for _, r := range results {
if r.GatesFailed > 0 {
- b.WriteString(fmt.Sprintf("- **%s** - %d gates failed\n", r.APIName, r.GatesFailed))
+ fmt.Fprintf(&b, "- **%s** - %d gates failed\n", r.APIName, r.GatesFailed)
}
}
allPassed := true
@@ -661,13 +661,13 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
for _, r := range results {
if r.Dogfood != nil {
if r.Dogfood.PathCheck.Tested > 0 {
- b.WriteString(fmt.Sprintf("- **%s** - %s, path validity %d/%d (%d%%)\n",
- r.APIName, r.Dogfood.Verdict, r.Dogfood.PathCheck.Valid, r.Dogfood.PathCheck.Tested, r.Dogfood.PathCheck.Pct))
+ fmt.Fprintf(&b, "- **%s** - %s, path validity %d/%d (%d%%)\n",
+ r.APIName, r.Dogfood.Verdict, r.Dogfood.PathCheck.Valid, r.Dogfood.PathCheck.Tested, r.Dogfood.PathCheck.Pct)
} else {
- b.WriteString(fmt.Sprintf("- **%s** - %s\n", r.APIName, r.Dogfood.Verdict))
+ fmt.Fprintf(&b, "- **%s** - %s\n", r.APIName, r.Dogfood.Verdict)
}
} else {
- b.WriteString(fmt.Sprintf("- **%s** - dogfood not run (%s)\n", r.APIName, r.DogfoodError))
+ fmt.Fprintf(&b, "- **%s** - dogfood not run (%s)\n", r.APIName, r.DogfoodError)
}
}
b.WriteString("\n")
@@ -676,14 +676,14 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
b.WriteString("## Verification Summary\n\n")
for _, r := range results {
if r.Verification != nil {
- b.WriteString(fmt.Sprintf("- **%s** - %s (paths:%d flags:%d ghost:%d fts:%d)\n",
+ fmt.Fprintf(&b, "- **%s** - %s (paths:%d flags:%d ghost:%d fts:%d)\n",
r.APIName, r.Verification.Verdict,
r.Verification.HallucinatedPaths,
r.Verification.DeadFlags,
r.Verification.GhostTables,
- r.Verification.OrphanFTS))
+ r.Verification.OrphanFTS)
} else {
- b.WriteString(fmt.Sprintf("- **%s** - not run (%s)\n", r.APIName, r.VerificationError))
+ fmt.Fprintf(&b, "- **%s** - not run (%s)\n", r.APIName, r.VerificationError)
}
}
b.WriteString("\n")
diff --git a/internal/pipeline/learnings.go b/internal/pipeline/learnings.go
index b75e473a..e7547278 100644
--- a/internal/pipeline/learnings.go
+++ b/internal/pipeline/learnings.go
@@ -9,9 +9,9 @@ import (
// PressLearning records what happened during a single pipeline run.
type PressLearning struct {
- APIName string `json:"api_name"`
- Date time.Time `json:"date"`
- SpecType string `json:"spec_type"`
+ APIName string `json:"api_name"`
+ Date time.Time `json:"date"`
+ SpecType string `json:"spec_type"`
Issues []LearningIssue `json:"issues"`
Fixes []LearningFix `json:"fixes"`
}
diff --git a/internal/pipeline/planner.go b/internal/pipeline/planner.go
index 1af18d1b..bfe4df47 100644
--- a/internal/pipeline/planner.go
+++ b/internal/pipeline/planner.go
@@ -59,7 +59,7 @@ func generateScaffoldPlan(ctx PlanContext) (string, error) {
writePlanHeader(&b, ctx.SeedData.APIName, "scaffold", "Generate the CLI with intelligence from research")
b.WriteString("## Phase Goal\n\n")
- b.WriteString(fmt.Sprintf("Generate the %s CLI from the validated OpenAPI spec, incorporating research insights.\n\n", ctx.SeedData.APIName))
+ fmt.Fprintf(&b, "Generate the %s CLI from the validated OpenAPI spec, incorporating research insights.\n\n", ctx.SeedData.APIName)
b.WriteString("## Context\n\n")
writePipelineContext(&b, ctx.SeedData)
@@ -67,22 +67,22 @@ func generateScaffoldPlan(ctx PlanContext) (string, error) {
// Dynamic section: research insights
if ctx.Research != nil {
b.WriteString("## Research Insights\n\n")
- b.WriteString(fmt.Sprintf("- **Novelty score:** %d/10 (%s)\n", ctx.Research.NoveltyScore, ctx.Research.Recommendation))
- b.WriteString(fmt.Sprintf("- **Alternatives found:** %d\n", len(ctx.Research.Alternatives)))
+ fmt.Fprintf(&b, "- **Novelty score:** %d/10 (%s)\n", ctx.Research.NoveltyScore, ctx.Research.Recommendation)
+ fmt.Fprintf(&b, "- **Alternatives found:** %d\n", len(ctx.Research.Alternatives))
if ctx.Research.CompetitorInsights != nil {
ci := ctx.Research.CompetitorInsights
- b.WriteString(fmt.Sprintf("- **Command target:** %d (based on competitor analysis)\n", ci.CommandTarget))
+ fmt.Fprintf(&b, "- **Command target:** %d (based on competitor analysis)\n", ci.CommandTarget)
if len(ci.UnmetFeatures) > 0 {
b.WriteString("- **Unmet features to include:**\n")
for _, f := range ci.UnmetFeatures[:min(5, len(ci.UnmetFeatures))] {
- b.WriteString(fmt.Sprintf(" - %s\n", f))
+ fmt.Fprintf(&b, " - %s\n", f)
}
}
if len(ci.PainPointsToAvoid) > 0 {
b.WriteString("- **Pain points to avoid:**\n")
for _, p := range ci.PainPointsToAvoid[:min(3, len(ci.PainPointsToAvoid))] {
- b.WriteString(fmt.Sprintf(" - %s\n", p))
+ fmt.Fprintf(&b, " - %s\n", p)
}
}
}
@@ -96,16 +96,16 @@ func generateScaffoldPlan(ctx PlanContext) (string, error) {
if len(suggestions) > 0 {
b.WriteString("Suggested flags based on past issues:\n")
for _, s := range suggestions {
- b.WriteString(fmt.Sprintf("- `%s`\n", s))
+ fmt.Fprintf(&b, "- `%s`\n", s)
}
b.WriteString("\n")
}
}
b.WriteString("## What This Phase Must Produce\n\n")
- b.WriteString(fmt.Sprintf("- Generated CLI source tree in %s\n", ctx.SeedData.OutputDir))
+ fmt.Fprintf(&b, "- Generated CLI source tree in %s\n", ctx.SeedData.OutputDir)
b.WriteString("- All seven generator quality gates passing\n")
- b.WriteString(fmt.Sprintf("- Working CLI binary for %s\n\n", ctx.SeedData.APIName))
+ fmt.Fprintf(&b, "- Working CLI binary for %s\n\n", ctx.SeedData.APIName)
b.WriteString("## Codebase Pointers\n\n")
b.WriteString("- Generator entrypoint: printing-press generate --spec <url> --output <dir>\n")
@@ -130,17 +130,17 @@ func generateEnrichPlan(ctx PlanContext) (string, error) {
if len(ci.UnmetFeatures) > 0 {
b.WriteString("Features that competitors requested but never got (add these):\n")
for _, f := range ci.UnmetFeatures {
- b.WriteString(fmt.Sprintf("- [ ] %s\n", f))
+ fmt.Fprintf(&b, "- [ ] %s\n", f)
}
b.WriteString("\n")
}
if ci.CommandTarget > 0 {
- b.WriteString(fmt.Sprintf("**Target:** Generate at least %d commands (competitors max: %d)\n\n", ci.CommandTarget, int(float64(ci.CommandTarget)/1.2)))
+ fmt.Fprintf(&b, "**Target:** Generate at least %d commands (competitors max: %d)\n\n", ci.CommandTarget, int(float64(ci.CommandTarget)/1.2))
}
}
b.WriteString("## What This Phase Must Produce\n\n")
- b.WriteString(fmt.Sprintf("- overlay.yaml in %s\n", ctx.SeedData.PipelineDir))
+ fmt.Fprintf(&b, "- overlay.yaml in %s\n", ctx.SeedData.PipelineDir)
b.WriteString("- At least one verified enrichment\n")
b.WriteString("- Overlay valid for downstream merge and regeneration\n\n")
@@ -168,9 +168,9 @@ func generateReviewPlan(ctx PlanContext) (string, error) {
b.WriteString("5. If any major dimension still fails, generate fix plans\n\n")
b.WriteString("## What This Phase Must Produce\n\n")
- b.WriteString(fmt.Sprintf("- dogfood-results.json in %s\n", ctx.SeedData.PipelineDir))
- b.WriteString(fmt.Sprintf("- scorecard.md in %s\n", ctx.SeedData.PipelineDir))
- b.WriteString(fmt.Sprintf("- review.md in %s\n", ctx.SeedData.PipelineDir))
+ fmt.Fprintf(&b, "- dogfood-results.json in %s\n", ctx.SeedData.PipelineDir)
+ fmt.Fprintf(&b, "- scorecard.md in %s\n", ctx.SeedData.PipelineDir)
+ fmt.Fprintf(&b, "- review.md in %s\n", ctx.SeedData.PipelineDir)
b.WriteString("- Fix plans for any low-scoring dimensions\n")
return b.String(), nil
@@ -187,18 +187,18 @@ func generateComparativePlan(ctx PlanContext) (string, error) {
if ctx.Scorecard != nil {
b.WriteString("## Current Steinberger Score\n\n")
- b.WriteString(fmt.Sprintf("- Overall: %d%% (%s)\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade))
+ fmt.Fprintf(&b, "- Overall: %d%% (%s)\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade)
if len(ctx.Scorecard.GapReport) > 0 {
b.WriteString("- Gaps:\n")
for _, g := range ctx.Scorecard.GapReport {
- b.WriteString(fmt.Sprintf(" - %s\n", g))
+ fmt.Fprintf(&b, " - %s\n", g)
}
}
b.WriteString("\n")
}
b.WriteString("## What This Phase Must Produce\n\n")
- b.WriteString(fmt.Sprintf("- comparative-analysis.md in %s\n\n", ctx.SeedData.PipelineDir))
+ fmt.Fprintf(&b, "- comparative-analysis.md in %s\n\n", ctx.SeedData.PipelineDir)
return b.String(), nil
}
@@ -216,17 +216,17 @@ func generateShipPlan(ctx PlanContext) (string, error) {
if ctx.Scorecard != nil {
b.WriteString("## Ship Decision\n\n")
if ctx.Scorecard.Steinberger.Percentage >= 65 {
- b.WriteString(fmt.Sprintf("**SHIP** - Quality score %d%% (grade %s) meets threshold.\n\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade))
+ fmt.Fprintf(&b, "**SHIP** - Quality score %d%% (grade %s) meets threshold.\n\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade)
} else {
- b.WriteString(fmt.Sprintf("**HOLD** - Quality score %d%% (grade %s) is below 65%% threshold.\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade))
+ fmt.Fprintf(&b, "**HOLD** - Quality score %d%% (grade %s) is below 65%% threshold.\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade)
b.WriteString("Fix the gaps identified in the scorecard before shipping.\n\n")
}
}
b.WriteString("## What This Phase Must Produce\n\n")
- b.WriteString(fmt.Sprintf("- Git repository initialized in %s\n", ctx.SeedData.OutputDir))
+ fmt.Fprintf(&b, "- Git repository initialized in %s\n", ctx.SeedData.OutputDir)
b.WriteString("- GoReleaser config validated\n")
- b.WriteString(fmt.Sprintf("- Morning report in %s\n", ctx.SeedData.PipelineDir))
+ fmt.Fprintf(&b, "- Morning report in %s\n", ctx.SeedData.PipelineDir)
return b.String(), nil
}
@@ -235,20 +235,20 @@ func generateShipPlan(ctx PlanContext) (string, error) {
func writePlanHeader(b *strings.Builder, apiName, phase, title string) {
b.WriteString("---\n")
- b.WriteString(fmt.Sprintf("title: \"%s CLI Pipeline - %s\"\n", apiName, title))
+ fmt.Fprintf(b, "title: \"%s CLI Pipeline - %s\"\n", apiName, title)
b.WriteString("type: feat\n")
b.WriteString("status: seed\n")
- b.WriteString(fmt.Sprintf("pipeline_phase: %s\n", phase))
- b.WriteString(fmt.Sprintf("pipeline_api: %s\n", apiName))
- b.WriteString(fmt.Sprintf("date: %s\n", time.Now().Format("2006-01-02")))
+ fmt.Fprintf(b, "pipeline_phase: %s\n", phase)
+ fmt.Fprintf(b, "pipeline_api: %s\n", apiName)
+ fmt.Fprintf(b, "date: %s\n", time.Now().Format("2006-01-02"))
b.WriteString("---\n\n")
}
func writePipelineContext(b *strings.Builder, sd SeedData) {
b.WriteString("## Context\n\n")
- b.WriteString(fmt.Sprintf("- Pipeline directory: %s\n", sd.PipelineDir))
- b.WriteString(fmt.Sprintf("- Output directory: %s\n", sd.OutputDir))
- b.WriteString(fmt.Sprintf("- Spec URL: %s\n\n", sd.SpecURL))
+ fmt.Fprintf(b, "- Pipeline directory: %s\n", sd.PipelineDir)
+ fmt.Fprintf(b, "- Output directory: %s\n", sd.OutputDir)
+ fmt.Fprintf(b, "- Spec URL: %s\n\n", sd.SpecURL)
}
func loadResearchForPlanState(state *PipelineState) (*ResearchResult, error) {
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index 7bcc2f5a..637cb767 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -162,7 +162,7 @@ func CopyDir(src, dst string) error {
return err
}
- return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
+ return filepath.Walk(src, func(path string, _ os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
@@ -176,7 +176,7 @@ func CopyDir(src, dst string) error {
}
target := filepath.Join(dst, rel)
- info, err = os.Lstat(path)
+ info, err := os.Lstat(path)
if err != nil {
return err
}
@@ -206,13 +206,13 @@ func copyFile(src, dst string, mode os.FileMode) error {
if err != nil {
return err
}
- defer in.Close()
+ defer func() { _ = in.Close() }()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
- defer out.Close()
+ defer func() { _ = out.Close() }()
if _, err := io.Copy(out, in); err != nil {
return err
diff --git a/internal/pipeline/remediate.go b/internal/pipeline/remediate.go
index cdfc78f0..9a26f621 100644
--- a/internal/pipeline/remediate.go
+++ b/internal/pipeline/remediate.go
@@ -150,55 +150,6 @@ func removeDeadFlags(dir string, deadFlags []string) error {
return os.WriteFile(rootPath, []byte(content), 0o644)
}
-func removeDeadFunctions(dir string, deadFuncs []string) error {
- helpersPath := filepath.Join(dir, "internal", "cli", "helpers.go")
- data, err := os.ReadFile(helpersPath)
- if os.IsNotExist(err) {
- return nil
- }
- if err != nil {
- return fmt.Errorf("reading helpers.go: %w", err)
- }
-
- lines := strings.Split(string(data), "\n")
-
- for _, funcName := range deadFuncs {
- funcSig := "func " + funcName + "("
- startIdx := -1
- for i, line := range lines {
- if strings.Contains(line, funcSig) {
- startIdx = i
- for startIdx > 0 && strings.HasPrefix(strings.TrimSpace(lines[startIdx-1]), "//") {
- startIdx--
- }
- break
- }
- }
- if startIdx == -1 {
- continue
- }
-
- braceCount := 0
- endIdx := -1
- for i := startIdx; i < len(lines); i++ {
- braceCount += strings.Count(lines[i], "{") - strings.Count(lines[i], "}")
- if braceCount > 0 || strings.Contains(lines[i], "{") {
- if braceCount == 0 && i > startIdx {
- endIdx = i
- break
- }
- }
- }
- if endIdx == -1 {
- continue
- }
-
- lines = append(lines[:startIdx], lines[endIdx+1:]...)
- }
-
- return os.WriteFile(helpersPath, []byte(strings.Join(lines, "\n")), 0o644)
-}
-
func removeGhostTables(dir string, ghostTables []string) error {
storePath := filepath.Join(dir, "internal", "store", "store.go")
data, err := os.ReadFile(storePath)
diff --git a/internal/pipeline/research.go b/internal/pipeline/research.go
index c230236b..6ca34013 100644
--- a/internal/pipeline/research.go
+++ b/internal/pipeline/research.go
@@ -19,14 +19,14 @@ import (
// ResearchResult holds the output of the research phase.
type ResearchResult struct {
- APIName string `json:"api_name"`
- NoveltyScore int `json:"novelty_score"` // 1-10
- Alternatives []Alternative `json:"alternatives"`
- Gaps []string `json:"gaps"` // what alternatives miss
- Patterns []string `json:"patterns"` // what alternatives do well
- Recommendation string `json:"recommendation"` // "proceed", "proceed-with-gaps", "skip"
- ResearchedAt time.Time `json:"researched_at"`
- CompetitorInsights *CompetitorInsights `json:"competitor_insights,omitempty"`
+ APIName string `json:"api_name"`
+ NoveltyScore int `json:"novelty_score"` // 1-10
+ Alternatives []Alternative `json:"alternatives"`
+ Gaps []string `json:"gaps"` // what alternatives miss
+ Patterns []string `json:"patterns"` // what alternatives do well
+ Recommendation string `json:"recommendation"` // "proceed", "proceed-with-gaps", "skip"
+ ResearchedAt time.Time `json:"researched_at"`
+ CompetitorInsights *CompetitorInsights `json:"competitor_insights,omitempty"`
}
// CompetitorAnalysis holds intelligence gathered from a single competitor repo.
@@ -217,7 +217,7 @@ func searchGitHubCLIs(apiName string) ([]Alternative, error) {
if err != nil {
return nil, err
}
- defer resp.Body.Close()
+ defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
@@ -407,8 +407,8 @@ type ghIssue struct {
// ghPull models a GitHub pull request from the API.
type ghPull struct {
- Title string `json:"title"`
- HTMLURL string `json:"html_url"`
+ Title string `json:"title"`
+ HTMLURL string `json:"html_url"`
MergedAt *string `json:"merged_at"`
}
@@ -495,7 +495,7 @@ func fetchIssues(client *http.Client, owner, repo, labels string) ([]ghIssue, er
if err != nil {
return nil, err
}
- defer resp.Body.Close()
+ defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
@@ -521,7 +521,7 @@ func fetchReadme(client *http.Client, owner, repo string) (string, error) {
if err != nil {
return "", err
}
- defer resp.Body.Close()
+ defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
return "", fmt.Errorf("GitHub API returned %d", resp.StatusCode)
@@ -555,7 +555,7 @@ func fetchAbandonedPRs(client *http.Client, owner, repo string) ([]ghPull, error
if err != nil {
return nil, err
}
- defer resp.Body.Close()
+ defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
diff --git a/internal/pipeline/runtime.go b/internal/pipeline/runtime.go
index 7454662a..7c9aba3a 100644
--- a/internal/pipeline/runtime.go
+++ b/internal/pipeline/runtime.go
@@ -376,7 +376,7 @@ func runDataPipelineTest(binary, mode string, envFn func() []string) bool {
if err != nil {
return false
}
- defer os.RemoveAll(tmpDir)
+ defer func() { _ = os.RemoveAll(tmpDir) }()
dbPath := filepath.Join(tmpDir, "test.db")
env = append(env, "HOME="+tmpDir) // so sync uses temp location
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index f81323a2..039915f6 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -1765,10 +1765,10 @@ func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
}
var b strings.Builder
- b.WriteString(fmt.Sprintf("# Scorecard: %s\n\n", sc.APIName))
- b.WriteString(fmt.Sprintf("**Overall Grade: %s** (%d%%)\n\n", sc.OverallGrade, sc.Steinberger.Percentage))
+ fmt.Fprintf(&b, "# Scorecard: %s\n\n", sc.APIName)
+ fmt.Fprintf(&b, "**Overall Grade: %s** (%d%%)\n\n", sc.OverallGrade, sc.Steinberger.Percentage)
if len(sc.UnscoredDimensions) > 0 {
- b.WriteString(fmt.Sprintf("Unscored dimensions omitted from the total denominator: %s\n\n", strings.Join(sc.UnscoredDimensions, ", ")))
+ fmt.Fprintf(&b, "Unscored dimensions omitted from the total denominator: %s\n\n", strings.Join(sc.UnscoredDimensions, ", "))
}
// Steinberger dimensions table
@@ -1800,11 +1800,11 @@ func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
}
for _, d := range dimensions {
if sc.IsDimensionUnscored(d.nameKey) {
- b.WriteString(fmt.Sprintf("| %s | N/A |\n", d.name))
+ fmt.Fprintf(&b, "| %s | N/A |\n", d.name)
continue
}
bar := strings.Repeat("#", d.score) + strings.Repeat(".", 10-d.score)
- b.WriteString(fmt.Sprintf("| %s | %d/10 %s |\n", d.name, d.score, bar))
+ fmt.Fprintf(&b, "| %s | %d/10 %s |\n", d.name, d.score, bar)
}
typeDimensions := []struct {
name string
@@ -1815,9 +1815,9 @@ func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
}
for _, d := range typeDimensions {
bar := strings.Repeat("#", d.score) + strings.Repeat(".", 5-d.score)
- b.WriteString(fmt.Sprintf("| %s | %d/5 %s |\n", d.name, d.score, bar))
+ fmt.Fprintf(&b, "| %s | %d/5 %s |\n", d.name, d.score, bar)
}
- b.WriteString(fmt.Sprintf("| **Total** | **%d/100** |\n\n", s.Total))
+ fmt.Fprintf(&b, "| **Total** | **%d/100** |\n\n", s.Total)
// Competitor comparison
if len(sc.CompetitorScores) > 0 {
@@ -1829,7 +1829,7 @@ func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
if cs.WeWin {
winner = "Us"
}
- b.WriteString(fmt.Sprintf("| %s | %d | %d | %s |\n", cs.Name, cs.OurScore, cs.TheirScore, winner))
+ fmt.Fprintf(&b, "| %s | %d | %d | %s |\n", cs.Name, cs.OurScore, cs.TheirScore, winner)
}
b.WriteString("\n")
}
@@ -1838,7 +1838,7 @@ func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
if len(sc.GapReport) > 0 {
b.WriteString("## Gaps\n\n")
for _, g := range sc.GapReport {
- b.WriteString(fmt.Sprintf("- %s\n", g))
+ fmt.Fprintf(&b, "- %s\n", g)
}
b.WriteString("\n")
}
diff --git a/internal/pipeline/selfimprove.go b/internal/pipeline/selfimprove.go
index a2b5c28c..c26f8b63 100644
--- a/internal/pipeline/selfimprove.go
+++ b/internal/pipeline/selfimprove.go
@@ -126,17 +126,17 @@ func GenerateFixPlans(scorecard *Scorecard, pipelineDir string) ([]string, error
func buildFixPlan(apiName, dimension string, currentScore int) string {
var b strings.Builder
- b.WriteString(fmt.Sprintf("# Fix Plan: %s\n\n", dimension))
- b.WriteString(fmt.Sprintf("**API:** %s\n", apiName))
- b.WriteString(fmt.Sprintf("**Current Score:** %d/10\n", currentScore))
- b.WriteString(fmt.Sprintf("**Target Score:** 8/10\n\n"))
+ fmt.Fprintf(&b, "# Fix Plan: %s\n\n", dimension)
+ fmt.Fprintf(&b, "**API:** %s\n", apiName)
+ fmt.Fprintf(&b, "**Current Score:** %d/10\n", currentScore)
+ b.WriteString("**Target Score:** 8/10\n\n")
// Templates involved
templates := templateMapping[dimension]
if len(templates) > 0 {
b.WriteString("## Templates to Modify\n\n")
for _, t := range templates {
- b.WriteString(fmt.Sprintf("- `templates/%s`\n", t))
+ fmt.Fprintf(&b, "- `templates/%s`\n", t)
}
} else {
b.WriteString("## Templates to Create\n\n")
@@ -157,9 +157,9 @@ func buildFixPlan(apiName, dimension string, currentScore int) string {
b.WriteString("## Verification\n\n")
b.WriteString("After applying changes, re-run the scorecard:\n\n")
b.WriteString("```bash\n")
- b.WriteString(fmt.Sprintf("printing-press scorecard --api %s\n", apiName))
+ fmt.Fprintf(&b, "printing-press scorecard --api %s\n", apiName)
b.WriteString("```\n\n")
- b.WriteString(fmt.Sprintf("The %s dimension should score at least 8/10.\n", dimension))
+ fmt.Fprintf(&b, "The %s dimension should score at least 8/10.\n", dimension)
return b.String()
}
diff --git a/internal/pipeline/spec_summary.go b/internal/pipeline/spec_summary.go
deleted file mode 100644
index 3d865f26..00000000
--- a/internal/pipeline/spec_summary.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package pipeline
-
-import (
- "encoding/json"
- "sort"
- "strings"
-
- apispec "github.com/mvanhorn/cli-printing-press/internal/spec"
-)
-
-type specSummary struct {
- Paths []string
- Auth apispec.AuthConfig
-}
-
-func loadSpecSummary(specPath string) (*specSummary, error) {
- if specPath == "" {
- return nil, nil
- }
-
- data, err := readSpecBytes(specPath)
- if err != nil {
- return nil, err
- }
-
- if summary, err := summarizeOpenAPILike(data); err == nil && summary != nil {
- return summary, nil
- }
-
- if parsed, err := apispec.ParseBytes(data); err == nil {
- return summarizeSpec(parsed), nil
- }
-
- return nil, nil
-}
-
-func summarizeSpec(parsed *apispec.APISpec) *specSummary {
- if parsed == nil {
- return nil
- }
-
- paths := collectSpecPaths(parsed.Resources)
- sort.Strings(paths)
-
- return &specSummary{
- Paths: paths,
- Auth: parsed.Auth,
- }
-}
-
-func collectSpecPaths(resources map[string]apispec.Resource) []string {
- if len(resources) == 0 {
- return nil
- }
-
- paths := make(map[string]struct{})
- var walk func(map[string]apispec.Resource)
- walk = func(resources map[string]apispec.Resource) {
- for _, resource := range resources {
- for _, endpoint := range resource.Endpoints {
- if endpoint.Path != "" {
- paths[endpoint.Path] = struct{}{}
- }
- }
- if len(resource.SubResources) > 0 {
- walk(resource.SubResources)
- }
- }
- }
- walk(resources)
-
- out := make([]string, 0, len(paths))
- for path := range paths {
- out = append(out, path)
- }
- return out
-}
-
-func summarizeOpenAPILike(data []byte) (*specSummary, error) {
- data, err := ensureJSON(data)
- if err != nil {
- return nil, err
- }
-
- var raw map[string]any
- if err := json.Unmarshal(data, &raw); err != nil {
- return nil, err
- }
-
- pathsRaw, _ := raw["paths"].(map[string]any)
- paths := make([]string, 0, len(pathsRaw))
- for path := range pathsRaw {
- paths = append(paths, path)
- }
- sort.Strings(paths)
-
- auth := summarizeSecuritySchemes(raw)
- if len(paths) == 0 && auth.Type == "" && auth.Format == "" && auth.Header == "" && auth.Scheme == "" && auth.In == "" && len(auth.EnvVars) == 0 {
- return nil, nil
- }
-
- return &specSummary{
- Paths: paths,
- Auth: auth,
- }, nil
-}
-
-func summarizeSecuritySchemes(raw map[string]any) apispec.AuthConfig {
- components, ok := raw["components"].(map[string]any)
- if !ok {
- return apispec.AuthConfig{}
- }
- schemes, ok := components["securitySchemes"].(map[string]any)
- if !ok {
- return apispec.AuthConfig{}
- }
-
- for schemeName, value := range schemes {
- scheme, ok := value.(map[string]any)
- if !ok {
- continue
- }
-
- auth := apispec.AuthConfig{
- Scheme: schemeName,
- Type: stringValue(scheme["type"]),
- Header: stringValue(scheme["name"]),
- In: stringValue(scheme["in"]),
- }
- schemeType := stringValue(scheme["scheme"])
-
- switch {
- case strings.EqualFold(auth.Type, "http") && strings.EqualFold(schemeType, "bearer"):
- auth.Type = "bearer_token"
- auth.Header = "Authorization"
- auth.Format = "Bearer "
- if strings.Contains(strings.ToLower(schemeName), "bot") {
- auth.Format = "Bot {bot_token}"
- }
- case strings.EqualFold(auth.Type, "http") && strings.EqualFold(schemeType, "basic"):
- auth.Type = "api_key"
- auth.Header = "Authorization"
- auth.Format = "Basic "
- case strings.EqualFold(auth.Type, "apikey"):
- if auth.Header == "" {
- auth.Header = "Authorization"
- }
- if strings.Contains(strings.ToLower(schemeName), "bot") && strings.EqualFold(auth.Header, "Authorization") {
- auth.Format = "Bot {bot_token}"
- }
- }
-
- return auth
- }
-
- return apispec.AuthConfig{}
-}
-
-func stringValue(value any) string {
- if s, ok := value.(string); ok {
- return s
- }
- return ""
-}
diff --git a/internal/pipeline/state.go b/internal/pipeline/state.go
index 16e1cadf..bb52b235 100644
--- a/internal/pipeline/state.go
+++ b/internal/pipeline/state.go
@@ -304,7 +304,6 @@ func FindStateByWorkingDir(dir string) (*PipelineState, error) {
func NewState(apiName, outputDir string) *PipelineState {
runID, err := newRunID(time.Now())
if err != nil {
- outputDir = outputDir
runID = fmt.Sprintf("fallback-%d", time.Now().UnixNano())
}
return NewStateWithRun(apiName, outputDir, runID, WorkspaceScope())
diff --git a/internal/pipeline/verify_report.go b/internal/pipeline/verify_report.go
index 68ec0546..3822b84a 100644
--- a/internal/pipeline/verify_report.go
+++ b/internal/pipeline/verify_report.go
@@ -10,12 +10,12 @@ import (
// VerificationReport holds the results of a Proof of Behavior verification run.
type VerificationReport struct {
- Dir string `json:"dir"`
- SpecPath string `json:"spec_path,omitempty"`
- Paths []PathProofResult `json:"paths"`
- Flags []FlagProofResult `json:"flags"`
+ Dir string `json:"dir"`
+ SpecPath string `json:"spec_path,omitempty"`
+ Paths []PathProofResult `json:"paths"`
+ Flags []FlagProofResult `json:"flags"`
Pipeline []PipelineProofResult `json:"pipeline"`
- Auth AuthProofResult `json:"auth"`
+ Auth AuthProofResult `json:"auth"`
HallucinatedPaths int `json:"hallucinated_paths"`
DeadFlags int `json:"dead_flags"`
@@ -158,7 +158,7 @@ func (r *VerificationReport) Markdown() string {
var b strings.Builder
b.WriteString("# Proof of Behavior Report\n\n")
- b.WriteString(fmt.Sprintf("**Verdict: %s**\n\n", r.Verdict))
+ fmt.Fprintf(&b, "**Verdict: %s**\n\n", r.Verdict)
// Path Proof section.
validPaths := 0
@@ -168,14 +168,14 @@ func (r *VerificationReport) Markdown() string {
}
}
b.WriteString("## Path Proof\n")
- b.WriteString(fmt.Sprintf("Tested: %d | Valid: %d | Hallucinated: %d\n\n", len(r.Paths), validPaths, r.HallucinatedPaths))
+ fmt.Fprintf(&b, "Tested: %d | Valid: %d | Hallucinated: %d\n\n", len(r.Paths), validPaths, r.HallucinatedPaths)
if r.HallucinatedPaths > 0 {
b.WriteString("| Path | Status |\n")
b.WriteString("|------|--------|\n")
for _, p := range r.Paths {
if !p.Valid {
- b.WriteString(fmt.Sprintf("| `%s` | INVALID |\n", p.Path))
+ fmt.Fprintf(&b, "| `%s` | INVALID |\n", p.Path)
}
}
b.WriteString("\n")
@@ -183,12 +183,12 @@ func (r *VerificationReport) Markdown() string {
// Flag Proof section.
b.WriteString("## Flag Proof\n")
- b.WriteString(fmt.Sprintf("Total: %d | Dead: %d\n\n", len(r.Flags), r.DeadFlags))
+ fmt.Fprintf(&b, "Total: %d | Dead: %d\n\n", len(r.Flags), r.DeadFlags)
if r.DeadFlags > 0 {
for _, f := range r.Flags {
if f.References == 0 {
- b.WriteString(fmt.Sprintf("- `%s` (0 references)\n", f.Flag))
+ fmt.Fprintf(&b, "- `%s` (0 references)\n", f.Flag)
}
}
b.WriteString("\n")
@@ -196,7 +196,7 @@ func (r *VerificationReport) Markdown() string {
// Pipeline Proof section.
b.WriteString("## Pipeline Proof\n")
- b.WriteString(fmt.Sprintf("Tables: %d | Ghost: %d | Orphan FTS: %d\n\n", len(r.Pipeline), r.GhostTables, r.OrphanFTS))
+ fmt.Fprintf(&b, "Tables: %d | Ghost: %d | Orphan FTS: %d\n\n", len(r.Pipeline), r.GhostTables, r.OrphanFTS)
if len(r.Pipeline) > 0 {
b.WriteString("| Table | WRITE | READ | SEARCH |\n")
@@ -205,20 +205,20 @@ func (r *VerificationReport) Markdown() string {
w := boolMark(p.HasWrite)
rd := boolMark(p.HasRead)
s := boolMark(p.HasSearch)
- b.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n", p.Table, w, rd, s))
+ fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", p.Table, w, rd, s)
}
b.WriteString("\n")
}
// Auth Proof section.
b.WriteString("## Auth Proof\n")
- b.WriteString(fmt.Sprintf("Spec: %s | Generated: %s | Match: %t\n\n", r.Auth.SpecScheme, r.Auth.GeneratedScheme, !r.Auth.Mismatch))
+ fmt.Fprintf(&b, "Spec: %s | Generated: %s | Match: %t\n\n", r.Auth.SpecScheme, r.Auth.GeneratedScheme, !r.Auth.Mismatch)
// Issues section.
if len(r.Issues) > 0 {
b.WriteString("## Issues\n")
for _, issue := range r.Issues {
- b.WriteString(fmt.Sprintf("- %s\n", issue))
+ fmt.Fprintf(&b, "- %s\n", issue)
}
b.WriteString("\n")
}
diff --git a/internal/profiler/apitype.go b/internal/profiler/apitype.go
index 4f7b7d88..1a753199 100644
--- a/internal/profiler/apitype.go
+++ b/internal/profiler/apitype.go
@@ -61,7 +61,7 @@ func readHead(path string, n int) ([]byte, error) {
if err != nil {
return nil, err
}
- defer f.Close()
+ defer func() { _ = f.Close() }()
buf := make([]byte, n)
nr, err := f.Read(buf)
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 7fc52bb2..2fe39854 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -37,9 +37,9 @@ type DomainSignals struct {
// PaginationProfile describes the detected pagination patterns across the API.
type PaginationProfile struct {
- CursorParam string `json:"cursor_param"` // most common cursor param name (after, cursor, page_token, offset)
- PageSizeParam string `json:"page_size_param"` // most common page size param (limit, per_page, page_size, first)
- SinceParam string `json:"since_param"` // temporal filter param (since, updated_after, modified_since)
+ CursorParam string `json:"cursor_param"` // most common cursor param name (after, cursor, page_token, offset)
+ PageSizeParam string `json:"page_size_param"` // most common page size param (limit, per_page, page_size, first)
+ SinceParam string `json:"since_param"` // temporal filter param (since, updated_after, modified_since)
ItemsKey string `json:"items_key"` // response array key (data, results, items, or "" for root array)
DefaultPageSize int `json:"default_page_size"` // detected or default 100
}
diff --git a/internal/vision/report.go b/internal/vision/report.go
index 42bc109c..634f9dca 100644
--- a/internal/vision/report.go
+++ b/internal/vision/report.go
@@ -14,28 +14,28 @@ func WriteReport(plan *VisionaryPlan, outputDir string) error {
var b strings.Builder
- b.WriteString(fmt.Sprintf("# Visionary Research: %s CLI\n\n", plan.APIName))
+ fmt.Fprintf(&b, "# Visionary Research: %s CLI\n\n", plan.APIName)
// API Identity
b.WriteString("## API Identity\n\n")
- b.WriteString(fmt.Sprintf("- **Domain:** %s\n", plan.Identity.DomainCategory))
- b.WriteString(fmt.Sprintf("- **Primary users:** %s\n", strings.Join(plan.Identity.PrimaryUsers, ", ")))
- b.WriteString(fmt.Sprintf("- **Core entities:** %s\n", strings.Join(plan.Identity.CoreEntities, ", ")))
- b.WriteString(fmt.Sprintf("- **Data profile:** %s volume, %s writes, realtime=%v, search need=%s\n\n",
+ fmt.Fprintf(&b, "- **Domain:** %s\n", plan.Identity.DomainCategory)
+ fmt.Fprintf(&b, "- **Primary users:** %s\n", strings.Join(plan.Identity.PrimaryUsers, ", "))
+ fmt.Fprintf(&b, "- **Core entities:** %s\n", strings.Join(plan.Identity.CoreEntities, ", "))
+ fmt.Fprintf(&b, "- **Data profile:** %s volume, %s writes, realtime=%v, search need=%s\n\n",
plan.Identity.DataProfile.Volume,
plan.Identity.DataProfile.WritePattern,
plan.Identity.DataProfile.Realtime,
- plan.Identity.DataProfile.SearchNeed))
+ plan.Identity.DataProfile.SearchNeed)
// Usage Patterns
if len(plan.UsagePatterns) > 0 {
b.WriteString("## Usage Patterns (by Evidence)\n\n")
for i, p := range plan.UsagePatterns {
- b.WriteString(fmt.Sprintf("### %d. %s (Evidence: %d/10)\n\n", i+1, p.Name, p.EvidenceScore))
+ fmt.Fprintf(&b, "### %d. %s (Evidence: %d/10)\n\n", i+1, p.Name, p.EvidenceScore)
b.WriteString(p.Description + "\n\n")
if len(p.EvidenceSources) > 0 {
for _, src := range p.EvidenceSources {
- b.WriteString(fmt.Sprintf("- %s\n", src))
+ fmt.Fprintf(&b, "- %s\n", src)
}
b.WriteString("\n")
}
@@ -51,9 +51,9 @@ func WriteReport(plan *VisionaryPlan, outputDir string) error {
b.WriteString("| Tool | Stars | Type | Language | Features |\n")
b.WriteString("|------|-------|------|----------|----------|\n")
for _, t := range plan.ToolLandscape {
- b.WriteString(fmt.Sprintf("| [%s](%s) | %d | %s | %s | %s |\n",
+ fmt.Fprintf(&b, "| [%s](%s) | %d | %s | %s | %s |\n",
t.Name, t.URL, t.Stars, t.ToolType, t.Language,
- strings.Join(t.Features, ", ")))
+ strings.Join(t.Features, ", "))
}
b.WriteString("\n")
}
@@ -62,7 +62,7 @@ func WriteReport(plan *VisionaryPlan, outputDir string) error {
if len(plan.Workflows) > 0 {
b.WriteString("## Workflows\n\n")
for i, w := range plan.Workflows {
- b.WriteString(fmt.Sprintf("### %d. %s\n\n", i+1, w.Name))
+ fmt.Fprintf(&b, "### %d. %s\n\n", i+1, w.Name)
if len(w.Steps) > 0 {
b.WriteString("**Steps:** ")
stepDescs := make([]string, len(w.Steps))
@@ -72,13 +72,13 @@ func WriteReport(plan *VisionaryPlan, outputDir string) error {
b.WriteString(strings.Join(stepDescs, " -> ") + "\n")
}
if w.Frequency != "" {
- b.WriteString(fmt.Sprintf("**Frequency:** %s\n", w.Frequency))
+ fmt.Fprintf(&b, "**Frequency:** %s\n", w.Frequency)
}
if w.PainPoint != "" {
- b.WriteString(fmt.Sprintf("**Pain:** %s\n", w.PainPoint))
+ fmt.Fprintf(&b, "**Pain:** %s\n", w.PainPoint)
}
if w.ProposedCLIFeature != "" {
- b.WriteString(fmt.Sprintf("**Proposed:** `%s`\n", w.ProposedCLIFeature))
+ fmt.Fprintf(&b, "**Proposed:** `%s`\n", w.ProposedCLIFeature)
}
b.WriteString("\n")
}
@@ -90,8 +90,8 @@ func WriteReport(plan *VisionaryPlan, outputDir string) error {
b.WriteString("| Area | Need | Decision | Rationale |\n")
b.WriteString("|------|------|----------|----------|\n")
for _, a := range plan.Architecture {
- b.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n",
- a.Area, a.NeedLevel, a.Decision, a.Rationale))
+ fmt.Fprintf(&b, "| %s | %s | %s | %s |\n",
+ a.Area, a.NeedLevel, a.Decision, a.Rationale)
}
b.WriteString("\n")
}
@@ -103,17 +103,17 @@ func WriteReport(plan *VisionaryPlan, outputDir string) error {
b.WriteString("|---|---------|-------|----------|--------|----------|\n")
for i, f := range plan.Features {
f.TotalScore = f.ComputeScore()
- b.WriteString(fmt.Sprintf("| %d | %s | %d/16 | %d/3 | %d/3 | %s |\n",
+ fmt.Fprintf(&b, "| %d | %s | %d/16 | %d/3 | %d/3 | %s |\n",
i+1, f.Name, f.TotalScore,
f.EvidenceStrength, f.UserImpact,
- strings.Join(f.TemplateNames, ", ")))
+ strings.Join(f.TemplateNames, ", "))
}
b.WriteString("\n")
// Detail for each feature
for i, f := range plan.Features {
f.TotalScore = f.ComputeScore()
- b.WriteString(fmt.Sprintf("### %d. %s (Score: %d/16)\n\n", i+1, f.Name, f.TotalScore))
+ fmt.Fprintf(&b, "### %d. %s (Score: %d/16)\n\n", i+1, f.Name, f.TotalScore)
b.WriteString(f.Description + "\n\n")
if len(f.TemplateNames) > 0 {
b.WriteString("**Templates:** " + strings.Join(f.TemplateNames, ", ") + "\n\n")
diff --git a/release-please-config.json b/release-please-config.json
new file mode 100644
index 00000000..40447b3a
--- /dev/null
+++ b/release-please-config.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
+ "packages": {
+ ".": {
+ "release-type": "go",
+ "bump-minor-pre-major": true,
+ "extra-files": [
+ {
+ "type": "json",
+ "path": ".claude-plugin/plugin.json",
+ "jsonpath": "$.version"
+ },
+ {
+ "type": "json",
+ "path": ".claude-plugin/marketplace.json",
+ "jsonpath": "$.plugins[0].version"
+ },
+ {
+ "type": "generic",
+ "path": "internal/cli/root.go"
+ }
+ ]
+ }
+ }
+}
← 03a19223 fix(ci): shared build cache and Go module caching to prevent
·
back to Cli Printing Press
·
feat(websniff): add HAR and enriched capture parser 6c6c9cf5 →