diff --git a/docs/engineering/incremental-build-equivalence-plan.md b/docs/engineering/incremental-build-equivalence-plan.md new file mode 100644 index 00000000..76044818 --- /dev/null +++ b/docs/engineering/incremental-build-equivalence-plan.md @@ -0,0 +1,70 @@ +# Implementation Plan: Incremental Build Equivalence + +## Context + +Spec: [Incremental Build Equivalence](incremental-build-equivalence-spec.md) + +Issue: [#686](https://github.com/cssbruno/GoWDK/issues/686) + +## Assumptions + +- The first checked-in slice can live in `internal/buildgen` because current + incremental SPA generation is implemented there. +- `gowdk-build-report.json` records progress-path information by design, so the + equivalence helper compares artifact-relevant report events and exact bytes + for every other file. + +## Proposed Changes + +- Add a reusable scenario helper for clean-versus-incremental output tree + comparison. +- Compare served output plus sibling compiler report output. +- Add deterministic multi-step coverage for route moves, component changes, + content-hashed assets, and layout changes. +- Remove stale asset-manifest files during incremental output publication. + +## Files Expected To Change + +- `internal/buildgen/equivalence_test.go` +- `internal/buildgen/build.go` +- `internal/buildgen/manifests.go` +- `docs/engineering/testing.md` +- `docs/engineering/incremental-build-equivalence-spec.md` +- `docs/engineering/incremental-build-equivalence-plan.md` + +## Data And API Impact + +- No public API changes. +- Incremental builds now remove stale generated asset files that were present in + the previous asset manifest but are absent from the current generated asset + set. + +## Tests + +- Unit: focused `internal/buildgen` equivalence scenario. +- Integration: existing generated-output determinism script remains separate and + continues to cover clean-build determinism. +- End-to-end: generated app equivalence remains a follow-up. +- Manual: none. + +## Verification Commands + +```sh +go test ./internal/buildgen -run 'TestIncrementalBuildMatchesCleanBuildEquivalence|TestBuildIncremental' -count=1 +go test ./internal/buildgen -count=1 +go build ./cmd/gowdk +``` + +## Rollback Plan + +- Revert the stale asset cleanup and equivalence test if incremental publication + needs to return to previous behavior. +- Existing clean build output remains unaffected by this slice. + +## Risks + +- The current report projection could miss a future artifact-relevant build + report event. Add such event kinds to the projection with the generator change + that introduces them. +- The deterministic matrix is not yet broad enough for generated app, split + target, backend endpoint, config, or WASM output equivalence. diff --git a/docs/engineering/incremental-build-equivalence-spec.md b/docs/engineering/incremental-build-equivalence-spec.md new file mode 100644 index 00000000..22730044 --- /dev/null +++ b/docs/engineering/incremental-build-equivalence-spec.md @@ -0,0 +1,97 @@ +# Feature Spec: Incremental Build Equivalence + +## Problem + +`gowdk dev` serves incrementally generated output. Focused incremental tests can +pass while stale files, manifests, or generated assets remain from an earlier +source state. For the same final project state, incremental output must converge +to the compiler-owned artifact tree produced by a clean build. + +## Goals + +- Compare incremental output with a clean build of the same final state. +- Cover complete served output, compiler-owned report output, file bytes, and + meaningful file modes. +- Include manifests and report-relevant metadata, not only HTML. +- Identify the edit step and divergent file when equivalence fails. +- Keep intentional normalization rules narrow and documented. + +## Non-Goals + +- Requiring every edit to use the incremental path; full rebuild fallback + remains valid. +- Comparing timestamps, wall-clock timings, or debug progress logs. +- Optimizing invalidation before correctness is covered. + +## Users And Permissions + +- Primary users: GOWDK maintainers and contributors changing build generation or + dev-loop invalidation. +- Roles or permissions: repository test access. +- Data visibility rules: test fixtures must not include secrets or local + environment-specific data. + +## User Flow + +1. A test defines an initial compiler source state. +2. The harness builds that state into an incremental output directory. +3. Each scenario step mutates source/config state and runs the incremental + build path. +4. The harness builds the same current state into a fresh clean directory. +5. The harness compares the incremental tree with the clean tree and reports the + first divergent file for the responsible step. + +## Requirements + +### Functional + +- Compare relative file paths, file bytes, and permission bits. +- Detect files present only in incremental output or only in clean output. +- Compare the served output directory and sibling `.gowdk/reports//` + compiler report directory. +- Canonicalize only `gowdk-build-report.json` fields that are path-dependent or + progress-path-dependent between clean and incremental runs. +- Exercise at least one multi-step scenario where edit order can expose stale + state. + +### Non-Functional + +- Performance: focused scenarios must stay cheap enough for normal Go test CI. +- Reliability: failures must name the step and artifact path. +- Accessibility: not applicable. +- Security/privacy: generated security posture reports are compared as compiler + output, but fixtures do not contain secret material. +- Observability: failure output is file-level and byte-offset-level. + +## Acceptance Criteria + +- [x] A reusable test harness compares incremental output with clean output. +- [x] Comparison covers artifact paths, bytes, modes, stale-file absence, served + output, and compiler-owned report output. +- [x] Manifest equivalence is included through exact file comparison. +- [x] Build report equivalence is included through a narrow canonical report + projection. +- [x] A deterministic multi-edit scenario covers route, component, asset, and + layout changes. +- [ ] Generated app and split frontend/backend output equivalence are covered. +- [ ] Backend endpoint, contract, config, selected-target, and WASM scenarios are + added. +- [ ] A bounded seeded state-machine test is added. + +## Edge Cases + +- Content-hashed asset turnover must remove stale emitted files. +- Route moves must remove stale page outputs. +- Security manifests live outside the served output directory and must still be + compared. + +## Dependencies + +- Internal: `internal/buildgen` clean and incremental SPA builders. +- External: none. + +## Open Questions + +- Which generated app targets should be the first appgen equivalence slice? +- Should broader state-machine coverage run in every pull request or only in a + scheduled lane once the deterministic matrix is larger? diff --git a/docs/engineering/testing.md b/docs/engineering/testing.md index 931318bc..312850aa 100644 --- a/docs/engineering/testing.md +++ b/docs/engineering/testing.md @@ -55,6 +55,7 @@ must pass `--config `. | Parser fuzz smoke | `scripts/test-parser-fuzz.sh` | Parser, tokenizer, syntax recovery, and diagnostic recovery changes. | | Generated app integration | `scripts/test-generated-app-integration.sh` | Generated binary, SSR, action, fragment, CSRF, and contract adapter changes. | | Generated output determinism | `scripts/test-generated-output-determinism.sh` | Generated HTML, manifest, report, inspect, or route/sitemap output changes. | +| Incremental build equivalence | `go test ./internal/buildgen -run TestIncrementalBuildMatchesCleanBuildEquivalence -count=1` | Incremental SPA output, stale generated asset cleanup, route cleanup, or build report comparison changes. | | Runtime race detector | `scripts/test-runtime-race.sh` | Shared-state runtime packages, lifecycle, contracts, SSE, rate limiting, trace, or testkit changes. | | VS Code extension syntax | `node --check editors/vscode/extension.js` | Editor extension changes and broad verification. | | VS Code extension behavior | `node --test editors/vscode/*.test.js` | Editor extension pure helper changes and broad verification. | @@ -82,6 +83,12 @@ must pass `--config `. - `scripts/test-generated-output-determinism.sh` is the generated output and report determinism slice. It compares two clean builds plus manifest, sitemap, routes, and asset-graph report output. +- `TestIncrementalBuildMatchesCleanBuildEquivalence` compares an incremental + output tree with a clean build of the same final state. It includes served + output, sibling compiler reports, manifests, file bytes, and file modes; the + only normalization is the documented `gowdk-build-report.json` projection for + progress-path events that intentionally differ between clean and incremental + runs. - `scripts/test-runtime-race.sh` is the focused race-detector slice. It runs an explicit package list under `go test -race -count=1` and fails preflight when a selected package has no tests. @@ -143,7 +150,9 @@ runtime integration, race detection, or nondeterministic output. generated file modification times so local dev loops do not retrigger on identical output. - `internal/buildgen` tests cover incremental changed-page rendering, complete - route manifest refreshes, and stale route output cleanup. + route manifest refreshes, stale route output cleanup, stale generated asset + cleanup, and clean-versus-incremental output equivalence for the current SPA + slice. - `internal/project` tests cover literal `gowdk.config.go` parsing for source discovery, module source groups, build output, and build targets. - Nested optional adapter modules cover Chi, Echo, Fiber, Gin, Redis Streams, diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 862fda64..9c5eb3ec 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -88,7 +88,7 @@ implemented. | Component CSS | Make component CSS explicit, compiler-scoped, and documented; Tailwind and processors remain optional. | Partial | | Accessibility | Add accessibility diagnostics as compiler warnings with stable codes and spans. | Partial — `missing_img_alt`, `missing_form_label`, `empty_link_text`, `missing_button_type`, and `heading_order_skip` warn on literal view markup in pages, components, and layouts. Broader ARIA and full WCAG rule coverage remain outside the current compiler slice. | | Diagnostics and LSP | Expand diagnostic catalogue before broad parser recovery; prioritize hover, semantic tokens, go-to-definition, and route/type navigation. | Partial — the diagnostic registry, `gowdk explain`, JSON check output, safe fix metadata, exact ranges for high-value parser/IR-backed diagnostics, LSP diagnostics/formatting/completions/hover/definitions/references/code actions/semantic tokens, dirty-buffer `g:command`/`g:query` binding diagnostics, CLI route/sitemap/inspect reports, and [diagnostics/navigation contract](diagnostics-and-navigation.md) exist; parser recovery, remaining aggregate/addon exact-span gaps, direct markup-family emitted codes, and workspace route/type navigation remain planned. | -| Testing and scaffolding | Add optional Go handler tests, generated app smoke tests, template/addon selection, and editable generated examples. | Partial — `gowdk init --tests` writes a starter `go.mod` and non-skipping generated app smoke test, `gowdk test` builds temporary generated output/app/binary artifacts and runs ordinary Go tests with `GOWDK_TEST_*` context, `runtime/testkit` provides HTTP scenario helpers, cookie-preserving clients, response assertions, and in-memory contract registry/event assertions, `examples/contracts/patients/contracts_test.go` demonstrates command event capture, `gowdk audit --run` builds a temporary generated app and runs generated runtime audit tests, and explicit repository scripts cover parser fuzz smoke, generated-app integration, generated-output/report determinism, and focused runtime race detection. Broader IR-generated endpoint test files and first-class browser/E2E scaffolds remain planned. | +| Testing and scaffolding | Add optional Go handler tests, generated app smoke tests, template/addon selection, and editable generated examples. | Partial — `gowdk init --tests` writes a starter `go.mod` and non-skipping generated app smoke test, `gowdk test` builds temporary generated output/app/binary artifacts and runs ordinary Go tests with `GOWDK_TEST_*` context, `runtime/testkit` provides HTTP scenario helpers, cookie-preserving clients, response assertions, and in-memory contract registry/event assertions, `examples/contracts/patients/contracts_test.go` demonstrates command event capture, `gowdk audit --run` builds a temporary generated app and runs generated runtime audit tests, and explicit repository scripts/tests cover parser fuzz smoke, generated-app integration, generated-output/report determinism, incremental-versus-clean SPA output equivalence, and focused runtime race detection. Broader IR-generated endpoint test files, generated app equivalence coverage, and first-class browser/E2E scaffolds remain planned. | | Deployment and operations | Prefer docs and optional generators for static hosts, Docker, systemd, reverse proxies, CDN policy, health checks, metrics, logging, binary deploy, rollback, and CSRF secret rotation. | Partial — [deployment.md](../reference/deployment.md) documents static output, one-binary, generated Docker contexts, split frontend/backend, backend-only, Docker, systemd, reverse proxy, CDN/cache, health, metrics, logging, readiness, graceful shutdown, artifact layout, rollback, CSRF secret rotation, backup ownership, and incident boundaries. `gowdk build --docker` emits a minimal non-root Dockerfile and `.dockerignore` beside a compiled one-binary artifact. `gowdk build --deploy-recipe` and `Build.Targets[].DeployRecipes` emit optional static-host, systemd, Caddy, Nginx, and split frontend/backend starting points without owning secrets, domains, TLS, CDN policy, storage, backups, incident response, or rollout logic. Release workflows pin third-party action SHAs, extension publishing uses locked local `vsce`, `govulncheck` is pinned through `tools/govulncheck`, and docs distinguish convenience and high-assurance install paths. | | Full-page hydration | Keep full-page hydration out of the repository core; use static pages, progressive enhancement, server fragments, and explicit islands. | Intentionally out of scope | | Island ergonomics | Improve compiler-owned island syntax, lifecycle cleanup, focus helpers, local batching, and diagnostics without exposing arbitrary JavaScript as the app contract. | Partial — generated JS islands support idempotent mount/remount, cleanup, lifecycle/effect blocks, bounded refs such as focus/blur/scroll, local batching, and diagnostics; broader HMR-style ergonomics remain deferred. | diff --git a/internal/buildgen/build.go b/internal/buildgen/build.go index 0e902edb..1c7c6948 100644 --- a/internal/buildgen/build.go +++ b/internal/buildgen/build.go @@ -782,6 +782,10 @@ func BuildIncrementalFromValidatedProgram(config gowdk.Config, validated compile if err != nil { return Result{}, reporter.fail("manifest", err) } + previousAssets, err := readAssetManifestIfExists(outputDir) + if err != nil { + return Result{}, reporter.fail("manifest", err) + } reporter.debug("manifest", "previous_route_manifest_read", "previous route manifest read", BuildEvent{ Data: map[string]string{"routes": fmt.Sprint(len(previousRoutes.Routes))}, }) @@ -894,6 +898,10 @@ func BuildIncrementalFromValidatedProgram(config gowdk.Config, validated compile result.RobotsPath = robotsPath reporter.info("seo", "robots_written", "robots.txt written", BuildEvent{Path: eventPath(outputDir, robotsPath)}) } + if err := removeStaleAssetManifestFiles(outputDir, previousAssets, result.CSSArtifacts, result.AssetArtifacts); err != nil { + return Result{}, reporter.fail("cleanup", err) + } + reporter.info("cleanup", "stale_assets_removed", "stale generated assets removed", BuildEvent{}) assetManifestPath, err := writeAssetManifest(outputDir, result.Artifacts, result.CSSArtifacts, result.AssetArtifacts) if err != nil { return Result{}, reporter.fail("manifest", err) diff --git a/internal/buildgen/equivalence_test.go b/internal/buildgen/equivalence_test.go new file mode 100644 index 00000000..39901774 --- /dev/null +++ b/internal/buildgen/equivalence_test.go @@ -0,0 +1,334 @@ +package buildgen + +import ( + "bytes" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/cssbruno/gowdk" + "github.com/cssbruno/gowdk/internal/gwdkanalysis" + "github.com/cssbruno/gowdk/internal/gwdkir" +) + +type incrementalEquivalenceScenario struct { + Name string + Initial gwdkanalysis.Sources + Steps []incrementalEquivalenceStep +} + +type incrementalEquivalenceStep struct { + Name string + ChangedPageSources []string + Mutate func(*gwdkanalysis.Sources) + PrepareCleanFixture func(t *testing.T) +} + +func (scenario incrementalEquivalenceScenario) Assert(t *testing.T) { + t.Helper() + t.Run(scenario.Name, func(t *testing.T) { + t.Helper() + root := t.TempDir() + incrementalDir := filepath.Join(root, "incremental", "site") + state := cloneSources(t, scenario.Initial) + if _, err := Build(gowdk.Config{}, state, incrementalDir); err != nil { + t.Fatalf("initial clean build failed: %v", err) + } + + for index, step := range scenario.Steps { + if step.Mutate == nil { + t.Fatalf("step %d %q is missing a mutation", index+1, step.Name) + } + step.Mutate(&state) + if step.PrepareCleanFixture != nil { + step.PrepareCleanFixture(t) + } + if _, err := BuildIncremental(gowdk.Config{}, state, incrementalDir, step.ChangedPageSources); err != nil { + t.Fatalf("incremental build after step %d %q failed: %v", index+1, step.Name, err) + } + cleanDir := filepath.Join(root, fmt.Sprintf("clean-%02d", index+1), "site") + if _, err := Build(gowdk.Config{}, state, cleanDir); err != nil { + t.Fatalf("clean build after step %d %q failed: %v", index+1, step.Name, err) + } + assertEquivalentBuildTrees(t, incrementalDir, cleanDir, fmt.Sprintf("after step %d %q", index+1, step.Name)) + } + }) +} + +func TestIncrementalBuildMatchesCleanBuildEquivalence(t *testing.T) { + projectRoot := t.TempDir() + homeSource := filepath.Join(projectRoot, "pages", "home.page.gwdk") + docsSource := filepath.Join(projectRoot, "pages", "docs.page.gwdk") + heroSource := filepath.Join(projectRoot, "components", "hero.cmp.gwdk") + layoutSource := filepath.Join(projectRoot, "layouts", "shell.layout.gwdk") + heroAsset := filepath.Join(projectRoot, "components", "hero.txt") + if err := os.MkdirAll(filepath.Dir(heroAsset), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, heroAsset, "hero asset v1\n") + + base := gwdkanalysis.Sources{ + Pages: []gwdkir.Page{ + { + Source: homeSource, + Package: "app", + ID: "home", + Route: "/old-home", + Layouts: []string{"shell"}, + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
`, + }, + }, + { + Source: docsSource, + Package: "app", + ID: "docs", + Route: "/docs", + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
Docs stable
`, + }, + }, + }, + Components: []gwdkir.Component{{ + Source: heroSource, + Package: "app", + Name: "Hero", + Assets: []string{"./hero.txt"}, + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
Hero v1
`, + }, + }}, + Layouts: []gwdkir.Layout{{ + Source: layoutSource, + Package: "app", + ID: "shell", + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: ``, + }, + }}, + } + + incrementalEquivalenceScenario{ + Name: "route component asset and layout edits converge", + Initial: base, + Steps: []incrementalEquivalenceStep{ + { + Name: "rename route and rewrite page body", + ChangedPageSources: []string{homeSource}, + Mutate: func(sources *gwdkanalysis.Sources) { + sources.Pages[0].Route = "/home" + sources.Pages[0].Blocks.ViewBody = `
Home v2
` + }, + }, + { + Name: "change component asset and transitive page output", + ChangedPageSources: []string{homeSource}, + PrepareCleanFixture: func(t *testing.T) { + t.Helper() + writeFile(t, heroAsset, "hero asset v2\n") + }, + Mutate: func(sources *gwdkanalysis.Sources) { + sources.Components[0].Blocks.ViewBody = `
Hero v2
` + }, + }, + { + Name: "remove layout from page", + ChangedPageSources: []string{homeSource}, + Mutate: func(sources *gwdkanalysis.Sources) { + sources.Pages[0].Layouts = nil + sources.Pages[0].Blocks.ViewBody = `
Home v3
` + }, + }, + }, + }.Assert(t) +} + +type buildTreeEntry struct { + contents []byte + mode fs.FileMode +} + +func assertEquivalentBuildTrees(t *testing.T, incrementalDir string, cleanDir string, context string) { + t.Helper() + incremental := snapshotBuildTree(t, incrementalDir) + clean := snapshotBuildTree(t, cleanDir) + for rel := range incremental { + if _, ok := clean[rel]; !ok { + t.Fatalf("%s: file only in incremental output: %s", context, rel) + } + } + for rel := range clean { + if _, ok := incremental[rel]; !ok { + t.Fatalf("%s: file only in clean output: %s", context, rel) + } + } + var rels []string + for rel := range incremental { + rels = append(rels, rel) + } + sort.Strings(rels) + for _, rel := range rels { + left := incremental[rel] + right := clean[rel] + if left.mode != right.mode { + t.Fatalf("%s: mode mismatch for %s: incremental=%s clean=%s", context, rel, left.mode, right.mode) + } + if !bytes.Equal(left.contents, right.contents) { + t.Fatalf("%s: byte mismatch for %s: %s", context, rel, firstByteDifference(left.contents, right.contents)) + } + } +} + +func snapshotBuildTree(t *testing.T, outputDir string) map[string]buildTreeEntry { + t.Helper() + out := map[string]buildTreeEntry{} + collectBuildTree(t, out, outputDir, outputDir, "") + reportPath, err := securityManifestPath(outputDir) + if err != nil { + t.Fatal(err) + } + reportRoot := filepath.Dir(reportPath) + collectBuildTree(t, out, reportRoot, reportRoot, filepath.ToSlash(filepath.Join(".gowdk", "reports", filepath.Base(outputDir)))) + return out +} + +func collectBuildTree(t *testing.T, out map[string]buildTreeEntry, root string, outputDir string, prefix string) { + t.Helper() + if _, err := os.Stat(root); os.IsNotExist(err) { + return + } else if err != nil { + t.Fatal(err) + } + if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if prefix != "" { + rel = filepath.ToSlash(filepath.Join(prefix, rel)) + } + contents, err := os.ReadFile(path) + if err != nil { + return err + } + if filepath.Base(path) == buildReportFile { + contents, err = canonicalBuildReport(contents) + if err != nil { + return err + } + } + out[rel] = buildTreeEntry{contents: contents, mode: info.Mode().Perm()} + return nil + }); err != nil { + t.Fatal(err) + } +} + +func canonicalBuildReport(payload []byte) ([]byte, error) { + var report BuildReport + if err := json.Unmarshal(payload, &report); err != nil { + return nil, fmt.Errorf("canonicalize build report: %w", err) + } + report.Mode = "equivalence" + report.OutputDir = "@OUTPUT_DIR@" + events := make([]BuildEvent, 0, len(report.Events)) + for _, event := range report.Events { + if !buildReportEquivalenceEvent(event) { + continue + } + event.Path = canonicalReportPath(event.Path) + events = append(events, event) + } + report.Events = events + out, err := json.MarshalIndent(report, "", " ") + if err != nil { + return nil, err + } + return append(out, '\n'), nil +} + +func buildReportEquivalenceEvent(event BuildEvent) bool { + if event.Level == BuildEventDebug { + return false + } + switch event.Stage { + case "bind", "seo": + return true + case "report": + return event.Kind == "cache_policy" || event.Kind == "asset_size" + default: + return false + } +} + +func canonicalReportPath(path string) string { + if strings.TrimSpace(path) == "" { + return "" + } + path = filepath.ToSlash(path) + if index := strings.Index(path, "/site/"); index >= 0 { + return "@OUTPUT_DIR@/" + strings.TrimPrefix(path[index+len("/site/"):], "/") + } + if index := strings.Index(path, "/.gowdk/reports/site/"); index >= 0 { + return "@REPORT_DIR@/" + strings.TrimPrefix(path[index+len("/.gowdk/reports/site/"):], "/") + } + return path +} + +func firstByteDifference(left []byte, right []byte) string { + limit := len(left) + if len(right) < limit { + limit = len(right) + } + for i := 0; i < limit; i++ { + if left[i] != right[i] { + return fmt.Sprintf("first difference at byte %d: incremental=%q clean=%q", i, excerptAt(left, i), excerptAt(right, i)) + } + } + return fmt.Sprintf("length differs: incremental=%d clean=%d", len(left), len(right)) +} + +func excerptAt(payload []byte, index int) string { + start := index - 24 + if start < 0 { + start = 0 + } + end := index + 24 + if end > len(payload) { + end = len(payload) + } + return string(payload[start:end]) +} + +func cloneSources(t *testing.T, sources gwdkanalysis.Sources) gwdkanalysis.Sources { + t.Helper() + payload, err := json.Marshal(sources) + if err != nil { + t.Fatal(err) + } + var cloned gwdkanalysis.Sources + if err := json.Unmarshal(payload, &cloned); err != nil { + t.Fatal(err) + } + return cloned +} diff --git a/internal/buildgen/manifests.go b/internal/buildgen/manifests.go index 829b4f5f..585bfb2f 100644 --- a/internal/buildgen/manifests.go +++ b/internal/buildgen/manifests.go @@ -166,6 +166,22 @@ func readRouteManifestIfExists(outputDir string) (routeManifest, error) { return manifest, nil } +func readAssetManifestIfExists(outputDir string) (runtimeasset.Manifest, error) { + manifestPath := filepath.Join(outputDir, assetManifestFile) + payload, err := os.ReadFile(manifestPath) + if os.IsNotExist(err) { + return runtimeasset.Manifest{}, nil + } + if err != nil { + return runtimeasset.Manifest{}, err + } + var manifest runtimeasset.Manifest + if err := json.Unmarshal(payload, &manifest); err != nil { + return runtimeasset.Manifest{}, fmt.Errorf("read existing asset manifest: %w", err) + } + return manifest, nil +} + func removeStaleChangedPageArtifacts(outputDir string, previous routeManifest, current []Artifact, changedPageIDs map[string]bool) error { if len(previous.Routes) == 0 || len(changedPageIDs) == 0 { return nil @@ -196,6 +212,40 @@ func removeStaleChangedPageArtifacts(outputDir string, previous routeManifest, c return nil } +func removeStaleAssetManifestFiles(outputDir string, previous runtimeasset.Manifest, cssArtifacts []CSSArtifact, assetArtifacts []AssetArtifact) error { + if len(previous.Files) == 0 { + return nil + } + keep := map[string]bool{} + for _, artifact := range cssArtifacts { + rel, err := relativeOutputPath(outputDir, artifact.Path) + if err != nil { + return err + } + keep[rel] = true + } + for _, artifact := range assetArtifacts { + rel, err := relativeOutputPath(outputDir, artifact.Path) + if err != nil { + return err + } + keep[rel] = true + } + for _, rel := range previous.Files { + if keep[rel] { + continue + } + filePath, err := outputFilePath(outputDir, rel) + if err != nil { + return err + } + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + func outputFilePath(outputDir, rel string) (string, error) { if strings.TrimSpace(rel) == "" { return "", fmt.Errorf("route manifest path is required")