2 Commits
Author SHA1 Message Date
Hein 25d40ad515 chore(release): bump version to 0.0.7
CI / Build (push) Successful in 20s
Release / Release (push) Successful in 36s
Release / Debian packages (push) Successful in 33s
Release / VSCode Extension (push) Successful in 35s
Release / AUR package (push) Successful in 55s
Release / RPM package (push) Successful in 56s
Release / Windows installer (push) Successful in 59s
Release / DataGrip Plugin (push) Successful in 4m29s
CI / Test (push) Successful in 33s
Release / Test (push) Successful in 31s
2026-07-17 14:29:45 +02:00
Hein bac966c2ac feat(format): add runtime semantic-equality safety gate
CI / Build (push) Successful in 23s
CI / Test (push) Successful in 31s
2026-07-17 14:29:24 +02:00
9 changed files with 109 additions and 46 deletions
+12
View File
@@ -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
+8
View File
@@ -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
+8
View File
@@ -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
+9
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=pgtidy-bin
pkgver=0.0.6
pkgver=0.0.7
pkgrel=1
pkgdesc="PostgreSQL SQL formatter and linter"
arch=('x86_64' 'aarch64')
+1 -1
View File
@@ -1,5 +1,5 @@
Name: pgtidy
Version: 0.0.6
Version: 0.0.7
Release: 1%{?dist}
Summary: PostgreSQL SQL formatter and linter
+2 -44
View File
@@ -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)
}
+61
View File
@@ -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
}
+7
View File
@@ -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.