diff --git a/cmd/gowdk/dev_loop.go b/cmd/gowdk/dev_loop.go index 3b0cd1b..72504b2 100644 --- a/cmd/gowdk/dev_loop.go +++ b/cmd/gowdk/dev_loop.go @@ -16,6 +16,7 @@ import ( "github.com/cssbruno/gowdk/internal/gwdkanalysis" "github.com/cssbruno/gowdk/internal/gwdkir" "github.com/cssbruno/gowdk/internal/lang" + "github.com/cssbruno/gowdk/internal/view" ) func buildDevChange(args []string, change inputChange, allowIncremental bool) (bool, error) { @@ -37,6 +38,7 @@ func buildIncrementalSPA(args []string, change inputChange) (bool, error) { if err != nil { return true, err } + timings := newBuildTimingRecorder(plan.Timings) if plan.shouldBuildConfiguredTargets() { return false, nil } @@ -67,7 +69,13 @@ func buildIncrementalSPA(args []string, change inputChange) (bool, error) { paths = discovered } - app, diagnostics := lang.ParseBuildFiles(paths) + timings.counter("incremental_input_changes", len(change.Changed)) + var app gwdkanalysis.Sources + var diagnostics lang.Diagnostics + timings.measure("parse_lower", func() error { + app, diagnostics = lang.ParseBuildFiles(paths) + return nil + }) for _, diagnostic := range diagnostics { fmt.Fprintln(os.Stderr, diagnostic.String()) } @@ -75,18 +83,32 @@ func buildIncrementalSPA(args []string, change inputChange) (bool, error) { return true, fmt.Errorf("build failed") } - pageSources, incremental := changedPageSources(app, change.Changed) + incrementalPlan, incremental := changedIncrementalSPAPages(app, change.Changed) if !incremental { return false, nil } - ir := gwdkanalysis.BuildProgram(options.Config, app) - result, err := buildgen.BuildIncrementalFromIR(options.Config, ir, outputDir, pageSources) - if err != nil { + timings.counter("incremental_page_changes", incrementalPlan.PageChanges) + timings.counter("incremental_component_changes", incrementalPlan.ComponentChanges) + timings.counter("incremental_layout_changes", incrementalPlan.LayoutChanges) + timings.counter("incremental_affected_pages", len(incrementalPlan.PageSources)) + var ir gwdkir.Program + timings.measure("ir_assembly", func() error { + ir = gwdkanalysis.BuildProgram(options.Config, app) + return nil + }) + var result buildgen.Result + if err := timings.measure("output_plan_writes", func() error { + var buildErr error + result, buildErr = buildgen.BuildIncrementalFromIR(options.Config, ir, outputDir, incrementalPlan.PageSources) + return buildErr + }); err != nil { printBuildgenBuildErrorReport(err, options.Debug) return true, err } + timings.counter("files_written", result.WriteStats.FilesWritten) + timings.counter("identical_writes_skipped", result.WriteStats.IdenticalWritesSkipped) for _, artifact := range result.Artifacts { - if pageIDChanged(artifact.PageID, pageSources, app.Pages) { + if pageIDChanged(artifact.PageID, incrementalPlan.PageSources, app.Pages) { fmt.Println(artifact.Path) } } @@ -106,6 +128,9 @@ func buildIncrementalSPA(args []string, change inputChange) (bool, error) { fmt.Println(result.BuildReportPath) } printBuildgenBuildReport(result.Report, options.Debug) + if _, err := timings.write(outputDir, plan.TimingsPath); err != nil { + return true, err + } return true, nil } @@ -134,28 +159,224 @@ func devConfigPath(configPath string) (string, bool) { return filepath.Clean(abs), err == nil } -func changedPageSources(app gwdkanalysis.Sources, changedPaths []string) ([]string, bool) { - pageSources := map[string]string{} +type incrementalSPAChangePlan struct { + PageSources []string + PageChanges int + ComponentChanges int + LayoutChanges int +} + +func changedIncrementalSPAPages(app gwdkanalysis.Sources, changedPaths []string) (incrementalSPAChangePlan, bool) { + index, ok := newIncrementalDependencyIndex(app) + if !ok { + return incrementalSPAChangePlan{}, false + } + affected := map[string]bool{} + plan := incrementalSPAChangePlan{} + for _, changedPath := range changedPaths { + abs, ok := cleanAbs(changedPath) + if !ok { + return incrementalSPAChangePlan{}, false + } + if source, ok := index.pagesBySource[abs]; ok { + affected[source] = true + plan.PageChanges++ + continue + } + if key, ok := index.componentsBySource[abs]; ok { + for _, source := range index.pagesByComponent[key] { + affected[source] = true + } + plan.ComponentChanges++ + continue + } + if key, ok := index.layoutsBySource[abs]; ok { + for _, source := range index.pagesByLayout[key] { + affected[source] = true + } + plan.LayoutChanges++ + continue + } + return incrementalSPAChangePlan{}, false + } + plan.PageSources = sortedKeys(affected) + return plan, true +} + +type incrementalDependencyIndex struct { + pagesBySource map[string]string + componentsBySource map[string]string + layoutsBySource map[string]string + pagesByComponent map[string][]string + pagesByLayout map[string][]string +} + +func newIncrementalDependencyIndex(app gwdkanalysis.Sources) (incrementalDependencyIndex, bool) { + index := incrementalDependencyIndex{ + pagesBySource: map[string]string{}, + componentsBySource: map[string]string{}, + layoutsBySource: map[string]string{}, + pagesByComponent: map[string][]string{}, + pagesByLayout: map[string][]string{}, + } + componentsByKey := map[string]gwdkir.Component{} for _, page := range app.Pages { abs, ok := cleanAbs(page.Source) - if ok { - pageSources[abs] = page.Source + if !ok { + return incrementalDependencyIndex{}, false } + index.pagesBySource[abs] = page.Source } - - var changedPages []string - for _, changedPath := range changedPaths { - abs, ok := cleanAbs(changedPath) + for _, component := range app.Components { + key := sourceComponentKey(component.Package, component.Name) + componentsByKey[key] = component + abs, ok := cleanAbs(component.Source) if !ok { - return nil, false + return incrementalDependencyIndex{}, false } - source, ok := pageSources[abs] + index.componentsBySource[abs] = key + } + layoutsByKey := map[string]gwdkir.Layout{} + for _, layout := range app.Layouts { + key := sourceLayoutKey(layout.Package, layout.ID) + layoutsByKey[key] = layout + abs, ok := cleanAbs(layout.Source) if !ok { - return nil, false + return incrementalDependencyIndex{}, false } - changedPages = append(changedPages, source) + index.layoutsBySource[abs] = key + } + for _, page := range app.Pages { + for key := range pageComponentDependencies(page, componentsByKey) { + index.pagesByComponent[key] = append(index.pagesByComponent[key], page.Source) + } + for key := range pageLayoutDependencies(page, layoutsByKey) { + index.pagesByLayout[key] = append(index.pagesByLayout[key], page.Source) + } + } + sortDependencyIndex(index.pagesByComponent) + sortDependencyIndex(index.pagesByLayout) + return index, true +} + +func pageComponentDependencies(page gwdkir.Page, components map[string]gwdkir.Component) map[string]bool { + seen := map[string]bool{} + refs, err := view.ComponentReferences(page.Blocks.ViewBody) + if err != nil { + return seen + } + for _, ref := range refs { + if component, ok := resolveComponentRef(page.Package, page.Uses, ref, components); ok { + collectComponentDependencies(component, components, seen) + } + } + return seen +} + +func collectComponentDependencies(component gwdkir.Component, components map[string]gwdkir.Component, seen map[string]bool) { + key := sourceComponentKey(component.Package, component.Name) + if seen[key] { + return + } + seen[key] = true + refs, err := view.ComponentReferences(component.Blocks.ViewBody) + if err != nil { + return + } + for _, ref := range refs { + if child, ok := resolveComponentRef(component.Package, component.Uses, ref, components); ok { + collectComponentDependencies(child, components, seen) + } + } +} + +func resolveComponentRef(ownerPackage string, uses []gwdkir.Use, ref string, components map[string]gwdkir.Component) (gwdkir.Component, bool) { + if alias, name, ok := strings.Cut(ref, "."); ok { + for _, use := range uses { + if use.Alias == alias { + component, exists := components[sourceComponentKey(use.Package, name)] + return component, exists + } + } + return gwdkir.Component{}, false + } + if ownerPackage != "" { + if component, ok := components[sourceComponentKey(ownerPackage, ref)]; ok { + return component, true + } + } + component, ok := components[sourceComponentKey("", ref)] + return component, ok +} + +func pageLayoutDependencies(page gwdkir.Page, layouts map[string]gwdkir.Layout) map[string]bool { + seen := map[string]bool{} + for _, ref := range page.Layouts { + if layout, ok := resolvePageLayoutDependency(page.Package, page.Uses, ref, layouts); ok { + collectLayoutDependencies(layout, layouts, seen) + } + } + return seen +} + +func collectLayoutDependencies(layout gwdkir.Layout, layouts map[string]gwdkir.Layout, seen map[string]bool) { + key := sourceLayoutKey(layout.Package, layout.ID) + if seen[key] { + return + } + seen[key] = true + for _, ref := range layout.Layouts { + if parent, ok := resolveLayoutDependency(layout.Package, layout.Uses, ref, layouts); ok { + collectLayoutDependencies(parent, layouts, seen) + } + } +} + +func resolvePageLayoutDependency(ownerPackage string, uses []gwdkir.Use, ref string, layouts map[string]gwdkir.Layout) (gwdkir.Layout, bool) { + return resolveLayoutDependency(ownerPackage, uses, ref, layouts) +} + +func resolveLayoutDependency(ownerPackage string, uses []gwdkir.Use, ref string, layouts map[string]gwdkir.Layout) (gwdkir.Layout, bool) { + if alias, id, ok := strings.Cut(ref, "."); ok { + for _, use := range uses { + if use.Alias == alias { + layout, exists := layouts[sourceLayoutKey(use.Package, id)] + return layout, exists + } + } + return gwdkir.Layout{}, false + } + if ownerPackage != "" { + if layout, ok := layouts[sourceLayoutKey(ownerPackage, ref)]; ok { + return layout, true + } + } + layout, ok := layouts[sourceLayoutKey("", ref)] + return layout, ok +} + +func sourceComponentKey(packageName string, name string) string { + return packageName + "\x00" + name +} + +func sourceLayoutKey(packageName string, id string) string { + return packageName + "\x00" + id +} + +func sortedKeys(values map[string]bool) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func sortDependencyIndex(index map[string][]string) { + for key, values := range index { + sort.Strings(values) + index[key] = values } - return changedPages, len(changedPages) > 0 } func pageIDChanged(pageID string, changedSources []string, pages []gwdkir.Page) bool { diff --git a/cmd/gowdk/main_test.go b/cmd/gowdk/main_test.go index af92700..3c97dc9 100644 --- a/cmd/gowdk/main_test.go +++ b/cmd/gowdk/main_test.go @@ -1822,9 +1822,10 @@ view { } } -func TestBuildIncrementalSPAFallsBackForComponentChanges(t *testing.T) { +func TestBuildIncrementalSPAUsesComponentDependencies(t *testing.T) { root := t.TempDir() page := filepath.Join(root, "home.page.gwdk") + about := filepath.Join(root, "about.page.gwdk") component := filepath.Join(root, "hero.cmp.gwdk") outputDir := filepath.Join(root, "dist") config := writeMinimalCLIConfig(t, root) @@ -1836,6 +1837,15 @@ route "/" view {
} +`) + writeCLIFile(t, about, `package app + +page about +route "/about" + +view { +
Stable
+} `) writeCLIFile(t, component, `package app @@ -1850,12 +1860,160 @@ view { } `) - used, err := buildIncrementalSPA([]string{"--config", config, "--out", outputDir, page, component}, inputChange{Changed: []string{component}}) + args := []string{"--config", config, "--timings", "--out", outputDir, page, about, component} + if err := build(args); err != nil { + t.Fatal(err) + } + aboutPath := filepath.Join(outputDir, "about", "index.html") + aboutInfo, err := os.Stat(aboutPath) + if err != nil { + t.Fatal(err) + } + + time.Sleep(20 * time.Millisecond) + writeCLIFile(t, component, `package app + +component Hero + +props { + title string +} + +view { +

{title} after

+} +`) + used, err := buildIncrementalSPA(args, inputChange{Changed: []string{component}}) + if err != nil { + t.Fatal(err) + } + if !used { + t.Fatal("expected incremental spa build to handle component dependency change") + } + homePayload, err := os.ReadFile(filepath.Join(outputDir, "index.html")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(homePayload), "GOWDK after") { + t.Fatalf("expected changed component output:\n%s", homePayload) + } + afterAboutInfo, err := os.Stat(aboutPath) + if err != nil { + t.Fatal(err) + } + if !afterAboutInfo.ModTime().Equal(aboutInfo.ModTime()) { + t.Fatalf("expected unchanged about output mod time: before=%s after=%s", aboutInfo.ModTime(), afterAboutInfo.ModTime()) + } + payload, err := os.ReadFile(filepath.Join(outputDir, buildTimingsFile)) + if err != nil { + t.Fatal(err) + } + var timings buildTimingReport + if err := json.Unmarshal(payload, &timings); err != nil { + t.Fatalf("invalid timings JSON: %v\n%s", err, payload) + } + if timings.Counters["incremental_component_changes"] != 1 || timings.Counters["incremental_affected_pages"] != 1 { + t.Fatalf("expected incremental dependency counters, got %#v", timings.Counters) + } + if _, ok := timings.Counters["files_written"]; !ok { + t.Fatalf("expected incremental write counters, got %#v", timings.Counters) + } +} + +func TestBuildIncrementalSPAUsesLayoutDependencies(t *testing.T) { + root := t.TempDir() + page := filepath.Join(root, "home.page.gwdk") + about := filepath.Join(root, "about.page.gwdk") + layout := filepath.Join(root, "root.layout.gwdk") + outputDir := filepath.Join(root, "dist") + config := writeMinimalCLIConfig(t, root) + writeCLIFile(t, page, `package app + +page home +route "/" +layout root + +view { +
Home
+} +`) + writeCLIFile(t, about, `package app + +page about +route "/about" + +view { +
Stable
+} +`) + writeCLIFile(t, layout, `package app + +view { +
+} +`) + + args := []string{"--config", config, "--out", outputDir, page, about, layout} + if err := build(args); err != nil { + t.Fatal(err) + } + aboutPath := filepath.Join(outputDir, "about", "index.html") + aboutInfo, err := os.Stat(aboutPath) + if err != nil { + t.Fatal(err) + } + + time.Sleep(20 * time.Millisecond) + writeCLIFile(t, layout, `package app + +view { +
+} +`) + used, err := buildIncrementalSPA(args, inputChange{Changed: []string{layout}}) + if err != nil { + t.Fatal(err) + } + if !used { + t.Fatal("expected incremental spa build to handle layout dependency change") + } + homePayload, err := os.ReadFile(filepath.Join(outputDir, "index.html")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(homePayload), `class="after"`) { + t.Fatalf("expected changed layout output:\n%s", homePayload) + } + afterAboutInfo, err := os.Stat(aboutPath) + if err != nil { + t.Fatal(err) + } + if !afterAboutInfo.ModTime().Equal(aboutInfo.ModTime()) { + t.Fatalf("expected unchanged about output mod time: before=%s after=%s", aboutInfo.ModTime(), afterAboutInfo.ModTime()) + } +} + +func TestBuildIncrementalSPAFallsBackForConfigChanges(t *testing.T) { + root := t.TempDir() + page := filepath.Join(root, "home.page.gwdk") + outputDir := filepath.Join(root, "dist") + config := writeMinimalCLIConfig(t, root) + writeCLIFile(t, page, `package app + +page home +route "/" + +view { +
Home
+} +`) + + used, err := buildIncrementalSPA([]string{"--config", config, "--out", outputDir, page}, inputChange{Changed: []string{config}}) if err != nil { t.Fatal(err) } if used { - t.Fatal("expected component change to fall back to full build") + t.Fatal("expected config change to fall back to full build") } } diff --git a/docs/compiler/README.md b/docs/compiler/README.md index ce9c706..2a7feb3 100644 --- a/docs/compiler/README.md +++ b/docs/compiler/README.md @@ -46,6 +46,8 @@ Not implemented yet: - `browser-compiler.md`: browser-facing partial runtime, JavaScript islands, and component-level WASM island behavior. - `build-report.md`: generated build report schema and CLI debug output. +- `incremental-cache-keys.md`: compiler cache-key model and implemented + incremental invalidation slice. - `manifest.md`: manifest and site-map JSON contracts. - `syntax-contributors.md`: checklist for parser, diagnostics, IR, generation, docs, and fixture coverage when syntax changes. diff --git a/docs/compiler/build-report.md b/docs/compiler/build-report.md index f82779d..8928350 100644 --- a/docs/compiler/build-report.md +++ b/docs/compiler/build-report.md @@ -92,3 +92,10 @@ Current phases include `config_load`, `source_discovery`, `parse_lower`, `ir_assembly`, `go_binding`, `ir_validation`, `contract_validation`, `output_plan_writes`, `app_generation`, `binary_build`, `wasm_build`, `backend_app_generation`, and `backend_binary_build` when those paths run. + +`gowdk dev` incremental SPA rebuilds reuse the same sidecar when `--timings` is +forwarded in the build flags. Incremental counters include +`incremental_input_changes`, `incremental_page_changes`, +`incremental_component_changes`, `incremental_layout_changes`, +`incremental_affected_pages`, `files_written`, and +`identical_writes_skipped`. diff --git a/docs/compiler/incremental-cache-keys.md b/docs/compiler/incremental-cache-keys.md new file mode 100644 index 0000000..e2e2c7f --- /dev/null +++ b/docs/compiler/incremental-cache-keys.md @@ -0,0 +1,69 @@ +# Incremental Cache Keys + +GOWDK cache keys are deterministic compiler inputs, not runtime state. The +cache model is split by phase so invalidation can stay local and reviewable. + +## Key Model + +Each key is a stable hash over the fields that affect the next compiler phase: + +- `.gwdk` source key: parsed source kind, package, declared identity, route, + metadata declarations, imports, `use` declarations, block bodies, endpoints, + component contracts, layout references, CSS/asset selections, and source file + path. Parser errors use the raw file hash until the file parses again. +- Go ABI key: owning package import path, package name, exported handler/type + names used by `.gwdk`, resolved signatures, relevant struct fields and tags, + build tags, GOOS/GOARCH, and Go toolchain version. Function bodies are not ABI + input unless generated output embeds the body through an inline Go block. +- Config/target key: normalized compiler config, selected modules, selected + build target, output mode, enabled feature flags, and active addons that affect + generated output. +- Toolchain key: Go version, GOOS/GOARCH, build tags, and compiler feature + gates that can change package loading or generated code. +- IR key: versioned `gwdkir.Program` records that downstream generators consume, + excluding diagnostics ordering noise and runtime-only secrets. +- Output-plan key: generated route, asset, CSS, app, backend, WASM, and binary + plans plus the generator version that owns their shape. +- Generated-file key: output path, content hash, cache policy, and the source + output-plan record that produced it. + +Runtime-only values do not invalidate static output. For example, CSRF secret +values rotate at runtime and are not cache inputs unless the generated code shape +or config field that enables CSRF changes. + +## Reverse Dependencies + +Reverse dependencies answer "which pages must be regenerated when this input +changes?" and are derived from IR plus parsed view references: + +- Page source changes affect that page. +- Component source changes affect pages that call the component directly or + through another component. +- Layout source changes affect pages that name the layout, including parent + layout chains. +- CSS source changes affect CSS artifacts and pages that include the stylesheet. +- Backend binding ABI changes affect generated adapters and reports, not static + page HTML unless a build/load function contributes build-time output. +- Config, target, addon, toolchain, added source, and removed source changes + conservatively invalidate the whole selected build. + +Dependencies stay attached to explicit source kinds. Avoid catch-all global +cache state; a phase may cache local package inspection or output planning, but +the invalidation edge must name the source kind and owner. + +## Implemented Slice + +The current implementation keeps the existing content-hash input snapshot for +`gowdk dev`, then adds reverse dependencies for incremental SPA rebuilds: + +- changed page sources still render only those pages; +- changed component sources render pages that reference the component directly + or transitively through another component; +- changed layout sources render pages that use the layout or a child layout that + inherits from it; +- added, removed, config, generated app, binary, WASM, backend, and configured + target changes still fall back to the full build path. + +When `--timings` is enabled, incremental rebuilds write the same timing sidecar +as normal builds and include counters for input changes, affected pages, +component/layout/page changes, files written, and identical writes skipped. diff --git a/docs/reference/dev.md b/docs/reference/dev.md index 549029a..ee41e59 100644 --- a/docs/reference/dev.md +++ b/docs/reference/dev.md @@ -23,20 +23,24 @@ attached to the terminal. ## Rebuild Scope -For plain SPA `--out` builds, page-only edits use the incremental SPA renderer: -the dev loop validates the full compiler IR, refreshes manifests, writes changed -page output, and removes stale route output for changed pages. +For plain SPA `--out` builds, page, component, and layout edits use the +incremental SPA renderer when the changed files are already in the source set. +The dev loop validates the full compiler IR, derives page/component/layout +reverse dependencies, refreshes manifests, writes affected page output, and +removes stale route output for changed pages. These changes use the full build path: -- component files; -- layout files; - CSS files; - config files; - source-set changes; - target changes; - generated app, binary, backend, or WASM output. +When build flags include `--timings`, incremental rebuilds update the timings +sidecar with counters for input changes, affected pages, component/layout/page +changes, files written, and identical writes skipped. + ## HMR Component-level HMR is not part of the current contract. The P0 baseline is diff --git a/internal/buildgen/build.go b/internal/buildgen/build.go index 69a5194..834c63f 100644 --- a/internal/buildgen/build.go +++ b/internal/buildgen/build.go @@ -442,16 +442,20 @@ func buildIncrementalFromIR(config gowdk.Config, ir gwdkir.Program, outputDir st }) changedPageIDs := map[string]bool{} for _, artifact := range css.assets { - if err := writeFileIfChanged(artifact.Path, artifact.contents); err != nil { + wrote, err := writeFileIfChangedStatus(artifact.Path, artifact.contents) + if err != nil { return Result{}, reporter.fail("write", err) } + recordWriteStat(&result, wrote) reporter.debug("write", "css_written", "CSS artifact written", BuildEvent{Path: eventPath(outputDir, artifact.Path)}) result.CSSArtifacts = append(result.CSSArtifacts, artifact.CSSArtifact) } for _, artifact := range runtime { - if err := writeFileIfChanged(artifact.Path, artifact.contents); err != nil { + wrote, err := writeFileIfChangedStatus(artifact.Path, artifact.contents) + if err != nil { return Result{}, reporter.fail("write", err) } + recordWriteStat(&result, wrote) reporter.debug("write", "asset_written", "runtime asset written", BuildEvent{Path: eventPath(outputDir, artifact.Path)}) result.AssetArtifacts = append(result.AssetArtifacts, artifact.AssetArtifact) } @@ -492,9 +496,11 @@ func buildIncrementalFromIR(config gowdk.Config, ir gwdkir.Program, outputDir st continue } for _, artifact := range pageArtifacts { - if err := writeFileIfChanged(artifact.Path, artifact.contents); err != nil { + wrote, err := writeFileIfChangedStatus(artifact.Path, artifact.contents) + if err != nil { return Result{}, reporter.fail("write", err) } + recordWriteStat(&result, wrote) reporter.debug("write", "page_written", "page artifact written", BuildEvent{ PageID: artifact.PageID, Route: artifact.Route,