// Package ns implements the namespace inheritance resolver (REQ-082) // and the ns.md frontmatter parser used by `orca ns` CLI subcommands. // // The resolver is a PURE function (no I/O): it takes a map of parsed // namespace configs keyed by name and returns a map of resolved // namespaces with merged env and unioned constraints. The inheritance // model is: // // - Each namespace declares zero or more parents in `ns.md` // frontmatter (`parents: ["ns1", "ns2"]`). // - The implicit root namespace `_defaults` (R-002 D-159) always // exists and has no parents; it is ALWAYS appended as the last // element of the chain (D-185). // - Opting out of `_defaults` is impossible (D-187): even with // `parents: []`, `_defaults` still appears at the end of the chain. // - Merge semantics: child overrides parent for scalars (env keys); // arrays union (child constraints add to parent constraints, with // duplicates removed, order: most-specific first). // - The chain order is most-specific first, `_defaults` last. // - `_defaults` may be listed explicitly in `parents`; the explicit // listing is de-duped silently (still appears once, at the end). // - Misordering (`parents: ["_defaults", "x"]`) is rejected: an // explicit `_defaults` entry must be the only entry (or omitted). // - Cycle detection uses DFS with a visited set; a cycle returns an // error with the cycle path. // - Missing parents return "parent X not found". package ns import ( "fmt" "sort" ) const defaultsName = "_defaults" // NSConfig is a parsed namespace declaration from ns.md frontmatter. // The resolver consumes this; the parser populates it. type NSConfig struct { Name string Parents []string Env map[string]string Constraints []string InheritsEnv bool InheritsSecrets bool } // ResolvedNS is the output of the resolver: the namespace with its // fully-merged env and unioned constraints, plus the ordered // inheritance chain (most-specific first, `_defaults` last). type ResolvedNS struct { Name string Chain []string Env map[string]string Constraints []string } // Resolve walks the parent chain for each namespace, merges env (child // wins scalars), unions constraints (child adds to parent, de-duped), // and detects cycles. It is PURE (no I/O). The empty-configs case // returns an empty map and no error. // // The `_defaults` namespace is ALWAYS the last element of every chain // (D-185); opting out is impossible (D-187). An explicit `_defaults` // entry in `parents` is de-duped silently. Misordering (e.g. // `parents: ["_defaults", "x"]`) is rejected. func Resolve(configs map[string]*NSConfig) (map[string]*ResolvedNS, error) { if len(configs) == 0 { return map[string]*ResolvedNS{}, nil } // Validate each config's parents reference exists and the // _defaults entry (if explicit) is the only entry. for name, cfg := range configs { if cfg == nil { return nil, fmt.Errorf("namespace %q has nil config", name) } for _, p := range cfg.Parents { if p == defaultsName { // Explicit _defaults must be the only parent. if len(cfg.Parents) != 1 { return nil, fmt.Errorf("namespace %q: %s must be the only parent if listed explicitly (misordering rejected)", name, defaultsName) } continue } if _, ok := configs[p]; !ok { return nil, fmt.Errorf("namespace %q: parent %q not found", name, p) } } } // `_defaults` must be present in the configs map (the parser // enforces this for ParseNSMdDir; Resolve trusts its input but // still requires _defaults to exist for chain assembly). if _, ok := configs[defaultsName]; !ok { return nil, fmt.Errorf("namespace %q not found (implicit root must be present)", defaultsName) } resolved := make(map[string]*ResolvedNS, len(configs)) // Resolve in deterministic order for stable error reporting. names := make([]string, 0, len(configs)) for n := range configs { names = append(names, n) } sort.Strings(names) for _, name := range names { r, err := resolveOne(configs, name) if err != nil { return nil, err } resolved[name] = r } return resolved, nil } // resolveOne resolves a single namespace. The chain is built by walking // parents depth-first in POST-order (least-specific first), then // reversing so the returned chain is most-specific first with // `_defaults` last (D-185). Cycle detection uses a visiting set. func resolveOne(configs map[string]*NSConfig, name string) (*ResolvedNS, error) { post, err := buildChain(configs, name) if err != nil { return nil, err } // post is least-specific first; reverse to most-specific first. reverseStrings(post) chain := post // Env: child (most-specific) wins. Walk least-specific to // most-specific (end -> beginning) so later writes override. env := make(map[string]string) for i := len(chain) - 1; i >= 0; i-- { c := configs[chain[i]] if c == nil { continue } for k, v := range c.Env { env[k] = v } } // Constraints: union, child (most-specific) first. Walk the chain // front-to-back (most-specific first) and append unseen items. constraintsSeen := make(map[string]bool) var constraints []string for _, ns := range chain { c := configs[ns] if c == nil { continue } for _, con := range c.Constraints { if !constraintsSeen[con] { constraintsSeen[con] = true constraints = append(constraints, con) } } } return &ResolvedNS{ Name: name, Chain: chain, Env: env, Constraints: constraints, }, nil } // buildChain walks parents depth-first and returns the chain in // POST-order (least-specific first, `_defaults` first). The caller // reverses to get most-specific first. Cycle detection uses the // visiting set: a node currently being walked indicates a back-edge. func buildChain(configs map[string]*NSConfig, name string) ([]string, error) { var post []string seen := make(map[string]bool) // final chain membership (de-dup) visiting := make(map[string]bool) if err := dfsChain(configs, name, &post, seen, visiting); err != nil { return nil, err } // `_defaults` is the implicit root: it must be the FIRST element // in post-order (so it ends up LAST after reversal). If it was not // reached via parents (no explicit listing and no chain leads to // it), prepend it. if !seen[defaultsName] { post = append([]string{defaultsName}, post...) seen[defaultsName] = true } return post, nil } // dfsChain appends each node AFTER its parents (post-order), producing // least-specific first. Cycle detection uses the visiting set. func dfsChain(configs map[string]*NSConfig, name string, post *[]string, seen, visiting map[string]bool) error { if visiting[name] { return fmt.Errorf("cycle detected: %s", cyclePath(visiting, configs, name)) } if seen[name] { return nil } visiting[name] = true cfg := configs[name] if cfg != nil { for _, p := range cfg.Parents { if err := dfsChain(configs, p, post, seen, visiting); err != nil { return err } } } delete(visiting, name) seen[name] = true *post = append(*post, name) return nil } // cyclePath reconstructs a readable cycle path from the visiting set. // Since visiting is a set (not ordered), we reconstruct by re-walking // parents from the offending node until we revisit it. func cyclePath(visiting map[string]bool, configs map[string]*NSConfig, start string) string { // Walk parents from start, collecting names until we hit start // again or run out. var path []string cur := start for i := 0; i < len(visiting)+1; i++ { path = append(path, cur) cfg := configs[cur] if cfg == nil || len(cfg.Parents) == 0 { break } next := cfg.Parents[0] if next == start { path = append(path, next) break } cur = next } return joinArrows(path) } func joinArrows(parts []string) string { out := "" for i, p := range parts { if i > 0 { out += " -> " } out += p } return out } func reverseStrings(s []string) { for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { s[i], s[j] = s[j], s[i] } }