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 (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
root = filepath.Clean(root)
|
||||
requestDir = filepath.Clean(requestDir)
|
||||
|
|
@ -107,25 +119,9 @@ func DefaultAppAt(root, requestDir string) string {
|
|||
}
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
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 ""
|
||||
}
|
||||
// The project segment is parts[0]; canonical folder is parts[1].
|
||||
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 ""
|
||||
return zddc.DefaultToolAt(root, requestDir)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,11 +86,13 @@ func TestDefaultAppAt(t *testing.T) {
|
|||
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "mdedit"},
|
||||
{root + "/Project-A/staging", "transmittal"},
|
||||
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"},
|
||||
// archive: at the archive root, party folders, and per-party
|
||||
// subfolders (incoming/received/issued).
|
||||
// archive: at the archive root, party folders default to archive.
|
||||
// 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/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/received", "archive"},
|
||||
// mdl wins over the broader archive rule.
|
||||
|
|
|
|||
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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 class="header-right">
|
||||
|
|
|
|||
|
|
@ -9,35 +9,45 @@ import (
|
|||
// the directory at dirPath. Empty when no cascade level (on-disk or
|
||||
// virtual via Paths) has declared a DefaultTool.
|
||||
//
|
||||
// Used by the URL dispatcher to route no-slash directory URLs (e.g.
|
||||
// /Project/working) to the appropriate tool. Replaces the hardcoded
|
||||
// apps.DefaultAppAt matrix once consumers are migrated (Phase 3b).
|
||||
// Lookup walks chain.Levels from leaf toward root, returning the
|
||||
// first non-empty value. This implements the "parent applies to
|
||||
// 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 {
|
||||
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if dt := leafLevel(chain).DefaultTool; dt != "" {
|
||||
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
||||
if dt := chain.Levels[i].DefaultTool; dt != "" {
|
||||
return dt
|
||||
}
|
||||
}
|
||||
return chain.Embedded.DefaultTool
|
||||
}
|
||||
|
||||
// AutoOwnAt reports whether mkdir at this directory should write an
|
||||
// auto-owned .zddc. False (with explicit set) and unset both return
|
||||
// false to the caller; the file API only auto-owns when at least one
|
||||
// cascade level explicitly set auto_own: true.
|
||||
// auto-owned .zddc. Returns the deepest explicit value found in the
|
||||
// cascade (true OR false), falling back to false when nothing set
|
||||
// anywhere. *bool semantics let descendants explicitly disable an
|
||||
// ancestor's auto_own: true.
|
||||
//
|
||||
// Replaces the AutoOwnCanonicalNames hardcoded list once the file
|
||||
// API's mkdir hook is migrated.
|
||||
// Replaces AutoOwnCanonicalNames once the file API's mkdir hook is
|
||||
// migrated.
|
||||
func AutoOwnAt(fsRoot, dirPath string) bool {
|
||||
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if v := leafLevel(chain).AutoOwn; v != nil {
|
||||
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
||||
if v := chain.Levels[i].AutoOwn; v != nil {
|
||||
return *v
|
||||
}
|
||||
}
|
||||
if v := chain.Embedded.AutoOwn; v != nil {
|
||||
return *v
|
||||
}
|
||||
|
|
@ -45,19 +55,20 @@ func AutoOwnAt(fsRoot, dirPath string) bool {
|
|||
}
|
||||
|
||||
// VirtualAt reports whether the directory at dirPath is declared as
|
||||
// purely virtual (never materialise on disk). Used by
|
||||
// EnsureCanonicalAncestors to skip MkdirAll for these paths.
|
||||
// purely virtual. Walks the cascade like DefaultToolAt; deepest
|
||||
// explicit value wins.
|
||||
//
|
||||
// Replaces the VirtualOnlyCanonicalNames hardcoded list once
|
||||
// consumers are migrated.
|
||||
// Replaces VirtualOnlyCanonicalNames once consumers are migrated.
|
||||
func VirtualAt(fsRoot, dirPath string) bool {
|
||||
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if v := leafLevel(chain).Virtual; v != nil {
|
||||
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
||||
if v := chain.Levels[i].Virtual; v != nil {
|
||||
return *v
|
||||
}
|
||||
}
|
||||
if v := chain.Embedded.Virtual; v != nil {
|
||||
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,
|
||||
// inherit:false stops the embedded layer from contributing. The
|
||||
// canonical paths are then no longer declared.
|
||||
|
|
|
|||
Loading…
Reference in a new issue