Skip to content

Commit 6ddbdee

Browse files
authored
introduce phase typed program boundaries (#754)
1 parent 44dd468 commit 6ddbdee

39 files changed

Lines changed: 1070 additions & 344 deletions

docs/compiler/pipeline.md

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ project config plus explicit file paths or configured discovery
88
-> lower parsed records into internal/gwdkir.Program
99
-> enrich IR with Go endpoint discovery and backend bindings
1010
-> validate IR invariants, render rules, routes, endpoints, packages, and assets
11+
into a compiler-owned ValidatedProgram phase token
1112
-> emit diagnostics, public manifest JSON, site-map JSON, route/endpoint metadata, build-output artifacts, browser runtime assets, or generated app output
1213
```
1314

@@ -45,19 +46,34 @@ boundaries so one syntax error does not hide later declarations in the same
4546
file.
4647

4748
`internal/gwdkanalysis` assembles parsed page, component, and layout records
48-
into `internal/gwdkir.Program`. The IR models packages, source
49-
files, page routes, backend endpoints from `.gwdk` declarations or explicit Go
50-
comments, templates, client behavior, source-selected assets, asset scope/hash
51-
metadata, parsed view nodes, typed literal records, and imported build-data call
52-
metadata. `gwdkir.Blocks` retains source bodies for spans and
53-
compatibility, but the parser-lowered handoff also carries parsed `view {}`
49+
into `internal/gwdkir.Program`. `internal/compiler.AnalyzedProgram` wraps that
50+
IR after enrichment, and `internal/compiler.ValidatedProgram` is the opaque
51+
phase token returned only by compiler validation. Build-output fast paths accept
52+
`ValidatedProgram`; raw `gwdkir.Program` entrypoints must run compiler
53+
validation before emission. Static build output resolves page artifacts, CSS,
54+
assets, manifests, and report metadata into `internal/buildgen.BuildPlan`
55+
before writing files. Generated app output resolves auto routes, endpoint
56+
projections, SSR artifacts, sitemap data, and generator-local validation into
57+
`internal/appgen.ApplicationPlan` before writing files.
58+
59+
The IR models packages, source files, page routes, backend endpoints from
60+
`.gwdk` declarations or explicit Go comments, templates, client behavior,
61+
source-selected assets, asset scope/hash metadata, parsed view nodes, typed
62+
literal records, and imported build-data call metadata.
63+
64+
`gwdkir.Blocks` retains source bodies for spans, formatting, inspection, and
65+
compatibility only. Parser/analyzer lowering must populate parsed `view {}`
5466
nodes, ordered literal `paths {}`/`build {}` records, and imported build-data
55-
call metadata; downstream passes should consume those typed fields before
56-
falling back to raw bodies. Build, memory build, incremental SPA build, SSR
57-
artifact, generated app planning, route reports, LSP metadata, and the public
58-
`gowdk manifest` report consume `internal/gwdkir.Program` and own their output
59-
planning separately. Route and asset manifests are generated output artifacts,
60-
not compiler handoff records.
67+
call metadata before validation. Downstream validation and generated-output
68+
planning consume those typed fields; raw block bodies are not a semantic fallback
69+
after lowering. `gwdkir.CheckInvariants` rejects non-empty supported `view {}`
70+
bodies that reach the IR without parsed view nodes.
71+
72+
Build, memory build, incremental SPA build, SSR artifact, generated app
73+
planning, route reports, LSP metadata, and the public `gowdk manifest` report
74+
consume `internal/gwdkir.Program` and own their output planning separately.
75+
Route and asset manifests are generated output artifacts, not compiler handoff
76+
records.
6177
Generated app Go, backend adapter Go, build-data helper Go, and starter config
6278
Go are constructed as Go ASTs, printed, and formatted before use or write.
6379

internal/appgen/appgen.go

Lines changed: 83 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,77 @@ const (
2121
modFileName = "go.mod"
2222
)
2323

24+
var errInvalidApplicationPlan = fmt.Errorf("application plan was not constructed by appgen planning")
25+
2426
// Generate writes a self-contained Go app that embeds outputDir.
2527
func Generate(outputDir, appDir string) (Result, error) {
2628
return GenerateWithOptions(outputDir, appDir, Options{})
2729
}
2830

31+
// PlanApplication resolves and validates generated app options before emission.
32+
func PlanApplication(outputDir string, options Options) (ApplicationPlan, error) {
33+
if strings.TrimSpace(outputDir) == "" {
34+
return ApplicationPlan{}, fmt.Errorf("build output directory is required")
35+
}
36+
absOutput, err := filepath.Abs(outputDir)
37+
if err != nil {
38+
return ApplicationPlan{}, err
39+
}
40+
planned, err := resolveOptions(absOutput, options)
41+
if err != nil {
42+
return ApplicationPlan{}, err
43+
}
44+
if err := validateAppPlanOptions(planned, true); err != nil {
45+
return ApplicationPlan{}, err
46+
}
47+
return ApplicationPlan{options: planned, outputDir: absOutput, valid: true}, nil
48+
}
49+
50+
// PlanBackendApplication resolves and validates backend-only generated app
51+
// options before emission.
52+
func PlanBackendApplication(options Options) (ApplicationPlan, error) {
53+
planned, err := resolveBackendOptions(options)
54+
if err != nil {
55+
return ApplicationPlan{}, err
56+
}
57+
if err := validateAppPlanOptions(planned, false); err != nil {
58+
return ApplicationPlan{}, err
59+
}
60+
return ApplicationPlan{options: planned, backendOnly: true, valid: true}, nil
61+
}
62+
63+
func validateAppPlanOptions(options Options, includeSSR bool) error {
64+
if err := validateActionEndpoints(options.Actions); err != nil {
65+
return err
66+
}
67+
if err := validateAPIEndpoints(options.APIs); err != nil {
68+
return err
69+
}
70+
if err := validateFragmentEndpoints(options.Fragments); err != nil {
71+
return err
72+
}
73+
if err := validateContractRoutes(options.IR); err != nil {
74+
return err
75+
}
76+
if includeSSR {
77+
if err := validateSSRRoutes(options.SSR); err != nil {
78+
return err
79+
}
80+
}
81+
return validateCORSConfig(options)
82+
}
83+
2984
// GenerateWithOptions writes a self-contained Go app that embeds outputDir.
3085
func GenerateWithOptions(outputDir, appDir string, options Options) (result Result, err error) {
86+
plan, err := PlanApplication(outputDir, options)
87+
if err != nil {
88+
return Result{}, err
89+
}
90+
return GenerateWithPlan(outputDir, appDir, plan)
91+
}
92+
93+
// GenerateWithPlan writes a self-contained Go app from an application plan.
94+
func GenerateWithPlan(outputDir, appDir string, plan ApplicationPlan) (result Result, err error) {
3195
defer recoverGeneratedIdentifierError(&err)
3296

3397
if strings.TrimSpace(outputDir) == "" {
@@ -48,26 +112,14 @@ func GenerateWithOptions(outputDir, appDir string, options Options) (result Resu
48112
if err := validateDirectories(absOutput, absApp); err != nil {
49113
return Result{}, err
50114
}
51-
options, err = resolveOptions(absOutput, options)
52-
if err != nil {
53-
return Result{}, err
54-
}
55-
if err := validateActionEndpoints(options.Actions); err != nil {
56-
return Result{}, err
57-
}
58-
if err := validateAPIEndpoints(options.APIs); err != nil {
59-
return Result{}, err
60-
}
61-
if err := validateFragmentEndpoints(options.Fragments); err != nil {
62-
return Result{}, err
115+
if plan.backendOnly {
116+
return Result{}, fmt.Errorf("backend application plan cannot be used for embedded app generation")
63117
}
64-
if err := validateContractRoutes(options.IR); err != nil {
65-
return Result{}, err
66-
}
67-
if err := validateSSRRoutes(options.SSR); err != nil {
68-
return Result{}, err
118+
if plan.outputDir != "" && filepath.Clean(plan.outputDir) != filepath.Clean(absOutput) {
119+
return Result{}, fmt.Errorf("application plan output directory %q does not match build output directory %q", plan.outputDir, absOutput)
69120
}
70-
if err := validateCORSConfig(options); err != nil {
121+
options, err := plan.optionsForEmit()
122+
if err != nil {
71123
return Result{}, err
72124
}
73125

@@ -153,6 +205,16 @@ func GenerateWithOptions(outputDir, appDir string, options Options) (result Resu
153205
// GenerateBackendWithOptions writes a generated Go app that serves only
154206
// request-time backend routes for feature-bound actions and APIs.
155207
func GenerateBackendWithOptions(appDir string, options Options) (result Result, err error) {
208+
plan, err := PlanBackendApplication(options)
209+
if err != nil {
210+
return Result{}, err
211+
}
212+
return GenerateBackendWithPlan(appDir, plan)
213+
}
214+
215+
// GenerateBackendWithPlan writes a generated Go app that serves only
216+
// request-time backend routes from an application plan.
217+
func GenerateBackendWithPlan(appDir string, plan ApplicationPlan) (result Result, err error) {
156218
defer recoverGeneratedIdentifierError(&err)
157219

158220
if strings.TrimSpace(appDir) == "" {
@@ -162,24 +224,12 @@ func GenerateBackendWithOptions(appDir string, options Options) (result Result,
162224
if err != nil {
163225
return Result{}, err
164226
}
165-
options, err = resolveBackendOptions(options)
227+
options, err := plan.optionsForEmit()
166228
if err != nil {
167229
return Result{}, err
168230
}
169-
if err := validateActionEndpoints(options.Actions); err != nil {
170-
return Result{}, err
171-
}
172-
if err := validateAPIEndpoints(options.APIs); err != nil {
173-
return Result{}, err
174-
}
175-
if err := validateFragmentEndpoints(options.Fragments); err != nil {
176-
return Result{}, err
177-
}
178-
if err := validateContractRoutes(options.IR); err != nil {
179-
return Result{}, err
180-
}
181-
if err := validateCORSConfig(options); err != nil {
182-
return Result{}, err
231+
if !plan.backendOnly {
232+
return Result{}, fmt.Errorf("embedded application plan cannot be used for backend app generation")
183233
}
184234
if err := os.MkdirAll(absApp, 0o755); err != nil {
185235
return Result{}, err

internal/appgen/appgen_test.go

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,15 +146,15 @@ func TestGenerateWritesDynamicSitemapRoute(t *testing.T) {
146146
writeTestFile(t, filepath.Join(outputDir, "sitemap.xml"), "<urlset></urlset>")
147147
writeTestFile(t, filepath.Join(outputDir, "gowdk-assets.json"), `{"version":1}`)
148148

149-
ir := gwdkir.Program{Pages: []gwdkir.Page{{
149+
ir := gwdkanalysis.BuildProgram(gowdk.Config{}, gwdkanalysis.Sources{Pages: []gwdkir.Page{{
150150
ID: "home",
151151
Route: "/",
152152
Guards: []string{"public"},
153153
Blocks: gwdkir.Blocks{
154154
View: true,
155155
ViewBody: `<main>Home</main>`,
156156
},
157-
}}}
157+
}}})
158158
config := gowdk.Config{Addons: []gowdk.Addon{seo.Addon(seo.Options{
159159
BaseURL: "https://example.com",
160160
DynamicSitemap: gowdk.SEODynamicSitemap{
@@ -2022,6 +2022,59 @@ func TestGenerateRejectsInvalidCORSConfig(t *testing.T) {
20222022
}
20232023
}
20242024

2025+
func TestGenerateWithPlanRejectsZeroValue(t *testing.T) {
2026+
root := t.TempDir()
2027+
outputDir := filepath.Join(root, "dist")
2028+
appDir := filepath.Join(root, "generated-app")
2029+
writeTestFile(t, filepath.Join(outputDir, "index.html"), "<main>Home</main>")
2030+
2031+
_, err := GenerateWithPlan(outputDir, appDir, ApplicationPlan{})
2032+
if err == nil {
2033+
t.Fatal("expected zero-value application plan error")
2034+
}
2035+
if !strings.Contains(err.Error(), "application plan was not constructed") {
2036+
t.Fatalf("unexpected error: %v", err)
2037+
}
2038+
}
2039+
2040+
func TestGenerateWithPlanRejectsBackendPlan(t *testing.T) {
2041+
root := t.TempDir()
2042+
outputDir := filepath.Join(root, "dist")
2043+
appDir := filepath.Join(root, "generated-app")
2044+
writeTestFile(t, filepath.Join(outputDir, "index.html"), "<main>Home</main>")
2045+
plan, err := PlanBackendApplication(Options{})
2046+
if err != nil {
2047+
t.Fatal(err)
2048+
}
2049+
2050+
_, err = GenerateWithPlan(outputDir, appDir, plan)
2051+
if err == nil {
2052+
t.Fatal("expected backend plan lane error")
2053+
}
2054+
if !strings.Contains(err.Error(), "backend application plan cannot be used") {
2055+
t.Fatalf("unexpected error: %v", err)
2056+
}
2057+
}
2058+
2059+
func TestGenerateBackendWithPlanRejectsEmbeddedPlan(t *testing.T) {
2060+
root := t.TempDir()
2061+
outputDir := filepath.Join(root, "dist")
2062+
appDir := filepath.Join(root, "generated-backend")
2063+
writeTestFile(t, filepath.Join(outputDir, "index.html"), "<main>Home</main>")
2064+
plan, err := PlanApplication(outputDir, Options{})
2065+
if err != nil {
2066+
t.Fatal(err)
2067+
}
2068+
2069+
_, err = GenerateBackendWithPlan(appDir, plan)
2070+
if err == nil {
2071+
t.Fatal("expected embedded plan lane error")
2072+
}
2073+
if !strings.Contains(err.Error(), "embedded application plan cannot be used") {
2074+
t.Fatalf("unexpected error: %v", err)
2075+
}
2076+
}
2077+
20252078
func TestGenerateRejectsInvalidEndpointCORSConfig(t *testing.T) {
20262079
root := t.TempDir()
20272080
outputDir := filepath.Join(root, "dist")
@@ -9394,17 +9447,39 @@ func waitForHTTPStatusWithHeaders(url, method, body string, headers map[string]s
93949447
// through the production manifest->IR path, asserting against exactly what the
93959448
// generated-app pipeline derives.
93969449
func actionEndpointsFromManifestFixture(app gwdkanalysis.Sources) ([]ActionEndpoint, error) {
9450+
app = normalizeEndpointFixtureSources(app)
93979451
return actionEndpointsFromIR(gowdk.Config{}, gwdkanalysis.BuildProgram(gowdk.Config{}, app))
93989452
}
93999453

94009454
func apiEndpointsFromManifestFixture(app gwdkanalysis.Sources) ([]APIEndpoint, error) {
9455+
app = normalizeEndpointFixtureSources(app)
94019456
return apiEndpointsFromIR(gwdkanalysis.BuildProgram(gowdk.Config{}, app))
94029457
}
94039458

94049459
func fragmentEndpointsFromManifestFixture(app gwdkanalysis.Sources) ([]FragmentEndpoint, error) {
9460+
app = normalizeEndpointFixtureSources(app)
94059461
return fragmentEndpointsFromIR(gwdkanalysis.BuildProgram(gowdk.Config{}, app))
94069462
}
94079463

9464+
func normalizeEndpointFixtureSources(app gwdkanalysis.Sources) gwdkanalysis.Sources {
9465+
for index := range app.Pages {
9466+
if strings.TrimSpace(app.Pages[index].Blocks.ViewBody) != "" {
9467+
app.Pages[index].Blocks.View = true
9468+
}
9469+
}
9470+
for index := range app.Components {
9471+
if strings.TrimSpace(app.Components[index].Blocks.ViewBody) != "" {
9472+
app.Components[index].Blocks.View = true
9473+
}
9474+
}
9475+
for index := range app.Layouts {
9476+
if strings.TrimSpace(app.Layouts[index].Blocks.ViewBody) != "" {
9477+
app.Layouts[index].Blocks.View = true
9478+
}
9479+
}
9480+
return app
9481+
}
9482+
94089483
func TestDeniedPageRoutesSelectsGuardlessStaticPages(t *testing.T) {
94099484
options := Options{
94109485
IR: &gwdkir.Program{Pages: []gwdkir.Page{

internal/appgen/auto_routes.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,21 @@ func resolveOptions(outputDir string, options Options) (Options, error) {
3939
if err != nil {
4040
return Options{}, err
4141
}
42-
ssrArtifacts, err := buildgen.SSRArtifactsFromIR(options.Config, ir, outputDir)
42+
var ssrArtifacts []buildgen.SSRArtifact
43+
if options.Program != nil {
44+
ssrArtifacts, err = buildgen.SSRArtifactsFromValidatedProgram(options.Config, *options.Program, outputDir)
45+
} else {
46+
ssrArtifacts, err = buildgen.SSRArtifactsFromIR(options.Config, ir, outputDir)
47+
}
4348
if err != nil {
4449
return Options{}, err
4550
}
46-
sitemap, err := buildgen.RuntimeSitemapPlanFromIR(options.Config, ir)
51+
var sitemap buildgen.RuntimeSitemapPlan
52+
if options.Program != nil {
53+
sitemap, err = buildgen.RuntimeSitemapPlanFromValidatedProgram(options.Config, *options.Program)
54+
} else {
55+
sitemap, err = buildgen.RuntimeSitemapPlanFromIR(options.Config, ir)
56+
}
4757
if err != nil {
4858
return Options{}, err
4959
}
@@ -91,6 +101,12 @@ func resolveBackendOptions(options Options) (Options, error) {
91101
}
92102

93103
func optionsIR(options Options) (gwdkir.Program, error) {
104+
if options.Program != nil {
105+
if !options.Program.Valid() {
106+
return gwdkir.Program{}, fmt.Errorf("validated program was not constructed by compiler validation")
107+
}
108+
return options.Program.Program(), nil
109+
}
94110
if options.IR != nil {
95111
if err := gwdkir.CheckInvariants(*options.IR); err != nil {
96112
return gwdkir.Program{}, fmt.Errorf("invalid compiler IR: %w", err)

internal/appgen/ir.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,13 @@ func actionEndpointRoutes(config gowdk.I18NConfig, pageRoute string, route strin
9494
}
9595

9696
func actionFormSchemaFromBlocks(blocks gwdkir.Blocks) (map[string][]view.ActionFormField, error) {
97-
if len(blocks.ViewNodes) > 0 {
98-
return view.ActionFormSchemaFromNodes(blocks.ViewNodes)
97+
if len(blocks.ViewNodes) == 0 {
98+
if strings.TrimSpace(blocks.ViewBody) == "" {
99+
return nil, nil
100+
}
101+
return nil, fmt.Errorf("view {} has source body but no parsed nodes")
99102
}
100-
return view.ActionFormSchema(blocks.ViewBody)
103+
return view.ActionFormSchemaFromNodes(blocks.ViewNodes)
101104
}
102105

103106
func apiEndpointsFromIR(ir gwdkir.Program) ([]APIEndpoint, error) {

0 commit comments

Comments
 (0)