feat(zddc): Phase 3 — DefaultToolAt cascade propagation + apps.DefaultAppAt migration
Two pieces: 1. Lookup helpers walk chain.Levels from leaf back to root. The "parent applies to descendants unless overridden" cascade rule means a working/ default_tool=mdedit propagates to deep paths like working/alice@example.com/notes/sub/deep without anyone declaring it at every level. AutoOwnAt and VirtualAt follow the same walk; explicit false at a descendant can override an ancestor's true (*bool semantics). 2. apps.DefaultAppAt delegates to zddc.DefaultToolAt. The hardcoded switch on parts[1] (archive→archive, staging→transmittal, working→mdedit, reviewing→mdedit, mdl→tables) and its case- sensitivity quirks now live in defaults.zddc.yaml. Operators can override any of these per-directory with an on-disk .zddc; no code change required. Semantic improvement: archive/<party>/incoming previously defaulted to "archive" (because parts[1]=archive and the switch didn't look deeper). The new convention routes it to "classifier" — incoming/ is the bulk-rename surface, not a record browser. Updated availability_test.go to reflect. All other DefaultAppAt cases — including case-fold (Archive/MDL), mdl override, reviewing virtual, project root returning "", random non-canonical names returning "" — produce bit-identical output. Two new tests in lookups_test.go cover the propagation: - TestDefaultToolAt_PropagatesToDescendants - TestAutoOwnAt_DescendantCanDisable Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ea0d29ed17
commit
9d18047a46
5 changed files with 85 additions and 41 deletions
|
|
@ -3,6 +3,8 @@ package apps
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppAvailableAt reports whether app's virtual HTML can be served at
|
// AppAvailableAt reports whether app's virtual HTML can be served at
|
||||||
|
|
@ -95,6 +97,16 @@ func inAncestorWithName(root, requestDir string, names ...string) bool {
|
||||||
//
|
//
|
||||||
// requestDir and root are absolute filesystem paths; requestDir must
|
// requestDir and root are absolute filesystem paths; requestDir must
|
||||||
// be under root (otherwise "" is returned).
|
// be under root (otherwise "" is returned).
|
||||||
|
//
|
||||||
|
// Phase 3b: delegates to zddc.DefaultToolAt, which resolves the
|
||||||
|
// answer from the .zddc cascade (operator on-disk + embedded
|
||||||
|
// defaults). The convention previously hardcoded in the switch
|
||||||
|
// statement below now lives in zddc/internal/zddc/defaults.zddc.yaml
|
||||||
|
// and is overridable per-directory by operators.
|
||||||
|
//
|
||||||
|
// Project root itself (depth-1) still returns "" — the cascade
|
||||||
|
// doesn't declare a default tool for it (landing is handled by the
|
||||||
|
// dispatcher's own depth-1 check).
|
||||||
func DefaultAppAt(root, requestDir string) string {
|
func DefaultAppAt(root, requestDir string) string {
|
||||||
root = filepath.Clean(root)
|
root = filepath.Clean(root)
|
||||||
requestDir = filepath.Clean(requestDir)
|
requestDir = filepath.Clean(requestDir)
|
||||||
|
|
@ -107,25 +119,9 @@ func DefaultAppAt(root, requestDir string) string {
|
||||||
}
|
}
|
||||||
parts := strings.Split(rel, string(filepath.Separator))
|
parts := strings.Split(rel, string(filepath.Separator))
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
// Project root itself — no default tool.
|
// Project root itself — no default tool from the cascade.
|
||||||
|
// (Landing is handled separately by the dispatcher.)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// The project segment is parts[0]; canonical folder is parts[1].
|
return zddc.DefaultToolAt(root, requestDir)
|
||||||
canonical := strings.ToLower(parts[1])
|
|
||||||
switch canonical {
|
|
||||||
case "archive":
|
|
||||||
// Inside archive/. Check for the mdl sub-case at depth 4
|
|
||||||
// (parts: project, archive, party, mdl).
|
|
||||||
if len(parts) >= 4 && strings.EqualFold(parts[3], "mdl") {
|
|
||||||
return "tables"
|
|
||||||
}
|
|
||||||
return "archive"
|
|
||||||
case "staging":
|
|
||||||
return "transmittal"
|
|
||||||
case "working":
|
|
||||||
return "mdedit"
|
|
||||||
case "reviewing":
|
|
||||||
return "mdedit"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,11 +86,13 @@ func TestDefaultAppAt(t *testing.T) {
|
||||||
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "mdedit"},
|
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "mdedit"},
|
||||||
{root + "/Project-A/staging", "transmittal"},
|
{root + "/Project-A/staging", "transmittal"},
|
||||||
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"},
|
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"},
|
||||||
// archive: at the archive root, party folders, and per-party
|
// archive: at the archive root, party folders default to archive.
|
||||||
// subfolders (incoming/received/issued).
|
// Per-party subfolders override per their function:
|
||||||
|
// incoming → classifier (the bulk-rename workflow)
|
||||||
|
// received / issued → archive (WORM record browser)
|
||||||
{root + "/Project-A/archive", "archive"},
|
{root + "/Project-A/archive", "archive"},
|
||||||
{root + "/Project-A/archive/Acme", "archive"},
|
{root + "/Project-A/archive/Acme", "archive"},
|
||||||
{root + "/Project-A/archive/Acme/incoming", "archive"},
|
{root + "/Project-A/archive/Acme/incoming", "classifier"},
|
||||||
{root + "/Project-A/archive/Acme/issued", "archive"},
|
{root + "/Project-A/archive/Acme/issued", "archive"},
|
||||||
{root + "/Project-A/archive/Acme/received", "archive"},
|
{root + "/Project-A/archive/Acme/received", "archive"},
|
||||||
// mdl wins over the broader archive rule.
|
// mdl wins over the broader archive rule.
|
||||||
|
|
|
||||||
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 20:00:21 · 2f08418-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 20:05:10 · ea0d29e-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -9,34 +9,44 @@ import (
|
||||||
// the directory at dirPath. Empty when no cascade level (on-disk or
|
// the directory at dirPath. Empty when no cascade level (on-disk or
|
||||||
// virtual via Paths) has declared a DefaultTool.
|
// virtual via Paths) has declared a DefaultTool.
|
||||||
//
|
//
|
||||||
// Used by the URL dispatcher to route no-slash directory URLs (e.g.
|
// Lookup walks chain.Levels from leaf toward root, returning the
|
||||||
// /Project/working) to the appropriate tool. Replaces the hardcoded
|
// first non-empty value. This implements the "parent applies to
|
||||||
// apps.DefaultAppAt matrix once consumers are migrated (Phase 3b).
|
// descendants unless overridden" cascade rule: a working/ folder's
|
||||||
|
// default_tool=mdedit propagates to working/alice/notes/ even when
|
||||||
|
// no .zddc declares mdedit at the deeper levels.
|
||||||
|
//
|
||||||
|
// Used by the URL dispatcher to route no-slash directory URLs.
|
||||||
|
// Replaces apps.DefaultAppAt once consumers are migrated (Phase 3b).
|
||||||
func DefaultToolAt(fsRoot, dirPath string) string {
|
func DefaultToolAt(fsRoot, dirPath string) string {
|
||||||
chain, err := EffectivePolicy(fsRoot, dirPath)
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if dt := leafLevel(chain).DefaultTool; dt != "" {
|
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
||||||
return dt
|
if dt := chain.Levels[i].DefaultTool; dt != "" {
|
||||||
|
return dt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return chain.Embedded.DefaultTool
|
return chain.Embedded.DefaultTool
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutoOwnAt reports whether mkdir at this directory should write an
|
// AutoOwnAt reports whether mkdir at this directory should write an
|
||||||
// auto-owned .zddc. False (with explicit set) and unset both return
|
// auto-owned .zddc. Returns the deepest explicit value found in the
|
||||||
// false to the caller; the file API only auto-owns when at least one
|
// cascade (true OR false), falling back to false when nothing set
|
||||||
// cascade level explicitly set auto_own: true.
|
// anywhere. *bool semantics let descendants explicitly disable an
|
||||||
|
// ancestor's auto_own: true.
|
||||||
//
|
//
|
||||||
// Replaces the AutoOwnCanonicalNames hardcoded list once the file
|
// Replaces AutoOwnCanonicalNames once the file API's mkdir hook is
|
||||||
// API's mkdir hook is migrated.
|
// migrated.
|
||||||
func AutoOwnAt(fsRoot, dirPath string) bool {
|
func AutoOwnAt(fsRoot, dirPath string) bool {
|
||||||
chain, err := EffectivePolicy(fsRoot, dirPath)
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if v := leafLevel(chain).AutoOwn; v != nil {
|
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
||||||
return *v
|
if v := chain.Levels[i].AutoOwn; v != nil {
|
||||||
|
return *v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v := chain.Embedded.AutoOwn; v != nil {
|
if v := chain.Embedded.AutoOwn; v != nil {
|
||||||
return *v
|
return *v
|
||||||
|
|
@ -45,18 +55,19 @@ func AutoOwnAt(fsRoot, dirPath string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// VirtualAt reports whether the directory at dirPath is declared as
|
// VirtualAt reports whether the directory at dirPath is declared as
|
||||||
// purely virtual (never materialise on disk). Used by
|
// purely virtual. Walks the cascade like DefaultToolAt; deepest
|
||||||
// EnsureCanonicalAncestors to skip MkdirAll for these paths.
|
// explicit value wins.
|
||||||
//
|
//
|
||||||
// Replaces the VirtualOnlyCanonicalNames hardcoded list once
|
// Replaces VirtualOnlyCanonicalNames once consumers are migrated.
|
||||||
// consumers are migrated.
|
|
||||||
func VirtualAt(fsRoot, dirPath string) bool {
|
func VirtualAt(fsRoot, dirPath string) bool {
|
||||||
chain, err := EffectivePolicy(fsRoot, dirPath)
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if v := leafLevel(chain).Virtual; v != nil {
|
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
||||||
return *v
|
if v := chain.Levels[i].Virtual; v != nil {
|
||||||
|
return *v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v := chain.Embedded.Virtual; v != nil {
|
if v := chain.Embedded.Virtual; v != nil {
|
||||||
return *v
|
return *v
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,41 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultToolAt_PropagatesToDescendants — once an ancestor sets
|
||||||
|
// default_tool, descendants inherit it unless they override. So a
|
||||||
|
// path under working/ that isn't explicitly declared in paths: still
|
||||||
|
// gets mdedit as its default tool.
|
||||||
|
func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
// Deep path under working/ — not explicitly mentioned in paths:.
|
||||||
|
deep := filepath.Join(root, "Project-X", "working", "alice@example.com", "notes", "sub", "deep")
|
||||||
|
if got := DefaultToolAt(root, deep); got != "mdedit" {
|
||||||
|
t.Errorf("DefaultToolAt(%q) = %q, want mdedit (cascade propagation)",
|
||||||
|
deep[len(root):], got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoOwnAt_DescendantCanDisable — explicit auto_own:false at a
|
||||||
|
// descendant overrides an ancestor's auto_own:true.
|
||||||
|
func TestAutoOwnAt_DescendantCanDisable(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
deepDir := filepath.Join(root, "Project-X", "working", "alice@example.com")
|
||||||
|
if err := os.MkdirAll(deepDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeZddc(t, deepDir, "auto_own: false\n")
|
||||||
|
if got := AutoOwnAt(root, deepDir); got != false {
|
||||||
|
t.Errorf("AutoOwnAt(%q) = %v, want false (descendant override)", deepDir, got)
|
||||||
|
}
|
||||||
|
// Ancestor still has it true.
|
||||||
|
ancestor := filepath.Join(root, "Project-X", "working")
|
||||||
|
if got := AutoOwnAt(root, ancestor); got != true {
|
||||||
|
t.Errorf("AutoOwnAt(%q) = %v, want true (ancestor untouched)", ancestor, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestInheritFalse_BlocksEmbeddedDefaults — at the on-disk root,
|
// TestInheritFalse_BlocksEmbeddedDefaults — at the on-disk root,
|
||||||
// inherit:false stops the embedded layer from contributing. The
|
// inherit:false stops the embedded layer from contributing. The
|
||||||
// canonical paths are then no longer declared.
|
// canonical paths are then no longer declared.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue