From bac966c2acc1cb098c7cd8323cbfc8facc144e1e Mon Sep 17 00:00:00 2001 From: Hein Date: Fri, 17 Jul 2026 14:29:24 +0200 Subject: [PATCH] feat(format): add runtime semantic-equality safety gate --- .gitea/workflows/release.yml | 12 +++++++ .github/workflows/release.yml | 8 +++++ AGENTS.md | 8 +++++ cmd/pgtidy/fmt.go | 9 ++++++ pkg/format/format_test.go | 46 ++------------------------ pkg/format/safety.go | 61 +++++++++++++++++++++++++++++++++++ pkg/lsp/server.go | 7 ++++ 7 files changed, 107 insertions(+), 44 deletions(-) create mode 100644 pkg/format/safety.go diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index ea98e97..99d82f2 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -365,6 +365,12 @@ jobs: with: version: latest + - name: Set version from tag + working-directory: editors/vscode + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + npm pkg set version="${TAG#v}" + - name: Install and package working-directory: editors/vscode run: | @@ -402,6 +408,12 @@ jobs: distribution: temurin java-version: '21' + - name: Set version from tag + working-directory: editors/datagrip + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + sed -i "s/^pluginVersion=.*/pluginVersion=${TAG#v}/" gradle.properties + - name: Build plugin working-directory: editors/datagrip run: ./gradlew buildPlugin diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfcaa87..7a1a52f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,10 @@ jobs: cache: npm cache-dependency-path: editors/vscode/package-lock.json + - name: Set version from tag + working-directory: editors/vscode + run: npm pkg set version="${GITHUB_REF_NAME#v}" + - name: Install and package working-directory: editors/vscode run: | @@ -73,6 +77,10 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + - name: Set version from tag + working-directory: editors/datagrip + run: sed -i "s/^pluginVersion=.*/pluginVersion=${GITHUB_REF_NAME#v}/" gradle.properties + - name: Build plugin working-directory: editors/datagrip run: ./gradlew buildPlugin diff --git a/AGENTS.md b/AGENTS.md index f09bfda..91eb68e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,14 @@ testdata/corpus/ — real-world .pgsql procedures used as the safety/idempoten 3. **Idempotence**: `fmt(fmt(x)) == fmt(x)`. 4. **Graceful degradation**: any span the parser cannot handle is passed through verbatim rather than corrupted. +5. **Runtime safety gate**: every frontend (CLI `fmt`, LSP `textDocument/formatting` and + `rangeFormatting`) calls `format.SemanticallyEqual(src, out)` before writing or returning + formatted output. It re-lexes both sides and compares non-trivia token streams + (case-insensitive for identifiers/keywords, exact otherwise, recursing into dollar-quoted + bodies). If it ever returns false, the formatter has a bug — the caller must refuse to + write/emit the result and keep the original source, never guess or best-effort it. This is + not just a test assertion (`pkg/format/format_test.go`); it is enforced at runtime so a + formatter bug can never silently drop or alter code. ## Commands diff --git a/cmd/pgtidy/fmt.go b/cmd/pgtidy/fmt.go index 2905db5..73533a6 100644 --- a/cmd/pgtidy/fmt.go +++ b/cmd/pgtidy/fmt.go @@ -63,6 +63,10 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 2 } out := format.File(parser.Parse(string(src)), st) + if !format.SemanticallyEqual(string(src), out) { + _, _ = fmt.Fprintln(stderr, "pgtidy: refusing to format stdin: formatter safety check failed (output would change code content)") + return 2 + } switch { case check: if out != string(src) { @@ -86,6 +90,11 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int { continue } out := format.File(parser.Parse(string(src)), st) + if !format.SemanticallyEqual(string(src), out) { + _, _ = fmt.Fprintf(stderr, "pgtidy: refusing to format %s: formatter safety check failed (output would change code content)\n", path) + exit = 2 + continue + } changed := out != string(src) if changed { anyDiff = true diff --git a/pkg/format/format_test.go b/pkg/format/format_test.go index 31ee278..743c8d5 100644 --- a/pkg/format/format_test.go +++ b/pkg/format/format_test.go @@ -7,7 +7,6 @@ import ( "testing" "git.warky.dev/wdevs/pgtidy/pkg/config" - "git.warky.dev/wdevs/pgtidy/pkg/lexer" "git.warky.dev/wdevs/pgtidy/pkg/parser" ) @@ -199,48 +198,7 @@ func TestCorpusIdempotentAndSafe(t *testing.T) { t.Logf("formatted %d corpus files (idempotent + semantically equal)", seen) } -// semanticallyEqual compares the non-trivia token streams of two sources, -// treating unquoted identifiers/keywords case-insensitively and everything -// else (strings, numbers, operators, punctuation) exactly. Dollar-quoted body -// tokens are compared recursively so body whitespace normalization does not -// trigger a false failure. +// semanticallyEqual is a test-local alias for the exported safety check. func semanticallyEqual(a, b string) bool { - ta := significant(a) - tb := significant(b) - if len(ta) != len(tb) { - return false - } - for i := range ta { - if ta[i].Kind != tb[i].Kind { - return false - } - switch ta[i].Kind { - case lexer.Ident: - if !strings.EqualFold(ta[i].Text, tb[i].Text) { - return false - } - case lexer.DollarString: - _, innerA, _, okA := splitDollarQuote(ta[i].Text) - _, innerB, _, okB := splitDollarQuote(tb[i].Text) - if okA != okB || (okA && !semanticallyEqual(innerA, innerB)) { - return false - } - default: - if ta[i].Text != tb[i].Text { - return false - } - } - } - return true -} - -func significant(src string) []lexer.Token { - var out []lexer.Token - for _, t := range lexer.Lex(src) { - if t.Kind == lexer.EOF || t.IsTrivia() { - continue - } - out = append(out, t) - } - return out + return SemanticallyEqual(a, b) } diff --git a/pkg/format/safety.go b/pkg/format/safety.go new file mode 100644 index 0000000..f1629bd --- /dev/null +++ b/pkg/format/safety.go @@ -0,0 +1,61 @@ +package format + +import ( + "strings" + + "git.warky.dev/wdevs/pgtidy/pkg/lexer" +) + +// SemanticallyEqual reports whether a and b have the same non-trivia token +// stream, i.e. formatting may only ever change whitespace/comment trivia and +// layout — it must never add, remove, or alter a token of actual code. +// Unquoted identifiers/keywords compare case-insensitively (casing is a +// style choice); everything else (strings, numbers, operators, punctuation) +// must match exactly. Dollar-quoted body tokens are compared recursively so +// that independent body reformatting doesn't trigger a false failure. +// +// The CLI and LSP must call this before ever writing or emitting formatted +// output: if it returns false, the formatter has a bug and the original +// source must be kept, never the (corrupting) formatted output. +func SemanticallyEqual(a, b string) bool { + ta := significantTokens(a) + tb := significantTokens(b) + if len(ta) != len(tb) { + return false + } + for i := range ta { + if ta[i].Kind != tb[i].Kind { + return false + } + switch ta[i].Kind { + case lexer.Ident: + if !strings.EqualFold(ta[i].Text, tb[i].Text) { + return false + } + case lexer.DollarString: + _, innerA, _, okA := splitDollarQuote(ta[i].Text) + _, innerB, _, okB := splitDollarQuote(tb[i].Text) + if okA != okB || (okA && !SemanticallyEqual(innerA, innerB)) { + return false + } + default: + if ta[i].Text != tb[i].Text { + return false + } + } + } + return true +} + +// significantTokens lexes src and returns its tokens excluding EOF and trivia +// (whitespace/comments). +func significantTokens(src string) []lexer.Token { + var out []lexer.Token + for _, t := range lexer.Lex(src) { + if t.Kind == lexer.EOF || t.IsTrivia() { + continue + } + out = append(out, t) + } + return out +} diff --git a/pkg/lsp/server.go b/pkg/lsp/server.go index aea1c8e..becbb4f 100644 --- a/pkg/lsp/server.go +++ b/pkg/lsp/server.go @@ -124,6 +124,10 @@ func (s *server) handle(raw []byte) bool { s.reply(req.ID, []textEdit{}) return false } + if !format.SemanticallyEqual(text, formatted) { + s.reply(req.ID, []textEdit{}) + return false + } s.reply(req.ID, []textEdit{fullReplace(text, formatted)}) case "textDocument/rangeFormatting": var p rangeFormattingParams @@ -224,6 +228,9 @@ func (s *server) rangeFormat(text string, r lspRange) []textEdit { if formatted == text { return nil } + if !format.SemanticallyEqual(text, formatted) { + return nil + } // Split both versions into lines, keeping the trailing newline attached to // each element so that joining them reconstructs the original string.