Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@ packages, and tooling contracts may change before a stable release.
candidate function stays silent so the 501-stub workflow is unaffected; strict
production builds still fail closed via `backend_binding_required`. This is the
first slice of #328.
- Backend handler binding no longer hides failures behind silent fallbacks: a
handler declared in both same-package Go and an inline `go {}` block is
reported as `ambiguous_backend_handler` instead of silently preferring one
source; a sibling Go package that fails to compile keeps a "could not be
inspected" binding instead of falling back to an inline block and reporting a
misleading bound handler (the compile error is reported by `go_package_error`);
a failing `go list` for a same-package build function now surfaces its real
cause (for example a missing `go.mod`) rather than a generic "requires a
buildable Go package" message; and a component-script resolution error during
build now fails the build instead of silently omitting the page's component
scripts. The contract scanner's `go list -deps -export` failures now surface
their stderr instead of reaching the type checker as an opaque "exit status 1",
and an unknown build-data interpolation reference now reports whether the
missing name was a build-data field or a route param instead of always saying
"unknown route param". Generated-app module resolution no longer silently
drops the app module's `require`/`replace` when `go list -m` fails: if the
generated app imports app-owned packages it now fails with the real `go list`
error instead of producing a go.mod that fails to build with an opaque
"cannot find package".

### Known Gaps

Expand Down
9 changes: 8 additions & 1 deletion cmd/gowdk/go_bindings_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"encoding/json"
"errors"
"fmt"
"go/ast"
"go/parser"
Expand Down Expand Up @@ -309,7 +310,13 @@ func inspectSamePackageImportPath(sourcePath string) (string, error) {
command.Dir = dir
output, err := command.Output()
if err != nil {
return "", fmt.Errorf("same-package build data function requires a buildable Go package for %s", dir)
var exit *exec.ExitError
if errors.As(err, &exit) {
if stderr := strings.TrimSpace(string(exit.Stderr)); stderr != "" {
return "", fmt.Errorf("same-package build data function requires a buildable Go package for %s: %w\n%s", dir, err, stderr)
}
}
return "", fmt.Errorf("same-package build data function requires a buildable Go package for %s: %w", dir, err)
}
var info struct {
ImportPath string
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/diagnostic-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ Parser diagnostics emit stable codes for common unsupported syntax and keep
`invalid_go_endpoint_handler`, `malformed_go_endpoint_comment`,
`go_endpoint_parse_error`, `duplicate_go_endpoint_comment`,
`unsupported_action_method`, `backend_binding_required`,
`unsupported_backend_signature`, `unexported_backend_handler`.
`unsupported_backend_signature`, `unexported_backend_handler`,
`ambiguous_backend_handler`.
- Layouts, CSS, and cache: `duplicate_layout_id`, `unknown_layout_id`,
`invalid_css_selection`, `duplicate_css_selection`,
`revalidate_requires_cache`, `duplicate_revalidate_policy`.
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/go-interop.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,19 @@ the JSON report or running a strict production build:
- `unexported_backend_handler` — a same-named Go function exists but is not
exported, so binding cannot see it (for example `func submit` when the block
expects `Submit`).
- `ambiguous_backend_handler` — the same handler is declared in both
same-package Go and an inline `go {}` block. (When both live in the same
compiled package, Go's own redeclaration error surfaces first.)

A handler with no candidate function stays silent because the default workflow
generates 501 stubs for not-yet-implemented handlers; strict production builds
still fail closed through `backend_binding_required`.

When the sibling Go package fails to compile, binding does not fall back to an
inline `go {}` block and report a misleading bound handler: the load/action/API
binding stays "could not be inspected" and the package error itself is reported
by `go_package_error`.

## Load Functions

Request-time pages with `load {}` bind same-package functions named
Expand Down
40 changes: 40 additions & 0 deletions internal/appgen/appgen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3085,6 +3085,46 @@ func HomeTitle() string {
}
}

func TestIsLocalModuleImportPath(t *testing.T) {
cases := map[string]bool{
"context": false,
"net/http": false,
"github.com/cssbruno/gowdk": false,
"github.com/cssbruno/gowdk/runtime/response": false,
"example.com/site/content": true,
"github.com/acme/app/pages": true,
"": false,
}
for path, want := range cases {
if got := isLocalModuleImportPath(path); got != want {
t.Fatalf("isLocalModuleImportPath(%q) = %v, want %v", path, got, want)
}
}
}

func TestAppHasLocalModuleImportsFromInlineGoBlock(t *testing.T) {
ir := gwdkir.Program{
Pages: []gwdkir.Page{{
Package: "pages",
ID: "home",
Route: "/",
Blocks: gwdkir.Blocks{GoBlocks: []gwdkir.GoBlock{{
Body: `import local "example.com/site/local"

func HomeTitle() string { return local.Suffix() }`,
}}},
}},
}
if !appHasLocalModuleImports(Options{IR: &ir}) {
t.Fatal("expected an app importing example.com/site/local to need the local module")
}

empty := gwdkir.Program{Pages: []gwdkir.Page{{Package: "pages", ID: "home", Route: "/"}}}
if appHasLocalModuleImports(Options{IR: &empty}) {
t.Fatal("expected an app with no app-owned imports not to need the local module")
}
}

func TestGenerateWritesAddonGoBlockConsumerFiles(t *testing.T) {
root := t.TempDir()
outputDir := filepath.Join(root, "dist")
Expand Down
84 changes: 70 additions & 14 deletions internal/appgen/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package appgen

import (
"encoding/json"
"errors"
"fmt"
"go/ast"
"os"
Expand Down Expand Up @@ -39,7 +40,17 @@ func moduleSource(options Options) (string, error) {
}
lines = append(lines, "", "replace "+gowdkRuntimeModulePath+" => "+filepath.ToSlash(root))
}
if appModule, ok := currentAppModule(); ok && appModule.Path != gowdkRuntimeModulePath && optionsUsesModuleImports(options, appModule.Path) {
appModule, err := currentAppModule()
if err != nil {
// A missing/broken main module is only fatal when the generated app
// imports app-owned packages: without the module path we cannot add the
// require/replace, so the generated app would otherwise fail to build
// later with an opaque "cannot find package" error. When the app imports
// nothing app-owned, the module is not needed and the failure is ignored.
if appHasLocalModuleImports(options) {
return "", fmt.Errorf("cannot determine the app Go module for generated app imports: %w", err)
}
} else if appModule.Path != gowdkRuntimeModulePath && optionsUsesModuleImports(options, appModule.Path) {
lines = append(lines,
"",
"require "+appModule.Path+" v0.0.0",
Expand All @@ -55,43 +66,88 @@ type appModuleInfo struct {
Dir string
}

func currentAppModule() (appModuleInfo, bool) {
func currentAppModule() (appModuleInfo, error) {
command := exec.Command("go", "list", "-m", "-json")
output, err := command.Output()
if err != nil {
return appModuleInfo{}, false
return appModuleInfo{}, goListModuleError(err)
}
var info appModuleInfo
if err := json.Unmarshal(output, &info); err != nil {
return appModuleInfo{}, false
return appModuleInfo{}, fmt.Errorf("parse go list -m output: %w", err)
}
if strings.TrimSpace(info.Path) == "" || strings.TrimSpace(info.Dir) == "" {
return appModuleInfo{}, false
return appModuleInfo{}, fmt.Errorf("go list -m did not report a main module path and directory")
}
return info, nil
}

// goListModuleError surfaces the underlying go list -m failure, including its
// stderr (e.g. a missing go.mod), instead of an opaque exit status.
func goListModuleError(err error) error {
var exit *exec.ExitError
if errors.As(err, &exit) {
if stderr := strings.TrimSpace(string(exit.Stderr)); stderr != "" {
return fmt.Errorf("%w\n%s", err, stderr)
}
}
return err
}

// appHasLocalModuleImports reports whether the generated app imports any
// app-owned package (a module-path import that is not stdlib and not the GOWDK
// runtime module), which is exactly the case that needs the main module's
// require/replace lines.
func appHasLocalModuleImports(options Options) bool {
for path := range appBackendImportPaths(options) {
if isLocalModuleImportPath(path) {
return true
}
}
return false
}

func isLocalModuleImportPath(path string) bool {
path = strings.TrimSpace(path)
if path == "" || path == gowdkRuntimeModulePath || strings.HasPrefix(path, gowdkRuntimeModulePath+"/") {
return false
}
first := path
if index := strings.Index(path, "/"); index >= 0 {
first = path[:index]
}
return info, true
// Standard-library import paths have no dot in their first segment.
return strings.Contains(first, ".")
}

func optionsUsesModuleImports(options Options, modulePath string) bool {
modulePath = strings.TrimRight(strings.TrimSpace(modulePath), "/")
if modulePath == "" {
return false
}
for importPath := range backendImports(options.Actions, options.APIs, options.Fragments, options.SSR) {
for importPath := range appBackendImportPaths(options) {
if importPath == modulePath || strings.HasPrefix(importPath, modulePath+"/") {
return true
}
}
return false
}

// appBackendImportPaths collects every Go import the generated app's backend
// glue pulls in: request-time handlers, contract exposures, and inline go {}
// blocks.
func appBackendImportPaths(options Options) map[string]bool {
paths := map[string]bool{}
for importPath := range backendImports(options.Actions, options.APIs, options.Fragments, options.SSR) {
paths[importPath] = true
}
for importPath := range backendContractImports(executableContractExposures(backendAdapterIR(options).ContractExposures)) {
if importPath == modulePath || strings.HasPrefix(importPath, modulePath+"/") {
return true
}
paths[importPath] = true
}
for importPath := range inlineGoBlockImports(options.IR) {
if importPath == modulePath || strings.HasPrefix(importPath, modulePath+"/") {
return true
}
paths[importPath] = true
}
return false
return paths
}

func inlineGoBlockImports(ir *gwdkir.Program) map[string]bool {
Expand Down
7 changes: 6 additions & 1 deletion internal/buildgen/build_data_interpolate.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,20 @@ func interpolateBuildValue(value string, routeParams map[string]string, data map
value = value[end+1:]
continue
}
wasField := false
if field, ok := buildFieldExpression(name); ok {
name = field
wasField = true
}
resolved, ok := data[name]
if !ok {
resolved, ok = routeParams[name]
}
if !ok {
return "", fmt.Errorf("unknown route param %q", name)
if wasField {
return "", fmt.Errorf("unknown build data field %q", name)
}
return "", fmt.Errorf("unknown build data field or route param %q", name)
}
parts = append(parts, resolved)
value = value[end+1:]
Expand Down
44 changes: 42 additions & 2 deletions internal/buildgen/build_data_routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,27 @@ import (
"github.com/cssbruno/gowdk/internal/gwdkir"
)

func TestSamePackageImportPathSurfacesGoListError(t *testing.T) {
// A temp dir outside any Go module makes `go list` fail with a clear reason
// (no go.mod), which must be surfaced rather than collapsed into a generic
// "requires a buildable Go package" message.
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "page.go"), []byte("package app\n"), 0o644); err != nil {
t.Fatal(err)
}

_, err := samePackageImportPath(filepath.Join(root, "home.page.gwdk"))
if err == nil {
t.Fatal("expected a same-package build data error outside a Go module")
}
if !strings.Contains(err.Error(), "buildable Go package") {
t.Fatalf("expected the user-facing message, got: %v", err)
}
if !strings.Contains(err.Error(), "go.mod") {
t.Fatalf("expected the underlying go list error (go.mod not found) to be surfaced, got: %v", err)
}
}

func TestBuildRendersLiteralBuildData(t *testing.T) {
outputDir := t.TempDir()
app := gwdkanalysis.Sources{
Expand Down Expand Up @@ -497,13 +518,32 @@ func TestBuildRejectsUnknownRouteParamInBuildDataValue(t *testing.T) {

_, err := Build(gowdk.Config{}, app, outputDir)
if err == nil {
t.Fatal("expected unknown route param error")
t.Fatal("expected unknown interpolation reference error")
}
if !strings.Contains(err.Error(), `build field title: unknown route param "missing"`) {
if !strings.Contains(err.Error(), `build field title: unknown build data field or route param "missing"`) {
t.Fatalf("unexpected error: %v", err)
}
}

func TestInterpolateBuildValueReportsAccurateUnknownReference(t *testing.T) {
data := map[string]string{"title": "Hi"}
params := map[string]string{"slug": "x"}
cases := []struct {
value string
want string
}{
{`{field("missing")}`, `unknown build data field "missing"`},
{`{missing}`, `unknown build data field or route param "missing"`},
{`{param("missing")}`, `unknown route param "missing"`},
}
for _, tc := range cases {
_, err := interpolateBuildValue(tc.value, params, data)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("interpolateBuildValue(%q): want error containing %q, got %v", tc.value, tc.want, err)
}
}
}

func TestBuildRendersExplicitRouteParamReferences(t *testing.T) {
outputDir := t.TempDir()
app := gwdkanalysis.Sources{Pages: []gwdkir.Page{{
Expand Down
27 changes: 22 additions & 5 deletions internal/buildgen/build_data_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package buildgen
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"go/ast"
"go/format"
Expand Down Expand Up @@ -222,7 +223,10 @@ func inlineBuildDataMainDecl(function string, returnsError bool) ast.Decl {

func samePackageImportPath(source string) (string, error) {
dir := sourceDir(source)
info := goListDir(dir)
info, err := goListDir(dir)
if err != nil {
return "", fmt.Errorf("same-package build data function requires a buildable Go package for %s: %w", dir, err)
}
if strings.TrimSpace(info.ImportPath) == "" {
return "", fmt.Errorf("same-package build data function requires a buildable Go package for %s", dir)
}
Expand All @@ -233,18 +237,31 @@ type goListDirInfo struct {
ImportPath string
}

func goListDir(dir string) goListDirInfo {
func goListDir(dir string) (goListDirInfo, error) {
command := exec.Command("go", "list", "-json", ".")
command.Dir = dir
output, err := command.Output()
if err != nil {
return goListDirInfo{}
return goListDirInfo{}, goListError(err)
}
var info goListDirInfo
if err := json.Unmarshal(output, &info); err != nil {
return goListDirInfo{}
return goListDirInfo{}, fmt.Errorf("parse go list output: %w", err)
}
return info, nil
}

// goListError surfaces the underlying go list failure, including its stderr,
// instead of collapsing it into a generic message that hides the cause (for
// example a sibling package compile error or a missing go.mod).
func goListError(err error) error {
var exit *exec.ExitError
if errors.As(err, &exit) {
if stderr := strings.TrimSpace(string(exit.Stderr)); stderr != "" {
return fmt.Errorf("%w\n%s", err, stderr)
}
}
return info
return err
}

func sourceDir(source string) string {
Expand Down
Loading