feat(zddc): Phase 2 — paths: walker, recursive cascade
Adds the recursive paths: schema and the cascade walker that threads
ancestor virtual contributions through to descendant levels.
Schema:
paths:
"*": # literal-segment or "*" segment-wildcard key
paths: # recursive — each step matches one segment
archive:
paths:
"*":
paths:
incoming:
title: "demo"
Each on-disk .zddc and the embedded defaults can declare paths:; the
walker collects every matching subtree and merges its contributions
into chain.Levels[depth] using mergeOverlay (per-field overlay with
on-disk most specific). The matched glob descends one segment at a
time; the value's own paths: becomes a new virtual source for deeper
matches.
Semantics:
- matchGlob: literal key first (case-insensitive on segment),
"*" wildcard fallback.
- mergeOverlay: top wins per-field on scalars; maps merge key-by-
key with top overriding; lists concat-dedupe; Paths replaces
(recursive walker threads it through naturally).
- inherit:false at any on-disk level drops accumulated ancestor
virtual sources AND zeroes chain.Embedded — the operator owns
every rule from that level outward.
- Behaviour is bit-identical when no .zddc declares paths:; the
walker reduces to the prior linear cascade.
Eight new tests cover the glob match table, ancestor-paths
contribution, on-disk-wins override, paths-absent bit-identical
behaviour, and inherit:false dropping ancestor paths: contributions.
All existing tests still pass.
Phase 3 next: populate defaults.zddc.yaml with the canonical
ZDDC convention via paths:, and replace apps.DefaultAppAt /
AppAvailableAt / AutoOwnCanonicalNames / VirtualOnlyCanonicalNames /
IsProjectRootFolder / IsArchivePartyFolder with cascade lookups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d84c1908f6
commit
2f08418fb0
5 changed files with 481 additions and 21 deletions
|
|
@ -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 19:40:57 · 4af0d8c-dirty</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 19:54:48 · d84c190-dirty</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
|
|
@ -98,38 +98,123 @@ func EffectivePolicy(fsRoot, dirPath string) (PolicyChain, error) {
|
|||
return cached.(PolicyChain), nil
|
||||
}
|
||||
|
||||
// Build policy chain: read each .zddc file
|
||||
var chain PolicyChain
|
||||
// Build policy chain: read each on-disk .zddc file.
|
||||
onDisk := make([]ZddcFile, 0, len(dirs))
|
||||
hasAny := false
|
||||
for _, dir := range dirs {
|
||||
zddcPath := filepath.Join(dir, ".zddc")
|
||||
// Check if .zddc file exists
|
||||
_, err := os.Stat(zddcPath)
|
||||
if err == nil {
|
||||
// File exists
|
||||
chain.HasAnyFile = true
|
||||
parsed, err := ParseFile(zddcPath)
|
||||
if err != nil {
|
||||
// Parse error — append empty file but continue
|
||||
chain.Levels = append(chain.Levels, ZddcFile{})
|
||||
hasAny = true
|
||||
parsed, perr := ParseFile(zddcPath)
|
||||
if perr != nil {
|
||||
onDisk = append(onDisk, ZddcFile{})
|
||||
} else {
|
||||
chain.Levels = append(chain.Levels, parsed)
|
||||
onDisk = append(onDisk, parsed)
|
||||
}
|
||||
} else if os.IsNotExist(err) {
|
||||
// File doesn't exist
|
||||
chain.Levels = append(chain.Levels, ZddcFile{})
|
||||
} else {
|
||||
// Other error (permission, etc.)
|
||||
chain.Levels = append(chain.Levels, ZddcFile{})
|
||||
onDisk = append(onDisk, ZddcFile{})
|
||||
}
|
||||
}
|
||||
|
||||
// Walk ancestor paths: trees alongside the on-disk chain. Each
|
||||
// virtual source is a paths-map seeded by an ancestor's Paths
|
||||
// (embedded or an on-disk level). At each segment we try to
|
||||
// match the source's glob; on hit, the matched ZddcFile becomes
|
||||
// a virtual contribution at THIS level, and its own Paths map
|
||||
// becomes a new virtual source descending into deeper segments.
|
||||
embedded := ZddcFile{}
|
||||
if e, err := EmbeddedDefaults(); err == nil {
|
||||
embedded = e
|
||||
}
|
||||
|
||||
// segments[0] is the first child segment under fsRoot. dirs[0]
|
||||
// is fsRoot itself, dirs[i+1] is the directory at segments[i].
|
||||
var segments []string
|
||||
if rel != "." {
|
||||
segments = strings.Split(rel, string(filepath.Separator))
|
||||
}
|
||||
|
||||
// Active virtual sources (paths-maps actively descending into the
|
||||
// target). Seeded with the embedded defaults' Paths and the
|
||||
// fsRoot/.zddc's Paths (both apply to fsRoot's children).
|
||||
var virtualSources []map[string]ZddcFile
|
||||
if embedded.Paths != nil {
|
||||
virtualSources = append(virtualSources, embedded.Paths)
|
||||
}
|
||||
if onDisk[0].Paths != nil {
|
||||
virtualSources = append(virtualSources, onDisk[0].Paths)
|
||||
}
|
||||
|
||||
// inherit:false at any level along the way zeroes the embedded
|
||||
// layer and drops accumulated ancestor contributions when first
|
||||
// encountered. Track once.
|
||||
embeddedActive := true
|
||||
if onDisk[0].Inherit != nil && !*onDisk[0].Inherit {
|
||||
embeddedActive = false
|
||||
virtualSources = nil
|
||||
if onDisk[0].Paths != nil {
|
||||
// Re-seed only THIS level's paths; embedded.Paths drops.
|
||||
virtualSources = append(virtualSources, onDisk[0].Paths)
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble chain.Levels with virtual contributions merged in.
|
||||
chain := PolicyChain{
|
||||
Levels: make([]ZddcFile, len(dirs)),
|
||||
HasAnyFile: hasAny,
|
||||
}
|
||||
chain.Levels[0] = onDisk[0] // root level has no ancestor virtual paths
|
||||
|
||||
for i, seg := range segments {
|
||||
depth := i + 1
|
||||
// Match each active virtual source against this segment.
|
||||
var contributions []ZddcFile
|
||||
var newSources []map[string]ZddcFile
|
||||
for _, src := range virtualSources {
|
||||
if match := matchGlob(src, seg); match != nil {
|
||||
contributions = append(contributions, *match)
|
||||
if match.Paths != nil {
|
||||
newSources = append(newSources, match.Paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
virtualSources = newSources
|
||||
|
||||
// Honor inherit:false on the on-disk level at this depth.
|
||||
// Dropping ancestor sources also means dropping this level's
|
||||
// inherited contributions; the level stands alone (plus its
|
||||
// own Paths for descendants).
|
||||
if onDisk[depth].Inherit != nil && !*onDisk[depth].Inherit {
|
||||
embeddedActive = false
|
||||
contributions = nil
|
||||
virtualSources = nil
|
||||
}
|
||||
|
||||
// Seed this level's own Paths as a virtual source for deeper
|
||||
// segments. It applies BELOW this level, not at this level.
|
||||
if onDisk[depth].Paths != nil {
|
||||
virtualSources = append(virtualSources, onDisk[depth].Paths)
|
||||
}
|
||||
|
||||
// Compose: ancestor contributions first (lowest specificity),
|
||||
// then on-disk at this level (most specific) on top.
|
||||
merged := ZddcFile{}
|
||||
for _, c := range contributions {
|
||||
merged = mergeOverlay(merged, c)
|
||||
}
|
||||
merged = mergeOverlay(merged, onDisk[depth])
|
||||
chain.Levels[depth] = merged
|
||||
}
|
||||
|
||||
// Layer in the embedded defaults as the bottom of the cascade
|
||||
// (used as fallback by consumers that consult Embedded). If any
|
||||
// on-disk level set top-level inherit:false, the embedded layer
|
||||
// is dropped — the operator owns every rule.
|
||||
if embedded, err := EmbeddedDefaults(); err == nil {
|
||||
// (consulted as fallback by lookups that don't find a value in
|
||||
// chain.Levels). The Paths-walking above has already threaded
|
||||
// embedded.Paths through to the appropriate levels; this is the
|
||||
// non-paths baseline for level-0-style lookups.
|
||||
if embeddedActive {
|
||||
chain.Embedded = embedded
|
||||
for _, lvl := range chain.Levels {
|
||||
for _, lvl := range onDisk {
|
||||
if lvl.Inherit != nil && !*lvl.Inherit {
|
||||
chain.Embedded = ZddcFile{}
|
||||
break
|
||||
|
|
|
|||
|
|
@ -170,6 +170,41 @@ type ZddcFile struct {
|
|||
// Pointer so an unset value (nil) is distinguishable from explicit
|
||||
// false. nil == defaults to true.
|
||||
Inherit *bool `yaml:"inherit,omitempty" json:"inherit,omitempty"`
|
||||
|
||||
// Paths declares virtual sub-directory rules without those
|
||||
// directories needing to exist on disk. Each key is a single path
|
||||
// segment — either a literal name or `*` (matches any segment).
|
||||
// The value is a nested ZddcFile that applies at the matching
|
||||
// child directory.
|
||||
//
|
||||
// Recursive: a Paths entry's value may itself have a Paths map,
|
||||
// matching further-down segments.
|
||||
//
|
||||
// Example, for a tree where every project should treat archive/
|
||||
// as the workflow archive and a party's incoming/ as the
|
||||
// classifier landing zone — without anyone creating those folders
|
||||
// on disk:
|
||||
//
|
||||
// paths:
|
||||
// "*": # project name (any)
|
||||
// paths:
|
||||
// archive:
|
||||
// paths:
|
||||
// "*": # party name
|
||||
// paths:
|
||||
// incoming:
|
||||
// default_tool: classifier
|
||||
//
|
||||
// Match: literal-segment key first (case-insensitive); fall back
|
||||
// to a "*" key if present. Multi-segment keys (e.g. "a/b") are
|
||||
// NOT supported in v1 — express depth via nested Paths blocks.
|
||||
//
|
||||
// Virtual contributions from ancestor Paths are merged into the
|
||||
// effective ZddcFile at each level by EffectivePolicy. On-disk
|
||||
// .zddc at the matching directory wins per-field (most specific
|
||||
// overrides). An inherit:false on any level drops the ancestor
|
||||
// contributions from that level and below.
|
||||
Paths map[string]ZddcFile `yaml:"paths,omitempty" json:"paths,omitempty"`
|
||||
}
|
||||
|
||||
// ParseFile reads and parses a .zddc YAML file.
|
||||
|
|
|
|||
150
zddc/internal/zddc/walker.go
Normal file
150
zddc/internal/zddc/walker.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package zddc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// matchGlob looks up a path segment in a paths: map. Literal
|
||||
// (case-insensitive) match first; falls back to a "*" segment-
|
||||
// wildcard key if present. Returns nil when neither hits.
|
||||
//
|
||||
// Phase 2: single-segment globs only. Multi-segment keys (a/b) are
|
||||
// rejected at the schema level and never reach this lookup.
|
||||
func matchGlob(m map[string]ZddcFile, seg string) *ZddcFile {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
// Fast path: exact key match (case-sensitive — operator-controlled).
|
||||
if v, ok := m[seg]; ok {
|
||||
return &v
|
||||
}
|
||||
// Case-insensitive literal match for canonical-folder ergonomics
|
||||
// (operator writes `archive:`; on-disk dir may be `Archive`).
|
||||
lower := strings.ToLower(seg)
|
||||
for k, v := range m {
|
||||
if k == "*" {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(k) == lower {
|
||||
return &v
|
||||
}
|
||||
}
|
||||
// Wildcard fallback.
|
||||
if v, ok := m["*"]; ok {
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeOverlay composes two ZddcFile values into one. `top` overrides
|
||||
// `base` per-field. Maps merge key-by-key (top wins on key clash);
|
||||
// scalar fields take top's value when non-zero (allowing base to fill
|
||||
// in unset fields).
|
||||
//
|
||||
// The intended use is a stack of contributions from lowest to highest
|
||||
// specificity, applied in order:
|
||||
//
|
||||
// merged = empty
|
||||
// for c in ancestor_virtual_contributions { // lowest specificity first
|
||||
// merged = mergeOverlay(merged, c)
|
||||
// }
|
||||
// merged = mergeOverlay(merged, on_disk_at_this_level)
|
||||
//
|
||||
// Each successive overlay overrides what came before for the same key.
|
||||
func mergeOverlay(base, top ZddcFile) ZddcFile {
|
||||
out := base
|
||||
|
||||
if top.Title != "" {
|
||||
out.Title = top.Title
|
||||
}
|
||||
if top.AppsPubKey != "" {
|
||||
out.AppsPubKey = top.AppsPubKey
|
||||
}
|
||||
if top.CreatedBy != "" {
|
||||
out.CreatedBy = top.CreatedBy
|
||||
}
|
||||
if top.Inherit != nil {
|
||||
out.Inherit = top.Inherit
|
||||
}
|
||||
|
||||
out.Admins = mergeStringSlice(out.Admins, top.Admins)
|
||||
out.ACL.Allow = mergeStringSlice(out.ACL.Allow, top.ACL.Allow)
|
||||
out.ACL.Deny = mergeStringSlice(out.ACL.Deny, top.ACL.Deny)
|
||||
if top.ACL.Inherit != nil {
|
||||
out.ACL.Inherit = top.ACL.Inherit
|
||||
}
|
||||
|
||||
out.ACL.Permissions = mergeStringMap(out.ACL.Permissions, top.ACL.Permissions)
|
||||
out.Apps = mergeStringMap(out.Apps, top.Apps)
|
||||
out.Tables = mergeStringMap(out.Tables, top.Tables)
|
||||
out.Display = mergeStringMap(out.Display, top.Display)
|
||||
|
||||
// Roles: shallow replace if top sets any. Roles are subtree-
|
||||
// scoped principal groups; layered merge of named lists would be
|
||||
// surprising — operators expecting a clean replacement at the
|
||||
// override level is the conventional pattern.
|
||||
if len(top.Roles) > 0 {
|
||||
out.Roles = top.Roles
|
||||
}
|
||||
|
||||
// Paths: top entirely replaces base if set. Recursive descent of
|
||||
// the walker is what threads ancestor Paths through to the right
|
||||
// level — merging Paths maps themselves at this layer would
|
||||
// double-apply.
|
||||
if len(top.Paths) > 0 {
|
||||
out.Paths = top.Paths
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeStringMap(base, top map[string]string) map[string]string {
|
||||
if len(top) == 0 {
|
||||
return base
|
||||
}
|
||||
if len(base) == 0 {
|
||||
out := make(map[string]string, len(top))
|
||||
for k, v := range top {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := make(map[string]string, len(base)+len(top))
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range top {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeStringSlice(base, top []string) []string {
|
||||
if len(top) == 0 {
|
||||
return base
|
||||
}
|
||||
if len(base) == 0 {
|
||||
out := make([]string, len(top))
|
||||
copy(out, top)
|
||||
return out
|
||||
}
|
||||
// Concatenate with dedupe (preserve order: base first, then
|
||||
// top entries that weren't already in base).
|
||||
seen := make(map[string]struct{}, len(base)+len(top))
|
||||
out := make([]string, 0, len(base)+len(top))
|
||||
for _, v := range base {
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
for _, v := range top {
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
190
zddc/internal/zddc/walker_test.go
Normal file
190
zddc/internal/zddc/walker_test.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
package zddc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMatchGlob_LiteralWinsOverWildcard — a literal key always beats
|
||||
// the "*" fallback even when both are present.
|
||||
func TestMatchGlob_LiteralWinsOverWildcard(t *testing.T) {
|
||||
m := map[string]ZddcFile{
|
||||
"archive": {Title: "literal"},
|
||||
"*": {Title: "wildcard"},
|
||||
}
|
||||
got := matchGlob(m, "archive")
|
||||
if got == nil || got.Title != "literal" {
|
||||
t.Errorf("matchGlob(archive) = %+v, want literal", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchGlob_CaseInsensitiveLiteral — operator writes lowercase,
|
||||
// on-disk segment is PascalCase. Match should still hit.
|
||||
func TestMatchGlob_CaseInsensitiveLiteral(t *testing.T) {
|
||||
m := map[string]ZddcFile{"archive": {Title: "ok"}}
|
||||
got := matchGlob(m, "Archive")
|
||||
if got == nil || got.Title != "ok" {
|
||||
t.Errorf("matchGlob(Archive) = %+v, want case-insensitive match", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchGlob_WildcardFallback — no literal key, the * key matches
|
||||
// any segment.
|
||||
func TestMatchGlob_WildcardFallback(t *testing.T) {
|
||||
m := map[string]ZddcFile{"*": {Title: "any"}}
|
||||
got := matchGlob(m, "Project-1")
|
||||
if got == nil || got.Title != "any" {
|
||||
t.Errorf("matchGlob(Project-1) under * = %+v, want wildcard match", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchGlob_NoMatch — no literal, no wildcard → nil.
|
||||
func TestMatchGlob_NoMatch(t *testing.T) {
|
||||
m := map[string]ZddcFile{"archive": {}}
|
||||
if got := matchGlob(m, "working"); got != nil {
|
||||
t.Errorf("matchGlob(working) = %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectivePolicy_PathsContributesViaWildcard — a root-level
|
||||
// paths: tree with "*" / archive / incoming applies to a deep path
|
||||
// even when none of those directories have a real .zddc.
|
||||
func TestEffectivePolicy_PathsContributesViaWildcard(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
// Root .zddc declares behaviour for any-project / archive /
|
||||
// any-party / incoming via a nested paths: tree. The actual
|
||||
// directories may not exist; the walker fires regardless.
|
||||
writeZddc(t, root, `title: root
|
||||
paths:
|
||||
"*":
|
||||
paths:
|
||||
archive:
|
||||
display:
|
||||
incoming: "Inbox"
|
||||
paths:
|
||||
"*":
|
||||
paths:
|
||||
incoming:
|
||||
title: "incoming-leaf"
|
||||
`)
|
||||
leaf := filepath.Join(root, "Project-1", "archive", "PartyA", "incoming")
|
||||
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain, err := EffectivePolicy(root, leaf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// chain.Levels: [root, Project-1, archive, PartyA, incoming] → 5
|
||||
if got, want := len(chain.Levels), 5; got != want {
|
||||
t.Fatalf("len(chain.Levels) = %d, want %d", got, want)
|
||||
}
|
||||
// At /Project-1/archive/ the display override should now be
|
||||
// merged in via the ancestor paths.
|
||||
if got := chain.Levels[2].Display["incoming"]; got != "Inbox" {
|
||||
t.Errorf("Levels[2].Display[incoming] = %q, want Inbox", got)
|
||||
}
|
||||
// At the leaf, the deepest paths: contribution sets title.
|
||||
if got := chain.Levels[4].Title; got != "incoming-leaf" {
|
||||
t.Errorf("Levels[4].Title = %q, want incoming-leaf", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectivePolicy_PathsOnDiskWins — virtual contributions are
|
||||
// lower specificity than on-disk; on-disk overrides per-field.
|
||||
func TestEffectivePolicy_PathsOnDiskWins(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
writeZddc(t, root, `paths:
|
||||
archive:
|
||||
title: "from-virtual"
|
||||
`)
|
||||
archive := filepath.Join(root, "archive")
|
||||
if err := os.MkdirAll(archive, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeZddc(t, archive, `title: "from-on-disk"`)
|
||||
|
||||
chain, err := EffectivePolicy(root, archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// chain.Levels[1] is /archive — should reflect on-disk wins.
|
||||
if got := chain.Levels[1].Title; got != "from-on-disk" {
|
||||
t.Errorf("Levels[1].Title = %q, want %q (on-disk overrides virtual)",
|
||||
got, "from-on-disk")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectivePolicy_PathsAbsentIsBitIdentical — without a paths:
|
||||
// declaration anywhere, the walker reduces to the prior linear
|
||||
// cascade behaviour.
|
||||
func TestEffectivePolicy_PathsAbsentIsBitIdentical(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
writeZddc(t, root, `title: "root"
|
||||
admins:
|
||||
- admin@example.com
|
||||
`)
|
||||
leaf := filepath.Join(root, "Project-1", "archive")
|
||||
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeZddc(t, leaf, `title: "leaf"`)
|
||||
chain, err := EffectivePolicy(root, leaf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := chain.Levels[0].Title; got != "root" {
|
||||
t.Errorf("Levels[0].Title = %q, want %q", got, "root")
|
||||
}
|
||||
if got := chain.Levels[2].Title; got != "leaf" {
|
||||
t.Errorf("Levels[2].Title = %q, want %q", got, "leaf")
|
||||
}
|
||||
// Mid level has no .zddc and no paths: contribution → empty.
|
||||
if got := chain.Levels[1].Title; got != "" {
|
||||
t.Errorf("Levels[1].Title = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectivePolicy_InheritFalseDropsAncestorPaths — inherit:false
|
||||
// at the on-disk root drops contributions from the embedded layer
|
||||
// (already covered in defaults_test.go) AND drops contributions
|
||||
// from ancestor paths: that hadn't yet matched. Here we set it on
|
||||
// the project level: an embedded paths: contribution that would
|
||||
// have applied to deeper levels is suppressed.
|
||||
func TestEffectivePolicy_InheritFalseDropsAncestorPaths(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
writeZddc(t, root, `paths:
|
||||
Project-1:
|
||||
title: "ancestor-virtual"
|
||||
paths:
|
||||
sub:
|
||||
title: "ancestor-deep"
|
||||
`)
|
||||
sub := filepath.Join(root, "Project-1", "sub")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Project-1/.zddc sets inherit:false. Effect: the ancestor's
|
||||
// /Project-1/sub/ contribution does NOT reach the sub level.
|
||||
writeZddc(t, filepath.Join(root, "Project-1"),
|
||||
`title: "standalone"
|
||||
inherit: false
|
||||
`)
|
||||
chain, err := EffectivePolicy(root, sub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := chain.Levels[1].Title; got != "standalone" {
|
||||
t.Errorf("Levels[1].Title = %q, want standalone (inherit:false should drop ancestor virtual contribution)",
|
||||
got)
|
||||
}
|
||||
if got := chain.Levels[2].Title; got != "" {
|
||||
t.Errorf("Levels[2].Title = %q, want empty (ancestor paths: blocked by inherit:false)",
|
||||
got)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue