Skip to content

Commit 2ea099f

Browse files
adnaanclaude
andauthored
feat: add unified validation engine package (#116)
* feat: add unified validation engine package (#57) Add internal/validation/ package that validates generated LiveTemplate apps programmatically: go.mod structure, template syntax, SQL migrations (via in-memory SQLite), and compilation. Reuses existing validator types and runs checks sequentially with context cancellation support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review feedback on validation engine - Fix nil dereference: split err/info guard in Walk callback - Use filepath.WalkDir with context cancellation support - Skip .git, vendor, node_modules directories during template walk - Make go mod tidy opt-in (RunGoModTidy) to avoid mutating app dir - Make sqlc opt-in (RunSqlc), prefer local binary via exec.LookPath - Treat SQL migration errors as warnings (SQLite dialect may differ) - Remove spurious no-require warning from GoModCheck - Replace flaky time.Sleep timeout test with immediate cancel - Use self-contained fixture in integration test for full engine coverage - Replace Unicode arrow marker with plain > for terminal compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address second round of review feedback - Fix CRLF corruption: normalize \r\n to \n in parseUpStatements - Surface WalkDir errors as warnings instead of silently discarding - Use errors.Is(err, fs.ErrNotExist) instead of os.IsNotExist - Rename misleading TestEngine_Timeout to TestEngine_CancelledContext Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address third round of review feedback - Add AddErrorWithHint to validator.ValidationResult, use in templates.go instead of direct struct mutation - Suppress mismatched-delimiter warning when template parse succeeds (avoids false positives from }} in JS/CSS) - Preserve comments inside StatementBegin/End blocks for accurate line numbers in SQL error reporting - Merge redundant cancellation tests, add TestEngine_WithTimeout with a stub check to exercise the deadline wiring - Document that Validate() includes compilation and may be slow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: warn on unclosed StatementBegin blocks, document html/template usage - Detect unclosed -- +goose StatementBegin blocks and warn - Document html/template choice matching generator/validate.go convention - Add tests for unclosed StatementBegin detection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: handle trailing SQL comments in semicolon detection, reduce stub delay - Strip inline trailing comments (e.g. `INSERT ...; -- seed`) before checking for semicolon-terminated statements in migration parser - Reduce stubCheck delay from 5s to 100ms to avoid goroutine leak - Add TestParseUpStatements_TrailingComment test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: short-circuit after sqlc failure, surface WalkDir entry errors - Return early from CompilationCheck if runSqlc records errors, since go build results would be unreliable with missing generated files - Surface WalkDir entry-level errors (e.g. permission denied) as warnings instead of silently swallowing them - Remove redundant sort.Strings — os.ReadDir already returns sorted - Fix misleading "Prefer" comment in runSqlc (there is no fallback) - Widen timeout test margins (50ms/500ms) for loaded CI runners Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: document inline comment limitation, pre-size env slice - Add comment noting that inline comment stripping in migration parser can misfire on " --" inside SQL string literals (rare edge case) - Pre-size envWithGOWORKOff slice to avoid incremental reallocations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: warn on missing sqlc binary, enable SQLite FK constraints - Change missing sqlc binary from AddInfo to AddWarning when RunSqlc is explicitly enabled (callers should know the check was skipped) - Enable PRAGMA foreign_keys = ON so out-of-order FK migrations produce warnings during validation - Add comment explaining compiler error pattern fallback behavior - Move performance caveat to first line of Validate() doc Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: strip ./ prefix from compiler error paths, add sqlc test - Strip leading ./ from go build error paths for consistency with other checks that use clean relative paths - Add TestCompilationCheck_SqlcOptIn to exercise the RunSqlc path Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: document design decisions for code duplication and check independence - Note that extractLineNumber/sourceContext parallel implementation in generator/validate.go is intentional (avoids coupling, follow-up) - Document that Engine.Run runs all checks independently so callers get a complete picture of issues Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: skip flushing partial SQL at Down boundary in unclosed blocks - Don't flush accumulated SQL when hitting -- +goose Down inside an unclosed StatementBegin block; the partial SQL would produce a spurious SQLite warning on top of the structural warning - Add TestParseUpStatements_MultipleStatements to verify semicolon- delimited flush-and-reset with 3 statements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: record cancellation in TemplateCheck, skip EOF flush in unclosed blocks - After WalkDir returns, check ctx.Err() and record cancellation so callers know the result is partial (prevents silent incomplete result) - Skip flushing partial SQL at EOF when inside an unclosed StatementBegin block (matches the Down-boundary guard) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: guard StatementBegin on inUp, tighten sqlc test assertion - Only set inStatementBlock when inside -- +goose Up section, preventing malformed migrations from corrupting block state - Improve TestCompilationCheck_SqlcOptIn to assert specific warning or error depending on whether sqlc is in PATH Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b0365d8 commit 2ea099f

9 files changed

Lines changed: 1361 additions & 4 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ require (
1717
github.com/pressly/goose/v3 v3.26.0
1818
github.com/stretchr/testify v1.11.0
1919
golang.org/x/crypto v0.43.0
20+
golang.org/x/mod v0.33.0
2021
golang.org/x/net v0.46.0
2122
golang.org/x/term v0.36.0
2223
golang.org/x/text v0.30.0

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,8 @@ golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
257257
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
258258
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
259259
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
260-
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
261-
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
260+
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
261+
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
262262
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
263263
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
264264
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
@@ -275,8 +275,8 @@ golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
275275
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
276276
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
277277
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
278-
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
279-
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
278+
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
279+
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
280280
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
281281
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
282282
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

internal/validation/compilation.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package validation
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"regexp"
10+
"strconv"
11+
"strings"
12+
13+
"github.com/livetemplate/lvt/internal/validator"
14+
)
15+
16+
// compilerErrorPattern matches Go compiler errors like "file.go:10:5: message".
17+
// This requires the file:line:col format. Diagnostics that omit the column
18+
// (e.g. linker messages) fall through to the raw-output fallback intentionally.
19+
var compilerErrorPattern = regexp.MustCompile(`^(.+?\.go):(\d+):\d+:\s+(.+)$`)
20+
21+
// CompilationCheck runs sqlc generate (optional), go mod tidy (opt-in), and
22+
// go build on an app directory. By default only go build runs; set
23+
// RunGoModTidy or RunSqlc to true to enable those steps.
24+
type CompilationCheck struct {
25+
// RunGoModTidy runs go mod tidy before building. Off by default because
26+
// it mutates go.mod/go.sum in the target directory.
27+
RunGoModTidy bool
28+
// RunSqlc runs sqlc generate before building (if sqlc.yaml exists and
29+
// queries.sql has content). Off by default because it requires a locally
30+
// installed sqlc binary.
31+
RunSqlc bool
32+
}
33+
34+
func (c *CompilationCheck) Name() string { return "compilation" }
35+
36+
func (c *CompilationCheck) Run(ctx context.Context, appPath string) *validator.ValidationResult {
37+
result := validator.NewValidationResult()
38+
env := envWithGOWORKOff()
39+
40+
// sqlc generate (opt-in)
41+
if c.RunSqlc {
42+
c.runSqlc(ctx, appPath, env, result)
43+
if result.HasErrors() {
44+
return result // sqlc failure means build results would be unreliable
45+
}
46+
}
47+
48+
// go mod tidy (opt-in — mutates go.mod/go.sum)
49+
if c.RunGoModTidy {
50+
tidyCmd := exec.CommandContext(ctx, "go", "mod", "tidy")
51+
tidyCmd.Dir = appPath
52+
tidyCmd.Env = env
53+
if output, err := tidyCmd.CombinedOutput(); err != nil {
54+
result.AddError("go mod tidy failed: "+strings.TrimSpace(string(output)), "go.mod", 0)
55+
return result
56+
}
57+
}
58+
59+
// go build ./...
60+
buildCmd := exec.CommandContext(ctx, "go", "build", "./...")
61+
buildCmd.Dir = appPath
62+
buildCmd.Env = env
63+
if output, err := buildCmd.CombinedOutput(); err != nil {
64+
parseCompilerErrors(string(output), result)
65+
if result.ErrorCount() == 0 {
66+
// Couldn't parse structured errors — add the raw output.
67+
result.AddError("compilation failed: "+strings.TrimSpace(string(output)), "", 0)
68+
}
69+
}
70+
71+
return result
72+
}
73+
74+
func (c *CompilationCheck) runSqlc(ctx context.Context, appPath string, env []string, result *validator.ValidationResult) {
75+
sqlcCfg := filepath.Join(appPath, "database/sqlc.yaml")
76+
if _, err := os.Stat(sqlcCfg); err != nil {
77+
return // no sqlc config — nothing to do
78+
}
79+
if !hasQueries(filepath.Join(appPath, "database/queries.sql")) {
80+
return // no actual queries
81+
}
82+
83+
// Require a locally installed sqlc binary; skip if not found.
84+
sqlcBin, err := exec.LookPath("sqlc")
85+
if err != nil {
86+
result.AddWarning("sqlc not found in PATH, skipping sqlc generate", "database/sqlc.yaml", 0)
87+
return
88+
}
89+
90+
cmd := exec.CommandContext(ctx, sqlcBin, "generate", "-f", sqlcCfg)
91+
cmd.Dir = appPath
92+
cmd.Env = env
93+
if output, err := cmd.CombinedOutput(); err != nil {
94+
result.AddError("sqlc generate failed: "+strings.TrimSpace(string(output)), "database/sqlc.yaml", 0)
95+
}
96+
}
97+
98+
// envWithGOWORKOff returns the current env with GOWORK=off.
99+
func envWithGOWORKOff() []string {
100+
environ := os.Environ()
101+
env := make([]string, 0, len(environ)+1)
102+
for _, e := range environ {
103+
if !strings.HasPrefix(e, "GOWORK=") {
104+
env = append(env, e)
105+
}
106+
}
107+
return append(env, "GOWORK=off")
108+
}
109+
110+
// hasQueries returns true if the file contains at least one non-comment line.
111+
func hasQueries(path string) bool {
112+
f, err := os.Open(path)
113+
if err != nil {
114+
return false
115+
}
116+
defer f.Close()
117+
118+
scanner := bufio.NewScanner(f)
119+
for scanner.Scan() {
120+
line := strings.TrimSpace(scanner.Text())
121+
if line != "" && !strings.HasPrefix(line, "--") {
122+
return true
123+
}
124+
}
125+
// If scanner hit an I/O error, conservatively return false.
126+
return false
127+
}
128+
129+
// parseCompilerErrors extracts file:line errors from go build output.
130+
func parseCompilerErrors(output string, result *validator.ValidationResult) {
131+
for _, line := range strings.Split(output, "\n") {
132+
line = strings.TrimSpace(line)
133+
if m := compilerErrorPattern.FindStringSubmatch(line); m != nil {
134+
lineNum, _ := strconv.Atoi(m[2])
135+
file := strings.TrimPrefix(m[1], "./")
136+
result.AddError(m[3], file, lineNum)
137+
}
138+
}
139+
}

internal/validation/go_mod.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package validation
2+
3+
import (
4+
"context"
5+
"errors"
6+
"io/fs"
7+
"os"
8+
"path/filepath"
9+
10+
"github.com/livetemplate/lvt/internal/validator"
11+
"golang.org/x/mod/modfile"
12+
)
13+
14+
// GoModCheck validates the go.mod file in an app directory.
15+
type GoModCheck struct{}
16+
17+
func (c *GoModCheck) Name() string { return "go.mod" }
18+
19+
func (c *GoModCheck) Run(_ context.Context, appPath string) *validator.ValidationResult {
20+
result := validator.NewValidationResult()
21+
goModPath := filepath.Join(appPath, "go.mod")
22+
23+
data, err := os.ReadFile(goModPath)
24+
if err != nil {
25+
if errors.Is(err, fs.ErrNotExist) {
26+
result.AddError("go.mod not found", "go.mod", 0)
27+
} else {
28+
result.AddError("failed to read go.mod: "+err.Error(), "go.mod", 0)
29+
}
30+
return result
31+
}
32+
33+
f, err := modfile.Parse("go.mod", data, nil)
34+
if err != nil {
35+
result.AddError("go.mod parse error: "+err.Error(), "go.mod", 0)
36+
return result
37+
}
38+
39+
if f.Module == nil || f.Module.Mod.Path == "" {
40+
result.AddError("go.mod has no module path", "go.mod", 0)
41+
}
42+
43+
if f.Go == nil || f.Go.Version == "" {
44+
result.AddWarning("go.mod has no go version directive", "go.mod", 0)
45+
}
46+
47+
return result
48+
}

0 commit comments

Comments
 (0)