feat(server): route no-slash directory URLs through views.dir (cascade spine)

serveSpecializedNoSlash now consults zddc.ViewAt(dir, "dir"): an explicit
`views.dir` in the cascade overrides the default_tool-derived app for the
no-slash directory URL. default_tool stays the sugar fallback (ViewAt returns
it when no views.dir is declared), so existing deployments are unaffected —
purely additive.

Also fixes the mergeOverlay trap (per the .zddc-policy-key checklist): added
Views to walker.go's per-level merge so views: survives cascade resolution at
default-driven paths (without it the key silently no-ops). Verified by a
defaults-path unit test (TestViewAt): default_tool/dir_tool surface via ViewAt;
an explicit views: entry overrides default_tool and declares a file shape.

go build + go test ./... all green. (Next: ServeView config injection from
.zddc.d/, the file→form shape, recognizer retirement, client + ./build.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-04 10:01:31 -05:00
parent 760cba96c4
commit 45af24b2b1
3 changed files with 63 additions and 0 deletions

View file

@ -623,6 +623,14 @@ func embeddedVersionsForLog(embedded map[string]string) string {
// authenticated user (may be empty).
func serveSpecializedNoSlash(cfg config.Config, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request, dirAbs, urlPath, email string) bool {
app := apps.DefaultAppAt(cfg.Root, dirAbs)
// An explicit `views.dir` in the cascade overrides the default_tool-
// derived app for the no-slash directory URL — the generalization's
// dir-shape routing. default_tool remains the sugar fallback (ViewAt
// returns it when no views.dir is declared), so existing deployments
// are unaffected.
if v, ok := zddc.ViewAt(cfg.Root, dirAbs, "dir"); ok && v.Tool != "" {
app = v.Tool
}
if app == "" {
return false
}

View file

@ -45,6 +45,48 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
}
}
// TestViewAt — default_tool/dir_tool act as sugar for the dir/dir_slash
// shapes, and an explicit views: entry overrides them.
func TestViewAt(t *testing.T) {
resetCache()
root := t.TempDir()
j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) }
// Sugar: the embedded default_tool/dir_tool surface via ViewAt.
if v, ok := ViewAt(root, j("mdl", "Acme"), "dir"); !ok || v.Tool != "tables" {
t.Errorf("ViewAt(mdl/Acme, dir) = (%+v,%v), want tables", v, ok)
}
if v, ok := ViewAt(root, j("working", "Acme"), "dir"); !ok || v.Tool != "browse" {
t.Errorf("ViewAt(working/Acme, dir) = (%+v,%v), want browse", v, ok)
}
// No file-shape declared by defaults.
if _, ok := ViewAt(root, j("working", "Acme"), "file"); ok {
t.Errorf("ViewAt(working/Acme, file) should be unset by default")
}
// Explicit views: overrides default_tool and declares a file shape.
resetCache()
dir := j("custom")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := WriteFile(dir, ZddcFile{
DefaultTool: "browse",
Views: map[string]ViewSpec{
"dir": {Tool: "tables", Config: "table.yaml"},
"file": {Tool: "form", Config: "form.yaml"},
},
}); err != nil {
t.Fatal(err)
}
if v, ok := ViewAt(root, dir, "dir"); !ok || v.Tool != "tables" || v.Config != "table.yaml" {
t.Errorf("ViewAt(custom, dir) = (%+v,%v), want {tables,table.yaml}", v, ok)
}
if v, ok := ViewAt(root, dir, "file"); !ok || v.Tool != "form" || v.Config != "form.yaml" {
t.Errorf("ViewAt(custom, file) = (%+v,%v), want {form,form.yaml}", v, ok)
}
}
// TestHistoryAt_Defaults — edit-history defaults on for the live-editing
// peers working/mdl/rsk and the ssr registry (subtree-inheriting). The
// other peers and the WORM archive do not get history.

View file

@ -124,6 +124,19 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
out.Tables = mergeStringMap(out.Tables, top.Tables)
out.Display = mergeStringMap(out.Display, top.Display)
// Views: per-shape latest-wins (a deeper level overrides a shape, others
// inherit). Mirror of the Roles/Records map merge.
if len(top.Views) > 0 {
merged := make(map[string]ViewSpec, len(out.Views)+len(top.Views))
for k, v := range out.Views {
merged[k] = v
}
for k, v := range top.Views {
merged[k] = v
}
out.Views = merged
}
// Convert: per-key latest-wins. Pointer-to-struct so we can tell
// "absent" from "explicitly empty" — the latter is rare but valid
// (an operator who wants to suppress a deployment-default value).