Compare commits
10
Commits
41a491564f
...
33b1b2370b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33b1b2370b | ||
|
|
bb963169dd | ||
|
|
e5536f9cce | ||
|
|
59c48a36cb | ||
|
|
272ce89ec5 | ||
|
|
80e93e9e15 | ||
|
|
f8d3199bde | ||
|
|
480aebfcd0 | ||
|
|
57bb1c4b62 | ||
|
|
a7a561fbbd |
@@ -0,0 +1,65 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: cpu
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
|
||||||
|
- name: Build cross-platform binaries
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p dist
|
||||||
|
build() {
|
||||||
|
os=$1
|
||||||
|
label=$2
|
||||||
|
goarch=$3
|
||||||
|
echo "building dist/shush-${os}-${label}"
|
||||||
|
CGO_ENABLED=0 GOOS="$os" GOARCH="$goarch" go build -ldflags="-w -s" -o "dist/shush-${os}-${label}" ./cmd/shush
|
||||||
|
}
|
||||||
|
build linux x86_64 amd64
|
||||||
|
build linux aarch64 arm64
|
||||||
|
build darwin x86_64 amd64
|
||||||
|
build darwin aarch64 arm64
|
||||||
|
ls -la dist
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if ! command -v tea >/dev/null 2>&1; then
|
||||||
|
echo "tea CLI not installed on runner; install from https://dl.gitea.com/tea/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${TAG:-}" ]; then
|
||||||
|
echo "no tag ref; run this workflow from a v* tag"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
tea login delete ci >/dev/null 2>&1 || true
|
||||||
|
tea login add --name ci --url https://git.mroberts.dev --token "$GITEA_TOKEN"
|
||||||
|
# Idempotent: drop any existing release for this tag so re-runs replace
|
||||||
|
# stale assets instead of failing with "already a release for this tag".
|
||||||
|
tea releases delete -y --login ci --repo mroberts/sush "$TAG" >/dev/null 2>&1 || true
|
||||||
|
tea releases create \
|
||||||
|
--login ci \
|
||||||
|
--repo mroberts/sush \
|
||||||
|
--tag "$TAG" \
|
||||||
|
--title "$TAG" \
|
||||||
|
--asset dist/shush-linux-x86_64 \
|
||||||
|
--asset dist/shush-linux-aarch64 \
|
||||||
|
--asset dist/shush-darwin-x86_64 \
|
||||||
|
--asset dist/shush-darwin-aarch64
|
||||||
@@ -136,6 +136,8 @@ Smart comment preservation and seamless Claude Code integration:
|
|||||||
- String-aware parsing preserves URLs and code in strings ✅
|
- String-aware parsing preserves URLs and code in strings ✅
|
||||||
- Automatic comment cleanup with configurable preservation ✅
|
- Automatic comment cleanup with configurable preservation ✅
|
||||||
|
|
||||||
|
**Note for AI agents (Claude Code):** When the shush hook is installed it runs `shush --changes-only --hook-output` after every `Write`/`Edit`/`MultiEdit`, so comments you just wrote may be stripped. This is intentional automated cleanup, not lost work or an error — do not re-add the removed comments. Keep important comments by adding preserve patterns to `.shush.toml`. The `--hook-output` flag makes shush emit a Claude Code PostToolUse JSON message reporting how many comments were removed, so the removal is announced in-context rather than appearing as a silent mutation.
|
||||||
|
|
||||||
## Development Notes
|
## Development Notes
|
||||||
|
|
||||||
### Common Development Workflow
|
### Common Development Workflow
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ tag: ## Create and push git tag for current version
|
|||||||
@echo "Run 'make push' to push tag and trigger release"
|
@echo "Run 'make push' to push tag and trigger release"
|
||||||
|
|
||||||
push: ## Push current branch and tags
|
push: ## Push current branch and tags
|
||||||
git push upstream
|
git push origin
|
||||||
git push upstream --tags
|
git push origin --tags
|
||||||
|
|
||||||
# Utility commands
|
# Utility commands
|
||||||
clean: ## Clean build artifacts
|
clean: ## Clean build artifacts
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ import (
|
|||||||
"github.com/carlosarraes/shush/internal/types"
|
"github.com/carlosarraes/shush/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "0.3.3"
|
var version = "0.6.1"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var cliArgs types.CLI
|
var cliArgs types.CLI
|
||||||
|
|||||||
+89
-22
@@ -1,8 +1,10 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
@@ -22,11 +24,73 @@ func Default() *Config {
|
|||||||
"XXX:",
|
"XXX:",
|
||||||
"@ts-ignore",
|
"@ts-ignore",
|
||||||
"@ts-expect-error",
|
"@ts-expect-error",
|
||||||
|
"@ts-nocheck",
|
||||||
"eslint-",
|
"eslint-",
|
||||||
"prettier-ignore",
|
"prettier-ignore",
|
||||||
|
"biome-ignore",
|
||||||
|
"deno-lint-ignore",
|
||||||
|
"stylelint-",
|
||||||
|
"istanbul ignore",
|
||||||
|
"c8 ignore",
|
||||||
|
"v8 ignore",
|
||||||
|
"type: ignore",
|
||||||
"pylint:",
|
"pylint:",
|
||||||
"mypy:",
|
"mypy:",
|
||||||
"type: ignore",
|
"pyright:",
|
||||||
|
"ruff:",
|
||||||
|
"flake8:",
|
||||||
|
"noqa",
|
||||||
|
"nosec",
|
||||||
|
"noinspection",
|
||||||
|
"pragma: no cover",
|
||||||
|
"fmt: off",
|
||||||
|
"fmt: on",
|
||||||
|
"pyre-ignore",
|
||||||
|
"pyre-fixme",
|
||||||
|
"isort:",
|
||||||
|
"nolint",
|
||||||
|
"go:",
|
||||||
|
"lint:ignore",
|
||||||
|
"rubocop:",
|
||||||
|
"frozen_string_literal",
|
||||||
|
"NOLINT",
|
||||||
|
"clang-format",
|
||||||
|
"cppcheck-suppress",
|
||||||
|
"IWYU pragma",
|
||||||
|
"ReSharper",
|
||||||
|
"CHECKSTYLE",
|
||||||
|
"NOPMD",
|
||||||
|
"ktlint-disable",
|
||||||
|
"swiftlint:",
|
||||||
|
"ignore_for_file",
|
||||||
|
"phpstan-",
|
||||||
|
"psalm-",
|
||||||
|
"phpcs:",
|
||||||
|
"@phan",
|
||||||
|
"scalafix:",
|
||||||
|
"scalastyle:",
|
||||||
|
"luacheck:",
|
||||||
|
"selene:",
|
||||||
|
"shellcheck",
|
||||||
|
"hadolint",
|
||||||
|
"tflint",
|
||||||
|
"tfsec:",
|
||||||
|
"zizmor:",
|
||||||
|
"checkov:",
|
||||||
|
"yamllint",
|
||||||
|
"{-#",
|
||||||
|
"HLINT",
|
||||||
|
"credo:",
|
||||||
|
"solhint-",
|
||||||
|
"slither-disable",
|
||||||
|
"buf:lint",
|
||||||
|
"@generated",
|
||||||
|
"DO NOT EDIT",
|
||||||
|
`/^#\s*v\d+(\.\d+)*$/`,
|
||||||
|
`/^\/\*\*/`,
|
||||||
|
`/^\/\*!/`,
|
||||||
|
`/^\/\/\//`,
|
||||||
|
`/^\/\/!/`,
|
||||||
},
|
},
|
||||||
ContextLines: 3,
|
ContextLines: 3,
|
||||||
}
|
}
|
||||||
@@ -142,6 +206,14 @@ func (c *Config) ShouldPreserveComment(comment string) bool {
|
|||||||
|
|
||||||
func matchesPattern(text, pattern string) bool {
|
func matchesPattern(text, pattern string) bool {
|
||||||
|
|
||||||
|
if len(pattern) >= 2 && strings.HasPrefix(pattern, "/") && strings.HasSuffix(pattern, "/") {
|
||||||
|
re, err := regexp.Compile(pattern[1 : len(pattern)-1])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return re.MatchString(text)
|
||||||
|
}
|
||||||
|
|
||||||
if strings.Contains(pattern, "*") {
|
if strings.Contains(pattern, "*") {
|
||||||
return matchesWildcard(text, pattern)
|
return matchesWildcard(text, pattern)
|
||||||
}
|
}
|
||||||
@@ -189,27 +261,22 @@ func matchesWildcard(text, pattern string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CreateExampleConfig() error {
|
func CreateExampleConfig() error {
|
||||||
content := `# Shush Configuration
|
def := Default()
|
||||||
# Patterns to preserve in comments (supports wildcards with *)
|
|
||||||
preserve = [
|
|
||||||
"TODO:",
|
|
||||||
"FIXME:",
|
|
||||||
"HACK:",
|
|
||||||
"XXX:",
|
|
||||||
"@ts-ignore",
|
|
||||||
"@ts-expect-error",
|
|
||||||
"eslint-",
|
|
||||||
"prettier-ignore",
|
|
||||||
"pylint:",
|
|
||||||
"mypy:",
|
|
||||||
"type: ignore",
|
|
||||||
"*IMPORTANT*", # Example wildcard: preserves any comment containing IMPORTANT
|
|
||||||
"*DEBUG*", # Example wildcard: preserves any comment containing DEBUG
|
|
||||||
]
|
|
||||||
|
|
||||||
# Number of context lines to show around changes in preview mode (default: 3)
|
var b strings.Builder
|
||||||
context_lines = 3
|
b.WriteString("# Shush Configuration\n")
|
||||||
`
|
b.WriteString("# Patterns to preserve in comments (substring, * wildcards, or /regex/)\n")
|
||||||
|
b.WriteString("# Defaults below cover common lint/tool directives for supported languages.\n")
|
||||||
|
b.WriteString("preserve = [\n")
|
||||||
|
for _, p := range def.Preserve {
|
||||||
|
fmt.Fprintf(&b, " %q,\n", p)
|
||||||
|
}
|
||||||
|
b.WriteString(" # Wildcards (*) preserve any comment containing the pattern:\n")
|
||||||
|
b.WriteString(" \"*IMPORTANT*\",\n")
|
||||||
|
b.WriteString(" \"*DEBUG*\",\n")
|
||||||
|
b.WriteString("]\n\n")
|
||||||
|
b.WriteString("# Number of context lines to show around changes in preview mode\n")
|
||||||
|
fmt.Fprintf(&b, "context_lines = %d\n", def.ContextLines)
|
||||||
|
|
||||||
return os.WriteFile(".shush.toml", []byte(content), 0644)
|
return os.WriteFile(".shush.toml", []byte(b.String()), 0644)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCreateExampleConfigRoundTrips(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Chdir(dir)
|
||||||
|
|
||||||
|
if err := CreateExampleConfig(); err != nil {
|
||||||
|
t.Fatalf("create example config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadFromFile(".shush.toml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse generated config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
def := Default()
|
||||||
|
have := make(map[string]bool, len(cfg.Preserve))
|
||||||
|
for _, p := range cfg.Preserve {
|
||||||
|
have[p] = true
|
||||||
|
}
|
||||||
|
for _, p := range def.Preserve {
|
||||||
|
if !have[p] {
|
||||||
|
t.Errorf("generated config missing default preserve pattern %q", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.ContextLines != def.ContextLines {
|
||||||
|
t.Errorf("context_lines = %d, want %d", cfg.ContextLines, def.ContextLines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldPreserveDirectives(t *testing.T) {
|
||||||
|
cfg := Default()
|
||||||
|
directives := []string{
|
||||||
|
"//nolint:errcheck",
|
||||||
|
"//go:build linux",
|
||||||
|
"# noqa: E501",
|
||||||
|
"# type: ignore",
|
||||||
|
"# rubocop:disable Style/Foo",
|
||||||
|
"# frozen_string_literal: true",
|
||||||
|
"{-# LANGUAGE OverloadedStrings #-}",
|
||||||
|
"// @phpstan-ignore-line",
|
||||||
|
"-- luacheck: ignore",
|
||||||
|
"// NOPMD",
|
||||||
|
"// ReSharper disable once Foo",
|
||||||
|
"// ignore_for_file: foo",
|
||||||
|
"# credo:disable-for-next-line",
|
||||||
|
"// solhint-disable-next-line",
|
||||||
|
"# yamllint disable-line",
|
||||||
|
"# zizmor: ignore[template-injection] trusted infra-generated command",
|
||||||
|
}
|
||||||
|
for _, d := range directives {
|
||||||
|
if !cfg.ShouldPreserveComment(d) {
|
||||||
|
t.Errorf("directive not preserved by default: %q", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldPreserveVersionPins(t *testing.T) {
|
||||||
|
cfg := Default()
|
||||||
|
|
||||||
|
preserved := []string{
|
||||||
|
"# v2.1.6",
|
||||||
|
"# v4",
|
||||||
|
"# v2.1",
|
||||||
|
"#v1.0.0",
|
||||||
|
}
|
||||||
|
for _, c := range preserved {
|
||||||
|
if !cfg.ShouldPreserveComment(c) {
|
||||||
|
t.Errorf("version pin not preserved by default: %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stripped := []string{
|
||||||
|
"# validate.this prose",
|
||||||
|
"# a regular note comment",
|
||||||
|
"# version bump",
|
||||||
|
"# 2.1.6",
|
||||||
|
"# v2 release notes",
|
||||||
|
}
|
||||||
|
for _, c := range stripped {
|
||||||
|
if cfg.ShouldPreserveComment(c) {
|
||||||
|
t.Errorf("non-version comment wrongly preserved: %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldPreserveDocComments(t *testing.T) {
|
||||||
|
cfg := Default()
|
||||||
|
|
||||||
|
preserved := []string{
|
||||||
|
"/**\n * JSDoc block\n * @param x foo\n */",
|
||||||
|
"/** single-line doc */",
|
||||||
|
"/*! license banner */",
|
||||||
|
"/// Rust or C# doc comment",
|
||||||
|
"//! Rust inner doc comment",
|
||||||
|
}
|
||||||
|
for _, c := range preserved {
|
||||||
|
if !cfg.ShouldPreserveComment(c) {
|
||||||
|
t.Errorf("doc comment not preserved by default: %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stripped := []string{
|
||||||
|
"/* plain block comment */",
|
||||||
|
"// plain line comment",
|
||||||
|
"# hash comment",
|
||||||
|
"/*\n * multi-line non-doc block\n */",
|
||||||
|
}
|
||||||
|
for _, c := range stripped {
|
||||||
|
if cfg.ShouldPreserveComment(c) {
|
||||||
|
t.Errorf("non-doc comment wrongly preserved: %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -109,9 +109,9 @@ func GetUnstagedChanges() ([]FileChange, error) {
|
|||||||
func getLineRangesFromDiff(file string, staged bool) ([]LineRange, error) {
|
func getLineRangesFromDiff(file string, staged bool) ([]LineRange, error) {
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
if staged {
|
if staged {
|
||||||
cmd = exec.Command("git", "diff", "--cached", "--unified=0", file)
|
cmd = exec.Command("git", "diff", "--no-ext-diff", "--cached", "--unified=0", file)
|
||||||
} else {
|
} else {
|
||||||
cmd = exec.Command("git", "diff", "--unified=0", file)
|
cmd = exec.Command("git", "diff", "--no-ext-diff", "--unified=0", file)
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
|
|||||||
@@ -1,9 +1,59 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func runGit(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLineRangesFromDiffIgnoresExternalDiff(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
runGit(t, dir, "config", "user.email", "t@t")
|
||||||
|
runGit(t, dir, "config", "user.name", "t")
|
||||||
|
runGit(t, dir, "config", "diff.external", "true")
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "f.py")
|
||||||
|
if err := os.WriteFile(path, []byte("a = 1\nb = 2\nc = 3\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "f.py")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte("a = 1\nb = 2\nc = 3 changed\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Chdir(wd)
|
||||||
|
if err := os.Chdir(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ranges, err := getLineRangesFromDiff("f.py", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getLineRangesFromDiff: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []LineRange{{Start: 3, End: 3}}
|
||||||
|
if len(ranges) != len(want) || ranges[0] != want[0] {
|
||||||
|
t.Fatalf("ranges = %v, want %v (external diff not bypassed)", ranges, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseGitDiffUnified(t *testing.T) {
|
func TestParseGitDiffUnified(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func SaveSettings(path string, settings *ClaudeSettings) error {
|
|||||||
func CreateShushHookEntry() HookEntry {
|
func CreateShushHookEntry() HookEntry {
|
||||||
return HookEntry{
|
return HookEntry{
|
||||||
Type: "command",
|
Type: "command",
|
||||||
Command: "shush --changes-only",
|
Command: "shush --changes-only --hook-output",
|
||||||
Timeout: 5,
|
Timeout: 5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,7 +208,7 @@ func AddShushHook(settings *ClaudeSettings) error {
|
|||||||
|
|
||||||
shushHook := map[string]interface{}{
|
shushHook := map[string]interface{}{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "shush --changes-only",
|
"command": "shush --changes-only --hook-output",
|
||||||
"timeout": 5,
|
"timeout": 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,16 @@ func changed(changes []LineChange) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func countRemoved(changes []LineChange) int {
|
||||||
|
n := 0
|
||||||
|
for _, c := range changes {
|
||||||
|
if c.Action == "modified" || c.Action == "removed" {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
func changesToInfos(changes []LineChange) ([]changeInfo, int, int, int) {
|
func changesToInfos(changes []LineChange) ([]changeInfo, int, int, int) {
|
||||||
var infos []changeInfo
|
var infos []changeInfo
|
||||||
changedCount, keptCount, preservedCount := 0, 0, 0
|
changedCount, keptCount, preservedCount := 0, 0, 0
|
||||||
|
|||||||
@@ -115,3 +115,45 @@ func TestStripComments(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStripCommentsPreservesDirectives(t *testing.T) {
|
||||||
|
def := config.Default()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
file string
|
||||||
|
src string
|
||||||
|
}{
|
||||||
|
{"go nolint", "a.go", "x := 1 //nolint:errcheck\n"},
|
||||||
|
{"go build directive", "a.go", "//go:build linux\npackage main\n"},
|
||||||
|
{"python noqa", "a.py", "x = 1 # noqa: E501\n"},
|
||||||
|
{"python type ignore", "a.py", "y = 2 # type: ignore\n"},
|
||||||
|
{"python nosec", "a.py", "p = 1 # nosec B105\n"},
|
||||||
|
{"js eslint disable", "a.js", "const a = 1 // eslint-disable-line\n"},
|
||||||
|
{"ts expect error", "a.ts", "const b = 1 // @ts-expect-error\n"},
|
||||||
|
{"c clang nolint", "a.c", "int x; // NOLINT(cert-err58-cpp)\n"},
|
||||||
|
{"shell shellcheck", "a.sh", "y=1 # shellcheck disable=SC2086\n"},
|
||||||
|
{"ruby rubocop", "a.rb", "x = 1 # rubocop:disable Style/Foo\n"},
|
||||||
|
{"ruby frozen string", "a.rb", "# frozen_string_literal: true\nx = 1\n"},
|
||||||
|
{"haskell pragma", "a.hs", "{-# LANGUAGE OverloadedStrings #-}\nmodule M where\n"},
|
||||||
|
{"php phpstan", "a.php", "<?php\n$x = 1; // @phpstan-ignore-line\n"},
|
||||||
|
{"lua luacheck", "a.lua", "local x = 1 -- luacheck: ignore\n"},
|
||||||
|
{"java nopmd", "a.java", "int x = 1; // NOPMD\n"},
|
||||||
|
{"csharp resharper", "a.cs", "int x = 1; // ReSharper disable once Foo\n"},
|
||||||
|
{"dart ignore for file", "a.dart", "// ignore_for_file: foo\nvar x = 1;\n"},
|
||||||
|
{"elixir credo", "a.ex", "x = 1 # credo:disable-for-next-line\n"},
|
||||||
|
{"solidity solhint", "a.sol", "uint x; // solhint-disable-next-line\n"},
|
||||||
|
{"kotlin ktlint", "a.kt", "val x = 1 // ktlint-disable\n"},
|
||||||
|
{"yaml yamllint", "a.yml", "x: 1 # yamllint disable-line\n"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
out, _, err := StripComments([]byte(c.src), c.file, def, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
if string(out) != c.src {
|
||||||
|
t.Errorf("directive not preserved:\ngot %q\nwant %q", out, c.src)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package processor
|
package processor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -53,7 +54,9 @@ func (p *Processor) processGitChanges() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(changes) == 0 {
|
if len(changes) == 0 {
|
||||||
|
if !p.cli.HookOutput {
|
||||||
fmt.Println("No changes found to process")
|
fmt.Println("No changes found to process")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +74,9 @@ func (p *Processor) processGitChanges() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(supportedChanges) == 0 {
|
if len(supportedChanges) == 0 {
|
||||||
|
if !p.cli.HookOutput {
|
||||||
fmt.Println("No supported files found to process")
|
fmt.Println("No supported files found to process")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +106,7 @@ func (p *Processor) processGitChanges() error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := p.processFileWithLineRanges(change.Path, change.LineRanges, cfg); err != nil {
|
if err := p.processFileWithLineRanges(change.Path, change.LineRanges, cfg, totals); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", change.Path, err)
|
fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", change.Path, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -113,10 +118,44 @@ func (p *Processor) processGitChanges() error {
|
|||||||
p.showGitTotals(totals)
|
p.showGitTotals(totals)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if p.cli.HookOutput {
|
||||||
|
if out, ok := buildHookOutput(totals.TotalChanged); ok {
|
||||||
|
fmt.Println(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Processor) processFileWithLineRanges(filename string, lineRanges []git.LineRange, cfg *config.Config) error {
|
type hookSpecificOutput struct {
|
||||||
|
HookEventName string `json:"hookEventName"`
|
||||||
|
AdditionalContext string `json:"additionalContext"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hookOutput struct {
|
||||||
|
HookSpecificOutput hookSpecificOutput `json:"hookSpecificOutput"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHookOutput(removed int) (string, bool) {
|
||||||
|
if removed <= 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
plural := ""
|
||||||
|
if removed != 1 {
|
||||||
|
plural = "s"
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("shush removed %d comment%s from files just edited, per this project's comment policy. This is expected automated cleanup, not an error or lost work. Do not re-add the removed comments; to keep specific comments, add preserve patterns to .shush.toml.", removed, plural)
|
||||||
|
b, err := json.Marshal(hookOutput{hookSpecificOutput{
|
||||||
|
HookEventName: "PostToolUse",
|
||||||
|
AdditionalContext: msg,
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return string(b), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) processFileWithLineRanges(filename string, lineRanges []git.LineRange, cfg *config.Config, totals *GitTotals) error {
|
||||||
src, err := os.ReadFile(filename)
|
src, err := os.ReadFile(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -150,6 +189,7 @@ func (p *Processor) processFileWithLineRanges(filename string, lineRanges []git.
|
|||||||
if err := os.WriteFile(filename, out, 0644); err != nil {
|
if err := os.WriteFile(filename, out, 0644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
totals.TotalChanged += countRemoved(changes)
|
||||||
if p.cli.Verbose {
|
if p.cli.Verbose {
|
||||||
fmt.Printf("✓ Comments removed from %s\n", filename)
|
fmt.Printf("✓ Comments removed from %s\n", filename)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,65 @@
|
|||||||
package processor
|
package processor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/carlosarraes/shush/internal/config"
|
"github.com/carlosarraes/shush/internal/config"
|
||||||
"github.com/carlosarraes/shush/internal/types"
|
"github.com/carlosarraes/shush/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestCountRemoved(t *testing.T) {
|
||||||
|
changes := []LineChange{
|
||||||
|
{Action: "removed"},
|
||||||
|
{Action: "modified"},
|
||||||
|
{Action: "preserved"},
|
||||||
|
{Action: "kept"},
|
||||||
|
{Action: ""},
|
||||||
|
}
|
||||||
|
if got := countRemoved(changes); got != 2 {
|
||||||
|
t.Errorf("countRemoved() = %d, want 2", got)
|
||||||
|
}
|
||||||
|
if got := countRemoved(nil); got != 0 {
|
||||||
|
t.Errorf("countRemoved(nil) = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHookOutput(t *testing.T) {
|
||||||
|
if _, ok := buildHookOutput(0); ok {
|
||||||
|
t.Error("buildHookOutput(0) should be silent (ok=false)")
|
||||||
|
}
|
||||||
|
if _, ok := buildHookOutput(-1); ok {
|
||||||
|
t.Error("buildHookOutput(-1) should be silent (ok=false)")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, ok := buildHookOutput(3)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("buildHookOutput(3) should emit (ok=true)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
HookSpecificOutput struct {
|
||||||
|
HookEventName string `json:"hookEventName"`
|
||||||
|
AdditionalContext string `json:"additionalContext"`
|
||||||
|
} `json:"hookSpecificOutput"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.HookSpecificOutput.HookEventName != "PostToolUse" {
|
||||||
|
t.Errorf("hookEventName = %q, want PostToolUse", parsed.HookSpecificOutput.HookEventName)
|
||||||
|
}
|
||||||
|
if !strings.Contains(parsed.HookSpecificOutput.AdditionalContext, "3 comments") {
|
||||||
|
t.Errorf("context missing count: %q", parsed.HookSpecificOutput.AdditionalContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
single, _ := buildHookOutput(1)
|
||||||
|
if !strings.Contains(single, "1 comment ") {
|
||||||
|
t.Errorf("singular form wrong: %q", single)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoveCommentsFromLine(t *testing.T) {
|
func TestRemoveCommentsFromLine(t *testing.T) {
|
||||||
cli := types.CLI{}
|
cli := types.CLI{}
|
||||||
p := &Processor{cli: cli}
|
p := &Processor{cli: cli}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func runMultilineCase(t *testing.T, tc multilineCase) {
|
|||||||
}
|
}
|
||||||
p := &Processor{cli: tc.cli}
|
p := &Processor{cli: tc.cli}
|
||||||
if tc.gitMode {
|
if tc.gitMode {
|
||||||
err = p.processFileWithLineRanges(work, tc.ranges, cfg)
|
err = p.processFileWithLineRanges(work, tc.ranges, cfg, &GitTotals{})
|
||||||
} else {
|
} else {
|
||||||
lang, derr := DetectLanguage(work)
|
lang, derr := DetectLanguage(work)
|
||||||
if derr != nil {
|
if derr != nil {
|
||||||
@@ -87,6 +87,8 @@ func TestMultiline(t *testing.T) {
|
|||||||
multilineCase{name: "marker in line comment", file: "marker_in_line.c"},
|
multilineCase{name: "marker in line comment", file: "marker_in_line.c"},
|
||||||
multilineCase{name: "unterminated", file: "unterminated.c"},
|
multilineCase{name: "unterminated", file: "unterminated.c"},
|
||||||
multilineCase{name: "conf fallback", file: "fallback.conf"},
|
multilineCase{name: "conf fallback", file: "fallback.conf"},
|
||||||
|
multilineCase{name: "doc comments js", file: "doc_js.js"},
|
||||||
|
multilineCase{name: "doc comments rust", file: "doc_rs.rs"},
|
||||||
multilineCase{name: "git straddle kept", file: "straddle.js",
|
multilineCase{name: "git straddle kept", file: "straddle.js",
|
||||||
gitMode: true, ranges: []git.LineRange{{Start: 4, End: 4}}},
|
gitMode: true, ranges: []git.LineRange{{Start: 4, End: 4}}},
|
||||||
multilineCase{name: "git range removes inside block", file: "range_inside.js",
|
multilineCase{name: "git range removes inside block", file: "range_inside.js",
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Adds two numbers.
|
||||||
|
* @param {number} a
|
||||||
|
* @param {number} b
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function add(a, b) {
|
||||||
|
/*
|
||||||
|
* plain block comment
|
||||||
|
*/
|
||||||
|
return a + b; // plain line comment
|
||||||
|
}
|
||||||
|
|
||||||
|
/*! license banner */
|
||||||
|
/** single-line doc */
|
||||||
|
function noop() {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Adds two numbers.
|
||||||
|
* @param {number} a
|
||||||
|
* @param {number} b
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function add(a, b) {
|
||||||
|
return a + b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*! license banner */
|
||||||
|
/** single-line doc */
|
||||||
|
function noop() {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//! Crate-level docs.
|
||||||
|
|
||||||
|
/// Adds two numbers.
|
||||||
|
/// Returns the sum.
|
||||||
|
pub fn add(a: i32, b: i32) -> i32 {
|
||||||
|
// plain line comment
|
||||||
|
/* plain block comment */
|
||||||
|
a + b
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//! Crate-level docs.
|
||||||
|
|
||||||
|
/// Adds two numbers.
|
||||||
|
/// Returns the sum.
|
||||||
|
pub fn add(a: i32, b: i32) -> i32 {
|
||||||
|
a + b
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ func runWholeFileCase(t *testing.T, tc wholeFileCase) {
|
|||||||
|
|
||||||
p := &Processor{cli: tc.cli}
|
p := &Processor{cli: tc.cli}
|
||||||
if tc.gitMode {
|
if tc.gitMode {
|
||||||
err = p.processFileWithLineRanges(work, tc.ranges, cfg)
|
err = p.processFileWithLineRanges(work, tc.ranges, cfg, &GitTotals{})
|
||||||
} else {
|
} else {
|
||||||
lang, derr := DetectLanguage(work)
|
lang, derr := DetectLanguage(work)
|
||||||
if derr != nil {
|
if derr != nil {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type CLI struct {
|
|||||||
ContextLines int `short:"c" help:"Number of context lines to show in preview mode (default: from config)" default:"-1"`
|
ContextLines int `short:"c" help:"Number of context lines to show in preview mode (default: from config)" default:"-1"`
|
||||||
LLM bool `help:"Show LLM-friendly usage guide"`
|
LLM bool `help:"Show LLM-friendly usage guide"`
|
||||||
ChangesOnly bool `help:"Remove comments only from git changes (staged + unstaged + untracked)"`
|
ChangesOnly bool `help:"Remove comments only from git changes (staged + unstaged + untracked)"`
|
||||||
|
HookOutput bool `help:"Emit Claude Code PostToolUse JSON reporting removed comments (for use in hooks)"`
|
||||||
Staged bool `help:"Remove comments only from staged git changes"`
|
Staged bool `help:"Remove comments only from staged git changes"`
|
||||||
Unstaged bool `help:"Remove comments only from unstaged git changes"`
|
Unstaged bool `help:"Remove comments only from unstaged git changes"`
|
||||||
InstallHook bool `help:"Install Claude Code hooks for automatic comment cleanup"`
|
InstallHook bool `help:"Install Claude Code hooks for automatic comment cleanup"`
|
||||||
|
|||||||
Reference in New Issue
Block a user